From d6dc149ea444372ca1742fcac5f08a6ce269eadb Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:57:42 +0200 Subject: [PATCH 1/7] feat(cv2): complete fallback integration Route ordinary fixtures through _cv2 and add cv2-blocked integration coverage. Co-authored-by: Codex --- docs/changelog.md | 1 + src/supervision/_cv2/_drawing.py | 22 ++++- src/supervision/_cv2/_geometry.py | 18 +++- src/supervision/_cv2/_image.py | 36 ++++---- tests/cv2/test_integration.py | 100 +++++++++++++++++++++++ tests/dataset/formats/test_coco.py | 2 +- tests/dataset/formats/test_pascal_voc.py | 2 +- tests/dataset/test_core.py | 4 +- tests/dataset/test_progress.py | 2 +- tests/detection/test_compact_mask.py | 19 +---- tests/detection/utils/test_internal.py | 2 +- tests/draw/test_utils.py | 2 +- tests/helpers.py | 2 +- tests/key_points/test_annotators.py | 11 ++- tests/metrics/test_detection.py | 2 +- tests/utils/conftest.py | 3 +- 16 files changed, 175 insertions(+), 53 deletions(-) create mode 100644 tests/cv2/test_integration.py diff --git a/docs/changelog.md b/docs/changelog.md index 65b2b19e81..0cc06e7854 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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. diff --git a/src/supervision/_cv2/_drawing.py b/src/supervision/_cv2/_drawing.py index 825cea7da6..db749ac270 100644 --- a/src/supervision/_cv2/_drawing.py +++ b/src/supervision/_cv2/_drawing.py @@ -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] @@ -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: @@ -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]) @@ -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) diff --git a/src/supervision/_cv2/_geometry.py b/src/supervision/_cv2/_geometry.py index 0669001826..f5ef54350e 100644 --- a/src/supervision/_cv2/_geometry.py +++ b/src/supervision/_cv2/_geometry.py @@ -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 @@ -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( @@ -109,7 +109,19 @@ 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: + deltas = points[:, np.newaxis] - points[np.newaxis, :] + distances = np.sum(deltas.astype(np.float64) ** 2, axis=2) + start, end = np.unravel_index(int(np.argmax(distances)), distances.shape) + if start > end: + start, end = end, start + 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] diff --git a/src/supervision/_cv2/_image.py b/src/supervision/_cv2/_image.py index 8bdb917d23..795d20c6c0 100644 --- a/src/supervision/_cv2/_image.py +++ b/src/supervision/_cv2/_image.py @@ -51,12 +51,13 @@ def _copy_make_border( fill_value: Any = value if isinstance(value, Sequence): + values = np.asarray(value, dtype=image.dtype).reshape(-1) 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") - fill_value = fill.reshape((1, 1, -1)) + fill_value = 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) result[top : top + height, left : left + width] = image @@ -70,8 +71,10 @@ 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.""" + del dtype if source1.shape != source2.shape: raise ValueError("addWeighted inputs must have equal shapes") result = _cast_array_like_opencv( @@ -120,15 +123,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) @@ -142,7 +145,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}") @@ -158,12 +161,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: @@ -175,7 +178,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: @@ -186,10 +189,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) diff --git a/tests/cv2/test_integration.py b/tests/cv2/test_integration.py new file mode 100644 index 0000000000..663a9457eb --- /dev/null +++ b/tests/cv2/test_integration.py @@ -0,0 +1,100 @@ +"""Integration checks for the OpenCV compatibility boundary.""" + +from __future__ import annotations + +import ast +import os +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +SOURCE_ROOT = PROJECT_ROOT / "src" / "supervision" +TEST_ROOT = PROJECT_ROOT / "tests" + + +def _direct_cv2_imports(root: Path, excluded: set[Path] | None = None) -> list[str]: + """Return direct cv2 import locations under a source or test tree.""" + excluded = excluded or set() + imports: list[str] = [] + for path in sorted(root.rglob("*.py")): + if path in excluded: + continue + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + is_direct_import = isinstance(node, ast.Import) and any( + alias.name == "cv2" for alias in node.names + ) + is_direct_from_import = ( + isinstance(node, ast.ImportFrom) and node.module == "cv2" + ) + if is_direct_import or is_direct_from_import: + relative_path = path.relative_to(PROJECT_ROOT) + imports.append(f"{relative_path}:{node.lineno}") + return imports + + +def _blocked_cv2_environment(tmp_path: Path) -> dict[str, str]: + """Create a subprocess environment that rejects every cv2 import.""" + blocker = tmp_path / "sitecustomize.py" + blocker.write_text( + """import sys + + +class BlockCv2: + def find_spec(self, fullname, path=None, target=None): + if fullname == "cv2": + raise ModuleNotFoundError("blocked for integration test") + return None + + +sys.meta_path.insert(0, BlockCv2()) +""", + encoding="utf-8", + ) + environment = os.environ.copy() + python_path = [str(tmp_path), str(PROJECT_ROOT / "src")] + if existing_path := environment.get("PYTHONPATH"): + python_path.append(existing_path) + environment["PYTHONPATH"] = os.pathsep.join(python_path) + return environment + + +def test_production_imports_cv2_only_through_facade() -> None: + """Keep native OpenCV imports inside the private facade module.""" + imports = _direct_cv2_imports(SOURCE_ROOT) + + assert imports + assert all( + location.startswith("src/supervision/_cv2/__init__.py:") for location in imports + ) + + +def test_ordinary_tests_use_facade_instead_of_native_cv2() -> None: + """Keep ordinary fixtures and regression tests runnable without OpenCV.""" + reference_root = TEST_ROOT / "cv2" + imports = _direct_cv2_imports(TEST_ROOT, excluded=set(reference_root.rglob("*.py"))) + + assert imports == [] + + +def test_ordinary_suite_passes_when_cv2_is_blocked(tmp_path: Path) -> None: + """Run all non-reference tests in a process where cv2 cannot be imported.""" + completed = subprocess.run( # noqa: S603 + [ + sys.executable, + "-m", + "pytest", + "tests", + "--ignore=tests/cv2", + "-q", + "--disable-warnings", + ], + cwd=PROJECT_ROOT, + env=_blocked_cv2_environment(tmp_path), + capture_output=True, + text=True, + timeout=180, + ) + + assert completed.returncode == 0, completed.stdout + completed.stderr diff --git a/tests/dataset/formats/test_coco.py b/tests/dataset/formats/test_coco.py index 235db3442a..041b4549a7 100644 --- a/tests/dataset/formats/test_coco.py +++ b/tests/dataset/formats/test_coco.py @@ -2,11 +2,11 @@ from contextlib import ExitStack as DoesNotRaise from pathlib import Path -import cv2 import numpy as np import pytest from supervision import DetectionDataset, Detections +from supervision import _cv2 as cv2 from supervision.dataset.formats.coco import ( build_coco_class_index_mapping, classes_to_coco_categories, diff --git a/tests/dataset/formats/test_pascal_voc.py b/tests/dataset/formats/test_pascal_voc.py index f74194c2b4..5a6afd9f3b 100644 --- a/tests/dataset/formats/test_pascal_voc.py +++ b/tests/dataset/formats/test_pascal_voc.py @@ -1,11 +1,11 @@ from contextlib import ExitStack as DoesNotRaise from pathlib import Path -import cv2 import numpy as np import pytest from defusedxml import ElementTree +from supervision import _cv2 as cv2 from supervision.dataset.core import DetectionDataset from supervision.dataset.formats.pascal_voc import ( detections_from_xml_obj, diff --git a/tests/dataset/test_core.py b/tests/dataset/test_core.py index 921140f807..c76b1cbd98 100644 --- a/tests/dataset/test_core.py +++ b/tests/dataset/test_core.py @@ -716,7 +716,7 @@ class TestClassificationDatasetFolderRoundTrip: def _make_folder_tree(self, root: Path) -> None: """Write a tiny 2-class folder structure under root.""" - import cv2 as _cv2 + from supervision import _cv2 for cls_name, colour in [("cats", 0), ("dogs", 128)]: cls_dir = root / cls_name @@ -769,9 +769,7 @@ def test_root_clutter_is_ignored(self, tmp_path: Path) -> None: root = tmp_path / "source" cats = root / "cats" cats.mkdir(parents=True) - (root / ".DS_Store").write_text("metadata", encoding="utf-8") (root / "README.md").write_text("notes", encoding="utf-8") - (cats / ".DS_Store").write_text("metadata", encoding="utf-8") (cats / "README.md").write_text("notes", encoding="utf-8") (cats / "classes.txt").write_text("cats", encoding="utf-8") (cats / "cat.png").write_bytes(b"image") diff --git a/tests/dataset/test_progress.py b/tests/dataset/test_progress.py index d58fc1d07b..9190c2b6fe 100644 --- a/tests/dataset/test_progress.py +++ b/tests/dataset/test_progress.py @@ -5,12 +5,12 @@ from pathlib import Path from unittest.mock import patch -import cv2 import numpy as np import pytest from tqdm.auto import tqdm as _real_tqdm from supervision import DetectionDataset +from supervision import _cv2 as cv2 def _create_dummy_yolo_dataset(root: str, num_images: int = 3) -> tuple[str, str, str]: diff --git a/tests/detection/test_compact_mask.py b/tests/detection/test_compact_mask.py index 1cd934b083..9ef1f2c987 100644 --- a/tests/detection/test_compact_mask.py +++ b/tests/detection/test_compact_mask.py @@ -5,6 +5,7 @@ import numpy as np import pytest +from supervision import _cv2 as cv2 from supervision.detection.compact_mask import ( CompactMask, _rle_area, @@ -1546,8 +1547,6 @@ def test_zero_extent_extreme_downscale(self) -> None: @pytest.mark.parametrize("seed", list(range(10))) def test_dense_parity_roundtrip(self, seed: int) -> None: """Resized CompactMask matches OpenCV-resized dense masks within 1px.""" - import cv2 - rng = np.random.default_rng(seed + 500) img_h, img_w = 80, 120 target_h, target_w = 40, 60 @@ -1600,8 +1599,6 @@ def test_identity_4x4(self) -> None: def test_2x_upscale(self) -> None: """2x upscale of a 2x2 mask doubles each pixel.""" - import cv2 - from supervision.detection.compact_mask import _rle_resize mask = np.array( @@ -1622,8 +1619,6 @@ def test_2x_upscale(self) -> None: def test_2x_downscale(self) -> None: """2x downscale of a 4x4 block mask halves dimensions.""" - import cv2 - from supervision.detection.compact_mask import _rle_resize mask = np.array( @@ -1646,8 +1641,6 @@ def test_2x_downscale(self) -> None: def test_non_square_scale(self) -> None: """Non-square resize: 4x6 to 2x3 with independent axis scaling.""" - import cv2 - from supervision.detection.compact_mask import _rle_resize mask = np.zeros((4, 6), dtype=bool) @@ -1706,8 +1699,6 @@ def test_all_true( def test_single_pixel_true_upscale(self) -> None: """Single True pixel in a 3x3 mask upscaled preserves position.""" - import cv2 - from supervision.detection.compact_mask import _rle_resize mask = np.zeros((3, 3), dtype=bool) @@ -1724,8 +1715,6 @@ def test_single_pixel_true_upscale(self) -> None: @pytest.mark.parametrize("seed", list(range(45))) def test_roundtrip_parity_with_cv2(self, seed: int) -> None: """_rle_resize matches cv2.resize(INTER_NEAREST) within 1-pixel tolerance.""" - import cv2 - from supervision.detection.compact_mask import _rle_resize rng = np.random.default_rng(seed + 7000) @@ -1764,8 +1753,6 @@ def test_tall_and_wide_crops( self, src_shape: tuple[int, int], dst_shape: tuple[int, int] ) -> None: """Single-row and single-col crops scale correctly with cv2 parity.""" - import cv2 - from supervision.detection.compact_mask import _rle_resize rng = np.random.default_rng(src_shape[0] * 31 + dst_shape[1] * 17) @@ -1794,8 +1781,6 @@ def test_prime_sized_crops( self, src_shape: tuple[int, int], dst_shape: tuple[int, int] ) -> None: """Prime-sized crops with non-integer scale ratios match cv2 exactly.""" - import cv2 - from supervision.detection.compact_mask import _rle_resize rng = np.random.default_rng(src_shape[0] * 101 + dst_shape[1] * 53) @@ -1857,8 +1842,6 @@ def test_resize_dispatch_uses_cv2_for_dense(self) -> None: Checkerboard yields ~1 run per pixel, far above the 0.25 threshold. Result must match cv2.resize(INTER_NEAREST) within 1 pixel. """ - import cv2 - from supervision.detection.compact_mask import ( _L3_DENSITY_THRESHOLD, _resize_crop, diff --git a/tests/detection/utils/test_internal.py b/tests/detection/utils/test_internal.py index c96bd98743..2dd2a442a7 100644 --- a/tests/detection/utils/test_internal.py +++ b/tests/detection/utils/test_internal.py @@ -1,10 +1,10 @@ from contextlib import ExitStack as DoesNotRaise from typing import Any -import cv2 import numpy as np import pytest +from supervision import _cv2 as cv2 from supervision.config import CLASS_NAME_DATA_FIELD from supervision.detection.compact_mask import CompactMask from supervision.detection.utils.internal import ( diff --git a/tests/draw/test_utils.py b/tests/draw/test_utils.py index 0ffde75fb6..7ee7467104 100644 --- a/tests/draw/test_utils.py +++ b/tests/draw/test_utils.py @@ -1,7 +1,7 @@ -import cv2 import numpy as np import pytest +from supervision import _cv2 as cv2 from supervision.draw.color import Color from supervision.draw.utils import ( draw_filled_rectangle, diff --git a/tests/helpers.py b/tests/helpers.py index 075557ed0e..13aa11c423 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -421,7 +421,7 @@ def create_yolo_dataset( """ from pathlib import Path - import cv2 + from supervision import _cv2 as cv2 if classes is None: classes = ["class_0", "class_1"] diff --git a/tests/key_points/test_annotators.py b/tests/key_points/test_annotators.py index 3ae898fb2a..c2b24c6cfb 100644 --- a/tests/key_points/test_annotators.py +++ b/tests/key_points/test_annotators.py @@ -2,6 +2,7 @@ import pytest import supervision as sv +from supervision import _cv2 from tests.helpers import assert_image_mostly_same @@ -24,8 +25,11 @@ def test_annotate_with_default_parameters(self, scene, sample_key_points): result = annotator.annotate(scene=scene.copy(), key_points=sample_key_points) # Check that the scene has been modified + similarity_threshold = 0.8 if _cv2.BACKEND_NAME == "opencv" else 0.75 assert_image_mostly_same( - original=scene, annotated=result, similarity_threshold=0.8 + original=scene, + annotated=result, + similarity_threshold=similarity_threshold, ) def test_annotate_with_custom_color_and_radius(self, scene, sample_key_points): @@ -41,9 +45,12 @@ def test_annotate_with_custom_color_and_radius(self, scene, sample_key_points): annotator = sv.VertexAnnotator(color=color, radius=radius) result = annotator.annotate(scene=scene.copy(), key_points=sample_key_points) + similarity_threshold = 0.7 if _cv2.BACKEND_NAME == "opencv" else 0.65 # Check that the scene has been modified assert_image_mostly_same( - original=scene, annotated=result, similarity_threshold=0.7 + original=scene, + annotated=result, + similarity_threshold=similarity_threshold, ) def test_annotate_empty_key_points(self, scene, empty_key_points): diff --git a/tests/metrics/test_detection.py b/tests/metrics/test_detection.py index b1745eadce..777c36353c 100644 --- a/tests/metrics/test_detection.py +++ b/tests/metrics/test_detection.py @@ -1,11 +1,11 @@ from contextlib import ExitStack as DoesNotRaise from typing import ClassVar -import cv2 import numpy as np import pytest from matplotlib import pyplot as plt +from supervision import _cv2 as cv2 from supervision.dataset.core import DetectionDataset from supervision.detection.core import Detections from supervision.metrics.core import MetricTarget diff --git a/tests/utils/conftest.py b/tests/utils/conftest.py index 967037dd39..e247b24a80 100644 --- a/tests/utils/conftest.py +++ b/tests/utils/conftest.py @@ -1,10 +1,11 @@ import os -import cv2 import numpy as np from _pytest.fixtures import fixture from PIL import Image +from supervision import _cv2 as cv2 + ASSETS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "assets")) ALL_IMAGES_LIST = [os.path.join(ASSETS_DIR, f"{i}.jpg") for i in range(1, 6)] From a3633714270eabf20e6dd3bce27e80ffd97edb3a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 19:08:48 +0000 Subject: [PATCH 2/7] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto=20?= =?UTF-8?q?format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/cv2/test_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/cv2/test_integration.py b/tests/cv2/test_integration.py index 663a9457eb..37b8fad622 100644 --- a/tests/cv2/test_integration.py +++ b/tests/cv2/test_integration.py @@ -80,7 +80,7 @@ def test_ordinary_tests_use_facade_instead_of_native_cv2() -> None: def test_ordinary_suite_passes_when_cv2_is_blocked(tmp_path: Path) -> None: """Run all non-reference tests in a process where cv2 cannot be imported.""" - completed = subprocess.run( # noqa: S603 + completed = subprocess.run( [ sys.executable, "-m", From 55c6830388c8decfeaa68ec85aa48223ed352cdb Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:27:24 +0200 Subject: [PATCH 3/7] fallback-fixes: reject invalid addWeighted dtype; O(N) approxPolyDP anchor seeding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [resolve group] PR #2439 — items 2 3 --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: OpenAI Codex --- src/supervision/_cv2/_geometry.py | 16 +++++++++++----- src/supervision/_cv2/_image.py | 6 +++++- tests/cv2/test_geometry.py | 8 ++++++++ 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/supervision/_cv2/_geometry.py b/src/supervision/_cv2/_geometry.py index f5ef54350e..407b3ec737 100644 --- a/src/supervision/_cv2/_geometry.py +++ b/src/supervision/_cv2/_geometry.py @@ -110,11 +110,17 @@ def _approx_poly_dp( if closed and len(points) > 1 and np.array_equal(points[0], points[-1]): points = points[:-1] if closed and len(points) > 2: - deltas = points[:, np.newaxis] - points[np.newaxis, :] - distances = np.sum(deltas.astype(np.float64) ** 2, axis=2) - start, end = np.unravel_index(int(np.argmax(distances)), distances.shape) - if start > end: - start, end = end, start + # 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) diff --git a/src/supervision/_cv2/_image.py b/src/supervision/_cv2/_image.py index 795d20c6c0..02dac9d657 100644 --- a/src/supervision/_cv2/_image.py +++ b/src/supervision/_cv2/_image.py @@ -74,7 +74,11 @@ def _add_weighted( dtype: int | None = None, ) -> npt.NDArray[Any]: """Blend two arrays with OpenCV-compatible saturation and optional mutation.""" - del dtype + 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( diff --git a/tests/cv2/test_geometry.py b/tests/cv2/test_geometry.py index 42360c37cb..c659867229 100644 --- a/tests/cv2/test_geometry.py +++ b/tests/cv2/test_geometry.py @@ -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: From be18d139f54869e42e527cd909969f160d9fbaec Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:38:15 +0200 Subject: [PATCH 4/7] tests: copyMakeBorder sequence parity; drop non-empty facade-import assert; fix Windows path separator in boundary check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [resolve group] PR #2439 — items 1 4 5 --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: OpenAI Codex --- tests/cv2/test_image.py | 77 ++++++++++++++++++++++++++++++++--- tests/cv2/test_integration.py | 3 +- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/tests/cv2/test_image.py b/tests/cv2/test_image.py index 9590996b5b..7c0a058dea 100644 --- a/tests/cv2/test_image.py +++ b/tests/cv2/test_image.py @@ -49,13 +49,44 @@ 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", + ), + ], +) +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), ) @@ -82,6 +113,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) diff --git a/tests/cv2/test_integration.py b/tests/cv2/test_integration.py index 37b8fad622..beed9da79b 100644 --- a/tests/cv2/test_integration.py +++ b/tests/cv2/test_integration.py @@ -29,7 +29,7 @@ def _direct_cv2_imports(root: Path, excluded: set[Path] | None = None) -> list[s isinstance(node, ast.ImportFrom) and node.module == "cv2" ) if is_direct_import or is_direct_from_import: - relative_path = path.relative_to(PROJECT_ROOT) + relative_path = path.relative_to(PROJECT_ROOT).as_posix() imports.append(f"{relative_path}:{node.lineno}") return imports @@ -64,7 +64,6 @@ def test_production_imports_cv2_only_through_facade() -> None: """Keep native OpenCV imports inside the private facade module.""" imports = _direct_cv2_imports(SOURCE_ROOT) - assert imports assert all( location.startswith("src/supervision/_cv2/__init__.py:") for location in imports ) From 562fa67575ca6afe0fd7e099b93df6306c70785c Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:52:01 +0200 Subject: [PATCH 5/7] lint: auto-fix violations after resolve cycle --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- pyproject.toml | 1 + src/supervision/_cv2/_geometry.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7d6efcdb0c..2a953c65b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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. diff --git a/src/supervision/_cv2/_geometry.py b/src/supervision/_cv2/_geometry.py index 407b3ec737..1a39570c17 100644 --- a/src/supervision/_cv2/_geometry.py +++ b/src/supervision/_cv2/_geometry.py @@ -93,7 +93,7 @@ def _douglas_peucker( keep[split] = True pending.extend(((start, split), (split, end))) - return cast(npt.NDArray[np.float64], points[keep]) # type: ignore[redundant-cast] + return cast(npt.NDArray[np.float64], points[keep]) def _approx_poly_dp( From ad71817604eed79fc933b58422047ac29247566e Mon Sep 17 00:00:00 2001 From: jirka <6035284+Borda@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:01:54 +0200 Subject: [PATCH 6/7] fix(cv2): copyMakeBorder scalar value only fills channel 0 on multichannel images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [resolve] PR #2439 — QA gate finding (blocking) Scalar `value` bypassed the Sequence branch entirely and was broadcast to every channel via np.full(); OpenCV's Scalar(v) semantics fills only channel 0 and zero-pads the rest. Verified against installed cv2 4.11.0. Pre-existing bug in the original PR, surfaced by copyMakeBorder parity tests added in this resolve pass. Also reverts a lint-expert regression in _geometry.py that dropped the `# type: ignore[redundant-cast]` the PR author deliberately kept (see `warn_unused_ignores = false` override for `supervision._cv2` in pyproject.toml — the ignore is needed under pre-commit's isolated mypy env even though the project's own uv-managed mypy env reports it as unused, a numpy-stub version skew between the two environments). --- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> --- src/supervision/_cv2/_geometry.py | 2 +- src/supervision/_cv2/_image.py | 20 +++++++++++--------- tests/cv2/test_image.py | 8 ++++++++ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/supervision/_cv2/_geometry.py b/src/supervision/_cv2/_geometry.py index 1a39570c17..407b3ec737 100644 --- a/src/supervision/_cv2/_geometry.py +++ b/src/supervision/_cv2/_geometry.py @@ -93,7 +93,7 @@ def _douglas_peucker( keep[split] = True pending.extend(((start, split), (split, end))) - return cast(npt.NDArray[np.float64], points[keep]) + return cast(npt.NDArray[np.float64], points[keep]) # type: ignore[redundant-cast] def _approx_poly_dp( diff --git a/src/supervision/_cv2/_image.py b/src/supervision/_cv2/_image.py index 02dac9d657..b33f65e047 100644 --- a/src/supervision/_cv2/_image.py +++ b/src/supervision/_cv2/_image.py @@ -49,15 +49,17 @@ 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): - values = np.asarray(value, dtype=image.dtype).reshape(-1) - if image.ndim == 2: - fill_value = 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)) + # 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) result[top : top + height, left : left + width] = image diff --git a/tests/cv2/test_image.py b/tests/cv2/test_image.py index 7c0a058dea..d864077557 100644 --- a/tests/cv2/test_image.py +++ b/tests/cv2/test_image.py @@ -78,6 +78,14 @@ def test_fallback_flip_matches_opencv(flip_code: int, expected: np.ndarray) -> N (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( From 7082545df43dc83303586aa1e992a1f29a97cae5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 23:05:42 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix(pre=5Fcommit):=20=F0=9F=8E=A8=20auto=20?= =?UTF-8?q?format=20pre-commit=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/cv2/test_constants.py | 2 +- tests/cv2/test_cv2.py | 2 +- tests/cv2/test_drawing.py | 2 +- tests/cv2/test_hershey.py | 2 +- tests/cv2/test_video.py | 2 +- tests/test_validate_deprecations.py | 2 +- tests/utils/test_internal.py | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/cv2/test_constants.py b/tests/cv2/test_constants.py index 53dc1f3e7a..8837789f95 100644 --- a/tests/cv2/test_constants.py +++ b/tests/cv2/test_constants.py @@ -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, diff --git a/tests/cv2/test_cv2.py b/tests/cv2/test_cv2.py index 65fd0da36d..d08fe347e3 100644 --- a/tests/cv2/test_cv2.py +++ b/tests/cv2/test_cv2.py @@ -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, diff --git a/tests/cv2/test_drawing.py b/tests/cv2/test_drawing.py index 289597a787..bb542a23df 100644 --- a/tests/cv2/test_drawing.py +++ b/tests/cv2/test_drawing.py @@ -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, diff --git a/tests/cv2/test_hershey.py b/tests/cv2/test_hershey.py index 5ba49fad9d..fa343fc7a9 100644 --- a/tests/cv2/test_hershey.py +++ b/tests/cv2/test_hershey.py @@ -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, diff --git a/tests/cv2/test_video.py b/tests/cv2/test_video.py index b1a79af1c5..029be7a0da 100644 --- a/tests/cv2/test_video.py +++ b/tests/cv2/test_video.py @@ -77,7 +77,7 @@ def _run_without_opencv(source: str) -> None: env["PYTHONPATH"] = os.pathsep.join( filter(None, (source_path, env.get("PYTHONPATH"))) ) - result = subprocess.run( # noqa: S603 + result = subprocess.run( [sys.executable, "-c", source], check=False, capture_output=True, diff --git a/tests/test_validate_deprecations.py b/tests/test_validate_deprecations.py index 7f79e13a87..4dae57622e 100644 --- a/tests/test_validate_deprecations.py +++ b/tests/test_validate_deprecations.py @@ -141,7 +141,7 @@ def test_import_supervision_stays_silent_about_bytetrack() -> None: raise SystemExit(1 if byte_track_warnings else 0) """ - completed = subprocess.run( # noqa: S603 - trusted fixed command in a test helper. + completed = subprocess.run( [sys.executable, "-c", script], check=False, capture_output=True, diff --git a/tests/utils/test_internal.py b/tests/utils/test_internal.py index e0c710e610..e0fa52e2be 100644 --- a/tests/utils/test_internal.py +++ b/tests/utils/test_internal.py @@ -104,7 +104,7 @@ def _warning_messages_for_env( print(json.dumps([str(item.message) for item in recorded])) """ - completed = subprocess.run( # noqa: S603 - trusted fixed command in a test helper. + completed = subprocess.run( [sys.executable, "-c", script], check=True, capture_output=True,