diff --git a/docs/cli.rst b/docs/cli.rst index b324e275..7685db12 100644 --- a/docs/cli.rst +++ b/docs/cli.rst @@ -649,6 +649,8 @@ Examples ``scenedetect -i video.mp4 save-images --num-images 5`` + ``scenedetect -i video.mp4 save-images --interval 2s --max-images 10`` + ``scenedetect -i video.mp4 save-images --width 1024`` ``scenedetect -i video.mp4 save-images --filename \$SCENE_NUMBER-img\$IMAGE_NUMBER`` @@ -670,10 +672,26 @@ Options .. option:: -n N, --num-images N - Number of images to generate per scene. Will always include start/end frame, unless -n 1, in which case the image will be the frame at the mid-point of the scene. + Number of images to generate per scene. Will always include start/end frame, unless -n 1, in which case the image will be the frame at the mid-point of the scene. Cannot be used with :option:`-i/--interval <-i>`. Default: ``3`` +.. option:: -i DURATION, --interval DURATION + + Generate one image per DURATION of each scene instead of a fixed number, so longer scenes get more images. Each scene gets floor(length / DURATION) + 1 images, chosen the same way as with :option:`-n/--num-images <-n>`. Use --min-images and --max-images to limit the number of images per scene. DURATION can be specified in frames (-i 24), in seconds with `s` suffix (-i 1.5s), or timecode (-i 00:00:01.500). Cannot be used with :option:`-n/--num-images <-n>`. + +.. option:: --min-images N + + Minimum number of images per scene. Can only be used with :option:`-i/--interval <-i>`. + + Default: ``1`` + +.. option:: --max-images N + + Maximum number of images per scene, or 0 for no limit. Can only be used with :option:`-i/--interval <-i>`. + + Default: ``0`` + .. option:: -j, --jpeg Set output format to JPEG (default). diff --git a/scenedetect.cfg b/scenedetect.cfg index 53e0e079..46c36106 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -228,9 +228,20 @@ # Image format (jpeg, png, webp). #format = jpeg -# Number of images to generate for each scene. +# Number of images to generate for each scene. Cannot be used with interval. #num-images = 3 +# Generate one image per DURATION of each scene instead of num-images, so longer +# scenes get more images: floor(length / DURATION) + 1 per scene. DURATION accepts +# frames (24), seconds with `s` suffix (1.5s), or timecode (00:00:01.500). +# Cannot be used with num-images. +#interval = 2s + +# Minimum and maximum number of images per scene with interval (ignored without +# interval). Set max-images to 0 for no limit. +#min-images = 1 +#max-images = 0 + # Image quality (jpeg/webp). Default is 95 for jpeg, 100 for webp #quality = 95 diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index fef4ddec..ae23241d 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -35,9 +35,11 @@ DEFAULT_JPG_QUALITY, DEFAULT_WEBP_QUALITY, RangeValue, + TimecodeValue, ) from scenedetect._cli.context import USER_CONFIG, CliContext, check_split_video_requirements from scenedetect.backends import AVAILABLE_BACKENDS +from scenedetect.common import FrameTimecode from scenedetect.detectors import ( AdaptiveDetector, ContentDetector, @@ -1400,6 +1402,8 @@ def split_video_command( {scenedetect_with_video} save-images --num-images 5 + {scenedetect_with_video} save-images --interval 2s --max-images 10 + {scenedetect_with_video} save-images --width 1024 {scenedetect_with_video} save-images --filename \\$SCENE_NUMBER-img\\$IMAGE_NUMBER @@ -1432,10 +1436,38 @@ def split_video_command( metavar="N", default=None, type=click.INT, - help="Number of images to generate per scene. Will always include start/end frame, unless -n 1, in which case the image will be the frame at the mid-point of the scene.{}".format( + help="Number of images to generate per scene. Will always include start/end frame, unless -n 1, in which case the image will be the frame at the mid-point of the scene. Cannot be used with -i/--interval.{}".format( USER_CONFIG.get_help_string("save-images", "num-images") ), ) +@click.option( + "--interval", + "-i", + metavar="DURATION", + default=None, + type=click.STRING, + help="Generate one image per DURATION of each scene instead of a fixed number, so longer scenes get more images. Each scene gets floor(length / DURATION) + 1 images, chosen the same way as with -n/--num-images. Use --min-images and --max-images to limit the number of images per scene. DURATION can be specified in frames (-i 24), in seconds with `s` suffix (-i 1.5s), or timecode (-i 00:00:01.500). Cannot be used with -n/--num-images.{}".format( + USER_CONFIG.get_help_string("save-images", "interval", show_default=False) + ), +) +@click.option( + "--min-images", + metavar="N", + default=None, + type=_click_range("save-images", "min-images"), + help="Minimum number of images per scene. Can only be used with -i/--interval.{}".format( + USER_CONFIG.get_help_string("save-images", "min-images") + ), +) +@click.option( + "--max-images", + metavar="N", + default=None, + type=_click_range("save-images", "max-images"), + help="Maximum number of images per scene, or 0 for no limit. Can only be used with -i/--interval.{}".format( + USER_CONFIG.get_help_string("save-images", "max-images") + ), +) @click.option( "--jpeg", "-j", @@ -1525,6 +1557,9 @@ def save_images_command( output: str | None = None, filename: str | None = None, num_images: int | None = None, + interval: str | None = None, + min_images: int | None = None, + max_images: int | None = None, jpeg: bool = False, webp: bool = False, quality: int | None = None, @@ -1577,6 +1612,60 @@ def save_images_command( raise click.BadParameter("\n".join(error_strs), param_hint="save-images") output = ctx.config.get_value("save-images", "output", output) + # -n/--num-images and -i/--interval are mutually exclusive. If neither is set, use the + # config file, where only one of them may be set. + if num_images is not None and interval is not None: + raise click.BadParameter( + "Only one of -n/--num-images or -i/--interval can be set.", param_hint="save-images" + ) + if interval is not None: + try: + TimecodeValue(interval) + except ValueError as ex: + raise click.BadParameter( + "Interval must be in frames (24), seconds (1.5s), or HH:MM:SS.", + param_hint="-i/--interval", + ) from ex + if FrameTimecode(timecode=interval, fps=ctx.video_stream.frame_rate).seconds <= 0: + raise click.BadParameter("Interval must be greater than 0.", param_hint="-i/--interval") + elif num_images is None: + if not ctx.config.is_default("save-images", "interval") and not ctx.config.is_default( + "save-images", "num-images" + ): + raise click.BadParameter( + "Only one of num-images or interval can be set in the config file.", + param_hint="save-images", + ) + if ctx.config.is_default("save-images", "interval"): + num_images = ctx.config.get_value("save-images", "num-images") + else: + interval_value: str = ctx.config.get_value("save-images", "interval") + if FrameTimecode(timecode=interval_value, fps=ctx.video_stream.frame_rate).seconds <= 0: + raise click.BadParameter( + "interval in the config file must be greater than 0.", + param_hint="save-images", + ) + interval = interval_value + # --min-images/--max-images only apply with an interval (from the command line or the config + # file). Without one, they are an error on the command line, and ignored in the config file. + if interval is None: + if min_images is not None or max_images is not None: + raise click.BadParameter( + "--min-images and --max-images can only be used with -i/--interval.", + param_hint="save-images", + ) + min_images, max_images = 1, None + else: + min_images_value: int = ctx.config.get_value("save-images", "min-images", min_images) + max_images_value: int = ctx.config.get_value("save-images", "max-images", max_images) + if max_images_value != 0 and max_images_value < min_images_value: + raise click.BadParameter( + "max-images must be 0 (no limit) or at least min-images.", + param_hint="save-images", + ) + # A max-images of 0 means no limit. + min_images, max_images = min_images_value, max_images_value or None + save_images_args = { "encoder_param": compression if png else quality, "frame_margin": ctx.config.get_value("save-images", "frame-margin", frame_margin), @@ -1584,7 +1673,10 @@ def save_images_command( "image_extension": image_extension, "filename": ctx.config.get_value("save-images", "filename", filename), "interpolation": scale_method, - "num_images": ctx.config.get_value("save-images", "num-images", num_images), + "interval": interval, + "max_images": max_images, + "min_images": min_images, + "num_images": num_images, "output": output, "scale": scale, "show_progress": not ctx.quiet_mode, diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 478ff1dc..7667f163 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -194,7 +194,10 @@ def save_images( context: CliContext, scenes: SceneList, cuts: CutList, - num_images: int, + num_images: int | None, + interval: str | None, + min_images: int, + max_images: int | None, frame_margin: int, image_extension: str, encoder_param: int, @@ -215,6 +218,9 @@ def save_images( scene_list=scenes, video=context.video_stream, num_images=num_images, + interval=interval, + min_images=min_images, + max_images=max_images, frame_margin=frame_margin, image_extension=image_extension, encoder_param=encoder_param, diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index bc033ac0..0e3ef9b9 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -97,15 +97,16 @@ def from_config(config_value: str, default: "TimecodeValue") -> "TimecodeValue": class RangeValue(ValidatedValue): - """Validator for int/float ranges. `min_val` and `max_val` are inclusive.""" + """Validator for int/float ranges. `min_val` and `max_val` are inclusive. A `max_val` of None + means there is no upper bound.""" def __init__( self, value: int | float, min_val: int | float, - max_val: int | float, + max_val: int | float | None, ): - if value < min_val or value > max_val: + if value < min_val or (max_val is not None and value > max_val): # min and max are inclusive. raise ValueError() self._value = value @@ -122,16 +123,20 @@ def min_val(self) -> int | float: return self._min_val @property - def max_val(self) -> int | float: - """Maximum value of the range.""" + def max_val(self) -> int | float | None: + """Maximum value of the range, or None if there is no upper bound.""" return self._max_val @property def click_range(self) -> "click.IntRange | click.FloatRange": """A `click` parameter type matching this range's bounds and value type.""" if isinstance(self._value, int): - return click.IntRange(int(self._min_val), int(self._max_val)) - return click.FloatRange(float(self._min_val), float(self._max_val)) + return click.IntRange( + int(self._min_val), None if self._max_val is None else int(self._max_val) + ) + return click.FloatRange( + float(self._min_val), None if self._max_val is None else float(self._max_val) + ) @staticmethod def from_config(config_value: str, default: "RangeValue") -> "RangeValue": @@ -142,6 +147,11 @@ def from_config(config_value: str, default: "RangeValue") -> "RangeValue": max_val=default.max_val, ) except ValueError as ex: + if default.max_val is None: + value_type = "an integer" if isinstance(default.value, int) else "a number" + raise OptionParseFailure( + f"Value must be {value_type} of at least {default.min_val}." + ) from ex raise OptionParseFailure( f"Value must be between {default.min_val} and {default.max_val}." ) from ex @@ -442,6 +452,9 @@ class FcpFormat(Enum): "format": "jpeg", "frame-margin": TimecodeValue(1), "height": 0, + "interval": TimecodeValue(0), # Placeholder, as there is no interval by default + "max-images": RangeValue(0, min_val=0, max_val=None), + "min-images": RangeValue(1, min_val=1, max_val=None), "num-images": 3, "output": None, "quality": RangeValue(_PLACEHOLDER, min_val=0, max_val=100), diff --git a/scenedetect/output/__init__.py b/scenedetect/output/__init__.py index 02f109cd..b0d56b35 100644 --- a/scenedetect/output/__init__.py +++ b/scenedetect/output/__init__.py @@ -228,8 +228,8 @@ def write_scene_list_html( the start times of each scene (besides the 0th scene) is used instead. css: String containing all the css information for the resulting html page. css_class: String containing the named css class - image_filenames: dict where key i contains a list with n elements (filenames of - the n saved images from that scene) + image_filenames: dict where key i contains a list of the filenames of the images + saved from that scene (the number of images can differ between scenes) image_width: Optional desired width of images in table in pixels image_height: Optional desired height of images in table in pixels """ diff --git a/scenedetect/output/image.py b/scenedetect/output/image.py index b7fced6a..5388e7eb 100644 --- a/scenedetect/output/image.py +++ b/scenedetect/output/image.py @@ -34,14 +34,49 @@ logger = logging.getLogger("pyscenedetect") +# Added to the ratio of scene length to interval (a number of intervals, so it has no unit) before +# rounding down, so floating point error doesn't drop an image from a scene that is a whole number +# of intervals long (e.g. 0.3 / 0.1 gives 2.9999999999999996). +_INTERVAL_COUNT_TOLERANCE: float = 1e-9 + + +def _get_num_images( + scene_list: SceneList, + num_images: int | None, + interval: TimecodeLike | None, + min_images: int, + max_images: int | None, +) -> list[int]: + """Get the number of images to generate for each scene in `scene_list`. + + If `interval` is set, each scene gets ``floor(scene length / interval) + 1`` images, clamped + to [`min_images`, `max_images`]. Otherwise, each scene gets `num_images`. + """ + if interval is None: + assert num_images is not None + return [num_images] * len(scene_list) + frame_rate = scene_list[0][0].frame_rate + assert frame_rate is not None + interval_secs = FrameTimecode(timecode=interval, fps=frame_rate).seconds + result = [] + for start, end in scene_list: + duration_secs = max((end - start).seconds, 0.0) + num_intervals = math.floor(duration_secs / interval_secs + _INTERVAL_COUNT_TOLERANCE) + scene_images = max(num_intervals + 1, min_images) + if max_images is not None: + scene_images = min(scene_images, max_images) + result.append(scene_images) + return result + def _generate_timecode_list( scene_list: SceneList, - num_images: int, + num_images_per_scene: list[int], frame_margin: TimecodeLike, ) -> list[list[FrameTimecode]]: """Generate per-scene image timecodes using PTS-accurate seconds-based timing. + `num_images_per_scene` is the number of images to generate for each scene in `scene_list`. `frame_margin` accepts any :data:`TimecodeLike` value (e.g. ``int`` frames, ``float`` seconds, or ``str`` such as ``"0.1s"``). """ @@ -49,7 +84,7 @@ def _generate_timecode_list( assert frame_rate is not None margin_secs = FrameTimecode(timecode=frame_margin, fps=frame_rate).seconds result = [] - for start, end in scene_list: + for (start, end), num_images in zip(scene_list, num_images_per_scene, strict=True): duration_secs = (end - start).seconds if duration_secs <= 0: result.append([start] * num_images) @@ -72,6 +107,14 @@ def _generate_timecode_list( return result +def _describe_num_images( + num_images: int | None, interval: TimecodeLike | None, total_images: int +) -> str: + if interval is None: + return f"{num_images} images per scene" + return f"{total_images} images (interval: {interval})" + + def _scale_image( image: np.ndarray, aspect_ratio: float | None, @@ -108,7 +151,7 @@ def _scale_image( class _ImageExtractor: def __init__( self, - num_images: int = 3, + num_images: int | None = 3, frame_margin: TimecodeLike = 1, image_extension: str = "jpg", imwrite_param: list[int] | None = None, @@ -117,6 +160,9 @@ def __init__( height: int | None = None, width: int | None = None, interpolation: Interpolation = Interpolation.CUBIC, + interval: TimecodeLike | None = None, + min_images: int = 1, + max_images: int | None = None, ): """Multi-threaded implementation of save-images functionality. Uses background threads to handle image encoding and saving images to disk to improve parallelism. @@ -124,7 +170,8 @@ def __init__( This object is thread-safe. Arguments: - num_images: Number of images to generate for each scene. Minimum is 1. + num_images: Number of images to generate for each scene. Minimum is 1. Must be None + if `interval` is set. frame_margin: Padding around the beginning and end of each scene used when selecting which frames to extract. Accepts an int (frames), float (seconds), or str (e.g. ``"0.1s"``, ``"00:00:00.100"``). Can be 0, but some video files @@ -149,8 +196,15 @@ def __init__( Specifying only width will rescale the image to that number of pixels wide while preserving the aspect ratio. interpolation: Type of interpolation to use when resizing images. + interval: If set, generate one image per `interval` of each scene's length instead of + `num_images`, see :func:`save_images`. + min_images: Minimum number of images to generate for each scene with `interval`. + max_images: Maximum number of images to generate for each scene with `interval`. """ self._num_images = num_images + self._interval = interval + self._min_images = min_images + self._max_images = max_images self._frame_margin = frame_margin self._image_extension = image_extension self._image_name_template = image_name_template @@ -175,19 +229,20 @@ def run( output_dir: Directory to write files to. show_progress: If `true` and tqdm is available, shows a progress bar. """ + timecode_list = self.generate_timecode_list(scene_list) + total_images = sum(len(timecodes) for timecodes in timecode_list) + max_scene_images = max(len(timecodes) for timecodes in timecode_list) + # Setup flags and init progress bar if available. completed = True logger.info( - f"Saving {self._num_images} images per scene [format={self._image_extension}]" - f" {output_dir if output_dir else ''} " + f"Saving {_describe_num_images(self._num_images, self._interval, total_images)}" + f" [format={self._image_extension}] {output_dir if output_dir else ''} " ) progress_bar = None if show_progress: - progress_bar = tqdm( - total=len(scene_list) * self._num_images, unit="images", dynamic_ncols=True - ) + progress_bar = tqdm(total=total_images, unit="images", dynamic_ncols=True) - timecode_list = self.generate_timecode_list(scene_list) image_filenames = {i: [] for i in range(len(timecode_list))} filename_template = Template(self._image_name_template) @@ -195,7 +250,7 @@ def run( scene_num_format = "%0" scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" image_num_format = "%0" - image_num_format += str(math.floor(math.log(self._num_images, 10)) + 2) + "d" + image_num_format += str(math.floor(math.log(max_scene_images, 10)) + 2) + "d" def format_filename(scene_number: int, image_number: int, image_timecode: FrameTimecode): return "{}.{}".format( @@ -337,7 +392,10 @@ def generate_timecode_list(self, scene_list: SceneList) -> list[list[FrameTimeco Uses PTS-accurate seconds-based timing so results are correct for both CFR and VFR video. """ - return _generate_timecode_list(scene_list, self._num_images, self._frame_margin) + num_images_per_scene = _get_num_images( + scene_list, self._num_images, self._interval, self._min_images, self._max_images + ) + return _generate_timecode_list(scene_list, num_images_per_scene, self._frame_margin) def resize_image( self, @@ -352,7 +410,7 @@ def resize_image( def save_images( scene_list: SceneList, video: VideoStream, - num_images: int = 3, + num_images: int | None = None, frame_margin: TimecodeLike = 1, image_extension: str = "jpg", encoder_param: int = 95, @@ -364,6 +422,9 @@ def save_images( width: int | None = None, interpolation: Interpolation = Interpolation.CUBIC, threading: bool = True, + interval: TimecodeLike | None = None, + min_images: int = 1, + max_images: int | None = None, ) -> dict[int, list[str]]: """Save a set number of images from each scene, given a list of scenes and the associated video/frame source. @@ -373,7 +434,8 @@ def save_images( from calling a SceneManager's detect_scenes() method. video: A VideoStream object corresponding to the scene list. Note that the video will be closed/re-opened and seeked through. - num_images: Number of images to generate for each scene. Minimum is 1. + num_images: Number of images to generate for each scene. Minimum is 1. Defaults to 3 + if `interval` is not set. Cannot be used with `interval`. frame_margin: Padding around the beginning and end of each scene used when selecting which frames to extract. Accepts an int (frames), float (seconds), or str (e.g. ``"0.1s"``, ``"00:00:00.100"``). Can be 0, but some video files @@ -402,6 +464,14 @@ def save_images( while preserving the aspect ratio. interpolation: Type of interpolation to use when resizing images. threading: Offload image encoding and disk IO to background threads to improve performance. + interval: If set, the number of images for each scene depends on its length instead of + being `num_images`: each scene gets ``floor(scene length / interval) + 1`` images, + spread over the scene the same way. Accepts the same values as `frame_margin`. + Cannot be used with `num_images`. + min_images: Minimum number of images to generate for each scene with `interval`. + Minimum is 1. Can only be set with `interval`. + max_images: Maximum number of images to generate for each scene with `interval`, or + None for no limit. Can only be set with `interval`. Returns: Dictionary of the format { scene_num : [image_paths] }, where scene_num is the @@ -415,7 +485,23 @@ def save_images( if not scene_list: return {} - if num_images <= 0: + if interval is not None: + if num_images is not None: + raise ValueError("num_images and interval cannot both be set") + frame_rate = scene_list[0][0].frame_rate + assert frame_rate is not None + if FrameTimecode(timecode=interval, fps=frame_rate).seconds <= 0: + raise ValueError("interval must be greater than 0") + if min_images <= 0: + raise ValueError("min_images must be greater than 0") + if max_images is not None and max_images < min_images: + raise ValueError("max_images must be greater than or equal to min_images") + else: + if min_images != 1 or max_images is not None: + raise ValueError("min_images and max_images can only be used with interval") + if num_images is None: + num_images = 3 + if num_images is not None and num_images <= 0: raise ValueError("num_images must be greater than 0") if isinstance(frame_margin, (int, float)) and frame_margin < 0: raise ValueError("frame_margin must be non-negative") @@ -440,27 +526,36 @@ def save_images( height, width, interpolation, + interval, + min_images, + max_images, ) return extractor.run(video, scene_list, output_dir, bool(show_progress)) + timecode_list = _generate_timecode_list( + scene_list, + _get_num_images(scene_list, num_images, interval, min_images, max_images), + frame_margin, + ) + total_images = sum(len(timecodes) for timecodes in timecode_list) + max_scene_images = max(len(timecodes) for timecodes in timecode_list) + # Setup flags and init progress bar if available. completed = True logger.info( - f"Saving {num_images} images per scene [format={image_extension}]" - f" {output_dir if output_dir else ''} " + f"Saving {_describe_num_images(num_images, interval, total_images)}" + f" [format={image_extension}] {output_dir if output_dir else ''} " ) progress_bar = None if show_progress: - progress_bar = tqdm(total=len(scene_list) * num_images, unit="images", dynamic_ncols=True) + progress_bar = tqdm(total=total_images, unit="images", dynamic_ncols=True) filename_template = Template(image_name_template) scene_num_format = "%0" scene_num_format += str(max(3, math.floor(math.log(len(scene_list), 10)) + 1)) + "d" image_num_format = "%0" - image_num_format += str(math.floor(math.log(num_images, 10)) + 2) + "d" - - timecode_list = _generate_timecode_list(scene_list, num_images, frame_margin) + image_num_format += str(math.floor(math.log(max_scene_images, 10)) + 2) + "d" image_filenames = {i: [] for i in range(len(timecode_list))} aspect_ratio = video.aspect_ratio diff --git a/tests/test_cli.py b/tests/test_cli.py index b96661c1..50106f53 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -36,6 +36,7 @@ import pytest import scenedetect +from scenedetect._cli.config import ConfigLoadFailure, ConfigRegistry from scenedetect.output import is_ffmpeg_available, is_mkvmerge_available from scenedetect.platform import StrPath from tests.helpers import invoke_cli @@ -668,6 +669,171 @@ def test_cli_save_images_rotation(rotated_video_file, tmp_path: Path): assert image.shape == (1280, 544, 3) +_TIME_AND_DETECTOR = ["time", *DEFAULT_TIME.split(), DEFAULT_DETECTOR] + + +def _count_images_per_scene(path: Path) -> list[int]: + return [ + len(list(path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-{scene + 1:03d}-*.jpg"))) + for scene in range(DEFAULT_NUM_SCENES) + ] + + +def _invoke_save_images( + tmp_path: Path, args: list[str], config: str | None = None +) -> tuple[int, str]: + """Run `save-images` with `args` in-process, using `config` as the [save-images] section of + the config file.""" + config_path = tmp_path / "config.cfg" + config_path.write_text(f"[save-images]\n{config or ''}\n") + return invoke_cli( + [ + "-c", + str(config_path), + "-i", + DEFAULT_VIDEO_PATH, + "-o", + str(tmp_path), + *_TIME_AND_DETECTOR, + "save-images", + *args, + ] + ) + + +@pytest.mark.parametrize( + "args,expected", + [ + # The two scenes are 1.752s and 2.252s long: floor(length / 1s) + 1 images each. + (["-i", "1s"], [2, 3]), + (["-i", "1.5s"], [2, 2]), + (["--interval", "00:00:01.000"], [2, 3]), + # DURATION in frames (24 frames is about 1s here). + (["-i", "24"], [2, 3]), + # --min-images and --max-images limit the number of images per scene. + (["-i", "1s", "--min-images", "3"], [3, 3]), + (["-i", "0.5s", "--max-images", "4"], [4, 4]), + (["-i", "0.2s", "--min-images", "10", "--max-images", "11"], [10, 11]), + (["-i", "0.5s", "--max-images", "0"], [4, 5]), + ], +) +def test_cli_save_images_interval(tmp_path: Path, args: list[str], expected: list[int]): + """Test `save-images -i/--interval` with --min-images/--max-images.""" + exit_code, _ = _invoke_save_images(tmp_path, args) + assert exit_code == 0 + assert _count_images_per_scene(tmp_path) == expected + + +@pytest.mark.parametrize( + "args,error", + [ + (["-n", "3", "-i", "1s"], "Only one of -n/--num-images or -i/--interval can be set."), + ( + ["--min-images", "2"], + "--min-images and --max-images can only be used with -i/--interval.", + ), + ( + ["-n", "3", "--max-images", "2"], + "--min-images and --max-images can only be used with -i/--interval.", + ), + ( + ["-i", "1s", "--min-images", "3", "--max-images", "2"], + "max-images must be 0 (no limit) or at least min-images.", + ), + (["-i", "1s", "--min-images", "0"], "0 is not in the range x>=1."), + (["-i", "1s", "--max-images", "-1"], "-1 is not in the range x>=0."), + ( + ["-i", "abc"], + "Invalid value for -i/--interval: Interval must be in frames (24), seconds (1.5s), or HH:MM:SS.", + ), + (["-i", "0"], "Invalid value for -i/--interval: Interval must be greater than 0."), + (["-i", "0s"], "Invalid value for -i/--interval: Interval must be greater than 0."), + ], +) +def test_cli_save_images_interval_rejects_invalid_values( + tmp_path: Path, args: list[str], error: str +): + exit_code, output = _invoke_save_images(tmp_path, args) + assert exit_code != 0 + assert error in output + assert not list(tmp_path.glob("*.jpg")) + + +@pytest.mark.parametrize( + "config,args,expected", + [ + ("interval = 1s", [], [2, 3]), + ("interval = 1s\nmin-images = 3", [], [3, 3]), + ("interval = 0.5s\nmax-images = 4", [], [4, 4]), + # Options on the command line take precedence over the config file. + ("interval = 2s\nmax-images = 2", ["-i", "0.5s"], [2, 2]), + ("interval = 0.5s\nmax-images = 4", ["--max-images", "2"], [2, 2]), + ("interval = 0.5s\nmax-images = 4", ["--max-images", "0"], [4, 5]), + ("interval = 2s", ["--min-images", "3"], [3, 3]), + ("num-images = 5", ["-i", "1s"], [2, 3]), + # Without an interval in effect, min-images and max-images in the config file are ignored. + ("interval = 1s\nmin-images = 5\nmax-images = 5", ["-n", "2"], [2, 2]), + ("num-images = 2\nmin-images = 5", [], [2, 2]), + ], +) +def test_cli_save_images_interval_config_file( + tmp_path: Path, config: str, args: list[str], expected: list[int] +): + """Test the `interval`, `min-images` and `max-images` options of `[save-images]`.""" + exit_code, _ = _invoke_save_images(tmp_path, args, config=config) + assert exit_code == 0 + assert _count_images_per_scene(tmp_path) == expected + + +@pytest.mark.parametrize( + "config,args,error", + [ + ( + "num-images = 3\ninterval = 1s", + [], + "Only one of num-images or interval can be set in the config file.", + ), + ( + "num-images = 3", + ["--max-images", "2"], + "--min-images and --max-images can only be used with -i/--interval.", + ), + ( + "interval = 1s\nmin-images = 3\nmax-images = 2", + [], + "max-images must be 0 (no limit) or at least min-images.", + ), + ("interval = 0", [], "interval in the config file must be greater than 0."), + ("interval = 0s", [], "interval in the config file must be greater than 0."), + ], +) +def test_cli_save_images_interval_config_file_rejects_invalid_values( + tmp_path: Path, config: str, args: list[str], error: str +): + exit_code, output = _invoke_save_images(tmp_path, args, config=config) + assert exit_code != 0 + assert error in output + assert not list(tmp_path.glob("*.jpg")) + + +@pytest.mark.parametrize( + "config,error", + [ + ("interval = abc", "Timecodes must be in seconds (100.0), frames (100), or HH:MM:SS."), + ("min-images = 0", "Value must be an integer of at least 1."), + ("min-images = 1.5", "Value must be an integer of at least 1."), + ("min-images = x", "Value must be an integer of at least 1."), + ("max-images = -1", "Value must be an integer of at least 0."), + ], +) +def test_cli_save_images_interval_config_file_load_errors(tmp_path: Path, config: str, error: str): + config_path = tmp_path.joinpath("config.cfg") + config_path.write_text(f"[save-images]\n{config}\n") + with pytest.raises(ConfigLoadFailure) as ex: + ConfigRegistry(str(config_path)) + assert any(error in message for _, message in ex.value.init_log) + + def test_cli_save_html(tmp_path: Path): """Test `save-html` command.""" base_command = "-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} {COMMAND}" diff --git a/tests/test_output.py b/tests/test_output.py index da7a23d2..78b957a7 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -237,6 +237,73 @@ def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) +@pytest.mark.parametrize("threading", [True, False]) +@pytest.mark.parametrize( + "interval,min_images,max_images,expected", + [ + # Scenes are 100, 10, and 60 frames long: floor(length / 25) + 1 images each. + (25, 1, None, [5, 1, 3]), + ("25", 1, None, [5, 1, 3]), + (25, 2, 4, [4, 2, 3]), + # 1 second is 29.97 frames. + ("1s", 1, None, [4, 1, 3]), + (1.0, 1, None, [4, 1, 3]), + ], +) +def test_save_images_interval( + test_video_file, tmp_path: Path, threading, interval, min_images, max_images, expected +): + """With `interval`, the number of images saved for each scene depends on its length.""" + video = VideoStreamCv2(test_video_file) + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 210), (300, 360)] + ] + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + interval=interval, + min_images=min_images, + max_images=max_images, + image_name_template="scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER", + threading=threading, + ) + assert [len(image_filenames[scene]) for scene in sorted(image_filenames)] == expected + for paths in image_filenames.values(): + for path in paths: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + assert sum(expected) == len(list(tmp_path.glob("scenedetect.tempfile.*.jpg"))) + + +@pytest.mark.parametrize( + "kwargs,error", + [ + ({"num_images": 3, "interval": 25}, "num_images and interval cannot both be set"), + ({"interval": 0}, "interval must be greater than 0"), + ({"interval": "0s"}, "interval must be greater than 0"), + ({"interval": 25, "min_images": 0}, "min_images must be greater than 0"), + ( + {"interval": 25, "min_images": 3, "max_images": 2}, + "max_images must be greater than or equal to min_images", + ), + ( + {"num_images": 3, "max_images": 2}, + "min_images and max_images can only be used with interval", + ), + ({"min_images": 2}, "min_images and max_images can only be used with interval"), + ], +) +def test_save_images_interval_rejects_invalid_arguments( + test_video_file, tmp_path: Path, kwargs, error +): + video = VideoStreamCv2(test_video_file) + scene_list = [(FrameTimecode(0, video.frame_rate), FrameTimecode(10, video.frame_rate))] + with pytest.raises(ValueError, match=error): + save_images(scene_list=scene_list, output_dir=tmp_path, video=video, **kwargs) + + # # Scene-list export API (EDL / FCPXML / FCP7 xmeml / OTIO) # diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 57aa57c9..2ab909e9 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -5,6 +5,7 @@ ### Unreleased + - [feature] Add `-i`/`--interval` to `save-images` to save images based on the length of each scene: one image per `DURATION`, with `--min-images`/`--max-images` to limit the number of images per scene, e.g. `save-images -i 2s --max-images 10` (`interval`/`min-images`/`max-images` in the config file, and `interval`/`min_images`/`max_images` in `save_images()`) [#280](https://github.com/Breakthrough/PySceneDetect/issues/280) - [bugfix] `$VIDEO_NAME` is now derived from the path component of URL inputs, ignoring the query string and fragment (e.g. `https://host/clip.mp4?token=1.2` -> `clip`). URLs with no path use the host. Previously the query string leaked into output filenames, which fails on Windows. [#197](https://github.com/Breakthrough/PySceneDetect/issues/197) - [bugfix] OTIO exports use the full source duration when provided, including from the CLI, instead of declaring a fixed 1,980-frame duration. The available range remains unspecified when the duration is unknown. Scene source ranges are unchanged. [#580](https://github.com/Breakthrough/PySceneDetect/pull/580) - [bugfix] The OpenCV backend keeps `position` on the last frame after reaching the end of a VFR video, so the last scene no longer ends early (or before it starts) [#561](https://github.com/Breakthrough/PySceneDetect/issues/561)