generated from roboflow/template-python
-
Notifications
You must be signed in to change notification settings - Fork 3k
Video class refactor #1947
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
timmermansjoy
wants to merge
14
commits into
roboflow:develop
Choose a base branch
from
timmermansjoy:video-refactor
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Video class refactor #1947
Changes from 3 commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
af406c7
starting structure for new video class
timmermansjoy 5beb137
Merge remote-tracking branch 'myfork/video-refactor' into video-refactor
timmermansjoy b8f820d
fix(pre_commit): 🎨 auto format pre-commit hooks
pre-commit-ci[bot] 505689b
add better deprication warnings
timmermansjoy 15286b1
add ffmpeg (pyav) backend as support
timmermansjoy bb67f6f
add better depracated warnings
timmermansjoy fab59ea
format docstrings correctly
timmermansjoy 9561e3d
keep keep naming consistent with pyav
timmermansjoy a9bf329
add test notebook (will removed later)
timmermansjoy 9bd156d
Merge branch 'video-refactor' of https://github.com/timmermansjoy/sup…
timmermansjoy 83d3b65
fix(pre_commit): 🎨 auto format pre-commit hooks
pre-commit-ci[bot] 375883c
remove answered question
timmermansjoy f25e3b1
Merge branch 'video-refactor' of https://github.com/timmermansjoy/sup…
timmermansjoy cb2f265
fix(pre_commit): 🎨 auto format pre-commit hooks
pre-commit-ci[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
from .core import Video | ||
from .utils import VideoInfo | ||
|
||
__all__ = ["Video", "VideoInfo"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
BACKENDS = { | ||
"opencv": "supervision.video.backends.opencv", | ||
"pyav": "supervision.video.backends.pyav", | ||
} | ||
|
||
|
||
__all__ = ["BACKENDS"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,118 @@ | ||
from __future__ import annotations | ||
|
||
from collections.abc import Iterator | ||
from typing import Protocol, runtime_checkable | ||
|
||
import numpy as np | ||
|
||
from ..utils import VideoInfo | ||
timmermansjoy marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
|
||
@runtime_checkable | ||
class Backend(Protocol): | ||
""" | ||
The high-level :pyclass:`~supervision.video.Video` adapter instantiates a | ||
backend - selected by name - and then only calls the methods defined | ||
below. Anything else is considered a private implementation detail. | ||
""" | ||
|
||
def __init__(self, source: str | int): | ||
"""Create a new backend for source. | ||
``source`` can be | ||
* ``str`` - file path, RTSP/HTTP URL … | ||
* ``int`` - webcam index (OpenCV-style) | ||
""" | ||
timmermansjoy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
def info(self) -> VideoInfo: | ||
"""Return static information (width / height / fps / total_frames).""" | ||
|
||
def read(self) -> tuple[bool, np.ndarray]: | ||
"""Decode the next frame. | ||
Returns ``(success, frame)`` where frame is a ``np.ndarray`` (HxWx3). | ||
""" | ||
|
||
def grab(self) -> bool: | ||
"""Grab the next frame without decoding pixels. | ||
Equivalent to OpenCV's ``VideoCapture.grab``. Useful if the user only | ||
wants to skip frames quickly (stride > 1 for example). | ||
""" | ||
|
||
def seek(self, frame_idx: int) -> None: | ||
"""Seek to frame_idx so that the next :py:meth:`read` returns it.""" | ||
|
||
# Encoding --------------------------------------------------------------- | ||
timmermansjoy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
||
def writer( | ||
self, | ||
path: str, | ||
info: VideoInfo, | ||
codec: str | None = None, | ||
) -> Writer: | ||
"""Return a writer that encodes frames to path. | ||
Parameters | ||
---------- | ||
path: | ||
Target file path. | ||
info: | ||
Expected output resolution / fps (copied from source by default). | ||
codec: | ||
FourCC / codec name to override the backend default. | ||
""" | ||
|
||
# Iterator convenience --------------------------------------------------- | ||
|
||
def __iter__(self) -> Iterator[np.ndarray]: | ||
"""Yield successive frames until exhaustion. | ||
This is considered convenience behaviour; the default implementation | ||
below is fine for most back-ends. | ||
""" | ||
|
||
|
||
@runtime_checkable | ||
class Writer(Protocol): | ||
"""Protocol for an encoded video writer returned by :py:meth:`Backend.writer`.""" | ||
|
||
def write(self, frame: np.ndarray, frame_number: int, callback) -> None: | ||
"""Write a single BGR / RGB frame to the output stream.""" | ||
|
||
def close(self) -> None: | ||
"""Flush and close the underlying container / file descriptor.""" | ||
|
||
def __enter__(self): | ||
return self | ||
|
||
def __exit__(self, exc_type, exc, tb): | ||
self.close() | ||
return False # propagate exception (if any) | ||
|
||
|
||
# --------------------------------------------------------------------------- | ||
# Utility - a dummy writer that does nothing. Useful for testing. | ||
# --------------------------------------------------------------------------- | ||
|
||
|
||
class _NullWriter: | ||
"""Fallback Writer that silently drops every frame.""" | ||
|
||
def write(self, frame: np.ndarray, frame_number: int, callback) -> None: | ||
pass | ||
|
||
def close(self) -> None: | ||
pass | ||
|
||
def __enter__(self): | ||
return self | ||
|
||
def __exit__(self, exc_type, exc, tb): | ||
return False | ||
|
||
|
||
__all__ = [ | ||
"Backend", | ||
"Writer", | ||
] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,195 @@ | ||
from collections.abc import Callable, Iterator | ||
from typing import Any | ||
|
||
import cv2 | ||
import numpy as np | ||
|
||
from ..utils import VideoInfo | ||
from .base import Writer | ||
|
||
|
||
class OpenCVWriter: | ||
def __init__(self, vw: cv2.VideoWriter, info: VideoInfo): | ||
self._vw = vw | ||
self.info = info | ||
|
||
def write( | ||
self, | ||
frame: np.ndarray, | ||
frame_number: int, | ||
callback: Callable[[np.ndarray], None] | None = None, | ||
) -> None: | ||
if callback: | ||
frame = callback(frame, frame_number) | ||
if frame.shape[0] != self.info.height or frame.shape[1] != self.info.width: | ||
frame = cv2.resize(frame, (self.info.width, self.info.height)) | ||
self._vw.write(frame) | ||
|
||
def close(self) -> None: | ||
self._vw.release() | ||
|
||
def __enter__(self) -> Writer: | ||
return self | ||
|
||
def __exit__(self, exc_type, exc_val, exc_tb) -> None: | ||
self.close() | ||
|
||
|
||
class Backend: | ||
timmermansjoy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
def __init__(self, source_path: str | int): | ||
"""Create a new backend for source. | ||
`source`` can b | ||
* ``str`` - file path, RTSP/HTTP URL … | ||
* ``int`` - webcam index (OpenCV-style) | ||
""" | ||
self.source_path = source_path | ||
self.cap = cv2.VideoCapture(self.source_path) | ||
if not self.cap.isOpened(): | ||
raise ValueError(f"Could not open video source {self.source_path}") | ||
|
||
def info(self) -> VideoInfo: | ||
"""Return static information (width / height / fps / total_frames).""" | ||
from ..core import VideoInfo | ||
|
||
w = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) | ||
h = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) | ||
precise_fps = self.cap.get(cv2.CAP_PROP_FPS) | ||
fps = int(round(precise_fps, 0)) | ||
n = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT)) | ||
return VideoInfo(w, h, fps, precise_fps, n) | ||
|
||
def read(self) -> tuple[bool, np.ndarray]: | ||
"""Decode the next frame.""" | ||
return self.cap.read() | ||
|
||
def grab(self) -> bool: | ||
"""Grab the next frame without decoding pixels.""" | ||
return self.cap.grab() | ||
|
||
def seek(self, frame_idx: int) -> None: | ||
"""Seek to frame_idx so that the next :py:meth:`read` returns it.""" | ||
self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_idx) | ||
|
||
# ? Do we want to mix and match different writers to different backends? | ||
timmermansjoy marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
def writer(self, path: str, info: VideoInfo, codec: str | None = None) -> Writer: | ||
"""Return a writer that encodes frames to path. | ||
Parameters | ||
---------- | ||
path: | ||
Target file path. | ||
info:" | ||
Expected output resolution / fps (copied from source by default). | ||
codec: | ||
FourCC / codec name to override the backend default. | ||
""" | ||
fourcc = ( | ||
cv2.VideoWriter_fourcc(*codec) if codec else cv2.VideoWriter_fourcc(*"mp4v") | ||
) | ||
vw = cv2.VideoWriter(path, fourcc, info.fps, (info.width, info.height)) | ||
return OpenCVWriter(vw, info) | ||
|
||
def frames( | ||
self, | ||
stride: int = 1, | ||
start: int = 0, | ||
end: int | None = None, | ||
resolution_wh: tuple[int, int] | None = None, | ||
interpolation=cv2.INTER_LINEAR, | ||
) -> Iterator[np.ndarray]: | ||
"""Yield frames lazily, with optional skipping and resizing. | ||
Parameters | ||
---------- | ||
stride: | ||
Number of frames to skip between yielded frames (``1`` yields every frame). | ||
start: | ||
First frame index (0-based) to yield. | ||
end: | ||
Index after the last frame to yield. ``None`` means until exhaustion. | ||
resolution_wh: | ||
Optional ``(width, height)`` to resize each yielded frame to. | ||
Yields | ||
------ | ||
np.ndarray | ||
The next decoded (and optionally resized) video frame. | ||
""" | ||
if stride < 1: | ||
raise ValueError("stride must be >= 1") | ||
|
||
info = self.info() | ||
total = ( | ||
info.total_frames if info.total_frames and info.total_frames > 0 else None | ||
) | ||
if end is None and total is not None: | ||
end = total | ||
if start < 0 or start >= end: | ||
return | ||
|
||
# Position capture at the start frame | ||
self.seek(start) | ||
current_idx = start | ||
infinate_stream = end is None | ||
|
||
while infinate_stream or current_idx < end: | ||
success, frame = self.read() | ||
if not success: | ||
break | ||
|
||
if resolution_wh is not None and ( | ||
frame.shape[1] != resolution_wh[0] or frame.shape[0] != resolution_wh[1] | ||
): | ||
frame = cv2.resize(frame, resolution_wh, interpolation=interpolation) | ||
|
||
yield frame | ||
current_idx += 1 | ||
|
||
# Efficiently skip stride-1 frames with grab() | ||
skip = stride - 1 | ||
while skip and current_idx < end: | ||
grabbed = self.grab() | ||
if not grabbed: | ||
return | ||
current_idx += 1 | ||
skip -= 1 | ||
|
||
def __iter__(self) -> Iterator[np.ndarray]: | ||
"""Yield successive frames until exhaustion. | ||
This is considered convenience behaviour; the default implementation | ||
below is fine for most back-ends. | ||
""" | ||
while True: | ||
success, frame = self.read() | ||
if not success: | ||
break | ||
yield frame | ||
|
||
def release(self): | ||
"""Release the video file.""" | ||
self.cap.release() | ||
|
||
def __enter__(self): | ||
return self | ||
|
||
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: | ||
self.release() | ||
|
||
def __len__(self) -> int: | ||
n = self.info().total_frames | ||
if n is None or n < 0: | ||
raise TypeError("length is unknown for this stream") | ||
return n | ||
|
||
def __getitem__(self, index: int) -> np.ndarray: | ||
current = int(self.cap.get(cv2.CAP_PROP_POS_FRAMES)) | ||
self.cap.set(cv2.CAP_PROP_POS_FRAMES, index) | ||
success, frame = self.read() | ||
self.cap.set( | ||
cv2.CAP_PROP_POS_FRAMES, current | ||
) # ? Do we want to restore the video to the original position? | ||
if not success: | ||
raise IndexError(f"Failed to read frame {index}") | ||
return frame |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.