diff --git a/scenedetect.cfg b/scenedetect.cfg index 205c614f..2cb1037b 100644 --- a/scenedetect.cfg +++ b/scenedetect.cfg @@ -220,22 +220,25 @@ # Image quality (jpeg/webp). Default is 95 for jpeg, 100 for webp #quality = 95 -# Compression amount for png images (0 to 9). Does not affect quality. +# Compression amount for png images (0 to 9). Only affects size, not quality. #compression = 3 -# Number of frames to skip at beginning/end of scene. +# Number of frames to ignore around each scene cut when selecting frames. #frame-margin = 1 -# Factor to resize images by (0.5 = half, 1.0 = same, 2.0 = double). +# Resize by scale factor (0.5 = half, 1.0 = same, 2.0 = double). #scale = 1.0 -# Override image height and/or width. Mutually exclusive with scale. +# Resize to specified height, width, or both. Mutually exclusive with scale. #height = 0 #width = 0 -# Method to use for image scaling (nearest, linear, cubic, area, lanczos4). +# Method to use for scaling (nearest, linear, cubic, area, lanczos4). #scale-method = linear +# Use separate threads for encoding and disk IO. Can improve performance. +#threading = yes + [export-html] # Filename format of created HTML file. Can use $VIDEO_NAME in the name. diff --git a/scenedetect/__init__.py b/scenedetect/__init__.py index 4066b18e..1463bc8f 100644 --- a/scenedetect/__init__.py +++ b/scenedetect/__init__.py @@ -22,43 +22,37 @@ # need to support both opencv-python and opencv-python-headless. Include some additional # context with the exception if this is the case. try: - import cv2 + import cv2 as _ except ModuleNotFoundError as ex: raise ModuleNotFoundError( "OpenCV could not be found, try installing opencv-python:\n\npip install opencv-python", name="cv2", ) from ex -import numpy as np -from scenedetect.backends import ( - AVAILABLE_BACKENDS, - VideoCaptureAdapter, - VideoStreamAv, - VideoStreamCv2, - VideoStreamMoviePy, -) +# Commonly used classes/functions exported under the `scenedetect` namespace for brevity. +# Note that order of importants is important! +from scenedetect.platform import init_logger # noqa: I001 +from scenedetect.frame_timecode import FrameTimecode +from scenedetect.video_stream import VideoStream, VideoOpenFailure +from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge +from scenedetect.scene_detector import SceneDetector from scenedetect.detectors import ( - AdaptiveDetector, ContentDetector, - HashDetector, - HistogramDetector, + AdaptiveDetector, ThresholdDetector, + HistogramDetector, + HashDetector, ) -from scenedetect.frame_timecode import FrameTimecode - -# Commonly used classes/functions exported under the `scenedetect` namespace for brevity. -from scenedetect.platform import ( # noqa: I001 - get_and_create_path, - get_cv2_imwrite_params, - init_logger, - tqdm, +from scenedetect.backends import ( + AVAILABLE_BACKENDS, + VideoStreamCv2, + VideoStreamAv, + VideoStreamMoviePy, + VideoCaptureAdapter, ) -from scenedetect.scene_detector import SceneDetector -from scenedetect.scene_manager import Interpolation, SceneList, SceneManager, save_images -from scenedetect.stats_manager import StatsFileCorrupt, StatsManager +from scenedetect.stats_manager import StatsManager, StatsFileCorrupt +from scenedetect.scene_manager import SceneManager, save_images, SceneList, CutList, Interpolation from scenedetect.video_manager import VideoManager # [DEPRECATED] DO NOT USE. -from scenedetect.video_splitter import split_video_ffmpeg, split_video_mkvmerge -from scenedetect.video_stream import VideoOpenFailure, VideoStream # Used for module identification and when printing version & about info # (e.g. calling `scenedetect version` or `scenedetect about`). diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 8298bc58..cfe5fe84 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -1494,6 +1494,7 @@ def save_images_command( "output_dir": output, "scale": scale, "show_progress": not ctx.quiet_mode, + "threading": ctx.config.get_value("save-images", "threading"), "width": width, } ctx.add_command(cli_commands.save_images, save_images_args) diff --git a/scenedetect/_cli/commands.py b/scenedetect/_cli/commands.py index 74a65386..9f6b5d32 100644 --- a/scenedetect/_cli/commands.py +++ b/scenedetect/_cli/commands.py @@ -177,6 +177,7 @@ def save_images( height: int, width: int, interpolation: Interpolation, + threading: bool, ): """Handles the `save-images` command.""" del cuts # save-images only uses scenes. @@ -195,6 +196,7 @@ def save_images( height=height, width=width, interpolation=interpolation, + threading=threading, ) # Save the result for use by `export-html` if required. context.save_images_result = (images, output_dir) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index c53dcb7c..76327a62 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -361,6 +361,7 @@ def format(self, timecode: FrameTimecode) -> str: "quality": RangeValue(_PLACEHOLDER, min_val=0, max_val=100), "scale": 1.0, "scale-method": Interpolation.LINEAR, + "threading": True, "width": 0, }, "save-qp": { diff --git a/scenedetect/scene_manager.py b/scenedetect/scene_manager.py index 67bb5fb7..58cf6726 100644 --- a/scenedetect/scene_manager.py +++ b/scenedetect/scene_manager.py @@ -213,7 +213,7 @@ def get_scenes_from_cuts( return scene_list -# TODO(v1.0): Move post-processing functionality into separate submodule. +# TODO(#463): Move post-processing functionality into separate submodule. def write_scene_list( diff --git a/tests/test_scene_manager.py b/tests/test_scene_manager.py index 58593485..036c29e6 100644 --- a/tests/test_scene_manager.py +++ b/tests/test_scene_manager.py @@ -18,6 +18,7 @@ import glob import os import os.path +from pathlib import Path from typing import List from scenedetect.backends.opencv import VideoStreamCv2 @@ -84,7 +85,7 @@ def test_get_scene_list_start_in_scene(test_video_file): assert scene_list[0][1] == end_time -def test_save_images(test_video_file): +def test_save_images(test_video_file, tmp_path: Path): """Test scenedetect.scene_manager.save_images function.""" video = VideoStreamCv2(test_video_file) sm = SceneManager() @@ -97,66 +98,101 @@ def test_save_images(test_video_file): "$TIMESTAMP_MS.$TIMECODE" ) - try: - video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 100), (200, 300), (300, 400)] - ] + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)] + ] + + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=3, + image_extension="jpg", + image_name_template=image_name_template, + threading=False, + ) - image_filenames = save_images( - scene_list=scene_list, - video=video, - num_images=3, - image_extension="jpg", - image_name_template=image_name_template, - ) + # Ensure images got created, and the proper number got created. + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 - # Ensure images got created, and the proper number got created. - total_images = 0 - for scene_number in image_filenames: - for path in image_filenames[scene_number]: - assert os.path.exists(path), f"expected {path} to exist" - total_images += 1 + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) - assert total_images == len(glob.glob(image_name_glob)) - finally: - for path in glob.glob(image_name_glob): - os.remove(path) +def test_save_images_singlethreaded(test_video_file, tmp_path: Path): + """Test scenedetect.scene_manager.save_images function.""" + video = VideoStreamCv2(test_video_file) + sm = SceneManager() + sm.add_detector(ContentDetector()) + + image_name_glob = "scenedetect.tempfile.*.jpg" + image_name_template = ( + "scenedetect.tempfile." + "$SCENE_NUMBER.$IMAGE_NUMBER.$FRAME_NUMBER." + "$TIMESTAMP_MS.$TIMECODE" + ) + + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 100), (200, 300), (300, 400)] + ] + + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=3, + image_extension="jpg", + image_name_template=image_name_template, + threading=True, + ) + + # Ensure images got created, and the proper number got created. + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 + + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) # TODO: Test other functionality against zero width scenes. -def test_save_images_zero_width_scene(test_video_file): +def test_save_images_zero_width_scene(test_video_file, tmp_path: Path): """Test scenedetect.scene_manager.save_images guards against zero width scenes.""" video = VideoStreamCv2(test_video_file) image_name_glob = "scenedetect.tempfile.*.jpg" image_name_template = "scenedetect.tempfile.$SCENE_NUMBER.$IMAGE_NUMBER" - try: - video_fps = video.frame_rate - scene_list = [ - (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) - for start, end in [(0, 0), (1, 1), (2, 3)] - ] - NUM_IMAGES = 10 - image_filenames = save_images( - scene_list=scene_list, - video=video, - num_images=10, - image_extension="jpg", - image_name_template=image_name_template, - ) - assert len(image_filenames) == 3 - assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) - total_images = 0 - for scene_number in image_filenames: - for path in image_filenames[scene_number]: - assert os.path.exists(path), f"expected {path} to exist" - total_images += 1 - assert total_images == len(glob.glob(image_name_glob)) - finally: - for path in glob.glob(image_name_glob): - os.remove(path) + + video_fps = video.frame_rate + scene_list = [ + (FrameTimecode(start, video_fps), FrameTimecode(end, video_fps)) + for start, end in [(0, 0), (1, 1), (2, 3)] + ] + NUM_IMAGES = 10 + image_filenames = save_images( + scene_list=scene_list, + output_dir=tmp_path, + video=video, + num_images=10, + image_extension="jpg", + image_name_template=image_name_template, + ) + assert len(image_filenames) == 3 + assert all(len(image_filenames[scene]) == NUM_IMAGES for scene in image_filenames) + total_images = 0 + for scene_number in image_filenames: + for path in image_filenames[scene_number]: + assert tmp_path.joinpath(path).exists(), f"expected {path} to exist" + total_images += 1 + + assert total_images == len([path for path in tmp_path.glob(image_name_glob)]) # TODO: This would be more readable if the callbacks were defined within the test case, e.g. diff --git a/website/pages/changelog.md b/website/pages/changelog.md index 9c24b2e5..ccbc2ada 100644 --- a/website/pages/changelog.md +++ b/website/pages/changelog.md @@ -588,15 +588,18 @@ Development - [feature] Add ability to configure CSV separators for rows/columns in config file [#423](https://github.com/Breakthrough/PySceneDetect/issues/423) - [feature] Add new `--show` flag to `export-html` command to launch browser after processing [#442](https://github.com/Breakthrough/PySceneDetect/issues/442) - [general] Timecodes of the form `MM:SS[.nnn]` are now processed correctly [#443](https://github.com/Breakthrough/PySceneDetect/issues/443) - - [bugfix] Fix `save-images`/`save_images()` not working correctly with UTF-8 paths [#450](https://github.com/Breakthrough/PySceneDetect/issues/455) + - [bugfix] Fix `save-images`/`save_images()` not working correctly with UTF-8 paths [#450](https://github.com/Breakthrough/PySceneDetect/issues/450) + - [improvement] Add new `threading` option to `save-images`/`save_images()` [#456](https://github.com/Breakthrough/PySceneDetect/issues/456) + - Enabled by default, offloads image encoding and disk IO to separate threads + - Improves performance by up to 50% in some cases - [bugfix] Fix crash when using `save-images`/`save_images()` with OpenCV backend [#455](https://github.com/Breakthrough/PySceneDetect/issues/455) - [bugfix] Fix new detectors not working with `default-detector` config option - [improvement] The `export-html` command now implicitly invokes `save-images` with default parameters - - The output of the `export-html` command will always use the result of the `save-images` command that *precedes* it + - The output of the `export-html` command will always use the result of the `save-images` command that *precedes* it - [general] Updates to Windows distributions: - The MoviePy backend is now included with Windows distributions - Bundled Python interpreter is now Python 3.13 - 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` + - [api] Add `col_separator` and `row_separator` args to `write_scene_list` function in `scenedetect.scene_manager` \ No newline at end of file