Skip to content
Open
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
1 change: 1 addition & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ date_modified: 2026-07-16
- `sv.mask_non_max_merge` now computes exact mask overlap at the original mask resolution and ignores the deprecated `mask_dimension` parameter. Code that relied on downscaled mask overlap should recalibrate thresholds; passing `mask_dimension` positionally now emits a deprecation warning, and the parameter is scheduled for removal in `0.33.0` ([#2400](https://github.com/roboflow/supervision/pull/2400)).

### Fixed
- The cv2-free fallback now preserves OpenCV-compatible edge and keyword semantics for image borders, resizing, drawing, and small polygon masks, keeping ordinary production consumers usable without cv2.
- Fixed [#2427](https://github.com/roboflow/supervision/issues/2427): size-bucketed `sv.Precision` and `sv.F1Score` no longer count out-of-bucket detections as false positives. `sv.Recall` now matches only targets in the requested bucket, and all three metrics prioritize in-bucket targets during matching, matching COCO evaluation and `sv.MeanAveragePrecision`. A pixel-perfect detector now scores 1.0 in every bucket.
- `sv.hex_to_rgba` now rejects multiple leading `#` characters instead of silently normalizing them, matching `sv.is_valid_hex` and the documented single optional prefix.
- `sv.box_iou_batch` now upcasts box corners to `float64` before computing areas and intersections, returning `float32`. This fixes integer-dtype overflow (e.g. `int32` coordinates around `50_000` could previously wrap to a negative area and produce an incorrect `0.0` IoU) and gives full `float64` precision to callers that pass `float64`/`int64` coordinates directly. It does not recover precision already lost when coordinates are stored as `float32` before this function is called (e.g. `Detections.xyxy`, which is `float32` throughout the library) — such callers must upcast their own arrays to `float64`/`int64` before calling `box_iou_batch` to benefit from this fix. Results for small-coordinate inputs are unchanged.
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ lint.per-file-ignores."src/**" = [
]
lint.per-file-ignores."tests/**" = [
"S101", # Use of `assert` detected
"S603", # `subprocess` call: subprocess with hardcoded args in test utilities is safe
]
lint.unfixable = []
# Allow unused variables when underscore-prefixed.
Expand Down
22 changes: 18 additions & 4 deletions src/supervision/_cv2/_drawing.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def _color_for_image(image: _ImageArray, color: Any) -> Any:
if values.size == 0:
return np.zeros(channels, dtype=image.dtype)
if values.size < channels:
values = np.pad(values, (0, channels - values.size))
return np.pad(values, (0, channels - values.size))
return values[:channels]


Expand Down Expand Up @@ -97,7 +97,9 @@ def _rectangle(
"""Draw or fill an inclusive-axis-aligned rectangle in place."""
del lineType
_validate_shift(shift)
first, second = _point(pt1), _point(pt2)
first_point, second_point = _point(pt1), _point(pt2)
first = tuple(min(left, right) for left, right in zip(first_point, second_point))
second = tuple(max(left, right) for left, right in zip(first_point, second_point))
if thickness < 0:
mask = _drawing_mask(img, lambda draw: draw.rectangle([first, second], fill=1))
else:
Expand Down Expand Up @@ -205,7 +207,15 @@ def _polylines(
def draw_polylines(draw: Any) -> None:
for polygon in pts:
points = _points(polygon)
if len(points) < 2:
if not points:
continue
points = [
point
for index, point in enumerate(points)
if index == 0 or point != points[index - 1]
]
if len(points) == 1:
draw.point(points[0], fill=1)
continue
if isClosed:
points.append(points[0])
Expand All @@ -229,7 +239,11 @@ def _fill_poly(
def draw_polygons(draw: Any) -> None:
for polygon in pts:
points = _points(polygon, offset=offset)
if len(points) >= 3:
if len(points) == 1:
draw.point(points[0], fill=1)
elif len(points) == 2:
draw.line(points, fill=1)
elif len(points) >= 3:
draw.polygon(points, fill=1)

return _paint(img, _drawing_mask(img, draw_polygons), color)
Expand Down
24 changes: 21 additions & 3 deletions src/supervision/_cv2/_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from __future__ import annotations

from typing import Any
from typing import Any, cast

import numpy as np
import numpy.typing as npt
Expand Down Expand Up @@ -93,7 +93,7 @@ def _douglas_peucker(
keep[split] = True
pending.extend(((start, split), (split, end)))

return points[keep]
return cast(npt.NDArray[np.float64], points[keep]) # type: ignore[redundant-cast]


def _approx_poly_dp(
Expand All @@ -109,7 +109,25 @@ def _approx_poly_dp(

if closed and len(points) > 1 and np.array_equal(points[0], points[-1]):
points = points[:-1]
simplified = _douglas_peucker(points, epsilon)
if closed and len(points) > 2:
# Seed the two split anchors the way OpenCV's approxPolyDP does: the point
# farthest from points[0], then the point farthest from that one. Two O(N)
# passes replace an O(N^2) all-pairs distance matrix while landing on cv2's
# own arc endpoints, which matters because approximate_polygon re-invokes
# this on the full-size polygon every simplification step.
coordinates = points.astype(np.float64)
anchor_a = int(np.argmax(np.sum((coordinates - coordinates[0]) ** 2, axis=1)))
anchor_b = int(
np.argmax(np.sum((coordinates - coordinates[anchor_a]) ** 2, axis=1))
)
start, end = sorted((anchor_a, anchor_b))
first_arc = points[start : end + 1]
second_arc = np.concatenate((points[end:], points[: start + 1]))
first_simplified = _douglas_peucker(first_arc, epsilon)
second_simplified = _douglas_peucker(second_arc, epsilon)
simplified = np.concatenate((first_simplified[:-1], second_simplified[:-1]))
else:
simplified = _douglas_peucker(points, epsilon)
if closed and len(simplified) > 1 and np.array_equal(simplified[0], simplified[-1]):
simplified = simplified[:-1]

Expand Down
46 changes: 29 additions & 17 deletions src/supervision/_cv2/_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,16 @@ def _copy_make_border(
height, width = image.shape[:2]
shape = (height + top + bottom, width + left + right, *image.shape[2:])

fill_value: Any = value
if isinstance(value, Sequence):
if image.ndim == 2:
raise ValueError("Sequence border value requires a multi-channel image")
fill = np.asarray(value, dtype=image.dtype)
if fill.shape != (image.shape[2],):
raise ValueError("Border value must match the number of channels")
# OpenCV's Scalar(v) fills only channel 0 and zero-pads the rest for
# multichannel images — a bare scalar is treated the same as a
# length-1 sequence, not broadcast to every channel.
sequence_value = value if isinstance(value, Sequence) else (value,)
values = np.asarray(sequence_value, dtype=image.dtype).reshape(-1)
if image.ndim == 2:
fill_value: Any = values[0] if values.size else 0
else:
fill = np.zeros(image.shape[2], dtype=image.dtype)
fill[: min(values.size, image.shape[2])] = values[: image.shape[2]]
fill_value = fill.reshape((1, 1, -1))

result = np.full(shape, fill_value, dtype=image.dtype)
Expand All @@ -70,8 +73,14 @@ def _add_weighted(
beta: float,
gamma: float,
dst: npt.NDArray[Any] | None = None,
dtype: int | None = None,
) -> npt.NDArray[Any]:
"""Blend two arrays with OpenCV-compatible saturation and optional mutation."""
if dtype is not None and dtype != -1:
raise ValueError(
"addWeighted fallback only supports the default output depth; "
f"unsupported dtype: {dtype}"
)
if source1.shape != source2.shape:
raise ValueError("addWeighted inputs must have equal shapes")
result = _cast_array_like_opencv(
Expand Down Expand Up @@ -120,15 +129,15 @@ def _mean(


def _resize(
image: npt.NDArray[Any],
dsize: tuple[int, int],
src: npt.NDArray[Any],
dsize: tuple[int, int] | None,
fx: float = 0,
fy: float = 0,
interpolation: int = _INTER_LINEAR,
) -> npt.NDArray[Any]:
"""Resize an array using OpenCV-compatible nearest or half-pixel linear sampling."""
source_height, source_width = image.shape[:2]
width, height = dsize
source_height, source_width = src.shape[:2]
width, height = dsize if dsize is not None else (0, 0)
if width == 0 or height == 0:
width = round(source_width * fx)
height = round(source_height * fy)
Expand All @@ -142,7 +151,7 @@ def _resize(
x_indices = np.minimum(
(np.arange(width) * source_width // width), source_width - 1
)
return np.ascontiguousarray(image[y_indices[:, np.newaxis], x_indices])
return np.ascontiguousarray(src[y_indices[:, np.newaxis], x_indices])

if interpolation != _INTER_LINEAR:
raise ValueError(f"Unsupported interpolation mode: {interpolation}")
Expand All @@ -158,12 +167,12 @@ def _resize(
wy = y - y_floor
wx = x - x_floor

source = image.astype(np.float64)
source = src.astype(np.float64)
top = source[y0[:, np.newaxis], x0]
top_right = source[y0[:, np.newaxis], x1]
bottom = source[y1[:, np.newaxis], x0]
bottom_right = source[y1[:, np.newaxis], x1]
if image.ndim == 3:
if src.ndim == 3:
wy = wy[:, np.newaxis, np.newaxis]
wx = wx[np.newaxis, :, np.newaxis]
else:
Expand All @@ -175,7 +184,7 @@ def _resize(
+ bottom * (1 - wx) * wy
+ bottom_right * wx * wy
)
return np.ascontiguousarray(_cast_array_like_opencv(result, image.dtype))
return np.ascontiguousarray(_cast_array_like_opencv(result, src.dtype))


def _imread(filename: str, flags: int = _IMREAD_COLOR) -> npt.NDArray[Any] | None:
Expand All @@ -186,10 +195,13 @@ def _imread(filename: str, flags: int = _IMREAD_COLOR) -> npt.NDArray[Any] | Non
with Image.open(filename) as image:
if flags == _IMREAD_UNCHANGED:
if image.mode == "P":
image = image.convert(
converted = image.convert(
"RGBA" if "transparency" in image.info else "RGB"
)
values = np.asarray(image)
values = np.asarray(converted)
converted.close()
else:
values = np.asarray(image)
elif image.mode in {"I", "I;16", "I;16B", "I;16L"}:
values = np.asarray(image).astype(np.float64)
values = np.clip(np.rint(values / 256), 0, 255).astype(np.uint8)
Expand Down
2 changes: 1 addition & 1 deletion tests/cv2/test_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ def _run_without_opencv(source: str) -> None:
env["PYTHONPATH"] = os.pathsep.join(
filter(None, (source_path, env.get("PYTHONPATH")))
)
subprocess.run( # noqa: S603
subprocess.run(
[sys.executable, "-c", source],
check=True,
env=env,
Expand Down
2 changes: 1 addition & 1 deletion tests/cv2/test_cv2.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ def test_facade_does_not_hide_a_breaking_opencv_import() -> None:
sys.modules["cv2"] = types.ModuleType("cv2")
from supervision import _cv2
"""
result = subprocess.run( # noqa: S603
result = subprocess.run(
[sys.executable, "-c", code],
capture_output=True,
text=True,
Expand Down
2 changes: 1 addition & 1 deletion tests/cv2/test_drawing.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ def find_spec(self, fullname, path=None, target=None):
environment["PYTHONPATH"] = os.pathsep.join(
filter(None, (source_path, environment.get("PYTHONPATH")))
)
completed = subprocess.run( # noqa: S603
completed = subprocess.run(
[sys.executable, "-c", source],
capture_output=True,
text=True,
Expand Down
8 changes: 8 additions & 0 deletions tests/cv2/test_geometry.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ def test_contour_area_matches_opencv(contour: np.ndarray, oriented: bool) -> Non
0.5,
id="collinear-runs",
),
pytest.param(
np.array(
[[4, 0], [8, 0], [12, 4], [12, 8], [8, 12], [4, 12], [0, 8], [0, 4]],
dtype=np.int32,
),
0.5,
id="octagon",
),
],
)
def test_approx_poly_dp_matches_opencv(contour: np.ndarray, epsilon: float) -> None:
Expand Down
2 changes: 1 addition & 1 deletion tests/cv2/test_hershey.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def _run_without_opencv(source: str) -> subprocess.CompletedProcess[str]:
environment["PYTHONPATH"] = os.pathsep.join(
filter(None, (source_path, environment.get("PYTHONPATH")))
)
return subprocess.run( # noqa: S603
return subprocess.run(
[sys.executable, "-c", source],
capture_output=True,
text=True,
Expand Down
85 changes: 79 additions & 6 deletions tests/cv2/test_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,52 @@ def test_fallback_flip_matches_opencv(flip_code: int, expected: np.ndarray) -> N
np.testing.assert_array_equal(_flip(source, flip_code), cv2.flip(source, flip_code))


def test_fallback_copy_make_border_matches_opencv() -> None:
"""Match OpenCV constant-border padding."""
source = np.array([[0, 100], [200, 255]], dtype=np.uint8)

@pytest.mark.parametrize(
("source", "value"),
[
pytest.param(
np.array([[0, 100], [200, 255]], dtype=np.uint8),
7,
id="grayscale-scalar",
),
pytest.param(
np.array([[0, 100], [200, 255]], dtype=np.uint8),
(5, 9, 20),
id="grayscale-sequence-uses-first-element",
),
pytest.param(
np.array(
[[[10, 20, 30], [40, 50, 60]], [[70, 80, 90], [100, 110, 120]]],
dtype=np.uint8,
),
(7, 8),
id="multichannel-sequence-shorter-than-channels-pads-with-zero",
),
pytest.param(
np.array(
[[[10, 20, 30], [40, 50, 60]], [[70, 80, 90], [100, 110, 120]]],
dtype=np.uint8,
),
(7, 8, 9, 10),
id="multichannel-sequence-longer-than-channels-truncates",
),
pytest.param(
np.array(
[[[10, 20, 30], [40, 50, 60]], [[70, 80, 90], [100, 110, 120]]],
dtype=np.uint8,
),
100,
id="multichannel-scalar-fills-only-first-channel",
),
],
)
def test_fallback_copy_make_border_matches_opencv(
source: np.ndarray, value: int | tuple[int, ...]
) -> None:
"""Match OpenCV constant-border padding for scalar and Sequence values."""
np.testing.assert_array_equal(
_copy_make_border(source, 1, 1, 2, 2, _BORDER_CONSTANT, 7),
cv2.copyMakeBorder(source, 1, 1, 2, 2, cv2.BORDER_CONSTANT, value=7),
_copy_make_border(source, 1, 1, 2, 2, _BORDER_CONSTANT, value),
cv2.copyMakeBorder(source, 1, 1, 2, 2, cv2.BORDER_CONSTANT, value=value),
)


Expand All @@ -82,6 +121,40 @@ def test_fallback_add_weighted_supports_destination() -> None:
np.testing.assert_array_equal(actual, cv2.addWeighted(source, 0.5, other, 0.5, 10))


@pytest.mark.parametrize(
"dtype",
[
pytest.param(None, id="none"),
pytest.param(-1, id="opencv-sentinel"),
],
)
def test_fallback_add_weighted_accepts_default_dtype(dtype: int | None) -> None:
"""Treat both None and OpenCV's -1 sentinel as the default output depth."""
source = np.array([[0, 100], [200, 255]], dtype=np.uint8)
other = np.full_like(source, 50)

np.testing.assert_array_equal(
_add_weighted(source, 0.5, other, 0.5, 10, dtype=dtype),
cv2.addWeighted(source, 0.5, other, 0.5, 10),
)


@pytest.mark.parametrize(
"dtype",
[
pytest.param(0, id="cv-8u"),
pytest.param(5, id="cv-32f"),
],
)
def test_fallback_add_weighted_rejects_non_default_dtype(dtype: int) -> None:
"""Fail loud when a caller requests an unsupported output depth."""
source = np.array([[0, 100], [200, 255]], dtype=np.uint8)
other = np.full_like(source, 50)

with pytest.raises(ValueError, match="output depth"):
_add_weighted(source, 0.5, other, 0.5, 10, dtype=dtype)


def test_fallback_convert_scale_abs_matches_opencv() -> None:
"""Match OpenCV absolute scale-and-convert semantics."""
source = np.array([[0, 100], [200, 255]], dtype=np.uint8)
Expand Down
Loading
Loading