forked from Breakthrough/PySceneDetect
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_video_stream.py
More file actions
310 lines (258 loc) · 12.5 KB
/
Copy pathtest_video_stream.py
File metadata and controls
310 lines (258 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# -*- coding: utf-8 -*-
#
# PySceneDetect: Python-Based Video Scene Detector
# ---------------------------------------------------------------
# [ Site: http://www.scenedetect.scenedetect.com/ ]
# [ Docs: http://manual.scenedetect.scenedetect.com/ ]
# [ Github: https://github.com/Breakthrough/PySceneDetect/ ]
#
# Copyright (C) 2014-2022 Brandon Castellano <http://www.bcastell.com>.
# PySceneDetect is licensed under the BSD 3-Clause License; see the
# included LICENSE file, or visit one of the above pages for details.
#
""" PySceneDetect scenedetect.video_stream Tests
This file includes unit tests for the scenedetect.video_stream module, as well as the video
backends implemented in scenedetect.backends. These tests enforce a consistent interface across
all supported backends, and verify that they are functionally equivalent where possible.
"""
# Standard project pylint disables for unit tests using pytest.
# pylint: disable=no-self-use, protected-access, multiple-statements, invalid-name
# pylint: disable=redefined-outer-name
from typing import Type
import os.path
# Third-Party Library Imports
import numpy
import pytest
from scenedetect.video_stream import VideoStream
from scenedetect.backends.opencv import VideoStreamCv2
from scenedetect.backends.pyav import VideoStreamAv
from scenedetect.video_manager import VideoManager
# Accuracy a framerate is checked to for testing purposes.
FRAMERATE_TOLERANCE = 0.001
# Accuracy a time in milliseconds is checked to for testing purposes.
TIME_TOLERANCE_MS = 0.1
# Accuracy a pixel aspect ratio is checked to for testing purposes.
PIXEL_ASPECT_RATIO_TOLERANCE = 0.001
def calculate_frame_delta(frame_a, frame_b, roi=None) -> float:
if roi:
assert False # TODO
assert frame_a.shape == frame_b.shape
num_pixels = frame_a.shape[0] * frame_a.shape[1]
return numpy.sum(numpy.abs(frame_b - frame_a)) / num_pixels
# TODO: Reduce code duplication here and in `conftest.py`
def get_absolute_path(relative_path: str) -> str:
# type: (str) -> str
""" Returns the absolute path to a (relative) path of a file that
should exist within the tests/ directory.
Throws FileNotFoundError if the file could not be found.
"""
abs_path = os.path.join(os.path.abspath(os.path.dirname(__file__)), relative_path)
if not os.path.exists(abs_path):
raise FileNotFoundError('Test video file (%s) must be present to run test case!' %
relative_path)
return abs_path
class VideoParameters:
def __init__(self, path: str, height: int, width: int, frame_rate: float, total_frames: int,
aspect_ratio: float):
self.path = path
self.height = height
self.width = width
self.frame_rate = frame_rate
self.total_frames = total_frames
self.aspect_ratio = aspect_ratio
def get_test_video_params():
# type: () -> str
"""Fixture for parameters of all videos."""
return [
VideoParameters(
path=get_absolute_path("resources/testvideo.mp4"),
width=1280,
height=720,
frame_rate=29.97,
total_frames=720,
aspect_ratio=1.0,
),
VideoParameters(
path=get_absolute_path("resources/goldeneye.mp4"),
width=1280,
height=544,
frame_rate=23.976,
total_frames=1980,
aspect_ratio=1.0,
),
VideoParameters(
path=get_absolute_path("resources/issue-195-aspect-ratio.mp4"),
width=704,
height=576,
frame_rate=25.0,
total_frames=628,
aspect_ratio=1.4545454545,
),
]
pytestmark = pytest.mark.parametrize("vs_type", [VideoStreamCv2, VideoStreamAv, VideoManager])
@pytest.mark.parametrize("test_video", get_test_video_params())
class TestVideoStream:
def test_properties(self, vs_type: Type[VideoStream], test_video: VideoParameters):
"""Validate video properties: frame size, frame rate, duration, aspect ratio, etc."""
stream = vs_type(test_video.path)
assert stream.frame_size == (test_video.width, test_video.height)
assert stream.frame_rate == pytest.approx(test_video.frame_rate, FRAMERATE_TOLERANCE)
assert stream.duration.get_frames() == test_video.total_frames
file_name = os.path.basename(test_video.path)
last_dot_pos = file_name.rfind('.')
assert stream.name == file_name[:last_dot_pos]
assert stream.aspect_ratio == pytest.approx(test_video.aspect_ratio,
PIXEL_ASPECT_RATIO_TOLERANCE)
def test_read(self, vs_type: Type[VideoStream], test_video: VideoParameters):
"""Validate basic `read` functionality."""
stream = vs_type(test_video.path)
frame = stream.read()
# For now hard-code 3 channels/pixel for each test video
assert frame.shape == (test_video.height, test_video.width, 3)
assert stream.frame_number == 1
def test_read_no_advance(self, vs_type: Type[VideoStream], test_video: VideoParameters):
"""Validate invoking `read` with `advance` set to False."""
stream = vs_type(test_video.path)
frame = stream.read().copy()
assert stream.frame_number == 1
frame_copy = stream.read(advance=False)
assert stream.frame_number == 1
assert calculate_frame_delta(frame, frame_copy) == pytest.approx(0.0)
def test_read_no_decode(self, vs_type: Type[VideoStream], test_video: VideoParameters):
"""Validate invoking `read` with `decode` set to False."""
stream = vs_type(test_video.path)
assert stream.read(decode=False) is True
assert stream.frame_number == 1
stream.read(decode=False, advance=False)
assert stream.frame_number == 1
def test_time_invariants(self, vs_type: Type[VideoStream], test_video: VideoParameters):
"""Validates basic time keeping identities/invariants on the `VideoStream.position`,
`VideoStream.position_ms`, and `VideoStream.frame_number` properties."""
stream = vs_type(test_video.path)
# Before any frame has been decoded, everything is at time/frame 0.
assert stream.position == stream.base_timecode
assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS)
assert stream.frame_number == 0
assert stream.read() is not False
# After the first frame has been decoded, position is still at 0 (PTS),
# but frame_number is 1.
assert stream.position == stream.base_timecode
assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS)
assert stream.frame_number == 1
stream.reset()
# After resetting the stream, we should be back in the initial time state.
assert stream.position == stream.base_timecode
assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS)
assert stream.frame_number == 0
# Test invariants over the first 100 frames.
stream.reset()
for i in range(1, 100 + 1):
assert stream.read() is not False
assert stream.position == stream.base_timecode + (i - 1)
assert stream.position_ms == pytest.approx(
1000.0 * (i - 1) / float(stream.frame_rate), abs=TIME_TOLERANCE_MS)
assert stream.frame_number == i
stream.reset()
def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters):
"""Validate seeking behaviour."""
#
# Basic timecode "identities".
#
stream = vs_type(test_video.path)
# Decode a few frames so we don't start at zero already.
for _ in range(100):
stream.read()
# Seek to given time in seconds.
stream.seek(0.0)
assert stream.frame_number == 0
# FrameTimecode is currently one "behind" the frame_number since it
# starts counting from zero. This should eventually be changed.
assert stream.position == stream.base_timecode
assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS)
stream.seek(2.0)
stream.read()
assert stream.frame_number == 1 + round(stream.frame_rate * 2.0)
# FrameTimecode is currently one "behind" the frame_number since it
# starts counting from zero. This should eventually be changed.
assert stream.position == stream.base_timecode + 2.0
assert stream.position_ms == pytest.approx(2000.0, abs=1000.0 / stream.frame_rate)
# Seek to given FrameTimecode.
stream.seek(stream.base_timecode)
assert stream.frame_number == 0
assert stream.position == stream.base_timecode
assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS)
# Seek to a given frame number.
stream.seek(200)
assert stream.position == stream.base_timecode + 199
assert stream.position_ms == pytest.approx(
1000.0 * (199.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS)
assert stream.frame_number == 200
stream.read()
assert stream.frame_number == 201
assert stream.position == stream.base_timecode + 200
assert stream.position_ms == pytest.approx(
1000.0 * (200.0 / float(stream.frame_rate)), abs=TIME_TOLERANCE_MS)
# Seek to given time in seconds.
stream.seek(0)
assert stream.frame_number == 0
# FrameTimecode is currently one "behind" the frame_number since it
# starts counting from zero. This should eventually be changed.
assert stream.position == stream.base_timecode
assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS)
stream.seek(1)
assert stream.frame_number == 1
# FrameTimecode is currently one "behind" the frame_number since it
# starts counting from zero. This should eventually be changed.
assert stream.position == stream.base_timecode
assert stream.position_ms == pytest.approx(0.0, abs=TIME_TOLERANCE_MS)
stream.read()
assert stream.frame_number == 2
def test_seek_end(self, vs_type: Type[VideoStream], test_video: VideoParameters):
"""Validate seeking behaviour at end of the video."""
if vs_type == VideoManager:
pytest.skip(reason='VideoManager does not have compliant end-of-video seek behaviour.')
stream = vs_type(test_video.path)
last_frame_pts = test_video.total_frames - 1
# Seek to a reasonably large seek offset. Some backends only support 32-bit frame numbers.
stream.seek(2**32)
# Shouldn't be able to decode any more frames since we seeked to the last frame.
assert stream.read(advance=True) is False
assert stream.read(advance=False) is not False
# TODO: On some videos, the PyAV backend seems to drop a frame. See where this occurs.
if vs_type == VideoStreamAv:
assert stream.position in (last_frame_pts, last_frame_pts - 1)
else:
assert stream.position == last_frame_pts
#
# Tests which only use a single video file
#
def test_invalid_path(vs_type: Type[VideoStream]):
"""Ensure correct exception is thrown if the path does not exist."""
with pytest.raises(OSError):
_ = vs_type('this_path_should_not_exist.mp4')
def test_seek_invalid(vs_type: Type[VideoStream], test_video_file: str):
"""Test `seek()` throws correct exception when specifying in invalid seek value."""
stream = vs_type(test_video_file)
with pytest.raises(ValueError):
stream.seek(-1)
with pytest.raises(ValueError):
stream.seek(-0.1)
def test_reset(vs_type: Type[VideoStream], test_video_file: str):
"""Test `reset()` functions as expected."""
stream = vs_type(test_video_file)
for _ in range(3):
stream.read()
assert stream.frame_number > 0
stream.reset()
assert stream.frame_number == 0
assert stream.position == 0
assert stream.position_ms == pytest.approx(0, abs=TIME_TOLERANCE_MS)
def test_corrupt_video(vs_type: Type[VideoStream], corrupt_video_file: str):
"""Test that backend handles video with corrupt frame gracefully with defaults."""
if vs_type == VideoManager:
pytest.skip(reason='VideoManager does not support handling corrupt videos.')
stream = vs_type(corrupt_video_file)
# OpenCV usually fails to read the video at frame 45, so we make sure all backends can
# get to 100 without reporting a failure.
for frame in range(100):
assert stream.read() is not False, "Failed on frame %d!" % frame