Skip to content

Commit e58f0e3

Browse files
committed
[stats] Allow save_to_csv to work with pathlib.Path.
1 parent ac693a8 commit e58f0e3

9 files changed

+70
-91
lines changed

scenedetect/stats_manager.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import os.path
2626
import typing as ty
2727
from logging import getLogger
28+
from pathlib import Path
2829

2930
# TODO: Replace below imports with `ty.` prefix.
3031
from typing import Any, Dict, Iterable, List, Optional, Set, TextIO, Union
@@ -167,7 +168,7 @@ def is_save_required(self) -> bool:
167168

168169
def save_to_csv(
169170
self,
170-
csv_file: Union[str, bytes, TextIO],
171+
csv_file: Union[str, bytes, Path, TextIO],
171172
base_timecode: Optional[FrameTimecode] = None,
172173
force_save=True,
173174
) -> None:
@@ -191,7 +192,7 @@ def save_to_csv(
191192

192193
# If we get a path instead of an open file handle, recursively call ourselves
193194
# again but with file handle instead of path.
194-
if isinstance(csv_file, (str, bytes)):
195+
if isinstance(csv_file, (str, bytes, Path)):
195196
with open(csv_file, "w") as file:
196197
self.save_to_csv(csv_file=file, force_save=force_save)
197198
return
@@ -250,7 +251,7 @@ def load_from_csv(self, csv_file: Union[str, bytes, TextIO]) -> Optional[int]:
250251

251252
# If we get a path instead of an open file handle, check that it exists, and if so,
252253
# recursively call ourselves again but with file set instead of path.
253-
if isinstance(csv_file, (str, bytes)):
254+
if isinstance(csv_file, (str, bytes, Path)):
254255
if os.path.exists(csv_file):
255256
with open(csv_file) as file:
256257
return self.load_from_csv(csv_file=file)

tests/test_backend_opencv.py

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from scenedetect.backends.opencv import VideoCaptureAdapter, VideoStreamCv2
2424

2525
GROUND_TRUTH_CAPTURE_ADAPTER_TEST = [1, 90, 210]
26-
GROUND_TRUTH_CAPTURE_ADAPTER_CALLBACK_TEST = [30, 180, 394]
26+
GROUND_TRUTH_CAPTURE_ADAPTER_CALLBACK_TEST = [180, 394]
2727

2828

2929
def test_open_image_sequence(test_image_sequence: str):
@@ -50,21 +50,3 @@ def test_capture_adapter(test_movie_clip: str):
5050
scenes = scene_manager.get_scene_list()
5151
assert len(scenes) == len(GROUND_TRUTH_CAPTURE_ADAPTER_TEST)
5252
assert [start.get_frames() for (start, _) in scenes] == GROUND_TRUTH_CAPTURE_ADAPTER_TEST
53-
54-
55-
def test_capture_adapter_callback(test_video_file: str):
56-
"""Test that the VideoCaptureAdapter works with SceneManager and a callback."""
57-
58-
callback_frames = []
59-
60-
def on_new_scene(_, frame_num: int):
61-
nonlocal callback_frames
62-
callback_frames.append(frame_num)
63-
64-
cap = cv2.VideoCapture(test_video_file)
65-
assert cap.isOpened()
66-
adapter = VideoCaptureAdapter(cap)
67-
scene_manager = SceneManager()
68-
scene_manager.add_detector(ContentDetector())
69-
scene_manager.detect_scenes(video=adapter, callback=on_new_scene)
70-
assert callback_frames == GROUND_TRUTH_CAPTURE_ADAPTER_CALLBACK_TEST

tests/test_backwards_compat.py

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str)
4141
base_timecode = video_manager.get_base_timecode()
4242
scene_list = []
4343
try:
44-
start_time = base_timecode + 20 # 00:00:00.667
45-
end_time = base_timecode + 10.0 # 00:00:10.000
44+
start_time = base_timecode + 4.0
45+
end_time = base_timecode + 8.0
4646

4747
if os.path.exists(stats_file_path):
4848
with open(stats_file_path) as stats_file:
@@ -67,19 +67,6 @@ def validate_backwards_compatibility(test_video_file: str, stats_file_path: str)
6767
# Correct end frame # for presentation duration.
6868
assert video_manager.get_current_timecode().get_frames() == end_time.get_frames() + 1
6969

70-
print("List of scenes obtained:")
71-
for i, scene in enumerate(scene_list):
72-
print(
73-
" Scene %2d: Start %s / Frame %d, End %s / Frame %d"
74-
% (
75-
i + 1,
76-
scene[0].get_timecode(),
77-
scene[0].get_frames(),
78-
scene[1].get_timecode(),
79-
scene[1].get_frames(),
80-
)
81-
)
82-
8370
if stats_manager.is_save_required():
8471
with open(stats_file_path, "w") as stats_file:
8572
stats_manager.save_to_csv(stats_file, base_timecode=base_timecode)

tests/test_cli.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,10 @@
5959
DEFAULT_DETECTOR = "detect-content"
6060
DEFAULT_CONFIG_FILE = "scenedetect.cfg" # Ensure we default to a "blank" config file.
6161
DEFAULT_NUM_SCENES = 2 # Number of scenes we expect to detect given above params.
62+
DEFAULT_FFMPEG_ARGS = (
63+
"-vf crop=128:128:0:0 -map 0:v:0 -c:v libx264 -preset ultrafast -qp 0 -tune zerolatency"
64+
)
65+
"""Only encodes a small crop of the frame and tuned for performance to speed up tests."""
6266

6367

6468
def invoke_scenedetect(
@@ -313,13 +317,13 @@ def test_cli_list_scenes(tmp_path: Path):
313317
@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available")
314318
def test_cli_split_video_ffmpeg(tmp_path: Path):
315319
"""Test `split-video` command using ffmpeg."""
320+
316321
# Assumption: The default filename format is VIDEO_NAME-Scene-SCENE_NUMBER.
317-
assert (
318-
invoke_scenedetect(
319-
"-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video", output_dir=tmp_path
320-
)
321-
== 0
322+
command = f"{SCENEDETECT_CMD} -i {DEFAULT_VIDEO_PATH} -o {tmp_path} time {DEFAULT_TIME} {DEFAULT_DETECTOR} split-video -a".split(
323+
" "
322324
)
325+
command.append(DEFAULT_FFMPEG_ARGS)
326+
assert subprocess.call(command) == 0
323327
entries = sorted(tmp_path.glob(f"{DEFAULT_VIDEO_NAME}-Scene-*"))
324328
assert len(entries) == DEFAULT_NUM_SCENES, entries
325329
[entry.unlink() for entry in entries]
@@ -334,20 +338,15 @@ def test_cli_split_video_ffmpeg(tmp_path: Path):
334338
assert len(entries) == DEFAULT_NUM_SCENES
335339
[entry.unlink() for entry in entries]
336340

337-
assert (
338-
invoke_scenedetect(
339-
"-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -f abc$VIDEO_NAME-123$SCENE_NUMBER",
340-
output_dir=tmp_path,
341-
)
342-
== 0
343-
)
341+
command += ["-f", "abc$VIDEO_NAME-123$SCENE_NUMBER"]
342+
assert subprocess.call(command) == 0
344343
entries = sorted(tmp_path.glob(f"abc{DEFAULT_VIDEO_NAME}-123*"))
345344
assert len(entries) == DEFAULT_NUM_SCENES, entries
346345
[entry.unlink() for entry in entries]
347346

348347
# -a/--args and -c/--copy are mutually exclusive, so this command should fail (return nonzero)
349348
assert invoke_scenedetect(
350-
'-i {VIDEO} -s {STATS} time {TIME} {DETECTOR} split-video -c -a "-c:v libx264"',
349+
'-i {VIDEO} {DETECTOR} split-video -c -a "-c:v libx264"',
351350
output_dir=tmp_path,
352351
)
353352

tests/test_detectors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ def test_detectors_with_stats(test_video_file):
212212
scene_manager = SceneManager(stats_manager=stats)
213213
scene_manager.add_detector(detector())
214214
scene_manager.auto_downscale = True
215-
end_time = FrameTimecode("00:00:08", video.frame_rate)
215+
end_time = FrameTimecode("00:00:05", video.frame_rate)
216216
scene_manager.detect_scenes(video=video, end_time=end_time)
217217
initial_scene_len = len(scene_manager.get_scene_list())
218218
assert initial_scene_len > 0, "Test case must have at least one scene."

tests/test_scene_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ def test_scene_list(test_video_file):
3636

3737
video_fps = video.frame_rate
3838
start_time = FrameTimecode("00:00:05", video_fps)
39-
end_time = FrameTimecode("00:00:15", video_fps)
39+
end_time = FrameTimecode("00:00:10", video_fps)
4040

4141
assert end_time.get_frames() > start_time.get_frames()
4242

tests/test_stats_manager.py

Lines changed: 20 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import csv
3030
import os
3131
import random
32+
from pathlib import Path
3233

3334
import pytest
3435

@@ -43,19 +44,6 @@
4344
StatsManager,
4445
)
4546

46-
# TODO(v1.0): use https://docs.pytest.org/en/6.2.x/tmpdir.html
47-
TEST_STATS_FILES = ["TEST_STATS_FILE"] * 4
48-
TEST_STATS_FILES = [
49-
"%s_%012d.csv" % (stats_file, random.randint(0, 10**12)) for stats_file in TEST_STATS_FILES
50-
]
51-
52-
53-
def teardown_module():
54-
"""Removes any created stats files, if any."""
55-
for stats_file in TEST_STATS_FILES:
56-
if os.path.exists(stats_file):
57-
os.remove(stats_file)
58-
5947

6048
def test_metrics():
6149
"""Test StatsManager metric registration/setting/getting with a set of pre-defined
@@ -103,25 +91,28 @@ def test_detector_metrics(test_video_file):
10391
assert stats_manager.get_metrics(0, ContentDetector.METRIC_KEYS)
10492

10593

106-
def test_load_empty_stats():
94+
def test_load_empty_stats(tmp_path: Path):
10795
"""Test loading an empty stats file, ensuring it results in no errors."""
108-
with open(TEST_STATS_FILES[0], "w"):
96+
path = tmp_path.joinpath("stats.csv")
97+
with open(path, "w"):
10998
pass
11099
stats_manager = StatsManager()
111-
stats_manager.load_from_csv(TEST_STATS_FILES[0])
100+
stats_manager.load_from_csv(path)
112101

113102

114-
def test_save_no_detect_scenes():
103+
def test_save_no_detect_scenes(tmp_path: Path):
115104
"""Test saving without calling detect_scenes."""
105+
path = tmp_path.joinpath("stats.csv")
116106
stats_manager = StatsManager()
117-
stats_manager.save_to_csv(TEST_STATS_FILES[0])
107+
stats_manager.save_to_csv(path)
118108

119109

120-
def test_load_hardcoded_file():
110+
def test_load_hardcoded_file(tmp_path: Path):
121111
"""Test loading a stats file with some hard-coded data generated by this test case."""
122112

113+
path = tmp_path.joinpath("stats.csv")
123114
stats_manager = StatsManager()
124-
with open(TEST_STATS_FILES[0], "w") as stats_file:
115+
with open(path, "w") as stats_file:
125116
stats_writer = csv.writer(stats_file, lineterminator="\n")
126117

127118
some_metric_key = "some_metric"
@@ -136,7 +127,7 @@ def test_load_hardcoded_file():
136127
[some_frame_key + 1, some_frame_timecode.get_timecode(), str(some_metric_value)]
137128
)
138129

139-
stats_manager.load_from_csv(TEST_STATS_FILES[0])
130+
stats_manager.load_from_csv(path)
140131

141132
# Check that we decoded the correct values.
142133
assert stats_manager.metrics_exist(some_frame_key, [some_metric_key])
@@ -145,7 +136,7 @@ def test_load_hardcoded_file():
145136
)
146137

147138

148-
def test_save_load_from_video(test_video_file):
139+
def test_save_load_from_video(test_video_file, tmp_path: Path):
149140
"""Test generating and saving some frame metrics from TEST_VIDEO_FILE to a file on disk, and
150141
loading the file back to ensure the loaded frame metrics agree with those that were saved.
151142
"""
@@ -161,13 +152,14 @@ def test_save_load_from_video(test_video_file):
161152
scene_manager.auto_downscale = True
162153
scene_manager.detect_scenes(video, duration=duration)
163154

164-
stats_manager.save_to_csv(csv_file=TEST_STATS_FILES[0])
155+
path = tmp_path.joinpath("stats.csv")
156+
stats_manager.save_to_csv(csv_file=path)
165157

166158
metrics = stats_manager.metric_keys
167159

168160
stats_manager_new = StatsManager()
169161

170-
stats_manager_new.load_from_csv(TEST_STATS_FILES[0])
162+
stats_manager_new.load_from_csv(path)
171163

172164
# Compare the first 5 frames. Frame 0 won't have any metrics for this detector.
173165
for frame in range(1, 5 + 1):
@@ -178,12 +170,13 @@ def test_save_load_from_video(test_video_file):
178170
assert metric_val == pytest.approx(new_metrics[i])
179171

180172

181-
def test_load_corrupt_stats():
173+
def test_load_corrupt_stats(tmp_path: Path):
182174
"""Test loading a corrupted stats file created by outputting data in the wrong format."""
183175

184176
stats_manager = StatsManager()
185177

186-
with open(TEST_STATS_FILES[0], "w") as stats_file:
178+
path = tmp_path.joinpath("stats.csv")
179+
with open(path, "w") as stats_file:
187180
stats_writer = csv.writer(stats_file, lineterminator="\n")
188181

189182
some_metric_key = "some_metric"
@@ -204,4 +197,4 @@ def test_load_corrupt_stats():
204197
stats_file.close()
205198

206199
with pytest.raises(StatsFileCorrupt):
207-
stats_manager.load_from_csv(TEST_STATS_FILES[0])
200+
stats_manager.load_from_csv(path)

tests/test_video_splitter.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,25 @@
2323
split_video_ffmpeg,
2424
)
2525

26+
FFMPEG_ARGS = (
27+
"-vf crop=128:128:0:0 -map 0:v:0 -c:v libx264 -preset ultrafast -qp 0 -tune zerolatency"
28+
)
29+
"""Only encodes a small crop of the frame and tuned for performance to speed up tests."""
30+
2631

2732
@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available")
2833
def test_split_video_ffmpeg_default(tmp_path, test_movie_clip):
2934
video = open_video(test_movie_clip)
30-
# Extract three hard-coded scenes for testing, each 60 frames.
35+
# Extract three hard-coded scenes for testing, each 30 frames.
3136
scenes = [
32-
(video.base_timecode + 60, video.base_timecode + 120),
33-
(video.base_timecode + 120, video.base_timecode + 180),
34-
(video.base_timecode + 180, video.base_timecode + 240),
37+
(video.base_timecode + 30, video.base_timecode + 60),
38+
(video.base_timecode + 60, video.base_timecode + 90),
39+
(video.base_timecode + 90, video.base_timecode + 120),
3540
]
36-
assert split_video_ffmpeg(test_movie_clip, scenes, tmp_path) == 0
41+
assert (
42+
split_video_ffmpeg(test_movie_clip, scenes, output_dir=tmp_path, arg_override=FFMPEG_ARGS)
43+
== 0
44+
)
3745
# The default filename format should be VIDEO_NAME-Scene-SCENE_NUMBER.mp4.
3846
video_name = Path(test_movie_clip).stem
3947
entries = sorted(tmp_path.glob(f"{video_name}-Scene-*"))
@@ -43,18 +51,27 @@ def test_split_video_ffmpeg_default(tmp_path, test_movie_clip):
4351
@pytest.mark.skipif(condition=not is_ffmpeg_available(), reason="ffmpeg is not available")
4452
def test_split_video_ffmpeg_formatter(tmp_path, test_movie_clip):
4553
video = open_video(test_movie_clip)
46-
# Extract three hard-coded scenes for testing, each 60 frames.
54+
# Extract three hard-coded scenes for testing, each 30 frames.
4755
scenes = [
48-
(video.base_timecode + 60, video.base_timecode + 120),
49-
(video.base_timecode + 120, video.base_timecode + 180),
50-
(video.base_timecode + 180, video.base_timecode + 240),
56+
(video.base_timecode + 30, video.base_timecode + 60),
57+
(video.base_timecode + 60, video.base_timecode + 90),
58+
(video.base_timecode + 90, video.base_timecode + 120),
5159
]
5260

5361
# Custom filename formatter:
5462
def name_formatter(video: VideoMetadata, scene: SceneMetadata):
5563
return "abc" + video.name + "-123-" + str(scene.index) + ".mp4"
5664

57-
assert split_video_ffmpeg(test_movie_clip, scenes, tmp_path, formatter=name_formatter) == 0
65+
assert (
66+
split_video_ffmpeg(
67+
test_movie_clip,
68+
scenes,
69+
output_dir=tmp_path,
70+
arg_override=FFMPEG_ARGS,
71+
formatter=name_formatter,
72+
)
73+
== 0
74+
)
5875
video_name = Path(test_movie_clip).stem
5976
entries = sorted(tmp_path.glob(f"abc{video_name}-123-*"))
6077
assert len(entries) == len(scenes)

tests/test_video_stream.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,6 @@ def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str):
358358
stream = vs_type(corrupt_video_file)
359359

360360
# OpenCV usually fails to read the video at frame 45, so we make sure all backends can
361-
# get to 100 without reporting a failure.
362-
for frame in range(100):
361+
# get to 60 without reporting a failure.
362+
for frame in range(60):
363363
assert stream.read() is not False, "Failed on frame %d!" % frame

0 commit comments

Comments
 (0)