|
| 1 | +# |
| 2 | +# PySceneDetect: Python-Based Video Scene Detector |
| 3 | +# ------------------------------------------------------------------- |
| 4 | +# [ Site: https://scenedetect.com ] |
| 5 | +# [ Docs: https://scenedetect.com/docs/ ] |
| 6 | +# [ Github: https://github.com/Breakthrough/PySceneDetect/ ] |
| 7 | +# |
| 8 | +# Copyright (C) 2014-2024 Brandon Castellano <http://www.bcastell.com>. |
| 9 | +# PySceneDetect is licensed under the BSD 3-Clause License; see the |
| 10 | +# included LICENSE file, or visit one of the above pages for details. |
| 11 | +# |
| 12 | +""" |
| 13 | +:class:`ColorDetector` detects when a frame is mostly a single color, |
| 14 | +e.g. a transition to a solid blue or any other color, emitting a new scene cut event. |
| 15 | +""" |
| 16 | + |
| 17 | +import typing as ty |
| 18 | +from collections import deque |
| 19 | + |
| 20 | +import cv2 |
| 21 | +import numpy |
| 22 | + |
| 23 | +from scenedetect.common import FrameTimecode |
| 24 | +from scenedetect.detector import SceneDetector |
| 25 | + |
| 26 | + |
| 27 | +class ColorDetector(SceneDetector): |
| 28 | + """Detects frames that are mostly a single color, with consistent color across subsequent frames. |
| 29 | +
|
| 30 | + A scene cut is triggered if: |
| 31 | + 1. The standard deviation of all color channels (HSV) falls below a certain threshold. |
| 32 | + 2. A minimum percentage of pixels match the dominant color. |
| 33 | + 3. The dominant color remains consistent across `min_scene_len` consecutive frames, |
| 34 | + within a specified `color_tolerance`. |
| 35 | + """ |
| 36 | + |
| 37 | + FRAME_SCORE_KEY = "color_val" |
| 38 | + """Key in statsfile representing the final frame score.""" |
| 39 | + |
| 40 | + METRIC_KEYS = [FRAME_SCORE_KEY] |
| 41 | + """All statsfile keys this detector produces.""" |
| 42 | + |
| 43 | + def __init__( |
| 44 | + self, |
| 45 | + threshold: float = 10.0, |
| 46 | + min_percentage: float = 0.95, |
| 47 | + min_scene_len: int = 15, |
| 48 | + color_tolerance: float = 20.0, |
| 49 | + ): |
| 50 | + """ |
| 51 | + Arguments: |
| 52 | + threshold: Maximum allowed standard deviation across all HSV channels for a frame |
| 53 | + to be considered a single color. Lower values mean stricter detection. |
| 54 | + min_percentage: Minimum percentage of pixels that must be within the dominant |
| 55 | + color range for a frame to be considered a single color. |
| 56 | + min_scene_len: The minimum number of consecutive frames that must meet the |
| 57 | + single color and color consistency criteria to trigger a scene cut. |
| 58 | + color_tolerance: The maximum Euclidean distance between the mean HSV values of |
| 59 | + the current frame and the rolling average of previous single-color frames |
| 60 | + to consider the color consistent. |
| 61 | + """ |
| 62 | + super().__init__() |
| 63 | + self._threshold: float = threshold |
| 64 | + self._min_percentage: float = min_percentage |
| 65 | + self._min_scene_len: int = min_scene_len |
| 66 | + self._color_tolerance: float = color_tolerance |
| 67 | + self._frames_in_color_state: int = 0 |
| 68 | + self._last_cut_frame: FrameTimecode | None = None |
| 69 | + self._frame_score: ty.Optional[float] = None |
| 70 | + self._color_history: deque[numpy.ndarray] = deque(maxlen=min_scene_len) |
| 71 | + |
| 72 | + def get_metrics(self) -> ty.List[str]: |
| 73 | + return ColorDetector.METRIC_KEYS |
| 74 | + |
| 75 | + def _calculate_frame_score(self, frame_img: numpy.ndarray) -> ty.Tuple[float, numpy.ndarray]: |
| 76 | + """Calculate score representing how 'single-colored' the frame is, and its mean HSV.""" |
| 77 | + |
| 78 | + # Convert image into HSV colorspace. |
| 79 | + hsv = cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV) |
| 80 | + |
| 81 | + # Calculate standard deviation for each channel |
| 82 | + h_std = numpy.std(hsv[:, :, 0]) |
| 83 | + s_std = numpy.std(hsv[:, :, 1]) |
| 84 | + v_std = numpy.std(hsv[:, :, 2]) |
| 85 | + |
| 86 | + # The frame score can be the maximum standard deviation of the channels. |
| 87 | + # A lower score indicates a more uniform color. |
| 88 | + frame_score = max(h_std, s_std, v_std) |
| 89 | + |
| 90 | + # Calculate mean HSV for color consistency check |
| 91 | + mean_hsv = numpy.mean(hsv.reshape(-1, 3), axis=0) |
| 92 | + |
| 93 | + return frame_score, mean_hsv |
| 94 | + |
| 95 | + def process_frame( |
| 96 | + self, timecode: FrameTimecode, frame_img: numpy.ndarray |
| 97 | + ) -> ty.List[FrameTimecode]: |
| 98 | + """Process the next frame. `timecode` is assumed to be sequential.""" |
| 99 | + self._frame_score, current_mean_hsv = self._calculate_frame_score(frame_img) |
| 100 | + |
| 101 | + if self.stats_manager is not None: |
| 102 | + self.stats_manager.set_metrics( |
| 103 | + timecode, {self.FRAME_SCORE_KEY: self._frame_score} |
| 104 | + ) |
| 105 | + |
| 106 | + cuts = [] |
| 107 | + is_single_color_candidate = False |
| 108 | + if self._frame_score <= self._threshold: |
| 109 | + # Check if a significant percentage of pixels are within a narrow color range |
| 110 | + hsv = cv2.cvtColor(frame_img, cv2.COLOR_BGR2HSV) |
| 111 | + # Flatten the HSV image to easily count pixel values |
| 112 | + flat_hsv = hsv.reshape(-1, 3) |
| 113 | + |
| 114 | + # Define a tolerance for each channel (e.g., 10 for H, 20 for S, 20 for V) |
| 115 | + h_tolerance = 10 |
| 116 | + s_tolerance = 20 |
| 117 | + v_tolerance = 20 |
| 118 | + |
| 119 | + # Count pixels within the tolerance range of the mean HSV |
| 120 | + pixels_in_range = numpy.sum( |
| 121 | + (flat_hsv[:, 0] >= current_mean_hsv[0] - h_tolerance) & |
| 122 | + (flat_hsv[:, 0] <= current_mean_hsv[0] + h_tolerance) & |
| 123 | + (flat_hsv[:, 1] >= current_mean_hsv[1] - s_tolerance) & |
| 124 | + (flat_hsv[:, 1] <= current_mean_hsv[1] + s_tolerance) & |
| 125 | + (flat_hsv[:, 2] >= current_mean_hsv[2] - v_tolerance) & |
| 126 | + (flat_hsv[:, 2] <= current_mean_hsv[2] + v_tolerance) |
| 127 | + ) |
| 128 | + percentage_in_range = pixels_in_range / flat_hsv.shape[0] |
| 129 | + |
| 130 | + if percentage_in_range >= self._min_percentage: |
| 131 | + is_single_color_candidate = True |
| 132 | + |
| 133 | + if is_single_color_candidate: |
| 134 | + if not self._color_history or self._is_color_consistent(current_mean_hsv): |
| 135 | + self._color_history.append(current_mean_hsv) |
| 136 | + self._frames_in_color_state += 1 |
| 137 | + if self._frames_in_color_state == self._min_scene_len: |
| 138 | + # Emit a cut at the start of this sequence |
| 139 | + cuts.append(timecode - (self._min_scene_len - 1)) |
| 140 | + else: |
| 141 | + # Color changed too much, reset sequence |
| 142 | + self._frames_in_color_state = 0 |
| 143 | + self._color_history.clear() |
| 144 | + else: |
| 145 | + # Not a single color candidate, reset sequence |
| 146 | + self._frames_in_color_state = 0 |
| 147 | + self._color_history.clear() |
| 148 | + |
| 149 | + self._last_cut_frame = timecode |
| 150 | + return cuts |
| 151 | + |
| 152 | + def _is_color_consistent(self, current_mean_hsv: numpy.ndarray) -> bool: |
| 153 | + """Checks if the current frame's dominant color is consistent with the history.""" |
| 154 | + if not self._color_history: |
| 155 | + return True # No history yet, so it's consistent by default |
| 156 | + |
| 157 | + # Calculate the mean of the colors in history |
| 158 | + history_mean_hsv = numpy.mean(list(self._color_history), axis=0) |
| 159 | + |
| 160 | + # Calculate Euclidean distance between current mean HSV and history mean HSV |
| 161 | + distance = numpy.linalg.norm(current_mean_hsv - history_mean_hsv) |
| 162 | + return distance <= self._color_tolerance |
| 163 | + |
| 164 | + def post_process(self, frame_num: int) -> ty.List[FrameTimecode]: |
| 165 | + """Optionally post-process the list of scenes after the last frame has been processed.""" |
| 166 | + return [] |
| 167 | + |
| 168 | + @property |
| 169 | + def event_buffer_length(self) -> int: |
| 170 | + return 0 # No flash filter, so buffer length is 0 |
0 commit comments