Skip to content

Commit e8c59ad

Browse files
committed
[detectors] Integrate FlashFilter with AdaptiveDetector #35
1 parent 894297d commit e8c59ad

File tree

7 files changed

+123
-126
lines changed

7 files changed

+123
-126
lines changed

scenedetect/_cli/config.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,11 @@
3333

3434
VALID_PYAV_THREAD_MODES = ['NONE', 'SLICE', 'FRAME', 'AUTO']
3535

36+
DEPRECATED_CONFIG_OPTIONS = {
37+
"global": {"drop-short-scenes"},
38+
"detect-adaptive": {"min-delta-hsv"},
39+
}
40+
3641

3742
class OptionParseFailure(Exception):
3843
"""Raised when a value provided in a user config file fails validation."""
@@ -251,8 +256,7 @@ class FlashFilterMode(Enum):
251256
DEFAULT_JPG_QUALITY = 95
252257
DEFAULT_WEBP_QUALITY = 100
253258

254-
# TODO(v0.6.4): Warn if [detect-adaptive] min-delta-hsv and [global] drop-short-scenes are used.
255-
# TODO(v0.7): Remove [detect-adaptive] min-delta-hsv and [global] drop-short-scenes
259+
# TODO(v0.7): Remove deprecated [detect-adaptive] min-delta-hsv and [global] drop-short-scenes
256260
CONFIG_MAP: ConfigDict = {
257261
'backend-opencv': {
258262
'max-decode-attempts': 5,
@@ -543,6 +547,14 @@ def _load_from_disk(self, path=None):
543547
for log_str in errors:
544548
self._init_log.append((logging.ERROR, log_str))
545549
raise ConfigLoadFailure(self._init_log)
550+
for command in self._config:
551+
for option in self._config[command]:
552+
if (command in DEPRECATED_CONFIG_OPTIONS
553+
and option in DEPRECATED_CONFIG_OPTIONS[command]):
554+
self._init_log.append(
555+
(logging.WARNING, "WARNING: Config file contains deprecated option:\n "
556+
f"[{command}] {option} will be removed in a future version."))
557+
pass
546558

547559
def is_default(self, command: str, option: str) -> bool:
548560
"""True if specified config option is unset (i.e. the default), False otherwise."""

scenedetect/_cli/context.py

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,13 @@ def handle_options(
275275
if drop_short_scenes:
276276
logger.warning(
277277
"WARNING: --drop-short-scenes is deprecated, use --filter-mode=drop instead.")
278-
if filter_mode is None:
279-
self.filter_mode = FlashFilterMode.DROP
278+
if self.config.get_value("global", "drop-short-scenes", drop_short_scenes):
279+
logger.info("drop-short-scenes set, overriding filter-mode")
280+
self.filter_mode = FlashFilterMode.DROP
280281
else:
281282
self.filter_mode = FlashFilterMode[self.config.get_value("global", "filter-mode",
282283
filter_mode).upper()]
284+
283285
self.merge_last_scene = merge_last_scene or self.config.get_value(
284286
"global", "merge-last-scene")
285287
self.frame_skip = self.config.get_value("global", "frame-skip", frame_skip)
@@ -360,13 +362,13 @@ def get_detect_adaptive_params(
360362

361363
# TODO(v0.7): Remove these branches when removing -d/--min-delta-hsv.
362364
if min_delta_hsv is not None:
363-
logger.error('-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.')
365+
logger.error("-d/--min-delta-hsv is deprecated, use -c/--min-content-val instead.")
364366
if min_content_val is None:
365367
min_content_val = min_delta_hsv
366368
# Handle case where deprecated min-delta-hsv is set, and use it to set min-content-val.
367369
if not self.config.is_default("detect-adaptive", "min-delta-hsv"):
368-
logger.error('[detect-adaptive] config file option `min-delta-hsv` is deprecated'
369-
', use `min-delta-hsv` instead.')
370+
logger.error("[detect-adaptive] config file option `min-delta-hsv` is deprecated"
371+
", use `min-delta-hsv` instead.")
370372
if self.config.is_default("detect-adaptive", "min-content-val"):
371373
self.config.config_dict["detect-adaptive"]["min-content-val"] = (
372374
self.config.config_dict["detect-adaptive"]["min-deleta-hsv"])
@@ -379,21 +381,21 @@ def get_detect_adaptive_params(
379381
weights = ContentDetector.Components(*weights)
380382
except ValueError as ex:
381383
logger.debug(str(ex))
382-
raise click.BadParameter(str(ex), param_hint='weights')
384+
raise click.BadParameter(str(ex), param_hint="weights")
383385
return {
384-
'adaptive_threshold':
386+
"adaptive_threshold":
385387
self.config.get_value("detect-adaptive", "threshold", threshold),
386-
'weights':
387-
self.config.get_value("detect-adaptive", "weights", weights),
388-
'kernel_size':
388+
"flash_filter":
389+
self._init_flash_filter("detect-content", min_scene_len),
390+
"kernel_size":
389391
self.config.get_value("detect-adaptive", "kernel-size", kernel_size),
390-
'luma_only':
392+
"luma_only":
391393
luma_only or self.config.get_value("detect-adaptive", "luma-only"),
392-
'min_content_val':
394+
"min_content_val":
393395
self.config.get_value("detect-adaptive", "min-content-val", min_content_val),
394-
'min_scene_len':
395-
min_scene_len,
396-
'window_width':
396+
"weights":
397+
self.config.get_value("detect-adaptive", "weights", weights),
398+
"window_width":
397399
self.config.get_value("detect-adaptive", "frame-window", frame_window),
398400
}
399401

scenedetect/detectors/adaptive_detector.py

Lines changed: 40 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,12 @@
1818
"""
1919

2020
from logging import getLogger
21-
from typing import List, Optional
21+
import typing as ty
2222

2323
import numpy as np
2424

2525
from scenedetect.detectors import ContentDetector
26+
from scenedetect.scene_detector import FlashFilter
2627

2728
logger = getLogger('pyscenedetect')
2829

@@ -43,15 +44,19 @@ def __init__(
4344
min_content_val: float = 15.0,
4445
weights: ContentDetector.Components = ContentDetector.DEFAULT_COMPONENT_WEIGHTS,
4546
luma_only: bool = False,
46-
kernel_size: Optional[int] = None,
47+
kernel_size: ty.Optional[int] = None,
48+
flash_filter: ty.Optional[FlashFilter] = None,
4749
video_manager=None,
48-
min_delta_hsv: Optional[float] = None,
50+
min_delta_hsv: ty.Optional[float] = None,
4951
):
5052
"""
5153
Arguments:
5254
adaptive_threshold: Threshold (float) that score ratio must exceed to trigger a
5355
new scene (see frame metric adaptive_ratio in stats file).
54-
min_scene_len: Minimum length of any scene.
56+
min_scene_len: Defines the minimum length of a given scene. Sequences of consecutive
57+
cuts that occur closer than this length will be merged. Equivalent to setting
58+
`flash_filter = FlashFilter(length=min_scene_len)`.
59+
Ignored if `flash_filter` is set.
5560
window_width: Size of window (number of frames) before and after each frame to
5661
average together in order to detect deviations from the mean. Must be at least 1.
5762
min_content_val: Minimum threshold (float) that the content_val must exceed in order to
@@ -65,8 +70,10 @@ def __init__(
6570
Overrides `weights` if both are set.
6671
kernel_size: Size of kernel to use for post edge detection filtering. If None,
6772
automatically set based on video resolution.
68-
video_manager: [DEPRECATED] DO NOT USE. For backwards compatibility only.
69-
min_delta_hsv: [DEPRECATED] DO NOT USE. Use `min_content_val` instead.
73+
flash_filter: Filter to use for scene length compliance. If None, initialized as
74+
`FlashFilter(length=min_scene_len)`. If set, `min_scene_length` is ignored.
75+
video_manager: [DEPRECATED] DO NOT USE.
76+
min_delta_hsv: [DEPRECATED] DO NOT USE.
7077
"""
7178
# TODO(v0.7): Replace with DeprecationWarning that `video_manager` and `min_delta_hsv` will
7279
# be removed in v0.8.
@@ -77,44 +84,35 @@ def __init__(
7784
min_content_val = min_delta_hsv
7885
if window_width < 1:
7986
raise ValueError('window_width must be at least 1.')
80-
8187
super().__init__(
8288
threshold=255.0,
83-
min_scene_len=0,
89+
min_scene_len=min_scene_len,
8490
weights=weights,
8591
luma_only=luma_only,
8692
kernel_size=kernel_size,
93+
flash_filter=flash_filter,
8794
)
88-
89-
# TODO: Turn these options into properties.
90-
self.min_scene_len = min_scene_len
91-
self.adaptive_threshold = adaptive_threshold
92-
self.min_content_val = min_content_val
93-
self.window_width = window_width
94-
95+
self._adaptive_threshold = adaptive_threshold
96+
self._min_content_val = min_content_val
97+
self._window_width = window_width
9598
self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format(
9699
window_width=window_width, luma_only='' if not luma_only else '_lum')
97-
self._first_frame_num = None
98-
99-
# NOTE: This must be different than `self._last_scene_cut` which is used by the base class.
100-
self._last_cut: Optional[int] = None
101-
102100
self._buffer = []
103101

104102
@property
105103
def event_buffer_length(self) -> int:
106104
"""Number of frames any detected cuts will be behind the current frame due to buffering."""
107-
return self.window_width
105+
return self._window_width
108106

109-
def get_metrics(self) -> List[str]:
107+
def get_metrics(self) -> ty.List[str]:
110108
"""Combines base ContentDetector metric keys with the AdaptiveDetector one."""
111109
return super().get_metrics() + [self._adaptive_ratio_key]
112110

113111
def stats_manager_required(self) -> bool:
114112
"""Not required for AdaptiveDetector."""
115113
return False
116114

117-
def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List[int]:
115+
def process_frame(self, frame_num: int, frame_img: ty.Optional[np.ndarray]) -> ty.List[int]:
118116
"""Process the next frame. `frame_num` is assumed to be sequential.
119117
120118
Args:
@@ -126,47 +124,33 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List
126124
List[int]: List of frames where scene cuts have been detected. There may be 0
127125
or more frames in the list, and not necessarily the same as frame_num.
128126
"""
129-
130-
# TODO(#283): Merge this with ContentDetector and turn it on by default.
131-
132-
super().process_frame(frame_num=frame_num, frame_img=frame_img)
133-
134-
# Initialize last scene cut point at the beginning of the frames of interest.
135-
if self._last_cut is None:
136-
self._last_cut = frame_num
137-
138-
required_frames = 1 + (2 * self.window_width)
139-
self._buffer.append((frame_num, self._frame_score))
127+
frame_score = self._calculate_frame_score(frame_num=frame_num, frame_img=frame_img)
128+
required_frames = 1 + (2 * self._window_width)
129+
self._buffer.append((frame_num, frame_score))
140130
if not len(self._buffer) >= required_frames:
141131
return []
142132
self._buffer = self._buffer[-required_frames:]
143-
target = self._buffer[self.window_width]
133+
target = self._buffer[self._window_width]
144134
average_window_score = (
145-
sum(frame[1] for i, frame in enumerate(self._buffer) if i != self.window_width) /
146-
(2.0 * self.window_width))
147-
135+
sum(frame[1] for i, frame in enumerate(self._buffer) if i != self._window_width) /
136+
(2.0 * self._window_width))
148137
average_is_zero = abs(average_window_score) < 0.00001
149-
150138
adaptive_ratio = 0.0
151139
if not average_is_zero:
152140
adaptive_ratio = min(target[1] / average_window_score, 255.0)
153-
elif average_is_zero and target[1] >= self.min_content_val:
141+
elif average_is_zero and target[1] >= self._min_content_val:
154142
# if we would have divided by zero, set adaptive_ratio to the max (255.0)
155143
adaptive_ratio = 255.0
156144
if self.stats_manager is not None:
157145
self.stats_manager.set_metrics(target[0], {self._adaptive_ratio_key: adaptive_ratio})
158146

159147
# Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there
160148
# being a large enough content_val to trigger a cut
161-
threshold_met: bool = (
162-
adaptive_ratio >= self.adaptive_threshold and target[1] >= self.min_content_val)
163-
min_length_met: bool = (frame_num - self._last_cut) >= self.min_scene_len
164-
if threshold_met and min_length_met:
165-
self._last_cut = target[0]
166-
return [target[0]]
167-
return []
168-
169-
def get_content_val(self, frame_num: int) -> Optional[float]:
149+
found_cut: bool = (
150+
adaptive_ratio >= self._adaptive_threshold and target[1] >= self._min_content_val)
151+
return self._flash_filter.apply(frame_num=target[0], found_cut=found_cut)
152+
153+
def get_content_val(self, frame_num: int) -> ty.Optional[float]:
170154
"""Returns the average content change for a frame."""
171155
# TODO(v0.7): Add DeprecationWarning that `get_content_val` will be removed in v0.7.
172156
logger.error("get_content_val is deprecated and will be removed. Lookup the value"
@@ -175,6 +159,10 @@ def get_content_val(self, frame_num: int) -> Optional[float]:
175159
return self.stats_manager.get_metrics(frame_num, [ContentDetector.FRAME_SCORE_KEY])[0]
176160
return 0.0
177161

178-
def post_process(self, _unused_frame_num: int):
179-
"""Not required for AdaptiveDetector."""
180-
return []
162+
def post_process(self, _frame_num: int):
163+
# Already processed frame at self._window_width, process the rest. This ensures we emit any
164+
# cuts the filtering mode might require.
165+
cuts = []
166+
for (frame_num, _) in self._buffer[self._window_width + 1:]:
167+
cuts += self._flash_filter.apply(frame_num=frame_num, found_cut=False)
168+
return cuts

scenedetect/detectors/content_detector.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,10 @@ def __init__(
122122
kernel_size: Size of kernel for expanding detected edges. Must be odd integer
123123
greater than or equal to 3. If None, automatically set using video resolution.
124124
flash_filter: Filter to use for scene length compliance. If None, initialized as
125-
`FlashFilter(length=min_scene_len)`.
125+
`FlashFilter(length=min_scene_len)`. If set, `min_scene_length` is ignored.
126126
"""
127127
super().__init__()
128128
self._threshold: float = threshold
129-
self._min_scene_len: int = min_scene_len
130-
self._last_above_threshold: ty.Optional[int] = None
131129
self._last_frame: ty.Optional[ContentDetector._FrameData] = None
132130
self._weights: ContentDetector.Components = weights
133131
if luma_only:
@@ -138,7 +136,6 @@ def __init__(
138136
if kernel_size < 3 or kernel_size % 2 == 0:
139137
raise ValueError('kernel_size must be odd integer >= 3')
140138
self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8)
141-
self._frame_score: ty.Optional[float] = None
142139
self._flash_filter = flash_filter if not flash_filter is None else FlashFilter(
143140
length=min_scene_len)
144141

@@ -202,11 +199,9 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> ty.List[int
202199
ty.List[int]: List of frames where scene cuts have been detected. There may be 0
203200
or more frames in the list, and not necessarily the same as frame_num.
204201
"""
205-
self._frame_score = self._calculate_frame_score(frame_num, frame_img)
206-
if self._frame_score is None:
207-
return []
208-
return self._flash_filter.filter(
209-
frame_num=frame_num, found_cut=self._frame_score >= self._threshold)
202+
frame_score = self._calculate_frame_score(frame_num, frame_img)
203+
found_cut = frame_score >= self._threshold
204+
return self._flash_filter.apply(frame_num=frame_num, found_cut=found_cut)
210205

211206
def _detect_edges(self, lum: numpy.ndarray) -> numpy.ndarray:
212207
"""Detect edges using the luma channel of a frame.

0 commit comments

Comments
 (0)