diff --git a/docs/changelog.md b/docs/changelog.md index 65b2b19e8..0cc06e785 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/pyproject.toml b/pyproject.toml index 7d6efcdb0..2a953c65b 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/_drawing.py b/src/supervision/_cv2/_drawing.py index 825cea7da..db749ac27 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 066900182..407b3ec73 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,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] diff --git a/src/supervision/_cv2/_image.py b/src/supervision/_cv2/_image.py index 8bdb917d2..b33f65e04 100644 --- a/src/supervision/_cv2/_image.py +++ b/src/supervision/_cv2/_image.py @@ -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) @@ -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( @@ -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) @@ -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}") @@ -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: @@ -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: @@ -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) diff --git a/tests/cv2/test_constants.py b/tests/cv2/test_constants.py index 53dc1f3e7..8837789f9 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 65fd0da36..d08fe347e 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 289597a78..bb542a23d 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_geometry.py b/tests/cv2/test_geometry.py index 42360c37c..c65986722 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: diff --git a/tests/cv2/test_hershey.py b/tests/cv2/test_hershey.py index 5ba49fad9..fa343fc7a 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_image.py b/tests/cv2/test_image.py index 9590996b5..d86407755 100644 --- a/tests/cv2/test_image.py +++ b/tests/cv2/test_image.py @@ -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), ) @@ -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) diff --git a/tests/cv2/test_integration.py b/tests/cv2/test_integration.py new file mode 100644 index 000000000..beed9da79 --- /dev/null +++ b/tests/cv2/test_integration.py @@ -0,0 +1,99 @@ +"""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).as_posix() + 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 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( + [ + 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/cv2/test_video.py b/tests/cv2/test_video.py index b1a79af1c..029be7a0d 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/dataset/formats/test_coco.py b/tests/dataset/formats/test_coco.py index 235db3442..041b4549a 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 f74194c2b..5a6afd9f3 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 921140f80..c76b1cbd9 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 d58fc1d07..9190c2b6f 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 1cd934b08..9ef1f2c98 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 c96bd9874..2dd2a442a 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 0ffde75fb..7ee746710 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 075557ed0..13aa11c42 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 3ae898fb2..c2b24c6cf 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 b1745eadc..777c36353 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/test_validate_deprecations.py b/tests/test_validate_deprecations.py index 7f79e13a8..4dae57622 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/conftest.py b/tests/utils/conftest.py index 967037dd3..e247b24a8 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)] diff --git a/tests/utils/test_internal.py b/tests/utils/test_internal.py index e0c710e61..e0fa52e2b 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,