From 38cbe2553970c5c510b48cde8fdbe23cfc193a3b Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Tue, 22 Oct 2024 21:29:21 -0400 Subject: [PATCH 1/2] [scene_manager] Add ability to crop input --- docs/cli.rst | 4 ++ scenedetect.cfg | 12 +++-- scenedetect/_cli/__init__.py | 12 ++++- scenedetect/_cli/config.py | 6 +++ scenedetect/_cli/context.py | 11 ++++- scenedetect/platform.py | 5 +- scenedetect/scene_manager.py | 93 ++++++++++++++++++++++++++++-------- tests/test_cli.py | 5 ++ tests/test_scene_manager.py | 35 ++++++++++++++ website/pages/changelog.md | 5 +- 10 files changed, 159 insertions(+), 29 deletions(-) diff --git a/docs/cli.rst b/docs/cli.rst index 6e757b6b..ee9bfbe2 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -57,6 +57,10 @@ Options Path to config file. See :ref:`config file reference ` for details. +.. option:: --crop X0 Y0 X1 Y1 + + Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99). + .. option:: -s CSV, --stats CSV Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis. diff --git a/scenedetect.cfg b/scenedetect.cfg index 2cb1037b..321eb4ff 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -27,15 +27,19 @@ # Must be one of: detect-adaptive, detect-content, detect-threshold, detect-hist #default-detector = detect-adaptive -# Video backend interface, must be one of: opencv, pyav, moviepy. -#backend = opencv +# Output directory for written files. Defaults to working directory. +#output = /usr/tmp/scenedetect/ # Verbosity of console output (debug, info, warning, error, or none). # Set to none for the same behavior as specifying -q/--quiet. #verbosity = debug -# Output directory for written files. Defaults to working directory. -#output = /usr/tmp/scenedetect/ +# Crop input video to area. Specified as two points in the form X0 Y0 X1 Y1 or +# as (X0 Y0), (X1 Y1). Coordinate (0, 0) is the top-left corner. +#crop = 100 100 200 250 + +# Video backend interface, must be one of: opencv, pyav, moviepy. +#backend = opencv # Minimum length of a given scene. #min-scene-len = 0.6s diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index cfe5fe84..6ed9593b 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -256,6 +256,14 @@ def print_command_help(ctx: click.Context, command: click.Command): help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s" % (", ".join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")), ) +@click.option( + "--crop", + metavar="X0 Y0 X1 Y1", + type=(int, int, int, int), + default=None, + help="Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99).%s" + % (USER_CONFIG.get_help_string("global", "crop", show_default=False)), +) @click.option( "--downscale", "-d", @@ -312,6 +320,7 @@ def scenedetect( drop_short_scenes: ty.Optional[bool], merge_last_scene: ty.Optional[bool], backend: ty.Optional[str], + crop: ty.Optional[ty.Tuple[int, int, int, int]], downscale: ty.Optional[int], frame_skip: ty.Optional[int], verbosity: ty.Optional[str], @@ -326,12 +335,13 @@ def scenedetect( output=output, framerate=framerate, stats_file=stats, - downscale=downscale, frame_skip=frame_skip, min_scene_len=min_scene_len, drop_short_scenes=drop_short_scenes, merge_last_scene=merge_last_scene, backend=backend, + crop=crop, + downscale=downscale, quiet=quiet, logfile=logfile, config=config, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 76327a62..e9dfbf81 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -340,6 +340,12 @@ def format(self, timecode: FrameTimecode) -> str: }, "global": { "backend": "opencv", + # + # + # FIXME: This should be a tuple of 4 valid ints similar to ScoreWeightsValue. + # + # + "crop": None, "default-detector": "detect-adaptive", "downscale": 0, "downscale-method": Interpolation.LINEAR, diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index bd8f88d6..7dfa7580 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -157,12 +157,13 @@ def handle_options( output: ty.Optional[ty.AnyStr], framerate: float, stats_file: ty.Optional[ty.AnyStr], - downscale: ty.Optional[int], frame_skip: int, min_scene_len: str, drop_short_scenes: ty.Optional[bool], merge_last_scene: ty.Optional[bool], backend: ty.Optional[str], + crop: ty.Optional[ty.Tuple[int, int, int, int]], + downscale: ty.Optional[int], quiet: bool, logfile: ty.Optional[ty.AnyStr], config: ty.Optional[ty.AnyStr], @@ -287,6 +288,7 @@ def handle_options( logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="downscale factor") from None scene_manager.interpolation = self.config.get_value("global", "downscale-method") + scene_manager.crop = self.config.get_value("global", "crop", crop) self.scene_manager = scene_manager @@ -545,7 +547,12 @@ def _open_video_stream( framerate=framerate, backend=backend, ) - logger.debug("Video opened using backend %s", type(self.video_stream).__name__) + logger.debug(f"""Video information: + Backend: {type(self.video_stream).__name__} + Resolution: {self.video_stream.frame_size} + Framerate: {self.video_stream.frame_rate} + Duration: {self.video_stream.duration} ({self.video_stream.duration.frame_num} frames)""") + except FrameRateUnavailable as ex: raise click.BadParameter( "Failed to obtain framerate for input video. Manually specify framerate with the" diff --git a/scenedetect/platform.py b/scenedetect/platform.py index 244b9cbe..9e12dbc2 100644 --- a/scenedetect/platform.py +++ b/scenedetect/platform.py @@ -330,7 +330,10 @@ def get_system_version_info() -> str: for module_name in third_party_packages: try: module = importlib.import_module(module_name) - out_lines.append(output_template.format(module_name, module.__version__)) + if hasattr(module, "__version__"): + out_lines.append(output_template.format(module_name, module.__version__)) + else: + out_lines.append(output_template.format(module_name, not_found_str)) except ModuleNotFoundError: out_lines.append(output_template.format(module_name, not_found_str)) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 58cf6726..69f31b9f 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -114,6 +114,11 @@ def on_new_scene(frame_img: numpy.ndarray, frame_num: int): CutList = ty.List[FrameTimecode] """Type hint for a list of cuts, where each timecode represents the first frame of a new shot.""" +CropRegion = ty.Tuple[int, int, int, int] +"""Type hint for rectangle of the form X0 Y0 X1 Y1 for cropping frames. Coordinates are relative +to source frame without downscaling. +""" + # TODO: This value can and should be tuned for performance improvements as much as possible, # until accuracy falls, on a large enough dataset. This has yet to be done, but the current # value doesn't seem to have caused any issues at least. @@ -145,7 +150,7 @@ class Interpolation(Enum): """Lanczos interpolation over 8x8 neighborhood.""" -def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH) -> int: +def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MIN_WIDTH) -> float: """Get the optimal default downscale factor based on a video's resolution (currently only the width in pixels is considered). @@ -159,10 +164,10 @@ def compute_downscale_factor(frame_width: int, effective_width: int = DEFAULT_MI Returns: int: The default downscale factor to use to achieve at least the target effective_width. """ - assert not (frame_width < 1 or effective_width < 1) + assert frame_width > 0 and effective_width > 0 if frame_width < effective_width: return 1 - return frame_width // effective_width + return frame_width / float(effective_width) def get_scenes_from_cuts( @@ -991,6 +996,7 @@ def __init__( self._frame_buffer = [] self._frame_buffer_size = 0 + self._crop = None @property def interpolation(self) -> Interpolation: @@ -1006,6 +1012,35 @@ def stats_manager(self) -> ty.Optional[StatsManager]: """Getter for the StatsManager associated with this SceneManager, if any.""" return self._stats_manager + @property + def crop(self) -> ty.Optional[CropRegion]: + """Portion of the frame to crop. Tuple of 4 ints in the form (X0, Y0, X1, Y1) where X0, Y0 + describes one point and X1, Y1 is another which describe a rectangle inside of the frame. + Coordinates start from 0 and are inclusive. For example, with a 100x100 pixel video, + (0, 0, 99, 99) covers the entire frame.""" + if self._crop is None: + return None + (x0, y0, x1, y1) = self._crop + return (x0, y0, x1 - 1, y1 - 1) + + @crop.setter + def crop(self, value: CropRegion): + """Raises: + ValueError: All coordinates must be >= 0. + """ + if value is None: + self._crop = None + return + if not (len(value) == 4 and all(isinstance(v, int) for v in value)): + raise TypeError("crop region must be tuple of 4 ints") + # Verify that the provided crop results in a non-empty portion of the frame. + if any(coordinate < 0 for coordinate in value): + raise ValueError("crop coordinates must be >= 0") + (x0, y0, x1, y1) = value + # Internally we store the value in the form used to de-reference the image, which must be + # one-past the end. + self._crop = (x0, y0, x1 + 1, y1 + 1) + @property def downscale(self) -> int: """Factor to downscale each frame by. Will always be >= 1, where 1 @@ -1232,6 +1267,33 @@ def detect_scenes( if end_time is not None and isinstance(end_time, (int, float)) and end_time < 0: raise ValueError("end_time must be greater than or equal to 0!") + effective_frame_size = video.frame_size + if self._crop: + logger.debug(f"Crop set: {self.crop}") + x0, y0, x1, y1 = self._crop + min_x, min_y = (min(x0, x1), min(y0, y1)) + max_x, max_y = (max(x0, x1), max(y0, y1)) + frame_width, frame_height = video.frame_size + if min_x >= frame_width or min_y >= frame_height: + raise ValueError("crop starts outside video boundary") + if max_x >= frame_width or max_y >= frame_height: + logger.warning("Warning: crop ends outside of video boundary.") + effective_frame_size = ( + 1 + min(max_x, frame_width) - min_x, + 1 + min(max_y, frame_height) - min_y, + ) + # Calculate downscale factor and log effective resolution. + if self.auto_downscale: + downscale_factor = compute_downscale_factor(max(effective_frame_size)) + else: + downscale_factor = self.downscale + logger.debug( + "Processing resolution: %d x %d, downscale: %1.1f", + int(effective_frame_size[0] / downscale_factor), + int(effective_frame_size[1] / downscale_factor), + downscale_factor, + ) + self._base_timecode = video.base_timecode # TODO: Figure out a better solution for communicating framerate to StatsManager. @@ -1251,19 +1313,6 @@ def detect_scenes( else: total_frames = video.duration.get_frames() - start_frame_num - # Calculate the desired downscale factor and log the effective resolution. - if self.auto_downscale: - downscale_factor = compute_downscale_factor(frame_width=video.frame_size[0]) - else: - downscale_factor = self.downscale - if downscale_factor > 1: - logger.info( - "Downscale factor set to %d, effective resolution: %d x %d", - downscale_factor, - video.frame_size[0] // downscale_factor, - video.frame_size[1] // downscale_factor, - ) - progress_bar = None if show_progress: progress_bar = tqdm( @@ -1320,7 +1369,7 @@ def _decode_thread( self, video: VideoStream, frame_skip: int, - downscale_factor: int, + downscale_factor: float, end_time: FrameTimecode, out_queue: queue.Queue, ): @@ -1361,12 +1410,16 @@ def _decode_thread( # Skip processing frames that have an incorrect size. continue - if downscale_factor > 1: + if self._crop: + (x0, y0, x1, y1) = self._crop + frame_im = frame_im[y0:y1, x0:x1] + + if downscale_factor > 1.0: frame_im = cv2.resize( frame_im, ( - round(frame_im.shape[1] / downscale_factor), - round(frame_im.shape[0] / downscale_factor), + max(1, round(frame_im.shape[1] / downscale_factor)), + max(1, round(frame_im.shape[0] / downscale_factor)), ), interpolation=self._interpolation.value, ) diff --git a/tests/test_cli.py b/tests/test_cli.py index 446437cb..82865701 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -116,6 +116,11 @@ def test_cli_default_detector(): assert invoke_scenedetect("-i {VIDEO} time {TIME}", config_file=None) == 0 +def test_cli_crop(): + """Test --crop functionality.""" + assert invoke_scenedetect("-i {VIDEO} --crop 0 0 256 256 time {TIME}", config_file=None) == 0 + + @pytest.mark.parametrize("info_command", ["help", "about", "version"]) def test_cli_info_command(info_command): """Test `scenedetect` info commands (e.g. help, about).""" diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 036c29e6..51238d76 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -21,6 +21,8 @@ from pathlib import Path from typing import List +import pytest + from scenedetect.backends.opencv import VideoStreamCv2 from scenedetect.detectors import AdaptiveDetector, ContentDetector from scenedetect.frame_timecode import FrameTimecode @@ -291,3 +293,36 @@ def test_detect_scenes_callback_adaptive(test_video_file): scene_list = sm.get_scene_list() assert [start for start, end in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL assert fake_callback.scene_list == TEST_VIDEO_START_FRAMES_ACTUAL[1:] + + +def test_detect_scenes_crop(test_video_file): + video = VideoStreamCv2(test_video_file) + sm = SceneManager() + sm.crop = (10, 10, 1900, 1000) + sm.add_detector(ContentDetector()) + + video_fps = video.frame_rate + start_time = FrameTimecode("00:00:05", video_fps) + end_time = FrameTimecode("00:00:15", video_fps) + video.seek(start_time) + sm.auto_downscale = True + + _ = sm.detect_scenes(video=video, end_time=end_time) + scene_list = sm.get_scene_list() + assert [start for start, _ in scene_list] == TEST_VIDEO_START_FRAMES_ACTUAL + + +def test_crop_invalid(): + sm = SceneManager() + sm.crop = None + sm.crop = (0, 0, 0, 0) + sm.crop = (1, 1, 0, 0) + sm.crop = (0, 0, 1, 1) + with pytest.raises(TypeError): + sm.crop = 1 + with pytest.raises(TypeError): + sm.crop = (1, 1) + with pytest.raises(TypeError): + sm.crop = (1, 1, 1) + with pytest.raises(ValueError): + sm.crop = (1, 1, 1, -1) diff --git a/website/pages/changelog.md b/website/pages/changelog.md index ccbc2ada..7e47e5e1 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -602,4 +602,7 @@ Development - Updated PyAV 10 -> 13.1.0 and OpenCV 4.10.0.82 -> 4.10.0.84 - [improvement] `save_to_csv` now works with paths from `pathlib` - [api] The `save_to_csv` function now works correctly with paths from the `pathlib` module - - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` \ No newline at end of file + - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` + - [feature] Add ability to crop input video before processing [#302](https://github.com/Breakthrough/PySceneDetect/issues/302) [#449](https://github.com/Breakthrough/PySceneDetect/issues/449) + - [cli] Add `--crop` option to `scenedetect` command and config file to crop video frames before scene detection + - [api] Add `crop` property to `SceneManager` to crop video frames before scene detection \ No newline at end of file From 0e342bffbf83033437cecce293e8f9b8e57a9007 Mon Sep 17 00:00:00 2001 From: Breakthrough Date: Sun, 24 Nov 2024 22:38:28 -0500 Subject: [PATCH 2/2] [scene_manager] Validate crop config params and improve error messaging Make sure exceptions are always thrown in debug mode from the source location. --- scenedetect/_cli/config.py | 66 ++++++++++++++++++----- scenedetect/_cli/context.py | 30 +++++++++-- scenedetect/detectors/content_detector.py | 1 - scenedetect/scene_manager.py | 4 +- tests/test_cli.py | 13 +++++ 5 files changed, 95 insertions(+), 19 deletions(-) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index e9dfbf81..496a40fb 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -135,6 +135,47 @@ def from_config(config_value: str, default: "RangeValue") -> "RangeValue": ) from ex +class CropValue(ValidatedValue): + """Validator for crop region defined as X0 Y0 X1 Y1.""" + + _IGNORE_CHARS = [",", "/", "(", ")"] + """Characters to ignore.""" + + def __init__(self, value: Optional[Union[str, Tuple[int, int, int, int]]] = None): + if isinstance(value, CropValue) or value is None: + self._crop = value + else: + crop = () + if isinstance(value, str): + translation_table = str.maketrans( + {char: " " for char in ScoreWeightsValue._IGNORE_CHARS} + ) + values = value.translate(translation_table).split() + crop = tuple(int(val) for val in values) + elif isinstance(value, tuple): + crop = value + if not len(crop) == 4: + raise ValueError("Crop region must be four numbers of the form X0 Y0 X1 Y1!") + if any(coordinate < 0 for coordinate in crop): + raise ValueError("Crop coordinates must be >= 0") + (x0, y0, x1, y1) = crop + self._crop = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)) + + @property + def value(self) -> Tuple[int, int, int, int]: + return self._crop + + def __str__(self) -> str: + return "[%d, %d], [%d, %d]" % self.value + + @staticmethod + def from_config(config_value: str, default: "CropValue") -> "CropValue": + try: + return CropValue(config_value) + except ValueError as ex: + raise OptionParseFailure(f"{ex}") from ex + + class ScoreWeightsValue(ValidatedValue): """Validator for score weight values (currently a tuple of four numbers).""" @@ -154,7 +195,7 @@ def __init__(self, value: Union[str, ContentDetector.Components]): self._value = ContentDetector.Components(*(float(val) for val in values)) @property - def value(self) -> Tuple[float, float, float, float]: + def value(self) -> ContentDetector.Components: return self._value def __str__(self) -> str: @@ -340,12 +381,7 @@ def format(self, timecode: FrameTimecode) -> str: }, "global": { "backend": "opencv", - # - # - # FIXME: This should be a tuple of 4 valid ints similar to ScoreWeightsValue. - # - # - "crop": None, + "crop": CropValue(), "default-detector": "detect-adaptive", "downscale": 0, "downscale-method": Interpolation.LINEAR, @@ -490,7 +526,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: out_map[command][option] = parsed except TypeError: errors.append( - "Invalid [%s] value for %s: %s. Must be one of: %s." + "Invalid value for [%s] option %s': %s. Must be one of: %s." % ( command, option, @@ -504,7 +540,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: except ValueError as _: errors.append( - "Invalid [%s] value for %s: %s is not a valid %s." + "Invalid value for [%s] option '%s': %s is not a valid %s." % (command, option, config.get(command, option), value_type) ) continue @@ -520,7 +556,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: ) except OptionParseFailure as ex: errors.append( - "Invalid [%s] value for %s:\n %s\n%s" + "Invalid value for [%s] option '%s': %s\nError: %s" % (command, option, config_value, ex.error) ) continue @@ -532,7 +568,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]: if command in CHOICE_MAP and option in CHOICE_MAP[command]: if config_value.lower() not in CHOICE_MAP[command][option]: errors.append( - "Invalid [%s] value for %s: %s. Must be one of: %s." + "Invalid value for [%s] option '%s': %s. Must be one of: %s." % ( command, option, @@ -618,8 +654,12 @@ def _load_from_disk(self, path=None): config_file_contents = config_file.read() config.read_string(config_file_contents, source=path) except ParsingError as ex: + if __debug__: + raise raise ConfigLoadFailure(self._init_log, reason=ex) from None except OSError as ex: + if __debug__: + raise raise ConfigLoadFailure(self._init_log, reason=ex) from None # At this point the config file syntax is correct, but we need to still validate # the parsed options (i.e. that the options have valid values). @@ -644,8 +684,8 @@ def get_value( """Get the current setting or default value of the specified command option.""" assert command in CONFIG_MAP and option in CONFIG_MAP[command] if override is not None: - return override - if command in self._config and option in self._config[command]: + value = override + elif command in self._config and option in self._config[command]: value = self._config[command][option] else: value = CONFIG_MAP[command][option] diff --git a/scenedetect/_cli/context.py b/scenedetect/_cli/context.py index 7dfa7580..c9798803 100644 --- a/scenedetect/_cli/context.py +++ b/scenedetect/_cli/context.py @@ -22,6 +22,7 @@ CHOICE_MAP, ConfigLoadFailure, ConfigRegistry, + CropValue, ) from scenedetect.detectors import ( AdaptiveDetector, @@ -213,7 +214,7 @@ def handle_options( logger.log(log_level, log_str) if init_failure: logger.critical("Error processing configuration file.") - raise click.Abort() + raise SystemExit(1) if self.config.config_dict: logger.debug("Current configuration:\n%s", str(self.config.config_dict).encode("utf-8")) @@ -286,9 +287,22 @@ def handle_options( scene_manager.downscale = downscale except ValueError as ex: logger.debug(str(ex)) - raise click.BadParameter(str(ex), param_hint="downscale factor") from None + raise click.BadParameter(str(ex), param_hint="downscale factor") from ex scene_manager.interpolation = self.config.get_value("global", "downscale-method") - scene_manager.crop = self.config.get_value("global", "crop", crop) + + # If crop was set, make sure it's valid (e.g. it should cover at least a single pixel). + try: + crop = self.config.get_value("global", "crop", CropValue(crop)) + if crop is not None: + (min_x, min_y) = crop[0:2] + frame_size = self.video_stream.frame_size + if min_x >= frame_size[0] or min_y >= frame_size[1]: + region = CropValue(crop) + raise ValueError(f"{region} is outside of video boundary of {frame_size}") + scene_manager.crop = crop + except ValueError as ex: + logger.debug(str(ex)) + raise click.BadParameter(str(ex), param_hint="--crop") from ex self.scene_manager = scene_manager @@ -320,6 +334,8 @@ def get_detect_content_params( try: weights = ContentDetector.Components(*weights) except ValueError as ex: + if __debug__: + raise logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="weights") from None @@ -375,6 +391,8 @@ def get_detect_adaptive_params( try: weights = ContentDetector.Components(*weights) except ValueError as ex: + if __debug__: + raise logger.debug(str(ex)) raise click.BadParameter(str(ex), param_hint="weights") from None return { @@ -554,18 +572,24 @@ def _open_video_stream( Duration: {self.video_stream.duration} ({self.video_stream.duration.frame_num} frames)""") except FrameRateUnavailable as ex: + if __debug__: + raise raise click.BadParameter( "Failed to obtain framerate for input video. Manually specify framerate with the" " -f/--framerate option, or try re-encoding the file.", param_hint="-i/--input", ) from ex except VideoOpenFailure as ex: + if __debug__: + raise raise click.BadParameter( "Failed to open input video%s: %s" % (" using %s backend" % backend if backend else "", str(ex)), param_hint="-i/--input", ) from ex except OSError as ex: + if __debug__: + raise raise click.BadParameter( "Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input" ) from None diff --git a/scenedetect/detectors/content_detector.py b/scenedetect/detectors/content_detector.py index 4b8b2e19..1269727c 100644 --- a/scenedetect/detectors/content_detector.py +++ b/scenedetect/detectors/content_detector.py @@ -133,7 +133,6 @@ def __init__( self._weights = ContentDetector.LUMA_ONLY_WEIGHTS self._kernel: Optional[numpy.ndarray] = None if kernel_size is not None: - print(kernel_size) if kernel_size < 3 or kernel_size % 2 == 0: raise ValueError("kernel_size must be odd integer >= 3") self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8) diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 69f31b9f..dbdfc563 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -1039,7 +1039,7 @@ def crop(self, value: CropRegion): (x0, y0, x1, y1) = value # Internally we store the value in the form used to de-reference the image, which must be # one-past the end. - self._crop = (x0, y0, x1 + 1, y1 + 1) + self._crop = (min(x0, x1), min(y0, y1), max(x0, x1) + 1, max(y0, y1) + 1) @property def downscale(self) -> int: @@ -1269,7 +1269,7 @@ def detect_scenes( effective_frame_size = video.frame_size if self._crop: - logger.debug(f"Crop set: {self.crop}") + logger.debug(f"Crop set: top left = {self.crop[0:2]}, bottom right = {self.crop[2:4]}") x0, y0, x1, y1 = self._crop min_x, min_y = (min(x0, x1), min(y0, y1)) max_x, max_y = (max(x0, x1), max(y0, y1)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 82865701..595d0988 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -121,6 +121,19 @@ def test_cli_crop(): assert invoke_scenedetect("-i {VIDEO} --crop 0 0 256 256 time {TIME}", config_file=None) == 0 +def test_cli_crop_rejects_invalid(): + """Test --crop rejects invalid options.""" + # Outside of video bounds + assert ( + invoke_scenedetect("-i {VIDEO} --crop 4000 0 8000 100 time {TIME}", config_file=None) != 1 + ) + assert ( + invoke_scenedetect("-i {VIDEO} --crop 0 4000 100 8000 time {TIME}", config_file=None) != 1 + ) + # Negative numbers + assert invoke_scenedetect("-i {VIDEO} --crop 0 0 -256 -256 time {TIME}", config_file=None) != 1 + + @pytest.mark.parametrize("info_command", ["help", "about", "version"]) def test_cli_info_command(info_command): """Test `scenedetect` info commands (e.g. help, about)."""