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:
Jirka Borovec 2026-07-16 20:13:15 +02:00 committed by GitHub
parent c3496134bc
commit 20b7c085b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 753 additions and 171 deletions

View File

@ -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

View File

@ -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" ]

View File

@ -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__ = [

View File

@ -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"
)

View 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

210
tests/cv2/test_video.py Normal file
View File

@ -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

View File

@ -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
View File

@ -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" },