diff --git a/Capture/capturelib/include/reprostim/CaptureApp.h b/Capture/capturelib/include/reprostim/CaptureApp.h index 5d27a70..c5193a2 100644 --- a/Capture/capturelib/include/reprostim/CaptureApp.h +++ b/Capture/capturelib/include/reprostim/CaptureApp.h @@ -111,7 +111,7 @@ namespace reprostim { std::string createOutPath(const std::optional &ts = std::nullopt, bool fCreateDir = true); SessionLogger_ptr createSessionLogger(const std::string& name, const std::string& filePath); - void listDevices(); + void listDevices(const std::string& devices); virtual bool loadConfig(AppConfig& cfg, const std::string& pathConfig); virtual void onCaptureStart(); virtual void onCaptureStop(const std::string& message); diff --git a/Capture/capturelib/include/reprostim/CaptureLib.h b/Capture/capturelib/include/reprostim/CaptureLib.h index 800a871..0623cc3 100644 --- a/Capture/capturelib/include/reprostim/CaptureLib.h +++ b/Capture/capturelib/include/reprostim/CaptureLib.h @@ -154,6 +154,8 @@ namespace reprostim { void listAudioDevices(); + void listVideoDevices(); + std::string mwcSdkVersion(); AudioVolume parseAudioVolume(const std::string text); diff --git a/Capture/capturelib/src/CaptureApp.cpp b/Capture/capturelib/src/CaptureApp.cpp index 5b5f181..abf72f8 100644 --- a/Capture/capturelib/src/CaptureApp.cpp +++ b/Capture/capturelib/src/CaptureApp.cpp @@ -91,15 +91,25 @@ namespace reprostim { return nullptr; } - void CaptureApp::listDevices() { + void CaptureApp::listDevices(const std::string& devices) { printVersion(); - _INFO(" "); - _INFO("[List of available Video devices]:"); - _INFO(" N/A in this version."); - _INFO(" "); - if( audioEnabled ) { + if( !(devices == "all" || devices == "audio" || devices == "video") ) { + _ERROR("Invalid device type: " << devices << ", must be 'all', 'audio' or 'video'"); + return; + } + if( devices=="all" || devices=="video" ) { + _INFO(" "); + _INFO("[List of available Video devices]:"); + listVideoDevices(); + } + if( devices=="all" || devices=="audio" ) { + _INFO(" "); _INFO("[List of available Audio devices]:"); - listAudioDevices(); + if (audioEnabled) { + listAudioDevices(); + } else { + _INFO("Audio capture is disabled in " << appName); + } } } diff --git a/Capture/capturelib/src/CaptureLib.cpp b/Capture/capturelib/src/CaptureLib.cpp index d6c1ea2..8c51f78 100644 --- a/Capture/capturelib/src/CaptureLib.cpp +++ b/Capture/capturelib/src/CaptureLib.cpp @@ -618,6 +618,65 @@ namespace reprostim { } while (snd_card_next(&card) >= 0 && card >= 0); } + void listVideoDevices() { + _VERBOSE("listVideoDevices()") + const int MAX_CAPTURE = 16; + MW_RESULT mr = MW_SUCCEEDED; + + BOOL fInit = MWCaptureInitInstance(); + if( !fInit ) + _ERROR("Failed MWCaptureInitInstance"); + + for(int k = 0; k<1; k++) { + SLEEP_MS(1); + mr = MWRefreshDevice(); + if (mr != MW_SUCCEEDED) + _ERROR("Failed MWRefreshDevice: " << mr); + + int n = MWGetChannelCount(); + MWCAP_CHANNEL_INFO mci; + MWCAP_VIDEO_SIGNAL_STATUS vss; + + for (int i = 0; i < n; i++) { + mr = MWGetChannelInfoByIndex(i, &mci); + if (mr != MW_SUCCEEDED) { + _ERROR("Failed MWGetChannelInfoByIndex[" << std::to_string(i) << "]: " << mr); + continue; + } + _INFO("Channel [" << std::to_string(i) << "]: " << mci); + + char wPath[256] = {0}; + if (MWGetDevicePath(i, wPath) == MW_SUCCEEDED) { + _INFO(" Device instance path: " << wPath); + } else { + _ERROR("Failed MWGetDevicePath[" << std::to_string(i) << "]"); + continue; + } + + HCHANNEL hChannel = MWOpenChannelByPath(wPath); + if (hChannel == NULL) { + _ERROR("Failed MWOpenChannelByPath[" << std::to_string(i) << "]"); + continue; + } + + MWGetChannelInfo(hChannel, &mci); + _INFO("Channel [" << std::to_string(i) << "]: " << mci); + + MWGetVideoSignalStatus(hChannel, &vss); + _INFO(" Video Signal Status: " << vss); + + safeMWCloseChannel(hChannel); + + if (i >= MAX_CAPTURE) { + _ERROR("Max capture limit reached: " << std::to_string(MAX_CAPTURE)); + break; + } + } + } + + MWCaptureExitInstance(); + } + std::string mwcSdkVersion() { BYTE bMajor = 0; BYTE bMinor = 0; diff --git a/Capture/nosignal/data/nosignal.png b/Capture/nosignal/data/nosignal.png new file mode 100644 index 0000000..57a19c6 Binary files /dev/null and b/Capture/nosignal/data/nosignal.png differ diff --git a/Capture/nosignal/docs/install.txt b/Capture/nosignal/docs/install.txt index dee6e41..2a1a7ab 100644 --- a/Capture/nosignal/docs/install.txt +++ b/Capture/nosignal/docs/install.txt @@ -6,7 +6,7 @@ >> Poetry (version 1.8.2) # set in project venv in poetry -./venv/bin/poetry config virtualenvs.create false +./venv/bin/poetry config virtualenvs.create true ./venv/bin/poetry config virtualenvs.in-project true # install poetry and run test diff --git a/Capture/nosignal/nosignal-batch.sh b/Capture/nosignal/nosignal-batch.sh new file mode 100755 index 0000000..e87eedc --- /dev/null +++ b/Capture/nosignal/nosignal-batch.sh @@ -0,0 +1,78 @@ +#!/bin/bash +# chmod +x ns_05.sh + +# Define external variables +#NOSIGNAL_ARGS="--number-of-checks 5 --truncated check5" +NOSIGNAL_ARGS="--number-of-checks 100 --truncated fixup --invalid-timing fixup --threshold 0.5" + +print_help() { + echo "Usage: $0 " + echo + echo "Arguments:" + echo " The directory containing the .mkv files to process." + echo + echo "Example:" + echo " $0 /data/repronim/reprostim-reproiner/Videos/2024/05" +} + +# Check if the DIRECTORY parameter is provided +if [ -z "$1" ]; then + print_help + exit 1 +fi + + +# log all to file +LOG_TIMESTAMP=$(date '+%Y%m%d_%H%M%S') +LOG_FILE="ns_$LOG_TIMESTAMP.log" +# Redirect stdout and stderr to the log file +exec > >(tee -a "$LOG_FILE") 2>&1 + + +#DIRECTORY="/data/repronim/reprostim-reproiner/Videos/2024/05" +DIRECTORY="$1" + +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') +echo "[$TIMESTAMP] Processing nosignal batch:" +echo " - DIRECTORY : $DIRECTORY" +echo " - NOSIGNAL_ARGS : $NOSIGNAL_ARGS" +echo " " + +FILES=("$DIRECTORY"/*.mkv) +TOTAL_FILES=${#FILES[@]} + + +COUNTER=1 +NOSIGNAL_COUNTER=0 +NOSIGNAL_FILES=() +for FILE in "${FILES[@]}" +do + echo " " + echo "----------------------------------------------" + TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + echo "[$TIMESTAMP] Processing ($COUNTER of $TOTAL_FILES) $FILE ..." + ./reprostim/nosignal $NOSIGNAL_ARGS "$FILE" + EXIT_CODE=$? + echo "EXIT_CODE=$EXIT_CODE" + if [ $EXIT_CODE -eq 1 ]; then + echo "[$TIMESTAMP] NOSIGNAL_FILE: $FILE" + ((NOSIGNAL_COUNTER++)) + NOSIGNAL_FILES+=("$FILE") + fi + ((COUNTER++)) +done + +echo " " +echo "----------------------------------------------" +echo "DIRECTORY : $DIRECTORY" +echo "NOSIGNAL_ARGS : $NOSIGNAL_ARGS" +echo " " +echo "TOTAL_FILES : $TOTAL_FILES" +echo "NOSIGNAL_COUNT : $NOSIGNAL_COUNTER" +for NOSIGNAL_FILE in "${NOSIGNAL_FILES[@]}" +do + echo "$NOSIGNAL_FILE" +done +echo " " + + diff --git a/Capture/nosignal/reprostim/nosignal b/Capture/nosignal/reprostim/nosignal index b49dbd9..f054b05 100755 --- a/Capture/nosignal/reprostim/nosignal +++ b/Capture/nosignal/reprostim/nosignal @@ -23,6 +23,7 @@ class VideoInfo(BaseModel): fps: float = Field(..., description="Frames per second (FPS)") width: int = Field(..., description="Video frame width") height: int = Field(..., description="Video frame height") + is_invalid_timing: bool = Field(False, description="Is video with invalid duration/timing") is_truncated: bool = Field(False, description="Is video truncated") frames_count: int = Field(..., description="Total number of frames") nosignal_count: int = Field(..., description="Total number of nosignal " @@ -49,6 +50,11 @@ ts = time.time() lower_rainbow = np.array([0, 50, 50]) upper_rainbow = np.array([30, 255, 255]) +# Specify nosignal grid size (8 bands) for custom algorithm +grid_rows: int = 6 +grid_cols: int = 8 +grid_colors = [[None for _ in range(grid_cols)] for _ in range(grid_rows)] + def auto_fix_video(video_path: str, temp_path: str): logger.info(f"Run mediainfo to get video information: mediainfo -i {video_path}") @@ -58,7 +64,7 @@ def auto_fix_video(video_path: str, temp_path: str): logger.info(f"[stdout] : {res.stdout}") logger.info(f"[stderr] : {res.stderr}") - logger.info(f"Run fixup ffmpeg : ffmpeg -i {video_path} -c copy {temp_path}") + logger.info(f"Run fixup ffmpeg : ffmpeg -i {video_path} -an -c copy {temp_path}") res = subprocess.run(f"ffmpeg -i {video_path} -c copy {temp_path}", check=True, shell=True, capture_output=True, text=True) @@ -68,6 +74,13 @@ def auto_fix_video(video_path: str, temp_path: str): logger.info("Video fixup completed.") +# Function to calculate opencv2 color difference +def calc_color_diff(color1, color2): + b1, g1, r1 = color1.astype(np.int32) + b2, g2, r2 = color2.astype(np.int32) + return abs(r1 - r2) + abs(g1 - g2) + abs(b1 - b2) + + def has_rainbow(frame): # Convert frame to HSV color space hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV) @@ -80,6 +93,61 @@ def has_rainbow(frame): return pixel_count > 10000 # Adjust threshold as needed +# match nosignal grid colors using custom algorithm +# based on reference image sample +def has_rainbow2(frame): + height, width, _ = frame.shape + cy = height // grid_rows + cx = width // grid_cols + n = grid_rows * grid_cols + diff = 0 + for i in range(grid_rows): + for j in range(grid_cols): + x = int(j * cx + cx // 2) + y = int(i * cy + cy // 2) + clr1 = grid_colors[i][j] + clr2 = frame[y, x] + diff = diff + calc_color_diff(clr1, clr2) + + diff = diff / n + logger.debug(f"diff={diff}") + # NOTE: tune the threshold value as needed + return diff < 35 + + +def init_grid_colors(ref_image_path: str): + # Load reference image + image = cv2.imread(ref_image_path) + + # Get the dimensions of the image + height, width, _ = image.shape + + # Calculate the height and width of grid cell + cell_height = height // grid_rows + cell_width = width // grid_cols + + # Loop over the grid + for i in range(grid_rows): + for j in range(grid_cols): + # Calculate the center of each region + x = int(j * cell_width + cell_width // 2) + y = int(i * cell_height + cell_height // 2) + + # Get the pixel color at the center + # opencv use (row, column) coordinates + clr = image[y, x] + + # Store the color in the 2D list + grid_colors[i][j] = clr + + # dump the grid colors + logger.debug("Nosignal reference grid colors:") + for i, row in enumerate(grid_colors): + for j, color in enumerate(row): + b, g, r = color.astype(np.int32) # OpenCV stores colors as BGR + logger.debug(f" grid_colors[{i+1}, {j+1}] : RGB({r}, {g}, {b})") + + def main_exit(code: int) -> int: # print debug message exec_time = time.time() - ts @@ -94,6 +162,7 @@ def find_no_signal(video_path: str, step: int = 1, show_progress_sec: float = 0.0, check_first_frames: int = 0) -> VideoInfo: vi: VideoInfo = VideoInfo(fps=0, width=0, height=0, + is_invalid_timing=False, is_truncated=False, frames_count=0, nosignal_count=0, nosignal_rate=0.0, scanned_count=0) @@ -127,6 +196,8 @@ def find_no_signal(video_path: str, step: int = 1, fps = cap.get(cv2.CAP_PROP_FPS) frame_width: int = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) frame_height: int = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + duration_sec: float = frame_count / fps if fps > 0 else -1.0 + logger.debug(f"duration_sec={duration_sec}") vi.width = frame_width vi.height = frame_height vi.fps = round(fps, 2) @@ -155,6 +226,12 @@ def find_no_signal(video_path: str, step: int = 1, vi.error = f"Invalid frame range: {pos_first_frame} - {pos_last_frame}" return vi + if duration_sec < 0 or duration_sec > 2*24*60*60: + vi.is_invalid_timing = True + if check_first_frames == 0: + vi.error = f"Invalid video duration: {duration_sec} seconds" + return vi + pos_cur_frame: int = pos_first_frame pos_next_frame: int = pos_cur_frame nosignal_counter: int = 0 @@ -174,6 +251,19 @@ def find_no_signal(video_path: str, step: int = 1, if ret is False: logger.debug(f"Failed reading frame {pos_cur_frame}") break + + # for some rare videos, opencv continues to read frames even after the end of video + # e.g. Videos/2024/03/2024.03.18.14.39.38.336_2024.03.18.14.44.02.541.mkv + # to fix this cases, just break the loop + if pos_cur_frame > pos_last_frame: + logger.debug(f"Failed reading frame sequencer, pos_cur_frame={pos_cur_frame} > pos_last_frame={pos_last_frame}") + break + + # also double check number_of_checks for similar cases + if 0 < number_of_checks <= scan_counter: + logger.debug(f"Failed reading frame, number_of_checks={number_of_checks} limit reached") + break + scan_counter += 1 # break if check_first_frames is set and reached @@ -187,7 +277,7 @@ def find_no_signal(video_path: str, step: int = 1, else: pos_next_frame = pos_cur_frame + step - if has_rainbow(frame): + if has_rainbow2(frame): logger.debug("rainbow-yes") nosignal_counter = nosignal_counter + 1 nosignal_frames.append(pos_cur_frame) @@ -240,12 +330,23 @@ def find_no_signal(video_path: str, step: int = 1, ' [exit2] - exit with error code 2, default value.\n' ' [fixup] - use ffmpeg to fix the video by copying\n' ' it into a temporary location with\n' - ' "ffmpeg -i -c copy "\n' + ' "ffmpeg -i -an -c copy "\n' ' command.\n' ' [check5] - check for nosignal first 5 frames only in\n' ' truncated video.\n \n' 'To check if video is truncated, use:\n' ' mediainfo -i | grep "IsTruncated"') +@click.option('--invalid-timing', type=click.Choice(['exit3', 'fixup', 'check5'], + case_sensitive=False), + default='exit3', + help='\b\nSpecify behavior on video with invalid duration:\n \n' + ' [exit3] - exit with error code 3, default value.\n' + ' [fixup] - use ffmpeg to fix the video by copying\n' + ' it into a temporary location with\n' + ' "ffmpeg -i -an -c copy "\n' + ' command.\n' + ' [check5] - check for nosignal first 5 frames only in\n' + ' truncated video.\n \n') @click.option('--threshold', default=0.01, type=float, help='Specify the threshold for nosignal frames, default is ' '0.01 which means 1% of the totally checked frames.') @@ -254,6 +355,7 @@ def main(ctx, path: str, log_level, step: int, number_of_checks: int, show_progress: float, truncated: str, + invalid_timing: str, threshold: float): logger.setLevel(log_level) logger.debug("nosignal.py tool") @@ -262,26 +364,48 @@ def main(ctx, path: str, log_level, step: int, temp_path: str = None + logger.debug("Initializing grid colors...") + init_grid_colors("data/nosignal.png") + res = find_no_signal(path, step, number_of_checks, show_progress, 0) + + if res.error is not None: + logger.error(f"ERROR : {res.error}") + + # process truncated video if any if res.is_truncated: logger.error(f"ERROR : Truncated video detected.") - if res.is_truncated and truncated == "exit2": - return main_exit(2) + if truncated == "exit2": + return main_exit(2) - if res.is_truncated and truncated == "fixup": - logger.info("TRUNCATED VIDEO: Attempting to fix the truncated video.") - temp_path = tempfile.mktemp(suffix="_nosignal_fixed.mkv") - logger.info(f"Copying video to temporary file: {temp_path}") - auto_fix_video(path, temp_path) - res = find_no_signal(temp_path, step, number_of_checks, show_progress, 0) + if truncated == "fixup": + logger.info("TRUNCATED VIDEO: Attempting to fix the truncated video.") + temp_path = tempfile.mktemp(suffix="_nosignal_fixed.mkv") + logger.info(f"Copying video to temporary file: {temp_path}") + auto_fix_video(path, temp_path) + res = find_no_signal(temp_path, step, number_of_checks, show_progress, 0) - if res.is_truncated and truncated == "check5": - logger.info("TRUNCATED VIDEO: Checking first 5 frames only.") - res = find_no_signal(path, step, number_of_checks, show_progress, 5) + if truncated == "check5": + logger.info("TRUNCATED VIDEO: Checking first 5 frames only.") + res = find_no_signal(path, step, number_of_checks, show_progress, 5) - if res.error is not None: - logger.error(f"ERROR : {res.error}") + elif res.is_invalid_timing: + logger.error(f"ERROR : Invaild video timing/duration detected.") + + if invalid_timing == "exit3": + return main_exit(3) + + if invalid_timing == "fixup": + logger.info("INVALID TIMING : Attempting to fix the video duration.") + temp_path = tempfile.mktemp(suffix="_nosignal_fixed.mkv") + logger.info(f"Copying video to temporary file: {temp_path}") + auto_fix_video(path, temp_path) + res = find_no_signal(temp_path, step, number_of_checks, show_progress, 0) + + if invalid_timing == "check5": + logger.info("INVALID TIMING : Checking first 5 frames only.") + res = find_no_signal(path, step, number_of_checks, show_progress, 5) # delete temp_path if exists if temp_path is not None and os.path.exists(temp_path): diff --git a/Capture/screencapture/src/ScreenCapture.cpp b/Capture/screencapture/src/ScreenCapture.cpp index 1e1b698..3120945 100644 --- a/Capture/screencapture/src/ScreenCapture.cpp +++ b/Capture/screencapture/src/ScreenCapture.cpp @@ -86,8 +86,13 @@ int ScreenCaptureApp::parseOpts(AppOpts& opts, int argc, char* argv[]) { "\t \tDefaults to console output\n" "\t-v, --verbose\n" "\t \tVerbose, provides detailed information to stdout\n" - "\t-l, --list-devices\n" - "\t \tList devices, only audio is supported\n" + "\t-l, --list-devices \n" + "\t \tList connected capture devices information.\n" + "\t \tSupported values:\n" + "\t \t all : list all available information\n" + "\t \t audio : list only audio devices information\n" + "\t \t video : list only video devices information\n" + "\t \tDefault value is \"all\"\n" "\t-V\n" "\t \tPrint version number only\n" "\t--version\n" @@ -106,7 +111,7 @@ int ScreenCaptureApp::parseOpts(AppOpts& opts, int argc, char* argv[]) { {"help", no_argument, nullptr, 'h'}, {"verbose", no_argument, nullptr, 'v'}, {"version", no_argument, nullptr, 1000}, - {"list-devices", no_argument, nullptr, 'l'}, + {"list-devices", optional_argument, nullptr, 'l'}, {"file-log", required_argument, nullptr, 'f'}, {nullptr, 0, nullptr, 0} }; @@ -125,8 +130,16 @@ int ScreenCaptureApp::parseOpts(AppOpts& opts, int argc, char* argv[]) { case 'h': _INFO(HELP_STR); return 1; - case 'l': - listDevices(); + case 'l': { + std::string devices = "all"; + if (optarg) { + devices = std::string(optarg); + } else if (optind < argc && argv[optind][0] != '-') { + devices = std::string(argv[optind]); + optind++; + } + listDevices(devices); + } return 1; case 'v': opts.verbose = true; diff --git a/Capture/version.txt b/Capture/version.txt index 35f6398..bcce4cc 100644 --- a/Capture/version.txt +++ b/Capture/version.txt @@ -1 +1 @@ -1.8.0.225 \ No newline at end of file +1.9.0.230 \ No newline at end of file diff --git a/Capture/videocapture/src/VideoCapture.cpp b/Capture/videocapture/src/VideoCapture.cpp index f502c2e..b1c7592 100644 --- a/Capture/videocapture/src/VideoCapture.cpp +++ b/Capture/videocapture/src/VideoCapture.cpp @@ -211,8 +211,13 @@ int VideoCaptureApp::parseOpts(AppOpts& opts, int argc, char* argv[]) { "\t \tPrint version number only\n" "\t--version\n" "\t \tPrint expanded version information\n" - "\t-l, --list-devices\n" - "\t \tList devices, only audio is supported\n" + "\t-l, --list-devices \n" + "\t \tList connected capture devices information.\n" + "\t \tSupported values:\n" + "\t \t all : list all available information\n" + "\t \t audio : list only audio devices information\n" + "\t \t video : list only video devices information\n" + "\t \tDefault value is \"all\"\n" "\t-h, --help\n" "\t \tPrint this help string\n"; @@ -227,7 +232,7 @@ int VideoCaptureApp::parseOpts(AppOpts& opts, int argc, char* argv[]) { {"help", no_argument, nullptr, 'h'}, {"verbose", no_argument, nullptr, 'v'}, {"version", no_argument, nullptr, 1000}, - {"list-devices", no_argument, nullptr, 'l'}, + {"list-devices", optional_argument, nullptr, 'l'}, {"file-log", required_argument, nullptr, 'f'}, {nullptr, 0, nullptr, 0} }; @@ -246,8 +251,16 @@ int VideoCaptureApp::parseOpts(AppOpts& opts, int argc, char* argv[]) { case 'h': _INFO(HELP_STR); return 1; - case 'l': - listDevices(); + case 'l': { + std::string devices = "all"; + if (optarg) { + devices = std::string(optarg); + } else if (optind < argc && argv[optind][0] != '-') { + devices = std::string(argv[optind]); + optind++; + } + listDevices(devices); + } return 1; case 'v': opts.verbose = true;