Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8caacebb96 | ||
|
|
09d9808179 | ||
|
|
b44810b03c | ||
|
|
c580730a08 | ||
|
|
ea3b51feb4 |
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
-311
File diff suppressed because one or more lines are too long
Vendored
-4
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"),
|
||||
[
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user