From f84f2d2f7614508ee08453744acdc2e9e38a4f74 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Thu, 27 Oct 2022 01:34:47 -0400 Subject: [PATCH 01/13] Initial implementation of HistogramDetector. --- scenedetect/cli/__init__.py | 48 +++++ scenedetect/cli/config.py | 11 +- scenedetect/cli/context.py | 31 ++++ scenedetect/detectors/__init__.py | 1 + scenedetect/detectors/histogram_detector.py | 186 ++++++++++++++++++++ 5 files changed, 276 insertions(+), 1 deletion(-) create mode 100644 scenedetect/detectors/histogram_detector.py diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py index 13f1f2e7..d30f0b60 100644 --- a/scenedetect/cli/__init__.py +++ b/scenedetect/cli/__init__.py @@ -710,6 +710,53 @@ def detect_threshold_command( ) +@click.command('detect-hist') +@click.option( + '--threshold', + '-t', + metavar='VAL', + type=click.FloatRange(CONFIG_MAP['detect-hist']['threshold'].min_val, + CONFIG_MAP['detect-hist']['threshold'].max_val), + default=None, + help='Threshold value (float) that the rgb histogram difference must exceed to trigger' + ' a new scene. Refer to frame metric hist_diff in stats file.%s' % + (USER_CONFIG.get_help_string('detect-hist', 'threshold'))) +@click.option( + '--bits', + '-b', + metavar='NUM', + type=click.INT, + default=None, + help='The number of most significant figures to keep when quantizing the RGB color channels.%s' + % (USER_CONFIG.get_help_string("detect-hist", "bits"))) +@click.option( + '--min-scene-len', + '-m', + metavar='TIMECODE', + type=click.STRING, + default=None, + help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' + ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' + ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % + ('' if USER_CONFIG.is_default('detect-hist', 'min-scene-len') else USER_CONFIG.get_help_string( + 'detect-hist', 'min-scene-len'))) +@click.pass_context +def detect_hist_command(ctx: click.Context, threshold: Optional[float], bits: Optional[int], + min_scene_len: Optional[str]): + """Perform detection of scenes by comparing differences in the RGB histograms of adjacent + frames. + + Examples: + + detect-hist + + detect-hist --threshold 20000.0 + """ + assert isinstance(ctx.obj, CliContext) + + ctx.obj.handle_detect_hist(threshold=threshold, bits=bits, min_scene_len=min_scene_len) + + @click.command('export-html') @click.option( '--filename', @@ -1125,3 +1172,4 @@ def _add_cli_command(cli: click.Group, command: click.Command): _add_cli_command(scenedetect_cli, detect_content_command) _add_cli_command(scenedetect_cli, detect_threshold_command) _add_cli_command(scenedetect_cli, detect_adaptive_command) +_add_cli_command(scenedetect_cli, detect_hist_command) diff --git a/scenedetect/cli/config.py b/scenedetect/cli/config.py index f4e6a4c7..5ef322ad 100644 --- a/scenedetect/cli/config.py +++ b/scenedetect/cli/config.py @@ -57,7 +57,11 @@ def from_config(config_value: str, default: 'ValidatedValue') -> 'ValidatedValue """Validate and get the user-specified configuration option. Raises: - OptionParseFailure: Value from config file did not meet validation constraints. + Option# Log detector args for debugging before we construct it. + logger.debug( + 'Adding detector: ThresholdDetector(threshold=%f, fade_bias=%f,' + ' min_scene_len=%d, add_last_scene=%s)', threshold, fade_bias, min_scene_len, + add_last_scene)ParseFailure: Value from config file did not meet validation constraints. """ raise NotImplementedError() @@ -255,6 +259,11 @@ def from_config(config_value: str, default: 'KernelSizeValue') -> 'KernelSizeVal 'min-scene-len': TimecodeValue(0), 'threshold': RangeValue(12.0, min_val=0.0, max_val=255.0), }, + 'detect-hist': { + 'threshold': RangeValue(20000.0, min_val=0.0, max_val=float(2**32 - 1)), + 'bits': RangeValue(4, min_val=1, max_val=8), + 'min-scene-len': TimecodeValue(0) + }, 'export-html': { 'filename': '$VIDEO_NAME-Scenes.html', 'image-height': 0, diff --git a/scenedetect/cli/context.py b/scenedetect/cli/context.py index 92485110..0b743728 100644 --- a/scenedetect/cli/context.py +++ b/scenedetect/cli/context.py @@ -443,6 +443,37 @@ def handle_detect_threshold( self.options_processed = options_processed_orig + def handle_detect_hist(self, threshold: Optional[float], bits: Optional[int], + min_scene_len: Optional[str]): + """Handle `detect-hist` command options.""" + self._check_input_open() + options_processed_orig = self.options_processed + self.options_processed = False + + if self.drop_short_scenes: + min_scene_len = 0 + else: + if min_scene_len is None: + if self.config.is_default("detect-hist", "min-scene-len"): + min_scene_len = self.min_scene_len.frame_num + else: + min_scene_len = self.config.get_value("detect-hist", "min-scene-len") + min_scene_len = parse_timecode(min_scene_len, self.video_stream.frame_rate).frame_num + + threshold = self.config.get_value("detect-hist", "threshold", threshold) + bits = self.config.get_value("detect-hist", "bits", bits) + + # Log detector args for debugging before we construct it. + logger.debug( + 'Adding detector: HistogramDetector(threshold=%f, bits=%d,' + ' min_scene_len=%d)', threshold, bits, min_scene_len) + + self._add_detector( + scenedetect.detectors.HistogramDetector( + threshold=threshold, bits=bits, min_scene_len=min_scene_len)) + + self.options_processed = options_processed_orig + def handle_export_html( self, filename: Optional[AnyStr], diff --git a/scenedetect/detectors/__init__.py b/scenedetect/detectors/__init__.py index 66aa5b20..23ecafa1 100644 --- a/scenedetect/detectors/__init__.py +++ b/scenedetect/detectors/__init__.py @@ -76,6 +76,7 @@ from scenedetect.detectors.content_detector import ContentDetector from scenedetect.detectors.threshold_detector import ThresholdDetector from scenedetect.detectors.adaptive_detector import AdaptiveDetector +from scenedetect.detectors.histogram_detector import HistogramDetector # Algorithms being ported: #from scenedetect.detectors.motion_detector import MotionDetector diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py new file mode 100644 index 00000000..fcaa0aa1 --- /dev/null +++ b/scenedetect/detectors/histogram_detector.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +# +# PySceneDetect: Python-Based Video Scene Detector +# --------------------------------------------------------------- +# [ Site: http://www.scenedetect.scenedetect.com/ ] +# [ Docs: http://manual.scenedetect.scenedetect.com/ ] +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] +# +# Copyright (C) 2014-2022 Brandon Castellano . +# PySceneDetect is licensed under the BSD 3-Clause License; see the +# included LICENSE file, or visit one of the above pages for details. +# +""":py:class:`HistogramDetector` compares the difference in the RGB histograms of subsequent +frames. If the difference exceeds a given threshold, a cut is detected. + +This detector is available from the command-line as the `detect-hist` command. +""" + +from typing import List + +import numpy + +# PySceneDetect Library Imports +from scenedetect.scene_detector import SceneDetector + + +class HistogramDetector(SceneDetector): + """Compares the difference in the RGB histograms of subsequent + frames. If the difference exceeds a given threshold, a cut is detected.""" + + METRIC_KEYS = ['hist_diff'] + + def __init__(self, threshold: float = 20000.0, bits: int = 4, min_scene_len: int = 15): + """ + Arguments: + threshold: Threshold value (float) that the calculated difference between subsequent + histograms must exceed to trigger a new scene. + bits: Number of most significant bits to keep of the pixel values. Most videos and + images are 8-bit rgb (0-255) and the default is to just keep the 4 most siginificant + bits. This compresses the 3*8bit (24bit) image down to 3*4bits (12bits). This makes + quantizing the rgb histogram a bit easier and comparisons more meaningful. + min_scene_len: Minimum length of any scene. + """ + super().__init__() + self.threshold = threshold + self.bits = bits + self.min_scene_len = min_scene_len + self._hist_bins = range(2**(3 * self.bits)) + self._last_hist = None + self._last_scene_cut = None + + def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: + """First, compress the image according to the self.bits value, then build a histogram for + the input frame. Afterward, compare against the previously analyzed frame and check if the + difference is large enough to trigger a cut. + + Arguments: + frame_num: Frame number of frame that is being passed. + frame_img: Decoded frame image (numpy.ndarray) to perform scene + detection on. + + Returns: + List of frames where scene cuts have been detected. There may be 0 + or more frames in the list, and not necessarily the same as frame_num. + """ + cut_list = [] + + np_data_type = frame_img.dtype + + if np_data_type != numpy.uint8: + raise ValueError('Image must be 8-bit rgb for HistogramDetector') + + # Initialize last scene cut point at the beginning of the frames of interest. + if not self._last_scene_cut: + self._last_scene_cut = frame_num + + # Quantize the image and separate the color channels + quantized_imgs = self._quantize_frame(frame_img=frame_img, bits=self.bits) + + # Perform bit shifting operations and bitwise combine color channels into one array + composite_img = self._shift_bits(quantized_imgs=quantized_imgs, bits=self.bits) + + # Create the histogram with a bin for every rgb value + hist, _ = numpy.histogram(composite_img, bins=self._hist_bins) + + # We can only start detecting once we have a frame to compare with. + if self._last_hist is not None: + # Compute histogram difference between frames + hist_diff = numpy.sum(numpy.fabs(self._last_hist - hist)) + + # Check if a new scene should be triggered + if hist_diff >= self.threshold and ( + (frame_num - self._last_scene_cut) >= self.min_scene_len): + cut_list.append(frame_num) + self._last_scene_cut = frame_num + + # Save stats to a StatsManager if it is being used + if self.stats_manager is not None: + self.stats_manager.set_metrics(frame_num, {self.METRIC_KEYS[0]: hist_diff}) + + self._last_hist = hist + + return cut_list + + def _quantize_frame(self, frame_img, bits): + """Quantizes the image based on the number of most significant figures to be preserved. + + Arguments: + frame_img: The 8-bit rgb image of the frame being analyzed. + bits: The number of most significant bits to keep during quantization. + + Returns: + [red_img, green_img, blue_img]: + The three separated color channels of the frame image that have been quantized. + """ + # First, find the value of the number of most significant bits, padding with zeroes + bit_value = int(bin(2**bits - 1).ljust(10, '0'), 2) + + # Separate R, G, and B color channels and cast to int for easier bitwise operations + red_img = frame_img[:, :, 0].astype(int) + green_img = frame_img[:, :, 1].astype(int) + blue_img = frame_img[:, :, 2].astype(int) + + # Quantize the frame images + red_img = red_img & bit_value + green_img = green_img & bit_value + blue_img = blue_img & bit_value + + return [red_img, green_img, blue_img] + + def _shift_bits(self, quantized_imgs, bits): + """Takes care of the bit shifting operations to combine the RGB color + channels into a single array. + + Arguments: + quantized_imgs: A list of the three quantized images of the RGB color channels + respectively. + bits: The number of most significant bits to use for quantizing the image. + + Returns: + composite_img: The resulting array after all bitwise operations. + """ + # First, figure out how much each shift needs to be + blue_shift = 8 - bits + green_shift = 8 - 2 * bits + red_shift = 8 - 3 * bits + + # Separate our color channels for ease + red_img = quantized_imgs[0] + green_img = quantized_imgs[1] + blue_img = quantized_imgs[2] + + # Perform the bit shifting for each color + red_img = self._shift_images(img=red_img, img_shift=red_shift) + green_img = self._shift_images(img=green_img, img_shift=green_shift) + blue_img = self._shift_images(img=blue_img, img_shift=blue_shift) + + # Join our rgb arrays together + composite_img = numpy.bitwise_or(red_img, numpy.bitwise_or(green_img, blue_img)) + + return composite_img + + def _shift_images(self, img, img_shift): + """Do bitwise shifting operations for a color channel image checking for shift direction. + + Arguments: + img: A quantized image of a single color channel + img_shift: How many bits to shift the values of img. If the value is negative, the shift + direction is to the left and 8 is added to make it a positive value. + + Returns: + shifted_img: The bitwise shifted image. + """ + if img_shift < 0: + img_shift += 8 + shifted_img = numpy.left_shift(img, img_shift) + else: + shifted_img = numpy.right_shift(img, img_shift) + + return shifted_img + + def is_processing_required(self, frame_num: int) -> bool: + return True + + def get_metrics(self) -> List[str]: + return HistogramDetector.METRIC_KEYS From bf87afd0565d4cf377a3331e918717c4bcb3a8bd Mon Sep 17 00:00:00 2001 From: wjs018 Date: Thu, 27 Oct 2022 01:46:57 -0400 Subject: [PATCH 02/13] Added check for color channels --- scenedetect/detectors/histogram_detector.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index fcaa0aa1..3cef02e5 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -70,6 +70,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: if np_data_type != numpy.uint8: raise ValueError('Image must be 8-bit rgb for HistogramDetector') + if frame_img.shape[2] != 3: + raise ValueError('Image must have three color channels for HistogramDetector') + # Initialize last scene cut point at the beginning of the frames of interest. if not self._last_scene_cut: self._last_scene_cut = frame_num From 7dec6c52cf70205a756d7d221da2360e64269e0c Mon Sep 17 00:00:00 2001 From: wjs018 Date: Mon, 31 Oct 2022 23:04:13 -0400 Subject: [PATCH 03/13] Added tests for detect-hist. --- tests/test_cli.py | 2 +- tests/test_detectors.py | 26 ++++++++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 89bd6d54..16496dd8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,7 +43,7 @@ DEFAULT_TIME = '-s 2s -d 4s' # Seek forward a bit but limit the amount we process. DEFAULT_DETECTOR = 'detect-content' DEFAULT_CONFIG_FILE = 'scenedetect.cfg' # Ensure we default to a "blank" config file. -ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive'] +ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hist'] ALL_BACKENDS = ['opencv', 'pyav', 'moviepy'] diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 2a41d2b7..8b7619a8 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -20,7 +20,7 @@ import time from scenedetect import detect, SceneManager, FrameTimecode, StatsManager -from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector +from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector, HistogramDetector from scenedetect.backends.opencv import VideoStreamCv2 # TODO(v1.0): Parameterize these tests like VideoStreams are. @@ -87,6 +87,28 @@ def test_adaptive_detector(test_movie_clip): assert scene_list[-1][1] == end_time +def test_histogram_detector(test_movie_clip): + """ Test SceneManager with VideoStreamCv2 and HistogramDetector. """ + video = VideoStreamCv2(test_movie_clip) + scene_manager = SceneManager() + scene_manager.add_detector(HistogramDetector()) + scene_manager.auto_downscale = True + + video_fps = video.frame_rate + start_time = FrameTimecode('00:00:50', video_fps) + end_time = FrameTimecode('00:01:19', video_fps) + + video.seek(start_time) + scene_manager.detect_scenes(video=video, end_time=end_time) + + scene_list = scene_manager.get_scene_list() + assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL) + detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list] + assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames + # Ensure last scene's end timecode matches the end time we set. + assert scene_list[-1][1] == end_time + + def test_threshold_detector(test_video_file): """ Test SceneManager with VideoStreamCv2 and ThresholdDetector. """ video = VideoStreamCv2(test_video_file) @@ -103,7 +125,7 @@ def test_threshold_detector(test_video_file): def test_detectors_with_stats(test_video_file): """ Test all detectors functionality with a StatsManager. """ # TODO(v1.0): Parameterize this test case (move fixture from cli to test config). - for detector in [ContentDetector, ThresholdDetector, AdaptiveDetector]: + for detector in [ContentDetector, ThresholdDetector, AdaptiveDetector, HistogramDetector]: video = VideoStreamCv2(test_video_file) stats = StatsManager() scene_manager = SceneManager(stats_manager=stats) From a7e6b478662833d6c59cc3fc65832240d36f538c Mon Sep 17 00:00:00 2001 From: wjs018 Date: Mon, 31 Oct 2022 23:30:07 -0400 Subject: [PATCH 04/13] Added documentation for detect-hist. --- docs/reference/command-line.md | 2 +- docs/reference/detection-methods.md | 4 +++ manual/api/detectors.rst | 9 ++++++ manual/cli/detectors.rst | 44 +++++++++++++++++++++++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/reference/command-line.md b/docs/reference/command-line.md index 80d82a6e..9cb437d4 100644 --- a/docs/reference/command-line.md +++ b/docs/reference/command-line.md @@ -13,6 +13,6 @@ The `scenedetect` command reference is available as part of [the PySceneDetect M - Exporting scene list as HTML (`export-html`) - [Detector Reference](http://scenedetect.com/projects/Manual/en/latest/cli/detectors.html): - - Detectors, e.g. `detect-content`, `detect-threshold`, `detect-adaptive` + - Detectors, e.g. `detect-content`, `detect-threshold`, `detect-adaptive`, `detect-hist` You can also run `scenedetect help all` locally for the full `scenedetect command reference. diff --git a/docs/reference/detection-methods.md b/docs/reference/detection-methods.md index 95ae2777..5957129b 100644 --- a/docs/reference/detection-methods.md +++ b/docs/reference/detection-methods.md @@ -21,6 +21,10 @@ The adaptive content detector (`detect-adaptive`) compares the difference in con The threshold-based scene detector (`detect-threshold`) is how most traditional scene detection methods work (e.g. the `ffmpeg blackframe` filter), by comparing the intensity/brightness of the current frame with a set threshold, and triggering a scene cut/break when this value crosses the threshold. In PySceneDetect, this value is computed by averaging the R, G, and B values for every pixel in the frame, yielding a single floating point number representing the average pixel value (from 0.0 to 255.0). +## Histogram Detector + +The color histogram detector uses color information to detect fast cuts. The input video for this detector must be in 8-bit color. The detection algorithm consists of separating the three RGB color channels and then quantizing them by eliminating all but the given number of most significant bits (`--bits/-b`). The resulting quantized color channels are then bit shifted and joined together into a new, composite image. A histogram is then constructed from the pixel values in the new, composite image. This histogram is compared element-wise with the histogram from the previous frame and if the total difference between the two adjacent histograms exceeds the given threshold (`--threshold/-t`), then a new scene is triggered. + # Creating New Detection Algorithms All scene detection algorithms must inherit from [the base `SceneDetector` class](https://scenedetect.com/projects/Manual/en/latest/api/scene_detector.html). Note that the current SceneDetector API is under development and expected to change somewhat before v1.0 is released, so make sure to pin your `scenedetect` dependency to the correct API version (e.g. `scenedetect < 0.6`, `scenedetect < 0.7`, etc...). diff --git a/manual/api/detectors.rst b/manual/api/detectors.rst index 2ff02890..955cdca5 100644 --- a/manual/api/detectors.rst +++ b/manual/api/detectors.rst @@ -28,6 +28,15 @@ AdaptiveDetector :undoc-members: +========================================= +HistogramDetector +========================================= + +.. automodule:: scenedetect.detectors.histogram_detector + :members: + :undoc-members: + + ========================================= ThresholdDetector ========================================= diff --git a/manual/cli/detectors.rst b/manual/cli/detectors.rst index 6df39415..ffdcbe2b 100644 --- a/manual/cli/detectors.rst +++ b/manual/cli/detectors.rst @@ -190,3 +190,47 @@ Detector Options specified as exact number of frames, a time in seconds followed by s, or a timecode in the format HH:MM:SS or HH:MM:SS.nnn. + + +======================================================================= +``detect-hist`` +======================================================================= + +Perform color histogram detection algorithm on input video. + +This algorithm first separates the color channels of the video and then +quantizes each color image. The color channels are then bit-shifted and joined +together once again. A histogram is calculated for the resulting composite image +and if the element-wise difference between histograms of adjacent frames +exceeds the threshold value, a new scene is triggered. + +The input video for the ``detect-hist`` must be an 8-bit color video due to the +bit shifting calculations that are done. + +Examples: + + ``detect-hist`` + + ``detect-hist --threshold 25000.0`` + + +Detector Options +----------------------------------------------------------------------- + + -t, --threshold VAL Threshold value (float) that the calculated + histogram difference must exceed to trigger a + new scene (see frame metric hist_diff in stats + file). [default: 20000.0] + + -b, --bits VAL The number of most significant bits to retain + when quantizing video frames. A higher value + retains more color information, but increases + computational complexity. Can be in the range + [1-8] since input video must be 8-bit color. + [default: 4] + + -m, --min-scene-len TIMECODE Minimum length of any scene. Overrides global + min-scene-len (-m) setting. TIMECODE can be + specified as exact number of frames, a time in + seconds followed by s, or a timecode in the + format HH:MM:SS or HH:MM:SS.nnn. From d31d2ed39bd322467c453b099682b80b757ec944 Mon Sep 17 00:00:00 2001 From: wjs018 Date: Tue, 4 Apr 2023 10:59:07 -0400 Subject: [PATCH 05/13] Add detect-hist to test_cli --- tests/test_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 08ec4592..7dd3b3bf 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,7 +43,7 @@ DEFAULT_TIME = '-s 2s -d 4s' # Seek forward a bit but limit the amount we process. DEFAULT_DETECTOR = 'detect-content' DEFAULT_CONFIG_FILE = 'scenedetect.cfg' # Ensure we default to a "blank" config file. -ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive'] +ALL_DETECTORS = ['detect-content', 'detect-threshold', 'detect-adaptive', 'detect-hist'] ALL_BACKENDS = ['opencv', 'pyav'] From db29d6cbc4c2c4216226f28121191cabf90c76df Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 16 Apr 2024 21:03:40 -0400 Subject: [PATCH 06/13] Fix formatting --- scenedetect/detectors/histogram_detector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scenedetect/detectors/histogram_detector.py b/scenedetect/detectors/histogram_detector.py index 3cef02e5..28d00eb5 100644 --- a/scenedetect/detectors/histogram_detector.py +++ b/scenedetect/detectors/histogram_detector.py @@ -92,8 +92,8 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]: hist_diff = numpy.sum(numpy.fabs(self._last_hist - hist)) # Check if a new scene should be triggered - if hist_diff >= self.threshold and ( - (frame_num - self._last_scene_cut) >= self.min_scene_len): + if hist_diff >= self.threshold and ((frame_num - self._last_scene_cut) + >= self.min_scene_len): cut_list.append(frame_num) self._last_scene_cut = frame_num From cb39fe6aec32b49ca2538a4f5247ce6a7047ce7d Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 16 Apr 2024 21:07:49 -0400 Subject: [PATCH 07/13] Fix test_histogram_detector --- tests/test_detectors.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_detectors.py b/tests/test_detectors.py index 38aea36b..fb71ac01 100644 --- a/tests/test_detectors.py +++ b/tests/test_detectors.py @@ -50,6 +50,8 @@ def get_absolute_path(relative_path: str) -> str: # TODO: Add a test case for this in the fixtures defined below. def test_histogram_detector(test_movie_clip): """ Test SceneManager with VideoStreamCv2 and HistogramDetector. """ + TEST_MOVIE_CLIP_START_FRAMES_ACTUAL = [1199, 1226, 1260, 1281, 1334, 1365, 1590, 1697, 1871] + """Ground truth of start frame for each fast cut in `test_movie_clip`.""" video = VideoStreamCv2(test_movie_clip) scene_manager = SceneManager() scene_manager.add_detector(HistogramDetector()) From b8568e88dc75f83557944d3b082850c89e580389 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 16 Apr 2024 21:16:33 -0400 Subject: [PATCH 08/13] Move detect-hist to new location. --- scenedetect/_cli/__init__.py | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index 33ad89e3..a6d0235a 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -710,6 +710,52 @@ def detect_threshold_command( ctx.obj.add_detector(ThresholdDetector(**detector_args)) +@click.command('detect-hist') +@click.option( + '--threshold', + '-t', + metavar='VAL', + type=click.FloatRange(CONFIG_MAP['detect-hist']['threshold'].min_val, + CONFIG_MAP['detect-hist']['threshold'].max_val), + default=None, + help='Threshold value (float) that the rgb histogram difference must exceed to trigger' + ' a new scene. Refer to frame metric hist_diff in stats file.%s' % + (USER_CONFIG.get_help_string('detect-hist', 'threshold'))) +@click.option( + '--bits', + '-b', + metavar='NUM', + type=click.INT, + default=None, + help='The number of most significant figures to keep when quantizing the RGB color channels.%s' + % (USER_CONFIG.get_help_string("detect-hist", "bits"))) +@click.option( + '--min-scene-len', + '-m', + metavar='TIMECODE', + type=click.STRING, + default=None, + help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' + ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' + ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % + ('' if USER_CONFIG.is_default('detect-hist', 'min-scene-len') else USER_CONFIG.get_help_string( + 'detect-hist', 'min-scene-len'))) +@click.pass_context +def detect_hist_command(ctx: click.Context, threshold: Optional[float], bits: Optional[int], + min_scene_len: Optional[str]): + """Perform detection of scenes by comparing differences in the RGB histograms of adjacent + frames. + + Examples: + + detect-hist + + detect-hist --threshold 20000.0 + """ + assert isinstance(ctx.obj, CliContext) + ctx.obj.handle_detect_hist(threshold=threshold, bits=bits, min_scene_len=min_scene_len) + + @click.command('load-scenes', cls=_Command) @click.option( '--input', From 220a47edda0c15d17e809ceb90ef63ce7a27bd43 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 16 Apr 2024 21:16:54 -0400 Subject: [PATCH 09/13] Delete scenedetect/cli/__init__.py Moved to scenedetect/_cli/__init__.py --- scenedetect/cli/__init__.py | 1175 ----------------------------------- 1 file changed, 1175 deletions(-) delete mode 100644 scenedetect/cli/__init__.py diff --git a/scenedetect/cli/__init__.py b/scenedetect/cli/__init__.py deleted file mode 100644 index 733f626a..00000000 --- a/scenedetect/cli/__init__.py +++ /dev/null @@ -1,1175 +0,0 @@ -# -*- coding: utf-8 -*- -# -# PySceneDetect: Python-Based Video Scene Detector -# --------------------------------------------------------------- -# [ Site: http://www.scenedetect.scenedetect.com/ ] -# [ Docs: http://manual.scenedetect.scenedetect.com/ ] -# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] -# -# Copyright (C) 2014-2023 Brandon Castellano . -# PySceneDetect is licensed under the BSD 3-Clause License; see the -# included LICENSE file, or visit one of the above pages for details. -# -"""``scenedetect.cli`` Module - -This file contains the implementation of the PySceneDetect command-line interface (CLI) parser -logic for the PySceneDetect application ("business logic"), The main CLI entry-point function is -the function scenedetect_cli, which is a chained command group. - -The scenedetect.cli module coordinates first parsing all commands and their options using a -`CliContext`, finally performing scene detection by passing the `CliContext` to the -`run_scenedetect` run in `scenedetect.cli.controller`. -""" - -# Some parts of this file need word wrap to be displayed. -# pylint: disable=line-too-long - -import logging -from typing import AnyStr, Optional, Tuple - -import click - -import scenedetect -from scenedetect.backends import AVAILABLE_BACKENDS -from scenedetect.platform import get_system_version_info - -from scenedetect.cli.config import CHOICE_MAP, CONFIG_FILE_PATH, CONFIG_MAP -from scenedetect.cli.context import CliContext, USER_CONFIG -from scenedetect.cli.controller import run_scenedetect - -logger = logging.getLogger('pyscenedetect') - - -def _get_help_command_preface(command_name='scenedetect'): - """Preface/intro help message shown at the beginning of the help command.""" - return """ -The PySceneDetect command-line interface is grouped into commands which -can be combined together, each containing its own set of arguments: - - > {command_name} ([options]) [command] ([options]) ([...other command(s)...]) - -Where [command] is the name of the command, and ([options]) are the -arguments/options associated with the command, if any. Options -associated with the {command_name} command below (e.g. --input, ---framerate) must be specified before any commands. The order of -commands is not strict, but each command should only be specified once. - -Commands can also be combined, for example, running the 'detect-content' -and 'list-scenes' (specifying options for the latter): - - > {command_name} -i vid0001.mp4 detect-content list-scenes -n - -A list of all commands is printed below. Help for a particular command -can be printed by specifying 'help [command]', or 'help all' to print -the help information for every command. - -Lastly, there are several commands used for displaying application -version and copyright information (e.g. {command_name} about): - - help: Display help information (e.g. `help [command]`). - version: Display version of PySceneDetect being used. - about: Display license and copyright information. -""".format(command_name=command_name) - - -_COMMAND_DICT = [] -"""All commands registered with the CLI. Used for generating help contexts.""" - - -def _print_command_help(ctx: click.Context, command: click.Command): - """Print PySceneDetect help/usage for a given command.""" - ctx.help_option_names = [] - ctx_name = ctx.info_name - ctx.info_name = command.name - click.echo(click.style('`%s` Command' % command.name, fg='cyan')) - click.echo(click.style('----------------------------------------------------', fg='cyan')) - click.echo(command.get_help(ctx)) - click.echo('') - ctx.info_name = ctx_name - - -def _print_command_list_header() -> None: - """Print header shown before the option/command list.""" - click.echo(click.style('PySceneDetect Options & Commands', fg='green')) - click.echo(click.style('----------------------------------------------------', fg='green')) - click.echo('') - - -def _print_help_header() -> None: - """Print header shown before the help command.""" - click.echo(click.style('----------------------------------------------------', fg='yellow')) - click.echo(click.style(' PySceneDetect %s Help' % scenedetect.__version__, fg='yellow')) - click.echo(click.style('----------------------------------------------------', fg='yellow')) - - -@click.group( - chain=True, - context_settings=dict(help_option_names=['-h', '--help']), -) -@click.option( - '--input', - '-i', - multiple=False, - required=False, - metavar='VIDEO', - type=click.STRING, - help='[Required] Input video file. Also supports image sequences and URLs.', -) -@click.option( - '--output', - '-o', - multiple=False, - required=False, - metavar='DIR', - type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=True), - help='Output directory for created files (stats file, output videos, images, etc...).' - ' If not set defaults to working directory. Some commands allow overriding this value.%s' % - (USER_CONFIG.get_help_string("global", "output", show_default=False)), -) -@click.option( - '--framerate', - '-f', - metavar='FPS', - type=click.FLOAT, - default=None, - help='Force framerate, in frames/sec (e.g. -f 29.97). Disables check to ensure that all' - ' input videos have the same framerates.', -) -@click.option( - '--downscale', - '-d', - metavar='N', - type=click.INT, - default=None, - help='Integer factor to downscale frames by (e.g. 2, 3, 4...), where the frame is scaled' - ' to width/N x height/N (thus -d 1 implies no downscaling). Leave unset for automatic' - ' downscaling based on source resolution.%s' % - (USER_CONFIG.get_help_string("global", "downscale", show_default=False)), -) -@click.option( - '--frame-skip', - '-fs', - metavar='N', - type=click.INT, - default=None, - help='Skips N frames during processing (-fs 1 skips every other frame, processing 50%%' - ' of the video, -fs 2 processes 33%% of the frames, -fs 3 processes 25%%, etc...).' - ' Reduces processing speed at expense of accuracy.%s' % - USER_CONFIG.get_help_string("global", "frame-skip"), -) -@click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Minimum length of any scene. TIMECODE can be specified as exact' - ' number of frames, a time in seconds followed by s, or a timecode in the' - ' format HH:MM:SS or HH:MM:SS.nnn.%s' % USER_CONFIG.get_help_string("global", "min-scene-len"), -) -@click.option( - '--drop-short-scenes', - is_flag=True, - flag_value=True, - help='Drop scenes shorter than `min-scene-len` instead of combining them with neighbors.%s' % - (USER_CONFIG.get_help_string('global', 'drop-short-scenes')), -) -@click.option( - '--merge-last-scene', - is_flag=True, - flag_value=True, - help='Merge last scene with previous if shorter than min-scene-len.%s' % - (USER_CONFIG.get_help_string('global', 'merge-last-scene')), -) -@click.option( - '--stats', - '-s', - metavar='CSV', - type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), - help='Path to stats file (.csv) for writing frame metrics to. If the file exists, any' - ' metrics will be processed, otherwise a new file will be created. Can be used to determine' - ' optimal values for various scene detector options, and to cache frame calculations in order' - ' to speed up multiple detection runs.', -) -@click.option( - '--verbosity', - '-v', - metavar='LEVEL', - type=click.Choice(CHOICE_MAP['global']['verbosity'], False), - default=None, - help='Level of debug/info/error information to show. Must be one of: %s.' - ' Overrides `-q`/`--quiet`. Use `-v debug` for bug reports.%s' % (', '.join( - CHOICE_MAP["global"]["verbosity"]), USER_CONFIG.get_help_string("global", "verbosity")), -) -@click.option( - '--logfile', - '-l', - metavar='LOG', - type=click.Path(exists=False, file_okay=True, writable=True, resolve_path=False), - help='Path to log file for writing application logging information, mainly for debugging.' - ' Set `-v debug` as well if you are submitting a bug report. If verbosity is none, logfile' - ' is still be generated with info-level verbosity.', -) -@click.option( - '--quiet', - '-q', - is_flag=True, - flag_value=True, - help='Suppresses all output of PySceneDetect to the terminal/stdout. Equivalent to `-v none`.', -) -@click.option( - '--backend', - '-b', - metavar='BACKEND', - type=click.Choice(CHOICE_MAP["global"]["backend"]), - default=None, - help='Backend to use for video input. Backends can be configured using -c/--config. Backends' - ' available on this system: %s.%s.' % - (', '.join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")), -) -@click.option( - '--config', - '-c', - metavar='FILE', - type=click.Path(exists=True, file_okay=True, readable=True, resolve_path=False), - help='Path to config file. If not set, tries to load one from %s' % (CONFIG_FILE_PATH), -) -@click.pass_context -# pylint: disable=redefined-builtin -def scenedetect_cli( - ctx: click.Context, - input: Optional[AnyStr], - output: Optional[AnyStr], - framerate: Optional[float], - downscale: Optional[int], - frame_skip: Optional[int], - min_scene_len: Optional[str], - drop_short_scenes: bool, - merge_last_scene: bool, - stats: Optional[AnyStr], - verbosity: Optional[str], - logfile: Optional[AnyStr], - quiet: bool, - backend: Optional[str], - config: Optional[AnyStr], -): - """For example: - - scenedetect -i video.mp4 -s video.stats.csv detect-content list-scenes - - Note that the following options represent [OPTIONS] above. To list the optional - [ARGS] for a particular COMMAND, type `scenedetect help COMMAND`. You can also - combine commands (e.g. scenedetect [...] detect-content save-images --png split-video). - - - """ - assert isinstance(ctx.obj, CliContext) - ctx.call_on_close(lambda: run_scenedetect(ctx.obj)) - ctx.obj.handle_options( - input_path=input, - 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, - quiet=quiet, - logfile=logfile, - config=config, - stats=stats, - verbosity=verbosity, - ) - - -# pylint: enable=redefined-builtin - - -@click.command('help') -@click.argument( - 'command_name', - required=False, - type=click.STRING, -) -@click.pass_context -def help_command(ctx: click.Context, command_name: str): - """Print help for command (`help [command]`) or all commands (`help all`).""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.process_input_flag = False - if command_name is not None: - if command_name.lower() == 'all': - _print_help_header() - click.echo(_get_help_command_preface(ctx.parent.info_name)) - _print_command_list_header() - click.echo(ctx.parent.get_help()) - click.echo('') - for command in _COMMAND_DICT: - _print_command_help(ctx, command) - else: - command = None - for command_ref in _COMMAND_DICT: - if command_name == command_ref.name: - command = command_ref - break - if command is None: - error_strs = [ - 'unknown command.', 'List of valid commands:', - ' %s' % ', '.join([command.name for command in _COMMAND_DICT]) - ] - raise click.BadParameter('\n'.join(error_strs), param_hint='command name') - click.echo('') - _print_command_help(ctx, command) - else: - _print_help_header() - click.echo(_get_help_command_preface(ctx.parent.info_name)) - _print_command_list_header() - click.echo(ctx.parent.get_help()) - click.echo("\nType '%s help [command]' for usage/help of [command], or" % - ctx.parent.info_name) - click.echo("'%s help all' to list usage information for every command." % - (ctx.parent.info_name)) - ctx.exit() - - -@click.command('about') -@click.pass_context -def about_command(ctx: click.Context): - """Print license/copyright info.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.process_input_flag = False - click.echo('') - click.echo(click.style('----------------------------------------------------', fg='cyan')) - click.echo(click.style(' About PySceneDetect %s' % scenedetect.__version__, fg='yellow')) - click.echo(click.style('----------------------------------------------------', fg='cyan')) - click.echo(scenedetect.ABOUT_STRING) - ctx.exit() - - -@click.command('version') -@click.option( - '-a', - '--all', - 'show_all', - is_flag=True, - flag_value=True, - help='Include system and package version information. Useful for troubleshooting.') -@click.pass_context -def version_command(ctx: click.Context, show_all: bool): - """Print PySceneDetect version.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.process_input_flag = False - click.echo('') - click.echo(click.style('PySceneDetect %s' % scenedetect.__version__, fg='yellow')) - if show_all: - click.echo('') - click.echo(get_system_version_info()) - ctx.exit() - - -@click.command('time') -@click.option( - '--start', - '-s', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Time in video to begin detecting scenes. TIMECODE can be specified as exact' - ' number of frames (-s 100 to start at frame 100), time in seconds followed by s' - ' (-s 100s to start at 100 seconds), or a timecode in the format HH:MM:SS or HH:MM:SS.nnn' - ' (-s 00:01:40 to start at 1m40s).', -) -@click.option( - '--duration', - '-d', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Maximum time in video to process. TIMECODE format is the same as other' - ' arguments. Mutually exclusive with --end / -e.', -) -@click.option( - '--end', - '-e', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Time in video to end detecting scenes. TIMECODE format is the same as other' - ' arguments. Mutually exclusive with --duration / -d.', -) -@click.pass_context -def time_command( - ctx: click.Context, - start: Optional[str], - duration: Optional[str], - end: Optional[str], -): - """Set start/end/duration of input video. - - Time values can be specified as frames (NNNN), seconds (NNNN.NNs), or as - a timecode (HH:MM:SS.nnn). For example, to start scene detection at 1 minute, - and stop after 100 seconds: - - time --start 00:01:00 --duration 100s - - Note that --end and --duration are mutually exclusive (i.e. only one of the two - can be set). Lastly, the following is an example using absolute frame numbers - to process frames 0 through 1000: - - time --start 0 --end 1000 - """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_time( - start=start, - duration=duration, - end=end, - ) - - -@click.command('detect-content') -@click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-content']['threshold'].min_val, - CONFIG_MAP['detect-content']['threshold'].max_val), - default=None, - help='Threshold value that the content_val frame metric must exceed to trigger a new scene.' - ' Refers to frame metric content_val in stats file.%s' % - (USER_CONFIG.get_help_string("detect-content", "threshold")), -) -@click.option( - '--weights', - '-w', - type=(float, float, float, float), - default=None, - help='Weights of the 4 components used to calculate content_val in the form' - ' (delta_hue, delta_sat, delta_lum, delta_edges).%s' % - (USER_CONFIG.get_help_string("detect-content", "weights")), -) -@click.option( - '--luma-only', - '-l', - is_flag=True, - flag_value=True, - help='Only consider luma (brightness) channel. Useful for greyscale videos. Equivalent to' - 'setting -w/--weights to 0, 0, 1, 0.%s' % - (USER_CONFIG.get_help_string("detect-content", "luma-only")), -) -@click.option( - '--kernel-size', - '-k', - metavar='N', - type=click.INT, - default=None, - help='Size of kernel for expanding detected edges. Must be odd integer greater than or' - ' equal to 3. If unset, kernel size is estimated using video resolution.%s' % - (USER_CONFIG.get_help_string("detect-content", "kernel-size")), -) -@click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' - ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' - ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % - ('' if USER_CONFIG.is_default('detect-content', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-content', 'min-scene-len')), -) -@click.pass_context -def detect_content_command( - ctx: click.Context, - threshold: Optional[float], - weights: Optional[Tuple[float, float, float, float]], - luma_only: bool, - kernel_size: Optional[int], - min_scene_len: Optional[str], -): - """Perform content detection algorithm on input video. - -When processing each frame, a score (from 0 to 255.0) is calculated representing the difference in content from the previous frame (higher = more difference). A change in scene is triggered when this value exceeds the value set for `-t`/`--threshold`. This value is the *content_val* column in a statsfile. - -Frame scores are calculated from several components, which are used to generate a final weighted value with `-w`/`--weights`. These are also recorded in the statsfile if set. Currently there are four components: - - - *delta_hue*: Difference between pixel hue values of adjacent frames. - - - *delta_sat*: Difference between pixel saturation values of adjacent frames. - - - *delta_lum*: Difference between pixel luma (brightness) values of adjacent frames. - - - *delta_edges*: Difference between calculated edges of adjacent frames. Typically larger than other components, so threshold may need to be increased to compensate. - -Weights are set as a set of 4 numbers in the form (*delta_hue*, *delta_sat*, *delta_lum*, *delta_edges*). For example, `-w 1.0 0.5 1.0 0.2 -t 32` is a good starting point to use with edge detection. - -Edge detection is not enabled by default. Current default parameters are `-w 1.0 1.0 1.0 0.0 -t 27`. The final weighted sum is normalized based on the weight of the components, so they do not need to equal 100%. - -Examples: - - detect-content - - detect-content --threshold 27.5 - """ - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_detect_content( - threshold=threshold, - luma_only=luma_only, - min_scene_len=min_scene_len, - weights=weights, - kernel_size=kernel_size) - - -@click.command('detect-adaptive') -@click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FLOAT, - default=None, - help='Threshold value (float) that the calculated frame score must exceed to' - ' trigger a new scene (see frame metric adaptive_ratio in stats file).%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'threshold')), -) -@click.option( - '--min-content-val', - '-c', - metavar='VAL', - type=click.FLOAT, - default=None, - help='Minimum threshold (float) that the content_val must exceed in order to register as a new' - ' scene. This is calculated the same way that `detect-content` calculates frame score.%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'min-content-val')), -) -@click.option( - '--min-delta-hsv', - '-d', - metavar='VAL', - type=click.FLOAT, - default=None, - help='[DEPRECATED] Use -c/--min-content-val instead.%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'min-delta-hsv')), - hidden=True, -) -@click.option( - '--frame-window', - '-f', - metavar='VAL', - type=click.INT, - default=None, - help='Size of window (number of frames) before and after each frame to average together in' - ' order to detect deviations from the mean.%s' % - (USER_CONFIG.get_help_string('detect-adaptive', 'frame-window')), -) -@click.option( - '--weights', - '-w', - type=(float, float, float, float), - default=None, - help='Weights of the 4 components used to calculate content_val in the form' - ' (delta_hue, delta_sat, delta_lum, delta_edges).%s' % - (USER_CONFIG.get_help_string("detect-content", "weights")), -) -@click.option( - '--luma-only', - '-l', - is_flag=True, - flag_value=True, - help='Only consider luma (brightness) channel. Useful for greyscale videos. Equivalent to' - 'setting -w/--weights to 0, 0, 1, 0.%s' % - (USER_CONFIG.get_help_string("detect-content", "luma-only")), -) -@click.option( - '--kernel-size', - '-k', - metavar='N', - type=click.INT, - default=None, - help='Size of kernel for expanding detected edges. Must be odd integer greater than or' - ' equal to 3. If unset, kernel size is estimated using video resolution.%s' % - (USER_CONFIG.get_help_string("detect-content", "kernel-size")), -) -@click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' - ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' - ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % - ('' if USER_CONFIG.is_default('detect-adaptive', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-adaptive', 'min-scene-len')), -) -@click.pass_context -def detect_adaptive_command( - ctx: click.Context, - threshold: Optional[float], - min_content_val: Optional[float], - min_delta_hsv: Optional[float], - frame_window: Optional[int], - weights: Optional[Tuple[float, float, float, float]], - luma_only: bool, - kernel_size: Optional[int], - min_scene_len: Optional[str], -): - """Perform adaptive detection algorithm on input video. - -Two-pass algorithm that first calculates frame scores with `detect-content`, and then applies a rolling average when processing the result. This can help mitigate false detections in situations such as camera movement. - -Examples: - - detect-adaptive - - detect-adaptive --threshold 3.2 - """ - assert isinstance(ctx.obj, CliContext) - - ctx.obj.handle_detect_adaptive( - threshold=threshold, - min_content_val=min_content_val, - min_delta_hsv=min_delta_hsv, - frame_window=frame_window, - luma_only=luma_only, - min_scene_len=min_scene_len, - weights=weights, - kernel_size=kernel_size, - ) - - -@click.command('detect-threshold') -@click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-threshold']['threshold'].min_val, - CONFIG_MAP['detect-threshold']['threshold'].max_val), - default=None, - help='Threshold value (integer) that the delta_rgb frame metric must exceed to trigger' - ' a new scene. Refers to frame metric delta_rgb in stats file.%s' % - (USER_CONFIG.get_help_string('detect-threshold', 'threshold')), -) -@click.option( - '--fade-bias', - '-f', - metavar='PERCENT', - type=click.FloatRange(CONFIG_MAP['detect-threshold']['fade-bias'].min_val, - CONFIG_MAP['detect-threshold']['fade-bias'].max_val), - default=None, - help='Percent (%%) from -100 to 100 of timecode skew for where cuts should be placed. -100' - ' indicates the start frame, +100 indicates the end frame, and 0 is the middle of both.%s' % - (USER_CONFIG.get_help_string('detect-threshold', 'fade-bias')), -) -@click.option( - '--add-last-scene', - '-l', - is_flag=True, - flag_value=True, - help='If set, if the video ends on a fade-out, a final scene will be generated from the' - ' last fade-out position to the end of the video.%s' % - (USER_CONFIG.get_help_string('detect-threshold', 'add-last-scene')), -) -@click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' - ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' - ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % - ('' if USER_CONFIG.is_default('detect-threshold', 'min-scene-len') else - USER_CONFIG.get_help_string('detect-threshold', 'min-scene-len')), -) -@click.pass_context -def detect_threshold_command( - ctx: click.Context, - threshold: Optional[float], - fade_bias: Optional[float], - add_last_scene: bool, - min_scene_len: Optional[str], -): - """Perform threshold detection algorithm on input video. - -Detects fades in/out based on average frame pixel value compared against `-t`/`--threshold`. - -Examples: - - detect-threshold - - detect-threshold --threshold 15 - """ - assert isinstance(ctx.obj, CliContext) - - ctx.obj.handle_detect_threshold( - threshold=threshold, - fade_bias=fade_bias, - add_last_scene=add_last_scene, - min_scene_len=min_scene_len, - ) - - -@click.command('detect-hist') -@click.option( - '--threshold', - '-t', - metavar='VAL', - type=click.FloatRange(CONFIG_MAP['detect-hist']['threshold'].min_val, - CONFIG_MAP['detect-hist']['threshold'].max_val), - default=None, - help='Threshold value (float) that the rgb histogram difference must exceed to trigger' - ' a new scene. Refer to frame metric hist_diff in stats file.%s' % - (USER_CONFIG.get_help_string('detect-hist', 'threshold'))) -@click.option( - '--bits', - '-b', - metavar='NUM', - type=click.INT, - default=None, - help='The number of most significant figures to keep when quantizing the RGB color channels.%s' - % (USER_CONFIG.get_help_string("detect-hist", "bits"))) -@click.option( - '--min-scene-len', - '-m', - metavar='TIMECODE', - type=click.STRING, - default=None, - help='Minimum length of any scene. Overrides global min-scene-len (-m) setting.' - ' TIMECODE can be specified as exact number of frames, a time in seconds followed by s,' - ' or a timecode in the format HH:MM:SS or HH:MM:SS.nnn.%s' % - ('' if USER_CONFIG.is_default('detect-hist', 'min-scene-len') else USER_CONFIG.get_help_string( - 'detect-hist', 'min-scene-len'))) -@click.pass_context -def detect_hist_command(ctx: click.Context, threshold: Optional[float], bits: Optional[int], - min_scene_len: Optional[str]): - """Perform detection of scenes by comparing differences in the RGB histograms of adjacent - frames. - - Examples: - - detect-hist - - detect-hist --threshold 20000.0 - """ - assert isinstance(ctx.obj, CliContext) - - ctx.obj.handle_detect_hist(threshold=threshold, bits=bits, min_scene_len=min_scene_len) - - -@click.command('export-html') -@click.option( - '--filename', - '-f', - metavar='NAME', - default='$VIDEO_NAME-Scenes.html', - type=click.STRING, - help='Filename format to use for the scene list HTML file. You can use the' - ' $VIDEO_NAME macro in the file name. Note that you may have to wrap' - ' the format name using single quotes.%s' % - (USER_CONFIG.get_help_string('export-html', 'filename')), -) -@click.option( - '--no-images', - is_flag=True, - flag_value=True, - help='Export the scene list including or excluding the saved images.%s' % - (USER_CONFIG.get_help_string('export-html', 'no-images')), -) -@click.option( - '--image-width', - '-w', - metavar='pixels', - type=click.INT, - help='Width in pixels of the images in the resulting HTML table.%s' % - (USER_CONFIG.get_help_string('export-html', 'image-width', show_default=False)), -) -@click.option( - '--image-height', - '-h', - metavar='pixels', - type=click.INT, - help='Height in pixels of the images in the resulting HTML table.%s' % - (USER_CONFIG.get_help_string('export-html', 'image-height', show_default=False)), -) -@click.pass_context -def export_html_command( - ctx: click.Context, - filename: Optional[AnyStr], - no_images: bool, - image_width: Optional[int], - image_height: Optional[int], -): - """Export scene list to HTML file. Requires `save-images` unless --no-images is specified.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_export_html( - filename=filename, - no_images=no_images, - image_width=image_width, - image_height=image_height, - ) - - -@click.command('list-scenes') -@click.option( - '--output', - '-o', - metavar='DIR', - type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory to save videos to. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'output', show_default=False)), -) -@click.option( - '--filename', - '-f', - metavar='NAME', - default='$VIDEO_NAME-Scenes.csv', - type=click.STRING, - help='Filename format to use for the scene list CSV file. You can use the' - ' $VIDEO_NAME macro in the file name. Note that you may have to wrap' - ' the name using single quotes.%s' % (USER_CONFIG.get_help_string('list-scenes', 'filename')), -) -@click.option( - '--no-output-file', - '-n', - is_flag=True, - flag_value=True, - help='Disable writing scene list CSV file to disk. If set, -o/--output and' - ' -f/--filename are ignored.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'no-output-file')), -) -@click.option( - '--quiet', - '-q', - is_flag=True, - flag_value=True, - help='Suppresses output of the table printed by the list-scenes command.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'quiet')), -) -@click.option( - '--skip-cuts', - '-s', - is_flag=True, - flag_value=True, - help='Skips outputting the cutting list as the first row in the CSV file.' - ' Set this option if compliance with RFC 4180 is required.%s' % - (USER_CONFIG.get_help_string('list-scenes', 'skip-cuts')), -) -@click.pass_context -def list_scenes_command( - ctx: click.Context, - output: Optional[AnyStr], - filename: Optional[AnyStr], - no_output_file: bool, - quiet: bool, - skip_cuts: bool, -): - """Print scene list and outputs to a CSV file. Ddefault filename is $VIDEO_NAME-Scenes.csv.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_list_scenes( - output=output, - filename=filename, - no_output_file=no_output_file, - quiet=quiet, - skip_cuts=skip_cuts, - ) - - -@click.command('split-video') -@click.option( - '--output', - '-o', - metavar='DIR', - type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory to save videos to. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('split-video', 'output', show_default=False)), -) -@click.option( - '--filename', - '-f', - metavar='NAME', - default=None, - type=click.STRING, - help='File name format to use when saving videos (with or without extension). You can use the' - ' $VIDEO_NAME and $SCENE_NUMBER macros in the filename (e.g. $VIDEO_NAME-Part-$SCENE_NUMBER).' - ' Note that you may have to wrap the format in single quotes to avoid variable expansion.%s' % - (USER_CONFIG.get_help_string('split-video', 'filename')), -) -@click.option( - '--quiet', - '-q', - is_flag=True, - flag_value=True, - help='Hides any output from the external video splitting tool.%s' % - (USER_CONFIG.get_help_string('split-video', 'quiet')), -) -@click.option( - '--copy', - '-c', - is_flag=True, - flag_value=True, - help='Copy instead of re-encode. Much faster, but less precise. Equivalent to specifying' - ' -a "-map 0 -c:v copy -c:a copy".%s' % (USER_CONFIG.get_help_string('split-video', 'copy')), -) -@click.option( - '--high-quality', - '-hq', - is_flag=True, - flag_value=True, - help='Encode video with higher quality, overrides -f option if present.' - ' Equivalent to specifying --rate-factor 17 and --preset slow.%s' % - (USER_CONFIG.get_help_string('split-video', 'high-quality')), -) -@click.option( - '--rate-factor', - '-crf', - metavar='RATE', - default=None, - type=click.IntRange(CONFIG_MAP['split-video']['rate-factor'].min_val, - CONFIG_MAP['split-video']['rate-factor'].max_val), - help='Video encoding quality (x264 constant rate factor), from 0-100, where lower' - ' values represent better quality, with 0 indicating lossless.%s' % - (USER_CONFIG.get_help_string('split-video', 'rate-factor')), -) -@click.option( - '--preset', - '-p', - metavar='LEVEL', - default=None, - type=click.Choice(CHOICE_MAP['split-video']['preset']), - help='Video compression quality preset (x264 preset). Can be one of: ultrafast, superfast,' - ' veryfast, faster, fast, medium, slow, slower, and veryslow. Faster modes take less' - ' time to run, but the output files may be larger.%s' % - (USER_CONFIG.get_help_string('split-video', 'preset')), -) -@click.option( - '--args', - '-a', - metavar='ARGS', - type=click.STRING, - default=None, - help='Override codec arguments/options passed to FFmpeg when splitting and re-encoding' - ' scenes. Use double quotes (") around specified arguments. Must specify at least' - ' audio/video codec to use (e.g. -a "-c:v [...] -c:a [...]").%s' % - (USER_CONFIG.get_help_string('split-video', 'args')), -) -@click.option( - '--mkvmerge', - '-m', - is_flag=True, - flag_value=True, - help='Split the video using mkvmerge. Faster than re-encoding, but less precise. The output' - ' will be named $VIDEO_NAME-$SCENE_NUMBER.mkv. If set, all options other than -f/--filename,' - ' -q/--quiet and -o/--output will be ignored. Note that mkvmerge automatically appends a' - 'suffix of "-$SCENE_NUMBER".%s' % (USER_CONFIG.get_help_string('split-video', 'mkvmerge')), -) -@click.pass_context -def split_video_command( - ctx: click.Context, - output: Optional[AnyStr], - filename: Optional[AnyStr], - quiet: bool, - copy: bool, - high_quality: bool, - rate_factor: Optional[int], - preset: Optional[str], - args: Optional[str], - mkvmerge: bool, -): - """Split input video using ffmpeg or mkvmerge.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_split_video( - output=output, - filename=filename, - quiet=quiet, - copy=copy, - high_quality=high_quality, - rate_factor=rate_factor, - preset=preset, - args=args, - mkvmerge=mkvmerge, - ) - - -@click.command('save-images') -@click.option( - '--output', - '-o', - metavar='DIR', - type=click.Path(exists=False, dir_okay=True, writable=True, resolve_path=False), - help='Output directory to save images to. Overrides global option -o/--output if set.%s' % - (USER_CONFIG.get_help_string('save-images', 'output', show_default=False)), -) -@click.option( - '--filename', - '-f', - metavar='NAME', - default=None, - type=click.STRING, - help='Filename format, *without* extension, to use when saving image files. You can use the' - ' $VIDEO_NAME, $SCENE_NUMBER, $IMAGE_NUMBER, and $FRAME_NUMBER macros in the file name.' - ' Note that you may have to wrap the format in single quotes.%s' % - (USER_CONFIG.get_help_string('save-images', 'filename')), -) -@click.option( - '--num-images', - '-n', - metavar='N', - default=None, - type=click.INT, - help='Number of images to generate. Will always include start/end frame,' - ' unless N = 1, in which case the image will be the frame at the mid-point' - ' in the scene.%s' % (USER_CONFIG.get_help_string('save-images', 'num-images')), -) -@click.option( - '--jpeg', - '-j', - is_flag=True, - flag_value=True, - help='Set output format to JPEG (default).%s' % - (USER_CONFIG.get_help_string('save-images', 'format', show_default=False)), -) -@click.option( - '--webp', - '-w', - is_flag=True, - flag_value=True, - help='Set output format to WebP', -) -@click.option( - '--quality', - '-q', - metavar='Q', - default=None, - type=click.IntRange(0, 100), - help='JPEG/WebP encoding quality, from 0-100 (higher indicates better quality).' - ' For WebP, 100 indicates lossless. [default: JPEG: 95, WebP: 100]%s' % - (USER_CONFIG.get_help_string('save-images', 'quality', show_default=False)), -) -@click.option( - '--png', - '-p', - is_flag=True, - flag_value=True, - help='Set output format to PNG.', -) -@click.option( - '--compression', - '-c', - metavar='C', - default=None, - type=click.IntRange(0, 9), - help='PNG compression rate, from 0-9. Higher values produce smaller files but result' - ' in longer compression time. This setting does not affect image quality, only' - ' file size.%s' % (USER_CONFIG.get_help_string('save-images', 'compression')), -) -@click.option( - '-m', - '--frame-margin', - metavar='N', - default=None, - type=click.INT, - help='Number of frames to ignore at the beginning and end of scenes when saving images.%s' % - (USER_CONFIG.get_help_string('save-images', 'num-images')), -) -@click.option( - '--scale', - '-s', - metavar='S', - default=None, - type=click.FLOAT, - help='Optional factor by which saved images are rescaled. A scaling factor of 1 would' - ' not result in rescaling. A value <1 results in a smaller saved image, while a' - ' value >1 results in an image larger than the original. This value is ignored if' - ' either the height, -h, or width, -w, values are specified.%s' % - (USER_CONFIG.get_help_string('save-images', 'scale', show_default=False)), -) -@click.option( - '--height', - '-h', - metavar='H', - default=None, - type=click.INT, - help='Optional value for the height of the saved images. Specifying both the height' - ' and width, -w, will resize images to an exact size, regardless of aspect ratio.' - ' Specifying only height will rescale the image to that number of pixels in height' - ' while preserving the aspect ratio.%s' % - (USER_CONFIG.get_help_string('save-images', 'height', show_default=False)), -) -@click.option( - '--width', - '-w', - metavar='W', - default=None, - type=click.INT, - help='Optional value for the width of the saved images. Specifying both the width' - ' and height, -h, will resize images to an exact size, regardless of aspect ratio.' - ' Specifying only width will rescale the image to that number of pixels wide' - ' while preserving the aspect ratio.%s' % - (USER_CONFIG.get_help_string('save-images', 'width', show_default=False)), -) -@click.pass_context -def save_images_command( - ctx: click.Context, - output: Optional[AnyStr], - filename: Optional[AnyStr], - num_images: Optional[int], - jpeg: bool, - webp: bool, - quality: Optional[int], - png: bool, - compression: Optional[int], - frame_margin: Optional[int], - scale: Optional[float], - height: Optional[int], - width: Optional[int], -): - """Create images for each detected scene.""" - assert isinstance(ctx.obj, CliContext) - ctx.obj.handle_save_images( - num_images=num_images, - output=output, - filename=filename, - jpeg=jpeg, - webp=webp, - quality=quality, - png=png, - compression=compression, - frame_margin=frame_margin, - scale=scale, - height=height, - width=width, - ) - - -def _add_cli_command(cli: click.Group, command: click.Command): - """Add the given `command` to the `cli` group as well as the global `_COMMAND_DICT`.""" - cli.add_command(command) - _COMMAND_DICT.append(command) - - -# ---------------------------------------------------------------------- -# Commands Omitted From Help List -# ---------------------------------------------------------------------- - -# Info Commands -_add_cli_command(scenedetect_cli, help_command) -_add_cli_command(scenedetect_cli, version_command) -_add_cli_command(scenedetect_cli, about_command) - -# ---------------------------------------------------------------------- -# Commands Added To Help List -# ---------------------------------------------------------------------- - -# Input / Output -_add_cli_command(scenedetect_cli, time_command) -_add_cli_command(scenedetect_cli, export_html_command) -_add_cli_command(scenedetect_cli, list_scenes_command) -_add_cli_command(scenedetect_cli, save_images_command) -_add_cli_command(scenedetect_cli, split_video_command) - -# Detection Algorithms -_add_cli_command(scenedetect_cli, detect_content_command) -_add_cli_command(scenedetect_cli, detect_threshold_command) -_add_cli_command(scenedetect_cli, detect_adaptive_command) -_add_cli_command(scenedetect_cli, detect_hist_command) From 08897925c05cb4467b36d1a19ab4f8d4c705d636 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 16 Apr 2024 21:28:14 -0400 Subject: [PATCH 10/13] Add config options for detect-hist --- scenedetect/_cli/config.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 6588d909..60000467 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -275,6 +275,11 @@ def format(self, timecode: FrameTimecode) -> str: 'min-scene-len': TimecodeValue(0), 'threshold': RangeValue(12.0, min_val=0.0, max_val=255.0), }, + 'detect-hist': { + 'bits': 4, + 'min-scene-len': TimecodeValue(0), + 'threshold': 20000.0, + }, 'load-scenes': { 'start-col-name': 'Start Frame', }, From fd25a40b4a69a915914cdfd602960617a1c79251 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 16 Apr 2024 21:36:01 -0400 Subject: [PATCH 11/13] Update config.py --- scenedetect/_cli/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 60000467..02e0b53c 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -278,7 +278,7 @@ def format(self, timecode: FrameTimecode) -> str: 'detect-hist': { 'bits': 4, 'min-scene-len': TimecodeValue(0), - 'threshold': 20000.0, + 'threshold': RangeValue(20000.0, min_val=0.0, max_val=10000000000.0), }, 'load-scenes': { 'start-col-name': 'Start Frame', From 0913f56615baa1799b8c3012aca8b26883f1e8b7 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 16 Apr 2024 21:46:00 -0400 Subject: [PATCH 12/13] Update __init__.py --- scenedetect/_cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenedetect/_cli/__init__.py b/scenedetect/_cli/__init__.py index a6d0235a..ecdb1429 100644 --- a/scenedetect/_cli/__init__.py +++ b/scenedetect/_cli/__init__.py @@ -710,7 +710,7 @@ def detect_threshold_command( ctx.obj.add_detector(ThresholdDetector(**detector_args)) -@click.command('detect-hist') +@click.command('detect-hist', cls=_Command) @click.option( '--threshold', '-t', From 037016be272e865352e89692e3fbd2e74414c6b0 Mon Sep 17 00:00:00 2001 From: Brandon Castellano Date: Tue, 16 Apr 2024 21:46:51 -0400 Subject: [PATCH 13/13] Update config.py --- scenedetect/_cli/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scenedetect/_cli/config.py b/scenedetect/_cli/config.py index 02e0b53c..2f72e9ca 100644 --- a/scenedetect/_cli/config.py +++ b/scenedetect/_cli/config.py @@ -278,7 +278,7 @@ def format(self, timecode: FrameTimecode) -> str: 'detect-hist': { 'bits': 4, 'min-scene-len': TimecodeValue(0), - 'threshold': RangeValue(20000.0, min_val=0.0, max_val=10000000000.0), + 'threshold': RangeValue(20000.0, min_val=0.0, max_val=10000000000.0), }, 'load-scenes': { 'start-col-name': 'Start Frame',