feat(video): require PyAV during cv2 transition (#2438)
- Add the PyAV-backed file-video and audio fallback to the compatibility layer. - Declare PyAV alongside OpenCV until the final dependency-removal integration. - _VideoWriter now rejects is_color=False (NotImplementedError) instead of silently dropping it, since the PyAV fallback only encodes 3-channel frames. - _mux_audio cleanup (container closes, temp-file removal) is now best-effort so a failing close/remove in finally can no longer mask the primary result or the original exception. - The subprocess used to validate the cv2-free fallback had no timeout; a hang (import deadlock, codec probe stall) could block the whole CI run. Added a 60s timeout so a hang fails fast with a clear traceback instead of an opaque suite-wide stall. - process_video(preserve_audio=True) docstring still described the old ffmpeg-based muxing; audio remuxing was reimplemented with PyAV and no longer requires an external ffmpeg executable. - get_video_frames_generator's documented webcam fallback (`_cv2.VideoCapture(0)`) silently fails under the PyAV backend: the BackendUnavailableError raised for integer sources was swallowed with no logging, so isOpened() just returns False with zero diagnostic signal. Doc note now states the limitation explicitly and the capture logs a warning instead of failing silently. --------- Co-authored-by: Codex <codex@openai.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
This commit is contained in:
parent
c3496134bc
commit
20b7c085b7
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
description: "Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version."
|
||||
date_modified: 2026-07-15
|
||||
date_modified: 2026-07-16
|
||||
---
|
||||
|
||||
# Changelog
|
||||
|
|
@ -48,6 +48,11 @@ date_modified: 2026-07-15
|
|||
- Fixed: dataset IO/export edge cases now avoid mutating caller-owned `Detections` during `DetectionDataset` construction, reject non-integer and out-of-range class ids with a clear `ValueError`, load COCO annotations that omit optional `iscrowd`/`area` fields, expose `DetectionDataset.from_coco(use_iscrowd=...)` without changing the existing positional `show_progress` argument, export mask pixel area to COCO when no stored area is present, ignore folder-structure root clutter and non-image files inside class folders, and accept PIL-readable YOLO images such as RGBA or palette PNGs.
|
||||
|
||||
### Added
|
||||
- Added a cv2-free PyAV fallback for file-video capture, writing, frame seeking,
|
||||
metadata, and `process_video(preserve_audio=True)` audio remuxing. OpenCV remains
|
||||
the primary backend when available; `av>=14.2.0` is now required alongside
|
||||
OpenCV during the transition, with the later OpenCV-removal integration removing
|
||||
the OpenCV dependency.
|
||||
- Added a cv2-free Hershey text fallback covering all eight OpenCV font faces,
|
||||
italic variants, exact text metrics, and packaged glyph provenance. OpenCV
|
||||
remains the primary renderer when available; the fallback uses the private
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ classifiers = [
|
|||
"Typing :: Typed",
|
||||
]
|
||||
dependencies = [
|
||||
"av>=14.2",
|
||||
"defusedxml>=0.7.1",
|
||||
"matplotlib>=3.6",
|
||||
"numpy>=1.21.2",
|
||||
|
|
@ -191,8 +192,12 @@ mypy_path = "src"
|
|||
explicit_package_bases = true
|
||||
ignore_missing_imports = false
|
||||
python_version = "3.10"
|
||||
warn_unused_ignores = true
|
||||
strict = true
|
||||
overrides = [ { module = [ "examples.*", "tests.*" ], ignore_errors = true } ]
|
||||
overrides = [
|
||||
{ module = [ "examples.*", "tests.*" ], ignore_errors = true },
|
||||
{ module = [ "supervision._cv2" ], warn_unused_ignores = false },
|
||||
]
|
||||
|
||||
[tool.pytest]
|
||||
ini_options.testpaths = [ "src", "tests" ]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from supervision._cv2._color import _cvt_color, _merge, _split
|
||||
from supervision._cv2._common import BackendUnavailableError, _unavailable
|
||||
from supervision._cv2._common import BackendUnavailableError
|
||||
from supervision._cv2._components import (
|
||||
_connected_components,
|
||||
_connected_components_with_stats,
|
||||
|
|
@ -40,6 +40,11 @@ from supervision._cv2._transform import (
|
|||
_get_rotation_matrix_2d,
|
||||
_warp_affine,
|
||||
)
|
||||
from supervision._cv2._video import (
|
||||
_video_writer_fourcc,
|
||||
_VideoCapture,
|
||||
_VideoWriter,
|
||||
)
|
||||
from supervision._cv2.constants import (
|
||||
_BORDER_CONSTANT,
|
||||
_CAP_PROP_FPS,
|
||||
|
|
@ -82,7 +87,7 @@ else:
|
|||
_IS_CV2_AVAILABLE = True
|
||||
|
||||
if _IS_CV2_AVAILABLE:
|
||||
from cv2 import (
|
||||
from cv2 import ( # type: ignore[attr-defined]
|
||||
BORDER_CONSTANT,
|
||||
CAP_PROP_FPS,
|
||||
CAP_PROP_FRAME_COUNT,
|
||||
|
|
@ -116,7 +121,7 @@ if _IS_CV2_AVAILABLE:
|
|||
RETR_TREE,
|
||||
VideoCapture,
|
||||
VideoWriter,
|
||||
VideoWriter_fourcc,
|
||||
VideoWriter_fourcc, # type: ignore[attr-defined]
|
||||
addWeighted,
|
||||
approxPolyDP,
|
||||
blur,
|
||||
|
|
@ -185,39 +190,41 @@ else:
|
|||
LINE_AA = _LINE_AA
|
||||
RETR_TREE = _RETR_TREE
|
||||
|
||||
VideoCapture = _unavailable
|
||||
VideoWriter = _unavailable
|
||||
VideoWriter_fourcc = _unavailable
|
||||
addWeighted = _add_weighted
|
||||
approxPolyDP = _approx_poly_dp
|
||||
blur = _blur
|
||||
circle = _circle
|
||||
connectedComponents = _connected_components
|
||||
connectedComponentsWithStats = _connected_components_with_stats
|
||||
contourArea = _contour_area
|
||||
convertScaleAbs = _convert_scale_abs
|
||||
copyMakeBorder = _copy_make_border
|
||||
cvtColor = _cvt_color
|
||||
distanceTransform = _distance_transform
|
||||
drawContours = _draw_contours
|
||||
ellipse = _ellipse
|
||||
fillPoly = _fill_poly
|
||||
findContours = _find_contours
|
||||
flip = _flip
|
||||
getRotationMatrix2D = _get_rotation_matrix_2d
|
||||
getTextSize = _get_text_size
|
||||
imread = _imread
|
||||
imwrite = _imwrite
|
||||
intersectConvexConvex = _intersect_convex_convex
|
||||
line = _line
|
||||
mean = _mean
|
||||
merge = _merge
|
||||
polylines = _polylines
|
||||
putText = _put_text
|
||||
rectangle = _rectangle
|
||||
resize = _resize
|
||||
split = _split
|
||||
warpAffine = _warp_affine
|
||||
# Fallback implementations when cv2 is not available. Suppress type errors because
|
||||
# fallback types differ from cv2 types, but are functionally equivalent.
|
||||
VideoCapture = _VideoCapture # type: ignore[assignment,misc]
|
||||
VideoWriter = _VideoWriter # type: ignore[assignment,misc]
|
||||
VideoWriter_fourcc = _video_writer_fourcc # type: ignore[assignment]
|
||||
addWeighted = _add_weighted # type: ignore[assignment]
|
||||
approxPolyDP = _approx_poly_dp # type: ignore[assignment]
|
||||
blur = _blur # type: ignore[assignment]
|
||||
circle = _circle # type: ignore[assignment]
|
||||
connectedComponents = _connected_components # type: ignore[assignment]
|
||||
connectedComponentsWithStats = _connected_components_with_stats # type: ignore[assignment]
|
||||
contourArea = _contour_area # type: ignore[assignment]
|
||||
convertScaleAbs = _convert_scale_abs # type: ignore[assignment]
|
||||
copyMakeBorder = _copy_make_border # type: ignore[assignment]
|
||||
cvtColor = _cvt_color # type: ignore[assignment]
|
||||
distanceTransform = _distance_transform # type: ignore[assignment]
|
||||
drawContours = _draw_contours # type: ignore[assignment]
|
||||
ellipse = _ellipse # type: ignore[assignment]
|
||||
fillPoly = _fill_poly # type: ignore[assignment]
|
||||
findContours = _find_contours # type: ignore[assignment]
|
||||
flip = _flip # type: ignore[assignment]
|
||||
getRotationMatrix2D = _get_rotation_matrix_2d # type: ignore[assignment]
|
||||
getTextSize = _get_text_size # type: ignore[assignment]
|
||||
imread = _imread # type: ignore[assignment]
|
||||
imwrite = _imwrite # type: ignore[assignment]
|
||||
intersectConvexConvex = _intersect_convex_convex # type: ignore[assignment]
|
||||
line = _line # type: ignore[assignment]
|
||||
mean = _mean # type: ignore[assignment]
|
||||
merge = _merge # type: ignore[assignment]
|
||||
polylines = _polylines # type: ignore[assignment]
|
||||
putText = _put_text # type: ignore[assignment]
|
||||
rectangle = _rectangle # type: ignore[assignment]
|
||||
resize = _resize # type: ignore[assignment]
|
||||
split = _split # type: ignore[assignment]
|
||||
warpAffine = _warp_affine # type: ignore[assignment]
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,397 @@
|
|||
"""Private PyAV-backed video and audio fallbacks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Callable, Iterator
|
||||
from fractions import Fraction
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from supervision._cv2._common import BackendUnavailableError
|
||||
from supervision._cv2.constants import (
|
||||
_CAP_PROP_FPS,
|
||||
_CAP_PROP_FRAME_COUNT,
|
||||
_CAP_PROP_FRAME_HEIGHT,
|
||||
_CAP_PROP_FRAME_WIDTH,
|
||||
_CAP_PROP_POS_FRAMES,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CODECS = {
|
||||
"mp4v": ("mpeg4", "yuv420p"),
|
||||
"xvid": ("mpeg4", "yuv420p"),
|
||||
"avc1": ("libx264", "yuv420p"),
|
||||
"h264": ("libx264", "yuv420p"),
|
||||
"mjpg": ("mjpeg", "yuvj420p"),
|
||||
"vp09": ("libvpx-vp9", "yuv420p"),
|
||||
}
|
||||
|
||||
|
||||
def _video_writer_fourcc(*chars: str) -> int:
|
||||
"""Encode four single-character strings using OpenCV's integer layout."""
|
||||
if len(chars) != 4 or any(len(char) != 1 for char in chars):
|
||||
raise TypeError("VideoWriter_fourcc requires exactly four characters")
|
||||
return sum(ord(char) << (8 * index) for index, char in enumerate(chars))
|
||||
|
||||
|
||||
def _decode_fourcc(fourcc: int) -> str:
|
||||
"""Decode a fourcc integer into its four-character representation."""
|
||||
return "".join(chr((fourcc >> (8 * index)) & 0xFF) for index in range(4))
|
||||
|
||||
|
||||
def _codec_details(fourcc: int) -> tuple[str, str]:
|
||||
"""Return the PyAV codec and pixel format for a supported fourcc."""
|
||||
code = _decode_fourcc(fourcc).lower()
|
||||
try:
|
||||
return _CODECS[code]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unsupported video codec: {code!r}") from exc
|
||||
|
||||
|
||||
class _VideoCapture:
|
||||
"""Expose OpenCV-shaped file capture backed by PyAV decoding."""
|
||||
|
||||
def __init__(self, source: str | os.PathLike[str] | int) -> None:
|
||||
"""Open a file source and retain a lazy PyAV frame iterator."""
|
||||
self._container: Any = None
|
||||
self._stream: Any = None
|
||||
self._frames: Iterator[Any] | None = None
|
||||
self._source = source
|
||||
self._position = 0
|
||||
self._frame_count_cache: int | None = None
|
||||
self._opened = False
|
||||
self._error: Exception | None = None
|
||||
|
||||
try:
|
||||
if isinstance(source, int):
|
||||
raise BackendUnavailableError(
|
||||
"PyAV fallback supports file paths, not webcam device indexes."
|
||||
)
|
||||
self._container = av.open(str(source), mode="r")
|
||||
if not self._container.streams.video:
|
||||
raise ValueError(f"Video source has no video stream: {source}")
|
||||
self._stream = self._container.streams.video[0]
|
||||
self._frames = iter(self._container.decode(video=self._stream.index))
|
||||
self._opened = True
|
||||
except Exception as exc:
|
||||
self._error = exc
|
||||
logger.warning("Failed to open video source %r: %s", source, exc)
|
||||
self.release()
|
||||
|
||||
def isOpened(self) -> bool:
|
||||
"""Return whether the underlying video file is open for reading."""
|
||||
return self._opened
|
||||
|
||||
def _frame_count(self) -> int:
|
||||
"""Return the stream count, decoding a second handle if metadata lacks it."""
|
||||
if self._frame_count_cache is not None:
|
||||
return self._frame_count_cache
|
||||
|
||||
count = int(getattr(self._stream, "frames", 0) or 0)
|
||||
if count <= 0 and not isinstance(self._source, int):
|
||||
container = av.open(str(self._source), mode="r")
|
||||
try:
|
||||
count = sum(1 for _ in container.decode(video=self._stream.index))
|
||||
finally:
|
||||
container.close()
|
||||
self._frame_count_cache = count
|
||||
return count
|
||||
|
||||
def get(self, property_id: int) -> float:
|
||||
"""Return the supported OpenCV capture property as a float."""
|
||||
if not self._opened:
|
||||
return 0.0
|
||||
if property_id == _CAP_PROP_FRAME_WIDTH:
|
||||
return float(self._stream.width)
|
||||
if property_id == _CAP_PROP_FRAME_HEIGHT:
|
||||
return float(self._stream.height)
|
||||
if property_id == _CAP_PROP_FPS:
|
||||
rate = getattr(self._stream, "average_rate", None) or getattr(
|
||||
self._stream, "base_rate", None
|
||||
)
|
||||
return float(rate) if rate is not None else 0.0
|
||||
if property_id == _CAP_PROP_FRAME_COUNT:
|
||||
return float(self._frame_count())
|
||||
if property_id == _CAP_PROP_POS_FRAMES:
|
||||
return float(self._position)
|
||||
return 0.0
|
||||
|
||||
def _reset(self) -> None:
|
||||
"""Seek the decoder to the first frame and reset the logical position."""
|
||||
self._container.seek(0, stream=self._stream, backward=True)
|
||||
self._frames = iter(self._container.decode(video=self._stream.index))
|
||||
self._position = 0
|
||||
|
||||
def set(self, property_id: int, value: float) -> bool:
|
||||
"""Set the supported frame-position property using exact frame decoding."""
|
||||
if not self._opened or property_id != _CAP_PROP_POS_FRAMES:
|
||||
return False
|
||||
target = max(0, round(value))
|
||||
if target > self._frame_count():
|
||||
return False
|
||||
if target < self._position:
|
||||
self._reset()
|
||||
while self._position < target:
|
||||
success, _ = self.read()
|
||||
if not success:
|
||||
return False
|
||||
return True
|
||||
|
||||
def read(self) -> tuple[bool, npt.NDArray[np.uint8] | None]:
|
||||
"""Decode and return the next frame in OpenCV's BGR array format."""
|
||||
if not self._opened or self._frames is None:
|
||||
return False, None
|
||||
try:
|
||||
frame = next(self._frames)
|
||||
except StopIteration:
|
||||
return False, None
|
||||
except Exception as exc:
|
||||
self._error = exc
|
||||
return False, None
|
||||
|
||||
self._position += 1
|
||||
return True, frame.to_ndarray(format="bgr24")
|
||||
|
||||
def grab(self) -> bool:
|
||||
"""Decode and discard one frame."""
|
||||
success, _ = self.read()
|
||||
return success
|
||||
|
||||
def release(self) -> None:
|
||||
"""Close the PyAV container and make subsequent reads return false."""
|
||||
container = self._container
|
||||
self._container = None
|
||||
self._stream = None
|
||||
self._frames = None
|
||||
self._opened = False
|
||||
if container is not None:
|
||||
container.close()
|
||||
|
||||
|
||||
class _VideoWriter:
|
||||
"""Expose OpenCV-shaped video writing backed by PyAV encoding."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
filename: str | os.PathLike[str],
|
||||
fourcc: int,
|
||||
fps: float,
|
||||
frame_size: tuple[int, int],
|
||||
is_color: bool = True,
|
||||
) -> None:
|
||||
"""Open a PyAV writer for the requested codec and frame dimensions.
|
||||
|
||||
The PyAV fallback always encodes 3-channel BGR frames, so grayscale
|
||||
output is unsupported. ``is_color=False`` is rejected up front rather
|
||||
than silently ignored, keeping the OpenCV-shaped contract honest for
|
||||
callers that would otherwise expect single-channel writes.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If ``is_color`` is ``False``; grayscale
|
||||
writing is not supported by the PyAV fallback.
|
||||
"""
|
||||
if not is_color:
|
||||
raise NotImplementedError(
|
||||
"PyAV video fallback only supports color (3-channel BGR) frames; "
|
||||
"is_color=False is not implemented."
|
||||
)
|
||||
self._container: Any = None
|
||||
self._stream: Any = None
|
||||
self._width, self._height = frame_size
|
||||
self._opened = False
|
||||
self._error: Exception | None = None
|
||||
|
||||
try:
|
||||
codec, pixel_format = _codec_details(fourcc)
|
||||
self._container = av.open(str(filename), mode="w")
|
||||
rate = Fraction(str(fps)).limit_denominator(100_000)
|
||||
self._stream = self._container.add_stream(codec, rate=rate)
|
||||
self._stream.width = self._width
|
||||
self._stream.height = self._height
|
||||
self._stream.pix_fmt = pixel_format
|
||||
self._opened = True
|
||||
except Exception as exc:
|
||||
self._error = exc
|
||||
self.release()
|
||||
|
||||
def isOpened(self) -> bool:
|
||||
"""Return whether the writer initialized successfully."""
|
||||
return self._opened
|
||||
|
||||
def write(self, frame: npt.NDArray[np.uint8]) -> None:
|
||||
"""Encode one BGR frame and mux all packets produced by the encoder."""
|
||||
if not self._opened or self._container is None or self._stream is None:
|
||||
raise RuntimeError("Video writer is not open") from self._error
|
||||
if frame.shape != (self._height, self._width, 3):
|
||||
raise ValueError(
|
||||
"Video frame must have shape "
|
||||
f"({self._height}, {self._width}, 3), got {frame.shape}"
|
||||
)
|
||||
if frame.dtype != np.uint8:
|
||||
raise ValueError("Video frames must use uint8 dtype")
|
||||
|
||||
video_frame = av.VideoFrame.from_ndarray(
|
||||
np.ascontiguousarray(frame), format="bgr24"
|
||||
)
|
||||
for packet in self._stream.encode(video_frame):
|
||||
self._container.mux(packet)
|
||||
|
||||
def release(self) -> None:
|
||||
"""Flush delayed encoder packets and close the output container."""
|
||||
container = self._container
|
||||
stream = self._stream
|
||||
self._container = None
|
||||
self._stream = None
|
||||
self._opened = False
|
||||
if container is None:
|
||||
return
|
||||
try:
|
||||
if stream is not None:
|
||||
for packet in stream.encode():
|
||||
container.mux(packet)
|
||||
finally:
|
||||
container.close()
|
||||
|
||||
|
||||
def _copy_stream(container: Any, source_stream: Any) -> Any:
|
||||
"""Create an output stream with the codec parameters needed for remuxing."""
|
||||
codec = source_stream.codec_context.name
|
||||
if source_stream.type == "video":
|
||||
rate = source_stream.average_rate or source_stream.base_rate
|
||||
else:
|
||||
rate = source_stream.sample_rate
|
||||
output_stream = container.add_stream(codec, rate=rate)
|
||||
|
||||
if source_stream.type == "video":
|
||||
output_stream.width = source_stream.width
|
||||
output_stream.height = source_stream.height
|
||||
if source_stream.codec_context.format is not None:
|
||||
output_stream.pix_fmt = source_stream.codec_context.format.name
|
||||
else:
|
||||
output_stream.layout = source_stream.layout.name
|
||||
|
||||
extradata = source_stream.codec_context.extradata
|
||||
if extradata:
|
||||
output_stream.codec_context.extradata = extradata
|
||||
return output_stream
|
||||
|
||||
|
||||
def _timestamp_seconds(timestamp: int | None, time_base: Any) -> float | None:
|
||||
"""Convert a stream timestamp to seconds while preserving missing values."""
|
||||
return None if timestamp is None else float(timestamp * time_base)
|
||||
|
||||
|
||||
def _best_effort_cleanup(action: Callable[[], None], description: str) -> None:
|
||||
"""Run a cleanup action, logging and suppressing any failure.
|
||||
|
||||
Cleanup steps in a ``finally`` block must never raise, otherwise a failing
|
||||
``container.close()`` (or file removal) would mask or replace the primary
|
||||
result or the original exception that sent control into ``finally``.
|
||||
"""
|
||||
try:
|
||||
action()
|
||||
except Exception as exc:
|
||||
logger.debug("Cleanup step failed (%s): %s", description, exc)
|
||||
|
||||
|
||||
def _mux_audio(source_path: str, video_path: str) -> None:
|
||||
"""Remux the source's first audio stream into the processed video with PyAV."""
|
||||
source_container: Any = None
|
||||
video_container: Any = None
|
||||
output_container: Any = None
|
||||
temporary_path: str | None = None
|
||||
try:
|
||||
source_container = av.open(source_path, mode="r")
|
||||
video_container = av.open(video_path, mode="r")
|
||||
if not source_container.streams.audio or not video_container.streams.video:
|
||||
logger.info("No audio or video stream available; leaving output unchanged")
|
||||
return
|
||||
|
||||
source_audio = source_container.streams.audio[0]
|
||||
target_video = video_container.streams.video[0]
|
||||
suffix = Path(video_path).suffix
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=suffix, dir=str(Path(video_path).absolute().parent), delete=False
|
||||
) as temporary_file:
|
||||
temporary_path = temporary_file.name
|
||||
|
||||
output_container = av.open(temporary_path, mode="w")
|
||||
output_video = _copy_stream(output_container, target_video)
|
||||
output_audio = _copy_stream(output_container, source_audio)
|
||||
|
||||
# Copy encoded packets to avoid a lossy decode/re-encode cycle for both streams.
|
||||
video_base: int | None = None
|
||||
video_duration: float | None = None
|
||||
for packet in video_container.demux(target_video):
|
||||
if packet.pts is None and packet.dts is None:
|
||||
continue
|
||||
if video_base is None:
|
||||
video_base = packet.pts if packet.pts is not None else packet.dts
|
||||
if video_base is None:
|
||||
continue
|
||||
packet.pts = None if packet.pts is None else packet.pts - video_base
|
||||
packet.dts = None if packet.dts is None else packet.dts - video_base
|
||||
packet.stream = output_video
|
||||
output_container.mux(packet)
|
||||
packet_end = packet.pts
|
||||
if packet_end is not None:
|
||||
packet_end += packet.duration or 0
|
||||
packet_seconds = _timestamp_seconds(packet_end, target_video.time_base)
|
||||
if packet_seconds is not None:
|
||||
video_duration = max(video_duration or 0.0, packet_seconds)
|
||||
|
||||
# Rebase audio independently and stop at the processed video duration, matching
|
||||
# ffmpeg's `-shortest` behavior without requiring a system executable.
|
||||
audio_base: int | None = None
|
||||
for packet in source_container.demux(source_audio):
|
||||
if packet.pts is None and packet.dts is None:
|
||||
continue
|
||||
if audio_base is None:
|
||||
audio_base = packet.pts if packet.pts is not None else packet.dts
|
||||
if audio_base is None:
|
||||
continue
|
||||
relative_pts = None if packet.pts is None else packet.pts - audio_base
|
||||
relative_seconds = _timestamp_seconds(relative_pts, source_audio.time_base)
|
||||
if (
|
||||
video_duration is not None
|
||||
and relative_seconds is not None
|
||||
and relative_seconds > video_duration
|
||||
):
|
||||
break
|
||||
packet.pts = relative_pts
|
||||
packet.dts = None if packet.dts is None else packet.dts - audio_base
|
||||
packet.stream = output_audio
|
||||
output_container.mux(packet)
|
||||
|
||||
output_container.close()
|
||||
output_container = None
|
||||
video_container.close()
|
||||
video_container = None
|
||||
source_container.close()
|
||||
source_container = None
|
||||
os.replace(temporary_path, video_path)
|
||||
temporary_path = None
|
||||
except Exception as exc:
|
||||
logger.warning("Audio remuxing failed: %s. Output video has no audio.", exc)
|
||||
finally:
|
||||
# Cleanup runs best-effort: a failing close/remove here must not mask the
|
||||
# primary result or replace the original exception handled above.
|
||||
if output_container is not None:
|
||||
_best_effort_cleanup(output_container.close, "closing output container")
|
||||
if video_container is not None:
|
||||
_best_effort_cleanup(video_container.close, "closing video container")
|
||||
if source_container is not None:
|
||||
_best_effort_cleanup(source_container.close, "closing source container")
|
||||
if temporary_path is not None and os.path.exists(temporary_path):
|
||||
leftover_path = temporary_path
|
||||
_best_effort_cleanup(
|
||||
lambda: os.remove(leftover_path), "removing temporary file"
|
||||
)
|
||||
|
|
@ -1,9 +1,5 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
|
|
@ -18,6 +14,7 @@ import numpy.typing as npt
|
|||
from tqdm.auto import tqdm
|
||||
|
||||
from supervision import _cv2 as cv2
|
||||
from supervision._cv2._video import _mux_audio
|
||||
from supervision.utils.logger import _get_logger
|
||||
|
||||
logger = _get_logger(__name__)
|
||||
|
|
@ -170,73 +167,6 @@ class VideoSink:
|
|||
self.__writer = None
|
||||
|
||||
|
||||
def _mux_audio(source_path: str, video_path: str) -> None:
|
||||
"""Mux audio from `source_path` into `video_path` in-place using ffmpeg.
|
||||
|
||||
Args:
|
||||
source_path: Path to the original video file containing the audio stream.
|
||||
video_path: Path to the video-only file to be updated with audio.
|
||||
"""
|
||||
ffmpeg_path = shutil.which("ffmpeg")
|
||||
if ffmpeg_path is None:
|
||||
logger.warning(
|
||||
"ffmpeg not found on PATH. Audio will not be preserved. "
|
||||
"Install ffmpeg to enable audio preservation."
|
||||
)
|
||||
return
|
||||
|
||||
tmp_path = None
|
||||
try:
|
||||
tmp_fd, tmp_path = tempfile.mkstemp(
|
||||
suffix=os.path.splitext(video_path)[1],
|
||||
dir=os.path.dirname(os.path.abspath(video_path)),
|
||||
)
|
||||
os.close(tmp_fd)
|
||||
result = subprocess.run( # noqa: S603
|
||||
[
|
||||
ffmpeg_path,
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostats",
|
||||
"-i",
|
||||
video_path,
|
||||
"-i",
|
||||
source_path,
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"1:a:0?",
|
||||
"-shortest",
|
||||
tmp_path,
|
||||
],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
stderr_msg = result.stderr.decode(errors="replace").strip()
|
||||
logger.warning(
|
||||
"ffmpeg failed to mux audio (return code %d)%s. "
|
||||
"The output video will not have audio.",
|
||||
result.returncode,
|
||||
f": {stderr_msg}" if stderr_msg else "",
|
||||
)
|
||||
return
|
||||
os.replace(tmp_path, video_path)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Audio muxing failed: %s. Output video will not have audio.", exc
|
||||
)
|
||||
finally:
|
||||
if tmp_path is not None and os.path.exists(tmp_path):
|
||||
os.remove(tmp_path)
|
||||
|
||||
|
||||
def _validate_and_setup_video(
|
||||
source_path: str, start: int, end: int | None, iterative_seek: bool = False
|
||||
) -> tuple[cv2.VideoCapture, int, int]:
|
||||
|
|
@ -290,7 +220,11 @@ def get_video_frames_generator(
|
|||
Note:
|
||||
For live camera streams, use `cv2.VideoCapture` with an integer device
|
||||
index directly. `get_video_frames_generator` is designed for file-based
|
||||
sources; `cv2.VideoCapture` must be released by the caller when done:
|
||||
sources; `cv2.VideoCapture` must be released by the caller when done.
|
||||
This requires OpenCV to be installed — the PyAV-based fallback used
|
||||
when OpenCV is unavailable only supports file paths, not webcam device
|
||||
indexes; passing an integer source to it always leaves the capture
|
||||
closed (`isOpened()` returns `False`):
|
||||
|
||||
```python
|
||||
from supervision import _cv2 as cv2
|
||||
|
|
@ -379,11 +313,11 @@ def process_video(
|
|||
Default is False.
|
||||
progress_message: Description shown in the progress bar.
|
||||
preserve_audio: If True, copy the audio stream from `source_path` into
|
||||
`target_path` after frame processing. Requires `ffmpeg` on PATH
|
||||
(e.g. `apt install ffmpeg`, `brew install ffmpeg`). If ffmpeg is
|
||||
not found or the mux step fails, a warning is logged and the output
|
||||
video is saved without audio — no exception is raised. Audio is
|
||||
truncated to match the processed video duration. Default is False.
|
||||
`target_path` after frame processing. Remuxing is done with PyAV;
|
||||
no external `ffmpeg` executable is required. If the mux step
|
||||
fails, a warning is logged and the output video is saved without
|
||||
audio — no exception is raised. Audio is truncated to match the
|
||||
processed video duration. Default is False.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
"""Tests for the PyAV-backed video compatibility surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import av
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision import _cv2
|
||||
from supervision._cv2._video import (
|
||||
_mux_audio,
|
||||
_VideoCapture,
|
||||
_VideoWriter,
|
||||
)
|
||||
|
||||
|
||||
def _write_video(path: Path, values: list[int], fps: int = 5) -> None:
|
||||
"""Write a small deterministic MPEG-4 video for fallback tests."""
|
||||
container = av.open(str(path), mode="w")
|
||||
stream = container.add_stream("mpeg4", rate=fps)
|
||||
stream.width = 16
|
||||
stream.height = 16
|
||||
stream.pix_fmt = "yuv420p"
|
||||
try:
|
||||
for value in values:
|
||||
frame = av.VideoFrame.from_ndarray(
|
||||
np.full((16, 16, 3), value, dtype=np.uint8), format="bgr24"
|
||||
)
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in stream.encode():
|
||||
container.mux(packet)
|
||||
finally:
|
||||
container.close()
|
||||
|
||||
|
||||
def _write_video_with_audio(path: Path, frame_count: int = 5, fps: int = 5) -> None:
|
||||
"""Write a short video with one AAC audio stream for remux tests."""
|
||||
container = av.open(str(path), mode="w")
|
||||
video_stream = container.add_stream("mpeg4", rate=fps)
|
||||
video_stream.width = 16
|
||||
video_stream.height = 16
|
||||
video_stream.pix_fmt = "yuv420p"
|
||||
audio_stream = container.add_stream("aac", rate=8_000)
|
||||
audio_stream.layout = "mono"
|
||||
try:
|
||||
for value in range(frame_count):
|
||||
frame = av.VideoFrame.from_ndarray(
|
||||
np.full((16, 16, 3), value * 20, dtype=np.uint8), format="bgr24"
|
||||
)
|
||||
for packet in video_stream.encode(frame):
|
||||
container.mux(packet)
|
||||
|
||||
samples = np.zeros((1, 8_000), dtype=np.int16)
|
||||
audio_frame = av.AudioFrame.from_ndarray(samples, format="s16", layout="mono")
|
||||
audio_frame.sample_rate = 8_000
|
||||
audio_frame.pts = 0
|
||||
for packet in audio_stream.encode(audio_frame):
|
||||
container.mux(packet)
|
||||
for packet in video_stream.encode():
|
||||
container.mux(packet)
|
||||
for packet in audio_stream.encode():
|
||||
container.mux(packet)
|
||||
finally:
|
||||
container.close()
|
||||
|
||||
|
||||
def _run_without_opencv(source: str) -> None:
|
||||
"""Run a Python snippet with cv2 imports blocked."""
|
||||
env = os.environ.copy()
|
||||
source_path = str(Path(__file__).resolve().parents[2] / "src")
|
||||
env["PYTHONPATH"] = os.pathsep.join(
|
||||
filter(None, (source_path, env.get("PYTHONPATH")))
|
||||
)
|
||||
result = subprocess.run( # noqa: S603
|
||||
[sys.executable, "-c", source],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=60,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_video_module_uses_required_pyav_dependency() -> None:
|
||||
"""The video fallback imports required PyAV directly without a loader."""
|
||||
from supervision._cv2 import _video
|
||||
|
||||
assert _video.av.__name__ == "av"
|
||||
assert not hasattr(_video, "_load_av")
|
||||
|
||||
|
||||
def test_fallback_capture_reports_metadata_and_supports_exact_seek(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Fallback capture exposes metadata and starts decoding at requested frames."""
|
||||
source_path = tmp_path / "source.mp4"
|
||||
_write_video(source_path, [0, 40, 80, 120, 160])
|
||||
|
||||
capture = _VideoCapture(str(source_path))
|
||||
assert capture.isOpened()
|
||||
assert capture.get(_cv2.CAP_PROP_FRAME_WIDTH) == 16
|
||||
assert capture.get(_cv2.CAP_PROP_FRAME_HEIGHT) == 16
|
||||
assert capture.get(_cv2.CAP_PROP_FPS) == pytest.approx(5.0)
|
||||
assert capture.get(_cv2.CAP_PROP_FRAME_COUNT) == 5
|
||||
|
||||
assert capture.set(_cv2.CAP_PROP_POS_FRAMES, 3)
|
||||
success, frame = capture.read()
|
||||
capture.release()
|
||||
|
||||
assert success
|
||||
assert frame is not None
|
||||
assert frame.shape == (16, 16, 3)
|
||||
assert float(frame.mean()) == pytest.approx(120.0, abs=20.0)
|
||||
assert not capture.isOpened()
|
||||
|
||||
|
||||
def test_fallback_writer_default_codec_round_trips(tmp_path: Path) -> None:
|
||||
"""The guaranteed mp4v fallback writer creates a readable video."""
|
||||
target_path = tmp_path / "target.mp4"
|
||||
fourcc = _cv2.VideoWriter_fourcc(*"mp4v")
|
||||
writer = _VideoWriter(str(target_path), fourcc, 5.0, (16, 16))
|
||||
|
||||
assert writer.isOpened()
|
||||
for value in [0, 40, 80]:
|
||||
writer.write(np.full((16, 16, 3), value, dtype=np.uint8))
|
||||
writer.release()
|
||||
|
||||
capture = _VideoCapture(str(target_path))
|
||||
frames = []
|
||||
while True:
|
||||
success, frame = capture.read()
|
||||
if not success:
|
||||
break
|
||||
assert frame is not None
|
||||
frames.append(frame)
|
||||
capture.release()
|
||||
|
||||
assert len(frames) == 3
|
||||
assert target_path.stat().st_size > 0
|
||||
|
||||
|
||||
def test_fallback_video_works_when_opencv_is_blocked(tmp_path: Path) -> None:
|
||||
"""Production video APIs use PyAV when cv2 cannot be imported."""
|
||||
source_path = tmp_path / "source.mp4"
|
||||
target_path = tmp_path / "target.mp4"
|
||||
_write_video(source_path, [0, 40, 80])
|
||||
source = f"""
|
||||
import sys
|
||||
|
||||
|
||||
class BlockCv2:
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname == "cv2":
|
||||
raise ModuleNotFoundError("blocked for test")
|
||||
return None
|
||||
|
||||
|
||||
sys.meta_path.insert(0, BlockCv2())
|
||||
from supervision import _cv2
|
||||
from supervision.utils.video import VideoInfo, VideoSink, get_video_frames_generator
|
||||
|
||||
assert _cv2._IS_CV2_AVAILABLE is False
|
||||
info = VideoInfo.from_video_path({str(source_path)!r})
|
||||
assert (info.width, info.height, info.total_frames) == (16, 16, 3)
|
||||
frames = list(get_video_frames_generator({str(source_path)!r}, start=1, end=3))
|
||||
assert len(frames) == 2
|
||||
with VideoSink({str(target_path)!r}, info) as sink:
|
||||
for frame in frames:
|
||||
sink.write_frame(frame)
|
||||
assert _cv2.VideoCapture({str(target_path)!r}).isOpened()
|
||||
"""
|
||||
_run_without_opencv(source)
|
||||
|
||||
|
||||
def test_mux_audio_remuxes_first_audio_stream_and_truncates_to_video(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Audio remuxing uses PyAV and keeps the processed video duration."""
|
||||
source_path = tmp_path / "source_with_audio.mp4"
|
||||
target_path = tmp_path / "target.mp4"
|
||||
_write_video_with_audio(source_path, frame_count=5)
|
||||
_write_video(target_path, [0, 40, 80], fps=5)
|
||||
|
||||
_mux_audio(str(source_path), str(target_path))
|
||||
|
||||
output = av.open(str(target_path))
|
||||
try:
|
||||
assert len(output.streams.video) == 1
|
||||
assert len(output.streams.audio) == 1
|
||||
assert output.streams.video[0].frames == 3
|
||||
finally:
|
||||
output.close()
|
||||
|
||||
|
||||
def test_mux_audio_leaves_target_unchanged_on_failure(tmp_path: Path) -> None:
|
||||
"""A failed PyAV remux never replaces the existing target file."""
|
||||
target_path = tmp_path / "target.mp4"
|
||||
_write_video(target_path, [0, 40, 80])
|
||||
original = target_path.read_bytes()
|
||||
|
||||
_mux_audio(str(tmp_path / "missing.mp4"), str(target_path))
|
||||
|
||||
assert target_path.read_bytes() == original
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from queue import Empty, Full
|
||||
from queue import Queue as StdQueue
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
|
@ -13,7 +12,6 @@ from supervision import _cv2 as cv2
|
|||
from supervision.utils.video import (
|
||||
FPSMonitor,
|
||||
VideoInfo,
|
||||
_mux_audio,
|
||||
get_video_frames_generator,
|
||||
process_video,
|
||||
)
|
||||
|
|
@ -625,59 +623,6 @@ def test_process_video_no_audio_by_default(dummy_video_path, tmp_path) -> None:
|
|||
mock_mux.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("which_rv", "run_kwargs"),
|
||||
[
|
||||
pytest.param(None, {}, id="ffmpeg_missing"),
|
||||
pytest.param(
|
||||
"/usr/bin/ffmpeg",
|
||||
{"return_value": MagicMock(returncode=1, stderr=b"")},
|
||||
id="ffmpeg_fails",
|
||||
),
|
||||
pytest.param(
|
||||
"/usr/bin/ffmpeg",
|
||||
{"side_effect": OSError("mux failed")},
|
||||
id="subprocess_raises",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_mux_audio_file_unchanged_on_failure(
|
||||
dummy_video_path, tmp_path, which_rv, run_kwargs
|
||||
) -> None:
|
||||
"""_mux_audio leaves the output file unchanged when muxing cannot complete."""
|
||||
target_path = str(tmp_path / "video.mp4")
|
||||
shutil.copy(dummy_video_path, target_path)
|
||||
original_size = os.path.getsize(target_path)
|
||||
|
||||
with (
|
||||
patch("supervision.utils.video.shutil.which", return_value=which_rv),
|
||||
patch("supervision.utils.video.subprocess.run", **run_kwargs),
|
||||
):
|
||||
_mux_audio(source_path=dummy_video_path, video_path=target_path)
|
||||
|
||||
assert os.path.getsize(target_path) == original_size
|
||||
|
||||
|
||||
def test_mux_audio_replaces_file_on_success(dummy_video_path, tmp_path) -> None:
|
||||
"""_mux_audio calls os.replace with video_path as destination on success."""
|
||||
target_path = str(tmp_path / "video.mp4")
|
||||
shutil.copy(dummy_video_path, target_path)
|
||||
|
||||
success_result = MagicMock()
|
||||
success_result.returncode = 0
|
||||
success_result.stderr = b""
|
||||
|
||||
with (
|
||||
patch("supervision.utils.video.shutil.which", return_value="/usr/bin/ffmpeg"),
|
||||
patch("supervision.utils.video.subprocess.run", return_value=success_result),
|
||||
patch("supervision.utils.video.os.replace") as mock_replace,
|
||||
):
|
||||
_mux_audio(source_path=dummy_video_path, video_path=target_path)
|
||||
|
||||
mock_replace.assert_called_once()
|
||||
assert mock_replace.call_args[0][1] == target_path
|
||||
|
||||
|
||||
def test_get_video_frames_generator_with_start_end(dummy_video_path) -> None:
|
||||
"""
|
||||
Verify that get_video_frames_generator respects start and end frame indices.
|
||||
|
|
|
|||
79
uv.lock
79
uv.lock
|
|
@ -123,6 +123,82 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "av"
|
||||
version = "17.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.11' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.11' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/e3/477fa20578c284abeda08d91b63ee9abaebc93445d8feeb989d3d444bae1/av-17.1.0.tar.gz", hash = "sha256:7f1e71ff621b66253333926f948e00faae11d855b2442133c65128bca64cdeb3", size = 4288546, upload-time = "2026-06-07T05:52:55.999Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/92/c9d0cea4f6f8f93f5b15a39f99d2d593f922484f22a2d98a8d482283e15b/av-17.1.0-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:19c84fd72af5ef81a20f18fbc6f9aedff9e1455e53a7062c1d4c95926d73da4e", size = 22622703, upload-time = "2026-06-07T05:51:40.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/57/74399770aa103ee4b5ff6da1781440c91a41901d89abb2433fe88773246e/av-17.1.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:19264c9bb4bee404accc7ce9ec461f2044b7f577a70234d29aafde31ed17de46", size = 18273538, upload-time = "2026-06-07T05:51:43.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/17/27c85b12e9ffa8f3f6854358b3eabcd91f3c29c7dac36843fa1376e833f4/av-17.1.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:22dff0ae582d10ef08c75c2150a4fd27cfc26653b54930c7c27b9f7b3aa20723", size = 34519101, upload-time = "2026-06-07T05:51:45.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/a4/542d4bfd9f4aec5f3265985b9dbc6b259d45c2e668f9714e5f4e05b71e64/av-17.1.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:90c49bc9608377d01e82e747377505419a229464873341db18202d5dddecce5a", size = 36647600, upload-time = "2026-06-07T05:51:48.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/1e/63bd5c59580f38109fa4c452b29b715a20c9a5eb3a078b3c447484593c40/av-17.1.0-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:cc5a5247622cb77e24c342364eb68f88c1442ddfaab60c1f1f483359d3cc7879", size = 25786289, upload-time = "2026-06-07T05:51:51.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/30/78155cef0c9f8bc13f044130192c58bf962f2c9066982ff3593afe8d27f1/av-17.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff457ed419348e5b8e8c811d341389b052c5e4d5839da3794d019b125b9fe830", size = 35599848, upload-time = "2026-06-07T05:51:54.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/cb/ae1d7a735a5ad9dc502dba864c51d605cbe932a769218352fd570254c38e/av-17.1.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1370b11a697eb3f2555906f8ab3519b0cfe48425d7830a3996ad42e6bffafda5", size = 26776479, upload-time = "2026-06-07T05:51:56.788Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/40/128429b9eb0c4a2beb122ed8d04b189515df68967987c2654a2e262a5c43/av-17.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3dcd41e53f53f9a3260751d9c3c11d34e93d70d61e506c81f13dbc1e3606e07b", size = 37763744, upload-time = "2026-06-07T05:51:59.222Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/6a/5980e7bbeeadfd7a9db8e38e9f1140a3e0c392fccc31bd7b1e4a75cf5a96/av-17.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:3453b06075c7bb973fdb6de52563f7692ff05cbc64c0bb45f4fd6e8709131f2f", size = 28126516, upload-time = "2026-06-07T05:52:01.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/87/8036b5c781bc3639ea04ef42d4e26da253bd4bd4311d8705b6a1c8824047/av-17.1.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:ad7b4aa011093324b7118245f50ac6db244cfe9900d4072508a5245a2b0d3f41", size = 22460847, upload-time = "2026-06-07T05:52:04.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/af/dfdf6fc7b17814b50d0aa9e7a7e37b87be91be3890f44b0d525433cd1fd1/av-17.1.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:43ebbe977f19a7f2d2bd1a4e119675a0b15e05852cf7309846b6ab922ba7ffe9", size = 18159115, upload-time = "2026-06-07T05:52:06.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/13/64f6c466471cea225b8b2f4cdc51a571f8a286984b55a08d169b932fda5d/av-17.1.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6a20658ec7d96a70e14b1196eff00b7cdd8831ac3b99868e16b8ba8b24090847", size = 33224427, upload-time = "2026-06-07T05:52:09.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/43/96b35170bf2e64e00a41748c6400ff73232dc0fc62ded283679fb07c7fe0/av-17.1.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f9a65d1f48b818323fb411e80358f89d77dec340b01d27c6b2dfbb9cbf4b779f", size = 35370183, upload-time = "2026-06-07T05:52:11.959Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/b3/8e8b4b6498731bfbd88e8399a756543f8088f1bd33d08eab678b5aebe728/av-17.1.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:58f7593726437cda5bd19793027e027768450b5c4a594777bf487798a33db702", size = 24459265, upload-time = "2026-06-07T05:52:14.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/ac/ceb84b7553db21f1143d817245c560d9267168e1e58b1a8eeae2b62c4d04/av-17.1.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:bbab058bd965309f39962e53caac8126987c68c0be094fc4f9427e5615b0218f", size = 34283709, upload-time = "2026-06-07T05:52:17.389Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/f9/4115fd84148c9a1cf365096694be6ac882fd3cd3cdb7a2f35e71fecf1631/av-17.1.0-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:9514cfda85180554c430695282faf4be3ffdf95775d8519733821244eecb58e0", size = 25397573, upload-time = "2026-06-07T05:52:20.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/ac/92e52d5ed0e0b84d9d93e52b4338c2713d8a44082b8696e6516fdae7c4e4/av-17.1.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e1c90f85cd7431ede95b11e8e711571a896ebea433f298849c2c0f1594c8d86e", size = 36451495, upload-time = "2026-06-07T05:52:22.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/f2/53a7cd34adb6a971d7e6d99663e74db286966c9db8afdca17472fdf0f98e/av-17.1.0-cp311-abi3-win_amd64.whl", hash = "sha256:5df5c1172ef1cf65a1529d612f7da7798ce2cf82c1ff7212466b538a6cc7214c", size = 28036393, upload-time = "2026-06-07T05:52:25.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/47/cd9ae0edf2206351c1251bb94b5ec58728e42c5f6ee16c03c412f3a1bb3e/av-17.1.0-cp311-abi3-win_arm64.whl", hash = "sha256:ee98534242a74da847af78624779ac5a3177dc7c69f956a4da9e6f0fdb37d7f6", size = 21174601, upload-time = "2026-06-07T05:52:28.077Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/90/b5668cddb3c401fcf22553bc495d5b0c6d8a01d118624b26f0db1d0b8653/av-17.1.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:5327807c1219293803ef0c5d1578ff3ae1cf638c09e5998962026e1a554ec240", size = 22699499, upload-time = "2026-06-07T05:52:30.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/7e/7be6bfddb823d045ff9fd5d4deb922ee3847605e162c3882e6c45b4c35ff/av-17.1.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:6c9b71fe5c0c5a8d303b1588d4d8ce9397d6b023f467cfef95000ba1f75507fa", size = 18366696, upload-time = "2026-06-07T05:52:32.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/23/391dcfa75c1ae1977efca44b753a11b929399b558826670c16a8808dd0e3/av-17.1.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f997e3351bdf51127c07a74e21741a2996e9230cbeb2d81c14acde761b116c9c", size = 36582649, upload-time = "2026-06-07T05:52:35.218Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/32/7312854868b318b9d1b1dcbd1bddb460aaaeac7d57f816e11efec3bef5b1/av-17.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:efe9b1397300b67b644ad220c89df4892a76f2debe70f16bae1749fa20526e63", size = 38479390, upload-time = "2026-06-07T05:52:37.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/72/af47f59b4458e81ca7d89f477698dbfb3d5a0cd8ae6c1e4441d01074af8a/av-17.1.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:fa64e1f1500d01c4a98e7a41dc1a9a35fb4dfe71f5de0389264ec1192200c76a", size = 27127432, upload-time = "2026-06-07T05:52:40.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/85/c2e6861baf0f8c7d21c4ce811d4d424fedac915e3910d3570ce4377717dc/av-17.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ffbd78d73d2c9bf31e9a007c992faec3991428b2941a3b085b84fb82e8c32d19", size = 37406592, upload-time = "2026-06-07T05:52:43.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/40/3cc13125aea976101c0858af99ac47257c0654411aa199b5d8e81eea7002/av-17.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bff8896454b38fcb785a70e5ae0485d7021cb776303a5849393128a30b8f850b", size = 28336228, upload-time = "2026-06-07T05:52:46.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/38/c7d9c3e746209a1a695c13e3aa7d817229e84a85d0a84271f313d1befdd3/av-17.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1284addf3c0dd939887a9722dc30df2241a97471ad52c3c507e31583ae22ff02", size = 39490680, upload-time = "2026-06-07T05:52:48.887Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/25/9d42da561b7b8f7dabdfaebba07b52977bee58c5c7e4285ac991abcfaa72/av-17.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ec630be6321b04e317862f6082e84812bbd801e55a3c2298312e3fc8a0a4af4f", size = 28355673, upload-time = "2026-06-07T05:52:51.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/41/562a61d5a61fba3ffb273a115e249f1d8471b9515c59fcc38b4b9deda238/av-17.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b41647e42884bf543b8e8d0a1dabd4d1b006c99183eb1a2d7afc5b01f73eeff4", size = 21324700, upload-time = "2026-06-07T05:52:53.972Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "av"
|
||||
version = "18.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12' and sys_platform == 'darwin'",
|
||||
"python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version == '3.11.*' and sys_platform == 'darwin'",
|
||||
"python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba", size = 4340222, upload-time = "2026-07-02T06:37:58.907Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25", size = 22499354, upload-time = "2026-07-02T06:36:58.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484", size = 18175248, upload-time = "2026-07-02T06:37:01.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0", size = 33387843, upload-time = "2026-07-02T06:37:05.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017", size = 35536910, upload-time = "2026-07-02T06:37:08.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f", size = 38984619, upload-time = "2026-07-02T06:37:11.861Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1", size = 34451176, upload-time = "2026-07-02T06:37:15.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44", size = 36619869, upload-time = "2026-07-02T06:37:18.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629", size = 27556236, upload-time = "2026-07-02T06:37:21.388Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6", size = 20221133, upload-time = "2026-07-02T06:37:23.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5", size = 22740741, upload-time = "2026-07-02T06:37:26.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8", size = 18384189, upload-time = "2026-07-02T06:37:29.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e", size = 36749881, upload-time = "2026-07-02T06:37:33.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe", size = 38645927, upload-time = "2026-07-02T06:37:37.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b", size = 40454783, upload-time = "2026-07-02T06:37:40.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f", size = 37573117, upload-time = "2026-07-02T06:37:44.856Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b", size = 39669026, upload-time = "2026-07-02T06:37:48.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b", size = 28448336, upload-time = "2026-07-02T06:37:52.477Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c", size = 21377289, upload-time = "2026-07-02T06:37:55.935Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "babel"
|
||||
version = "2.17.0"
|
||||
|
|
@ -3370,6 +3446,8 @@ name = "supervision"
|
|||
version = "0.30.0.dev0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "av", version = "17.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "av", version = "18.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "matplotlib" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
|
|
@ -3423,6 +3501,7 @@ docs = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "av", specifier = ">=14.2.0" },
|
||||
{ name = "defusedxml", specifier = ">=0.7.1" },
|
||||
{ name = "matplotlib", specifier = ">=3.6" },
|
||||
{ name = "numpy", specifier = ">=1.21.2" },
|
||||
|
|
|
|||
Loading…
Reference in New Issue