Skip to content

Commit 3b28522

Browse files
committed
[VideoStreamAv] Implement multithreaded decoding. #213
1 parent 32658a0 commit 3b28522

File tree

5 files changed

+113
-91
lines changed

5 files changed

+113
-91
lines changed

scenedetect/backends/opencv.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def __init__(self, path_or_device: Union[bytes, str, int], framerate: Optional[f
4141
framerate: If set, overrides the detected framerate.
4242
4343
Raises:
44-
IOError: file could not be found or access was denied
44+
OSError: file could not be found or access was denied
4545
VideoOpenFailure: video could not be opened (may be corrupted)
4646
ValueError: specified framerate is invalid
4747
"""
@@ -237,7 +237,7 @@ def _open_capture(self, framerate: Optional[float] = None):
237237
if not self._is_device and not ('%' in self._path_or_device
238238
or '://' in self._path_or_device):
239239
if not os.path.exists(self._path_or_device):
240-
raise IOError('Video file not found.')
240+
raise OSError('Video file not found.')
241241

242242
cap = cv2.VideoCapture(self._path_or_device)
243243
if not cap.isOpened():

scenedetect/backends/pyav.py

Lines changed: 107 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"""
1717

1818
from logging import getLogger
19-
from typing import BinaryIO, Optional, Tuple, Union
19+
from typing import AnyStr, BinaryIO, Optional, Tuple, Union
2020

2121
import av
2222
from numpy import ndarray
@@ -32,56 +32,57 @@
3232
class VideoStreamAv(VideoStream):
3333
"""PyAV `av.InputContainer` backend."""
3434

35-
def __init__(self,
36-
path_or_io: Union[str, bytes, BinaryIO],
37-
framerate: Optional[float] = None,
38-
name: Optional[str] = None):
35+
def __init__(
36+
self,
37+
path_or_io: Union[AnyStr, BinaryIO],
38+
framerate: Optional[float] = None,
39+
name: Optional[str] = None,
40+
threading_mode: str = 'AUTO',
41+
):
3942
"""Open a video by path.
4043
4144
Arguments:
4245
path_or_io: Path to the video, or a file-like object.
4346
framerate: If set, overrides the detected framerate.
44-
name: Overrides the `name` property derived from the video path.
45-
Should be set if `path_or_io` is a file-like object.
47+
name: Overrides the `name` property derived from the video path. Should be set if
48+
`path_or_io` is a file-like object.
49+
threading_mode: The PyAV video stream `thread_type`. See av.codec.context.ThreadType
50+
for valid threading modes ('AUTO', 'FRAME', 'NONE', and 'SLICE'). If this mode is
51+
'AUTO' or 'FRAME' and not all frames have been decoded, the video will be reopened
52+
if it is seekable, and the remaining frames will be decoded in single-threaded mode.
53+
Default is 'AUTO' for performance (there will be a slight pause near the end).
4654
4755
Raises:
48-
IOError: file could not be found or access was denied
56+
OSError: file could not be found or access was denied
4957
VideoOpenFailure: video could not be opened (may be corrupted)
5058
ValueError: specified framerate is invalid
5159
"""
52-
# TODO: Investigate why setting the video stream threading mode to 'AUTO' / 'FRAME'
53-
# causes decoding to stop early, e.g. adding the following:
54-
#
55-
# self._container.streams.video[0].thread_type = 'AUTO' # Go faster!
56-
#
57-
# As a workaround, we can re-open the video without threading, and continue decoding from
58-
# where the multithreaded version left off. That could be as simple as re-initializing
59-
# self._container and retrying the read() call.
60-
#
61-
# The 'FRAME' threading method provides a significant speed boost (~400 FPS vs
62-
# 240 FPS without), so this seems like a worth-while tradeoff. The OpenCV backend
63-
# gets around 350 FPS for comparison.
64-
65-
# TODO(#258): See if setting self._container.discard_corrupt = True affects anything.
60+
# TODO(#258): See what self._container.discard_corrupt = True does with corrupt videos.
6661
super().__init__()
6762

68-
self._path: Union[str, bytes] = ''
63+
# Ensure specified framerate is valid if set.
64+
if framerate is not None and framerate < MAX_FPS_DELTA:
65+
raise ValueError('Specified framerate (%f) is invalid!' % framerate)
66+
6967
self._name: Union[str, bytes] = '' if name is None else name
70-
self._io: Optional[BinaryIO] = None
71-
self._duration_frames: int = 0
7268
self._frame = None
73-
74-
if isinstance(path_or_io, (str, bytes)):
75-
self._path = path_or_io
76-
if not self._name:
77-
self._name = get_file_name(self.path, include_extension=False)
78-
else:
79-
self._io = path_or_io
69+
self._reopened = True
8070

8171
try:
82-
self._container = av.open(self._path if self._path else self._io)
83-
except av.error.FileNotFoundError as ex:
84-
raise IOError from ex
72+
if isinstance(path_or_io, (str, bytes)):
73+
self._path = path_or_io
74+
self._io = open(path_or_io, 'rb')
75+
if not self._name:
76+
self._name = get_file_name(self.path, include_extension=False)
77+
else:
78+
self._io = path_or_io
79+
80+
self._container = av.open(self._io)
81+
if threading_mode is not None:
82+
self._video_stream.thread_type = threading_mode
83+
self._reopened = False
84+
except OSError:
85+
raise
8586
except Exception as ex:
8687
raise VideoOpenFailure(str(ex)) from ex
8788

@@ -95,57 +96,12 @@ def __init__(self,
9596
raise FrameRateUnavailable()
9697
self._frame_rate: float = frame_rate
9798
else:
98-
# Ensure specified framerate is valid.
99-
if framerate < MAX_FPS_DELTA:
100-
raise ValueError('Specified framerate (%f) is invalid!' % framerate)
99+
assert framerate >= MAX_FPS_DELTA
101100
self._frame_rate: float = framerate
102101

103102
# Calculate duration in terms of number of frames once we have set the framerate.
104103
self._duration_frames = self._get_duration()
105104

106-
#
107-
# Backend-Specific Methods/Properties
108-
#
109-
110-
@property
111-
def _video_stream(self):
112-
"""PyAV `av.video.stream.VideoStream` being used."""
113-
return self._container.streams.video[0]
114-
115-
@property
116-
def _codec_context(self):
117-
"""PyAV `av.codec.context.CodecContext` associated with the `video_stream`."""
118-
return self._video_stream.codec_context
119-
120-
def _get_duration(self) -> int:
121-
"""Get video duration as number of frames based on the video and set framerate."""
122-
# See https://pyav.org/docs/develop/api/time.html for details on how ffmpeg/PyAV
123-
# handle time calculations internally and which time base to use.
124-
assert self.frame_rate is not None, "Frame rate must be set before calling _get_duration!"
125-
# See if we can obtain the number of frames directly from the stream itself.
126-
if self._video_stream.frames > 0:
127-
return self._video_stream.frames
128-
# Calculate based on the reported container duration.
129-
duration_sec = None
130-
container = self._video_stream.container
131-
if container.duration is not None and container.duration > 0:
132-
# Containers use AV_TIME_BASE as the time base.
133-
duration_sec = float(self._video_stream.container.duration / av.time_base)
134-
# Lastly, if that calculation fails, try to calculate it based on the stream duration.
135-
if duration_sec is None or duration_sec < MAX_FPS_DELTA:
136-
if self._video_stream.duration is None:
137-
logger.warning('Video duration unavailable.')
138-
return 0
139-
# Streams use stream `time_base` as the time base.
140-
time_base = self._video_stream.time_base
141-
if time_base.denominator == 0:
142-
logger.warning(
143-
'Unable to calculate video duration: time_base (%s) has zero denominator!',
144-
str(time_base))
145-
return 0
146-
duration_sec = float(self._video_stream.duration / time_base)
147-
return round(duration_sec * self.frame_rate)
148-
149105
#
150106
# VideoStream Methods/Properties
151107
#
@@ -166,9 +122,7 @@ def name(self) -> Union[bytes, str]:
166122
@property
167123
def is_seekable(self) -> bool:
168124
"""True if seek() is allowed, False otherwise."""
169-
if not self._io is None and not self._io.seekable():
170-
return False
171-
return self._container.format.seek_to_pts
125+
return self._io.seekable()
172126

173127
@property
174128
def frame_size(self) -> Tuple[int, int]:
@@ -249,7 +203,8 @@ def seek(self, target: Union[FrameTimecode, float, int]) -> None:
249203
if not beginning:
250204
self.read(decode=False, advance=True)
251205
while self.position < target:
252-
self.read(decode=False, advance=True)
206+
if self.read(decode=False, advance=True) is False:
207+
break
253208

254209
def reset(self):
255210
""" Close and re-open the VideoStream (should be equivalent to calling `seek(0)`). """
@@ -279,10 +234,77 @@ def read(self, decode: bool = True, advance: bool = True) -> Union[ndarray, bool
279234
self._frame = next(self._container.decode(video=0))
280235
except av.error.EOFError:
281236
self._frame = last_frame
237+
if self._handle_eof():
238+
return self.read(decode, advance=True)
282239
return False
283240
except StopIteration:
284241
return False
285242
has_advanced = True
286243
if decode:
287244
return self._frame.to_ndarray(format='bgr24')
288245
return has_advanced
246+
247+
#
248+
# Private Methods/Properties
249+
#
250+
251+
@property
252+
def _video_stream(self):
253+
"""PyAV `av.video.stream.VideoStream` being used."""
254+
return self._container.streams.video[0]
255+
256+
@property
257+
def _codec_context(self):
258+
"""PyAV `av.codec.context.CodecContext` associated with the `video_stream`."""
259+
return self._video_stream.codec_context
260+
261+
def _get_duration(self) -> int:
262+
"""Get video duration as number of frames based on the video and set framerate."""
263+
# See https://pyav.org/docs/develop/api/time.html for details on how ffmpeg/PyAV
264+
# handle time calculations internally and which time base to use.
265+
assert self.frame_rate is not None, "Frame rate must be set before calling _get_duration!"
266+
# See if we can obtain the number of frames directly from the stream itself.
267+
if self._video_stream.frames > 0:
268+
return self._video_stream.frames
269+
# Calculate based on the reported container duration.
270+
duration_sec = None
271+
container = self._video_stream.container
272+
if container.duration is not None and container.duration > 0:
273+
# Containers use AV_TIME_BASE as the time base.
274+
duration_sec = float(self._video_stream.container.duration / av.time_base)
275+
# Lastly, if that calculation fails, try to calculate it based on the stream duration.
276+
if duration_sec is None or duration_sec < MAX_FPS_DELTA:
277+
if self._video_stream.duration is None:
278+
logger.warning('Video duration unavailable.')
279+
return 0
280+
# Streams use stream `time_base` as the time base.
281+
time_base = self._video_stream.time_base
282+
if time_base.denominator == 0:
283+
logger.warning(
284+
'Unable to calculate video duration: time_base (%s) has zero denominator!',
285+
str(time_base))
286+
return 0
287+
duration_sec = float(self._video_stream.duration / time_base)
288+
return round(duration_sec * self.frame_rate)
289+
290+
def _handle_eof(self):
291+
"""Fix issue where if thread_type is 'AUTO' the whole video is sometimes not decoded.
292+
293+
Re-open video if the threading mode is AUTO and we didn't decode all of the frames."""
294+
# Don't re-open the video if we already did, or if we already decoded all the frames.
295+
if self._reopened or self.frame_number >= self.duration:
296+
return False
297+
# Don't re-open the video if we can't seek or aren't in AUTO/FRAME thread_type mode.
298+
if not self.is_seekable or not self._video_stream.thread_type in ('AUTO', 'FRAME'):
299+
return False
300+
last_frame = self.frame_number
301+
orig_pos = self._io.tell()
302+
try:
303+
self._io.seek(0)
304+
container = av.open(self._io)
305+
except:
306+
self._io.seek(orig_pos)
307+
raise
308+
self._container = container
309+
self.seek(last_frame)
310+
return True

scenedetect/cli/context.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ def handle_options(
236236
self._open_video_stream(
237237
input_path=input_path,
238238
framerate=framerate,
239-
backend=self.config.get_value("global", "backend", ignore_default=True))
239+
backend=self.config.get_value("global", "backend", backend, ignore_default=True))
240240

241241
self.output_directory = output if output else self.config.get_value("global", "output")
242242
if self.output_directory:
@@ -756,7 +756,7 @@ def _open_video_stream(self, input_path: AnyStr, framerate: Optional[float],
756756
'Failed to open input video%s: %s' %
757757
(' using %s backend' % backend if backend else '', str(ex)),
758758
param_hint='-i/--input') from ex
759-
except IOError as ex:
759+
except OSError as ex:
760760
raise click.BadParameter('Input error:\n\n\t%s\n' % str(ex), param_hint='-i/--input')
761761

762762
def _open_stats_file(self, file_path: str):

scenedetect/stats_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ def save_to_csv(self,
195195
196196
Raises:
197197
ValueError: If both path and file are specified.
198-
IOError: If `path` cannot be opened or a write failure occurs.
198+
OSError: If `path` cannot be opened or a write failure occurs.
199199
"""
200200

201201
if path is not None and file is not None:

tests/test_video_stream.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,7 @@ def test_seek(self, vs_type: Type[VideoStream], test_video: VideoParameters):
252252

253253
def test_invalid_path(vs_type: Type[VideoStream]):
254254
"""Ensure correct exception is thrown if the path does not exist."""
255-
with pytest.raises(IOError):
255+
with pytest.raises(OSError):
256256
_ = vs_type('this_path_should_not_exist.mp4')
257257

258258

0 commit comments

Comments
 (0)