Skip to content

Commit 93c5922

Browse files
committed
[detectors] Add ColorDetector to detect long runs of solid color frames
1 parent 85d79b6 commit 93c5922

File tree

1 file changed

+170
-0
lines changed

1 file changed

+170
-0
lines changed
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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

Comments
 (0)