1616"""
1717
1818from logging import getLogger
19- from typing import BinaryIO , Optional , Tuple , Union
19+ from typing import AnyStr , BinaryIO , Optional , Tuple , Union
2020
2121import av
2222from numpy import ndarray
3232class 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
0 commit comments