diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 4002566e..70e0fd6b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -8,7 +8,7 @@ This file provides context-aware guidance for GitHub Copilot when working in the **Supervision** is a Python library providing reusable computer vision utilities for working with object detection models (YOLO, SAM, etc.). It offers tools for detections processing, tracking, annotation, and dataset management. -- **Languages**: Python 3.9+ +- **Languages**: Python 3.10+ - **Key Dependencies**: NumPy, OpenCV, SciPy - **License**: MIT diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 8ce66c8b..f8348358 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -29,7 +29,7 @@ jobs: fail-fast: false matrix: os: ["ubuntu-latest", "windows-latest", "macos-latest"] - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.10", "3.11", "3.12", "3.13"] runs-on: ${{ matrix.os }} steps: - name: 📥 Checkout the repository diff --git a/README.md b/README.md index 97ea7844..c8d587e0 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ ## 💻 Install -Pip install the supervision package in a [**Python>=3.9**](https://www.python.org/) environment. +Pip install the supervision package in a [**Python>=3.10**](https://www.python.org/) environment. ```bash pip install supervision diff --git a/docs/changelog.md b/docs/changelog.md index 53a79fcc..8b4c847c 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,14 @@ date_modified: 2026-06-25 # Changelog +### Unreleased upcoming + +!!! failure "Python 3.9 Support Terminated" + + With the upcoming `supervision-0.30.0` release, we are terminating official support for Python 3.9, which reached end-of-life in October 2025. The minimum supported Python version is now **3.10**. + + Users on Python 3.9 should upgrade their environment before updating supervision. + ### 0.29.1 Jun 23, 2026 - Fixed [#2353](https://github.com/roboflow/supervision/pull/2353): `sv.Detections.from_inference` no longer raises `TypeError` when the Inference package returns a mixed batch where only some predictions carry a `tracker_id`. `detections.tracker_id` is `None` for the full result in that case; fully-tracked and fully-untracked batches are unchanged. diff --git a/docs/index.md b/docs/index.md index 67a841fa..29ca5cfe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -45,7 +45,7 @@ We write your reusable computer vision tools. Whether you need to load your data ## 💻 Install -You can install `supervision` in a [**Python>=3.9**](https://www.python.org/) environment. +You can install `supervision` in a [**Python>=3.10**](https://www.python.org/) environment. !!! example "Installation" diff --git a/docs/llms.full.txt b/docs/llms.full.txt index 47e9d506..4ade0ef9 100644 --- a/docs/llms.full.txt +++ b/docs/llms.full.txt @@ -129,7 +129,7 @@ Supervision is an open-source Python library by Roboflow for computer vision wor ### How do I install supervision? -Install with `pip install supervision`. For optional metric dependencies use `pip install supervision[metrics]`. Sample asset utilities are included in the base package under `supervision.assets`. The current package metadata requires Python 3.9+. +Install with `pip install supervision`. For optional metric dependencies use `pip install supervision[metrics]`. Sample asset utilities are included in the base package under `supervision.assets`. The current package metadata requires Python 3.10+. ### What can I do with supervision? diff --git a/docs/llms.txt b/docs/llms.txt index b2f97864..3271822f 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -120,7 +120,7 @@ Supervision is an open-source Python library by Roboflow for computer vision wor ### How do I install supervision? -Install with `pip install supervision`. For optional metric dependencies use `pip install supervision[metrics]`. Sample asset utilities are included in the base package under `supervision.assets`. The current package metadata requires Python 3.9+. +Install with `pip install supervision`. For optional metric dependencies use `pip install supervision[metrics]`. Sample asset utilities are included in the base package under `supervision.assets`. The current package metadata requires Python 3.10+. ### What can I do with supervision? diff --git a/docs/theme/main.html b/docs/theme/main.html index 45110773..3054edde 100644 --- a/docs/theme/main.html +++ b/docs/theme/main.html @@ -120,7 +120,7 @@ "name": "How do I install supervision?", "acceptedAnswer": { "@type": "Answer", - "text": "Install supervision with pip: pip install supervision. For optional metric dependencies use pip install supervision[metrics]. Sample asset utilities are included in the base package under supervision.assets. The current package metadata requires Python 3.9+." + "text": "Install supervision with pip: pip install supervision. For optional metric dependencies use pip install supervision[metrics]. Sample asset utilities are included in the base package under supervision.assets. The current package metadata requires Python 3.10+." } }, { diff --git a/examples/compact_mask/benchmark.py b/examples/compact_mask/benchmark.py index 052e1b46..f1e37763 100644 --- a/examples/compact_mask/benchmark.py +++ b/examples/compact_mask/benchmark.py @@ -12,8 +12,6 @@ Mask complexity is controlled by ``num_vertices``: random polygons with more vertices produce jaggier boundaries and more RLE runs per row. """ -from __future__ import annotations - import dataclasses import gc import json diff --git a/examples/time_in_zone/scripts/download_from_youtube.py b/examples/time_in_zone/scripts/download_from_youtube.py index 808d098e..029a946a 100644 --- a/examples/time_in_zone/scripts/download_from_youtube.py +++ b/examples/time_in_zone/scripts/download_from_youtube.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os import sys from typing import Any diff --git a/examples/time_in_zone/scripts/draw_zones.py b/examples/time_in_zone/scripts/draw_zones.py index e52d0602..a6bea0b2 100644 --- a/examples/time_in_zone/scripts/draw_zones.py +++ b/examples/time_in_zone/scripts/draw_zones.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import json import os from typing import Any diff --git a/examples/time_in_zone/ultralytics_stream_example.py b/examples/time_in_zone/ultralytics_stream_example.py index 75935680..ca44fcfc 100644 --- a/examples/time_in_zone/ultralytics_stream_example.py +++ b/examples/time_in_zone/ultralytics_stream_example.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import cv2 import numpy as np from inference import InferencePipeline diff --git a/examples/traffic_analysis/inference_example.py b/examples/traffic_analysis/inference_example.py index c6bd739e..8bd13941 100644 --- a/examples/traffic_analysis/inference_example.py +++ b/examples/traffic_analysis/inference_example.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os from collections.abc import Iterable diff --git a/examples/traffic_analysis/ultralytics_example.py b/examples/traffic_analysis/ultralytics_example.py index 76d916e6..03b8d776 100644 --- a/examples/traffic_analysis/ultralytics_example.py +++ b/examples/traffic_analysis/ultralytics_example.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from collections.abc import Iterable import cv2 diff --git a/pyproject.toml b/pyproject.toml index c5c24735..a064d29f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ maintainers = [ authors = [ { name = "Roboflow et al.", email = "develop@roboflow.com" }, ] -requires-python = ">=3.9" +requires-python = ">=3.10" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", @@ -33,7 +33,6 @@ classifiers = [ "Operating System :: Microsoft :: Windows", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -83,7 +82,7 @@ dev = [ ] docs = [ "mike>=2", - "mkdocs-git-committers-plugin-2>=2.4.1; python_version>='3.9' and python_version<'4'", + "mkdocs-git-committers-plugin-2>=2.4.1; python_version>='3.10' and python_version<'4'", "mkdocs-git-revision-date-localized-plugin>=1.2.4", "mkdocs-jupyter>=0.24.3", "mkdocs-material[imaging]>=9.7", @@ -102,12 +101,9 @@ package-data.supervision = [ "py.typed" ] packages.find.where = [ "src" ] packages.find.include = [ "supervision*" ] -[tool.uv] -dependency-groups.docs = { requires-python = ">=3.10" } - # exclude = [ "docs*", "tests*", "examples*" ] [tool.ruff] -target-version = "py39" +target-version = "py310" line-length = 88 indent-width = 4 # Exclude a variety of commonly ignored directories. @@ -190,7 +186,7 @@ quiet-level = 3 ignore-words-list = "STrack,sTrack,strack" [tool.mypy] -python_version = "3.9" +python_version = "3.10" ignore_missing_imports = false explicit_package_bases = true strict = true @@ -206,6 +202,7 @@ overrides = [ "tests.*", "examples.*", ], ignore_errors = true }, + { module = "deprecate", ignore_missing_imports = true }, ] [tool.pytest] diff --git a/src/supervision/annotators/core.py b/src/supervision/annotators/core.py index 04441524..7ec07860 100644 --- a/src/supervision/annotators/core.py +++ b/src/supervision/annotators/core.py @@ -1,8 +1,6 @@ -from __future__ import annotations - from functools import lru_cache from math import sqrt -from typing import Any, cast, overload +from typing import Any, cast import cv2 import numpy as np @@ -54,16 +52,6 @@ from supervision.utils.logger import _get_logger logger = _get_logger(__name__) -@overload -def _normalize_color_input(color: Color | str) -> Color: ... - - -@overload -def _normalize_color_input( - color: Color | ColorPalette | str, -) -> Color | ColorPalette: ... - - def _normalize_color_input(color: Color | ColorPalette | str) -> Color | ColorPalette: """Normalize accepted color inputs to internal color objects. @@ -1958,7 +1946,7 @@ class BlurAnnotator(BaseAnnotator): return scene image_height, image_width = scene.shape[:2] clipped_xyxy: npt.NDArray[np.int32] = clip_boxes( - xyxy=cast(npt.NDArray[np.number], detections.xyxy), + xyxy=detections.xyxy, resolution_wh=(image_width, image_height), ).astype(int) @@ -2311,7 +2299,7 @@ class PixelateAnnotator(BaseAnnotator): return scene image_height, image_width = scene.shape[:2] clipped_xyxy: npt.NDArray[np.int32] = clip_boxes( - xyxy=cast(npt.NDArray[np.number], detections.xyxy), + xyxy=detections.xyxy, resolution_wh=(image_width, image_height), ).astype(int) @@ -2635,7 +2623,7 @@ class PercentageBarAnnotator(BaseAnnotator): self.height: int = height self.width: int = width self.color: Color | ColorPalette = _normalize_color_input(color) - self.border_color: Color = _normalize_color_input(border_color) + self.border_color = cast(Color, _normalize_color_input(border_color)) self.position: Position = position self.color_lookup: ColorLookup = color_lookup @@ -3213,7 +3201,7 @@ class ComparisonAnnotator: return mask resolution_wh = scene.shape[1], scene.shape[0] - polygons = xyxy_to_polygons(cast(npt.NDArray[np.number], detections.xyxy)) + polygons = xyxy_to_polygons(detections.xyxy) for polygon in polygons: polygon_mask = polygon_to_mask(polygon, resolution_wh=resolution_wh) diff --git a/src/supervision/annotators/utils.py b/src/supervision/annotators/utils.py index caa53757..dc0da2d3 100644 --- a/src/supervision/annotators/utils.py +++ b/src/supervision/annotators/utils.py @@ -1,9 +1,7 @@ -from __future__ import annotations - import re import textwrap from enum import Enum -from typing import Any +from typing import cast import numpy as np import numpy.typing as npt @@ -156,7 +154,7 @@ def resolve_color( return get_color_by_index(color=color, idx=idx) -def wrap_text(text: Any, max_line_length: int | None = None) -> list[str]: +def wrap_text(text: object, max_line_length: int | None = None) -> list[str]: """ Wrap `text` to the specified maximum line length, respecting existing newlines. Falls back to str() if `text` is not already a string. @@ -264,9 +262,9 @@ def get_labels_text( def snap_boxes( - xyxy: np.ndarray[Any, np.dtype[np.float32]], + xyxy: npt.NDArray[np.float32], resolution_wh: tuple[int, int], -) -> np.ndarray[Any, np.dtype[np.float32]]: +) -> npt.NDArray[np.float32]: """ Shifts `label` bounding boxes into the frame so that they are fully contained within the given resolution, prioritizing the top/left edge. @@ -307,7 +305,7 @@ def snap_boxes( ``` """ - result = np.copy(xyxy) + result: npt.NDArray[np.float32] = np.array(xyxy, dtype=np.float32, copy=True) width, height = resolution_wh # X-axis (prioritize left edge) @@ -326,7 +324,7 @@ def snap_boxes( bottom_shift = height - result[bottom_overflow, 3] result[bottom_overflow, 1:4:2] += bottom_shift[:, np.newaxis] - return result.astype(np.float32) # type: ignore + return cast(npt.NDArray[np.float32], result.astype(np.float32, copy=False)) class Trace: @@ -340,9 +338,9 @@ class Trace: self.max_size = max_size self.anchor = anchor - self.frame_id = np.array([], dtype=int) - self.xy = np.empty((0, 2), dtype=np.float32) - self.tracker_id = np.array([], dtype=int) + self.frame_id: npt.NDArray[np.int_] = np.array([], dtype=int) + self.xy: npt.NDArray[np.float32] = np.empty((0, 2), dtype=np.float32) + self.tracker_id: npt.NDArray[np.int_] = np.array([], dtype=int) def put(self, detections: Detections) -> None: frame_id: npt.NDArray[np.int_] = np.full( @@ -374,11 +372,11 @@ class Trace: self.current_frame_id += 1 - def get(self, tracker_id: int) -> np.ndarray[Any, np.dtype[np.float32]]: - filtered: np.ndarray[Any, np.dtype[np.float32]] = ( - self.xy[self.tracker_id == tracker_id].copy().astype(np.float32, copy=False) + def get(self, tracker_id: int) -> npt.NDArray[np.float32]: + xy: npt.NDArray[np.float32] = np.asarray( + self.xy[self.tracker_id == tracker_id], dtype=np.float32 ) - return filtered + return xy def hex_to_rgba(hex_color: str) -> tuple[int, int, int, int]: diff --git a/src/supervision/assets/downloader.py b/src/supervision/assets/downloader.py index ba41c579..19cd7442 100644 --- a/src/supervision/assets/downloader.py +++ b/src/supervision/assets/downloader.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os from hashlib import md5 from pathlib import Path diff --git a/src/supervision/dataset/core.py b/src/supervision/dataset/core.py index 1f57024b..4e94cb1b 100644 --- a/src/supervision/dataset/core.py +++ b/src/supervision/dataset/core.py @@ -6,6 +6,7 @@ from collections.abc import Iterator from dataclasses import dataclass from itertools import chain from pathlib import Path +from typing import cast import cv2 import numpy as np @@ -114,7 +115,7 @@ class DetectionDataset(BaseDataset): image = cv2.imread(image_path) if image is None: raise ValueError(f"Could not read image from path: {image_path}") - return image + return cast(npt.NDArray[np.uint8], image) def __len__(self) -> int: return len(self._images_in_memory) or len(self.image_paths) @@ -1010,7 +1011,7 @@ class ClassificationDataset(BaseDataset): image = cv2.imread(image_path) if image is None: raise ValueError(f"Could not read image from path: {image_path}") - return image + return cast(npt.NDArray[np.uint8], image) def __len__(self) -> int: return len(self._images_in_memory) or len(self.image_paths) diff --git a/src/supervision/dataset/formats/coco.py b/src/supervision/dataset/formats/coco.py index b7270ebd..ee331d0e 100644 --- a/src/supervision/dataset/formats/coco.py +++ b/src/supervision/dataset/formats/coco.py @@ -297,7 +297,7 @@ def detections_to_coco_annotations( box_width, box_height = xyxy[2] - xyxy[0], xyxy[3] - xyxy[1] segmentation: list[list[float]] | dict[str, list[int]] = [] if mask is not None: - mask_bool = cast(npt.NDArray[np.bool_], mask) + mask_bool = mask if "iscrowd" in data: iscrowd = int(np.asarray(data["iscrowd"]).item()) else: diff --git a/src/supervision/dataset/formats/pascal_voc.py b/src/supervision/dataset/formats/pascal_voc.py index c2332377..1798b798 100644 --- a/src/supervision/dataset/formats/pascal_voc.py +++ b/src/supervision/dataset/formats/pascal_voc.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os from pathlib import Path from xml.etree.ElementTree import Element, SubElement diff --git a/src/supervision/dataset/formats/yolo.py b/src/supervision/dataset/formats/yolo.py index df0728d5..fd7da78a 100644 --- a/src/supervision/dataset/formats/yolo.py +++ b/src/supervision/dataset/formats/yolo.py @@ -2,8 +2,9 @@ from __future__ import annotations import os import warnings +from collections.abc import Sequence from pathlib import Path -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import numpy as np import numpy.typing as npt @@ -13,6 +14,7 @@ from tqdm.auto import tqdm from supervision.config import ORIENTED_BOX_COORDINATES from supervision.dataset.utils import approximate_mask_with_polygons from supervision.detection.core import Detections +from supervision.detection.utils._typing import _DetectionDataType from supervision.detection.utils.converters import polygon_to_mask, polygon_to_xyxy from supervision.utils.file import ( list_files_with_extensions, @@ -41,7 +43,8 @@ def _parse_box(values: list[str]) -> npt.NDArray[np.float32]: def _box_to_polygon(box: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: return np.array( - [[box[0], box[1]], [box[2], box[1]], [box[2], box[3]], [box[0], box[3]]] + [[box[0], box[1]], [box[2], box[1]], [box[2], box[3]], [box[0], box[3]]], + dtype=np.float32, ) @@ -50,7 +53,7 @@ def _parse_polygon(values: list[str]) -> npt.NDArray[np.float32]: def _polygons_to_masks( - polygons: list[npt.NDArray[np.number]], resolution_wh: tuple[int, int] + polygons: Sequence[npt.NDArray[np.number]], resolution_wh: tuple[int, int] ) -> npt.NDArray[np.bool_]: return np.array( [ @@ -145,41 +148,44 @@ def yolo_annotations_to_detections( if len(lines) == 0: return Detections.empty() - class_id, relative_xyxy, relative_polygon, relative_xyxyxyxy = [], [], [], [] + class_id_list: list[int] = [] + relative_xyxy_list: list[npt.NDArray[np.number]] = [] + relative_polygon_list: list[npt.NDArray[np.float32]] = [] + relative_xyxyxyxy_list: list[npt.NDArray[np.float32]] = [] w, h = resolution_wh for line in lines: values = line.split() - class_id.append(int(values[0])) + class_id_list.append(int(values[0])) if len(values) == 5: box = _parse_box(values=values[1:]) - relative_xyxy.append(box) + relative_xyxy_list.append(box) if with_masks: - relative_polygon.append(_box_to_polygon(box=box)) + relative_polygon_list.append(_box_to_polygon(box=box)) elif len(values) > 5: polygon = _parse_polygon(values=values[1:]) - relative_xyxy.append(polygon_to_xyxy(polygon=polygon)) + relative_xyxy_list.append(polygon_to_xyxy(polygon=polygon)) if is_obb: - relative_xyxyxyxy.append(np.array(values[1:])) + relative_xyxyxyxy_list.append(np.array(values[1:], dtype=np.float32)) if with_masks: - relative_polygon.append(polygon) + relative_polygon_list.append(polygon) - class_id = np.array(class_id, dtype=int) - relative_xyxy = np.array(relative_xyxy, dtype=np.float32) + class_id = np.array(class_id_list, dtype=int) + relative_xyxy = np.array(relative_xyxy_list, dtype=np.float32) xyxy = relative_xyxy * np.array([w, h, w, h], dtype=np.float32) - data = {} + data: _DetectionDataType = {} if is_obb: - relative_xyxyxyxy = np.array(relative_xyxyxyxy, dtype=np.float32) + relative_xyxyxyxy = np.array(relative_xyxyxyxy_list, dtype=np.float32) xyxyxyxy = relative_xyxyxyxy.reshape(-1, 4, 2) xyxyxyxy *= np.array([w, h], dtype=np.float32) - data[ORIENTED_BOX_COORDINATES] = xyxyxyxy + data[ORIENTED_BOX_COORDINATES] = cast(npt.NDArray[np.generic], xyxyxyxy) if not with_masks: return Detections(class_id=class_id, xyxy=xyxy, data=data) polygons = [ polygon * np.array(resolution_wh, dtype=np.float32) - for polygon in relative_polygon + for polygon in relative_polygon_list ] mask = _polygons_to_masks(polygons=polygons, resolution_wh=resolution_wh) return Detections(class_id=class_id, xyxy=xyxy, data=data, mask=mask) diff --git a/src/supervision/dataset/utils.py b/src/supervision/dataset/utils.py index c0bceb47..70c5b445 100644 --- a/src/supervision/dataset/utils.py +++ b/src/supervision/dataset/utils.py @@ -5,7 +5,7 @@ import os import random import shutil from pathlib import Path -from typing import TYPE_CHECKING, Any, TypeVar +from typing import TYPE_CHECKING, TypeVar, cast import cv2 import numpy as np @@ -32,16 +32,16 @@ def mask_to_rle( mask: npt.NDArray[np.bool_], compressed: bool = False ) -> list[int] | str: """Deprecated. Use `supervision.detection.utils.converters.mask_to_rle`.""" - return void(mask, compressed) # type: ignore[no-any-return] + return cast(list[int] | str, void(mask, compressed)) @deprecated(target=_rle_to_mask, deprecated_in="0.28.0", remove_in="0.30.0") # type: ignore[untyped-decorator] def rle_to_mask( - rle: npt.NDArray[np.integer[Any]] | list[int] | str | bytes, + rle: npt.NDArray[np.integer] | list[int] | str | bytes, resolution_wh: tuple[int, int], ) -> npt.NDArray[np.bool_]: """Deprecated. Use `supervision.detection.utils.converters.rle_to_mask`.""" - return void(rle, resolution_wh) + return cast(npt.NDArray[np.bool_], void(rle, resolution_wh)) if TYPE_CHECKING: @@ -61,7 +61,7 @@ def approximate_mask_with_polygons( minimum_detection_area = min_image_area_percentage * image_area maximum_detection_area = max_image_area_percentage * image_area - polygons = mask_to_polygons(mask=mask) + polygons = cast(list[npt.NDArray[np.number]], mask_to_polygons(mask=mask)) if len(polygons) == 1: polygons = filter_polygons_by_area( polygons=polygons, min_area=None, max_area=maximum_detection_area diff --git a/src/supervision/detection/compact_mask.py b/src/supervision/detection/compact_mask.py index b2ffdac0..9244159e 100644 --- a/src/supervision/detection/compact_mask.py +++ b/src/supervision/detection/compact_mask.py @@ -13,7 +13,7 @@ from __future__ import annotations import os from collections.abc import Iterator -from typing import Any +from typing import cast, overload import numpy as np import numpy.typing as npt @@ -323,9 +323,10 @@ def _rle_resize( col_cache: dict[int, list[int]] = {} scaled_cols = [] for src_c in col_map: - if src_c not in col_cache: - col_cache[src_c] = _rle_scale_col(per_col[src_c], crop_h, row_map) - scaled_cols.append(col_cache[src_c]) + src_c_int = int(src_c) + if src_c_int not in col_cache: + col_cache[src_c_int] = _rle_scale_col(per_col[src_c_int], crop_h, row_map) + scaled_cols.append(col_cache[src_c_int]) return _rle_join_cols(scaled_cols, new_total) @@ -476,7 +477,7 @@ class CompactMask: def from_dense( cls, masks: npt.NDArray[np.bool_], - xyxy: npt.NDArray[Any], + xyxy: npt.NDArray[np.number], image_shape: tuple[int, int], ) -> CompactMask: """Create a :class:`CompactMask` from a dense ``(N, H, W)`` bool array. @@ -720,7 +721,7 @@ class CompactMask: return np.column_stack((x1, y1, x2, y2)).astype(np.int32, copy=False) @property - def dtype(self) -> np.dtype[Any]: + def dtype(self) -> np.dtype[np.bool_]: """Return ``np.dtype(bool)`` — always. Returns: @@ -763,7 +764,9 @@ class CompactMask: """ return np.array([_rle_area(rle) for rle in self._rles], dtype=np.int64) - def sum(self, axis: int | tuple[int, ...] | None = None) -> npt.NDArray[Any] | int: + def sum( + self, axis: int | tuple[int, ...] | None = None + ) -> npt.NDArray[np.int64] | np.int64: """NumPy-compatible sum with a fast path for per-mask area. When ``axis=(1, 2)``, returns the per-mask True-pixel count via @@ -790,11 +793,32 @@ class CompactMask: """ if axis == (1, 2): return self.area - return self.to_dense().sum(axis=axis) + return cast(npt.NDArray[np.int64] | np.int64, self.to_dense().sum(axis=axis)) + + @overload + def __getitem__(self, index: int | np.integer) -> npt.NDArray[np.bool_]: ... + + @overload + def __getitem__( + self, + index: slice + | list[int] + | list[bool] + | npt.NDArray[np.int_] + | npt.NDArray[np.bool_], + ) -> CompactMask: ... def __getitem__( self, - index: int | slice | list[Any] | npt.NDArray[Any], + index: ( + int + | np.integer + | slice + | list[int] + | list[bool] + | npt.NDArray[np.int_] + | npt.NDArray[np.bool_] + ), ) -> npt.NDArray[np.bool_] | CompactMask: """Index into the mask collection. @@ -860,7 +884,9 @@ class CompactMask: new_offsets: npt.NDArray[np.int32] = self._offsets[idx_arr] return CompactMask(new_rles, new_crop_shapes, new_offsets, self._image_shape) - def __array__(self, dtype: np.dtype[Any] | None = None) -> npt.NDArray[Any]: + def __array__( + self, dtype: np.dtype[np.generic] | None = None + ) -> npt.NDArray[np.generic]: """NumPy interop: materialise as a dense ``(N, H, W)`` array. Called by ``np.asarray(compact_mask)`` and similar NumPy functions. diff --git a/src/supervision/detection/core.py b/src/supervision/detection/core.py index b130d1db..23d6edbe 100644 --- a/src/supervision/detection/core.py +++ b/src/supervision/detection/core.py @@ -19,6 +19,7 @@ from supervision.detection.tools.transformers import ( process_transformers_v4_segmentation_result, process_transformers_v5_segmentation_result, ) +from supervision.detection.utils._typing import _DetectionDataType, _MetadataType from supervision.detection.utils.boxes import obb_polygon_area, xyxyxyxy_to_xyxy from supervision.detection.utils.converters import ( mask_to_xyxy, @@ -153,13 +154,13 @@ class Detections: as the video name, camera parameters, timestamp, or other global metadata. """ # noqa: E501 // docs - xyxy: npt.NDArray[np.generic] - mask: npt.NDArray[np.generic] | CompactMask | None = None - confidence: npt.NDArray[np.generic] | None = None - class_id: npt.NDArray[np.generic] | None = None - tracker_id: npt.NDArray[np.generic] | None = None - data: dict[str, npt.NDArray[np.generic] | list[Any]] = field(default_factory=dict) - metadata: dict[str, Any] = field(default_factory=dict) + xyxy: npt.NDArray[np.number] + mask: npt.NDArray[np.bool_] | CompactMask | None = None + confidence: npt.NDArray[np.floating] | None = None + class_id: npt.NDArray[np.integer] | None = None + tracker_id: npt.NDArray[np.integer] | None = None + data: _DetectionDataType = field(default_factory=dict) + metadata: _MetadataType = field(default_factory=dict) def __post_init__(self) -> None: _validate_detections_fields( @@ -181,12 +182,12 @@ class Detections: self, ) -> Iterator[ tuple[ - npt.NDArray[np.generic], - npt.NDArray[np.generic] | None, + npt.NDArray[np.number], + npt.NDArray[np.bool_] | None, np.generic | None, np.generic | None, np.generic | None, - dict[str, npt.NDArray[np.generic] | list[Any]], + _DetectionDataType, ] ]: """ @@ -206,13 +207,34 @@ class Detections: def __eq__(self, other: object) -> bool: if not isinstance(other, Detections): return NotImplemented + + def array_equal_or_none( + a: npt.NDArray[np.generic] | None, + b: npt.NDArray[np.generic] | None, + ) -> bool: + if a is None or b is None: + return a is b + return bool(np.array_equal(a, b)) + + def mask_equal( + a: npt.NDArray[np.generic] | CompactMask | None, + b: npt.NDArray[np.generic] | CompactMask | None, + ) -> bool: + if a is None or b is None: + return a is b + if isinstance(a, CompactMask): + return bool(a == b) + if isinstance(b, CompactMask): + return bool(b == a) + return bool(np.array_equal(a, b)) + return all( [ np.array_equal(self.xyxy, other.xyxy), - np.array_equal(self.mask, other.mask), - np.array_equal(self.class_id, other.class_id), - np.array_equal(self.confidence, other.confidence), - np.array_equal(self.tracker_id, other.tracker_id), + mask_equal(self.mask, other.mask), + array_equal_or_none(self.class_id, other.class_id), + array_equal_or_none(self.confidence, other.confidence), + array_equal_or_none(self.tracker_id, other.tracker_id), is_data_equal(self.data, other.data), is_metadata_equal(self.metadata, other.metadata), ] @@ -302,7 +324,9 @@ class Detections: ) if hasattr(ultralytics_results, "boxes") and ultralytics_results.boxes is None: - masks = extract_ultralytics_masks(ultralytics_results) + masks = cast( + npt.NDArray[np.bool_], extract_ultralytics_masks(ultralytics_results) + ) return cls( xyxy=mask_to_xyxy(masks), mask=masks, @@ -1891,7 +1915,7 @@ class Detections: if vlm == VLM.PALIGEMMA: assert isinstance(result, str) xyxy, class_id, class_name = from_paligemma(result, **kwargs) - data: dict[str, npt.NDArray[np.generic] | list[Any]] = { + data: _DetectionDataType = { CLASS_NAME_DATA_FIELD: class_name, } return cls(xyxy=xyxy, class_id=class_id, data=data) @@ -2176,25 +2200,31 @@ class Detections: xyxy = np.vstack([d.xyxy for d in detections_list]) - def stack_or_none( - name: str, - ) -> npt.NDArray[np.generic] | CompactMask | None: - if all(d.__getattribute__(name) is None for d in detections_list): + def stack_mask_or_none() -> npt.NDArray[np.generic] | CompactMask | None: + masks = [d.mask for d in detections_list] + if all(m is None for m in masks): return None - if any(d.__getattribute__(name) is None for d in detections_list): - raise ValueError(f"All or none of the '{name}' fields must be None") - if name == "mask": - masks = [d.__getattribute__(name) for d in detections_list] - if all(isinstance(m, CompactMask) for m in masks): - return CompactMask.merge(masks) - # Mixed or all-ndarray: __array__ auto-converts any CompactMask. - return np.vstack([np.asarray(m) for m in masks]) - return np.hstack([d.__getattribute__(name) for d in detections_list]) + if any(m is None for m in masks): + raise ValueError("All or none of the 'mask' fields must be None") + if all(isinstance(m, CompactMask) for m in masks): + return CompactMask.merge(cast(list[CompactMask], masks)) + # Mixed or all-ndarray: __array__ auto-converts any CompactMask. + return cast( + npt.NDArray[np.generic], np.vstack([np.asarray(m) for m in masks]) + ) - mask = stack_or_none("mask") - confidence = stack_or_none("confidence") - class_id = stack_or_none("class_id") - tracker_id = stack_or_none("tracker_id") + def stack_or_none(name: str) -> npt.NDArray[np.generic] | None: + values = [getattr(d, name) for d in detections_list] + if all(v is None for v in values): + return None + if any(v is None for v in values): + raise ValueError(f"All or none of the '{name}' fields must be None") + return cast(npt.NDArray[np.generic], np.hstack(values)) + + mask = cast(npt.NDArray[np.bool_] | CompactMask | None, stack_mask_or_none()) + confidence = cast(npt.NDArray[np.floating] | None, stack_or_none("confidence")) + class_id = cast(npt.NDArray[np.integer] | None, stack_or_none("class_id")) + tracker_id = cast(npt.NDArray[np.integer] | None, stack_or_none("tracker_id")) data = merge_data([d.data for d in detections_list]) @@ -2230,14 +2260,18 @@ class Detections: Raises: ValueError: If the provided `anchor` is not supported. """ - xyxy = cast(npt.NDArray[np.number], self.xyxy) + xyxy = self.xyxy + + def coordinates( + x: npt.NDArray[np.number], y: npt.NDArray[np.number] + ) -> npt.NDArray[np.generic]: + return cast(npt.NDArray[np.generic], np.array([x, y]).transpose()) + if anchor == Position.CENTER: - return np.array( - [ - (xyxy[:, 0] + xyxy[:, 2]) / 2, - (xyxy[:, 1] + xyxy[:, 3]) / 2, - ] - ).transpose() + return coordinates( + (xyxy[:, 0] + xyxy[:, 2]) / 2, + (xyxy[:, 1] + xyxy[:, 3]) / 2, + ) elif anchor == Position.CENTER_OF_MASS: if self.mask is None: raise ValueError( @@ -2245,31 +2279,21 @@ class Detections: ) return calculate_masks_centroids(masks=self.mask) elif anchor == Position.CENTER_LEFT: - return np.array( - [ - xyxy[:, 0], - (xyxy[:, 1] + xyxy[:, 3]) / 2, - ] - ).transpose() + return coordinates(xyxy[:, 0], (xyxy[:, 1] + xyxy[:, 3]) / 2) elif anchor == Position.CENTER_RIGHT: - return np.array( - [ - xyxy[:, 2], - (xyxy[:, 1] + xyxy[:, 3]) / 2, - ] - ).transpose() + return coordinates(xyxy[:, 2], (xyxy[:, 1] + xyxy[:, 3]) / 2) elif anchor == Position.BOTTOM_CENTER: - return np.array([(xyxy[:, 0] + xyxy[:, 2]) / 2, xyxy[:, 3]]).transpose() + return coordinates((xyxy[:, 0] + xyxy[:, 2]) / 2, xyxy[:, 3]) elif anchor == Position.BOTTOM_LEFT: - return np.array([xyxy[:, 0], xyxy[:, 3]]).transpose() + return coordinates(xyxy[:, 0], xyxy[:, 3]) elif anchor == Position.BOTTOM_RIGHT: - return np.array([xyxy[:, 2], xyxy[:, 3]]).transpose() + return coordinates(xyxy[:, 2], xyxy[:, 3]) elif anchor == Position.TOP_CENTER: - return np.array([(xyxy[:, 0] + xyxy[:, 2]) / 2, xyxy[:, 1]]).transpose() + return coordinates((xyxy[:, 0] + xyxy[:, 2]) / 2, xyxy[:, 1]) elif anchor == Position.TOP_LEFT: - return np.array([xyxy[:, 0], xyxy[:, 1]]).transpose() + return coordinates(xyxy[:, 0], xyxy[:, 1]) elif anchor == Position.TOP_RIGHT: - return np.array([xyxy[:, 2], xyxy[:, 1]]).transpose() + return coordinates(xyxy[:, 2], xyxy[:, 1]) raise ValueError(f"{anchor} is not supported.") @@ -2312,13 +2336,20 @@ class Detections: return self if isinstance(index, int): index = [index] + array_index = cast( + slice | list[int] | npt.NDArray[np.integer | np.bool_], index + ) return Detections( - xyxy=self.xyxy[index], - mask=self.mask[index] if self.mask is not None else None, - confidence=self.confidence[index] if self.confidence is not None else None, - class_id=self.class_id[index] if self.class_id is not None else None, - tracker_id=self.tracker_id[index] if self.tracker_id is not None else None, - data=get_data_item(self.data, index), + xyxy=self.xyxy[array_index], + mask=self.mask[cast(Any, array_index)] if self.mask is not None else None, + confidence=( + self.confidence[array_index] if self.confidence is not None else None + ), + class_id=self.class_id[array_index] if self.class_id is not None else None, + tracker_id=( + self.tracker_id[array_index] if self.tracker_id is not None else None + ), + data=get_data_item(self.data, array_index), metadata=self.metadata, ) @@ -2402,7 +2433,9 @@ class Detections: return self.mask.area return np.array([np.sum(mask) for mask in self.mask]) if ORIENTED_BOX_COORDINATES in self.data: - return obb_polygon_area(self.data[ORIENTED_BOX_COORDINATES]) + return obb_polygon_area( + cast(npt.NDArray[np.number], self.data[ORIENTED_BOX_COORDINATES]) + ) return self.box_area @property @@ -2492,18 +2525,24 @@ class Detections: ) if class_agnostic: - predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) + predictions = cast( + npt.NDArray[np.floating], + np.hstack((self.xyxy, self.confidence.reshape(-1, 1))), + ) else: assert self.class_id is not None, ( "Detections class_id must be given for NMS to be executed. If you" " intended to perform class agnostic NMS set class_agnostic=True." ) - predictions = np.hstack( - ( - self.xyxy, - self.confidence.reshape(-1, 1), - self.class_id.reshape(-1, 1), - ) + predictions = cast( + npt.NDArray[np.floating], + np.hstack( + ( + self.xyxy, + self.confidence.reshape(-1, 1), + self.class_id.reshape(-1, 1), + ) + ), ) if self.mask is not None: @@ -2581,18 +2620,24 @@ class Detections: ) if class_agnostic: - predictions = np.hstack((self.xyxy, self.confidence.reshape(-1, 1))) + predictions = cast( + npt.NDArray[np.floating], + np.hstack((self.xyxy, self.confidence.reshape(-1, 1))), + ) else: assert self.class_id is not None, ( "Detections class_id must be given for NMM to be executed. If you" " intended to perform class agnostic NMM set class_agnostic=True." ) - predictions = np.hstack( - ( - self.xyxy, - self.confidence.reshape(-1, 1), - self.class_id.reshape(-1, 1), - ) + predictions = cast( + npt.NDArray[np.floating], + np.hstack( + ( + self.xyxy, + self.confidence.reshape(-1, 1), + self.class_id.reshape(-1, 1), + ) + ), ) if self.mask is not None: @@ -2697,6 +2742,7 @@ def _merge_detection_group(detections: list[Detections]) -> Detections: all_xyxy = np.array([d.xyxy[0] for d in detections], dtype=np.float32) areas = (all_xyxy[:, 2] - all_xyxy[:, 0]) * (all_xyxy[:, 3] - all_xyxy[:, 1]) + confidence: npt.NDArray[np.floating] | None if winner.confidence is not None: total_area = float(areas.sum()) if total_area > 0: @@ -2828,7 +2874,10 @@ def merge_inner_detection_object_pair( if detections_1.mask is None and detections_2.mask is None: merged_mask = None else: - merged_mask = np.logical_or(detections_1.mask, detections_2.mask) + merged_mask = np.logical_or( + cast(npt.NDArray[Any], detections_1.mask), + cast(npt.NDArray[Any], detections_2.mask), + ) if detections_1.confidence is None or detections_2.confidence is None: winning_detection = detections_1 @@ -2871,7 +2920,11 @@ def merge_inner_detections_objects( 0 ] else: - iou = box_iou_batch(detections_1.xyxy, detections_2.xyxy, overlap_metric)[0] + iou = box_iou_batch( + detections_1.xyxy, + detections_2.xyxy, + overlap_metric, + )[0] if iou < threshold: break detections_1 = merge_inner_detection_object_pair(detections_1, detections_2) diff --git a/src/supervision/detection/line_zone.py b/src/supervision/detection/line_zone.py index 3178fca2..8a726e7b 100644 --- a/src/supervision/detection/line_zone.py +++ b/src/supervision/detection/line_zone.py @@ -1,11 +1,9 @@ -from __future__ import annotations - import math import warnings from collections import Counter, defaultdict, deque from collections.abc import Iterable from functools import lru_cache -from typing import Any, Literal, cast +from typing import Literal import cv2 import numpy as np @@ -99,7 +97,7 @@ class LineZone: Position.BOTTOM_RIGHT, ), minimum_crossing_threshold: int = 1, - ): + ) -> None: """ Args: start: The starting point of the line. @@ -344,7 +342,7 @@ class LineZoneAnnotator: display_text_box: bool = True, text_orient_to_line: bool = False, text_centered: bool = True, - ): + ) -> None: """ A class for drawing the `LineZone` and its detected object count on an image. @@ -673,29 +671,33 @@ class LineZoneAnnotator: annotation_shape = (annotation_dim, annotation_dim) annotation_center = Point(annotation_dim // 2, annotation_dim // 2) - annotation = np.zeros((*annotation_shape, 3), dtype=np.uint8) - annotation_alpha = np.zeros((*annotation_shape, 1), dtype=np.uint8) - - text_args: dict[str, Any] = dict( + annotation: npt.NDArray[np.uint8] = np.zeros( + (*annotation_shape, 3), dtype=np.uint8 + ) + annotation_alpha: npt.NDArray[np.uint8] = np.zeros( + (*annotation_shape, 1), dtype=np.uint8 + ) + draw_text( + scene=annotation, text=text, text_anchor=annotation_center, text_scale=text_scale, text_thickness=text_thickness, text_padding=text_padding, - ) - draw_text( - scene=annotation, text_color=text_color, background_color=text_box_color if text_box_show else None, - **text_args, ) draw_text( scene=annotation_alpha, + text=text, + text_anchor=annotation_center, + text_scale=text_scale, + text_thickness=text_thickness, + text_padding=text_padding, text_color=Color.WHITE, background_color=Color.WHITE if text_box_show else None, - **text_args, ) - annotation = np.dstack((annotation, annotation_alpha)) + annotation = np.dstack((annotation, annotation_alpha)).astype(np.uint8) # Make sure text is displayed upright if 90 < line_angle_degrees % 360 < 270: @@ -705,9 +707,11 @@ class LineZoneAnnotator: rotation_matrix = cv2.getRotationMatrix2D( annotation_center.as_xy_float_tuple(), rotation_angle, scale=1 ) - annotation = cv2.warpAffine(annotation, rotation_matrix, annotation_shape) + annotation = cv2.warpAffine( + annotation, rotation_matrix, annotation_shape + ).astype(np.uint8) - return cast(npt.NDArray[np.uint8], annotation) + return annotation class LineZoneAnnotatorMulticlass: @@ -728,7 +732,7 @@ class LineZoneAnnotatorMulticlass: text_scale: float = 0.75, text_thickness: int = 1, force_draw_class_ids: bool = False, - ): + ) -> None: """ Draw a table showing how many items of each class crossed each line. diff --git a/src/supervision/detection/tools/inference_slicer.py b/src/supervision/detection/tools/inference_slicer.py index 897403aa..ee40d4ab 100644 --- a/src/supervision/detection/tools/inference_slicer.py +++ b/src/supervision/detection/tools/inference_slicer.py @@ -4,10 +4,10 @@ import threading import warnings from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable +from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable if TYPE_CHECKING: - from typing_extensions import TypeGuard + from typing import TypeGuard import numpy as np import numpy.typing as npt @@ -74,7 +74,10 @@ def move_detections( detections.xyxy = move_boxes(xyxy=detections.xyxy, offset=offset) if ORIENTED_BOX_COORDINATES in detections.data: detections.data[ORIENTED_BOX_COORDINATES] = move_oriented_boxes( - xyxyxyxy=detections.data[ORIENTED_BOX_COORDINATES], offset=offset + xyxyxyxy=cast( + npt.NDArray[np.number], detections.data[ORIENTED_BOX_COORDINATES] + ), + offset=offset, ) if detections.mask is not None: if resolution_wh is None: @@ -564,7 +567,10 @@ class InferenceSlicer: slices = [crop_image(image=image, xyxy=offset) for offset in offsets] resolution_wh = get_image_resolution_wh(image) - detections_in_slices = self.callback(slices) + batch_callback = cast( + Callable[[list[npt.NDArray[Any]]], list[Detections]], self.callback + ) + detections_in_slices = batch_callback(slices) if not isinstance(detections_in_slices, list): raise ValueError( "Callback must return `list[Detections]` when `batch_size > 1`. " diff --git a/src/supervision/detection/tools/polygon_zone.py b/src/supervision/detection/tools/polygon_zone.py index acb95522..0beb4b62 100644 --- a/src/supervision/detection/tools/polygon_zone.py +++ b/src/supervision/detection/tools/polygon_zone.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from collections.abc import Iterable from typing import Any, cast @@ -57,7 +55,7 @@ class PolygonZone: self, polygon: npt.NDArray[np.int64], triggering_anchors: Iterable[Position] = (Position.BOTTOM_CENTER,), - ): + ) -> None: self.polygon = polygon.astype(int) self.triggering_anchors = triggering_anchors if not list(self.triggering_anchors): @@ -89,7 +87,7 @@ class PolygonZone: """ if len(detections) == 0: self.current_count = 0 - return np.array([], dtype=bool) + return cast(npt.NDArray[np.bool_], np.array([], dtype=bool)) all_anchors = np.array( [ @@ -105,7 +103,7 @@ class PolygonZone: y_safe = np.clip(y, 0, mask_h - 1) is_in_zone = np.all(in_bounds & self.mask[y_safe, x_safe], axis=0) self.current_count = int(np.sum(is_in_zone)) - return is_in_zone.astype(bool) + return cast(npt.NDArray[np.bool_], is_in_zone.astype(bool)) class PolygonZoneAnnotator: @@ -139,7 +137,7 @@ class PolygonZoneAnnotator: text_padding: int = 10, display_in_zone_count: bool = True, opacity: float = 0, - ): + ) -> None: self.zone = zone self.color = color self.thickness = thickness diff --git a/src/supervision/detection/tools/smoother.py b/src/supervision/detection/tools/smoother.py index 9ebfd054..77305e29 100644 --- a/src/supervision/detection/tools/smoother.py +++ b/src/supervision/detection/tools/smoother.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import warnings from collections import defaultdict, deque from copy import deepcopy diff --git a/src/supervision/detection/tools/transformers.py b/src/supervision/detection/tools/transformers.py index f535217a..6056a35e 100644 --- a/src/supervision/detection/tools/transformers.py +++ b/src/supervision/detection/tools/transformers.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import io from typing import Any, cast diff --git a/src/supervision/detection/utils/_typing.py b/src/supervision/detection/utils/_typing.py new file mode 100644 index 00000000..7b903ab3 --- /dev/null +++ b/src/supervision/detection/utils/_typing.py @@ -0,0 +1,8 @@ +from typing import Any, TypeAlias + +import numpy as np +import numpy.typing as npt + +_DetectionDataValueType: TypeAlias = npt.NDArray[np.generic] | list[Any] +_DetectionDataType: TypeAlias = dict[str, _DetectionDataValueType] +_MetadataType: TypeAlias = dict[str, Any] diff --git a/src/supervision/detection/utils/boxes.py b/src/supervision/detection/utils/boxes.py index 3aa2498e..4f18699e 100644 --- a/src/supervision/detection/utils/boxes.py +++ b/src/supervision/detection/utils/boxes.py @@ -1,4 +1,4 @@ -from __future__ import annotations +from typing import Any, cast import numpy as np import numpy.typing as npt @@ -92,11 +92,11 @@ def pad_boxes( if py is None: py = px - result = xyxy.copy() + result = cast(npt.NDArray[Any], xyxy.copy()) result[:, [0, 1]] -= [px, py] result[:, [2, 3]] += [px, py] - return result + return cast(npt.NDArray[np.number], result) @deprecated( # type: ignore[untyped-decorator] @@ -156,17 +156,17 @@ def denormalize_boxes( ``` """ width, height = resolution_wh - result = xyxy.copy() + result = cast(npt.NDArray[Any], xyxy.copy()) result[:, [0, 2]] = (result[:, [0, 2]] * width) / normalization_factor result[:, [1, 3]] = (result[:, [1, 3]] * height) / normalization_factor - return result + return cast(npt.NDArray[np.number], result) def move_boxes( - xyxy: npt.NDArray[np.float64], offset: npt.NDArray[np.int32] -) -> npt.NDArray[np.float64]: + xyxy: npt.NDArray[np.number], offset: npt.NDArray[np.integer] +) -> npt.NDArray[np.number]: """ Args: xyxy: An array of shape `(n, 4)` containing the @@ -196,8 +196,8 @@ def move_boxes( def move_oriented_boxes( - xyxyxyxy: npt.NDArray[np.float64], offset: npt.NDArray[np.int32] -) -> npt.NDArray[np.float64]: + xyxyxyxy: npt.NDArray[np.number], offset: npt.NDArray[np.integer] +) -> npt.NDArray[np.number]: """ Args: xyxyxyxy: An array of shape `(n, 4, 2)` containing the @@ -244,7 +244,7 @@ def move_oriented_boxes( return xyxyxyxy + offset -def obb_polygon_area(corners: npt.NDArray) -> npt.NDArray[np.float64]: +def obb_polygon_area(corners: npt.NDArray[np.number]) -> npt.NDArray[np.float64]: """Compute the area of N oriented bounding boxes using the shoelace formula. Args: @@ -263,13 +263,13 @@ def obb_polygon_area(corners: npt.NDArray) -> npt.NDArray[np.float64]: >>> obb_polygon_area(corners) array([50.]) """ - corners = np.asarray(corners) + corners = cast(npt.NDArray[np.number], np.asarray(corners)) if corners.ndim != 3 or corners.shape[-2:] != (4, 2): raise ValueError(f"corners must have shape (N, 4, 2); got {corners.shape}") x = corners[..., 0].astype(np.float64, copy=False) y = corners[..., 1].astype(np.float64, copy=False) cross = x * np.roll(y, -1, axis=-1) - y * np.roll(x, -1, axis=-1) - return 0.5 * np.abs(np.sum(cross, axis=-1)) + return cast(npt.NDArray[np.float64], 0.5 * np.abs(np.sum(cross, axis=-1))) def xyxyxyxy_to_xyxy( @@ -302,14 +302,14 @@ def xyxyxyxy_to_xyxy( ``` """ - xyxyxyxy = np.asarray(xyxyxyxy) + xyxyxyxy = cast(npt.NDArray[np.number], np.asarray(xyxyxyxy)) if xyxyxyxy.ndim != 3 or xyxyxyxy.shape[-2:] != (4, 2): raise ValueError(f"xyxyxyxy must have shape (N, 4, 2); got {xyxyxyxy.shape}") x_min = xyxyxyxy[..., 0].min(axis=-1) y_min = xyxyxyxy[..., 1].min(axis=-1) x_max = xyxyxyxy[..., 0].max(axis=-1) y_max = xyxyxyxy[..., 1].max(axis=-1) - return np.stack([x_min, y_min, x_max, y_max], axis=-1) + return cast(npt.NDArray[np.number], np.stack([x_min, y_min, x_max, y_max], axis=-1)) def scale_boxes( @@ -378,7 +378,7 @@ def spread_out_boxes( if len(xyxy) == 0: return xyxy - xyxy_padded = pad_boxes(xyxy, px=1) + xyxy_padded = cast(npt.NDArray[Any], pad_boxes(xyxy, px=1)) for _ in range(max_iterations): # NxN iou = box_iou_batch(xyxy_padded, xyxy_padded) diff --git a/src/supervision/detection/utils/converters.py b/src/supervision/detection/utils/converters.py index a1652e47..764b9678 100644 --- a/src/supervision/detection/utils/converters.py +++ b/src/supervision/detection/utils/converters.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any, cast import cv2 @@ -79,7 +77,7 @@ def xywh_to_xyxy(xywh: npt.NDArray[np.number]) -> npt.NDArray[np.number]: xyxy = xywh.copy() xyxy[:, 2] = xywh[:, 0] + xywh[:, 2] xyxy[:, 3] = xywh[:, 1] + xywh[:, 3] - return xyxy + return cast(npt.NDArray[np.number], np.asarray(xyxy)) def xyxy_to_xywh(xyxy: npt.NDArray[np.number]) -> npt.NDArray[np.number]: @@ -113,7 +111,7 @@ def xyxy_to_xywh(xyxy: npt.NDArray[np.number]) -> npt.NDArray[np.number]: xywh = xyxy.copy() xywh[:, 2] = xyxy[:, 2] - xyxy[:, 0] xywh[:, 3] = xyxy[:, 3] - xyxy[:, 1] - return xywh + return cast(npt.NDArray[np.number], np.asarray(xywh)) def xcycwh_to_xyxy(xcycwh: npt.NDArray[np.number]) -> npt.NDArray[np.number]: @@ -149,7 +147,7 @@ def xcycwh_to_xyxy(xcycwh: npt.NDArray[np.number]) -> npt.NDArray[np.number]: xyxy[:, 1] = xcycwh[:, 1] - xcycwh[:, 3] / 2 xyxy[:, 2] = xcycwh[:, 0] + xcycwh[:, 2] / 2 xyxy[:, 3] = xcycwh[:, 1] + xcycwh[:, 3] / 2 - return xyxy + return cast(npt.NDArray[np.number], np.asarray(xyxy)) def xyxy_to_xcycarh(xyxy: npt.NDArray[np.number]) -> npt.NDArray[np.floating]: diff --git a/src/supervision/detection/utils/internal.py b/src/supervision/detection/utils/internal.py index c5d09216..467c84b4 100644 --- a/src/supervision/detection/utils/internal.py +++ b/src/supervision/detection/utils/internal.py @@ -1,14 +1,13 @@ -from __future__ import annotations - import logging from itertools import chain -from typing import Any, Union, cast +from typing import Any, cast import cv2 import numpy as np import numpy.typing as npt from supervision.config import CLASS_NAME_DATA_FIELD +from supervision.detection.utils._typing import _DetectionDataType, _MetadataType from supervision.detection.utils.converters import polygon_to_mask, rle_to_mask from supervision.geometry.core import Vector @@ -59,7 +58,7 @@ def process_roboflow_result( npt.NDArray[np.integer], npt.NDArray[np.bool_] | None, npt.NDArray[np.integer] | None, - dict[str, npt.NDArray[np.generic]], + _DetectionDataType, ]: """Parse a Roboflow API or Inference package result into detection arrays. @@ -194,7 +193,7 @@ def process_roboflow_result( if tracker_ids and None not in tracker_ids else None ) - data: dict[str, npt.NDArray[np.generic]] = {CLASS_NAME_DATA_FIELD: class_name_arr} + data: _DetectionDataType = {CLASS_NAME_DATA_FIELD: class_name_arr} return ( xyxy_arr, @@ -207,8 +206,8 @@ def process_roboflow_result( def is_data_equal( - data_a: dict[str, npt.NDArray[np.generic] | list[Any]], - data_b: dict[str, npt.NDArray[np.generic] | list[Any]], + data_a: _DetectionDataType, + data_b: _DetectionDataType, ) -> bool: """ Compares the data payloads of two Detections instances. @@ -224,7 +223,7 @@ def is_data_equal( ) -def is_metadata_equal(metadata_a: dict[str, Any], metadata_b: dict[str, Any]) -> bool: +def is_metadata_equal(metadata_a: _MetadataType, metadata_b: _MetadataType) -> bool: """ Compares the metadata payloads of two Detections instances. @@ -246,8 +245,8 @@ def is_metadata_equal(metadata_a: dict[str, Any], metadata_b: dict[str, Any]) -> def merge_data( - data_list: list[dict[str, npt.NDArray[np.generic] | list[Any]]], -) -> dict[str, npt.NDArray[np.generic] | list[Any]]: + data_list: list[_DetectionDataType], +) -> _DetectionDataType: """ Merges the data payloads of a list of Detections instances. @@ -303,10 +302,10 @@ def merge_data( f"types are allowed." ) - return cast(dict[str, Union[npt.NDArray[np.generic], list[Any]]], merged_data) + return cast(_DetectionDataType, merged_data) -def merge_metadata(metadata_list: list[dict[str, Any]]) -> dict[str, Any]: +def merge_metadata(metadata_list: list[_MetadataType]) -> _MetadataType: """ Merge metadata from a list of metadata dictionaries. @@ -333,7 +332,7 @@ def merge_metadata(metadata_list: list[dict[str, Any]]) -> dict[str, Any]: if not all(keys_set == all_keys_sets[0] for keys_set in all_keys_sets): raise ValueError("All metadata dictionaries must have the same keys to merge.") - merged_metadata: dict[str, Any] = {} + merged_metadata: _MetadataType = {} for metadata in metadata_list: for key, value in metadata.items(): if key not in merged_metadata: @@ -361,9 +360,9 @@ def merge_metadata(metadata_list: list[dict[str, Any]]) -> dict[str, Any]: def get_data_item( - data: dict[str, npt.NDArray[np.generic] | list[Any]], + data: _DetectionDataType, index: int | slice | list[int] | npt.NDArray[np.integer | np.bool_], -) -> dict[str, npt.NDArray[np.generic] | list[Any]]: +) -> _DetectionDataType: """ Retrieve a subset of the data dictionary based on the given index. @@ -374,7 +373,7 @@ def get_data_item( Returns: A subset of the data dictionary corresponding to the specified index. """ - subset_data: dict[str, npt.NDArray[np.generic] | list[Any]] = {} + subset_data: _DetectionDataType = {} for key, value in data.items(): if isinstance(value, np.ndarray): subset_data[key] = value[index] diff --git a/src/supervision/detection/utils/iou_and_nms.py b/src/supervision/detection/utils/iou_and_nms.py index e32eb31e..f27092fe 100644 --- a/src/supervision/detection/utils/iou_and_nms.py +++ b/src/supervision/detection/utils/iou_and_nms.py @@ -518,8 +518,13 @@ def oriented_box_iou_batch( # Capture identity before reshape: NMS / NMM pass the same array twice, so # the matrix is symmetric and we can compute only its upper triangle. is_self_comparison = boxes_true is boxes_detection - boxes_true = boxes_true.reshape(-1, 4, 2).astype(np.float64) - boxes_detection = boxes_detection.reshape(-1, 4, 2).astype(np.float64) + boxes_true = cast( + npt.NDArray[np.floating], boxes_true.reshape(-1, 4, 2).astype(np.float64) + ) + boxes_detection = cast( + npt.NDArray[np.floating], + boxes_detection.reshape(-1, 4, 2).astype(np.float64), + ) n, m = len(boxes_true), len(boxes_detection) if n == 0 or m == 0: @@ -694,11 +699,15 @@ def _mask_iou_batch_split( # ~4096x4096) we promote to float64 so the counts stay exact. pixels = int(np.prod(masks_true.shape[1:])) count_dtype = np.float32 if pixels <= 2**24 else np.float64 - true_flat = masks_true.reshape(masks_true.shape[0], pixels).astype( - count_dtype, copy=False + true_flat = cast( + npt.NDArray[np.floating], + masks_true.reshape(masks_true.shape[0], pixels).astype(count_dtype, copy=False), ) - detection_flat = masks_detection.reshape(masks_detection.shape[0], pixels).astype( - count_dtype, copy=False + detection_flat = cast( + npt.NDArray[np.floating], + masks_detection.reshape(masks_detection.shape[0], pixels).astype( + count_dtype, copy=False + ), ) with np.errstate(divide="ignore", over="ignore", invalid="ignore"): intersection_area: npt.NDArray[np.floating[Any]] = true_flat @ detection_flat.T @@ -734,8 +743,8 @@ def _mask_iou_batch_split( def mask_iou_batch( - masks_true: npt.NDArray[Any], - masks_detection: npt.NDArray[Any], + masks_true: npt.NDArray[Any] | CompactMask, + masks_detection: npt.NDArray[Any] | CompactMask, overlap_metric: OverlapMetric = OverlapMetric.IOU, memory_limit: int = 1024 * 5, ) -> npt.NDArray[np.floating]: @@ -831,7 +840,7 @@ def mask_iou_batch( def mask_non_max_suppression( predictions: npt.NDArray[np.floating], - masks: npt.NDArray[Any], + masks: npt.NDArray[Any] | CompactMask, iou_threshold: float = 0.5, overlap_metric: OverlapMetric = OverlapMetric.IOU, mask_dimension: int = 640, @@ -893,7 +902,7 @@ def mask_non_max_suppression( condition[row_idx + 1 :], False, keep[row_idx + 1 :] ) - return cast(npt.NDArray[np.bool_], keep[sort_index.argsort()]) + return keep[sort_index.argsort()] def _prepare_predictions_for_nms( @@ -967,12 +976,12 @@ def box_non_max_suppression( sort_index, predictions, categories = _prepare_predictions_for_nms(predictions) ious = box_iou_batch(predictions[:, :4], predictions[:, :4], overlap_metric) keep = _nms_loop_from_iou_matrix(ious, categories, iou_threshold) - return cast(npt.NDArray[np.bool_], keep[sort_index.argsort()]) + return keep[sort_index.argsort()] def _group_overlapping_masks( - predictions: npt.NDArray[np.float64], - masks: npt.NDArray[np.float64], + predictions: npt.NDArray[np.floating], + masks: npt.NDArray[np.bool_], iou_threshold: float = 0.5, overlap_metric: OverlapMetric = OverlapMetric.IOU, ) -> list[list[int]]: @@ -1029,7 +1038,7 @@ def _group_overlapping_masks( def mask_non_max_merge( predictions: npt.NDArray[np.floating], - masks: npt.NDArray[Any], + masks: npt.NDArray[Any] | CompactMask, iou_threshold: float = 0.5, mask_dimension: int = 640, overlap_metric: OverlapMetric = OverlapMetric.IOU, @@ -1103,7 +1112,7 @@ def mask_non_max_merge( def _greedy_nmm_via_iou_callback( - predictions: npt.NDArray[np.float64], + predictions: npt.NDArray[np.floating], iou_against_candidate: Callable[ [npt.NDArray[np.int_], int], npt.NDArray[np.floating] ], @@ -1134,7 +1143,7 @@ def _greedy_nmm_via_iou_callback( def _non_max_merge_per_category( - predictions: npt.NDArray[np.float64], + predictions: npt.NDArray[np.floating], group_within: Callable[[npt.NDArray[np.int_]], list[list[int]]], ) -> list[list[int]]: """Dispatch NMM grouping per class, then translate local indices back to @@ -1167,7 +1176,7 @@ def _non_max_merge_per_category( def _group_overlapping_boxes( - predictions: npt.NDArray[np.float64], + predictions: npt.NDArray[np.floating], iou_threshold: float = 0.5, overlap_metric: OverlapMetric = OverlapMetric.IOU, ) -> list[list[int]]: @@ -1204,7 +1213,7 @@ def _group_overlapping_boxes( def box_non_max_merge( - predictions: npt.NDArray[np.float64], + predictions: npt.NDArray[np.floating], iou_threshold: float = 0.5, overlap_metric: OverlapMetric = OverlapMetric.IOU, ) -> list[list[int]]: @@ -1327,7 +1336,7 @@ def oriented_box_non_max_suppression( # same object intentional — triggers upper-triangle optimization ious = oriented_box_iou_batch(oriented_boxes, oriented_boxes, overlap_metric) keep = _nms_loop_from_iou_matrix(ious, categories, iou_threshold) - return cast(npt.NDArray[np.bool_], keep[sort_index.argsort()]) + return keep[sort_index.argsort()] def _group_overlapping_oriented_boxes( diff --git a/src/supervision/detection/utils/masks.py b/src/supervision/detection/utils/masks.py index e8dd4322..c7a32e89 100644 --- a/src/supervision/detection/utils/masks.py +++ b/src/supervision/detection/utils/masks.py @@ -1,6 +1,4 @@ -from __future__ import annotations - -from typing import Any, Literal, cast +from typing import Literal, cast import cv2 import numpy as np @@ -11,7 +9,7 @@ from supervision.detection.compact_mask import CompactMask def move_masks( masks: npt.NDArray[np.bool_], - offset: npt.NDArray[np.int32], + offset: npt.NDArray[np.integer], resolution_wh: tuple[int, int], ) -> npt.NDArray[np.bool_]: """ @@ -88,7 +86,7 @@ def move_masks( def calculate_masks_centroids( - masks: npt.NDArray[Any] | CompactMask, + masks: npt.NDArray[np.bool_] | CompactMask, ) -> npt.NDArray[np.int_]: """ Calculate the centroids of binary masks in a tensor. @@ -260,7 +258,9 @@ def contains_multiple_segments( return bool(number_of_labels > 2) -def resize_masks(masks: npt.NDArray[Any], max_dimension: int = 640) -> npt.NDArray[Any]: +def resize_masks( + masks: npt.NDArray[np.bool_], max_dimension: int = 640 +) -> npt.NDArray[np.bool_]: """ Resize all masks in the array to have a maximum dimension of max_dimension, maintaining aspect ratio. @@ -374,19 +374,17 @@ def filter_segments_by_distance( height, width = mask.shape if not np.any(mask): - return mask.copy() + return cast(npt.NDArray[np.bool_], mask.copy()) - image = mask.astype(np.uint8) - num_labels: int - labels: npt.NDArray[np.int32] - stats: npt.NDArray[np.int32] - centroids: npt.NDArray[np.float64] - num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats( - image, connectivity=connectivity - ) + image = cast(npt.NDArray[np.uint8], mask.astype(np.uint8)) + components = cv2.connectedComponentsWithStats(image, connectivity=connectivity) + num_labels = int(components[0]) + labels = cast(npt.NDArray[np.int32], components[1]) + stats = cast(npt.NDArray[np.int32], components[2]) + centroids = cast(npt.NDArray[np.float64], components[3]) if num_labels <= 1: - return mask.copy() + return cast(npt.NDArray[np.bool_], mask.copy()) areas = stats[1:, cv2.CC_STAT_AREA] main_label = 1 + int(np.argmax(areas)) diff --git a/src/supervision/detection/utils/polygons.py b/src/supervision/detection/utils/polygons.py index 5d86acfe..4b749e90 100644 --- a/src/supervision/detection/utils/polygons.py +++ b/src/supervision/detection/utils/polygons.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import cv2 import numpy as np import numpy.typing as npt diff --git a/src/supervision/detection/utils/vlms.py b/src/supervision/detection/utils/vlms.py index 8a30abd7..24a784e5 100644 --- a/src/supervision/detection/utils/vlms.py +++ b/src/supervision/detection/utils/vlms.py @@ -1,6 +1,3 @@ -from __future__ import annotations - - def edit_distance(string_1: str, string_2: str, case_sensitive: bool = True) -> int: """ Calculates the minimum number of single-character edits required diff --git a/src/supervision/draw/base.py b/src/supervision/draw/base.py index ce6e6f70..0f3d50c6 100644 --- a/src/supervision/draw/base.py +++ b/src/supervision/draw/base.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import TypeVar import numpy as np diff --git a/src/supervision/draw/utils.py b/src/supervision/draw/utils.py index 8d7bb679..057a39af 100644 --- a/src/supervision/draw/utils.py +++ b/src/supervision/draw/utils.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os from typing import cast diff --git a/src/supervision/geometry/utils.py b/src/supervision/geometry/utils.py index 0c3da397..e9a95a31 100644 --- a/src/supervision/geometry/utils.py +++ b/src/supervision/geometry/utils.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import numpy.typing as npt diff --git a/src/supervision/key_points/annotators.py b/src/supervision/key_points/annotators.py index 02db1c9a..26e0daee 100644 --- a/src/supervision/key_points/annotators.py +++ b/src/supervision/key_points/annotators.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from abc import ABC, abstractmethod from collections.abc import Sequence from typing import cast diff --git a/src/supervision/key_points/core.py b/src/supervision/key_points/core.py index 02f73bb4..f0b10a0c 100644 --- a/src/supervision/key_points/core.py +++ b/src/supervision/key_points/core.py @@ -3,13 +3,14 @@ from __future__ import annotations import logging from collections.abc import Iterable, Iterator from dataclasses import dataclass, field -from typing import Any, Union, cast +from typing import Any, cast import numpy as np import numpy.typing as npt from supervision.config import CLASS_NAME_DATA_FIELD from supervision.detection.core import Detections +from supervision.detection.utils._typing import _DetectionDataType from supervision.detection.utils.internal import get_data_item, is_data_equal from supervision.detection.utils.iou_and_nms import ( OverlapMetric, @@ -20,16 +21,12 @@ from supervision.validators import _validate_keypoints_fields logger = logging.getLogger(__name__) -Index1D = Union[ - int, slice, list[int], list[bool], npt.NDArray[np.int_], npt.NDArray[np.bool_] -] +Index1D = ( + int | slice | list[int] | list[bool] | npt.NDArray[np.int_] | npt.NDArray[np.bool_] +) Index2D = tuple[Index1D, Index1D] -_RowIndexInput = Union[int, np.integer[Any], npt.NDArray[np.generic], list[Any], slice] -_NormalizedRowIndex = Union[ - npt.NDArray[np.generic], - list[Any], - slice, -] +_RowIndexInput = int | np.integer[Any] | npt.NDArray[np.generic] | list[Any] | slice +_NormalizedRowIndex = npt.NDArray[np.generic] | list[Any] | slice def _optional_array_equal( @@ -233,7 +230,7 @@ class KeyPoints: keypoint_confidence: npt.NDArray[np.float32] | None = None detection_confidence: npt.NDArray[np.float32] | None = None visible: npt.NDArray[np.bool_] | None = None - data: dict[str, npt.NDArray[np.generic] | list[Any]] = field(default_factory=dict) + data: _DetectionDataType = field(default_factory=dict) def __init__( self, @@ -242,7 +239,7 @@ class KeyPoints: keypoint_confidence: npt.NDArray[np.float32] | None = None, detection_confidence: npt.NDArray[np.float32] | None = None, visible: npt.NDArray[np.bool_] | None = None, - data: dict[str, npt.NDArray[np.generic] | list[Any]] | None = None, + data: _DetectionDataType | None = None, *, confidence: npt.NDArray[np.float32] | None = None, ) -> None: @@ -342,7 +339,7 @@ class KeyPoints: npt.NDArray[np.float32], npt.NDArray[np.float32] | None, npt.NDArray[np.int_] | None, - dict[str, npt.NDArray[np.generic] | list[Any]], + _DetectionDataType, ] ]: """ @@ -450,9 +447,7 @@ class KeyPoints: class_id.append(prediction["class_id"]) class_names.append(prediction["class"]) - data: dict[str, npt.NDArray[np.generic] | list[Any]] = { - CLASS_NAME_DATA_FIELD: np.array(class_names) - } + data: _DetectionDataType = {CLASS_NAME_DATA_FIELD: np.array(class_names)} return cls( xy=np.array(xy, dtype=np.float32), @@ -621,9 +616,7 @@ class KeyPoints: class_names = np.array([ultralytics_results.names[i] for i in class_id]) confidence = ultralytics_results.keypoints.conf.cpu().numpy() - data: dict[str, npt.NDArray[np.generic] | list[Any]] = { - CLASS_NAME_DATA_FIELD: class_names - } + data: _DetectionDataType = {CLASS_NAME_DATA_FIELD: class_names} return cls(xy=xy, class_id=class_id, keypoint_confidence=confidence, data=data) @classmethod @@ -669,7 +662,7 @@ class KeyPoints: else: class_id = None - data: dict[str, npt.NDArray[np.generic] | list[Any]] = {} + data: _DetectionDataType = {} if class_id is not None and yolo_nas_results.class_names is not None: class_names = [] for c_id in class_id: diff --git a/src/supervision/key_points/skeletons.py b/src/supervision/key_points/skeletons.py index 414a8460..71edfd65 100644 --- a/src/supervision/key_points/skeletons.py +++ b/src/supervision/key_points/skeletons.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from enum import Enum Edges = tuple[tuple[int, int], ...] diff --git a/src/supervision/metrics/core.py b/src/supervision/metrics/core.py index ad6a1781..347b61dd 100644 --- a/src/supervision/metrics/core.py +++ b/src/supervision/metrics/core.py @@ -2,16 +2,18 @@ from __future__ import annotations from abc import ABC, abstractmethod from enum import Enum -from typing import Any +from typing import Any, Generic, TypeVar + +R = TypeVar("R") -class Metric(ABC): +class Metric(ABC, Generic[R]): """ The base class for all supervision metrics. """ @abstractmethod - def update(self, *args: Any, **kwargs: Any) -> Metric: + def update(self, *args: Any, **kwargs: Any) -> Metric[R]: """ Add data to the metric, without computing the result. Return the metric itself to allow method chaining. @@ -26,7 +28,7 @@ class Metric(ABC): raise NotImplementedError @abstractmethod - def compute(self, *args: Any, **kwargs: Any) -> Any: + def compute(self, *args: Any, **kwargs: Any) -> R: """ Compute the metric from the internal state and return the result. """ diff --git a/src/supervision/metrics/detection.py b/src/supervision/metrics/detection.py index 15795e49..558ceecd 100644 --- a/src/supervision/metrics/detection.py +++ b/src/supervision/metrics/detection.py @@ -283,8 +283,8 @@ def _split_detections_by_outcome( ) else: iou_matrix = box_iou_batch( - boxes_true=cast(npt.NDArray[np.number], targets.xyxy), - boxes_detection=cast(npt.NDArray[np.number], filtered_predictions.xyxy), + boxes_true=targets.xyxy, + boxes_detection=filtered_predictions.xyxy, ) target_candidate_indices, prediction_candidate_indices = np.where( @@ -483,7 +483,7 @@ def _annotate_detection_panel( title_thickness, cv2.LINE_AA, ) - return panel + return cast(npt.NDArray[np.uint8], panel) def _save_detection_validation_visualization( diff --git a/src/supervision/metrics/f1_score.py b/src/supervision/metrics/f1_score.py index 8cb0f7fa..f2f5eab6 100644 --- a/src/supervision/metrics/f1_score.py +++ b/src/supervision/metrics/f1_score.py @@ -2,7 +2,7 @@ from __future__ import annotations from copy import deepcopy from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import numpy as np import numpy.typing as npt @@ -27,7 +27,7 @@ if TYPE_CHECKING: import pandas as pd -class F1Score(Metric): +class F1Score(Metric["F1ScoreResult"]): """ F1 Score is a metric used to evaluate object detection models. It is the harmonic mean of precision and recall, calculated at different IoU thresholds. @@ -160,7 +160,7 @@ class F1Score(Metric): is ``zeros((0,))``. - Targets present: IoU matching produces ``matches`` array. """ - iou_thresholds = np.linspace(0.5, 0.95, 10) + iou_thresholds = np.linspace(0.5, 0.95, 10, dtype=np.float32) stats: list[Any] = [] for predictions, targets in zip(predictions_list, targets_list): @@ -453,12 +453,10 @@ class F1Score(Metric): def _detections_content(self, detections: Detections) -> npt.NDArray[Any]: """Return boxes, masks or oriented bounding boxes from detections.""" if self._metric_target == MetricTarget.BOXES: - result_boxes: npt.NDArray[np.float32] = detections.xyxy - return result_boxes + return cast(npt.NDArray[Any], detections.xyxy) if self._metric_target == MetricTarget.MASKS: if detections.mask is not None: - result_masks: npt.NDArray[np.bool_] = detections.mask - return result_masks + return cast(npt.NDArray[Any], detections.mask) return self._make_empty_content() if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES: obb = detections.data.get(ORIENTED_BOX_COORDINATES) diff --git a/src/supervision/metrics/mean_average_precision.py b/src/supervision/metrics/mean_average_precision.py index 8d598da9..1b4e14a2 100644 --- a/src/supervision/metrics/mean_average_precision.py +++ b/src/supervision/metrics/mean_average_precision.py @@ -7,7 +7,7 @@ from collections import defaultdict from copy import deepcopy from dataclasses import dataclass from enum import Enum -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, TypeAlias, TypedDict import numpy as np import numpy.typing as npt @@ -26,6 +26,40 @@ if TYPE_CHECKING: import pandas as pd +class _TypeCocoDict(TypedDict, total=False): + id: int + image_id: int + category_id: int + bbox: list[float] + area: float + iscrowd: int + ignore: int + _ignore: int + score: float + segmentation: list[list[float]] + name: str + supercategory: str + caption: str + keypoints: list[float] + + +_TypeCocoDataset: TypeAlias = dict[str, list[_TypeCocoDict]] + + +class _TypeEvaluationImageResult(TypedDict): + image_id: int + category_id: int + area_range: list[float] | tuple[float, float] + max_det: int + dt_ids: list[int] + gt_ids: list[int] + dtMatches: npt.NDArray[np.int64] + gtMatches: npt.NDArray[np.int64] + dtScores: list[float] + gtIgnore: npt.NDArray[np.int64] + dtIgnore: npt.NDArray[np.bool_] + + @dataclass class MeanAveragePrecisionResult: """ @@ -152,7 +186,7 @@ class MeanAveragePrecisionResult: ensure_pandas_installed() import pandas as pd - pandas_data = { + pandas_data: dict[str, object] = { "mAP@50:95": self.map50_95, "mAP@50": self.map50, "mAP@75": self.map75, @@ -252,7 +286,7 @@ class EvaluationDataset: `COCOEvaluator` class. """ - def __init__(self, targets: dict[str, Any] | None = None): + def __init__(self, targets: _TypeCocoDataset | None = None) -> None: """ Constructor of EvaluationDataset object used to evaluate models with Mean Average Precision. @@ -263,11 +297,11 @@ class EvaluationDataset: """ # Initialize members # Initialize members - self.dataset: dict[str, Any] = dict() - self.anns: dict[int, Any] = dict() - self.cats: dict[int, Any] = dict() - self.imgs: dict[int, Any] = dict() - self.img_to_anns: dict[int, list[Any]] = defaultdict(list) + self.dataset: _TypeCocoDataset = {} + self.anns: dict[int, _TypeCocoDict] = {} + self.cats: dict[int, _TypeCocoDict] = {} + self.imgs: dict[int, _TypeCocoDict] = {} + self.img_to_anns: dict[int, list[_TypeCocoDict]] = defaultdict(list) self.cat_to_imgs: dict[int, list[int]] = defaultdict(list) if targets is None: @@ -285,8 +319,11 @@ class EvaluationDataset: """ Create index elements for the dataset. """ - anns, cats, imgs = {}, {}, {} - img_to_anns, cat_to_imgs = defaultdict(list), defaultdict(list) + anns: dict[int, _TypeCocoDict] = {} + cats: dict[int, _TypeCocoDict] = {} + imgs: dict[int, _TypeCocoDict] = {} + img_to_anns: dict[int, list[_TypeCocoDict]] = defaultdict(list) + cat_to_imgs: dict[int, list[int]] = defaultdict(list) if "annotations" in self.dataset: for ann in self.dataset["annotations"]: img_to_anns[ann["image_id"]].append(ann) @@ -442,7 +479,7 @@ class EvaluationDataset: return list(ids_set) - def get_annotations(self, ids: list[int] | None = None) -> list[dict[str, Any]]: + def get_annotations(self, ids: list[int] | None = None) -> list[_TypeCocoDict]: """ Get annotations with the specified ids. @@ -456,7 +493,7 @@ class EvaluationDataset: return [] return [self.anns[idx] for idx in ids] - def load_predictions(self, predictions: list[dict[str, Any]]) -> EvaluationDataset: + def load_predictions(self, predictions: list[_TypeCocoDict]) -> EvaluationDataset: """ Load prediction result into an EvaluationDataset object. @@ -468,7 +505,7 @@ class EvaluationDataset: """ # Create an empty EvaluationDataset object for the predictions predictions_dataset = EvaluationDataset.empty() - predictions_dataset.dataset["images"] = [img for img in self.dataset["images"]] + predictions_dataset.dataset["images"] = list(self.dataset["images"]) if not isinstance(predictions, list): raise ValueError("results must be a list") @@ -588,7 +625,7 @@ class COCOEvaluator: def __init__( self, coco_targets: EvaluationDataset, coco_predictions: EvaluationDataset - ): + ) -> None: """ Constructor of COCOEvaluator object. @@ -606,18 +643,22 @@ class COCOEvaluator: # List of dictionaries containing the evaluation results # len(eval_imgs) = (categories) * (area_ranges) * (images) # For COCO 2017: len(eval_images) = 80 * 4 * 5000 = 1600000 - self.eval_imgs: Any = defaultdict(list) + self.eval_imgs: list[_TypeEvaluationImageResult | None] = [] # Dictionary of accumulated results - self.results: dict[str, Any] = {} + self.results: dict[str, object] = {} # Dictionary of targets for evaluation - self._targets: defaultdict[tuple[int, int], list[Any]] = defaultdict(list) - self._predictions: defaultdict[tuple[int, int], list[Any]] = defaultdict(list) + self._targets: defaultdict[tuple[int, int], list[_TypeCocoDict]] = defaultdict( + list + ) + self._predictions: defaultdict[tuple[int, int], list[_TypeCocoDict]] = ( + defaultdict(list) + ) # Parameters for evaluation self.params = COCOEvaluatorParameters() # List of results summarization - self.stats: list[Any] = [] + self.stats: list[object] = [] # Dictionary of IOUs between all targets and predictions - self.ious: dict[tuple[int, int], Any] = {} + self.ious: dict[tuple[int, int], npt.NDArray[np.float32]] = {} # Set image and category ids self.params.img_ids = sorted(self.coco_targets.get_image_ids()) self.params.cat_ids = sorted(self.coco_targets.get_category_ids()) @@ -653,7 +694,7 @@ class COCOEvaluator: self._predictions[dt["image_id"], dt["category_id"]].append(dt) # Initialize evaluation results - self.eval_imgs = defaultdict(list) + self.eval_imgs = [] self.results = {} def _compute_iou(self, img_id: int, cat_id: int) -> npt.NDArray[np.float32]: @@ -703,7 +744,7 @@ class COCOEvaluator: cat_id: int, area_range: list[float] | tuple[float, float], max_det: int, - ) -> dict[str, Any] | None: + ) -> _TypeEvaluationImageResult | None: """ Perform evaluation for single category and image. Args: @@ -716,8 +757,8 @@ class COCOEvaluator: The evaluation results. """ # Get targets (gt) and predictions (dt) for the given image and category - gt: list[dict[str, Any]] = self._targets[img_id, cat_id] - dt: list[dict[str, Any]] = self._predictions[img_id, cat_id] + gt: list[_TypeCocoDict] = self._targets[img_id, cat_id] + dt: list[_TypeCocoDict] = self._predictions[img_id, cat_id] # If there is nothing to evaluate if len(gt) == 0 and len(dt) == 0: @@ -754,11 +795,11 @@ class COCOEvaluator: num_detections = len(dt) # Initialize matches: 0 means no match - gt_matches = np.zeros((num_thresholds, num_ground_truths)) - dt_matches = np.zeros((num_thresholds, num_detections)) + gt_matches = np.zeros((num_thresholds, num_ground_truths), dtype=np.int64) + dt_matches = np.zeros((num_thresholds, num_detections), dtype=np.int64) # Initialize ignore flags: 0 means no ignore - gt_ignore = np.array([g["_ignore"] for g in gt]) - dt_ignore = np.zeros((num_thresholds, num_detections)) + gt_ignore = np.array([g["_ignore"] for g in gt], dtype=np.int64) + dt_ignore = np.zeros((num_thresholds, num_detections), dtype=np.bool_) if len(ious) != 0: # Go through the iou thresholds for tresh_idx, thresh in enumerate(self.params.iou_thrs): @@ -901,10 +942,12 @@ class COCOEvaluator: # Loop through max detections for max_det_idx, max_det in enumerate(selected_max_detections): - eval_img_data = [ + eval_img_data_raw = [ self.eval_imgs[cat_offset + area_offset + i] for i in image_inds ] - eval_img_data = [e for e in eval_img_data if e is not None] + eval_img_data: list[_TypeEvaluationImageResult] = [ + e for e in eval_img_data_raw if e is not None + ] # No image to evaluate if len(eval_img_data) == 0: @@ -1009,23 +1052,23 @@ class COCOEvaluator: # Helper function to compute average precision while handling -1 sentinel values def compute_average_precision( precision_slice: npt.NDArray[np.float32], - ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]: + ) -> tuple[npt.NDArray[np.float64], npt.NDArray[np.float64]]: """Compute average precision while handling -1 sentinel values.""" valid_mask = precision_slice != -1 valid_precision = np.where(valid_mask, precision_slice, np.float32(0.0)) def mean_with_mask( axis: int | tuple[int, ...], - ) -> npt.NDArray[np.float32]: + ) -> npt.NDArray[np.float64]: sums = valid_precision.sum(axis=axis, dtype=np.float64) counts = valid_mask.sum(axis=axis) - means = np.divide( + means: npt.NDArray[np.float64] = np.divide( sums, counts, out=np.full(sums.shape, -1.0, dtype=np.float64), where=counts > 0, ) - return means.astype(np.float32) + return means mAP_scores = mean_with_mask((1, 2)) ap_per_class = mean_with_mask(1).transpose(1, 0) @@ -1119,7 +1162,9 @@ class COCOEvaluator: if use_ap: # Dimension of precision: # threshold x recall x classes x areas x max detections - s = self.results["precision"] + s: npt.NDArray[np.float32] = np.asarray( + self.results["precision"], dtype=np.float32 + ) # IOU if iou_thr is not None: t = np.where(iou_thr == self.params.iou_thrs)[0] @@ -1128,7 +1173,7 @@ class COCOEvaluator: else: # Dimension of recall: # threshold x classes x areas x max detections - s = self.results["recall"] + s = np.asarray(self.results["recall"], dtype=np.float32) if iou_thr is not None: t = np.where(iou_thr == self.params.iou_thrs)[0] s = s[t] @@ -1223,7 +1268,7 @@ class COCOEvaluator: self._accumulate() -class MeanAveragePrecision(Metric): +class MeanAveragePrecision(Metric[MeanAveragePrecisionResult]): """ Mean Average Precision (mAP) is a metric used to evaluate object detection models. It is the average of the precision-recall curves at different IoU thresholds. @@ -1267,7 +1312,7 @@ class MeanAveragePrecision(Metric): class_agnostic: bool = False, class_mapping: dict[int, int] | None = None, image_indices: list[int] | None = None, - ): + ) -> None: """ Initialize the Mean Average Precision metric. @@ -1336,13 +1381,13 @@ class MeanAveragePrecision(Metric): def _prepare_targets( self, targets: list[Detections] - ) -> dict[str, list[dict[str, Any]]]: + ) -> dict[str, list[_TypeCocoDict]]: """Transform targets into a dictionary that can be used by the COCO evaluator""" - images = [{"id": img_id} for img_id in range(len(targets))] + images: list[_TypeCocoDict] = [{"id": img_id} for img_id in range(len(targets))] if self._image_indices is not None: images = [{"id": self._image_indices[img["id"]]} for img in images] # Annotations list - annotations: list[dict[str, Any]] = [] + annotations: list[_TypeCocoDict] = [] for image_id, image_targets in enumerate(targets): if self._image_indices is not None: image_id = self._image_indices[image_id] @@ -1367,16 +1412,22 @@ class MeanAveragePrecision(Metric): # Use area from data if available, otherwise calculate from bbox area = None if image_targets.data is not None and "area" in image_targets.data: - area = float(image_targets.data["area"][target_idx]) + area_data: npt.NDArray[np.float32] = np.asarray( + image_targets.data["area"], dtype=np.float32 + ) + area = float(area_data[target_idx]) if area is None: area = xywh[2] * xywh[3] iscrowd = 0 if image_targets.data is not None and "iscrowd" in image_targets.data: - iscrowd = int(image_targets.data["iscrowd"][target_idx]) + iscrowd_data: npt.NDArray[np.int64] = np.asarray( + image_targets.data["iscrowd"], dtype=np.int64 + ) + iscrowd = int(iscrowd_data[target_idx]) - dict_annotation = { + dict_annotation: _TypeCocoDict = { "area": area, "iscrowd": iscrowd, "image_id": image_id, @@ -1387,8 +1438,8 @@ class MeanAveragePrecision(Metric): } annotations.append(dict_annotation) # Category list - all_cat_ids = {annotation.get("category_id") for annotation in annotations} - categories = [{"id": cat_id} for cat_id in all_cat_ids] + all_cat_ids = {annotation["category_id"] for annotation in annotations} + categories: list[_TypeCocoDict] = [{"id": cat_id} for cat_id in all_cat_ids] # Create coco dictionary return { "images": images, @@ -1398,10 +1449,10 @@ class MeanAveragePrecision(Metric): def _prepare_predictions( self, predictions: list[Detections] - ) -> list[dict[str, Any]]: + ) -> list[_TypeCocoDict]: """Transform predictions into a list of predictions that can be used by the COCO evaluator.""" - coco_predictions: list[dict[str, Any]] = [] + coco_predictions: list[_TypeCocoDict] = [] for image_id, image_predictions in enumerate(predictions): if self._image_indices is not None: image_id = self._image_indices[image_id] @@ -1431,12 +1482,15 @@ class MeanAveragePrecision(Metric): image_predictions.data is not None and "area" in image_predictions.data ): - area = float(image_predictions.data["area"][pred_idx]) + area_data: npt.NDArray[np.float32] = np.asarray( + image_predictions.data["area"], dtype=np.float32 + ) + area = float(area_data[pred_idx]) if area is None: area = xywh[2] * xywh[3] - dict_prediction = { + dict_prediction: _TypeCocoDict = { "image_id": image_id, "bbox": xywh, "score": score, @@ -1481,38 +1535,54 @@ class MeanAveragePrecision(Metric): mAP_small = MeanAveragePrecisionResult( metric_target=self._metric_target, is_class_agnostic=self._class_agnostic, - mAP_scores=cocoEval.results["mAP_scores_small"], - ap_per_class=cocoEval.results["ap_per_class_small"], - iou_thresholds=cocoEval.params.iou_thrs, - matched_classes=np.array(cocoEval.params.cat_ids), + mAP_scores=np.asarray( + cocoEval.results["mAP_scores_small"], dtype=np.float64 + ), + ap_per_class=np.asarray( + cocoEval.results["ap_per_class_small"], dtype=np.float64 + ), + iou_thresholds=np.asarray(cocoEval.params.iou_thrs, dtype=np.float64), + matched_classes=np.asarray(cocoEval.params.cat_ids, dtype=np.int32), ) # Create MeanAveragePrecisionResult object for medium objects mAP_medium = MeanAveragePrecisionResult( metric_target=self._metric_target, is_class_agnostic=self._class_agnostic, - mAP_scores=cocoEval.results["mAP_scores_medium"], - ap_per_class=cocoEval.results["ap_per_class_medium"], - iou_thresholds=cocoEval.params.iou_thrs, - matched_classes=np.array(cocoEval.params.cat_ids), + mAP_scores=np.asarray( + cocoEval.results["mAP_scores_medium"], dtype=np.float64 + ), + ap_per_class=np.asarray( + cocoEval.results["ap_per_class_medium"], dtype=np.float64 + ), + iou_thresholds=np.asarray(cocoEval.params.iou_thrs, dtype=np.float64), + matched_classes=np.asarray(cocoEval.params.cat_ids, dtype=np.int32), ) # Create MeanAveragePrecisionResult object for large objects mAP_large = MeanAveragePrecisionResult( metric_target=self._metric_target, is_class_agnostic=self._class_agnostic, - mAP_scores=cocoEval.results["mAP_scores_large"], - ap_per_class=cocoEval.results["ap_per_class_large"], - iou_thresholds=cocoEval.params.iou_thrs, - matched_classes=np.array(cocoEval.params.cat_ids), + mAP_scores=np.asarray( + cocoEval.results["mAP_scores_large"], dtype=np.float64 + ), + ap_per_class=np.asarray( + cocoEval.results["ap_per_class_large"], dtype=np.float64 + ), + iou_thresholds=np.asarray(cocoEval.params.iou_thrs, dtype=np.float64), + matched_classes=np.asarray(cocoEval.params.cat_ids, dtype=np.int32), ) # Create the final MeanAveragePrecisionResult object mAP_result = MeanAveragePrecisionResult( metric_target=self._metric_target, is_class_agnostic=self._class_agnostic, - mAP_scores=cocoEval.results["mAP_scores_all_sizes"], - ap_per_class=cocoEval.results["ap_per_class_all_sizes"], - iou_thresholds=cocoEval.params.iou_thrs, - matched_classes=np.array(cocoEval.params.cat_ids), + mAP_scores=np.asarray( + cocoEval.results["mAP_scores_all_sizes"], dtype=np.float64 + ), + ap_per_class=np.asarray( + cocoEval.results["ap_per_class_all_sizes"], dtype=np.float64 + ), + iou_thresholds=np.asarray(cocoEval.params.iou_thrs, dtype=np.float64), + matched_classes=np.asarray(cocoEval.params.cat_ids, dtype=np.int32), small_objects=mAP_small, medium_objects=mAP_medium, large_objects=mAP_large, diff --git a/src/supervision/metrics/mean_average_recall.py b/src/supervision/metrics/mean_average_recall.py index 4f81831b..98b5a4b3 100644 --- a/src/supervision/metrics/mean_average_recall.py +++ b/src/supervision/metrics/mean_average_recall.py @@ -2,7 +2,7 @@ from __future__ import annotations from copy import deepcopy from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import numpy as np import numpy.typing as npt @@ -256,7 +256,7 @@ class MeanAverageRecallResult: plt.show() -class MeanAverageRecall(Metric): +class MeanAverageRecall(Metric["MeanAverageRecallResult"]): """ Mean Average Recall (mAR) measures how well the model detects and retrieves relevant objects by averaging recall over multiple @@ -381,7 +381,7 @@ class MeanAverageRecall(Metric): def _compute( self, predictions_list: list[Detections], targets_list: list[Detections] ) -> MeanAverageRecallResult: - iou_thresholds = np.linspace(0.5, 0.95, 10) + iou_thresholds = np.linspace(0.5, 0.95, 10, dtype=np.float32) stats: list[Any] = [] for predictions, targets in zip(predictions_list, targets_list): @@ -490,7 +490,7 @@ class MeanAverageRecall(Metric): ]: unique_classes, class_counts = np.unique(true_class_ids, return_counts=True) - recalls_at_k = [] + recalls_at_k: list[npt.NDArray[np.float64]] = [] for max_detections in self.max_detections: # Shape: PxTh,P,C,C -> CxThx3 confusion_matrix = self._compute_confusion_matrix( @@ -505,8 +505,8 @@ class MeanAverageRecall(Metric): recalls_at_k.append(recall_per_class) # Shape: KxCxTh -> KxC - recalls_at_k = np.array(recalls_at_k) - average_recall_per_class = np.mean(recalls_at_k, axis=2) + recalls_at_k_array = np.array(recalls_at_k) + average_recall_per_class = np.mean(recalls_at_k_array, axis=2) # Shape: KxC -> K recall_scores = np.mean(average_recall_per_class, axis=1) @@ -548,8 +548,8 @@ class MeanAverageRecall(Metric): def _compute_confusion_matrix( sorted_matches: npt.NDArray[np.bool_], sorted_prediction_class_ids: npt.NDArray[np.int32], - unique_classes: npt.NDArray[np.int32], - class_counts: npt.NDArray[np.int32], + unique_classes: npt.NDArray[np.integer], + class_counts: npt.NDArray[np.integer], ) -> npt.NDArray[np.float64]: """ Compute the confusion matrix for each class and IoU threshold. @@ -638,12 +638,10 @@ class MeanAverageRecall(Metric): def _detections_content(self, detections: Detections) -> npt.NDArray[Any]: """Return boxes, masks or oriented bounding boxes from detections.""" if self._metric_target == MetricTarget.BOXES: - result_boxes: npt.NDArray[np.float32] = detections.xyxy - return result_boxes + return cast(npt.NDArray[Any], detections.xyxy) if self._metric_target == MetricTarget.MASKS: if detections.mask is not None: - result_masks: npt.NDArray[np.bool_] = detections.mask - return result_masks + return cast(npt.NDArray[Any], detections.mask) return self._make_empty_content() if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES: obb = detections.data.get(ORIENTED_BOX_COORDINATES) diff --git a/src/supervision/metrics/precision.py b/src/supervision/metrics/precision.py index 8e3ac5d7..77613b0e 100644 --- a/src/supervision/metrics/precision.py +++ b/src/supervision/metrics/precision.py @@ -2,7 +2,7 @@ from __future__ import annotations from copy import deepcopy from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import numpy as np import numpy.typing as npt @@ -27,7 +27,7 @@ if TYPE_CHECKING: import pandas as pd -class Precision(Metric): +class Precision(Metric["PrecisionResult"]): """ Precision is a metric used to evaluate object detection models. It is the ratio of true positive detections to the total number of predicted detections. We calculate @@ -163,7 +163,7 @@ class Precision(Metric): is ``zeros((0,))``. - Targets present: IoU matching produces ``matches`` array. """ - iou_thresholds = np.linspace(0.5, 0.95, 10) + iou_thresholds = np.linspace(0.5, 0.95, 10, dtype=np.float32) stats: list[Any] = [] for predictions, targets in zip(predictions_list, targets_list): @@ -459,12 +459,10 @@ class Precision(Metric): def _detections_content(self, detections: Detections) -> npt.NDArray[Any]: """Return boxes, masks or oriented bounding boxes from detections.""" if self._metric_target == MetricTarget.BOXES: - result_boxes: npt.NDArray[np.float32] = detections.xyxy - return result_boxes + return cast(npt.NDArray[Any], detections.xyxy) if self._metric_target == MetricTarget.MASKS: if detections.mask is not None: - result_masks: npt.NDArray[np.bool_] = detections.mask - return result_masks + return cast(npt.NDArray[Any], detections.mask) return self._make_empty_content() if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES: obb = detections.data.get(ORIENTED_BOX_COORDINATES) diff --git a/src/supervision/metrics/recall.py b/src/supervision/metrics/recall.py index f122fd8d..f34b05bd 100644 --- a/src/supervision/metrics/recall.py +++ b/src/supervision/metrics/recall.py @@ -2,7 +2,7 @@ from __future__ import annotations from copy import deepcopy from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import numpy as np import numpy.typing as npt @@ -27,7 +27,7 @@ if TYPE_CHECKING: import pandas as pd -class Recall(Metric): +class Recall(Metric["RecallResult"]): """ Recall is a metric used to evaluate object detection models. It is the ratio of true positive detections to the total number of ground truth instances. We calculate @@ -155,7 +155,7 @@ class Recall(Metric): def _compute( self, predictions_list: list[Detections], targets_list: list[Detections] ) -> RecallResult: - iou_thresholds = np.linspace(0.5, 0.95, 10) + iou_thresholds = np.linspace(0.5, 0.95, 10, dtype=np.float32) stats: list[Any] = [] for predictions, targets in zip(predictions_list, targets_list): @@ -319,8 +319,8 @@ class Recall(Metric): def _compute_confusion_matrix( sorted_matches: npt.NDArray[np.bool_], sorted_prediction_class_ids: npt.NDArray[np.int32], - unique_classes: npt.NDArray[np.int32], - class_counts: npt.NDArray[np.int32], + unique_classes: npt.NDArray[np.integer], + class_counts: npt.NDArray[np.integer], ) -> npt.NDArray[np.float64]: """ Compute the confusion matrix for each class and IoU threshold. @@ -408,12 +408,10 @@ class Recall(Metric): def _detections_content(self, detections: Detections) -> npt.NDArray[Any]: """Return boxes, masks or oriented bounding boxes from detections.""" if self._metric_target == MetricTarget.BOXES: - result_boxes: npt.NDArray[np.float32] = detections.xyxy - return result_boxes + return cast(npt.NDArray[Any], detections.xyxy) if self._metric_target == MetricTarget.MASKS: if detections.mask is not None: - result_masks: npt.NDArray[np.bool_] = detections.mask - return result_masks + return cast(npt.NDArray[Any], detections.mask) return self._make_empty_content() if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES: obb = detections.data.get(ORIENTED_BOX_COORDINATES) diff --git a/src/supervision/metrics/utils/object_size.py b/src/supervision/metrics/utils/object_size.py index 03f005a1..131dd5e4 100644 --- a/src/supervision/metrics/utils/object_size.py +++ b/src/supervision/metrics/utils/object_size.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import numpy as np import numpy.typing as npt @@ -44,7 +44,8 @@ class ObjectSizeCategory(Enum): def get_object_size_category( - data: npt.NDArray, metric_target: MetricTarget + data: npt.NDArray[np.number] | npt.NDArray[np.bool_], + metric_target: MetricTarget, ) -> npt.NDArray[np.int_]: """ Get the size category of an object. Distinguish based on the metric target. @@ -74,15 +75,18 @@ def get_object_size_category( ``` """ if metric_target == MetricTarget.BOXES: - return get_bbox_size_category(data) + bbox_data = cast(npt.NDArray[np.number], data) + return get_bbox_size_category(bbox_data) if metric_target == MetricTarget.MASKS: - return get_mask_size_category(data) + mask_data = cast(npt.NDArray[np.bool_], data) + return get_mask_size_category(mask_data) if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES: - return get_obb_size_category(data) + obb_data = cast(npt.NDArray[np.number], data) + return get_obb_size_category(obb_data) raise ValueError("Invalid metric type") -def get_bbox_size_category(xyxy: npt.NDArray[np.float32]) -> npt.NDArray[np.int_]: +def get_bbox_size_category(xyxy: npt.NDArray[np.number]) -> npt.NDArray[np.int_]: """ Get the size category of a bounding boxes array. @@ -165,7 +169,7 @@ def get_mask_size_category( return result -def get_obb_size_category(xyxyxyxy: npt.NDArray[np.float32]) -> npt.NDArray[np.int_]: +def get_obb_size_category(xyxyxyxy: npt.NDArray[np.number]) -> npt.NDArray[np.int_]: """ Get the size category of a oriented bounding boxes array. @@ -229,13 +233,18 @@ def get_detection_size_category( if metric_target == MetricTarget.BOXES: return get_bbox_size_category(detections.xyxy) if metric_target == MetricTarget.MASKS: - if detections.mask is None: + mask = detections.mask + if mask is None: raise ValueError("Detections mask is not available") - return get_mask_size_category(detections.mask) + return get_mask_size_category(mask) if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES: - if detections.data.get(ORIENTED_BOX_COORDINATES) is None: + oriented_box_coordinates = detections.data.get(ORIENTED_BOX_COORDINATES) + if oriented_box_coordinates is None: raise ValueError("Detections oriented bounding boxes are not available") return get_obb_size_category( - np.array(detections.data[ORIENTED_BOX_COORDINATES]) + cast( + npt.NDArray[np.number], + np.asarray(oriented_box_coordinates, dtype=np.float32), + ) ) raise ValueError("Invalid metric type") diff --git a/src/supervision/tracker/byte_tracker/core.py b/src/supervision/tracker/byte_tracker/core.py index f83db914..5b174c73 100644 --- a/src/supervision/tracker/byte_tracker/core.py +++ b/src/supervision/tracker/byte_tracker/core.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import cast import numpy as np @@ -63,7 +61,7 @@ class ByteTrack: minimum_matching_threshold: float = 0.8, frame_rate: float = 30, minimum_consecutive_frames: int = 1, - ): + ) -> None: self.track_activation_threshold = track_activation_threshold self.minimum_matching_threshold = minimum_matching_threshold diff --git a/src/supervision/tracker/byte_tracker/kalman_filter.py b/src/supervision/tracker/byte_tracker/kalman_filter.py index fbaf779c..0d5cbeb9 100644 --- a/src/supervision/tracker/byte_tracker/kalman_filter.py +++ b/src/supervision/tracker/byte_tracker/kalman_filter.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import numpy.typing as npt import scipy.linalg @@ -146,10 +144,10 @@ class KalmanFilter: ] sqr = np.square(np.r_[std_pos, std_vel]).T - motion_cov = [] + motion_cov_list: list[npt.NDArray[np.float32]] = [] for i in range(len(mean)): - motion_cov.append(np.diag(sqr[i])) - motion_cov = np.asarray(motion_cov) + motion_cov_list.append(np.diag(sqr[i])) + motion_cov = np.asarray(motion_cov_list) mean = np.dot(mean, self._motion_mat.T) left = np.dot(self._motion_mat, covariance).transpose((1, 0, 2)) diff --git a/src/supervision/tracker/byte_tracker/matching.py b/src/supervision/tracker/byte_tracker/matching.py index 9d2c7dc6..b792d9df 100644 --- a/src/supervision/tracker/byte_tracker/matching.py +++ b/src/supervision/tracker/byte_tracker/matching.py @@ -1,15 +1,11 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING +from typing import cast import numpy as np import numpy.typing as npt from scipy.optimize import linear_sum_assignment from supervision.detection.utils.iou_and_nms import box_iou_batch - -if TYPE_CHECKING: - from supervision.tracker.byte_tracker.single_object_track import STrack +from supervision.tracker.byte_tracker.single_object_track import STrack def indices_to_matches( @@ -48,17 +44,23 @@ def iou_distance( if (len(atracks) > 0 and isinstance(atracks[0], np.ndarray)) or ( len(btracks) > 0 and isinstance(btracks[0], np.ndarray) ): - atlbrs = atracks - btlbrs = btracks + atlbrs = cast(list[npt.NDArray[np.float32]], atracks) + btlbrs = cast(list[npt.NDArray[np.float32]], btracks) else: - atlbrs = [track.tlbr for track in atracks] - btlbrs = [track.tlbr for track in btracks] + atlbrs = [track.tlbr for track in cast(list[STrack], atracks)] + btlbrs = [track.tlbr for track in cast(list[STrack], btracks)] - _ious = np.zeros((len(atlbrs), len(btlbrs)), dtype=np.float32) - if _ious.size != 0: - _ious = box_iou_batch(np.asarray(atlbrs), np.asarray(btlbrs)) - cost_matrix = 1 - _ious + if len(atlbrs) == 0 or len(btlbrs) == 0: + return cast( + npt.NDArray[np.float32], + np.empty((len(atlbrs), len(btlbrs)), dtype=np.float32), + ) + ious = box_iou_batch( + np.asarray(atlbrs, dtype=np.float32), + np.asarray(btlbrs, dtype=np.float32), + ) + cost_matrix = np.asarray(1 - ious, dtype=np.float32) return cost_matrix @@ -68,8 +70,8 @@ def fuse_score( if cost_matrix.size == 0: return cost_matrix iou_sim = 1 - cost_matrix - det_scores = np.array([strack.score for strack in stracks]) + det_scores = np.array([strack.score for strack in stracks], dtype=np.float32) det_scores = np.expand_dims(det_scores, axis=0).repeat(cost_matrix.shape[0], axis=0) fuse_sim = iou_sim * det_scores - fuse_cost = 1 - fuse_sim + fuse_cost = np.asarray(1 - fuse_sim, dtype=np.float32) return fuse_cost diff --git a/src/supervision/tracker/byte_tracker/single_object_track.py b/src/supervision/tracker/byte_tracker/single_object_track.py index 01618b3c..5194b490 100644 --- a/src/supervision/tracker/byte_tracker/single_object_track.py +++ b/src/supervision/tracker/byte_tracker/single_object_track.py @@ -1,6 +1,7 @@ from __future__ import annotations from enum import Enum +from typing import cast import numpy as np import numpy.typing as npt @@ -142,11 +143,11 @@ class STrack: width, height)`. """ if self.mean is None: - return self._tlwh.copy() + return cast(npt.NDArray[np.float32], self._tlwh.copy()) ret = self.mean[:4].copy() ret[2] *= ret[3] ret[:2] -= ret[2:] / 2 - return ret + return cast(npt.NDArray[np.float32], ret) @property def tlbr(self) -> npt.NDArray[np.float32]: @@ -155,7 +156,7 @@ class STrack: """ ret = self.tlwh.copy() ret[2:] += ret[:2] - return ret + return cast(npt.NDArray[np.float32], ret) @staticmethod def tlwh_to_xyah(tlwh: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: @@ -165,7 +166,7 @@ class STrack: ret = np.asarray(tlwh).copy() ret[:2] += ret[2:] / 2 ret[2] /= ret[3] - return ret + return cast(npt.NDArray[np.float32], ret) def to_xyah(self) -> npt.NDArray[np.float32]: return self.tlwh_to_xyah(self.tlwh) @@ -174,13 +175,13 @@ class STrack: def tlbr_to_tlwh(tlbr: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: ret = np.asarray(tlbr).copy() ret[2:] -= ret[:2] - return ret + return cast(npt.NDArray[np.float32], ret) @staticmethod def tlwh_to_tlbr(tlwh: npt.NDArray[np.float32]) -> npt.NDArray[np.float32]: ret = np.asarray(tlwh).copy() ret[2:] += ret[:2] - return ret + return cast(npt.NDArray[np.float32], ret) def __repr__(self) -> str: return f"OT_{self.internal_track_id}_({self.start_frame}-{self.frame_id})" diff --git a/src/supervision/tracker/byte_tracker/utils.py b/src/supervision/tracker/byte_tracker/utils.py index 3404f809..4e1c1c29 100644 --- a/src/supervision/tracker/byte_tracker/utils.py +++ b/src/supervision/tracker/byte_tracker/utils.py @@ -1,8 +1,5 @@ -from __future__ import annotations - - class IdCounter: - def __init__(self, start_id: int = 0): + def __init__(self, start_id: int = 0) -> None: """ Initialize the ID counter. diff --git a/src/supervision/utils/conversion.py b/src/supervision/utils/conversion.py index 5341a154..55a71b88 100644 --- a/src/supervision/utils/conversion.py +++ b/src/supervision/utils/conversion.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import functools from collections.abc import Callable from typing import Any, TypeVar, cast @@ -30,7 +28,7 @@ def ensure_cv2_image_for_class_method( """ @functools.wraps(annotate_func) - def wrapper(self: Any, scene: ImageType, *args: Any, **kwargs: Any) -> ImageType: + def wrapper(self: Any, scene: ImageType, *args: Any, **kwargs: Any) -> Any: if isinstance(scene, np.ndarray): return annotate_func(self, scene, *args, **kwargs) @@ -70,7 +68,7 @@ def ensure_cv2_image_for_standalone_function( """ @functools.wraps(image_processing_fun) - def wrapper(image: ImageType, *args: Any, **kwargs: Any) -> ImageType: + def wrapper(image: ImageType, *args: Any, **kwargs: Any) -> Any: if isinstance(image, np.ndarray): return image_processing_fun(image, *args, **kwargs) @@ -98,7 +96,7 @@ def ensure_pil_image_for_class_method( """ @functools.wraps(annotate_func) - def wrapper(self: Any, scene: ImageType, *args: Any, **kwargs: Any) -> ImageType: + def wrapper(self: Any, scene: ImageType, *args: Any, **kwargs: Any) -> Any: if isinstance(scene, np.ndarray): scene_pil = cv2_to_pillow(scene) annotated_pil = annotate_func(self, scene_pil, *args, **kwargs) @@ -135,7 +133,9 @@ def ensure_cv2_image_for_processing( return cast(F, void(image_processing_fun)) -def images_to_cv2(images: list[ImageType]) -> list[npt.NDArray[np.uint8]]: +def images_to_cv2( + images: list[npt.NDArray[np.uint8] | Image.Image], +) -> list[npt.NDArray[np.uint8]]: """ Converts images provided either as Pillow images or OpenCV images into OpenCV format. @@ -148,11 +148,12 @@ def images_to_cv2(images: list[ImageType]) -> list[npt.NDArray[np.uint8]]: (with order preserved). """ - result = [] + result: list[npt.NDArray[np.uint8]] = [] for image in images: - if issubclass(type(image), Image.Image): - image = pillow_to_cv2(image) - result.append(image) + if isinstance(image, Image.Image): + result.append(pillow_to_cv2(image)) + else: + result.append(image) return result @@ -171,7 +172,7 @@ def pillow_to_cv2(image: Image.Image) -> npt.NDArray[np.uint8]: scene = cv2.cvtColor(scene, cv2.COLOR_RGB2BGR) # cvtColor already returns uint8 here, so astype is a no-op other than the # full-image copy it forces; copy=False keeps the dtype guard without it. - return scene.astype(np.uint8, copy=False) + return cast(npt.NDArray[np.uint8], scene.astype(np.uint8, copy=False)) def cv2_to_pillow(image: npt.NDArray[np.uint8]) -> Image.Image: @@ -185,5 +186,5 @@ def cv2_to_pillow(image: npt.NDArray[np.uint8]) -> Image.Image: Returns: Input image converted to Pillow format. """ - image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) - return Image.fromarray(image) + rgb_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) + return Image.fromarray(rgb_image) diff --git a/src/supervision/utils/file.py b/src/supervision/utils/file.py index 55716a08..00466e53 100644 --- a/src/supervision/utils/file.py +++ b/src/supervision/utils/file.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import json from pathlib import Path from typing import Any diff --git a/src/supervision/utils/image.py b/src/supervision/utils/image.py index 22685c67..a7bd3605 100644 --- a/src/supervision/utils/image.py +++ b/src/supervision/utils/image.py @@ -6,7 +6,8 @@ import os import shutil from collections.abc import Callable from functools import partial -from typing import Any, Literal, cast +from types import TracebackType +from typing import Literal, cast import cv2 import numpy as np @@ -36,7 +37,7 @@ MAX_COLUMNS_FOR_SINGLE_ROW_GRID = 3 @ensure_cv2_image_for_standalone_function def crop_image( image: ImageType, - xyxy: npt.NDArray[int] | list[int] | tuple[int, int, int, int], + xyxy: npt.NDArray[np.number] | list[int] | tuple[int, int, int, int], ) -> ImageType: """ Crop image based on bounding box coordinates. @@ -77,17 +78,14 @@ def crop_image( ![crop-image](https://media.roboflow.com/supervision-docs/supervision-docs-crop-image-2.png){ align=center width="1000" } """ # noqa E501 // docs - if isinstance(xyxy, (list, tuple)): - xyxy = np.array(xyxy) - - xyxy = np.round(xyxy).astype(int) - x_min, y_min, x_max, y_max = xyxy.flatten() + xyxy_arr = np.asarray(xyxy, dtype=np.float64).round().astype(np.int32) + x_min, y_min, x_max, y_max = xyxy_arr.flatten() if isinstance(image, np.ndarray): return image[y_min:y_max, x_min:x_max] if isinstance(image, Image.Image): - return image.crop((x_min, y_min, x_max, y_max)) + return image.crop((float(x_min), float(y_min), float(x_max), float(y_max))) raise TypeError( f"`image` must be a numpy.ndarray or PIL.Image.Image. Received {type(image)}" @@ -142,7 +140,10 @@ def scale_image(image: ImageType, scale_factor: float) -> ImageType: width_old, height_old = image.shape[1], image.shape[0] width_new = int(width_old * scale_factor) height_new = int(height_old * scale_factor) - return cv2.resize(image, (width_new, height_new), interpolation=cv2.INTER_LINEAR) + return cast( + npt.NDArray[np.uint8], + cv2.resize(image, (width_new, height_new), interpolation=cv2.INTER_LINEAR), + ) @ensure_cv2_image_for_standalone_function @@ -208,7 +209,10 @@ def resize_image( else: width_new, height_new = resolution_wh - return cv2.resize(image, (width_new, height_new), interpolation=cv2.INTER_LINEAR) + return cast( + npt.NDArray[np.uint8], + cv2.resize(image, (width_new, height_new), interpolation=cv2.INTER_LINEAR), + ) @ensure_cv2_image_for_standalone_function @@ -269,14 +273,17 @@ def letterbox_image( padding_bottom = resolution_wh[1] - height_new - padding_top padding_left = (resolution_wh[0] - width_new) // 2 padding_right = resolution_wh[0] - width_new - padding_left - image_with_borders = cv2.copyMakeBorder( - resized_image, - padding_top, - padding_bottom, - padding_left, - padding_right, - cv2.BORDER_CONSTANT, - value=color, + image_with_borders = cast( + npt.NDArray[np.uint8], + cv2.copyMakeBorder( + resized_image, + padding_top, + padding_bottom, + padding_left, + padding_right, + cv2.BORDER_CONSTANT, + value=color, + ), ) return image_with_borders @@ -345,12 +352,12 @@ def overlay_image( b, g, r, alpha = cv2.split( overlay[crop_y_min:crop_y_max, crop_x_min:crop_x_max] ) - alpha = alpha[:, :, None] / 255.0 - overlay_color = cv2.merge((b, g, r)) + alpha_f32 = alpha[:, :, None].astype(np.float32) / 255.0 + overlay_color = cv2.merge((b, g, r)).astype(np.float32) - roi = image[y_min:y_max, x_min:x_max] - roi[:] = roi * (1 - alpha) + overlay_color * alpha - image[y_min:y_max, x_min:x_max] = roi + roi = image[y_min:y_max, x_min:x_max].astype(np.float32) + blended = roi * (1 - alpha_f32) + overlay_color * alpha_f32 + image[y_min:y_max, x_min:x_max] = np.clip(blended, 0, 255).astype(np.uint8) else: image[y_min:y_max, x_min:x_max] = overlay[ crop_y_min:crop_y_max, crop_x_min:crop_x_max @@ -434,8 +441,9 @@ def grayscale_image(image: ImageType) -> ImageType: ![grayscale-image](https://media.roboflow.com/supervision-docs/supervision-docs-grayscale-image-2.png){ align=center width="1000" } """ # noqa E501 // docs - grayscaled = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) - return cv2.cvtColor(grayscaled, cv2.COLOR_GRAY2BGR) + assert isinstance(image, np.ndarray) + grayscaled = cast(npt.NDArray[np.uint8], cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)) + return cast(npt.NDArray[np.uint8], cv2.cvtColor(grayscaled, cv2.COLOR_GRAY2BGR)) def get_image_resolution_wh(image: ImageType) -> tuple[int, int]: @@ -491,7 +499,7 @@ class ImageSink: target_dir_path: str, overwrite: bool = False, image_name_pattern: str = "image_{:05d}.png", - ): + ) -> None: """ Initialize context manager for saving images to directory. @@ -559,7 +567,7 @@ class ImageSink: self, exc_type: type[BaseException] | None, exc_value: BaseException | None, - exc_traceback: Any, + exc_traceback: TracebackType | None, ) -> None: pass @@ -663,32 +671,32 @@ def create_tiles( return_type = _negotiate_tiles_format(images=images) tile_padding_color = unify_to_bgr(color=tile_padding_color) tile_margin_color = unify_to_bgr(color=tile_margin_color) - images = images_to_cv2(images=images) + images_cv2 = images_to_cv2(images=images) if single_tile_size is None: - single_tile_size = _aggregate_images_shape(images=images, mode=tile_scaling) + single_tile_size = _aggregate_images_shape(images=images_cv2, mode=tile_scaling) resized_images = [ letterbox_image( image=i, resolution_wh=single_tile_size, color=tile_padding_color ) - for i in images + for i in images_cv2 ] - grid_size = _establish_grid_size(images=images, grid_size=grid_size) - if len(images) > grid_size[0] * grid_size[1]: + grid_size = _establish_grid_size(images=images_cv2, grid_size=grid_size) + if len(images_cv2) > grid_size[0] * grid_size[1]: raise ValueError( - f"Could not place {len(images)} in grid with size: {grid_size}." + f"Could not place {len(images_cv2)} in grid with size: {grid_size}." ) if titles is not None: - titles = fill(sequence=titles, desired_size=len(images), content=None) + titles = fill(sequence=titles, desired_size=len(images_cv2), content=None) if isinstance(titles_anchors, list): titles_anchors_sequence = titles_anchors else: titles_anchors_sequence = [titles_anchors] titles_anchors = fill( - sequence=titles_anchors_sequence, desired_size=len(images), content=None + sequence=titles_anchors_sequence, desired_size=len(images_cv2), content=None ) titles_color = unify_to_bgr(color=titles_color) titles_background_color = unify_to_bgr(color=titles_background_color) - tiles = _generate_tiles( + tiles_image = _generate_tiles( images=resized_images, grid_size=grid_size, single_tile_size=single_tile_size, @@ -706,8 +714,10 @@ def create_tiles( default_title_placement=default_title_placement, ) if return_type == "pillow": - tiles = cv2_to_pillow(image=tiles) - return cast(ImageType, tiles) + tiles_image_pillow: object = cv2_to_pillow(image=tiles_image) + return cast(ImageType, tiles_image_pillow) + tiles_image_cv2: object = tiles_image + return cast(ImageType, tiles_image_cv2) def _negotiate_tiles_format(images: list[ImageType]) -> Literal["cv2", "pillow"]: @@ -890,9 +900,10 @@ def _merge_tiles_elements( tile_margin: int, tile_margin_color: tuple[int, int, int], ) -> npt.NDArray[np.uint8]: - vertical_padding: npt.NDArray[np.uint8] = ( - np.ones((single_tile_size[1], tile_margin, 3), dtype=np.uint8) - * tile_margin_color + vertical_padding: npt.NDArray[np.uint8] = np.full( + (single_tile_size[1], tile_margin, 3), + tile_margin_color, + dtype=np.uint8, ) merged_rows = [ np.concatenate( @@ -906,26 +917,19 @@ def _merge_tiles_elements( for row in tiles_elements ] row_width = merged_rows[0].shape[1] - horizontal_padding = ( - np.ones((tile_margin, row_width, 3), dtype=np.uint8) * tile_margin_color + horizontal_padding: npt.NDArray[np.uint8] = np.full( + (tile_margin, row_width, 3), + tile_margin_color, + dtype=np.uint8, ) - rows_with_paddings = [] + rows_with_paddings: list[npt.NDArray[np.uint8]] = [] for row in merged_rows: rows_with_paddings.append(row) rows_with_paddings.append(horizontal_padding) - return cast( - npt.NDArray[np.uint8], - np.concatenate( - rows_with_paddings[:-1], - axis=0, - ).astype(np.uint8), - ) + return np.concatenate(rows_with_paddings[:-1], axis=0).astype(np.uint8, copy=False) def _generate_color_image( shape: tuple[int, int], color: tuple[int, int, int] ) -> npt.NDArray[np.uint8]: - return cast( - npt.NDArray[np.uint8], - np.ones((*shape[::-1], 3), dtype=np.uint8) * color, - ) + return np.full((*shape[::-1], 3), color, dtype=np.uint8) diff --git a/src/supervision/utils/internal.py b/src/supervision/utils/internal.py index 2ceddb47..8d6e2c00 100644 --- a/src/supervision/utils/internal.py +++ b/src/supervision/utils/internal.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import functools import inspect import os @@ -142,7 +140,7 @@ class classproperty(Generic[T]): ... """ - def __init__(self, fget: Callable[..., T]): + def __init__(self, fget: Callable[..., T]) -> None: """ Args: The function that is called when the property is accessed. diff --git a/src/supervision/utils/iterables.py b/src/supervision/utils/iterables.py index 54646a94..9b30e6bb 100644 --- a/src/supervision/utils/iterables.py +++ b/src/supervision/utils/iterables.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from collections.abc import Generator, Iterable from typing import TypeVar diff --git a/src/supervision/utils/logger.py b/src/supervision/utils/logger.py index f6ac66e8..2ed8c452 100644 --- a/src/supervision/utils/logger.py +++ b/src/supervision/utils/logger.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging import os import sys diff --git a/src/supervision/utils/notebook.py b/src/supervision/utils/notebook.py index ed8f4f68..25d0f4b4 100644 --- a/src/supervision/utils/notebook.py +++ b/src/supervision/utils/notebook.py @@ -1,7 +1,7 @@ -from __future__ import annotations - import cv2 import matplotlib.pyplot as plt +import numpy as np +import numpy.typing as npt from PIL import Image from supervision.draw.base import ImageType @@ -34,14 +34,16 @@ def plot_image( ``` """ if isinstance(image, Image.Image): - image = pillow_to_cv2(image) + image_np = pillow_to_cv2(image) + else: + image_np = image plt.figure(figsize=size) - if image.ndim == 2: - plt.imshow(image, cmap=cmap) + if image_np.ndim == 2: + plt.imshow(image_np, cmap=cmap) else: - plt.imshow(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) + plt.imshow(cv2.cvtColor(image_np, cv2.COLOR_BGR2RGB)) plt.axis("off") plt.show() @@ -91,11 +93,11 @@ def plot_images_grid( """ nrows, ncols = grid_size - for idx, img in enumerate(images): - if isinstance(img, Image.Image): - images[idx] = pillow_to_cv2(img) + images_np: list[npt.NDArray[np.uint8]] = [ + pillow_to_cv2(img) if isinstance(img, Image.Image) else img for img in images + ] - if len(images) > nrows * ncols: + if len(images_np) > nrows * ncols: raise ValueError( "The number of images exceeds the grid size. Please increase the grid size" " or reduce the number of images." @@ -104,11 +106,11 @@ def plot_images_grid( _fig, axes = plt.subplots(nrows=nrows, ncols=ncols, figsize=size) for idx, ax in enumerate(axes.flat): - if idx < len(images): - if images[idx].ndim == 2: - ax.imshow(images[idx], cmap=cmap) + if idx < len(images_np): + if images_np[idx].ndim == 2: + ax.imshow(images_np[idx], cmap=cmap) else: - ax.imshow(cv2.cvtColor(images[idx], cv2.COLOR_BGR2RGB)) + ax.imshow(cv2.cvtColor(images_np[idx], cv2.COLOR_BGR2RGB)) if titles is not None and idx < len(titles): ax.set_title(titles[idx]) diff --git a/src/supervision/utils/video.py b/src/supervision/utils/video.py index 1d3fe2a4..51a2e16d 100644 --- a/src/supervision/utils/video.py +++ b/src/supervision/utils/video.py @@ -10,7 +10,8 @@ from collections import deque from collections.abc import Callable, Generator from dataclasses import dataclass from queue import Empty, Full, Queue -from typing import Any +from types import TracebackType +from typing import cast import cv2 import numpy as np @@ -96,18 +97,24 @@ class VideoSink: ``` """ # noqa: E501 // docs - def __init__(self, target_path: str, video_info: VideoInfo, codec: str = "mp4v"): + def __init__( + self, target_path: str, video_info: VideoInfo, codec: str = "mp4v" + ) -> None: self.target_path = target_path self.video_info = video_info self.__codec = codec - self.__writer = None + self.__fourcc: int = 0 + self.__writer: cv2.VideoWriter | None = None def __enter__(self) -> VideoSink: + fourcc_fn = cast( + Callable[[str, str, str, str], int], getattr(cv2, "VideoWriter_fourcc") + ) try: - self.__fourcc = cv2.VideoWriter_fourcc(*self.__codec) + self.__fourcc = int(fourcc_fn(*self.__codec)) except TypeError as e: logger.warning("%s. Defaulting to mp4v...", str(e)) - self.__fourcc = cv2.VideoWriter_fourcc(*"mp4v") + self.__fourcc = int(fourcc_fn(*"mp4v")) self.__writer = cv2.VideoWriter( self.target_path, self.__fourcc, @@ -131,7 +138,7 @@ class VideoSink: self, exc_type: type[BaseException] | None, exc_value: BaseException | None, - exc_traceback: Any, + exc_traceback: TracebackType | None, ) -> None: if self.__writer is not None: self.__writer.release() @@ -271,7 +278,7 @@ def get_video_frames_generator( if not success or frame_position >= end: break if frame is not None: - yield frame + yield cast(npt.NDArray[np.uint8], frame) for _ in range(stride - 1): success = video.grab() if not success: @@ -461,7 +468,7 @@ class FPSMonitor: A class for monitoring frames per second (FPS) to benchmark latency. """ - def __init__(self, sample_size: int = 30): + def __init__(self, sample_size: int = 30) -> None: """ Args: sample_size: The maximum number of observations for latency diff --git a/src/supervision/validators/__init__.py b/src/supervision/validators/__init__.py index 313cd412..b8cd58d3 100644 --- a/src/supervision/validators/__init__.py +++ b/src/supervision/validators/__init__.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import Any import numpy as np diff --git a/tests/annotators/test_core.py b/tests/annotators/test_core.py index 27180d4f..005f8166 100644 --- a/tests/annotators/test_core.py +++ b/tests/annotators/test_core.py @@ -2,8 +2,6 @@ Tests for supervision/annotators/core.py """ -from __future__ import annotations - import warnings import numpy as np diff --git a/tests/annotators/test_docs.py b/tests/annotators/test_docs.py index b31b06e1..f9def6ca 100644 --- a/tests/annotators/test_docs.py +++ b/tests/annotators/test_docs.py @@ -1,7 +1,5 @@ """Regression tests for docs/detection/annotators.md tab structure.""" -from __future__ import annotations - import ast import re from pathlib import Path diff --git a/tests/annotators/test_utils.py b/tests/annotators/test_utils.py index 51642cfe..21b19310 100644 --- a/tests/annotators/test_utils.py +++ b/tests/annotators/test_utils.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import numpy as np diff --git a/tests/assets/test_downloader.py b/tests/assets/test_downloader.py index a7f16115..ca0c677d 100644 --- a/tests/assets/test_downloader.py +++ b/tests/assets/test_downloader.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from unittest.mock import MagicMock, mock_open, patch import pytest @@ -9,7 +7,7 @@ from supervision.assets.list import ImageAssets, VideoAssets class TestMD5HashMatching: - def test_file_exists_matching_hash(self): + def test_file_exists_matching_hash(self) -> None: """Test is_md5_hash_matching when file exists and hash matches.""" test_content = b"test content" test_hash = "9473fdd0d880a43c21b7778d34872157" # MD5 of "test content" @@ -20,7 +18,7 @@ class TestMD5HashMatching: ): assert is_md5_hash_matching("dummy_file", test_hash) - def test_file_exists_not_matching_hash(self): + def test_file_exists_not_matching_hash(self) -> None: """Test is_md5_hash_matching when file exists but hash doesn't match.""" test_content = b"test content" wrong_hash = "wrong_hash" @@ -31,7 +29,7 @@ class TestMD5HashMatching: ): assert not is_md5_hash_matching("dummy_file", wrong_hash) - def test_file_not_exists(self): + def test_file_not_exists(self) -> None: """Test is_md5_hash_matching when file doesn't exist.""" with patch("os.path.exists", return_value=False): assert not is_md5_hash_matching("nonexistent_file", "some_hash") @@ -41,7 +39,7 @@ class TestDownloadAssets: @patch("supervision.assets.downloader.logger") @patch("supervision.assets.downloader.is_md5_hash_matching", return_value=True) @patch("pathlib.Path.exists", return_value=True) - def test_already_exists_and_valid(self, mock_exists, mock_md5, mock_logger): + def test_already_exists_and_valid(self, mock_exists, mock_md5, mock_logger) -> None: """Test download_assets when file already exists and is valid.""" filename = "vehicles.mp4" result = download_assets(filename) @@ -57,7 +55,7 @@ class TestDownloadAssets: @patch("pathlib.Path.exists", return_value=True) def test_already_exists_but_corrupted( self, mock_exists, mock_md5, mock_remove, mock_logger - ): + ) -> None: """Test download_assets when file exists but is corrupted (re-downloads).""" filename = "vehicles.mp4" result = download_assets(filename) @@ -81,7 +79,7 @@ class TestDownloadAssets: mock_mkdir, mock_open_file, mock_logger, - ): + ) -> None: """Test download_assets downloading a new file.""" filename = "vehicles.mp4" @@ -104,7 +102,7 @@ class TestDownloadAssets: mock_copyfileobj.assert_called_once() @patch("pathlib.Path.exists", return_value=False) - def test_invalid_asset(self, mock_exists): + def test_invalid_asset(self, mock_exists) -> None: """Test download_assets with invalid asset name.""" invalid_filename = "invalid.mp4" @@ -115,7 +113,7 @@ class TestDownloadAssets: assert "vehicles.mp4" in str(exc_info.value) @patch("pathlib.Path.exists", return_value=True) - def test_invalid_asset_when_file_exists(self, mock_exists): + def test_invalid_asset_when_file_exists(self, mock_exists) -> None: """Test download_assets with invalid asset name that already exists.""" invalid_filename = "invalid.mp4" @@ -141,7 +139,7 @@ class TestDownloadAssets: mock_mkdir, mock_open_file, mock_logger, - ): + ) -> None: """Test download_assets with VideoAssets enum.""" asset = VideoAssets.VEHICLES @@ -174,7 +172,7 @@ class TestDownloadAssets: mock_mkdir, mock_open_file, mock_logger, - ): + ) -> None: """Test download_assets with ImageAssets enum.""" asset = ImageAssets.SOCCER diff --git a/tests/assets/test_list.py b/tests/assets/test_list.py index 2574475c..db143ed2 100644 --- a/tests/assets/test_list.py +++ b/tests/assets/test_list.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from supervision.assets.list import ( BASE_IMAGE_URL, BASE_VIDEO_URL, @@ -9,7 +7,7 @@ from supervision.assets.list import ( ) -def test_video_assets_list(): +def test_video_assets_list() -> None: """Test that VideoAssets.list() returns all video filenames.""" expected_filenames = [ "vehicles.mp4", @@ -26,7 +24,7 @@ def test_video_assets_list(): assert VideoAssets.list() == expected_filenames -def test_image_assets_list(): +def test_image_assets_list() -> None: """Test that ImageAssets.list() returns all image filenames.""" expected_filenames = [ "people-walking.jpg", @@ -35,21 +33,21 @@ def test_image_assets_list(): assert ImageAssets.list() == expected_filenames -def test_video_assets_values(): +def test_video_assets_values() -> None: """Test that VideoAssets enum members have correct attributes.""" assert VideoAssets.VEHICLES.filename == "vehicles.mp4" assert VideoAssets.VEHICLES.md5_hash == "8155ff4e4de08cfa25f39de96483f918" assert VideoAssets.VEHICLES.value == "vehicles.mp4" -def test_image_assets_values(): +def test_image_assets_values() -> None: """Test that ImageAssets enum members have correct attributes.""" assert ImageAssets.SOCCER.filename == "soccer.jpg" assert ImageAssets.SOCCER.md5_hash == "0f5a4b98abf3e3973faf9e9260a7d876" assert ImageAssets.SOCCER.value == "soccer.jpg" -def test_media_assets_dict_keys(): +def test_media_assets_dict_keys() -> None: """Test that MEDIA_ASSETS has all VideoAssets and ImageAssets as keys.""" expected_keys = {asset.filename for asset in VideoAssets} | { asset.filename for asset in ImageAssets @@ -57,7 +55,7 @@ def test_media_assets_dict_keys(): assert set(MEDIA_ASSETS.keys()) == expected_keys -def test_media_assets_dict_values(): +def test_media_assets_dict_values() -> None: """Test that MEDIA_ASSETS values are tuples of (url, md5_hash).""" for filename, (url, md5_hash) in MEDIA_ASSETS.items(): assert isinstance(url, str) diff --git a/tests/conftest.py b/tests/conftest.py index d4f6294b..40990c61 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import matplotlib import numpy as np import pytest diff --git a/tests/dataset/formats/test_coco.py b/tests/dataset/formats/test_coco.py index 83a1f717..f4dcf360 100644 --- a/tests/dataset/formats/test_coco.py +++ b/tests/dataset/formats/test_coco.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import json from contextlib import ExitStack as DoesNotRaise from pathlib import Path diff --git a/tests/dataset/formats/test_createml.py b/tests/dataset/formats/test_createml.py index ee258c92..0097c3c5 100644 --- a/tests/dataset/formats/test_createml.py +++ b/tests/dataset/formats/test_createml.py @@ -1,7 +1,5 @@ """Tests for CreateML object-detection annotation load/save and conversion helpers.""" -from __future__ import annotations - import json from collections.abc import Callable from pathlib import Path diff --git a/tests/dataset/formats/test_labelme.py b/tests/dataset/formats/test_labelme.py index 96f1ab7c..a0e09ec7 100644 --- a/tests/dataset/formats/test_labelme.py +++ b/tests/dataset/formats/test_labelme.py @@ -1,7 +1,5 @@ """Tests for the LabelMe dataset format loader and exporter.""" -from __future__ import annotations - import json from pathlib import Path diff --git a/tests/dataset/formats/test_pascal_voc.py b/tests/dataset/formats/test_pascal_voc.py index 25fc56a4..26959337 100644 --- a/tests/dataset/formats/test_pascal_voc.py +++ b/tests/dataset/formats/test_pascal_voc.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import numpy as np @@ -15,7 +13,7 @@ from supervision.dataset.formats.pascal_voc import ( from tests.helpers import _create_detections -def are_xml_elements_equal(elem1, elem2): +def are_xml_elements_equal(elem1, elem2) -> bool: if ( elem1.tag != elem2.tag or elem1.attrib != elem2.attrib @@ -67,7 +65,7 @@ def test_object_to_pascal_voc( polygon: np.ndarray | None, expected_result, exception: Exception, -): +) -> None: with exception: result = object_to_pascal_voc(xyxy=xyxy, name=name, polygon=polygon) assert are_xml_elements_equal(result, expected_result) @@ -132,7 +130,7 @@ def test_parse_polygon_points( polygon_element, expected_result: list[list], exception, -): +) -> None: with exception: result = parse_polygon_points(polygon_element) assert np.array_equal(result, expected_result) @@ -221,7 +219,7 @@ MIXED_POLYGON_AND_BOX = """test ) def test_detections_from_xml_obj( xml_string, classes, resolution_wh, force_masks, expected_result, exception -): +) -> None: with exception: root = ElementTree.fromstring(xml_string) result, _ = detections_from_xml_obj(root, classes, resolution_wh, force_masks) diff --git a/tests/dataset/formats/test_yolo.py b/tests/dataset/formats/test_yolo.py index 9c56a4c6..72c719a5 100644 --- a/tests/dataset/formats/test_yolo.py +++ b/tests/dataset/formats/test_yolo.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise from pathlib import Path diff --git a/tests/dataset/test_core.py b/tests/dataset/test_core.py index fcfa48e8..33220110 100644 --- a/tests/dataset/test_core.py +++ b/tests/dataset/test_core.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise from pathlib import Path diff --git a/tests/dataset/test_progress.py b/tests/dataset/test_progress.py index 8fac9d69..54d9d284 100644 --- a/tests/dataset/test_progress.py +++ b/tests/dataset/test_progress.py @@ -1,7 +1,5 @@ """Tests for show_progress parameter on dataset load/save operations.""" -from __future__ import annotations - import json import os from pathlib import Path diff --git a/tests/dataset/test_utils.py b/tests/dataset/test_utils.py index febed326..13248365 100644 --- a/tests/dataset/test_utils.py +++ b/tests/dataset/test_utils.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise from typing import TypeVar diff --git a/tests/detection/test_compact_mask.py b/tests/detection/test_compact_mask.py index c05bc876..173b55ab 100644 --- a/tests/detection/test_compact_mask.py +++ b/tests/detection/test_compact_mask.py @@ -1,7 +1,5 @@ """Unit tests for CompactMask and its private RLE helpers.""" -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import numpy as np diff --git a/tests/detection/test_compact_mask_integration.py b/tests/detection/test_compact_mask_integration.py index 210ec818..ab55ed17 100644 --- a/tests/detection/test_compact_mask_integration.py +++ b/tests/detection/test_compact_mask_integration.py @@ -1,7 +1,5 @@ """Integration tests: CompactMask <-> Detections, annotators, merge.""" -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import numpy as np diff --git a/tests/detection/test_compact_mask_iou.py b/tests/detection/test_compact_mask_iou.py index dc4aed7e..c573eee7 100644 --- a/tests/detection/test_compact_mask_iou.py +++ b/tests/detection/test_compact_mask_iou.py @@ -8,8 +8,6 @@ These tests verify that: and produce the same keep-set as when given equivalent dense arrays. """ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/detection/test_core.py b/tests/detection/test_core.py index d529c722..8070613d 100644 --- a/tests/detection/test_core.py +++ b/tests/detection/test_core.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import warnings from contextlib import ExitStack as DoesNotRaise @@ -882,7 +880,7 @@ def test_merge_inner_detection_object_pair( detection_2: Detections, expected_result: Detections | None, exception: Exception, -): +) -> None: with exception: result = merge_inner_detection_object_pair(detection_1, detection_2) assert result == expected_result diff --git a/tests/detection/test_csv.py b/tests/detection/test_csv.py index be837666..3535d6fc 100644 --- a/tests/detection/test_csv.py +++ b/tests/detection/test_csv.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import csv import os from typing import Any @@ -515,7 +513,7 @@ def test_csv_sink_manual( assert_csv_equal(file_name, expected_result) -def assert_csv_equal(file_name, expected_rows): +def assert_csv_equal(file_name, expected_rows) -> None: with open(file_name, newline="") as file: reader = csv.reader(file) for i, row in enumerate(reader): diff --git a/tests/detection/test_from_adapters.py b/tests/detection/test_from_adapters.py index 7cc0d0d3..fdbefb28 100644 --- a/tests/detection/test_from_adapters.py +++ b/tests/detection/test_from_adapters.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/detection/test_from_sam.py b/tests/detection/test_from_sam.py index 370a135e..57a5c7b7 100644 --- a/tests/detection/test_from_sam.py +++ b/tests/detection/test_from_sam.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/detection/test_inference_slicer_compact.py b/tests/detection/test_inference_slicer_compact.py index 4a4a3e5f..0090c346 100644 --- a/tests/detection/test_inference_slicer_compact.py +++ b/tests/detection/test_inference_slicer_compact.py @@ -6,8 +6,6 @@ Verifies that with compact_masks=True: - Final detections are pixel-identical to the compact_masks=False path. """ -from __future__ import annotations - import numpy as np import supervision as sv diff --git a/tests/detection/test_json.py b/tests/detection/test_json.py index cdb3ea5d..7c584908 100644 --- a/tests/detection/test_json.py +++ b/tests/detection/test_json.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import json import os from typing import Any diff --git a/tests/detection/test_line_counter.py b/tests/detection/test_line_counter.py index 75355a27..95677e8d 100644 --- a/tests/detection/test_line_counter.py +++ b/tests/detection/test_line_counter.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import numpy as np diff --git a/tests/detection/test_polygon_zone_annotator.py b/tests/detection/test_polygon_zone_annotator.py index c46b6513..ad0202f1 100644 --- a/tests/detection/test_polygon_zone_annotator.py +++ b/tests/detection/test_polygon_zone_annotator.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/detection/test_polygonzone.py b/tests/detection/test_polygonzone.py index a897799d..45a2a98e 100644 --- a/tests/detection/test_polygonzone.py +++ b/tests/detection/test_polygonzone.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import numpy as np @@ -42,7 +40,7 @@ class TestPolygonZoneInit: ), ], ) - def test_empty_anchors_raises(self, polygon, triggering_anchors, exception): + def test_empty_anchors_raises(self, polygon, triggering_anchors, exception) -> None: with exception: sv.PolygonZone(polygon, triggering_anchors=triggering_anchors) diff --git a/tests/detection/test_vlm.py b/tests/detection/test_vlm.py index 7b191d9d..0653ed0c 100644 --- a/tests/detection/test_vlm.py +++ b/tests/detection/test_vlm.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise from contextlib import nullcontext as does_not_raise @@ -1153,7 +1151,7 @@ def test_from_google_gemini_2_5( classes: list[str] | None, expected_results: None | (tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]), -): +) -> None: with exception: ( xyxy, @@ -1274,7 +1272,7 @@ def test_from_deepseek_vl_2( resolution_wh: tuple[int, int], classes: list[str] | None, expected_detections: Detections, -): +) -> None: with exception: detections = Detections.from_vlm( vlm=VLM.DEEPSEEK_VL_2, diff --git a/tests/detection/tools/test_inference_slicer.py b/tests/detection/tools/test_inference_slicer.py index abbd9ba8..1f26197b 100644 --- a/tests/detection/tools/test_inference_slicer.py +++ b/tests/detection/tools/test_inference_slicer.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import threading import warnings diff --git a/tests/detection/tools/test_inference_slicer_geotiff.py b/tests/detection/tools/test_inference_slicer_geotiff.py index 67175e4a..1189ee81 100644 --- a/tests/detection/tools/test_inference_slicer_geotiff.py +++ b/tests/detection/tools/test_inference_slicer_geotiff.py @@ -1,10 +1,8 @@ """Tests for windowed GeoTIFF reads in InferenceSlicer.""" -from __future__ import annotations - import threading import time -from typing import Callable +from collections.abc import Callable import numpy as np import pytest diff --git a/tests/detection/tools/test_smoother.py b/tests/detection/tools/test_smoother.py index 54fba34d..70cb7cb3 100644 --- a/tests/detection/tools/test_smoother.py +++ b/tests/detection/tools/test_smoother.py @@ -1,7 +1,5 @@ """Tests for DetectionsSmoother bounding-box and confidence smoothing.""" -from __future__ import annotations - import numpy as np import pytest from numpy.testing import assert_allclose diff --git a/tests/detection/utils/test_boxes.py b/tests/detection/utils/test_boxes.py index ba294db7..1192d111 100644 --- a/tests/detection/utils/test_boxes.py +++ b/tests/detection/utils/test_boxes.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import numpy as np diff --git a/tests/detection/utils/test_converters.py b/tests/detection/utils/test_converters.py index ca07a4f0..2e0ad622 100644 --- a/tests/detection/utils/test_converters.py +++ b/tests/detection/utils/test_converters.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import numpy as np diff --git a/tests/detection/utils/test_internal.py b/tests/detection/utils/test_internal.py index 514404ec..293ce332 100644 --- a/tests/detection/utils/test_internal.py +++ b/tests/detection/utils/test_internal.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise from typing import Any @@ -750,7 +748,7 @@ def test_merge_data( data_list: list[dict[str, Any]], expected_result: dict[str, Any] | None, exception: Exception, -): +) -> None: with exception: result = merge_data(data_list=data_list) if expected_result is None: @@ -926,7 +924,7 @@ def test_get_data_item( index: Any, expected_result: dict[str, Any] | None, exception: Exception, -): +) -> None: with exception: result = get_data_item(data=data, index=index) for key in result: @@ -1103,7 +1101,7 @@ def test_get_data_item( ), ], ) -def test_merge_metadata(metadata_list, expected_result, exception): +def test_merge_metadata(metadata_list, expected_result, exception) -> None: with exception: result = merge_metadata(metadata_list) if expected_result is None: diff --git a/tests/detection/utils/test_masks.py b/tests/detection/utils/test_masks.py index ca308c8e..58595b43 100644 --- a/tests/detection/utils/test_masks.py +++ b/tests/detection/utils/test_masks.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import numpy as np diff --git a/tests/detection/utils/test_polygons.py b/tests/detection/utils/test_polygons.py index ad617448..05dfb128 100644 --- a/tests/detection/utils/test_polygons.py +++ b/tests/detection/utils/test_polygons.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import numpy as np diff --git a/tests/detection/utils/test_vlms.py b/tests/detection/utils/test_vlms.py index d08f6385..4eb314af 100644 --- a/tests/detection/utils/test_vlms.py +++ b/tests/detection/utils/test_vlms.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from supervision.detection.utils.vlms import edit_distance, fuzzy_match_index @@ -62,7 +60,7 @@ from supervision.detection.utils.vlms import edit_distance, fuzzy_match_index ("123ABC!", "123abc!", False, 0), ], ) -def test_edit_distance(string_1, string_2, case_sensitive, expected_result): +def test_edit_distance(string_1, string_2, case_sensitive, expected_result) -> None: assert ( edit_distance(string_1, string_2, case_sensitive=case_sensitive) == expected_result @@ -107,7 +105,7 @@ def test_edit_distance(string_1, string_2, case_sensitive, expected_result): ) def test_fuzzy_match_index( candidates, query, threshold, case_sensitive, expected_result -): +) -> None: assert ( fuzzy_match_index( candidates=candidates, diff --git a/tests/draw/test_color.py b/tests/draw/test_color.py index e7baca72..1f4007d2 100644 --- a/tests/draw/test_color.py +++ b/tests/draw/test_color.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise import pytest diff --git a/tests/draw/test_utils.py b/tests/draw/test_utils.py index fcb28b0a..c7733185 100644 --- a/tests/draw/test_utils.py +++ b/tests/draw/test_utils.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import cv2 import numpy as np import pytest diff --git a/tests/geometry/test_core.py b/tests/geometry/test_core.py index 556fff33..0908949e 100644 --- a/tests/geometry/test_core.py +++ b/tests/geometry/test_core.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from supervision.geometry.core import Point, Vector diff --git a/tests/geometry/test_utils.py b/tests/geometry/test_utils.py index 5952cec5..59b3975b 100644 --- a/tests/geometry/test_utils.py +++ b/tests/geometry/test_utils.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/helpers.py b/tests/helpers.py index c0d9a848..c9cefe9a 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -194,7 +194,7 @@ def _generate_random_boxes( return out -def assert_almost_equal(actual, expected, tolerance=1e-5): +def assert_almost_equal(actual, expected, tolerance=1e-5) -> None: """ Assert that two values are equal within a specified tolerance. @@ -248,7 +248,7 @@ def assert_image_mostly_same( class _FakeTensor: """Minimal tensor wrapper for cpu().numpy() and int().""" - def __init__(self, arr: np.ndarray): + def __init__(self, arr: np.ndarray) -> None: self._arr = np.asarray(arr) def cpu(self) -> _FakeTensor: @@ -264,7 +264,7 @@ class _FakeTensor: class _FakeYOLOv5Results: """YOLOv5-like results exposing pred list.""" - def __init__(self, pred0: np.ndarray): + def __init__(self, pred0: np.ndarray) -> None: self.pred = [_FakeTensor(pred0)] @@ -277,7 +277,7 @@ class _FakeUltralyticsBoxes: conf: np.ndarray, cls: np.ndarray, id_: np.ndarray | None = None, - ): + ) -> None: self.xyxy = _FakeTensor(xyxy) self.conf = _FakeTensor(conf) self.cls = _FakeTensor(cls) @@ -287,7 +287,7 @@ class _FakeUltralyticsBoxes: class _FakeUltralyticsResults: """Ultralytics-like results container used by from_ultralytics.""" - def __init__(self, boxes, names: dict[int, str], length: int = 0): + def __init__(self, boxes, names: dict[int, str], length: int = 0) -> None: self.boxes = boxes self.names = names self.obb = None @@ -301,7 +301,7 @@ class _FakeUltralyticsResults: class _FakeYoloNasPrediction: """YOLO-NAS-like prediction struct.""" - def __init__(self, bboxes_xyxy, confidence, labels): + def __init__(self, bboxes_xyxy, confidence, labels) -> None: self.bboxes_xyxy = bboxes_xyxy self.confidence = confidence self.labels = labels @@ -310,14 +310,14 @@ class _FakeYoloNasPrediction: class _FakeYoloNasResults: """YOLO-NAS-like results exposing prediction.""" - def __init__(self, prediction: _FakeYoloNasPrediction): + def __init__(self, prediction: _FakeYoloNasPrediction) -> None: self.prediction = prediction class _FakeYoloNasKeyPoint: """YOLO-NAS-like key point struct.""" - def __init__(self, poses, labels=None): + def __init__(self, poses, labels=None) -> None: self.poses = np.array(poses, dtype=np.float32) if labels is not None: self.labels = np.array(labels, dtype=int) @@ -326,20 +326,20 @@ class _FakeYoloNasKeyPoint: class _FakeYoloNasKeyPointResults: """YOLO-NAS-like results exposing key points.""" - def __init__(self, prediction: _FakeYoloNasKeyPoint, class_names=None): + def __init__(self, prediction: _FakeYoloNasKeyPoint, class_names=None) -> None: self.prediction = prediction self.class_names = class_names class _FakeMediapipeLandmark: - def __init__(self, x, y, visibility=1.0): + def __init__(self, x, y, visibility=1.0) -> None: self.x = x self.y = y self.visibility = visibility class _FakeMediapipePose: - def __init__(self, landmarks: list[_FakeMediapipeLandmark]): + def __init__(self, landmarks: list[_FakeMediapipeLandmark]) -> None: self.landmark = landmarks @@ -351,7 +351,7 @@ class _FakeMediapipeResults: | None = None, face_landmarks: _FakeMediapipeLandmark | None = None, multi_face_landmarks: list[_FakeMediapipeLandmark] | None = None, - ): + ) -> None: self.pose_landmarks = pose_landmarks self.face_landmarks = face_landmarks self.multi_face_landmarks = multi_face_landmarks diff --git a/tests/key_points/test_annotators.py b/tests/key_points/test_annotators.py index 404adb4b..d2eae10b 100644 --- a/tests/key_points/test_annotators.py +++ b/tests/key_points/test_annotators.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/key_points/test_core.py b/tests/key_points/test_core.py index 640d0ab4..e1848064 100644 --- a/tests/key_points/test_core.py +++ b/tests/key_points/test_core.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import nullcontext as DoesNotRaise import numpy as np diff --git a/tests/key_points/test_skeletons.py b/tests/key_points/test_skeletons.py index fb771bd9..b4bc4f7e 100644 --- a/tests/key_points/test_skeletons.py +++ b/tests/key_points/test_skeletons.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from supervision.key_points.skeletons import ( SKELETONS_BY_EDGE_COUNT, SKELETONS_BY_VERTEX_COUNT, @@ -8,7 +6,7 @@ from supervision.key_points.skeletons import ( class TestSkeletons: - def test_skeleton_enum_values(self): + def test_skeleton_enum_values(self) -> None: """Test skeleton enum has correct structure.""" for skeleton in Skeleton: assert isinstance(skeleton.value, tuple) @@ -16,7 +14,7 @@ class TestSkeletons: isinstance(edge, tuple) and len(edge) == 2 for edge in skeleton.value ) - def test_skeletons_by_vertex_count(self): + def test_skeletons_by_vertex_count(self) -> None: """Test SKELETONS_BY_VERTEX_COUNT dictionary population.""" # Test that the dictionary is populated assert len(SKELETONS_BY_VERTEX_COUNT) > 0 @@ -26,7 +24,7 @@ class TestSkeletons: assert 17 in SKELETONS_BY_VERTEX_COUNT # COCO has 17 keypoints assert SKELETONS_BY_VERTEX_COUNT[17] == coco_skeleton - def test_skeletons_by_edge_count(self): + def test_skeletons_by_edge_count(self) -> None: """Test SKELETONS_BY_EDGE_COUNT dictionary mapping.""" # Test that the dictionary is populated assert len(SKELETONS_BY_EDGE_COUNT) > 0 @@ -40,13 +38,13 @@ class TestSkeletons: assert SKELETONS_BY_EDGE_COUNT == expected - def test_unique_vertices_calculation(self): + def test_unique_vertices_calculation(self) -> None: """Test unique vertices calculation from skeleton edges.""" coco_skeleton = Skeleton.COCO.value unique_vertices = {vertex for edge in coco_skeleton for vertex in edge} assert len(unique_vertices) == 17 # COCO has 17 keypoints - def test_skeletons_by_vertex_count_mapping_behaviour(self): + def test_skeletons_by_vertex_count_mapping_behaviour(self) -> None: """Test SKELETONS_BY_VERTEX_COUNT uses last-in-wins for duplicate counts.""" expected_mapping = {} for skeleton in Skeleton: diff --git a/tests/metrics/conftest.py b/tests/metrics/conftest.py index 92cf4192..7033763b 100644 --- a/tests/metrics/conftest.py +++ b/tests/metrics/conftest.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/metrics/test_detection.py b/tests/metrics/test_detection.py index 4b1dd38a..b1ceb33b 100644 --- a/tests/metrics/test_detection.py +++ b/tests/metrics/test_detection.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise from typing import ClassVar @@ -513,7 +511,7 @@ class TestDetectionMetrics: iou_threshold, expected_result: np.ndarray | None, exception: Exception, - ): + ) -> None: with exception: result = ConfusionMatrix.from_tensors( predictions=predictions, @@ -557,7 +555,7 @@ class TestDetectionMetrics: iou_threshold, expected_result: np.ndarray | None, exception: Exception, - ): + ) -> None: with exception: result = ConfusionMatrix.evaluate_detection_batch( predictions=predictions, @@ -585,7 +583,7 @@ class TestDetectionMetrics: matches, expected_result: np.ndarray | None, exception: Exception, - ): + ) -> None: with exception: result = ConfusionMatrix._drop_extra_matches(matches) @@ -1105,7 +1103,7 @@ class TestDetectionMetrics: iou_threshold, expected_result, exception: Exception, - ): + ) -> None: with exception: confusion_matrix = ConfusionMatrix.from_detections( predictions=predictions, @@ -1119,7 +1117,7 @@ class TestDetectionMetrics: # AssertionError if the two arrays are not equal np.testing.assert_array_equal(confusion_matrix.matrix, expected_result) - def test_confusion_matrix_on_yolo_dataset(self, yolo_dataset_structure): + def test_confusion_matrix_on_yolo_dataset(self, yolo_dataset_structure) -> None: """ Test confusion matrix calculation on a YOLO-format dataset. @@ -1306,7 +1304,6 @@ class TestDetectionMetrics: def test_validate_input_tensors_obb( self, predictions, targets, metric_target, exception ): - with exception: _validate_input_tensors(predictions, targets, metric_target=metric_target) diff --git a/tests/metrics/test_f1_score.py b/tests/metrics/test_f1_score.py index aa41b9d0..64f7d869 100644 --- a/tests/metrics/test_f1_score.py +++ b/tests/metrics/test_f1_score.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/metrics/test_mean_average_precision.py b/tests/metrics/test_mean_average_precision.py index 89b7329d..6b072de2 100644 --- a/tests/metrics/test_mean_average_precision.py +++ b/tests/metrics/test_mean_average_precision.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np from supervision.config import ORIENTED_BOX_COORDINATES diff --git a/tests/metrics/test_mean_average_precision_area.py b/tests/metrics/test_mean_average_precision_area.py index fcc3991a..96bf664e 100644 --- a/tests/metrics/test_mean_average_precision_area.py +++ b/tests/metrics/test_mean_average_precision_area.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest @@ -44,7 +42,7 @@ class TestMeanAveragePrecisionArea: ) def test_area_calculation_and_size_specific_map( self, xyxy, expected_areas, expected_size_maps - ): + ) -> None: """Test area calculation and size-specific mAP functionality.""" gt = Detections( xyxy=xyxy, @@ -96,7 +94,7 @@ class TestMeanAveragePrecisionArea: "Large objects should have no data" ) - def test_area_preserved_from_data(self): + def test_area_preserved_from_data(self) -> None: """Test that area from data field is preserved (COCO case).""" gt = Detections( xyxy=np.array( diff --git a/tests/metrics/test_mean_average_recall.py b/tests/metrics/test_mean_average_recall.py index 3e552f27..e241e136 100644 --- a/tests/metrics/test_mean_average_recall.py +++ b/tests/metrics/test_mean_average_recall.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest @@ -361,7 +359,47 @@ def three_class_single_image_detections(): return predictions, targets -def test_single_perfect_detection(): +@pytest.mark.parametrize( + "missing_attribute", + ["predictions_class_id", "targets_class_id", "predictions_confidence"], +) +def test_compute_value_error_for_missing_required_fields_after_update( + missing_attribute, +) -> None: + """Raises ValueError when required detection fields are missing.""" + metric = MeanAverageRecall() + boxes = np.array([[10, 10, 50, 50]], dtype=np.float32) + class_id = np.array([0], dtype=np.int32) + confidence = np.array([0.9], dtype=np.float32) + + predictions = Detections( + xyxy=boxes, + confidence=confidence, + class_id=class_id, + ) + targets = Detections( + xyxy=boxes, + class_id=class_id, + ) + + if missing_attribute == "predictions_class_id": + predictions = Detections( + xyxy=boxes, + confidence=confidence, + ) + elif missing_attribute == "targets_class_id": + targets = Detections(xyxy=boxes) + else: + predictions = Detections( + xyxy=boxes, + class_id=class_id, + ) + + with pytest.raises(ValueError, match="MeanAverageRecall metric requires"): + metric.update(predictions, targets).compute() + + +def test_single_perfect_detection() -> None: """Test that a single perfect detection yields 1.0 recall.""" detections = Detections( xyxy=np.array([[10, 10, 50, 50]], dtype=np.float32), @@ -417,7 +455,7 @@ def test_compute_value_error_for_missing_required_fields(missing_attribute) -> N def test_complex_integration_scenario( complex_scenario_predictions, complex_scenario_targets -): +) -> None: """Test integration scenario with multiple images and varying performance.""" def mock_detections_list(boxes_list): @@ -443,7 +481,9 @@ def test_complex_integration_scenario( np.testing.assert_almost_equal(result.recall_scores, expected_result, decimal=6) -def test_mar_at_k_limits_per_image_not_per_class(two_class_two_image_detections): +def test_mar_at_k_limits_per_image_not_per_class( + two_class_two_image_detections, +) -> None: """ Test that `mAR @ K` limits detections per image, not per class. @@ -498,7 +538,7 @@ def test_mar_at_k_limits_per_image_not_per_class(two_class_two_image_detections) ) -def test_three_class_single_image_scenario(three_class_single_image_detections): +def test_three_class_single_image_scenario(three_class_single_image_detections) -> None: """ Test with 3 classes on single image - explicit N x K bug reproduction. @@ -545,7 +585,7 @@ def test_three_class_single_image_scenario(three_class_single_image_detections): ) -def test_dataset_split_integration(yolo_dataset_two_classes): +def test_dataset_split_integration(yolo_dataset_two_classes) -> None: """ Test mAR with a roboflow-format dataset loaded from disk. diff --git a/tests/metrics/test_oriented_bounding_box_metrics.py b/tests/metrics/test_oriented_bounding_box_metrics.py index 295322c1..9b7a96db 100644 --- a/tests/metrics/test_oriented_bounding_box_metrics.py +++ b/tests/metrics/test_oriented_bounding_box_metrics.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/metrics/test_precision.py b/tests/metrics/test_precision.py index f7caa78f..8709b210 100644 --- a/tests/metrics/test_precision.py +++ b/tests/metrics/test_precision.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/metrics/test_recall.py b/tests/metrics/test_recall.py index a9249b2d..2286d8ba 100644 --- a/tests/metrics/test_recall.py +++ b/tests/metrics/test_recall.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/test_validate_deprecations.py b/tests/test_validate_deprecations.py index b753da87..69bb1db5 100644 --- a/tests/test_validate_deprecations.py +++ b/tests/test_validate_deprecations.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import warnings from collections.abc import Callable diff --git a/tests/tracker/test_byte_tracker.py b/tests/tracker/test_byte_tracker.py index dc4f0e2e..83e25b06 100644 --- a/tests/tracker/test_byte_tracker.py +++ b/tests/tracker/test_byte_tracker.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest diff --git a/tests/utils/conftest.py b/tests/utils/conftest.py index 1d035fda..967037dd 100644 --- a/tests/utils/conftest.py +++ b/tests/utils/conftest.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os import cv2 diff --git a/tests/utils/test_conversion.py b/tests/utils/test_conversion.py index 2c178296..e9fabb0d 100644 --- a/tests/utils/test_conversion.py +++ b/tests/utils/test_conversion.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np from PIL import Image, ImageChops diff --git a/tests/utils/test_file.py b/tests/utils/test_file.py index af8e106f..62ca0add 100644 --- a/tests/utils/test_file.py +++ b/tests/utils/test_file.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os from contextlib import ExitStack as DoesNotRaise from pathlib import Path @@ -64,7 +62,7 @@ def test_read_txt_file( skip_empty: bool, expected_result: list[str] | None, exception: Exception, -): +) -> None: with exception: result = read_txt_file(file_name, skip_empty) assert result == expected_result diff --git a/tests/utils/test_image.py b/tests/utils/test_image.py index f1c0b936..88b84d40 100644 --- a/tests/utils/test_image.py +++ b/tests/utils/test_image.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import numpy as np import pytest from PIL import Image, ImageChops @@ -8,6 +6,7 @@ from supervision.utils.image import ( crop_image, get_image_resolution_wh, letterbox_image, + overlay_image, resize_image, scale_image, tint_image, @@ -141,6 +140,42 @@ def test_letterbox_image_for_pillow_image() -> None: ) +def test_overlay_image_blends_rgba_with_float32_rounding() -> None: + """RGBA overlay uses current float32 blend semantics.""" + # given + image = np.full((1, 1, 3), 22, dtype=np.uint8) + overlay = np.array([[[39, 39, 39, 60]]], dtype=np.uint8) + expected = np.full((1, 1, 3), 26, dtype=np.uint8) + + # when + result = overlay_image(image=image, overlay=overlay, anchor=(0, 0)) + + # then + np.testing.assert_array_equal(result, expected) + + +def test_overlay_image_crops_rgba_overlay_at_scene_boundary() -> None: + """RGBA overlay is cropped when anchored outside scene bounds.""" + # given + image = np.zeros((3, 3, 3), dtype=np.uint8) + overlay = np.array( + [ + [[1, 11, 21, 255], [2, 12, 22, 255], [3, 13, 23, 255]], + [[4, 14, 24, 255], [5, 15, 25, 255], [6, 16, 26, 255]], + [[7, 17, 27, 255], [8, 18, 28, 255], [9, 19, 29, 255]], + ], + dtype=np.uint8, + ) + expected = np.zeros((3, 3, 3), dtype=np.uint8) + expected[:2, :2] = overlay[1:, 1:, :3] + + # when + result = overlay_image(image=image, overlay=overlay, anchor=(-1, -1)) + + # then + np.testing.assert_array_equal(result, expected) + + @pytest.mark.parametrize( ("image", "xyxy", "expected_size"), [ @@ -170,7 +205,7 @@ def test_letterbox_image_for_pillow_image() -> None: ), ], ) -def test_crop_image(image, xyxy, expected_size): +def test_crop_image(image, xyxy, expected_size) -> None: cropped = crop_image(image=image, xyxy=xyxy) if isinstance(image, np.ndarray): assert isinstance(cropped, np.ndarray) @@ -194,7 +229,7 @@ def test_crop_image(image, xyxy, expected_size): (Image.new("L", (20, 10), color=0), (20, 10)), ], ) -def test_get_image_resolution_wh(image, expected): +def test_get_image_resolution_wh(image, expected) -> None: resolution = get_image_resolution_wh(image) assert resolution == expected diff --git a/tests/utils/test_internal.py b/tests/utils/test_internal.py index 8e3e249d..6db27855 100644 --- a/tests/utils/test_internal.py +++ b/tests/utils/test_internal.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import ExitStack as DoesNotRaise from dataclasses import dataclass, field from typing import Any @@ -12,30 +10,30 @@ from supervision.utils.internal import get_instance_variables class MockClass: - def __init__(self): + def __init__(self) -> None: self.public = 0 self._protected = 1 self.__private = 2 - def public_method(self): + def public_method(self) -> None: pass - def _protected_method(self): + def _protected_method(self) -> None: pass - def __private_method(self): + def __private_method(self) -> None: pass @property - def public_property(self): + def public_property(self) -> int: return 0 @property - def _protected_property(self): + def _protected_property(self) -> int: return 1 @property - def __private_property(self): + def __private_property(self) -> int: return 2 @@ -53,25 +51,25 @@ class MockDataclass: _protected_field_with_factory: dict = field(default_factory=dict) __private_field_with_factory: dict = field(default_factory=dict) - def public_method(self): + def public_method(self) -> None: pass - def _protected_method(self): + def _protected_method(self) -> None: pass - def __private_method(self): + def __private_method(self) -> None: pass @property - def public_property(self): + def public_property(self) -> int: return 0 @property - def _protected_property(self): + def _protected_property(self) -> int: return 1 @property - def __private_property(self): + def __private_property(self) -> int: return 2 diff --git a/tests/utils/test_iterables.py b/tests/utils/test_iterables.py index 5c661115..c48130be 100644 --- a/tests/utils/test_iterables.py +++ b/tests/utils/test_iterables.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import pytest from supervision.utils.iterables import create_batches, fill diff --git a/tests/utils/test_logger.py b/tests/utils/test_logger.py index bf3bc1df..8e75cffc 100644 --- a/tests/utils/test_logger.py +++ b/tests/utils/test_logger.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging import sys from unittest.mock import patch @@ -8,46 +6,46 @@ from supervision.utils.logger import _get_logger class TestGetLogger: - def test_default_name(self): + def test_default_name(self) -> None: """Logger is created with default name.""" logger = _get_logger() assert logger.name == "supervision" - def test_custom_name(self): + def test_custom_name(self) -> None: """Logger is created with provided name.""" logger = _get_logger("supervision.test_module") assert logger.name == "supervision.test_module" - def test_default_level_is_info(self): + def test_default_level_is_info(self) -> None: """Logger defaults to INFO level when LOG_LEVEL env var is not set.""" with patch.dict("os.environ", {}, clear=True): # Use a unique name to avoid cached logger state from other tests logger = _get_logger("supervision.test_default_level") assert logger.level == logging.INFO - def test_explicit_level(self): + def test_explicit_level(self) -> None: """Logger uses the explicitly provided level.""" logger = _get_logger("supervision.test_explicit_level", level=logging.DEBUG) assert logger.level == logging.DEBUG - def test_log_level_env_var(self): + def test_log_level_env_var(self) -> None: """Logger respects the LOG_LEVEL environment variable.""" with patch.dict("os.environ", {"LOG_LEVEL": "DEBUG"}): logger = _get_logger("supervision.test_env_level") assert logger.level == logging.DEBUG - def test_log_level_env_var_warning(self): + def test_log_level_env_var_warning(self) -> None: """Logger respects the LOG_LEVEL=WARNING environment variable.""" with patch.dict("os.environ", {"LOG_LEVEL": "WARNING"}): logger = _get_logger("supervision.test_env_warning") assert logger.level == logging.WARNING - def test_two_handlers_configured(self): + def test_two_handlers_configured(self) -> None: """Logger has exactly two handlers: one for stdout, one for stderr.""" logger = _get_logger("supervision.test_handlers") assert len(logger.handlers) == 2 - def test_stdout_handler_present(self): + def test_stdout_handler_present(self) -> None: """Logger has a StreamHandler pointing to stdout.""" logger = _get_logger("supervision.test_stdout") stdout_handlers = [ @@ -57,7 +55,7 @@ class TestGetLogger: ] assert len(stdout_handlers) == 1 - def test_stderr_handler_present(self): + def test_stderr_handler_present(self) -> None: """Logger has a StreamHandler pointing to stderr for warnings.""" logger = _get_logger("supervision.test_stderr") stderr_handlers = [ @@ -67,12 +65,12 @@ class TestGetLogger: ] assert len(stderr_handlers) == 1 - def test_no_propagation(self): + def test_no_propagation(self) -> None: """Logger does not propagate to the root logger.""" logger = _get_logger("supervision.test_propagation") assert not logger.propagate - def test_idempotent_no_duplicate_handlers(self): + def test_idempotent_no_duplicate_handlers(self) -> None: """Calling _get_logger twice with the same name does not add extra handlers.""" name = "supervision.test_idempotent" logger1 = _get_logger(name) diff --git a/tests/utils/test_video.py b/tests/utils/test_video.py index 90be24b9..2310ec80 100644 --- a/tests/utils/test_video.py +++ b/tests/utils/test_video.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import os import shutil from unittest.mock import MagicMock, patch @@ -28,7 +26,7 @@ def dummy_video_path(tmp_path): return path -def test_process_video_exception_handling(dummy_video_path, tmp_path): +def test_process_video_exception_handling(dummy_video_path, tmp_path) -> None: """ Verify that process_video correctly propagates exceptions from the callback. @@ -51,7 +49,7 @@ def test_process_video_exception_handling(dummy_video_path, tmp_path): ) -def test_process_video_success(dummy_video_path, tmp_path): +def test_process_video_success(dummy_video_path, tmp_path) -> None: """ Verify successful video processing with a pass-through callback. @@ -72,7 +70,7 @@ def test_process_video_success(dummy_video_path, tmp_path): assert os.path.exists(target_path) -def test_process_video_exception_with_small_buffer(dummy_video_path, tmp_path): +def test_process_video_exception_with_small_buffer(dummy_video_path, tmp_path) -> None: """ Verify that process_video handles exceptions correctly even with small buffers. @@ -96,7 +94,7 @@ def test_process_video_exception_with_small_buffer(dummy_video_path, tmp_path): ) -def test_process_video_max_frames(dummy_video_path, tmp_path): +def test_process_video_max_frames(dummy_video_path, tmp_path) -> None: """ Verify that process_video respects the max_frames parameter. @@ -122,7 +120,7 @@ def test_process_video_max_frames(dummy_video_path, tmp_path): assert processed_indices == [0, 1, 2, 3, 4] -def test_process_video_custom_params(dummy_video_path, tmp_path): +def test_process_video_custom_params(dummy_video_path, tmp_path) -> None: """ Verify that process_video works correctly with custom performance parameters. @@ -147,7 +145,7 @@ def test_process_video_custom_params(dummy_video_path, tmp_path): assert os.path.exists(target_path) -def test_video_info(dummy_video_path): +def test_video_info(dummy_video_path) -> None: """ Verify that VideoInfo correctly retrieves metadata from a video file. @@ -164,7 +162,7 @@ def test_video_info(dummy_video_path): assert video_info.resolution_wh == (640, 480) -def test_video_info_float_fps(dummy_video_path, monkeypatch): +def test_video_info_float_fps(dummy_video_path, monkeypatch) -> None: """ Verify that VideoInfo preserves non-integer FPS values as floats. @@ -187,7 +185,7 @@ def test_video_info_float_fps(dummy_video_path, monkeypatch): assert video_info.fps != int(video_info.fps) -def test_get_video_frames_generator(dummy_video_path): +def test_get_video_frames_generator(dummy_video_path) -> None: """ Verify that get_video_frames_generator yields frames with correct shapes. @@ -202,7 +200,7 @@ def test_get_video_frames_generator(dummy_video_path): assert all(frame.shape == (480, 640, 3) for frame in frames) -def test_get_video_frames_generator_with_stride(dummy_video_path): +def test_get_video_frames_generator_with_stride(dummy_video_path) -> None: """ Verify that get_video_frames_generator correctly handles the stride parameter. @@ -215,7 +213,7 @@ def test_get_video_frames_generator_with_stride(dummy_video_path): assert len(frames) == 5 -def test_process_video_preserve_audio_calls_mux(dummy_video_path, tmp_path): +def test_process_video_preserve_audio_calls_mux(dummy_video_path, tmp_path) -> None: """ Verify that process_video calls _mux_audio when preserve_audio=True. @@ -237,7 +235,7 @@ def test_process_video_preserve_audio_calls_mux(dummy_video_path, tmp_path): ) -def test_process_video_no_audio_by_default(dummy_video_path, tmp_path): +def test_process_video_no_audio_by_default(dummy_video_path, tmp_path) -> None: """ Verify that process_video does not call _mux_audio when preserve_audio=False. @@ -274,7 +272,7 @@ def test_process_video_no_audio_by_default(dummy_video_path, tmp_path): ) def test_mux_audio_file_unchanged_on_failure( dummy_video_path, tmp_path, which_rv, run_kwargs -): +) -> None: """_mux_audio leaves the output file unchanged when muxing cannot complete.""" target_path = str(tmp_path / "video.mp4") shutil.copy(dummy_video_path, target_path) @@ -289,7 +287,7 @@ def test_mux_audio_file_unchanged_on_failure( assert os.path.getsize(target_path) == original_size -def test_mux_audio_replaces_file_on_success(dummy_video_path, tmp_path): +def test_mux_audio_replaces_file_on_success(dummy_video_path, tmp_path) -> None: """_mux_audio calls os.replace with video_path as destination on success.""" target_path = str(tmp_path / "video.mp4") shutil.copy(dummy_video_path, target_path) @@ -309,7 +307,7 @@ def test_mux_audio_replaces_file_on_success(dummy_video_path, tmp_path): assert mock_replace.call_args[0][1] == target_path -def test_get_video_frames_generator_with_start_end(dummy_video_path): +def test_get_video_frames_generator_with_start_end(dummy_video_path) -> None: """ Verify that get_video_frames_generator respects start and end frame indices. diff --git a/tox.ini b/tox.ini index 3f44d215..d25fdc59 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38,py39,py310,py311,py312,py313 +envlist = py310,py311,py312,py313,py314 [testenv] changedir = test