Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ date_modified: 2026-09-17

- Removed, as scheduled for `supervision-0.31.0`: `sv.ByteTrack` (use `ByteTrackTracker` from the `trackers` package instead); the `supervision.keypoint` module (use `supervision.key_points`); `create_tiles` and `overlay_image` in `supervision.utils.image`; `ensure_cv2_image_for_annotation`, `ensure_pil_image_for_annotation`, and `ensure_cv2_image_for_processing` in `supervision.utils.conversion`; `validate_keypoint_confidence` and `validate_keypoints_fields` in `supervision.validators`; the `normalized_xyxy` argument of `sv.denormalize_boxes` (use `xyxy`); the `supervision.dataset.utils` import path for `sv.mask_to_rle`/`sv.rle_to_mask` (import from `supervision.detection.utils.converters` instead); `sv.LMM` and `Detections.from_lmm` (use `sv.VLM`/`Detections.from_vlm`); and the legacy `MeanAveragePrecision` in `supervision.metrics.detection` (use `supervision.metrics.mean_average_precision.MeanAveragePrecision`, exposed as `sv.metrics.MeanAveragePrecision`). See [Deprecated](deprecated.md) for the full list. [#2582](https://github.com/roboflow/supervision/pull/2582)

- `sv.VideoInfo.from_video_path`, `sv.get_video_frames_generator` and `sv.process_video` now turn a rotated video upright when OpenCV is not installed, as they already do with OpenCV. Phones usually store a portrait clip as landscape frames and record the turn in the container's display matrix. OpenCV's FFmpeg backend applies that turn to every frame it decodes and to the width and height it reports, but the OpenCV-free fallback ignored it, so a portrait phone video came back sideways, with its width and height swapped: models ran on sideways frames and `sv.VideoSink` saved the output sideways. The fallback now applies quarter and half turns, the same angles OpenCV applies. Videos without a display rotation, and every read with OpenCV installed, are unchanged. [#2601](https://github.com/roboflow/supervision/pull/2601)
Comment thread
Borda marked this conversation as resolved.

- `sv.ImageSink` and the dataset exports that encode in-memory images now write JPEG and WebP files at OpenCV's default quality when OpenCV is not installed. Without parameters, `cv2.imwrite` and `cv2.imencode` write JPEG at quality 95 and WebP losslessly, but the OpenCV-free fallback backend used Pillow's defaults, JPEG at quality 75 and lossy WebP at quality 80. Whether `opencv-python` was installed therefore decided how a frame was stored: a `.jpg` came out with stronger compression artifacts (a third of the size for one test frame), and a `.webp` no longer held the frame's exact pixels. The fallback now passes OpenCV's defaults to Pillow, and its JPEG output uses the same quantization tables as OpenCV's. A `.webp` written by the fallback is now larger than before, since lossless output is larger than the previous lossy default. The fallback's in-memory encoder also now accepts `.jpe` as a JPEG alias alongside `.jpg`, and resolves `.tif`, `.jp2`, and `.pgm` extensions to their Pillow format names — it previously rejected all four, returning `False, None`, even though file writes already accepted them. PNG and the other lossless formats, and every write with OpenCV installed, are unchanged. [#2592](https://github.com/roboflow/supervision/pull/2592)

- `sv.IconAnnotator` now draws grayscale PNG icons. It reads icons with `cv2.IMREAD_UNCHANGED` so that their alpha channel survives, but for a grayscale PNG without alpha that returns a 2-D array, and the icon overlay only handles BGR and BGRA arrays, so `annotate` failed with `IndexError: tuple index out of range`. Single-color icons and logos are often stored as grayscale, and PNG optimizers such as `optipng` and `oxipng` convert an RGB image to grayscale whenever all its pixels are gray. A grayscale icon is now expanded to BGR when it is loaded, as `cv2.imread` does by default. The same read also keeps a 16-bit PNG at 16 bits, which the 8-bit scene wraps modulo 256: a 16-bit color icon of value `60000` drew as `96`, near black. A 16-bit icon, grayscale or color, is now scaled down to 8 bits as it is loaded, and an icon that is neither 8-bit nor 16-bit is rejected with a `ValueError` naming the file and its pixel type. Eight-bit color icons, with or without alpha, are drawn as before. [#2591](https://github.com/roboflow/supervision/pull/2591)
Expand Down
55 changes: 48 additions & 7 deletions src/supervision/_cv2/_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,17 @@ def _codec_details(fourcc: int) -> tuple[str, str]:
raise ValueError(f"Unsupported video codec: {code!r}") from exc


def _quarter_turns(rotation: int) -> int:
"""Return the counterclockwise quarter turns OpenCV applies for a display rotation.

Phones store a portrait video as landscape frames plus a display matrix, and
OpenCV's FFmpeg backend turns every frame upright by that matrix, but only for
multiples of 90 degrees; any other angle is left alone.
"""
quarter_turns, remainder = divmod(rotation, 90)
return 0 if remainder else quarter_turns % 4


class _VideoCapture:
"""Expose OpenCV-shaped file capture backed by PyAV decoding."""

Expand All @@ -73,6 +84,7 @@ def __init__(self, source: str | os.PathLike[str] | int) -> None:
self._source = source
self._position = 0
self._frame_count_cache: int | None = None
self._rotation_cache: int | None = None
self._opened = False
self._error: Exception | None = None

Expand Down Expand Up @@ -115,14 +127,39 @@ def _frame_count(self) -> int:
self._frame_count_cache = count
return count

def _rotation(self) -> int:
"""Return the stream's display rotation, decoding one frame on a second handle.

PyAV only exposes the display matrix on decoded frames, so the first frame is
decoded from a separate container to leave this capture's position untouched.
"""
if self._rotation_cache is not None:
return self._rotation_cache

import av

container = av.open(str(self._source), mode="r")
try:
frames = container.decode(video=self._stream.index)
rotation = next((int(frame.rotation) for frame in frames), 0)
finally:
container.close()
self._rotation_cache = rotation
return rotation

def get(self, property_id: int) -> float:
"""Return the supported OpenCV capture property as a float."""
"""Return the supported OpenCV capture property as a float.

Width and height are those of the upright frames `read` returns, so they swap
for a video whose display matrix turns it by a quarter, as in OpenCV.
"""
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 in (_CAP_PROP_FRAME_WIDTH, _CAP_PROP_FRAME_HEIGHT):
width, height = self._stream.width, self._stream.height
if _quarter_turns(self._rotation()) % 2:
width, height = height, width
return float(width if property_id == _CAP_PROP_FRAME_WIDTH else height)
if property_id == _CAP_PROP_FPS:
rate = getattr(self._stream, "average_rate", None) or getattr(
self._stream, "base_rate", None
Expand Down Expand Up @@ -156,7 +193,7 @@ def set(self, property_id: int, value: float) -> bool:
return True

def read(self) -> tuple[bool, npt.NDArray[np.uint8] | None]:
"""Decode and return the next frame in OpenCV's BGR array format."""
"""Decode and return the next frame upright in OpenCV's BGR array format."""
if not self._opened or self._frames is None:
return False, None
try:
Expand All @@ -168,7 +205,11 @@ def read(self) -> tuple[bool, npt.NDArray[np.uint8] | None]:
return False, None

self._position += 1
return True, frame.to_ndarray(format="bgr24")
image = frame.to_ndarray(format="bgr24")
quarter_turns = _quarter_turns(int(frame.rotation))
if quarter_turns:
image = np.ascontiguousarray(np.rot90(image, quarter_turns))
return True, image

def grab(self) -> bool:
"""Decode and discard one frame."""
Expand Down
87 changes: 87 additions & 0 deletions tests/cv2/test_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,28 @@ def _write_video_with_audio(path: Path, frame_count: int = 5, fps: int = 5) -> N
container.close()


def _write_rotated_video(path: Path, rotation: int) -> np.ndarray:
"""Write a landscape clip tagged with a display rotation and return its frame."""
frame = np.zeros((16, 32, 3), dtype=np.uint8)
frame[:8, :16] = 255
container = av.open(str(path), mode="w")
stream = container.add_stream("mpeg4", rate=5)
stream.width = 32
stream.height = 16
stream.pix_fmt = "yuv420p"
stream.set_display_rotation(rotation)
try:
for _ in range(3):
video_frame = av.VideoFrame.from_ndarray(frame, format="bgr24")
for packet in stream.encode(video_frame):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
finally:
container.close()
return frame


def _run_without_opencv(source: str) -> None:
"""Run a Python snippet with cv2 imports blocked."""
env = os.environ.copy()
Expand Down Expand Up @@ -139,6 +161,71 @@ def test_fallback_capture_reports_metadata_and_supports_exact_seek(
assert not capture.isOpened()


@pytest.mark.parametrize(
("rotation", "quarter_turns"),
[
pytest.param(90, 1, id="counterclockwise-quarter-turn"),
pytest.param(-90, 3, id="clockwise-quarter-turn"),
pytest.param(180, 2, id="half-turn"),
pytest.param(0, 0, id="no-rotation"),
Comment thread
Borda marked this conversation as resolved.
pytest.param(45, 0, id="non-quarter-turn"),
],
)
def test_fallback_capture_turns_rotated_video_upright(
tmp_path: Path, rotation: int, quarter_turns: int
) -> None:
"""Fallback capture applies a display rotation to its frames and frame size."""
source_path = tmp_path / "rotated.mp4"
stored_frame = _write_rotated_video(source_path, rotation)
expected = np.rot90(stored_frame, quarter_turns)

capture = _VideoCapture(str(source_path))
size = (
capture.get(_cv2.CAP_PROP_FRAME_WIDTH),
capture.get(_cv2.CAP_PROP_FRAME_HEIGHT),
)
success, frame = capture.read()
capture.release()

assert success
assert frame is not None
assert size == (expected.shape[1], expected.shape[0])
assert frame.shape == expected.shape
np.testing.assert_allclose(frame.astype(np.int16), expected, atol=16)


@pytest.mark.parametrize("rotation", [90, -90, 180])
def test_fallback_capture_matches_opencv_display_rotation(
tmp_path: Path, rotation: int
) -> None:
"""Fallback capture turns rotated videos upright the way OpenCV does."""
cv2 = pytest.importorskip("cv2")
source_path = tmp_path / "rotated.mp4"
_write_rotated_video(source_path, rotation)

reference = cv2.VideoCapture(str(source_path))
expected_size = (
reference.get(cv2.CAP_PROP_FRAME_WIDTH),
reference.get(cv2.CAP_PROP_FRAME_HEIGHT),
)
_, expected_frame = reference.read()
reference.release()
capture = _VideoCapture(str(source_path))
size = (
capture.get(_cv2.CAP_PROP_FRAME_WIDTH),
capture.get(_cv2.CAP_PROP_FRAME_HEIGHT),
)
_, frame = capture.read()
capture.release()

assert size == expected_size
assert frame is not None
assert frame.shape == expected_frame.shape
np.testing.assert_allclose(
frame.astype(np.int16), expected_frame.astype(np.int16), atol=16
)


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"
Expand Down
26 changes: 26 additions & 0 deletions tests/utils/test_video.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from types import SimpleNamespace
from unittest.mock import patch

import av
import numpy as np
import pytest

Expand Down Expand Up @@ -573,6 +574,31 @@ def mocked_get(self, prop_id):
assert video_info.fps != int(video_info.fps)


def test_video_info_and_frames_follow_display_rotation(tmp_path: Path) -> None:
"""Report and yield a phone-style portrait clip upright, not as it is stored."""
video_path = str(tmp_path / "portrait.mp4")
container = av.open(video_path, mode="w")
stream = container.add_stream("mpeg4", rate=5)
stream.width = 32
stream.height = 16
stream.pix_fmt = "yuv420p"
stream.set_display_rotation(90)
stored_frame = np.zeros((16, 32, 3), dtype=np.uint8)
for _ in range(3):
for packet in stream.encode(av.VideoFrame.from_ndarray(stored_frame)):
container.mux(packet)
for packet in stream.encode():
container.mux(packet)
container.close()

video_info = VideoInfo.from_video_path(video_path)
frames = list(get_video_frames_generator(video_path))

assert video_info.resolution_wh == (16, 32)
assert len(frames) == 3
assert all(frame.shape == (32, 16, 3) for frame in frames)


def test_get_video_frames_generator(dummy_video_path) -> None:
"""Verify that get_video_frames_generator yields frames with correct shapes.

Expand Down
Loading