Skip to content

Commit c7a5965

Browse files
committed
[adaptive_detector] Fix first scene not being checked against min_scene_len
Refactor detector test cases to make it easier to add new ones.
1 parent 7de8f43 commit c7a5965

File tree

10 files changed

+177
-148
lines changed

10 files changed

+177
-148
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Video Scene Cut Detection and Analysis Tool
1111

1212
----------------------------------------------------------
1313

14-
### Latest Release: v0.6.3 (March 8, 2024)
14+
### Latest Release: v0.6.3 (March 9, 2024)
1515

1616
**Website**: [scenedetect.com](https://www.scenedetect.com)
1717

scenedetect/_cli/controller.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ def run_scenedetect(context: CliContext):
4747

4848
if context.load_scenes_input:
4949
# Skip detection if load-scenes was used.
50-
logger.info("Loading scenes from file: %s", context.load_scenes_input)
50+
logger.info("Skipping detection, loading scenes from: %s", context.load_scenes_input)
5151
if context.stats_file_path:
5252
logger.warning("WARNING: -s/--stats will be ignored due to load-scenes.")
5353
scene_list, cut_list = _load_scenes(context)

scenedetect/detectors/adaptive_detector.py

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,8 @@ def __init__(
9595
self._adaptive_ratio_key = AdaptiveDetector.ADAPTIVE_RATIO_KEY_TEMPLATE.format(
9696
window_width=window_width, luma_only='' if not luma_only else '_lum')
9797
self._first_frame_num = None
98-
self._last_frame_num = None
9998

99+
# NOTE: This must be different than `self._last_scene_cut` which is used by the base class.
100100
self._last_cut: Optional[int] = None
101101

102102
self._buffer = []
@@ -131,6 +131,10 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List
131131

132132
super().process_frame(frame_num=frame_num, frame_img=frame_img)
133133

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+
134138
required_frames = 1 + (2 * self.window_width)
135139
self._buffer.append((frame_num, self._frame_score))
136140
if not len(self._buffer) >= required_frames:
@@ -152,23 +156,15 @@ def process_frame(self, frame_num: int, frame_img: Optional[np.ndarray]) -> List
152156
if self.stats_manager is not None:
153157
self.stats_manager.set_metrics(target[0], {self._adaptive_ratio_key: adaptive_ratio})
154158

155-
cut_list = []
156159
# Check to see if adaptive_ratio exceeds the adaptive_threshold as well as there
157160
# being a large enough content_val to trigger a cut
158-
if (adaptive_ratio >= self.adaptive_threshold and target[1] >= self.min_content_val):
159-
160-
if self._last_cut is None:
161-
# No previously detected cuts
162-
cut_list.append(target[0])
163-
self._last_cut = target[0]
164-
elif (target[0] - self._last_cut) >= self.min_scene_len:
165-
# Respect the min_scene_len parameter
166-
cut_list.append(target[0])
167-
# TODO: Should this be updated every time the threshold is exceeded?
168-
# It might help with flash suppression for example.
169-
self._last_cut = target[0]
170-
171-
return cut_list
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 []
172168

173169
def get_content_val(self, frame_num: int) -> Optional[float]:
174170
"""Returns the average content change for a frame."""

scenedetect/detectors/content_detector.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ def process_frame(self, frame_num: int, frame_img: numpy.ndarray) -> List[int]:
205205

206206
# We consider any frame over the threshold a new scene, but only if
207207
# the minimum scene length has been reached (otherwise it is ignored).
208-
min_length_met = (frame_num - self._last_scene_cut) >= self._min_scene_len
208+
min_length_met: bool = (frame_num - self._last_scene_cut) >= self._min_scene_len
209209
if self._frame_score >= self._threshold and min_length_met:
210210
self._last_scene_cut = frame_num
211211
return [frame_num]

tests/test_cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -238,9 +238,9 @@ def test_cli_time_end_of_video():
238238

239239

240240
@pytest.mark.parametrize('detector_command', ALL_DETECTORS)
241-
def test_cli_detector(detector_command: str): #
241+
def test_cli_detector(detector_command: str):
242242
"""Test each detection algorithm."""
243-
# Ensure all detectors work without a statsfile.
243+
# Ensure all detectors work without a statsfile.
244244
assert invoke_scenedetect('-i {VIDEO} time {TIME} {DETECTOR}', DETECTOR=detector_command) == 0
245245

246246

tests/test_detectors.py

Lines changed: 150 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -17,93 +17,161 @@
1717
test case material.
1818
"""
1919

20-
import time
20+
from dataclasses import dataclass
21+
import os
22+
import typing as ty
2123

22-
from scenedetect import detect, SceneManager, FrameTimecode, StatsManager
24+
import pytest
25+
26+
from scenedetect import detect, SceneManager, FrameTimecode, StatsManager, SceneDetector
2327
from scenedetect.detectors import AdaptiveDetector, ContentDetector, ThresholdDetector
2428
from scenedetect.backends.opencv import VideoStreamCv2
2529

26-
# TODO: Test more parameters and add more videos. Parameterize the tests below such that
27-
# a detector instance is combined with the other parameters like ground truth that go along
28-
# with a specific video and detector values. E.g. Use Video-000, Video-001, etc..., and map
29-
# that to a particular filename.
30-
31-
TEST_MOVIE_CLIP_START_FRAMES_ACTUAL = [1199, 1226, 1260, 1281, 1334, 1365, 1590, 1697, 1871]
32-
"""Ground truth of start frame for each fast cut in `test_movie_clip`."""
33-
34-
TEST_VIDEO_FILE_START_FRAMES_ACTUAL = [0, 15, 198, 376]
35-
"""Results for `test_video_file` with default ThresholdDetector values."""
36-
37-
FADES_FLOOR_START_FRAMES = [0, 84, 167, 245]
38-
"""Results for `test_fades_clip` with default ThresholdDetector values."""
39-
40-
FADES_CEILING_START_FRAMES = [0, 42, 125, 209]
41-
"""Results for `test_fades_clip` with ThresholdDetector fade to light with threshold 243."""
42-
43-
44-
def test_detect(test_video_file):
45-
""" Test scenedetect.detect and ThresholdDetector. """
46-
scene_list = detect(video_path=test_video_file, detector=ThresholdDetector())
47-
assert len(scene_list) == len(TEST_VIDEO_FILE_START_FRAMES_ACTUAL)
48-
detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list]
49-
assert all(x == y for (x, y) in zip(TEST_VIDEO_FILE_START_FRAMES_ACTUAL, detected_start_frames))
50-
51-
52-
def test_content_detector(test_movie_clip):
53-
""" Test SceneManager with VideoStreamCv2 and ContentDetector. """
54-
video = VideoStreamCv2(test_movie_clip)
55-
scene_manager = SceneManager()
56-
scene_manager.add_detector(ContentDetector())
57-
58-
video_fps = video.frame_rate
59-
start_time = FrameTimecode('00:00:50', video_fps)
60-
end_time = FrameTimecode('00:01:19', video_fps)
6130

62-
video.seek(start_time)
63-
scene_manager.auto_downscale = True
64-
65-
scene_manager.detect_scenes(video=video, end_time=end_time)
66-
scene_list = scene_manager.get_scene_list()
67-
assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL)
68-
detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list]
69-
assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames
70-
# Ensure last scene's end timecode matches the end time we set.
71-
assert scene_list[-1][1] == end_time
72-
73-
74-
def test_adaptive_detector(test_movie_clip):
75-
""" Test SceneManager with VideoStreamCv2 and AdaptiveDetector. """
76-
video = VideoStreamCv2(test_movie_clip)
77-
scene_manager = SceneManager()
78-
scene_manager.add_detector(AdaptiveDetector())
79-
scene_manager.auto_downscale = True
80-
81-
video_fps = video.frame_rate
82-
start_time = FrameTimecode('00:00:50', video_fps)
83-
end_time = FrameTimecode('00:01:19', video_fps)
84-
85-
video.seek(start_time)
86-
scene_manager.detect_scenes(video=video, end_time=end_time)
87-
88-
scene_list = scene_manager.get_scene_list()
89-
assert len(scene_list) == len(TEST_MOVIE_CLIP_START_FRAMES_ACTUAL)
90-
detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list]
91-
assert TEST_MOVIE_CLIP_START_FRAMES_ACTUAL == detected_start_frames
92-
# Ensure last scene's end timecode matches the end time we set.
93-
assert scene_list[-1][1] == end_time
94-
95-
96-
def test_threshold_detector(test_video_file):
97-
""" Test SceneManager with VideoStreamCv2 and ThresholdDetector. """
98-
video = VideoStreamCv2(test_video_file)
99-
scene_manager = SceneManager()
100-
scene_manager.add_detector(ThresholdDetector())
101-
scene_manager.auto_downscale = True
102-
scene_manager.detect_scenes(video)
103-
scene_list = scene_manager.get_scene_list()
104-
assert len(scene_list) == len(TEST_VIDEO_FILE_START_FRAMES_ACTUAL)
105-
detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list]
106-
assert all(x == y for (x, y) in zip(TEST_VIDEO_FILE_START_FRAMES_ACTUAL, detected_start_frames))
31+
# TODO: Reduce code duplication here and in `conftest.py`
32+
def get_absolute_path(relative_path: str) -> str:
33+
""" Returns the absolute path to a (relative) path of a file that
34+
should exist within the tests/ directory.
35+
36+
Throws FileNotFoundError if the file could not be found.
37+
"""
38+
abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path)
39+
if not os.path.exists(abs_path):
40+
raise FileNotFoundError("""
41+
Test video file (%s) must be present to run test case. This file can be obtained by running the following commands from the root of the repository:
42+
43+
git fetch --depth=1 https://github.com/Breakthrough/PySceneDetect.git refs/heads/resources:refs/remotes/origin/resources
44+
git checkout refs/remotes/origin/resources -- tests/resources/
45+
git reset
46+
""" % relative_path)
47+
return abs_path
48+
49+
50+
@dataclass
51+
class TestCase:
52+
"""Properties for detector test cases."""
53+
path: str
54+
"""Path to video for test case."""
55+
detector: SceneDetector
56+
"""Detector instance to use."""
57+
start_time: int
58+
"""Start time as frames."""
59+
end_time: int
60+
"""End time as frames."""
61+
scene_boundaries: ty.List[int]
62+
"""Scene boundaries."""
63+
64+
def detect(self):
65+
"""Run scene detection for test case. Should only be called once."""
66+
return detect(
67+
video_path=self.path,
68+
detector=self.detector,
69+
start_time=self.start_time,
70+
end_time=self.end_time)
71+
72+
73+
def get_fast_cut_test_cases():
74+
"""Fixture for parameterized test cases that detect fast cuts."""
75+
return [
76+
pytest.param(
77+
TestCase(
78+
path=get_absolute_path("resources/goldeneye.mp4"),
79+
detector=ContentDetector(),
80+
start_time=1199,
81+
end_time=1450,
82+
scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]),
83+
id="content_default"),
84+
pytest.param(
85+
TestCase(
86+
path=get_absolute_path("resources/goldeneye.mp4"),
87+
detector=AdaptiveDetector(),
88+
start_time=1199,
89+
end_time=1450,
90+
scene_boundaries=[1199, 1226, 1260, 1281, 1334, 1365]),
91+
id="adaptive_default"),
92+
pytest.param(
93+
TestCase(
94+
path=get_absolute_path("resources/goldeneye.mp4"),
95+
detector=ContentDetector(min_scene_len=30),
96+
start_time=1199,
97+
end_time=1450,
98+
scene_boundaries=[1199, 1260, 1334, 1365]),
99+
id="content_min_scene_len"),
100+
pytest.param(
101+
TestCase(
102+
path=get_absolute_path("resources/goldeneye.mp4"),
103+
detector=AdaptiveDetector(min_scene_len=30),
104+
start_time=1199,
105+
end_time=1450,
106+
scene_boundaries=[1199, 1260, 1334, 1365]),
107+
id="adaptive_min_scene_len"),
108+
]
109+
110+
111+
def get_fade_in_out_test_cases():
112+
"""Fixture for parameterized test cases that detect fades."""
113+
# TODO: min_scene_len doesn't seem to be working as intended for ThresholdDetector.
114+
# Possibly related to #278: https://github.com/Breakthrough/PySceneDetect/issues/278
115+
return [
116+
pytest.param(
117+
TestCase(
118+
path=get_absolute_path("resources/testvideo.mp4"),
119+
detector=ThresholdDetector(),
120+
start_time=0,
121+
end_time=500,
122+
scene_boundaries=[0, 15, 198, 376]),
123+
id="threshold_testvideo_default"),
124+
pytest.param(
125+
TestCase(
126+
path=get_absolute_path("resources/fades.mp4"),
127+
detector=ThresholdDetector(),
128+
start_time=0,
129+
end_time=250,
130+
scene_boundaries=[0, 84, 167]),
131+
id="threshold_fades_default"),
132+
pytest.param(
133+
TestCase(
134+
path=get_absolute_path("resources/fades.mp4"),
135+
detector=ThresholdDetector(
136+
threshold=12.0,
137+
method=ThresholdDetector.Method.FLOOR,
138+
add_final_scene=True,
139+
),
140+
start_time=0,
141+
end_time=250,
142+
scene_boundaries=[0, 84, 167, 245]),
143+
id="threshold_fades_floor"),
144+
pytest.param(
145+
TestCase(
146+
path=get_absolute_path("resources/fades.mp4"),
147+
detector=ThresholdDetector(
148+
threshold=243.0,
149+
method=ThresholdDetector.Method.CEILING,
150+
add_final_scene=True,
151+
),
152+
start_time=0,
153+
end_time=250,
154+
scene_boundaries=[0, 42, 125, 209]),
155+
id="threshold_fades_ceil"),
156+
]
157+
158+
159+
@pytest.mark.parametrize("test_case", get_fast_cut_test_cases())
160+
def test_detect_fast_cuts(test_case: TestCase):
161+
scene_list = test_case.detect()
162+
start_frames = [timecode.get_frames() for timecode, _ in scene_list]
163+
assert test_case.scene_boundaries == start_frames
164+
assert scene_list[0][0] == test_case.start_time
165+
assert scene_list[-1][1] == test_case.end_time
166+
167+
168+
@pytest.mark.parametrize("test_case", get_fade_in_out_test_cases())
169+
def test_detect_fades(test_case: TestCase):
170+
scene_list = test_case.detect()
171+
start_frames = [timecode.get_frames() for timecode, _ in scene_list]
172+
assert test_case.scene_boundaries == start_frames
173+
assert scene_list[0][0] == test_case.start_time
174+
assert scene_list[-1][1] == test_case.end_time
107175

108176

109177
def test_detectors_with_stats(test_video_file):
@@ -116,10 +184,7 @@ def test_detectors_with_stats(test_video_file):
116184
scene_manager.add_detector(detector())
117185
scene_manager.auto_downscale = True
118186
end_time = FrameTimecode('00:00:08', video.frame_rate)
119-
benchmark_start = time.time()
120187
scene_manager.detect_scenes(video=video, end_time=end_time)
121-
benchmark_end = time.time()
122-
time_no_stats = benchmark_end - benchmark_start
123188
initial_scene_len = len(scene_manager.get_scene_list())
124189
assert initial_scene_len > 0 # test case must have at least one scene!
125190
# Re-analyze using existing stats manager.
@@ -129,44 +194,6 @@ def test_detectors_with_stats(test_video_file):
129194
video.reset()
130195
scene_manager.auto_downscale = True
131196

132-
benchmark_start = time.time()
133197
scene_manager.detect_scenes(video=video, end_time=end_time)
134-
benchmark_end = time.time()
135-
time_with_stats = benchmark_end - benchmark_start
136198
scene_list = scene_manager.get_scene_list()
137199
assert len(scene_list) == initial_scene_len
138-
139-
print("--------------------------------------------------------------------")
140-
print("StatsManager Benchmark For %s" % (detector.__name__))
141-
print("--------------------------------------------------------------------")
142-
print("No Stats:\t%2.1fs" % time_no_stats)
143-
print("With Stats:\t%2.1fs" % time_with_stats)
144-
print("--------------------------------------------------------------------")
145-
146-
147-
def test_threshold_detector_fade_out(test_fades_clip):
148-
"""Test ThresholdDetector handles fading out to black."""
149-
video = VideoStreamCv2(test_fades_clip)
150-
scene_manager = SceneManager()
151-
scene_manager.add_detector(ThresholdDetector(add_final_scene=True))
152-
scene_manager.auto_downscale = True
153-
scene_manager.detect_scenes(video)
154-
scene_list = scene_manager.get_scene_list()
155-
assert len(scene_list) == len(FADES_FLOOR_START_FRAMES)
156-
detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list]
157-
assert all(x == y for (x, y) in zip(FADES_FLOOR_START_FRAMES, detected_start_frames))
158-
159-
160-
def test_threshold_detector_fade_in(test_fades_clip):
161-
"""Test ThresholdDetector handles fading in from white."""
162-
video = VideoStreamCv2(test_fades_clip)
163-
scene_manager = SceneManager()
164-
scene_manager.add_detector(
165-
ThresholdDetector(
166-
threshold=243, method=ThresholdDetector.Method.CEILING, add_final_scene=True))
167-
scene_manager.auto_downscale = True
168-
scene_manager.detect_scenes(video)
169-
scene_list = scene_manager.get_scene_list()
170-
assert len(scene_list) == len(FADES_CEILING_START_FRAMES)
171-
detected_start_frames = [timecode.get_frames() for timecode, _ in scene_list]
172-
assert all(x == y for (x, y) in zip(FADES_CEILING_START_FRAMES, detected_start_frames))

0 commit comments

Comments
 (0)