Compare commits

..
5 Commits
Author SHA1 Message Date
jirka 8caacebb96 releasing 0.29.0.post0 2026-06-17 16:24:43 +02:00
09d9808179 fix: accept deprecated confidence kwarg in KeyPoints() constructor (#2335)
- Move conflict ValueError before warn_deprecated so stray warning not
  emitted on invalid calls (both kwargs passed)
- Add backtick-quoted version numbers to deprecation message for style
  consistency with other warn_deprecated calls in the same class
- Make ValueError message actionable: name deprecated param and remedy
- Add Google-style docstring to __init__: Args + Raises sections
- Fix pre-existing mypy error in detection/core.py (_merge_obb_corners
  had bare np.ndarray without type args)
- Add docstrings to all test methods in TestDeprecatedConfidenceConstructor
- Parametrize ValueError test with both kwarg orderings (confidence-first
  and keypoint-confidence-first) to document order-independence
- Add test_constructor_normal_keypoint_confidence_path: confirms custom
  __init__ initialises all fields and emits no warning on normal path
- Add test_constructor_confidence_none_does_not_warn: guards the
  `if confidence is not None` branch against guard-condition typos
- Add test_constructor_data_none_defaults_to_empty_dict: tests the
  None->{} normalisation path introduced by the custom __init__
- Add test_keypoints_init_covers_all_dataclass_fields: drift guard that
  asserts dataclasses.fields(KeyPoints) == __init__ params, catching
  future field additions that forget to update __init__

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 16:24:43 +02:00
Jirka Borovec b44810b03c docs(changelog): backfill missing 0.29.0 entries and fix duplicate (#2329) 2026-06-17 16:24:43 +02:00
Jirka Borovec c580730a08 docs: fix broken links in changelog file (#2328) 2026-06-17 16:24:43 +02:00
dependabot[bot] ea3b51feb4 ⬆️ Bump the uv group across 1 directory with 2 updates (#2326)
Bumps the uv group with 2 updates in the / directory: [cryptography](https://github.com/pyca/cryptography) and [tornado](https://github.com/tornadoweb/tornado).


Updates `cryptography` from 46.0.7 to 48.0.1
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.7...48.0.1)

Updates `tornado` from 6.5.6 to 6.5.7
- [Changelog](https://github.com/tornadoweb/tornado/blob/master/docs/releases.rst)
- [Commits](https://github.com/tornadoweb/tornado/compare/v6.5.6...v6.5.7)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 48.0.1
  dependency-type: indirect
- dependency-name: tornado
  dependency-version: 6.5.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-17 16:24:43 +02:00
37 changed files with 224 additions and 2401 deletions
-1
View File
@@ -19,7 +19,6 @@ exclude = [
"http://127.0.0.1:8000", # hint for local docs server
"https://sam2.metademolab.com/", # returns 403 Forbidden
"https://snyk.io/advisor/python/supervision/badge.svg", # badge URL
"https://trendshift.io", # badge API times out in CI
"https://universe.roboflow.com/",
"https://universe.roboflow.com/model-examples/segmented-animals-basic",
# fixme: this page returns 401 Unauthorized when accessed and 404 Not Found when accessed with browser,
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🐍 Install uv and set Python version ${{ inputs.python-version }}
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
timeout-minutes: 10
steps:
- name: 📥 Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🐍 Install uv and set Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
uses: actions/checkout@v6
- name: 🔗 Link Checker
uses: lycheeverse/lychee-action@v2
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: 📥 Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: 🐍 Install uv and set Python version ${{ matrix.python-version }}
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
+1 -2
View File
@@ -32,7 +32,7 @@ jobs:
timeout-minutes: 10
steps:
- name: 📥 Checkout the repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
@@ -62,7 +62,6 @@ jobs:
env:
MKDOCS_GIT_COMMITTERS_APIKEY: ${{ secrets.GITHUB_TOKEN }}
run: |
if mike list | grep -Eq '^latest(\s|$)'; then mike delete latest; fi
mike deploy --push latest
- name: 🏷️ Determine release deployment metadata
+19 -33
View File
@@ -1,6 +1,6 @@
<div align="center">
<p>
<a align="center" href="https://supervision.roboflow.com" target="_blank">
<a align="center" href="" target="https://supervision.roboflow.com">
<img
width="100%"
src="https://media.roboflow.com/open-source/supervision/rf-supervision-banner.png?updatedAt=1678995927529"
@@ -24,27 +24,11 @@
</div>
<details>
<summary><strong>📑 Table of Contents</strong></summary>
- [👋 Hello](#-hello)
- [💻 Install](#-install)
- [🔥 Quickstart](#-quickstart)
- [Models](#models)
- [Annotators](#annotators)
- [Datasets](#datasets)
- [🎬 Tutorials](#-tutorials)
- [💜 Built with Supervision](#-built-with-supervision)
- [📚 Documentation](#-documentation)
- [🏆 Contribution](#-contribution)
</details>
## 👋 Hello
## 👋 hello
**We are your essential toolkit for computer vision.** From data loading to real-time zone counting, we provide the building blocks so you can focus on building applications around your models. 🤝
## 💻 Install
## 💻 install
Pip install the supervision package in a [**Python>=3.9**](https://www.python.org/) environment.
@@ -54,9 +38,9 @@ pip install supervision
Read more about conda, mamba, and installing from source in our [guide](https://roboflow.github.io/supervision/).
## 🔥 Quickstart
## 🔥 quickstart
### Models
### models
Supervision was designed to be model agnostic. Just plug in any classification, detection, or segmentation model. For your convenience, we have created [connectors](https://supervision.roboflow.com/latest/detection/core/#detections) for the most popular libraries like Ultralytics, Transformers, MMDetection, or Inference. Other integrations, like `rfdetr`, already return `sv.Detections` directly.
@@ -67,7 +51,7 @@ import supervision as sv
from PIL import Image
from rfdetr import RFDETRSmall
image = Image.open("path/to/image.jpg")
image = Image.open(...)
model = RFDETRSmall()
detections = model.predict(image, threshold=0.5)
@@ -87,7 +71,7 @@ len(detections)
from PIL import Image
from inference import get_model
image = Image.open("path/to/image.jpg")
image = Image.open(...)
model = get_model(model_id="rfdetr-small", api_key="ROBOFLOW_API_KEY")
result = model.infer(image)[0]
detections = sv.Detections.from_inference(result)
@@ -98,7 +82,7 @@ len(detections)
</details>
### Annotators
### annotators
Supervision offers a wide range of highly customizable [annotators](https://supervision.roboflow.com/latest/detection/annotators/), allowing you to compose the perfect visualization for your use case.
@@ -106,8 +90,7 @@ Supervision offers a wide range of highly customizable [annotators](https://supe
import cv2
import supervision as sv
image = cv2.imread("path/to/image.jpg")
# Assuming detections are obtained from a model
image = cv2.imread(...)
detections = sv.Detections(...)
box_annotator = sv.BoxAnnotator()
@@ -116,7 +99,7 @@ annotated_frame = box_annotator.annotate(scene=image.copy(), detections=detectio
https://github.com/roboflow/supervision/assets/26109316/691e219c-0565-4403-9218-ab5644f39bce
### Datasets
### datasets
Supervision provides a set of [utils](https://supervision.roboflow.com/latest/datasets/core/) that allow you to load, split, merge, and save datasets in one of the supported formats.
@@ -140,7 +123,7 @@ for path, image, annotation in ds:
pass
```
<details>
<details close>
<summary>👉 more dataset utils</summary>
- load
@@ -230,7 +213,7 @@ for path, image, annotation in ds:
</details>
## 🎬 Tutorials
## 🎬 tutorials
Want to learn how to use Supervision? Explore our [how-to guides](https://supervision.roboflow.com/develop/how_to/detect_and_annotate/), [end-to-end examples](./examples), [cheatsheet](https://roboflow.github.io/cheatsheet-supervision/), and [cookbooks](https://supervision.roboflow.com/develop/cookbooks/)!
@@ -250,7 +233,7 @@ Want to learn how to use Supervision? Explore our [how-to guides](https://superv
<div><strong>Created: 11 Jan 2024</strong></div>
<br/>Learn how to track and estimate the speed of vehicles using YOLO, ByteTrack, and Roboflow Inference. This comprehensive tutorial covers object detection, multi-object tracking, filtering detections, perspective transformation, speed estimation, visualization improvements, and more.</p>
## 💜 Built with Supervision
## 💜 built with supervision
Did you build something cool using supervision? [Let us know!](https://github.com/roboflow/supervision/discussions/categories/built-with-supervision)
@@ -260,11 +243,11 @@ https://github.com/roboflow/supervision/assets/26109316/c9436828-9fbf-4c25-ae8c-
https://github.com/roboflow/supervision/assets/26109316/3ac6982f-4943-4108-9b7f-51787ef1a69f
## 📚 Documentation
## 📚 documentation
Visit our [documentation](https://roboflow.github.io/supervision) page to learn how supervision can help you build computer vision applications faster and more reliably.
## 🏆 Contribution
## 🏆 contribution
We love your input! Please see our [contributing guide](.github/CONTRIBUTING.md) to get started. Thank you 🙏 to all our contributors!
@@ -276,6 +259,8 @@ We love your input! Please see our [contributing guide](.github/CONTRIBUTING.md)
<br>
<div align="center">
<div align="center">
<a href="https://youtube.com/roboflow">
<img
@@ -310,7 +295,6 @@ We love your input! Please see our [contributing guide](.github/CONTRIBUTING.md)
src="https://media.roboflow.com/notebooks/template/icons/purple/forum.png?ik-sdk-version=javascript-1.4.3&updatedAt=1672949633584"
width="3%"
/>
</a>
<img src="https://raw.githubusercontent.com/ultralytics/assets/main/social/logo-transparent.png" width="3%"/>
<a href="https://blog.roboflow.com">
<img
@@ -318,4 +302,6 @@ We love your input! Please see our [contributing guide](.github/CONTRIBUTING.md)
width="3%"
/>
</a>
</a>
</div>
</div>
+4 -27
View File
@@ -1,33 +1,12 @@
---
description: "Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version."
date_modified: 2026-06-16
date_modified: 2026-06-15
---
# Changelog
### 0.29.1 <small>Jun 23, 2026</small>
### UnReleased
- Added [#2338](https://github.com/roboflow/supervision/pull/2338): [`sv.KeyPoints.with_nms`](https://supervision.roboflow.com/latest/keypoint/core/#supervision.key_points.core.KeyPoints.with_nms) — non-maximum suppression for keypoint detections. Derives axis-aligned bounding boxes from valid (non-zero and visible) keypoints and applies `box_non_max_suppression`. Requires `detection_confidence`; supports class-aware and class-agnostic modes via `threshold`, `class_agnostic`, and `overlap_metric`.
- Fixed [#2342](https://github.com/roboflow/supervision/pull/2342): `sv.Detections.from_vlm` with `sv.VLM.GOOGLE_GEMINI_2_0`, `sv.VLM.GOOGLE_GEMINI_2_5`, and `sv.VLM.QWEN_2_5_VL` no longer raises when the model returns valid JSON of the wrong shape (non-list top-level or non-dict elements). A non-string or malformed `"mask"` value in Gemini 2.5 output no longer triggers `AttributeError`; invalid base64 or non-PNG mask data falls back to an empty mask, keeping `xyxy`, `confidence`, and `masks` arrays aligned.
- Fixed [#2341](https://github.com/roboflow/supervision/pull/2341): `sv.DetectionDataset.as_pascal_voc` no longer mutates the source `Detections.xyxy` by the 1-index offset on every call. Previously, repeated exports accumulated a `+1` shift in the caller's bounding boxes.
- Fixed [#2334](https://github.com/roboflow/supervision/pull/2334): `sv.JSONSink` now serializes NumPy scalars (e.g. `np.int64` frame indices) in `custom_data` as JSON numbers instead of raising `TypeError` at close time. File handle is now guaranteed to close even when serialization fails.
- Fixed [#2333](https://github.com/roboflow/supervision/pull/2333): [`sv.DetectionsSmoother`](https://supervision.roboflow.com/latest/detection/tools/smoother/#supervision.detection.tools.smoother.DetectionsSmoother) no longer raises when smoothing detections without `confidence`. Confidence is now averaged over the frames that carry it; when tracks in the same frame disagree on confidence presence, `confidence` is set to `None` for all smoothed detections.
- Fixed [#2332](https://github.com/roboflow/supervision/pull/2332): `sv.approximate_polygon` now returns a polygon within the requested point-count budget (at most `floor(N * (1 - percentage))` points, minimum 3). The function now also validates that `epsilon_step > 0`.
- Fixed [#2331](https://github.com/roboflow/supervision/pull/2331): `sv.Precision` and `sv.F1Score` now count predictions on background images (empty target set) as false positives, and count predictions of classes absent from ground truth as false positives under `MICRO` and `MACRO` averaging. Previously both edge cases were silently ignored, inflating scores. `WEIGHTED` averaging is unchanged — absent classes retain weight 0, consistent with scikit-learn. Users relying on previous scores should re-evaluate after upgrading; no API change is required.
- Fixed [#2322](https://github.com/roboflow/supervision/pull/2322): COCO export now preserves all polygon parts for multi-component masks. Previously, only the first polygon was written when a non-crowd mask had disjoint segments; all parts are now included.
- Performance [#2339](https://github.com/roboflow/supervision/pull/2339): `sv.HaloAnnotator` now uses the same CompactMask painting path as `sv.MaskAnnotator` via a shared `_paint_masks_by_area` helper. On a 1080p frame with 30 CompactMask detections, `HaloAnnotator` runs approximately 4× faster; annotated output is unchanged.
- Performance [#2330](https://github.com/roboflow/supervision/pull/2330): `sv.mask_to_xyxy` and `sv.KeyPoints.as_detections` are now vectorized. `mask_to_xyxy` uses batched occupancy-profile reductions instead of per-mask pixel scans; `KeyPoints.as_detections` computes all bounding boxes in a single batch operation. Both produce bit-identical results.
- Performance [#2323](https://github.com/roboflow/supervision/pull/2323): Mask IoU computation now uses matrix multiplication on flattened masks instead of an explicit `(N, M, H, W)` intersection tensor, reducing peak memory for large mask sets. For masks larger than 4096×4096 pixels, computation automatically promotes to float64 to preserve exact pixel counts. Results are numerically identical.
### 0.29.0.post0 <small>Jun 17, 2026</small>
@@ -35,10 +14,6 @@ date_modified: 2026-06-16
### 0.29.0 <small>Jun 15, 2026</small>
- Added [#2314](https://github.com/roboflow/supervision/pull/2314): new cookbook **Oriented Bounding Boxes** showing how an oriented box differs from an axis-aligned one on a marina of boats: DOTA-pretrained detection, the effect on [`with_nms`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nms) and [`Detections.area`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.area), and YOLO OBB dataset export.
- Fixed [#2306](https://github.com/roboflow/supervision/pull/2306): [`sv.Detections.area`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.area) now returns the rotated body's area for detections carrying `data["xyxyxyxy"]` (oriented box corners) instead of the area of the derived axis-aligned bounding box, which overestimates by up to ~2x at 45° rotation. Affects annotator z-ordering inside [`MaskAnnotator`](https://supervision.roboflow.com/0.29.0/detection/annotators/#supervision.annotators.core.MaskAnnotator) and [`HaloAnnotator`](https://supervision.roboflow.com/0.29.0/detection/annotators/#supervision.annotators.core.HaloAnnotator), and any user code that filters or sorts OBB detections by area. The mask path and the non-OBB AABB fallback are unchanged.
- Added [#2277](https://github.com/roboflow/supervision/pull/2277), [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.VertexEllipseAreaAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseAreaAnnotator), [`sv.VertexEllipseOutlineAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseOutlineAnnotator), and [`sv.VertexEllipseHaloAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseHaloAnnotator) for visualizing keypoint uncertainty as covariance ellipses. Requires models that output keypoint uncertainty (e.g. RF-DETR keypoint models).
- Added [#2303](https://github.com/roboflow/supervision/pull/2303): [`sv.oriented_box_non_max_suppression`](https://supervision.roboflow.com/0.29.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.oriented_box_non_max_suppression) and [`sv.oriented_box_non_max_merge`](https://supervision.roboflow.com/0.29.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.oriented_box_non_max_merge) for performing NMS and NMM directly on oriented bounding boxes using oriented-box IoU instead of axis-aligned IoU.
@@ -63,6 +38,8 @@ date_modified: 2026-06-16
- Changed [#2325](https://github.com/roboflow/supervision/pull/2325): [`sv.VertexEllipseAreaAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseAreaAnnotator), [`sv.VertexEllipseOutlineAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseOutlineAnnotator), and [`sv.VertexEllipseHaloAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseHaloAnnotator) now draw sigma levels level-by-level (outermost first) across all points, ensuring correct visual layering when ellipses overlap.
- Changed [#2306](https://github.com/roboflow/supervision/pull/2306): [`sv.Detections.area`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.area) now returns the polygon area of the rotated bounding box (via the shoelace formula) when oriented box coordinates are present, instead of the axis-aligned box area.
- Changed [#2256](https://github.com/roboflow/supervision/pull/2256): [`sv.InferenceSlicer`](https://supervision.roboflow.com/0.29.0/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer) now detects OBB outputs from callbacks and automatically falls back to sequential processing to avoid thread-safety issues when `thread_workers > 1`.
- Changed [#2324](https://github.com/roboflow/supervision/pull/2324): Project-wide deprecation policy unified to a minimum 3-minor-release window. All current deprecations (including `KeyPoints.confidence` and `validate_*` helpers) are scheduled for removal in `0.32.0`.
File diff suppressed because one or more lines are too long
-4
View File
@@ -62,10 +62,6 @@
<p class="card repo-card" data-name="Memory-Efficient Instance Segmentation"
data-labels="COMPACT MASK,SAM3,SEGMENTATION" data-version="v0.28.0" data-author="Borda"></p>
</a>
<a href="../notebooks/oriented-bounding-boxes/">
<p class="card repo-card" data-name="Oriented Bounding Boxes for Densely Packed Objects"
data-labels="OBB,DETECTIONS,NMS,DATASET" data-version="v0.29.0" data-author="kounelisagis"></p>
</a>
</div>
</div>
</section>
+1 -1
View File
@@ -4,7 +4,7 @@ requires = [ "setuptools>=61" ]
[project]
name = "supervision"
version = "0.29.1"
version = "0.29.0.post0"
description = "A set of easy-to-use utils that will come in handy in any Computer Vision project"
readme = "README.md"
keywords = [
+46 -78
View File
@@ -361,65 +361,6 @@ class OrientedBoxAnnotator(BaseAnnotator):
return scene
# --- Shared mask-painting utilities ---
def _paint_masks_by_area(
canvas: npt.NDArray[np.uint8],
detections: Detections,
color: Color | ColorPalette,
color_lookup: ColorLookup | npt.NDArray[np.int_],
collect_union: bool = False,
) -> npt.NDArray[np.bool_] | None:
"""Paint each detection's mask into `canvas` in descending-area order.
Smaller masks are drawn on top of larger ones. `CompactMask` detections
are painted into their bounding-box crop only, avoiding a full `(H, W)`
allocation per mask; dense masks fall back to full-frame boolean indexing.
Args:
canvas: BGR image array painted in place. Shape ``(H, W, 3)``.
detections: Detections whose masks to paint. Returns immediately
without modifying `canvas` when ``detections.mask`` is ``None``.
color: Single color or palette used to resolve each detection's color.
color_lookup: Strategy for mapping colors to detection indices.
collect_union: When ``True``, allocate and return a ``(H, W)``
boolean array that accumulates the union of all painted masks
(useful for callers like `HaloAnnotator` that need the combined
mask footprint). When ``False`` (default), returns ``None``.
Returns:
A ``(H, W)`` boolean union array when ``collect_union=True``,
otherwise ``None``.
"""
masks = detections.mask
if masks is None:
return None
union: npt.NDArray[np.bool_] | None = (
np.zeros(canvas.shape[:2], dtype=bool) if collect_union else None
)
compact_mask = masks if isinstance(masks, CompactMask) else None
for detection_idx in np.flip(np.argsort(detections.area)):
color_bgr = resolve_color(
color=color,
detections=detections,
detection_idx=detection_idx,
color_lookup=color_lookup,
).as_bgr()
if compact_mask is not None:
x1 = int(compact_mask.offsets[detection_idx, 0])
y1 = int(compact_mask.offsets[detection_idx, 1])
crop_m = compact_mask.crop(detection_idx)
crop_h, crop_w = crop_m.shape
canvas[y1 : y1 + crop_h, x1 : x1 + crop_w][crop_m] = color_bgr
if union is not None:
union[y1 : y1 + crop_h, x1 : x1 + crop_w] |= crop_m
else:
mask = np.asarray(masks[detection_idx], dtype=bool)
canvas[mask] = color_bgr
if union is not None:
union |= mask
return union
class MaskAnnotator(BaseAnnotator):
"""
A class for drawing masks on an image using provided detections.
@@ -496,12 +437,35 @@ class MaskAnnotator(BaseAnnotator):
return scene
colored_mask = np.array(scene, copy=True, dtype=np.uint8)
_paint_masks_by_area(
colored_mask,
detections,
self.color,
self.color_lookup if custom_color_lookup is None else custom_color_lookup,
compact_mask = (
detections.mask if isinstance(detections.mask, CompactMask) else None
)
for detection_idx in np.flip(np.argsort(detections.area)):
color = resolve_color(
color=self.color,
detections=detections,
detection_idx=detection_idx,
color_lookup=self.color_lookup
if custom_color_lookup is None
else custom_color_lookup,
)
if compact_mask is not None:
# Paint only the bounding-box crop — avoids a full (H, W) alloc.
x1 = int(compact_mask.offsets[detection_idx, 0])
y1 = int(compact_mask.offsets[detection_idx, 1])
crop_m = compact_mask.crop(detection_idx)
crop_h, crop_w = crop_m.shape
colored_mask[y1 : y1 + crop_h, x1 : x1 + crop_w][crop_m] = (
color.as_bgr()
)
else:
mask = np.asarray(
detections.mask[detection_idx],
dtype=bool,
)
colored_mask[mask] = color.as_bgr()
cv2.addWeighted(
colored_mask, self.opacity, scene, 1 - self.opacity, 0, dst=scene
)
@@ -737,7 +701,7 @@ class HaloAnnotator(BaseAnnotator):
Annotates the given scene with halos based on the provided detections.
Args:
scene: The image where the halo effect will be applied.
scene: The image where masks will be drawn.
`ImageType` is a flexible type, accepting either `numpy.ndarray`
or `PIL.Image.Image`.
detections: Object detections to annotate.
@@ -755,7 +719,6 @@ class HaloAnnotator(BaseAnnotator):
>>> image = np.zeros((100, 100, 3), dtype=np.uint8)
>>> detections = sv.Detections(
... xyxy=np.array([[20, 20, 80, 80]]),
... mask=np.zeros((1, 100, 100), dtype=bool),
... class_id=np.array([0])
... )
>>> halo_annotator = sv.HaloAnnotator()
@@ -774,23 +737,28 @@ class HaloAnnotator(BaseAnnotator):
if detections.mask is None:
return scene
colored_mask = np.zeros_like(scene, dtype=np.uint8)
fmask = _paint_masks_by_area(
colored_mask,
detections,
self.color,
self.color_lookup if custom_color_lookup is None else custom_color_lookup,
collect_union=True,
fmask = np.array([False] * scene.shape[0] * scene.shape[1]).reshape(
scene.shape[0], scene.shape[1]
)
assert fmask is not None # collect_union=True always returns an array
for detection_idx in np.flip(np.argsort(detections.area)):
color = resolve_color(
color=self.color,
detections=detections,
detection_idx=detection_idx,
color_lookup=self.color_lookup
if custom_color_lookup is None
else custom_color_lookup,
)
mask = np.asarray(detections.mask[detection_idx], dtype=bool)
fmask = np.logical_or(fmask, mask)
color_bgr = color.as_bgr()
colored_mask[mask] = color_bgr
colored_mask = cv2.blur(colored_mask, (self.kernel_size, self.kernel_size))
colored_mask[fmask] = [0, 0, 0]
gray = cv2.cvtColor(colored_mask, cv2.COLOR_BGR2GRAY)
gray_max = gray.max()
if gray_max == 0:
# no halo to draw (e.g. empty masks); leave the scene untouched
return scene
alpha = self.opacity * gray / gray_max
alpha = self.opacity * gray / gray.max()
alpha_mask = alpha[:, :, np.newaxis]
blended_scene = np.uint8(scene * (1 - alpha_mask) + colored_mask * self.opacity)
np.copyto(scene, blended_scene)
-12
View File
@@ -247,18 +247,6 @@ def detections_to_coco_annotations(
Raises:
ValueError: If any detection has ``class_id`` equal to ``None``.
Note:
For ``iscrowd=0`` annotations, ``segmentation`` is a
``list[list[float]]`` where each inner list encodes one polygon
part as flat ``[x1, y1, x2, y2, ...]`` coordinates. A single
object with *N* disjoint parts produces *N* inner lists.
When ``iscrowd`` is not in ``detections.data``, masks with holes
or multiple disjoint segments are auto-encoded as RLE
(``iscrowd=1``); simple single-region masks use polygon format
(``iscrowd=0``). Supply ``data={"iscrowd": np.array([0])}`` to
force polygon output regardless of mask topology.
Examples:
```python
import numpy as np
+4 -37
View File
@@ -21,41 +21,13 @@ def object_to_pascal_voc(
name: str,
polygon: npt.NDArray[np.number] | None = None,
) -> Element:
"""Build a Pascal VOC ``<object>`` XML element for one detection.
Coordinates are converted to 1-indexed Pascal VOC convention before writing.
The input arrays are never mutated; new arrays are allocated for the offset.
Args:
xyxy: Bounding box in zero-indexed pixel coordinates ``[x1, y1, x2, y2]``.
Shape ``(4,)``.
name: Class label string written to the ``<name>`` child element.
polygon: Optional segmentation polygon in zero-indexed pixel coordinates.
Shape ``(N, 2)``.
Returns:
An XML ``Element`` rooted at ``<object>`` containing ``<name>``,
``<bndbox>``, and optionally ``<polygon>`` children.
Examples:
>>> import numpy as np
>>> from supervision.dataset.formats.pascal_voc import object_to_pascal_voc
>>> elem = object_to_pascal_voc(np.array([0, 0, 9, 9]), name="cat")
>>> elem.find("bndbox/xmin").text
'1'
>>> elem.find("bndbox/xmax").text
'10'
"""
root = Element("object")
object_name = SubElement(root, "name")
object_name.text = name
# Pascal VOC coordinates are 1-indexed (https://github.com/roboflow/supervision/issues/144).
# Rebind to a new array instead of `+= 1`: `xyxy` is a view into the source
# `Detections.xyxy` (yielded by `Detections.__iter__`), so an in-place add
# would corrupt the caller's detections by +1 on every export.
xyxy = xyxy + 1
# https://github.com/roboflow/supervision/issues/144
xyxy += 1
bndbox = SubElement(root, "bndbox")
xmin = SubElement(bndbox, "xmin")
@@ -68,8 +40,8 @@ def object_to_pascal_voc(
ymax.text = str(int(xyxy[3]))
if polygon is not None:
# 1-indexed, rebound to avoid mutating the caller's array (see above).
polygon = polygon + 1
# https://github.com/roboflow/supervision/issues/144
polygon += 1
object_polygon = SubElement(root, "polygon")
for index, point in enumerate(polygon, start=1):
x_coordinate, y_coordinate = point
@@ -109,11 +81,6 @@ def detections_to_pascal_voc(
polygon points to be removed from the input polygon, in the range [0, 1).
Returns:
An XML string in Pascal VOC format representing the detections.
Note:
``detections`` is never mutated by this function; the source ``xyxy``
array is unchanged after the call. The function is therefore safe to
call multiple times on the same ``Detections`` object.
"""
height, width, depth = image_shape
+2 -33
View File
@@ -24,8 +24,6 @@ class JSONSink:
When a list or tuple value in custom_data (or detections.data) has the
same length as the detection count, each element is written to the
corresponding detection row; any other value is broadcast to all rows.
NumPy scalars (e.g. ``np.int64``, ``np.float32``) are serialized as
JSON numbers; NumPy arrays are serialized as JSON arrays.
Args:
file_name: The name of the JSON file where the detections will be stored.
@@ -81,42 +79,13 @@ class JSONSink:
self.file = open(self.file_name, "w")
@staticmethod
def _json_default(value: Any) -> Any:
"""Return a JSON-serializable equivalent of a NumPy scalar or array.
Called as the ``default`` hook by :func:`json.dump`. Converts
:class:`numpy.generic` scalars via ``.item()`` and
:class:`numpy.ndarray` instances via ``.tolist()``.
Args:
value: Object the standard JSON encoder could not serialize.
Returns:
A Python scalar or nested list equivalent of ``value``.
Raises:
TypeError: If ``value`` is neither a NumPy scalar nor an ndarray.
"""
if isinstance(value, np.generic):
return value.item()
if isinstance(value, np.ndarray):
return value.tolist()
raise TypeError(
f"Object of type {type(value).__name__} is not JSON serializable"
)
def write_and_close(self) -> None:
"""
Write and close the JSON file.
"""
if self.file:
try:
json.dump(
self.data, self.file, indent=4, default=JSONSink._json_default
)
finally:
self.file.close()
json.dump(self.data, self.file, indent=4)
self.file.close()
@staticmethod
def _slice_value(value: Any, i: int, n: int) -> Any:
+1 -29
View File
@@ -29,9 +29,6 @@ class DetectionsSmoother:
[Roboflow Trackers](/latest/trackers/) for
information on integrating tracking into your inference pipeline.
- This class is not compatible with segmentation models.
- When detections in a frame disagree on confidence presence — some tracks
carry confidence scores and others do not — `confidence` is set to `None`
for all smoothed detections in that frame.
Example:
```pycon
@@ -131,20 +128,6 @@ class DetectionsSmoother:
return self.get_smoothed_detections()
def get_track(self, track_id: int) -> Detections | None:
"""Return the smoothed `Detections` for a single track.
Averages `xyxy` over all valid (non-`None`) frames in the track window.
`confidence` is averaged only over frames that carry it; frames with
`confidence=None` are excluded. Returns `None` when the track is unknown
or its entire window is empty.
Args:
track_id: The tracker ID whose smoothed detection to retrieve.
Returns:
Smoothed `Detections` for the track, or `None` if the track is
unknown or all frames in its window are empty.
"""
track = self.tracks.get(track_id, None)
if track is None:
return None
@@ -155,11 +138,7 @@ class DetectionsSmoother:
ret = deepcopy(valid[0])
ret.xyxy = np.mean([d.xyxy for d in valid], axis=0)
# Average confidence only over frames that carry it; frames with
# confidence=None contribute nothing to the mean. Retain None when
# no frame in the window carries confidence.
confidences = [d.confidence for d in valid if d.confidence is not None]
ret.confidence = np.mean(np.array(confidences), axis=0) if confidences else None
ret.confidence = np.mean([d.confidence for d in valid], axis=0)
return ret
@@ -170,13 +149,6 @@ class DetectionsSmoother:
if track is not None:
tracked_detections.append(track)
# Detections.merge requires all-or-none for optional fields.
# When tracks disagree on confidence presence, drop it from all to
# prevent ValueError inside Detections.merge (stack_or_none invariant).
if tracked_detections and any(d.confidence is None for d in tracked_detections):
for d in tracked_detections:
d.confidence = None
detections = Detections.merge(tracked_detections)
if len(detections) == 0:
detections.tracker_id = np.array([], dtype=int)
+9 -30
View File
@@ -204,44 +204,23 @@ def mask_to_xyxy(masks: npt.NDArray[np.bool_]) -> npt.NDArray[np.int_]:
Converts a 3D `np.array` of 2D bool masks into a 2D `np.array` of bounding boxes.
Args:
masks: A 3D `np.array` of shape `(N, H, W)` containing 2D bool masks.
masks: A 3D `np.array` of shape `(N, W, H)` containing 2D bool masks.
Returns:
A 2D `np.array` of shape `(N, 4)` containing the bounding boxes
`(x_min, y_min, x_max, y_max)` for each mask.
Examples:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> masks = np.array([
... [[False, False, False],
... [False, True, True],
... [False, True, True]],
... ])
>>> sv.mask_to_xyxy(masks)
array([[1, 1, 2, 2]])
```
"""
n, height, width = masks.shape
if masks.size == 0:
return np.zeros((n, 4), dtype=int)
n = masks.shape[0]
xyxy = np.zeros((n, 4), dtype=int)
# Reduce the mask stack to per-row / per-column occupancy, then read the
# tight bounds straight off those 1D profiles instead of scanning every
# pixel of every mask with `np.where`.
rows_any = cast(npt.NDArray[np.bool_], masks.any(axis=2)) # (N, H)
cols_any = cast(npt.NDArray[np.bool_], masks.any(axis=1)) # (N, W)
for i, mask in enumerate(masks):
rows, cols = np.where(mask)
x_min = cols_any.argmax(axis=1)
x_max = width - 1 - cols_any[:, ::-1].argmax(axis=1)
y_min = rows_any.argmax(axis=1)
y_max = height - 1 - rows_any[:, ::-1].argmax(axis=1)
if len(rows) > 0 and len(cols) > 0:
x_min, x_max = int(np.min(cols)), int(np.max(cols))
y_min, y_max = int(np.min(rows)), int(np.max(rows))
xyxy[i, :] = [x_min, y_min, x_max, y_max]
xyxy = np.stack((x_min, y_min, x_max, y_max), axis=1).astype(int)
# Empty masks have no bounds; keep the original all-zeros box for them.
xyxy[~rows_any.any(axis=1)] = 0
return xyxy
+25 -61
View File
@@ -1,6 +1,5 @@
from __future__ import annotations
import warnings
from collections.abc import Callable
from enum import Enum
from typing import Any, cast
@@ -688,23 +687,12 @@ def _mask_iou_batch_split(
Returns:
Pairwise IoU of masks from `masks_true` and `masks_detection`.
"""
# The overlap of two binary masks is the dot product of their flattened
# pixels, so the whole (N, M) intersection matrix is a single matmul.
# float32 counts pixels exactly up to 2**24; for larger masks (beyond
# ~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
intersection_area = np.logical_and(masks_true[:, None], masks_detection).sum(
axis=(2, 3)
)
detection_flat = 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
masks_true_area = true_flat.sum(axis=1)
masks_detection_area = detection_flat.sum(axis=1)
masks_true_area = masks_true.sum(axis=(1, 2)) # (area1, area2, ...)
masks_detection_area = masks_detection.sum(axis=(1, 2)) # (area1)
if overlap_metric == OverlapMetric.IOU:
union_area = masks_true_area[:, None] + masks_detection_area - intersection_area
@@ -755,20 +743,10 @@ def mask_iou_batch(
overlap_metric: Metric used to compute the degree of overlap
between pairs of masks (e.g., IoU, IoS).
memory_limit: Memory limit in MB, default is 1024 * 5 MB (5GB).
Controls chunking of ``masks_true`` so that flattened detection
masks plus each chunk's buffers stay within this limit. A
``UserWarning`` is raised when ``masks_detection`` alone
exceeds the limit, as chunking cannot reduce peak memory
below that floor. Ignored when both inputs are
:class:`~supervision.detection.compact_mask.CompactMask`.
Ignored when both inputs are CompactMask.
Returns:
Pairwise IoU of masks from `masks_true` and `masks_detection`.
Raises:
ValueError: If ``masks_true`` or ``masks_detection`` are not 3D
``(N, H, W)`` arrays, or if they do not share the same
spatial dimensions ``(H, W)``.
"""
if isinstance(masks_true, CompactMask) and isinstance(masks_detection, CompactMask):
@@ -780,43 +758,29 @@ def mask_iou_batch(
if isinstance(masks_detection, CompactMask):
masks_detection = np.asarray(masks_detection)
if masks_true.ndim != 3 or masks_detection.ndim != 3:
raise ValueError(
"masks_true and masks_detection must be 3D (N, H, W); got "
f"ndim={masks_true.ndim} and ndim={masks_detection.ndim}."
)
if masks_true.shape[1:] != masks_detection.shape[1:]:
raise ValueError(
"masks_true and masks_detection must share the same (H, W); got "
f"{masks_true.shape[1:]} and {masks_detection.shape[1:]}."
)
# A single pass already handles empty inputs and avoids np.vstack([]) below.
if masks_true.shape[0] == 0 or masks_detection.shape[0] == 0:
return _mask_iou_batch_split(masks_true, masks_detection, overlap_metric)
# Peak memory of a single matmul pass: the flattened detection masks (shared
# across chunks) plus, per true-mask row, its flattened pixels and the three
# (N, M) matrices it touches (intersection, denominator and output). The
# previous (N, M, H, W) estimate overcounted by a factor of M and forced
# needless chunking now that the intersection is a matmul.
pixels = masks_true.shape[1] * masks_true.shape[2]
itemsize = 4 if pixels <= 2**24 else 8
limit_bytes = memory_limit * 1024 * 1024
detection_bytes = masks_detection.shape[0] * pixels * itemsize
per_true_row = pixels * itemsize + 3 * masks_detection.shape[0] * 8
if detection_bytes > limit_bytes > 0:
warnings.warn(
f"detection masks ({detection_bytes // 1024 // 1024} MB) exceed "
f"memory_limit ({memory_limit} MB); chunking cannot reduce peak "
"memory below this floor.",
UserWarning,
stacklevel=2,
)
if detection_bytes + masks_true.shape[0] * per_true_row <= limit_bytes:
memory = (
masks_true.shape[0]
* masks_true.shape[1]
* masks_true.shape[2]
* masks_detection.shape[0]
/ 1024
/ 1024
)
if memory <= memory_limit:
return _mask_iou_batch_split(masks_true, masks_detection, overlap_metric)
ious = []
step = max((limit_bytes - detection_bytes) // per_true_row, 1)
step = max(
memory_limit
* 1024
* 1024
// (
masks_detection.shape[0]
* masks_detection.shape[1]
* masks_detection.shape[2]
),
1,
)
for chunk_start in range(0, masks_true.shape[0], step):
ious.append(
_mask_iou_batch_split(
+13 -40
View File
@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import cast
import cv2
import numpy as np
import numpy.typing as npt
@@ -55,47 +57,19 @@ def approximate_polygon(
the `x`, `y` coordinates of the input polygon's points.
percentage: The percentage of points to be removed from the
input polygon, in the range `[0, 1)`.
epsilon_step: Approximation accuracy step, must be positive.
epsilon_step: Approximation accuracy step.
Epsilon is the maximum distance between the original curve
and its approximation.
Returns:
A new 2D NumPy array of shape `(M, 2)` containing the `x`, `y`
coordinates of the approximated polygon's points. `M` is at most
`floor(N * (1 - percentage))` (minimum 3). Because epsilon
advances in discrete `epsilon_step` increments, `M` may be
noticeably smaller than the budget when a single step crosses
the target band. At least 3 points are always kept; when further
simplification would collapse the polygon below 3 points the last
valid approximation is returned and `M` may exceed the budget.
Raises:
ValueError: If `percentage` is outside the range `[0, 1)`.
ValueError: If `epsilon_step` is not positive.
Examples:
Reduce a polygon to at most half its original point count:
>>> import numpy as np
>>> polygon = np.array([[0, 0], [10, 0], [10, 10], [0, 10],
... [5, 10], [5, 5], [3, 7], [1, 9]])
>>> result = approximate_polygon(polygon, percentage=0.5)
>>> result.shape[1]
2
>>> len(result) <= max(int(len(polygon) * 0.5), 3)
True
Polygon already at or below target — returned unchanged:
>>> tiny = np.array([[0, 0], [5, 0], [2, 4]])
>>> approximate_polygon(tiny, percentage=0.5) is tiny
True
A new 2D NumPy array of shape `(M, 2)`,
where `M <= N * (1 - percentage)`, containing
the `x`, `y` coordinates of the
approximated polygon's points.
"""
if percentage < 0 or percentage >= 1:
raise ValueError("Percentage must be in the range [0, 1).")
if epsilon_step <= 0:
raise ValueError("epsilon_step must be positive.")
target_points = max(int(len(polygon) * (1 - percentage)), 3)
@@ -104,13 +78,12 @@ def approximate_polygon(
epsilon: float = 0
approximated_points = polygon
while len(approximated_points) > target_points:
while True:
epsilon += epsilon_step
candidate = np.squeeze(cv2.approxPolyDP(polygon, epsilon, closed=True), axis=1)
# Stop before the approximation collapses below a valid polygon; keep the
# last result with at least three points.
if len(candidate) < 3:
new_approximated_points = cv2.approxPolyDP(polygon, epsilon, closed=True)
if len(new_approximated_points) > target_points:
approximated_points = new_approximated_points
else:
break
approximated_points = candidate
return approximated_points
return cast(npt.NDArray[np.number], np.squeeze(approximated_points, axis=1))
+40 -63
View File
@@ -214,7 +214,7 @@ def validate_vlm_parameters(vlm: VLM | str, result: Any, kwargs: dict[str, Any])
def from_paligemma(
result: str, resolution_wh: tuple[int, int], classes: list[str] | None = None
) -> tuple[npt.NDArray[Any], npt.NDArray[Any] | None, npt.NDArray[Any]]:
) -> tuple[npt.NDArray[Any], npt.NDArray[Any], npt.NDArray[Any]]:
"""
Parse bounding boxes from paligemma-formatted text, scale them to the specified
resolution, and optionally filter by classes.
@@ -238,23 +238,23 @@ def from_paligemma(
r"(?<!<loc\d{4}>)<loc(\d{4})><loc(\d{4})><loc(\d{4})><loc(\d{4})> ([\w\s\-]+)"
)
matches = pattern.findall(result)
matches_arr: npt.NDArray[Any] = np.array(matches) if matches else np.empty((0, 5))
matches = np.array(matches) if matches else np.empty((0, 5))
if matches_arr.shape[0] == 0:
if matches.shape[0] == 0:
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0, dtype=str)
xyxy_arr = np.array(matches_arr[:, [1, 0, 3, 2]], dtype=float)
xyxy_arr = xyxy_arr.astype(int) / 1024 * np.array([w, h, w, h])
class_name = np.char.strip(matches_arr[:, 4].astype(str))
class_id: npt.NDArray[Any] | None = None
xyxy, class_name = matches[:, [1, 0, 3, 2]], matches[:, 4]
xyxy = xyxy.astype(int) / 1024 * np.array([w, h, w, h])
class_name = np.char.strip(class_name.astype(str))
class_id = None
if classes is not None:
mask = np.array([name in classes for name in class_name], dtype=bool)
xyxy_arr = xyxy_arr[mask]
xyxy = xyxy[mask]
class_name = class_name[mask]
class_id = np.array([classes.index(name) for name in class_name])
return xyxy_arr, class_id, class_name
return xyxy, class_id, class_name
def recover_truncated_qwen_2_5_vl_response(text: str) -> Any | None:
@@ -369,7 +369,7 @@ def from_qwen_2_5_vl(
labels_list = []
for item in data:
if not isinstance(item, dict) or "bbox_2d" not in item or "label" not in item:
if "bbox_2d" not in item or "label" not in item:
continue
boxes_list.append(item["bbox_2d"])
labels_list.append(item["label"])
@@ -460,13 +460,12 @@ def from_deepseek_vl_2(
f"and det tags ({len(detection_segments)}) in the result must be equal."
)
xyxy_list: list[list[float]] = []
class_name_list: list[str] = []
xyxy, class_name_list = [], []
for label, detection_blob in zip(label_segments, detection_segments):
current_class_name = label.strip()
for box in re.findall(r"\[(.*?)\]", detection_blob):
x1, y1, x2, y2 = map(float, box.strip("[]").split(","))
xyxy_list.append(
xyxy.append(
[
(x1 / 999 * width),
(y1 / 999 * height),
@@ -476,7 +475,7 @@ def from_deepseek_vl_2(
)
class_name_list.append(current_class_name)
xyxy = np.array(xyxy_list, dtype=np.float32)
xyxy = np.array(xyxy, dtype=np.float32)
class_name = np.array(class_name_list)
if classes is not None:
@@ -542,15 +541,15 @@ def from_florence_2(
return xyxy, labels, None, xyxyxyxy
if task in ["<REFERRING_EXPRESSION_SEGMENTATION>", "<REGION_TO_SEGMENTATION>"]:
xyxy_list: list[npt.NDArray[Any]] = []
masks_list: list[npt.NDArray[Any]] = []
xyxy_list = []
masks_list = []
for polygons_of_same_class in result["polygons"]:
for polygon in polygons_of_same_class:
polygon = np.reshape(polygon, (-1, 2)).astype(np.int32)
mask = polygon_to_mask(polygon, resolution_wh).astype(bool)
masks_list.append(mask)
xyxy_box = polygon_to_xyxy(polygon)
xyxy_list.append(xyxy_box)
xyxy = polygon_to_xyxy(polygon)
xyxy_list.append(xyxy)
# per-class labels also provided, but they are ["", "", "", ...]
# when we figure out how to set class names, we can do
# zip(result["labels"], result["polygons"])
@@ -641,14 +640,11 @@ def from_google_gemini_2_0(
except json.JSONDecodeError:
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty((0,), dtype=str)
if not isinstance(data, list):
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty((0,), dtype=str)
labels = []
xyxy = []
for item in data:
if not isinstance(item, dict) or "box_2d" not in item or "label" not in item:
if "box_2d" not in item or "label" not in item:
continue
labels.append(item["label"])
box = item["box_2d"]
@@ -738,22 +734,13 @@ def from_google_gemini_2_5(
None,
)
if not isinstance(data, list):
return (
np.empty((0, 4)),
np.array([], dtype=int),
np.array([], dtype=str),
np.array([], dtype=float),
None,
)
boxes_list: list[Any] = []
labels_list: list[str] = []
confidence_list: list[float] | None = []
masks_list: list[npt.NDArray[Any]] | None = []
for item in data:
if not isinstance(item, dict) or "box_2d" not in item or "label" not in item:
if "box_2d" not in item or "label" not in item:
continue
labels_list.append(item["label"])
box = item["box_2d"]
@@ -768,39 +755,29 @@ def from_google_gemini_2_5(
if "mask" in item:
if masks_list is not None:
png_str = item["mask"]
if not isinstance(png_str, str) or not png_str.startswith(
"data:image/png;base64,"
):
# Malformed mask: keep an empty mask but still fall through to
# the confidence handling below, so the per-item arrays stay
# aligned (a `continue` here desynced confidence vs boxes).
if not png_str.startswith("data:image/png;base64,"):
masks_list.append(np.zeros((h, w), dtype=bool))
continue
png_str = png_str.removeprefix("data:image/png;base64,")
png_str = base64.b64decode(png_str)
mask_img = Image.open(io.BytesIO(png_str))
y_min, y_max = int(absolute_bbox[1]), int(absolute_bbox[3])
x_min, x_max = int(absolute_bbox[0]), int(absolute_bbox[2])
bbox_height = y_max - y_min
bbox_width = x_max - x_min
if bbox_height > 0 and bbox_width > 0:
mask_img = mask_img.resize(
(bbox_width, bbox_height), resample=Image.Resampling.BILINEAR
)
np_mask: npt.NDArray[np.bool_] = np.zeros((h, w), dtype=bool)
np_mask[y_min:y_max, x_min:x_max] = np.array(mask_img) > 0
masks_list.append(np_mask)
else:
png_str = png_str.removeprefix("data:image/png;base64,")
try:
png_bytes = base64.b64decode(png_str)
mask_img = Image.open(io.BytesIO(png_bytes)).convert("L")
except Exception:
masks_list.append(np.zeros((h, w), dtype=bool))
else:
y_min, y_max = int(absolute_bbox[1]), int(absolute_bbox[3])
x_min, x_max = int(absolute_bbox[0]), int(absolute_bbox[2])
bbox_height = y_max - y_min
bbox_width = x_max - x_min
if bbox_height > 0 and bbox_width > 0:
mask_img = mask_img.resize(
(bbox_width, bbox_height),
resample=Image.Resampling.BILINEAR,
)
np_mask: npt.NDArray[np.bool_] = np.zeros(
(h, w), dtype=bool
)
np_mask[y_min:y_max, x_min:x_max] = np.array(mask_img) > 0
masks_list.append(np_mask)
else:
masks_list.append(np.zeros((h, w), dtype=bool))
masks_list.append(np.zeros((h, w), dtype=bool))
else:
masks_list = None
+33 -124
View File
@@ -11,10 +11,6 @@ import numpy.typing as npt
from supervision.config import CLASS_NAME_DATA_FIELD
from supervision.detection.core import Detections
from supervision.detection.utils.internal import get_data_item, is_data_equal
from supervision.detection.utils.iou_and_nms import (
OverlapMetric,
box_non_max_suppression,
)
from supervision.utils.internal import warn_deprecated
from supervision.validators import _validate_keypoints_fields
@@ -1133,94 +1129,6 @@ class KeyPoints:
empty_key_points.data = self.data
return self == empty_key_points
def with_nms(
self,
threshold: float = 0.5,
class_agnostic: bool = False,
overlap_metric: OverlapMetric = OverlapMetric.IOU,
) -> KeyPoints:
"""
Performs non-max suppression on the keypoint detections. Bounding boxes
are derived from valid keypoints of each skeleton, and standard box NMS
is applied. A keypoint is considered valid when its coordinates are not
all-zero and its `visible` flag is `True` (if `visible` is set).
Args:
threshold: The intersection-over-union threshold to use for
non-maximum suppression. Must be in [0, 1]. Defaults to 0.5.
class_agnostic: Whether to perform class-agnostic non-maximum
suppression. If True, the class_id of each detection will be
ignored. Defaults to False.
overlap_metric: Metric used to compute the degree of overlap
between pairs of bounding boxes. Defaults to
`OverlapMetric.IOU`.
Returns:
A new `sv.KeyPoints` object after non-maximum suppression.
Raises:
ValueError: If `detection_confidence` is None.
ValueError: If `class_agnostic` is False and `class_id`
is None.
Examples:
```python
import cv2
import supervision as sv
from rfdetr import RFDETRKeypointPreview
image = cv2.imread("<SOURCE_IMAGE_PATH>")
model = RFDETRKeypointPreview()
key_points = model.predict(image)
key_points = key_points.with_nms(threshold=0.5)
```
"""
if len(self) == 0:
return self
if self.detection_confidence is None:
raise ValueError(
"KeyPoints detection_confidence must be given for NMS to be executed."
)
if not class_agnostic and self.class_id is None:
raise ValueError(
"KeyPoints class_id must be given for NMS to be executed. If "
"you intended to perform class agnostic NMS set "
"class_agnostic=True."
)
xy = self.xy
valid = ~np.all(xy == 0, axis=-1)
if self.visible is not None:
valid = valid & self.visible
x_min = np.min(np.where(valid, xy[..., 0], np.inf), axis=1)
y_min = np.min(np.where(valid, xy[..., 1], np.inf), axis=1)
x_max = np.max(np.where(valid, xy[..., 0], -np.inf), axis=1)
y_max = np.max(np.where(valid, xy[..., 1], -np.inf), axis=1)
xyxy = np.stack([x_min, y_min, x_max, y_max], axis=1).astype(np.float32)
if class_agnostic:
predictions = np.hstack([xyxy, self.detection_confidence.reshape(-1, 1)])
else:
class_id = cast(npt.NDArray[np.int_], self.class_id)
predictions = np.hstack(
[
xyxy,
self.detection_confidence.reshape(-1, 1),
class_id.reshape(-1, 1),
]
)
keep = box_non_max_suppression(
predictions=predictions,
iou_threshold=threshold,
overlap_metric=overlap_metric,
)
return cast(KeyPoints, self[keep])
def as_detections(
self, selected_keypoint_indices: Iterable[int] | None = None
) -> Detections:
@@ -1233,9 +1141,7 @@ class KeyPoints:
selected_keypoint_indices: The
indices of the key points to include in the bounding box
calculation. This helps focus on a subset of key points,
e.g. when some are occluded. Captures all key points by
default. An empty sequence (`[]`) is treated the same as
`None` and selects all key points.
e.g. when some are occluded. Captures all key points by default.
Returns:
detections: The converted detections object.
@@ -1256,37 +1162,40 @@ class KeyPoints:
if self.is_empty():
return Detections.empty()
xy = self.xy
if selected_keypoint_indices:
indices = np.asarray(list(selected_keypoint_indices), dtype=np.intp)
xy = xy[:, indices, :]
# [0, 0] is used by some frameworks to indicate a missing keypoint; those
# points are excluded from each skeleton's bounding box.
valid = ~np.all(xy == 0, axis=2) # (N, M)
has_valid = valid.any(axis=1) # (N,)
x, y = xy[:, :, 0], xy[:, :, 1]
x_min = np.where(valid, x, np.inf).min(axis=1)
y_min = np.where(valid, y, np.inf).min(axis=1)
x_max = np.where(valid, x, -np.inf).max(axis=1)
y_max = np.where(valid, y, -np.inf).max(axis=1)
xyxy = np.stack((x_min, y_min, x_max, y_max), axis=1).astype(np.float32)
# Skeletons with no valid keypoints keep the original empty [0, 0, 0, 0] box.
xyxy[~has_valid] = 0.0
if self.detection_confidence is not None:
confidence = self.detection_confidence.astype(np.float32)
elif self.keypoint_confidence is not None:
keypoint_confidence = self.keypoint_confidence
detections_list = []
for i, xy in enumerate(self.xy):
if selected_keypoint_indices:
keypoint_confidence = keypoint_confidence[:, indices]
confidence = keypoint_confidence.mean(axis=1).astype(np.float32)
else:
confidence = None
xy = xy[selected_keypoint_indices]
detections = Detections(xyxy=xyxy, confidence=confidence)
# [0, 0] used by some frameworks to indicate missing keypoints
xy = xy[~np.all(xy == 0, axis=1)]
if len(xy) == 0:
xyxy = np.array([[0, 0, 0, 0]], dtype=np.float32)
else:
x_min = xy[:, 0].min()
x_max = xy[:, 0].max()
y_min = xy[:, 1].min()
y_max = xy[:, 1].max()
xyxy = np.array([[x_min, y_min, x_max, y_max]], dtype=np.float32)
if self.detection_confidence is not None:
confidence = np.array([self.detection_confidence[i]], dtype=np.float32)
elif self.keypoint_confidence is not None:
confidence = self.keypoint_confidence[i]
if selected_keypoint_indices:
confidence = confidence[selected_keypoint_indices]
confidence = np.array([confidence.mean()], dtype=np.float32)
else:
confidence = None
detections_list.append(
Detections(
xyxy=xyxy,
confidence=confidence,
)
)
detections = Detections.merge(detections_list)
detections.class_id = self.class_id
detections.data = self.data
detections = cast(Detections, detections[cast(Any, detections.area) > 0])
+6 -50
View File
@@ -152,14 +152,6 @@ class F1Score(Metric):
def _compute(
self, predictions_list: list[Detections], targets_list: list[Detections]
) -> F1ScoreResult:
"""Build per-image stats tuples and delegate to class-level computation.
Each stats tuple is ``(matches, confidence, class_ids, true_class_ids)``:
- Both empty: skip (no information).
- Targets empty, predictions present: all predictions are FPs; true_class_ids
is ``zeros((0,))``.
- Targets present: IoU matching produces ``matches`` array.
"""
iou_thresholds = np.linspace(0.5, 0.95, 10)
stats: list[Any] = []
@@ -167,22 +159,7 @@ class F1Score(Metric):
prediction_contents = self._detections_content(predictions)
target_contents = self._detections_content(targets)
if len(targets) == 0 and len(predictions) > 0:
# Only predictions are present (e.g. a background image); every
# prediction is a false positive.
if predictions.class_id is None or predictions.confidence is None:
continue
stats.append(
(
np.zeros(
(len(predictions), iou_thresholds.size), dtype=np.bool_
),
predictions.confidence,
predictions.class_id,
np.zeros((0,), dtype=np.int32),
)
)
elif len(targets) > 0:
if len(targets) > 0:
if len(predictions) == 0:
stats.append(
(
@@ -267,23 +244,10 @@ class F1Score(Metric):
npt.NDArray[np.float64],
npt.NDArray[np.int32],
]:
"""Compute F1 scores from concatenated stats across all images.
``unique_classes`` is the union of GT and predicted classes so that
predictions of classes absent from GT still count as false positives.
"""
sorted_indices = np.argsort(-prediction_confidence)
matches = matches[sorted_indices]
prediction_class_ids = prediction_class_ids[sorted_indices]
# Predictions whose class never appears in the ground truth are still
# false positives, so include those classes in the confusion matrix
# (their true-instance count is zero).
unique_classes = np.unique(
np.concatenate((true_class_ids, prediction_class_ids))
)
true_classes, true_counts = np.unique(true_class_ids, return_counts=True)
class_counts = np.zeros(unique_classes.shape[0], dtype=int)
class_counts[np.searchsorted(unique_classes, true_classes)] = true_counts
unique_classes, class_counts = np.unique(true_class_ids, return_counts=True)
# Shape: PxTh,P,C,C -> CxThx3
confusion_matrix = self._compute_confusion_matrix(
@@ -301,13 +265,7 @@ class F1Score(Metric):
f1_scores = self._compute_f1(confusion_matrix_merged)
elif self.averaging_method == AveragingMethod.WEIGHTED:
class_counts = class_counts.astype(np.float32)
if class_counts.sum() == 0:
# No ground-truth support (e.g. only false-positive classes, or a
# size bucket with predictions but no targets): weighting is
# undefined, so report 0 as the empty case did before.
f1_scores = np.zeros(f1_per_class.shape[1])
else:
f1_scores = np.average(f1_per_class, axis=0, weights=class_counts)
f1_scores = np.average(f1_per_class, axis=0, weights=class_counts)
return f1_scores, f1_per_class, unique_classes
@@ -525,12 +483,10 @@ class F1ScoreResult:
f1_scores: the F1 scores at each IoU threshold.
Shape: `(num_iou_thresholds,)`
f1_per_class: the F1 scores per class and IoU threshold.
Shape: `(num_classes, num_iou_thresholds)`
Shape: `(num_target_classes, num_iou_thresholds)`
iou_thresholds: the IoU thresholds used in the calculations.
matched_classes: the class IDs present in either predictions or ground
truth. Corresponds to the rows of `f1_per_class`. Classes that
appear only in predictions (no ground-truth instances) are
included; their per-threshold F1 values will be `0.0`.
matched_classes: the class IDs of all matched classes.
Corresponds to the rows of `f1_per_class`.
small_objects: the F1 metric results
for small objects (area < 32²).
medium_objects: the F1 metric results
+10 -52
View File
@@ -155,14 +155,6 @@ class Precision(Metric):
def _compute(
self, predictions_list: list[Detections], targets_list: list[Detections]
) -> PrecisionResult:
"""Build per-image stats tuples and delegate to class-level computation.
Each stats tuple is ``(matches, confidence, class_ids, true_class_ids)``:
- Both empty: skip (no information).
- Targets empty, predictions present: all predictions are FPs; true_class_ids
is ``zeros((0,))``.
- Targets present: IoU matching produces ``matches`` array.
"""
iou_thresholds = np.linspace(0.5, 0.95, 10)
stats: list[Any] = []
@@ -170,22 +162,7 @@ class Precision(Metric):
prediction_contents = self._detections_content(predictions)
target_contents = self._detections_content(targets)
if len(targets) == 0 and len(predictions) > 0:
# Only predictions are present (e.g. a background image); every
# prediction is a false positive.
if predictions.class_id is None or predictions.confidence is None:
continue
stats.append(
(
np.zeros(
(len(predictions), iou_thresholds.size), dtype=np.bool_
),
predictions.confidence,
predictions.class_id,
np.zeros((0,), dtype=np.int32),
)
)
elif len(targets) > 0:
if len(targets) > 0:
if len(predictions) == 0:
stats.append(
(
@@ -270,23 +247,10 @@ class Precision(Metric):
npt.NDArray[np.float64],
npt.NDArray[np.int32],
]:
"""Compute precision scores from concatenated stats across all images.
``unique_classes`` is the union of GT and predicted classes so that
predictions of classes absent from GT still count as false positives.
"""
sorted_indices = np.argsort(-prediction_confidence)
matches = matches[sorted_indices]
prediction_class_ids = prediction_class_ids[sorted_indices]
# Predictions whose class never appears in the ground truth are still
# false positives, so include those classes in the confusion matrix
# (their true-instance count is zero).
unique_classes = np.unique(
np.concatenate((true_class_ids, prediction_class_ids))
)
true_classes, true_counts = np.unique(true_class_ids, return_counts=True)
class_counts = np.zeros(unique_classes.shape[0], dtype=int)
class_counts[np.searchsorted(unique_classes, true_classes)] = true_counts
unique_classes, class_counts = np.unique(true_class_ids, return_counts=True)
# Shape: PxTh,P,C,C -> CxThx3
confusion_matrix = self._compute_confusion_matrix(
@@ -304,15 +268,9 @@ class Precision(Metric):
precision_scores = self._compute_precision(confusion_matrix_merged)
elif self.averaging_method == AveragingMethod.WEIGHTED:
class_counts = class_counts.astype(np.float32)
if class_counts.sum() == 0:
# No ground-truth support (e.g. only false-positive classes, or a
# size bucket with predictions but no targets): weighting is
# undefined, so report 0 as the empty case did before.
precision_scores = np.zeros(precision_per_class.shape[1])
else:
precision_scores = np.average(
precision_per_class, axis=0, weights=class_counts
)
precision_scores = np.average(
precision_per_class, axis=0, weights=class_counts
)
return precision_scores, precision_per_class, unique_classes
@@ -470,6 +428,8 @@ class Precision(Metric):
raise ValueError(f"Invalid metric target: {self._metric_target}")
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _filter_detections_by_size(
self, detections: Detections, size_category: ObjectSizeCategory
) -> Detections:
@@ -534,12 +494,10 @@ class PrecisionResult:
precision_scores: the precision scores at each IoU threshold.
Shape: `(num_iou_thresholds,)`
precision_per_class: the precision scores per class and
IoU threshold. Shape: `(num_classes, num_iou_thresholds)`
IoU threshold. Shape: `(num_target_classes, num_iou_thresholds)`
iou_thresholds: the IoU thresholds used in the calculations.
matched_classes: the class IDs present in either predictions or ground
truth. Corresponds to the rows of `precision_per_class`. Classes
that appear only in predictions (no ground-truth instances) are
included; their per-threshold precision values will be `0.0`.
matched_classes: the class IDs of all matched classes.
Corresponds to the rows of `precision_per_class`.
small_objects: the Precision metric results
for small objects (area < 32²).
medium_objects: the Precision metric results
+1 -3
View File
@@ -158,9 +158,7 @@ def pillow_to_cv2(image: Image.Image) -> npt.NDArray[np.uint8]:
"""
scene = np.array(image)
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 scene.astype(np.uint8)
def cv2_to_pillow(image: npt.NDArray[np.uint8]) -> Image.Image:
-169
View File
@@ -30,10 +30,8 @@ from supervision.annotators.core import (
RoundBoxAnnotator,
TraceAnnotator,
TriangleAnnotator,
_paint_masks_by_area,
)
from supervision.annotators.utils import ColorLookup
from supervision.detection.compact_mask import CompactMask
from supervision.detection.core import Detections
from supervision.draw.color import Color
from supervision.geometry.core import Position
@@ -365,173 +363,6 @@ class TestHaloAnnotator:
)
assert np.array_equal(result_bool, result_uint8)
def test_annotate_with_all_false_mask_preserves_scene(self):
"""Test that an all-False mask leaves the scene unchanged, not corrupted."""
scene = np.full((100, 100, 3), 127, dtype=np.uint8)
masks = [np.zeros((100, 100), dtype=bool)]
detections = _create_detections(
xyxy=[[10, 10, 90, 90]], mask=masks, class_id=[0]
)
result = HaloAnnotator().annotate(scene=scene.copy(), detections=detections)
assert np.array_equal(result, scene)
class TestPaintMasksByArea:
"""Tests for the _paint_masks_by_area helper function."""
def test_paint_masks_by_area_is_noop_without_masks(self):
"""_paint_masks_by_area is a no-op when detections carry no mask."""
canvas = np.full((10, 10, 3), 7, dtype=np.uint8)
detections = _create_detections(xyxy=[[1, 1, 8, 8]], class_id=[0])
_paint_masks_by_area(canvas, detections, Color.RED, ColorLookup.INDEX)
assert np.array_equal(canvas, np.full((10, 10, 3), 7, dtype=np.uint8))
def test_union_accumulation_dense(self):
"""Dense path: collect_union=True returns array covering all painted pixels."""
height, width = 50, 60
canvas = np.zeros((height, width, 3), dtype=np.uint8)
masks = [np.zeros((height, width), dtype=bool)]
masks[0][5:20, 10:40] = True
detections = _create_detections(
xyxy=[[10.0, 5.0, 40.0, 20.0]], mask=masks, class_id=[0]
)
result_union = _paint_masks_by_area(
canvas, detections, Color.RED, ColorLookup.INDEX, collect_union=True
)
assert result_union is not None
# every painted pixel must be in the union (RED is BGR (0, 0, 255),
# so detect painted pixels via any non-zero channel)
painted = canvas.any(axis=-1)
assert np.array_equal(painted, result_union)
def test_union_accumulation_compact(self):
"""CompactMask path: collect_union=True returns array matching dense."""
height, width = 50, 60
mask = np.zeros((height, width), dtype=bool)
mask[5:20, 10:40] = True
xyxy = np.array([[10.0, 5.0, 40.0, 20.0]])
canvas_dense = np.zeros((height, width, 3), dtype=np.uint8)
dense = _create_detections(xyxy=xyxy.tolist(), mask=[mask], class_id=[0])
union_dense = _paint_masks_by_area(
canvas_dense, dense, Color.RED, ColorLookup.INDEX, collect_union=True
)
canvas_compact = np.zeros((height, width, 3), dtype=np.uint8)
compact = _create_detections(xyxy=xyxy.tolist(), mask=[mask], class_id=[0])
compact.mask = CompactMask.from_dense(
np.array([mask]), compact.xyxy, (height, width)
)
union_compact = _paint_masks_by_area(
canvas_compact, compact, Color.RED, ColorLookup.INDEX, collect_union=True
)
assert union_dense is not None
assert union_compact is not None
# compact union must cover exactly the same pixels as dense union
assert np.array_equal(union_dense, union_compact)
def test_compact_mask_drops_pixels_outside_bbox(self):
"""CompactMask is lossy: True pixels outside xyxy bbox are silently dropped.
This test documents that compact and dense paths diverge when a mask has
True pixels outside its bounding box — the 'bit-identical' claim holds
only for bbox-contained masks.
"""
height, width = 50, 60
mask = np.zeros((height, width), dtype=bool)
mask[5:25, 10:40] = True # mask extends 5 rows beyond bbox bottom
bbox = [[10.0, 5.0, 40.0, 20.0]] # y2=20 clips the mask at row 20
canvas_dense = np.zeros((height, width, 3), dtype=np.uint8)
dense = _create_detections(xyxy=bbox, mask=[mask], class_id=[0])
_paint_masks_by_area(canvas_dense, dense, Color.RED, ColorLookup.INDEX)
canvas_compact = np.zeros((height, width, 3), dtype=np.uint8)
compact = _create_detections(xyxy=bbox, mask=[mask], class_id=[0])
compact.mask = CompactMask.from_dense(
np.array([mask]), compact.xyxy, (height, width)
)
_paint_masks_by_area(canvas_compact, compact, Color.RED, ColorLookup.INDEX)
# Dense paints all True pixels incl. rows 21-24; compact only within bbox.
assert not np.array_equal(canvas_dense, canvas_compact), (
"Expected divergence: compact mask drops True pixels outside bbox"
)
# Compact subset: every pixel painted by compact is also painted by dense.
compact_painted = canvas_compact.any(axis=-1)
dense_painted = canvas_dense.any(axis=-1)
assert np.all(dense_painted[compact_painted])
class TestCompactMaskParity:
"""Tests that CompactMask and dense mask produce identical annotator output."""
@pytest.mark.parametrize(
"annotator_factory",
[
pytest.param(
lambda: MaskAnnotator(opacity=1.0, color_lookup=ColorLookup.INDEX),
id="mask",
),
pytest.param(
lambda: HaloAnnotator(kernel_size=15, color_lookup=ColorLookup.INDEX),
id="halo",
),
],
)
def test_annotator_compact_mask_matches_dense_mask(self, annotator_factory):
"""CompactMask detections annotate identically to dense bool masks."""
height, width = 120, 160
rng = np.random.default_rng(0)
scene = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
boxes = [[10, 10, 70, 60], [40, 30, 150, 110], [90, 70, 140, 115]]
masks = []
for x1, y1, x2, y2 in boxes:
mask = np.zeros((height, width), dtype=bool)
mask[y1 : y2 + 1, x1 : x2 + 1] = True
masks.append(mask)
class_id = [0, 1, 2]
xyxy = [[float(value) for value in box] for box in boxes]
dense = _create_detections(xyxy=xyxy, mask=masks, class_id=class_id)
compact = _create_detections(xyxy=xyxy, mask=masks, class_id=class_id)
compact.mask = CompactMask.from_dense(
np.array(masks), compact.xyxy, (height, width)
)
result_dense = annotator_factory().annotate(
scene=scene.copy(), detections=dense
)
result_compact = annotator_factory().annotate(
scene=scene.copy(), detections=compact
)
assert not np.array_equal(result_dense, scene), "annotator painted nothing"
assert np.array_equal(result_dense, result_compact)
def test_annotator_compact_mask_handles_edge_clipping(self):
"""CompactMask detection straddling image edge paints via NumPy clip."""
height, width = 50, 60
rng = np.random.default_rng(42)
scene = rng.integers(0, 256, (height, width, 3), dtype=np.uint8)
# Box extends 10 pixels beyond right/bottom edges
mask = np.zeros((height, width), dtype=bool)
mask[40:height, 50:width] = True
bbox = [[50.0, 40.0, width + 10.0, height + 10.0]]
detections = _create_detections(xyxy=bbox, mask=[mask], class_id=[0])
detections.mask = CompactMask.from_dense(
np.array([mask]), detections.xyxy, (height, width)
)
annotator = MaskAnnotator(opacity=1.0, color_lookup=ColorLookup.INDEX)
result = annotator.annotate(scene=scene.copy(), detections=detections)
# Result must differ from scene (something was painted) and must not raise
assert not np.array_equal(result, scene), "Expected pixels to be painted"
class TestHeatMapAnnotator:
"""Tests for HeatMapAnnotator class"""
-75
View File
@@ -13,7 +13,6 @@ from supervision.dataset.formats.coco import (
build_coco_class_index_mapping,
classes_to_coco_categories,
coco_annotations_to_detections,
coco_annotations_to_masks,
coco_categories_to_classes,
detections_to_coco_annotations,
group_coco_annotations_by_image_id,
@@ -996,80 +995,6 @@ def test_detections_to_coco_annotations_handles_empty_approximated_polygons() ->
assert annotations[0]["iscrowd"] == 0
_DISJOINT_2X2_MASK = np.array(
[
[
[1, 1, 0, 0, 0],
[1, 1, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
]
],
dtype=bool,
)
_SINGLE_COMPONENT_MASK = np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
]
],
dtype=bool,
)
def _make_iscrowd0_detections(mask: np.ndarray) -> Detections:
"""Build a single-detection Detections with iscrowd=0 from a (1, H, W) mask."""
_, h, w = mask.shape
return Detections(
xyxy=np.array([[0, 0, w, h]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=mask,
data={"iscrowd": np.array([0])},
)
@pytest.mark.parametrize(
("mask", "expected_segment_count"),
[
pytest.param(_DISJOINT_2X2_MASK, 2, id="disjoint-two-parts"),
pytest.param(_SINGLE_COMPONENT_MASK, 1, id="single-component"),
],
)
def test_detections_to_coco_annotations_segmentation_count(
mask: np.ndarray, expected_segment_count: int
) -> None:
"""Non-crowd mask export produces one polygon list entry per connected component."""
annotations, _ = detections_to_coco_annotations(
detections=_make_iscrowd0_detections(mask), image_id=0, annotation_id=0
)
segmentation = annotations[0]["segmentation"]
assert annotations[0]["iscrowd"] == 0
assert isinstance(segmentation, list)
assert len(segmentation) == expected_segment_count
assert all(len(part) >= 6 for part in segmentation)
assert all(np.isfinite(c) for part in segmentation for c in part)
def test_detections_to_coco_annotations_round_trip_disjoint_mask() -> None:
"""Two-part disjoint mask round-trips through COCO export and import unchanged."""
W, H = 5, 5
annotations, _ = detections_to_coco_annotations(
detections=_make_iscrowd0_detections(_DISJOINT_2X2_MASK),
image_id=0,
annotation_id=0,
)
reloaded = coco_annotations_to_masks([annotations[0]], resolution_wh=(W, H))
assert np.array_equal(reloaded[0], _DISJOINT_2X2_MASK[0])
def test_detections_to_coco_annotations_preserves_area_from_data() -> None:
"""area stored in detections.data should be used instead of bbox area."""
detections = Detections(
-42
View File
@@ -8,7 +8,6 @@ from defusedxml import ElementTree
from supervision.dataset.formats.pascal_voc import (
detections_from_xml_obj,
detections_to_pascal_voc,
object_to_pascal_voc,
parse_polygon_points,
)
@@ -73,47 +72,6 @@ def test_object_to_pascal_voc(
assert are_xml_elements_equal(result, expected_result)
def test_object_to_pascal_voc_does_not_mutate_inputs():
"""Serializing an object must not write the 1-index offset back into the inputs."""
xyxy = np.array([10, 20, 30, 40], dtype=np.float32)
polygon = np.array([[0, 0], [10, 0], [10, 10], [0, 10]], dtype=np.float32)
object_to_pascal_voc(xyxy=xyxy, name="test", polygon=polygon)
assert np.array_equal(xyxy, np.array([10, 20, 30, 40], dtype=np.float32))
assert np.array_equal(
polygon, np.array([[0, 0], [10, 0], [10, 10], [0, 10]], dtype=np.float32)
)
def test_object_to_pascal_voc_does_not_mutate_view_input():
"""Mutation guard holds when xyxy is a NumPy row-view (the actual bug scenario)."""
base = np.array([[10, 20, 30, 40]], dtype=np.float32)
xyxy_view = base[0] # row-view, shares memory with base
object_to_pascal_voc(xyxy=xyxy_view, name="test", polygon=None)
assert np.array_equal(base[0], np.array([10, 20, 30, 40], dtype=np.float32)), (
"object_to_pascal_voc mutated the source array via a view"
)
def test_detections_to_pascal_voc_does_not_mutate_detections():
"""Exporting detections must not shift the source xyxy, and must be repeatable."""
detections = _create_detections(xyxy=[[10, 20, 30, 40]], class_id=[0])
expected_xyxy = detections.xyxy.copy()
first = detections_to_pascal_voc(
detections, classes=["test"], filename="image.jpg", image_shape=(100, 100, 3)
)
second = detections_to_pascal_voc(
detections, classes=["test"], filename="image.jpg", image_shape=(100, 100, 3)
)
assert np.array_equal(detections.xyxy, expected_xyxy)
assert first == second
@pytest.mark.parametrize(
("polygon_element", "expected_result", "exception"),
[
-47
View File
@@ -395,53 +395,6 @@ def test_json_sink(
assert_json_equal(file_name, expected_result)
@pytest.mark.parametrize(
("scalar", "expected"),
[
pytest.param(np.int64(7), 7, id="np_int64"),
pytest.param(np.float32(0.5), pytest.approx(0.5), id="np_float32"),
pytest.param(np.float64(1.5), pytest.approx(1.5), id="np_float64"),
pytest.param(np.int32(3), 3, id="np_int32"),
pytest.param(np.bool_(True), True, id="np_bool"),
],
)
def test_json_sink_serializes_numpy_scalar_custom_data(
tmp_path: Any, scalar: Any, expected: Any
) -> None:
"""NumPy scalar in custom_data serializes as a JSON number or boolean."""
file_name = str(tmp_path / "test_numpy_scalar.json")
detections = sv.Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
confidence=np.array([0.9]),
)
with sv.JSONSink(file_name) as sink:
sink.append(detections, custom_data={"value": scalar})
with open(file_name) as f:
data = json.load(f)
assert data[0]["value"] == expected
def test_json_sink_serializes_nested_numpy_array_custom_data(tmp_path: Any) -> None:
"""NumPy array nested inside a custom_data dict value serializes as a JSON array."""
file_name = str(tmp_path / "test_nested_array.json")
detections = sv.Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
)
with sv.JSONSink(file_name) as sink:
sink.append(detections, custom_data={"meta": {"arr": np.array([1, 2, 3])}})
with open(file_name) as f:
data = json.load(f)
assert data[0]["meta"]["arr"] == [1, 2, 3]
def test_json_default_raises_for_unserializable_type() -> None:
"""_json_default raises TypeError for non-numpy objects."""
with pytest.raises(TypeError, match="is not JSON serializable"):
sv.JSONSink._json_default(object())
def assert_json_equal(file_name, expected_rows):
with open(file_name) as file:
data = json.load(file)
-46
View File
@@ -1297,49 +1297,3 @@ def test_from_deepseek_vl_2(
detections.data[CLASS_NAME_DATA_FIELD],
expected_detections.data[CLASS_NAME_DATA_FIELD],
)
def test_from_google_gemini_2_5_malformed_mask_keeps_confidence_aligned():
"""A non-data-URI mask must not skip the item's confidence and desync arrays."""
result = (
'[{"box_2d": [10, 10, 100, 100], "label": "cat", "mask": "bad", '
'"confidence": 0.8}, {"box_2d": [20, 20, 120, 120], "label": "dog", '
'"mask": "bad", "confidence": 0.9}]'
)
xyxy, _, _, confidence, masks = from_google_gemini_2_5(
result=result, resolution_wh=(640, 480)
)
assert xyxy.shape == (2, 4)
assert confidence is not None
assert confidence.shape == (2,)
assert np.allclose(confidence, [0.8, 0.9])
assert masks is not None
assert masks.shape == (2, 480, 640)
@pytest.mark.parametrize(
("parser", "result"),
[
pytest.param(
from_google_gemini_2_0, "[1, 2, 3]", id="gemini_2_0_non_dict_items"
),
pytest.param(from_google_gemini_2_0, "42", id="gemini_2_0_non_list"),
pytest.param(
from_google_gemini_2_5, "[1, 2, 3]", id="gemini_2_5_non_dict_items"
),
pytest.param(from_google_gemini_2_5, "42", id="gemini_2_5_non_list"),
pytest.param(from_qwen_2_5_vl, "[1, 2, 3]", id="qwen_2_5_non_dict_items"),
pytest.param(from_qwen_2_5_vl, "42", id="qwen_2_5_non_list"),
],
)
def test_vlm_parsers_degrade_on_malformed_json(parser, result):
"""Valid JSON of the wrong shape should yield empty results, not raise."""
kwargs: dict = {"resolution_wh": (640, 480)}
if parser is from_qwen_2_5_vl:
kwargs["input_wh"] = (640, 480)
xyxy = parser(result=result, **kwargs)[0]
assert xyxy.shape == (0, 4)
-128
View File
@@ -1,128 +0,0 @@
"""Tests for DetectionsSmoother bounding-box and confidence smoothing."""
from __future__ import annotations
import numpy as np
import pytest
from numpy.testing import assert_allclose
from supervision.detection.core import Detections
from supervision.detection.tools.smoother import DetectionsSmoother
from supervision.utils.internal import SupervisionWarnings
class TestDetectionsSmoother:
@pytest.mark.parametrize(
("conf1", "conf2", "expected_confidence"),
[
pytest.param(
np.array([0.5]),
np.array([0.7]),
np.array([0.6]),
id="with_confidence",
),
pytest.param(
None,
None,
None,
id="no_confidence",
),
pytest.param(
np.array([0.5]),
None,
np.array([0.5]),
id="mixed_window_averages_present",
),
],
)
def test_smoother_confidence_scenarios(
self,
conf1: np.ndarray | None,
conf2: np.ndarray | None,
expected_confidence: np.ndarray | None,
) -> None:
"""Boxes average over window; confidence averages present values or None."""
smoother = DetectionsSmoother(length=3)
smoother.update_with_detections(
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=conf1,
tracker_id=np.array([1]),
)
)
smoothed = smoother.update_with_detections(
Detections(
xyxy=np.array([[2, 2, 12, 12]], dtype=np.float32),
confidence=conf2,
tracker_id=np.array([1]),
)
)
assert_allclose(smoothed.xyxy, np.array([[1, 1, 11, 11]]), atol=1e-5)
if expected_confidence is None:
assert smoothed.confidence is None
else:
assert smoothed.confidence is not None
assert_allclose(smoothed.confidence, expected_confidence, atol=1e-5)
def test_smoother_multi_track_mixed_confidence_does_not_crash(self) -> None:
"""Two tracks with different confidence presence must not raise on merge."""
smoother = DetectionsSmoother(length=3)
smoother.update_with_detections(
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.5]),
tracker_id=np.array([1]),
)
)
smoothed = smoother.update_with_detections(
Detections(
xyxy=np.array([[20, 20, 30, 30]], dtype=np.float32),
tracker_id=np.array([2]),
)
)
assert len(smoothed) == 2
assert smoothed.confidence is None
def test_smoother_tracker_id_none_warns_and_returns_unchanged(self) -> None:
"""update_with_detections warns and returns input when tracker_id is None."""
smoother = DetectionsSmoother(length=3)
detections = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
tracker_id=None,
)
with pytest.warns(SupervisionWarnings):
result = smoother.update_with_detections(detections)
assert result is detections
def test_smoother_window_full_averages_all_frames(self) -> None:
"""Full window (length=3) averages all 3 frames, not just the last two."""
smoother = DetectionsSmoother(length=3)
smoother.update_with_detections(
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.3]),
tracker_id=np.array([1]),
)
)
smoother.update_with_detections(
Detections(
xyxy=np.array([[3, 3, 13, 13]], dtype=np.float32),
confidence=np.array([0.6]),
tracker_id=np.array([1]),
)
)
smoothed = smoother.update_with_detections(
Detections(
xyxy=np.array([[6, 6, 16, 16]], dtype=np.float32),
confidence=np.array([0.9]),
tracker_id=np.array([1]),
)
)
assert_allclose(smoothed.xyxy, np.array([[3, 3, 13, 13]]), atol=1e-5)
assert smoothed.confidence is not None
assert_allclose(smoothed.confidence, np.array([0.6]), atol=1e-5)
-81
View File
@@ -15,7 +15,6 @@ from supervision.detection.utils.converters import (
_rle_counts_to_mask,
is_compressed_rle,
mask_to_rle,
mask_to_xyxy,
rle_to_mask,
xcycwh_to_xyxy,
xywh_to_xyxy,
@@ -316,86 +315,6 @@ def test_xyxy_to_mask(boxes: np.ndarray, resolution_wh, expected: np.ndarray) ->
np.testing.assert_array_equal(result, expected)
def _mask_to_xyxy_reference(masks: np.ndarray) -> np.ndarray:
"""Per-mask `np.where` loop used as a ground-truth oracle."""
xyxy = np.zeros((masks.shape[0], 4), dtype=int)
for i, mask in enumerate(masks):
rows, cols = np.where(mask)
if len(rows) > 0 and len(cols) > 0:
xyxy[i, :] = [
int(cols.min()),
int(rows.min()),
int(cols.max()),
int(rows.max()),
]
return xyxy
class TestMaskToXyxy:
@pytest.mark.parametrize(
("masks", "expected"),
[
pytest.param(
np.zeros((0, 5, 5), dtype=bool),
np.zeros((0, 4), dtype=int),
id="no-masks",
),
pytest.param(
np.zeros((2, 5, 5), dtype=bool),
np.zeros((2, 4), dtype=int),
id="all-empty-masks",
),
pytest.param(
np.array([[[False, False], [False, True]]], dtype=bool),
np.array([[1, 1, 1, 1]], dtype=int),
id="single-pixel-bottom-right",
),
pytest.param(
np.array([[[True, False], [False, False]]], dtype=bool),
np.array([[0, 0, 0, 0]], dtype=int),
id="single-pixel-top-left",
),
pytest.param(
np.array([[[False, True], [False, False]]], dtype=bool),
np.array([[1, 0, 1, 0]], dtype=int),
id="single-pixel-top-right",
),
pytest.param(
np.array([[[False, False], [True, False]]], dtype=bool),
np.array([[0, 1, 0, 1]], dtype=int),
id="single-pixel-bottom-left",
),
pytest.param(
np.ones((1, 4, 6), dtype=bool),
np.array([[0, 0, 5, 3]], dtype=int),
id="full-mask",
),
],
)
def test_mask_to_xyxy_known_values(
self, masks: np.ndarray, expected: np.ndarray
) -> None:
"""Known boxes for empty, single-pixel, and full masks."""
result = mask_to_xyxy(masks)
assert result.dtype == expected.dtype
assert result.shape == expected.shape
np.testing.assert_array_equal(result, expected)
@pytest.mark.parametrize("seed", [0, 1, 2, 3, 4])
def test_mask_to_xyxy_matches_reference(self, seed: int) -> None:
"""Vectorized output is bit-identical to the per-mask scalar loop."""
rng = np.random.default_rng(seed)
masks = rng.random((6, 30, 40)) < 0.1
masks[0] = False # include an empty mask in the batch
result = mask_to_xyxy(masks)
reference = _mask_to_xyxy_reference(masks)
assert result.dtype == reference.dtype
np.testing.assert_array_equal(result, reference)
@pytest.mark.parametrize(
("mask", "compressed", "expected_rle", "exception"),
[
-159
View File
@@ -11,7 +11,6 @@ from supervision.detection.utils.iou_and_nms import (
box_iou,
box_iou_batch,
box_non_max_suppression,
mask_iou_batch,
mask_non_max_merge,
mask_non_max_suppression,
oriented_box_iou_batch,
@@ -1581,161 +1580,3 @@ class TestOrientedBoxNonMaxMerge:
sorted_groups = sorted(sorted(g) for g in groups)
assert sorted_groups == [[0, 1], [2]]
def _naive_mask_iou(
masks_true: np.ndarray,
masks_detection: np.ndarray,
overlap_metric: OverlapMetric = OverlapMetric.IOU,
) -> np.ndarray:
"""Reference IoU that materialises the (N, M, H, W) overlap explicitly."""
intersection = np.logical_and(masks_true[:, None], masks_detection).sum(axis=(2, 3))
area_true = masks_true.sum(axis=(1, 2))
area_detection = masks_detection.sum(axis=(1, 2))
if overlap_metric == OverlapMetric.IOU:
denominator = area_true[:, None] + area_detection - intersection
else:
denominator = np.minimum(area_true[:, None], area_detection)
return np.divide(
intersection,
denominator,
out=np.zeros((len(masks_true), len(masks_detection)), dtype=float),
where=denominator != 0,
)
class TestMaskIouBatch:
"""`mask_iou_batch` (matmul intersection) matches the naive boolean reference."""
@pytest.mark.parametrize(
"overlap_metric",
[
pytest.param(OverlapMetric.IOU, id="iou"),
pytest.param(OverlapMetric.IOS, id="ios"),
],
)
@pytest.mark.parametrize(
("n_true", "n_detection", "height", "width"),
[
pytest.param(1, 1, 20, 24, id="single"),
pytest.param(7, 7, 32, 40, id="several-square"),
pytest.param(5, 9, 28, 36, id="rectangular"),
],
)
def test_matches_naive_reference_on_random_masks(
self,
n_true: int,
n_detection: int,
height: int,
width: int,
overlap_metric: OverlapMetric,
) -> None:
"""Random masks give the same matrix as the explicit (N, M, H, W) reference."""
rng = np.random.default_rng(0)
masks_true = rng.random((n_true, height, width)) > 0.5
masks_detection = rng.random((n_detection, height, width)) > 0.5
result = mask_iou_batch(masks_true, masks_detection, overlap_metric)
expected = _naive_mask_iou(masks_true, masks_detection, overlap_metric)
np.testing.assert_allclose(result, expected)
def test_chunking_matches_single_pass(self) -> None:
"""A tiny memory_limit forces chunking yet returns the same matrix."""
rng = np.random.default_rng(1)
masks_true = rng.random((6, 16, 16)) > 0.5
masks_detection = rng.random((4, 16, 16)) > 0.5
single_pass = mask_iou_batch(masks_true, masks_detection)
chunked = mask_iou_batch(masks_true, masks_detection, memory_limit=0)
np.testing.assert_allclose(single_pass, chunked, rtol=1e-6)
# empty masks_true under a tiny limit must not hit np.vstack([])
empty = mask_iou_batch(
np.zeros((0, 16, 16), dtype=bool), masks_detection, memory_limit=0
)
assert empty.shape == (0, 4)
def test_mismatched_spatial_shape_raises(self) -> None:
"""Equal pixel counts but different (H, W) must raise, not silently mismatch."""
masks_true = np.zeros((1, 4, 9), dtype=bool)
masks_detection = np.zeros((1, 6, 6), dtype=bool) # 36 px, different (H, W)
with pytest.raises(ValueError, match="must share the same"):
mask_iou_batch(masks_true, masks_detection)
def test_invalid_ndim_raises(self) -> None:
"""2D and 4D inputs must raise ValueError before reaching shape[1:] check."""
mask_3d = np.zeros((1, 10, 10), dtype=bool)
with pytest.raises(ValueError, match="3D"):
mask_iou_batch(np.zeros((10, 10), dtype=bool), mask_3d)
with pytest.raises(ValueError, match="3D"):
mask_iou_batch(mask_3d, np.zeros((10, 10), dtype=bool))
with pytest.raises(ValueError, match="3D"):
mask_iou_batch(np.zeros((1, 10, 10, 1), dtype=bool), mask_3d)
def test_identical_disjoint_and_empty(self) -> None:
"""Identical/all-True → 1.0; all-False/disjoint → 0.0; empty keeps shape."""
mask = np.zeros((1, 10, 10), dtype=bool)
mask[0, 2:6, 2:6] = True
far = np.zeros((1, 10, 10), dtype=bool)
far[0, 7:9, 7:9] = True
assert mask_iou_batch(mask, mask)[0, 0] == pytest.approx(1.0)
assert mask_iou_batch(mask, far)[0, 0] == 0.0
assert mask_iou_batch(np.zeros((0, 10, 10), dtype=bool), mask).shape == (0, 1)
# all-True: denominator is non-zero, result must be exactly 1.0
all_true = np.ones((2, 5, 5), dtype=bool)
np.testing.assert_allclose(mask_iou_batch(all_true, all_true), np.ones((2, 2)))
# all-False: denominator is zero; result must be 0.0 (not NaN)
all_false = np.zeros((2, 5, 5), dtype=bool)
np.testing.assert_array_equal(
mask_iou_batch(all_false, all_false), np.zeros((2, 2))
)
def test_matmul_emits_no_runtime_warnings(
self, recwarn: pytest.WarningsChecker
) -> None:
"""mask_iou_batch on bool input must not emit any RuntimeWarning."""
rng = np.random.default_rng(2)
masks_true = rng.random((7, 32, 40)) > 0.5
masks_detection = rng.random((5, 32, 40)) > 0.5
mask_iou_batch(masks_true, masks_detection)
runtime = [w for w in recwarn.list if issubclass(w.category, RuntimeWarning)]
assert not runtime, (
f"Unexpected RuntimeWarnings: {[str(w.message) for w in runtime]}"
)
def test_float64_promotion_for_large_float32_masks(self) -> None:
"""float32 masks with pixel count > 2**24 must give exact IoU via float64."""
# 1x(2**24+1) pixels: float32 area sum rounds, float64 area sum is exact.
# Without CRIT-2 fix, IoU would exceed 1.0 due to dtype mismatch.
mask = np.ones((1, 1, 2**24 + 1), dtype=np.float32)
result = mask_iou_batch(mask, mask)
assert result[0, 0] == pytest.approx(1.0)
def test_compact_mask_mixed_input_gives_same_result(self) -> None:
"""CompactMask + dense mixed dispatch must match dense + dense."""
from supervision.detection.compact_mask import CompactMask
from supervision.detection.utils.converters import mask_to_xyxy
rng = np.random.default_rng(3)
masks = (rng.random((4, 20, 24)) > 0.3).astype(bool)
xyxy = mask_to_xyxy(masks)
cm = CompactMask.from_dense(masks, xyxy, image_shape=(20, 24))
dense_vs_dense = mask_iou_batch(masks, masks)
np.testing.assert_allclose(mask_iou_batch(cm, masks), dense_vs_dense, rtol=1e-5)
np.testing.assert_allclose(mask_iou_batch(masks, cm), dense_vs_dense, rtol=1e-5)
def test_detection_exceeds_limit_warns_and_completes(self) -> None:
"""detection > memory_limit emits UserWarning; result shape is still correct."""
rng = np.random.default_rng(4)
masks_true = rng.random((3, 32, 32)) > 0.5
# 500 x 1024 px x 4 bytes ~2 MB > memory_limit=1 MB triggers OOM warning.
masks_detection = rng.random((500, 32, 32)) > 0.5
with pytest.warns(UserWarning, match="exceed"):
result = mask_iou_batch(masks_true, masks_detection, memory_limit=1)
assert result.shape == (3, 500)
+1 -70
View File
@@ -5,10 +5,7 @@ from contextlib import ExitStack as DoesNotRaise
import numpy as np
import pytest
from supervision.detection.utils.polygons import (
approximate_polygon,
filter_polygons_by_area,
)
from supervision.detection.utils.polygons import filter_polygons_by_area
@pytest.mark.parametrize(
@@ -103,69 +100,3 @@ def test_filter_polygons_by_area(
assert len(result) == len(expected_result)
for result_polygon, expected_result_polygon in zip(result, expected_result):
assert np.array_equal(result_polygon, expected_result_polygon)
def _regular_polygon(num_points: int, radius: float = 40.0) -> np.ndarray:
angles = np.linspace(0, 2 * np.pi, num_points, endpoint=False)
return np.stack(
[50 + radius * np.cos(angles), 50 + radius * np.sin(angles)], axis=1
).astype(np.float32)
class TestApproximatePolygon:
@pytest.mark.parametrize("num_points", [20, 50, 100, 200])
@pytest.mark.parametrize("percentage", [0.1, 0.5, 0.75, 0.9])
def test_within_budget_and_valid(self, num_points: int, percentage: float) -> None:
"""The result stays a valid polygon and respects the point budget.
The exception is the 3-point floor: keeping a valid polygon (at least 3
points) can leave more points than the budget by an arbitrary margin,
since a single epsilon step may jump straight from above budget to below
3 points.
"""
polygon = _regular_polygon(num_points)
target_points = max(int(num_points * (1 - percentage)), 3)
result = approximate_polygon(polygon, percentage=percentage)
assert result.ndim == 2
assert result.shape[1] == 2
assert 3 <= len(result) <= num_points
if target_points > 3:
assert len(result) <= target_points
def test_zero_percentage_keeps_polygon(self) -> None:
"""A percentage of 0 removes no points."""
polygon = _regular_polygon(40)
result = approximate_polygon(polygon, percentage=0.0)
assert len(result) == len(polygon)
@pytest.mark.parametrize(
"percentage",
[
pytest.param(-0.001, id="just-below-zero"),
pytest.param(-1.0, id="negative-one"),
pytest.param(1.0, id="exactly-one"),
pytest.param(1.5, id="above-one"),
],
)
def test_raises_on_out_of_range_percentage(self, percentage: float) -> None:
"""Percentage outside [0, 1) must raise ValueError."""
polygon = _regular_polygon(20)
with pytest.raises(ValueError, match="Percentage must be in the range"):
approximate_polygon(polygon, percentage=percentage)
@pytest.mark.parametrize(
"epsilon_step",
[
pytest.param(0.0, id="zero"),
pytest.param(-0.05, id="negative"),
],
)
def test_raises_on_non_positive_epsilon_step(self, epsilon_step: float) -> None:
"""Non-positive epsilon_step must raise ValueError."""
polygon = _regular_polygon(20)
with pytest.raises(ValueError, match="epsilon_step must be positive"):
approximate_polygon(polygon, percentage=0.5, epsilon_step=epsilon_step)
-425
View File
@@ -706,75 +706,6 @@ def test_key_points_as_detections_empty():
assert empty_detections.is_empty()
def test_key_points_as_detections_ignores_missing_keypoints():
"""A [0, 0] keypoint is treated as missing and excluded from the box."""
key_points = _create_key_points(
xy=[[[0, 0], [10, 20], [30, 40]]],
confidence=[[0.0, 0.8, 0.6]],
class_id=[0],
)
detections = key_points.as_detections()
assert np.array_equal(detections.xyxy, np.array([[10, 20, 30, 40]]))
def test_key_points_as_detections_uses_detection_confidence():
"""detection_confidence is preferred over the keypoint-confidence mean."""
key_points = _create_key_points(
xy=[[[10, 20], [30, 40]]],
confidence=[[0.1, 0.2]],
class_id=[0],
detection_confidence=[0.95],
)
detections = key_points.as_detections()
assert np.allclose(detections.confidence, np.array([0.95], dtype=np.float32))
def test_key_points_as_detections_selected_keypoint_indices():
"""Only the selected keypoints contribute to the bounding box."""
key_points = _create_key_points(
xy=[[[0, 0], [10, 20], [30, 40], [100, 100]]],
confidence=[[0.5, 0.8, 0.6, 0.9]],
class_id=[0],
)
detections = key_points.as_detections(selected_keypoint_indices=[1, 2])
assert np.array_equal(detections.xyxy, np.array([[10, 20, 30, 40]]))
def test_key_points_as_detections_confidence_over_selected_indices():
"""Confidence mean uses only the selected keypoint columns, not all."""
key_points = _create_key_points(
xy=[[[0, 0], [10, 20], [30, 40], [100, 100]]],
confidence=[[0.5, 0.8, 0.6, 0.9]],
class_id=[0],
)
detections = key_points.as_detections(selected_keypoint_indices=[1, 2])
expected_confidence = np.mean([0.8, 0.6], dtype=np.float32)
assert np.isclose(detections.confidence[0], expected_confidence)
def test_key_points_as_detections_mixed_valid_invalid_batch():
"""Batch with one all-zero skeleton: invalid skeleton gets box zeroed."""
key_points = _create_key_points(
xy=[[[0, 0], [0, 0]], [[10, 20], [30, 40]]],
confidence=[[0.0, 0.0], [0.8, 0.6]],
class_id=[0, 1],
)
detections = key_points.as_detections()
# Only the valid skeleton survives the area>0 filter
assert len(detections) == 1
assert np.array_equal(detections.xyxy, np.array([[10, 20, 30, 40]]))
def test_key_points_as_detections_with_data():
"""Test the as_detections method preserves data."""
key_points = _create_key_points(
@@ -1082,359 +1013,3 @@ class TestDeprecatedConfidenceConstructor:
assert field_names == init_params, (
f"Field/init drift: {field_names.symmetric_difference(init_params)}"
)
@pytest.mark.parametrize(
("key_points", "threshold", "class_agnostic", "expected_result"),
[
pytest.param(
KeyPoints.empty(),
0.5,
True,
KeyPoints.empty(),
id="empty",
),
pytest.param(
_create_key_points(
xy=[[[10, 20], [30, 40]]],
detection_confidence=[0.9],
class_id=[0],
),
0.5,
False,
_create_key_points(
xy=[[[10, 20], [30, 40]]],
detection_confidence=[0.9],
class_id=[0],
),
id="single-skeleton",
),
pytest.param(
_create_key_points(
xy=[
[[10, 10], [20, 20]],
[[500, 500], [600, 600]],
],
detection_confidence=[0.9, 0.8],
class_id=[0, 0],
),
0.5,
False,
_create_key_points(
xy=[
[[10, 10], [20, 20]],
[[500, 500], [600, 600]],
],
detection_confidence=[0.9, 0.8],
class_id=[0, 0],
),
id="two-non-overlapping",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[150, 150], [250, 250]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 0],
),
0.9,
False,
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[150, 150], [250, 250]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 0],
),
id="two-overlapping-below-threshold",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 0],
),
0.3,
False,
_create_key_points(
xy=[[[100, 100], [200, 200]]],
detection_confidence=[0.9],
class_id=[0],
),
id="two-overlapping-above-threshold",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[110, 110], [210, 210]],
[[500, 500], [600, 600]],
],
detection_confidence=[0.9, 0.7, 0.8],
class_id=[0, 0, 0],
),
0.3,
False,
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[500, 500], [600, 600]],
],
detection_confidence=[0.9, 0.8],
class_id=[0, 0],
),
id="three-skeletons-two-overlap",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 1],
),
0.3,
False,
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 1],
),
id="class-aware-different-classes-kept",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 1],
),
0.3,
True,
_create_key_points(
xy=[[[100, 100], [200, 200]]],
detection_confidence=[0.9],
class_id=[0],
),
id="class-agnostic-suppresses-across-classes",
),
pytest.param(
_create_key_points(
xy=[
[[0, 0], [100, 100], [200, 200]],
[[0, 0], [110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 0],
),
0.3,
False,
_create_key_points(
xy=[[[0, 0], [100, 100], [200, 200]]],
detection_confidence=[0.9],
class_id=[0],
),
id="zero-keypoints-excluded-from-bbox",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [200, 200], [0, 0], [0, 0]],
[[0, 0], [0, 0], [110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 1],
),
0.3,
False,
_create_key_points(
xy=[
[[100, 100], [200, 200], [0, 0], [0, 0]],
[[0, 0], [0, 0], [110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 1],
),
id="multi-skeleton-schema-class-aware",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [200, 200], [0, 0], [0, 0]],
[[0, 0], [0, 0], [110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 1],
),
0.3,
True,
_create_key_points(
xy=[[[100, 100], [200, 200], [0, 0], [0, 0]]],
detection_confidence=[0.9],
class_id=[0],
),
id="multi-skeleton-schema-class-agnostic",
),
pytest.param(
_create_key_points(
xy=[
[[0, 0], [0, 0]],
[[100, 100], [200, 200]],
],
detection_confidence=[0.9, 0.8],
class_id=[0, 0],
),
0.5,
False,
_create_key_points(
xy=[
[[0, 0], [0, 0]],
[[100, 100], [200, 200]],
],
detection_confidence=[0.9, 0.8],
class_id=[0, 0],
),
id="all-zero-skeleton-passes-through",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 0],
visible=[[True, False], [False, True]],
),
0.3,
False,
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 0],
visible=[[True, False], [False, True]],
),
id="visible-mask-excludes-keypoints-from-bbox",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [0, 0]],
[[300, 300], [0, 0]],
],
detection_confidence=[0.9, 0.8],
class_id=[0, 0],
),
0.3,
False,
_create_key_points(
xy=[
[[100, 100], [0, 0]],
[[300, 300], [0, 0]],
],
detection_confidence=[0.9, 0.8],
class_id=[0, 0],
),
id="single-valid-keypoint-zero-area-bbox",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 0],
),
0.0,
False,
_create_key_points(
xy=[[[100, 100], [200, 200]]],
detection_confidence=[0.9],
class_id=[0],
),
id="threshold-zero-suppresses-any-overlap",
),
pytest.param(
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 0],
),
1.0,
False,
_create_key_points(
xy=[
[[100, 100], [200, 200]],
[[110, 110], [210, 210]],
],
detection_confidence=[0.9, 0.7],
class_id=[0, 0],
),
id="threshold-one-keeps-all",
),
],
)
def test_with_nms(key_points, threshold, class_agnostic, expected_result):
"""NMS filters overlapping keypoint skeletons."""
result = key_points.with_nms(threshold=threshold, class_agnostic=class_agnostic)
assert result == expected_result
@pytest.mark.parametrize(
("key_points", "threshold", "class_agnostic", "match"),
[
pytest.param(
_create_key_points(
xy=[[[10, 20], [30, 40]]],
class_id=[0],
),
0.5,
False,
"detection_confidence",
id="no-detection-confidence",
),
pytest.param(
_create_key_points(
xy=[[[10, 20], [30, 40]]],
detection_confidence=[0.9],
),
0.5,
False,
"class_id",
id="no-class-id-class-aware",
),
pytest.param(
_create_key_points(
xy=[[[10, 20], [30, 40]]],
class_id=[0],
),
0.5,
True,
"detection_confidence",
id="no-detection-confidence-class-agnostic",
),
],
)
def test_with_nms_raises(key_points, threshold, class_agnostic, match):
"""NMS raises when required fields are missing."""
with pytest.raises(ValueError, match=match):
key_points.with_nms(threshold=threshold, class_agnostic=class_agnostic)
-80
View File
@@ -124,86 +124,6 @@ class TestF1Score:
assert result.f1_50 == 0.0
assert result.f1_75 == 0.0
def test_false_positives_on_background_image_counted(self):
"""Predictions on an image with no targets must count as false positives."""
predictions_with_gt = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
confidence=np.array([0.9]),
)
targets_with_gt = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
)
background_predictions = Detections(
xyxy=np.array([[20, 0, 25, 5], [40, 0, 45, 5], [60, 0, 65, 5]], np.float32),
class_id=np.array([0, 0, 0]),
confidence=np.array([0.9, 0.9, 0.9]),
)
metric = F1Score(averaging_method=AveragingMethod.MICRO)
result = metric.update(
[predictions_with_gt, background_predictions],
[targets_with_gt, Detections.empty()],
).compute()
# TP=1, FP=3, FN=0 -> F1 = 2*1 / (2*1 + 3 + 0) = 0.4
assert result.f1_50 == pytest.approx(0.4)
assert 0 in result.matched_classes
@pytest.mark.parametrize(
("method", "expected"),
[
pytest.param(
AveragingMethod.MICRO, 0.5, id="micro-counts-absent-class-fps"
),
pytest.param(
AveragingMethod.MACRO, 0.5, id="macro-counts-absent-class-fps"
),
pytest.param(
AveragingMethod.WEIGHTED,
1.0,
id="weighted-absent-class-fps-not-counted-by-design",
),
],
)
def test_false_positives_of_absent_class_counted(self, method, expected):
"""Predictions of absent class count as FPs under MICRO/MACRO; WEIGHTED
excludes them by design (GT support=0 → weight=0, consistent with sklearn)."""
predictions = Detections(
xyxy=np.array(
[[0, 0, 10, 10], [100, 0, 110, 10], [120, 0, 130, 10]], np.float32
),
class_id=np.array([0, 1, 1]), # class 1 never appears in the targets
confidence=np.array([0.9, 0.8, 0.7]),
)
targets = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
)
metric = F1Score(averaging_method=method)
result = metric.update(predictions, targets).compute()
# MICRO: TP=1, FP=2, FN=0 -> F1 = 2/(2+2) = 0.5
# MACRO: mean([F1_class0=1.0, F1_class1=0.0]) = 0.5
# WEIGHTED: class_1 weight=0 -> only class 0 contributes -> 1.0
assert result.f1_50 == pytest.approx(expected)
def test_false_positives_on_background_image_weighted_returns_zero(self):
"""WEIGHTED F1 is 0.0 when all images are background (no GT anywhere)."""
background_predictions = Detections(
xyxy=np.array([[20, 0, 25, 5], [40, 0, 45, 5]], np.float32),
class_id=np.array([0, 0]),
confidence=np.array([0.9, 0.8]),
)
metric = F1Score(averaging_method=AveragingMethod.WEIGHTED)
result = metric.update(background_predictions, Detections.empty()).compute()
# No GT support anywhere -> class_counts.sum() == 0 -> returns 0.0
assert result.f1_50 == 0.0
def test_single_class_mixed_results(
self, predictions_confidence_ranking, targets_50_50
):
-80
View File
@@ -115,86 +115,6 @@ class TestPrecision:
assert result.precision_at_50 == 0.0
assert result.precision_at_75 == 0.0
def test_false_positives_on_background_image_counted(self):
"""Predictions on an image with no targets must count as false positives."""
predictions_with_gt = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
confidence=np.array([0.9]),
)
targets_with_gt = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
)
background_predictions = Detections(
xyxy=np.array([[20, 0, 25, 5], [40, 0, 45, 5], [60, 0, 65, 5]], np.float32),
class_id=np.array([0, 0, 0]),
confidence=np.array([0.9, 0.9, 0.9]),
)
metric = Precision(averaging_method=AveragingMethod.MICRO)
result = metric.update(
[predictions_with_gt, background_predictions],
[targets_with_gt, Detections.empty()],
).compute()
# 1 TP, 3 background FPs -> precision = 1 / (1 + 3) = 0.25
assert result.precision_at_50 == 0.25
assert 0 in result.matched_classes
@pytest.mark.parametrize(
("method", "expected"),
[
pytest.param(
AveragingMethod.MICRO, 1 / 3, id="micro-counts-absent-class-fps"
),
pytest.param(
AveragingMethod.MACRO, 0.5, id="macro-counts-absent-class-fps"
),
pytest.param(
AveragingMethod.WEIGHTED,
1.0,
id="weighted-absent-class-fps-not-counted-by-design",
),
],
)
def test_false_positives_of_absent_class_counted(self, method, expected):
"""Predictions of absent class count as FPs under MICRO/MACRO; WEIGHTED
excludes them by design (GT support=0 → weight=0, consistent with sklearn)."""
predictions = Detections(
xyxy=np.array(
[[0, 0, 10, 10], [100, 0, 110, 10], [120, 0, 130, 10]], np.float32
),
class_id=np.array([0, 1, 1]), # class 1 never appears in the targets
confidence=np.array([0.9, 0.8, 0.7]),
)
targets = Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
class_id=np.array([0]),
)
metric = Precision(averaging_method=method)
result = metric.update(predictions, targets).compute()
# MICRO: 1 TP (class 0), 2 FPs (class 1) -> 1/3
# MACRO: mean([precision_class0=1.0, precision_class1=0.0]) = 0.5
# WEIGHTED: class_1 weight=0 -> only class 0 contributes -> 1.0
assert result.precision_at_50 == pytest.approx(expected)
def test_false_positives_on_background_image_weighted_returns_zero(self):
"""WEIGHTED precision is 0.0 when all images are background (no GT anywhere)."""
background_predictions = Detections(
xyxy=np.array([[20, 0, 25, 5], [40, 0, 45, 5]], np.float32),
class_id=np.array([0, 0]),
confidence=np.array([0.9, 0.8]),
)
metric = Precision(averaging_method=AveragingMethod.WEIGHTED)
result = metric.update(background_predictions, Detections.empty()).compute()
# No GT support anywhere -> class_counts.sum() == 0 -> returns 0.0
assert result.precision_at_50 == 0.0
def test_single_class(self, predictions_confidence_ranking, targets_50_50):
"""Test precision calculation for single class with mixed results"""
metric = Precision()
Generated
+4 -4
View File
@@ -1791,7 +1791,7 @@ wheels = [
[[package]]
name = "jupyterlab"
version = "4.5.9"
version = "4.5.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-lru" },
@@ -1810,9 +1810,9 @@ dependencies = [
{ name = "tornado" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/a8d4895bef501ffeb6af448e8bf7079541c7772978211963aa653518c2d9/jupyterlab-4.5.9.tar.gz", hash = "sha256:dd79a073fecae7a39066ea99e4627ed6c76269ac926e95a810e1e1df6358d865", size = 23994445, upload-time = "2026-06-17T15:42:16.406Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2b/22/8440ec827762146e7cdecf04335bd348795899d29dc6ae82238707353a2c/jupyterlab-4.5.7.tar.gz", hash = "sha256:55a9822c4754da305f41e113452c68383e214dcf96de760146af89ce5d5117b0", size = 23992763, upload-time = "2026-04-29T16:43:51.328Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c6/bb/2f9b425062416fba58f580c9b89c3b07277ccdf0a292501fedbca8ea00ea/jupyterlab-4.5.9-py3-none-any.whl", hash = "sha256:5ff0f908e8ac0afbed32b106fdef360f101c0a6654d1bf4a81e98a293ae1b336", size = 12449803, upload-time = "2026-06-17T15:42:12.18Z" },
{ url = "https://files.pythonhosted.org/packages/3d/aa/537b8f7d80e799af19af35fb3ddfc970b951088a13c57dd9387dcfbb7f61/jupyterlab-4.5.7-py3-none-any.whl", hash = "sha256:fba4cb0e2c44a52859669d8c98b45de029d5e515f8407bf8534d2a8fc5f0964d", size = 12450123, upload-time = "2026-04-29T16:43:46.639Z" },
]
[[package]]
@@ -4143,7 +4143,7 @@ wheels = [
[[package]]
name = "supervision"
version = "0.29.1"
version = "0.29.0"
source = { editable = "." }
dependencies = [
{ name = "defusedxml" },