Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49ecac0376 | ||
|
|
9251893bd9 | ||
|
|
ccd7098f2f | ||
|
|
07d182be56 | ||
|
|
cca3c14911 | ||
|
|
44546a13f2 | ||
|
|
4b60bbc9cc | ||
|
|
6918d44190 | ||
|
|
a48532f6f5 | ||
|
|
11a586c133 | ||
|
|
31e84f7909 | ||
|
|
7e765eb054 | ||
|
|
393ff52954 | ||
|
|
c9962c9262 | ||
|
|
04b6d45ea2 | ||
|
|
2854932965 | ||
|
|
bcc785fbb5 | ||
|
|
c0df72b84a | ||
|
|
15cf80abac | ||
|
|
b9c24ddafd | ||
|
|
c2490e6015 | ||
|
|
a914a00ab8 | ||
|
|
d3a0a84dd5 | ||
|
|
1e551ec9c0 | ||
|
|
1fa735d0d9 | ||
|
|
62a24d7960 | ||
|
|
3d07099cf9 | ||
|
|
483e3e9335 | ||
|
|
e0e1abd865 | ||
|
|
117a9baab8 | ||
|
|
497f336d13 |
@@ -252,6 +252,18 @@ Type hints are required on all new code. mypy is enforced by the pre-commit hook
|
||||
- Prefer vectorized operations over Python loops in hot paths.
|
||||
- Lazy-import heavy framework dependencies (`torch`, `transformers`, `ultralytics`) inside the function that needs them — never at module top level.
|
||||
|
||||
### Deprecation policy
|
||||
|
||||
**Minimum window**: deprecated APIs must remain for at least **3 minor releases** before removal. Example: deprecated in `0.29.0` → removed in `0.32.0`.
|
||||
|
||||
Use the appropriate mechanism depending on what is being deprecated:
|
||||
|
||||
- **Module-level alias**: `supervision.utils.internal.warn_deprecated` in the deprecated module's `__init__.py`
|
||||
- **Renamed parameter**: `supervision.utils.internal.deprecated_parameter` decorator
|
||||
- **Public function, method, or class**: `@deprecated` from `pydeprecate`
|
||||
|
||||
Always specify both the deprecation version and the planned removal version in the message or decorator arguments.
|
||||
|
||||
### Deprecated module aliases
|
||||
|
||||
`supervision.keypoint` is deprecated since `0.27.0` and will be removed in `0.30.0`. Always import from `supervision.key_points`:
|
||||
|
||||
@@ -19,6 +19,7 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -62,6 +62,7 @@ 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
|
||||
|
||||
@@ -75,6 +75,8 @@ For branching, commit, code style, and API design conventions see [CONTRIBUTING.
|
||||
|
||||
## 5. Deprecating APIs
|
||||
|
||||
**Minimum window**: deprecated APIs must remain for at least **3 minor releases** before removal. Example: deprecated in `0.29.0` → removed in `0.32.0`.
|
||||
|
||||
- Module-level: `supervision.utils.internal.warn_deprecated` in the deprecated module's own `__init__.py`
|
||||
- Parameter renamed (old→new): `supervision.utils.internal.deprecated_parameter` decorator
|
||||
- Public function, method, or class: `@deprecated` from `pydeprecate`
|
||||
@@ -82,7 +84,7 @@ For branching, commit, code style, and API design conventions see [CONTRIBUTING.
|
||||
Always name the version introduced and the removal version:
|
||||
|
||||
```python
|
||||
warn_deprecated("'foo' deprecated in `0.27.0`, removed in `0.30.0`. Use 'bar'.")
|
||||
warn_deprecated("'foo' deprecated in `0.29.0`, removed in `0.32.0`. Use 'bar'.")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
<p>
|
||||
<a align="center" href="" target="https://supervision.roboflow.com">
|
||||
<a align="center" href="https://supervision.roboflow.com" target="_blank">
|
||||
<img
|
||||
width="100%"
|
||||
src="https://media.roboflow.com/open-source/supervision/rf-supervision-banner.png?updatedAt=1678995927529"
|
||||
@@ -24,11 +24,27 @@
|
||||
|
||||
</div>
|
||||
|
||||
## 👋 hello
|
||||
<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
|
||||
|
||||
**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.
|
||||
|
||||
@@ -38,9 +54,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.
|
||||
|
||||
@@ -51,7 +67,7 @@ import supervision as sv
|
||||
from PIL import Image
|
||||
from rfdetr import RFDETRSmall
|
||||
|
||||
image = Image.open(...)
|
||||
image = Image.open("path/to/image.jpg")
|
||||
model = RFDETRSmall()
|
||||
detections = model.predict(image, threshold=0.5)
|
||||
|
||||
@@ -71,7 +87,7 @@ len(detections)
|
||||
from PIL import Image
|
||||
from inference import get_model
|
||||
|
||||
image = Image.open(...)
|
||||
image = Image.open("path/to/image.jpg")
|
||||
model = get_model(model_id="rfdetr-small", api_key="ROBOFLOW_API_KEY")
|
||||
result = model.infer(image)[0]
|
||||
detections = sv.Detections.from_inference(result)
|
||||
@@ -82,7 +98,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.
|
||||
|
||||
@@ -90,7 +106,8 @@ Supervision offers a wide range of highly customizable [annotators](https://supe
|
||||
import cv2
|
||||
import supervision as sv
|
||||
|
||||
image = cv2.imread(...)
|
||||
image = cv2.imread("path/to/image.jpg")
|
||||
# Assuming detections are obtained from a model
|
||||
detections = sv.Detections(...)
|
||||
|
||||
box_annotator = sv.BoxAnnotator()
|
||||
@@ -99,7 +116,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.
|
||||
|
||||
@@ -123,7 +140,7 @@ for path, image, annotation in ds:
|
||||
pass
|
||||
```
|
||||
|
||||
<details close>
|
||||
<details>
|
||||
<summary>👉 more dataset utils</summary>
|
||||
|
||||
- load
|
||||
@@ -213,7 +230,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/)!
|
||||
|
||||
@@ -233,7 +250,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)
|
||||
|
||||
@@ -243,11 +260,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!
|
||||
|
||||
@@ -259,8 +276,6 @@ 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
|
||||
@@ -295,6 +310,7 @@ 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
|
||||
@@ -302,6 +318,4 @@ We love your input! Please see our [contributing guide](.github/CONTRIBUTING.md)
|
||||
width="3%"
|
||||
/>
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+81
-11
@@ -1,25 +1,97 @@
|
||||
---
|
||||
description: "Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version."
|
||||
date_modified: 2026-06-09
|
||||
date_modified: 2026-06-16
|
||||
---
|
||||
|
||||
# Changelog
|
||||
|
||||
### UnReleased
|
||||
### 0.29.1 <small>Jun 23, 2026</small>
|
||||
|
||||
- Fixed [#2306](https://github.com/roboflow/supervision/pull/2306): [`sv.Detections.area`](https://supervision.roboflow.com/latest/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/latest/detection/annotators/#supervision.annotators.core.MaskAnnotator) and [`HaloAnnotator`](https://supervision.roboflow.com/latest/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 [#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 [#2289](https://github.com/roboflow/supervision/pull/2289): [`DetectionDataset.as_yolo`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.as_yolo) now accepts `is_obb=True` to round-trip oriented bounding box datasets without losing the rotation. Previously the save path had no OBB option and silently wrote 5-token axis-aligned bbox lines for datasets loaded with `from_yolo(..., is_obb=True)`, dropping the four corners stored in `detections.data["xyxyxyxy"]`. Re-loading those saved files with `is_obb=True` then crashed the validator. Mirrors the existing `from_yolo(..., is_obb=True)` load path.
|
||||
- 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.
|
||||
|
||||
- Deprecated: public `validate_*` helper functions now delegate to private `_validate_*` implementations via `pydeprecate` shims. Existing imports continue to work with a `FutureWarning`; internal Supervision code uses the private helpers directly.
|
||||
- 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 [#2282](https://github.com/roboflow/supervision/pull/2282): [`oriented_box_iou_batch`](https://supervision.roboflow.com/latest/detection/utils/iou_and_nms/) now allocates the rasterization canvas with correct dimensions. Previously x-extents were mapped to canvas height and y-extents to width, causing IoU to be computed on a transposed canvas for non-square oriented bounding boxes. Metrics using OBB IoU (Precision, Recall, F1Score, MeanAverageRecall with `metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES`) now return correct scores.
|
||||
- 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 [#2276](https://github.com/roboflow/supervision/pull/2276): [`DetectionDataset.as_coco`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.as_coco) now emits 1-indexed `category_id` and `categories[].id` values as required by the COCO spec, fixing CVAT import failures reported in [#1181](https://github.com/roboflow/supervision/issues/1181). Files generated by earlier supervision versions used 0-indexed category ids; they can still be loaded correctly by `from_coco` because the read path maps categories by name, not by id value.
|
||||
- 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.
|
||||
|
||||
- Added [#2267](https://github.com/roboflow/supervision/pull/2267): [`DetectionDataset.as_coco`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.as_coco) and `save_coco_annotations` now accept `starting_image_id` and `starting_annotation_id` parameters (both default to `1`, preserving existing behavior) and return a `(next_image_id, next_annotation_id)` tuple. Feed the returned values into the next split's call to produce globally unique COCO ids across train/valid/test exports. Fixes id collisions reported in [#768](https://github.com/roboflow/supervision/issues/768). **Note**: the return type changes from `None` to `tuple[int, int]` — callers that assert `result is None` must be updated.
|
||||
- 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`.
|
||||
|
||||
- Changed [#2303](https://github.com/roboflow/supervision/pull/2303): [`sv.Detections.with_nms`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.with_nms) and [`sv.Detections.with_nmm`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.with_nmm) now use oriented-box IoU when `data["xyxyxyxy"]` coordinates are present, fixing silent suppression of crossed/rotated OBB detections (reported in [#1679](https://github.com/roboflow/supervision/issues/1679)). Previously, both methods always used axis-aligned bounding-box IoU even for OBB inputs, causing the X-pattern AABB IoU ≈ 1.0 to suppress valid detections at any practical threshold. Callers relying on the old AABB behaviour (e.g. to force AABB suppression on OBB data) should remove `data["xyxyxyxy"]` before calling these methods. Threshold values calibrated against AABB IoU may need adjustment when OBB IoU is substantially different.
|
||||
- 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>
|
||||
|
||||
- Fixed [#2335](https://github.com/roboflow/supervision/pull/2335): `sv.KeyPoints(confidence=...)` now works again. The `0.29.0` refactor accidentally dropped the deprecated `confidence` constructor kwarg; it is now accepted and mapped to `keypoint_confidence` with a deprecation warning.
|
||||
|
||||
### 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.
|
||||
|
||||
- Added [#2247](https://github.com/roboflow/supervision/pull/2247): [`sv.ConfusionMatrix`](https://supervision.roboflow.com/0.29.0/detection/metrics/#supervision.metrics.detection.ConfusionMatrix) now supports `MetricTarget.ORIENTED_BOUNDING_BOXES`, computing IoU via `oriented_box_iou_batch` on `xyxyxyxy` corners. Previously, OBB inputs silently fell back to axis-aligned bounding-box IoU, producing incorrect match scores for rotated detections.
|
||||
|
||||
- Added [#2252](https://github.com/roboflow/supervision/pull/2252): [`sv.process_video`](https://supervision.roboflow.com/0.29.0/utils/video/#supervision.utils.video.process_video) gains a `preserve_audio` parameter. When enabled, the audio stream from the source video is muxed into the output using ffmpeg.
|
||||
|
||||
- Added [#2302](https://github.com/roboflow/supervision/pull/2302), [#2289](https://github.com/roboflow/supervision/pull/2289): [`sv.DetectionDataset.as_yolo`](https://supervision.roboflow.com/0.29.0/datasets/core/#supervision.dataset.core.DetectionDataset.as_yolo) gains an `is_obb` parameter for exporting oriented bounding box annotations in the YOLO OBB format (9-token lines with 4 corner coordinates).
|
||||
|
||||
- Added [#2312](https://github.com/roboflow/supervision/pull/2312): [`sv.xyxyxyxy_to_xyxy`](https://supervision.roboflow.com/0.29.0/detection/utils/boxes/#supervision.detection.utils.boxes.xyxyxyxy_to_xyxy) — vectorised utility that converts oriented bounding box corners `(N, 4, 2)` to axis-aligned bounding boxes `(N, 4)`.
|
||||
|
||||
- Changed [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.KeyPoints`](https://supervision.roboflow.com/0.29.0/keypoint/core/#supervision.key_points.core.KeyPoints) now separates keypoint-level and detection-level confidence into distinct fields: `keypoint_confidence` (shape `(n, m)`) and `detection_confidence` (shape `(n,)`). A new `visible` mask (shape `(n, m)`) controls per-keypoint visibility. The legacy `KeyPoints.confidence` property still works but is deprecated.
|
||||
|
||||
- Changed [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.EdgeAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.EdgeAnnotator) and [`sv.VertexAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexAnnotator) now respect the `visible` mask. Invisible keypoints and their edges are skipped during rendering.
|
||||
|
||||
- Changed [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.EdgeAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.EdgeAnnotator) and [`sv.VertexLabelAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexLabelAnnotator) now support per-class skeleton definitions, enabling correct rendering when multiple skeleton topologies (e.g. person + animal) coexist in one frame.
|
||||
|
||||
- Changed [#2303](https://github.com/roboflow/supervision/pull/2303): [`sv.Detections.with_nms`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nms) and [`sv.Detections.with_nmm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nmm) now use oriented-box IoU when `data["xyxyxyxy"]` coordinates are present, instead of axis-aligned box IoU. Callers relying on the previous axis-aligned behaviour should remove `data["xyxyxyxy"]` before calling, or recalibrate any IoU thresholds.
|
||||
|
||||
- Changed [#2312](https://github.com/roboflow/supervision/pull/2312): [`sv.Detections.with_nmm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nmm) now computes the merged oriented bounding box as the tightest rectangle at the winner's orientation enclosing all corners from every detection in a merge group.
|
||||
|
||||
- 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 [#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`.
|
||||
|
||||
- Fixed [#2252](https://github.com/roboflow/supervision/pull/2252): [`sv.process_video`](https://supervision.roboflow.com/0.29.0/utils/video/#supervision.utils.video.process_video) audio muxing path now correctly creates temp files on the same filesystem, decodes ffmpeg errors, and avoids muxing incomplete output.
|
||||
|
||||
- Fixed [#2282](https://github.com/roboflow/supervision/pull/2282), [#2317](https://github.com/roboflow/supervision/pull/2317): [`sv.oriented_box_iou_batch`](https://supervision.roboflow.com/0.29.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.oriented_box_iou_batch) now computes exact IoU via convex polygon intersection (`cv2.intersectConvexConvex`) and uses an axis-aligned bounding box envelope gate to skip pairs that cannot overlap, improving both accuracy and performance. Previously, rasterization on a discrete grid was used, which assumed square dimensions and introduced quantisation noise.
|
||||
|
||||
- Fixed [#2239](https://github.com/roboflow/supervision/pull/2239): [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.from_vlm) no longer returns `None` for `class_id` on empty VLM parses; now returns an empty int ndarray.
|
||||
|
||||
- Fixed [#2270](https://github.com/roboflow/supervision/pull/2270): [`sv.Detections.from_inference`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.from_inference) now preserves `class_name` as a string-dtype array when predictions are empty.
|
||||
|
||||
- Fixed [#2269](https://github.com/roboflow/supervision/pull/2269): [`sv.HeatMapAnnotator`](https://supervision.roboflow.com/0.29.0/detection/annotators/#supervision.annotators.core.HeatMapAnnotator) no longer crashes with a divide-by-zero when called with empty detections.
|
||||
|
||||
- Fixed [#2276](https://github.com/roboflow/supervision/pull/2276): COCO export now emits 1-indexed `category_id` values as required by the COCO specification.
|
||||
|
||||
- Fixed [#2267](https://github.com/roboflow/supervision/pull/2267): COCO annotation and image IDs are now sequential across train/val/test splits via `starting_image_id` and `starting_annotation_id` parameters.
|
||||
|
||||
- Fixed [#2289](https://github.com/roboflow/supervision/pull/2289): [`sv.DetectionDataset.as_yolo`](https://supervision.roboflow.com/0.29.0/datasets/core/#supervision.dataset.core.DetectionDataset.as_yolo) no longer loses OBB rotation when exporting oriented bounding boxes.
|
||||
|
||||
- Fixed [#2296](https://github.com/roboflow/supervision/pull/2296): YOLO dataset loading now sorts class names by numeric keys when `data.yaml` uses integer class IDs.
|
||||
|
||||
- Fixed [#2297](https://github.com/roboflow/supervision/pull/2297): Letterbox utility now supports grayscale images.
|
||||
|
||||
- Fixed [#2298](https://github.com/roboflow/supervision/pull/2298): File extension filters now normalize casing (e.g. `.JPG` matches `.jpg`).
|
||||
|
||||
- Fixed [#2321](https://github.com/roboflow/supervision/pull/2321): [`sv.DetectionDataset.as_coco()`](https://supervision.roboflow.com/0.29.0/datasets/core/#supervision.dataset.core.DetectionDataset.as_coco) now round-trips polygon and RLE segmentation data. Segmentations loaded from COCO annotations are preserved in `detections.data["coco_raw_segmentation"]` and written back on export, preventing data loss in train/val/test split workflows.
|
||||
|
||||
- Deprecated: `KeyPoints.confidence` (use `KeyPoints.keypoint_confidence`), `merge_inner_detection_object_pair`, `merge_inner_detections_objects`, `merge_inner_detections_objects_without_iou`, `validate_detections_fields`, `validate_vlm_parameters`, `validate_fields_both_defined_or_none`, `validate_xyxy`, `validate_mask`, `validate_class_id`, `validate_confidence`, `validate_tracker_id`, `validate_data`, `validate_xy`, `validate_key_point_confidence`, `validate_key_points_fields`, `validate_resolution`, `validate_custom_values`, `validate_input_tensors`, and `validate_labels` are deprecated in `0.29.0` and will be removed in `0.32.0`.
|
||||
|
||||
### 0.28.0 <small>Apr 30, 2026</small>
|
||||
|
||||
@@ -41,8 +113,6 @@ date_modified: 2026-06-09
|
||||
|
||||
- Added [#932](https://github.com/roboflow/supervision/pull/932): [`sv.ImageAssets`](https://supervision.roboflow.com/latest/assets/) for downloading sample images alongside existing video assets, useful for examples and tutorials.
|
||||
|
||||
- Added [#2247](https://github.com/roboflow/supervision/pull/2247): [`sv.ConfusionMatrix`](https://supervision.roboflow.com/metrics/detection/#confusionmatrix) now accepts `metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES`, computing IoU via `oriented_box_iou_batch` over `xyxyxyxy` coordinates. Previously, OBB inputs silently fell back to axis-aligned bounding-box IoU, producing incorrect match scores for rotated detections.
|
||||
|
||||
- Changed [#2169](https://github.com/roboflow/supervision/pull/2169): [`sv.MeanAveragePrecisionResult`](https://supervision.roboflow.com/latest/metrics/mean_average_precision/) and related metric arrays (`mAP_scores`, `ap_per_class`, `iou_thresholds`, precision/recall) are now `float32` instead of `float64`. Reduces memory and speeds up computation; numerical results may differ in the last few digits.
|
||||
|
||||
- Changed [#2178](https://github.com/roboflow/supervision/pull/2178): [`sv.rle_to_mask`](https://supervision.roboflow.com/latest/detection/utils/converters/#supervision.detection.utils.converters.rle_to_mask) and [`sv.mask_to_rle`](https://supervision.roboflow.com/latest/detection/utils/converters/#supervision.detection.utils.converters.mask_to_rle) moved to `supervision.detection.utils.converters`. The old import path `supervision.dataset.utils` continues to work but is deprecated.
|
||||
|
||||
@@ -33,3 +33,9 @@ comments: true
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.boxes.denormalize_boxes
|
||||
|
||||
<div class="md-typeset">
|
||||
<h2><a href="#supervision.detection.utils.boxes.xyxyxyxy_to_xyxy">xyxyxyxy_to_xyxy</a></h2>
|
||||
</div>
|
||||
|
||||
:::supervision.detection.utils.boxes.xyxyxyxy_to_xyxy
|
||||
|
||||
+311
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -62,6 +62,10 @@
|
||||
<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.0.rc0"
|
||||
version = "0.29.1"
|
||||
description = "A set of easy-to-use utils that will come in handy in any Computer Vision project"
|
||||
readme = "README.md"
|
||||
keywords = [
|
||||
|
||||
@@ -62,6 +62,7 @@ from supervision.detection.utils.boxes import (
|
||||
move_boxes,
|
||||
pad_boxes,
|
||||
scale_boxes,
|
||||
xyxyxyxy_to_xyxy,
|
||||
)
|
||||
from supervision.detection.utils.converters import (
|
||||
is_compressed_rle,
|
||||
|
||||
@@ -361,6 +361,65 @@ 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.
|
||||
@@ -437,35 +496,12 @@ class MaskAnnotator(BaseAnnotator):
|
||||
return scene
|
||||
|
||||
colored_mask = np.array(scene, copy=True, dtype=np.uint8)
|
||||
|
||||
compact_mask = (
|
||||
detections.mask if isinstance(detections.mask, CompactMask) else None
|
||||
_paint_masks_by_area(
|
||||
colored_mask,
|
||||
detections,
|
||||
self.color,
|
||||
self.color_lookup if custom_color_lookup is None else custom_color_lookup,
|
||||
)
|
||||
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
|
||||
)
|
||||
@@ -701,7 +737,7 @@ class HaloAnnotator(BaseAnnotator):
|
||||
Annotates the given scene with halos based on the provided detections.
|
||||
|
||||
Args:
|
||||
scene: The image where masks will be drawn.
|
||||
scene: The image where the halo effect will be applied.
|
||||
`ImageType` is a flexible type, accepting either `numpy.ndarray`
|
||||
or `PIL.Image.Image`.
|
||||
detections: Object detections to annotate.
|
||||
@@ -719,6 +755,7 @@ 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()
|
||||
@@ -737,28 +774,23 @@ class HaloAnnotator(BaseAnnotator):
|
||||
if detections.mask is None:
|
||||
return scene
|
||||
colored_mask = np.zeros_like(scene, dtype=np.uint8)
|
||||
fmask = np.array([False] * scene.shape[0] * scene.shape[1]).reshape(
|
||||
scene.shape[0], scene.shape[1]
|
||||
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,
|
||||
)
|
||||
|
||||
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
|
||||
assert fmask is not None # collect_union=True always returns an array
|
||||
|
||||
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)
|
||||
alpha = self.opacity * gray / gray.max()
|
||||
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_mask = alpha[:, :, np.newaxis]
|
||||
blended_scene = np.uint8(scene * (1 - alpha_mask) + colored_mask * self.opacity)
|
||||
np.copyto(scene, blended_scene)
|
||||
@@ -2746,7 +2778,7 @@ class PercentageBarAnnotator(BaseAnnotator):
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_custom_values.__func__, # type: ignore[attr-defined]
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_custom_values(
|
||||
custom_values: npt.NDArray[np.float64] | list[float] | None,
|
||||
|
||||
@@ -226,7 +226,7 @@ def _validate_labels(labels: list[str] | None, detections: Detections) -> None:
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_labels,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_labels(labels: list[str] | None, detections: Detections) -> None:
|
||||
void(labels, detections)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
CLASS_NAME_DATA_FIELD: str = "class_name"
|
||||
COCO_RAW_SEGMENTATION: str = "coco_raw_segmentation"
|
||||
#: Key for oriented bounding-box corner coordinates in ``Detections.data``.
|
||||
#:
|
||||
#: Value layout: ``np.ndarray`` of shape ``(N, 4, 2)``, dtype ``float32``, pixel
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Union, cast
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from supervision.config import COCO_RAW_SEGMENTATION
|
||||
from supervision.dataset.utils import (
|
||||
approximate_mask_with_polygons,
|
||||
map_detections_class_id,
|
||||
@@ -168,6 +169,10 @@ def coco_annotations_to_detections(
|
||||
Returns:
|
||||
Detections with ``class_id`` set to raw COCO ``category_id`` values.
|
||||
Call :func:`map_detections_class_id` on the result before use.
|
||||
When ``with_masks=False``, ``detections.data[COCO_RAW_SEGMENTATION]`` is
|
||||
populated as an object array (shape ``(N,)``) holding the raw polygon list or
|
||||
RLE dict per annotation; consumed by :func:`detections_to_coco_annotations`
|
||||
for a coordinate-preserving round-trip.
|
||||
"""
|
||||
if not image_annotations:
|
||||
return Detections.empty()
|
||||
@@ -175,11 +180,11 @@ def coco_annotations_to_detections(
|
||||
class_ids = [
|
||||
image_annotation["category_id"] for image_annotation in image_annotations
|
||||
]
|
||||
xyxy = [image_annotation["bbox"] for image_annotation in image_annotations]
|
||||
xyxy = np.asarray(xyxy, dtype=np.float32)
|
||||
xyxy_list = [image_annotation["bbox"] for image_annotation in image_annotations]
|
||||
xyxy: npt.NDArray[np.float32] = np.asarray(xyxy_list, dtype=np.float32)
|
||||
xyxy[:, 2:4] += xyxy[:, 0:2]
|
||||
|
||||
data: dict[str, npt.NDArray[np.generic]] = {}
|
||||
data: dict[str, Union[npt.NDArray[np.generic], list[Any]]] = {}
|
||||
if use_iscrowd:
|
||||
iscrowd = [
|
||||
image_annotation["iscrowd"] for image_annotation in image_annotations
|
||||
@@ -195,6 +200,14 @@ def coco_annotations_to_detections(
|
||||
)
|
||||
else:
|
||||
mask = None
|
||||
# Preserve raw polygon/RLE data so as_coco() can round-trip without
|
||||
# binary-mask encoding. Stored as an object array (one entry per detection).
|
||||
raw_segs: npt.NDArray[np.object_] = np.empty(
|
||||
len(image_annotations), dtype=object
|
||||
)
|
||||
for k, _ann in enumerate(image_annotations):
|
||||
raw_segs[k] = _ann.get("segmentation", [])
|
||||
data[COCO_RAW_SEGMENTATION] = raw_segs
|
||||
|
||||
return Detections(
|
||||
class_id=np.asarray(class_ids, dtype=int), xyxy=xyxy, mask=mask, data=data
|
||||
@@ -234,6 +247,18 @@ 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
|
||||
@@ -260,21 +285,25 @@ def detections_to_coco_annotations(
|
||||
box_width, box_height = xyxy[2] - xyxy[0], xyxy[3] - xyxy[1]
|
||||
segmentation: Union[list[list[float]], dict[str, list[int]]] = []
|
||||
if mask is not None:
|
||||
mask_bool = cast(npt.NDArray[np.bool_], mask)
|
||||
if "iscrowd" in data:
|
||||
iscrowd = int(np.asarray(data["iscrowd"]).item())
|
||||
else:
|
||||
iscrowd = int(
|
||||
contains_holes(mask=mask) or contains_multiple_segments(mask=mask)
|
||||
contains_holes(mask=mask_bool)
|
||||
or contains_multiple_segments(mask=mask_bool)
|
||||
)
|
||||
|
||||
if iscrowd:
|
||||
segmentation = {
|
||||
"counts": cast(list[int], mask_to_rle(mask=mask, compressed=False)),
|
||||
"counts": cast(
|
||||
list[int], mask_to_rle(mask=mask_bool, compressed=False)
|
||||
),
|
||||
"size": list(mask.shape[:2]),
|
||||
}
|
||||
else:
|
||||
polygons = approximate_mask_with_polygons(
|
||||
mask=mask,
|
||||
mask=mask_bool,
|
||||
min_image_area_percentage=min_image_area_percentage,
|
||||
max_image_area_percentage=max_image_area_percentage,
|
||||
approximation_percentage=approximation_percentage,
|
||||
@@ -282,7 +311,8 @@ def detections_to_coco_annotations(
|
||||
# Small/noisy masks can be filtered out by approximation settings.
|
||||
# Guard against empty output and keep a valid COCO annotation record.
|
||||
if polygons:
|
||||
segmentation = [list(polygons[0].flatten())]
|
||||
# Export ALL polygons so disjoint mask components are preserved.
|
||||
segmentation = [list(p.flatten()) for p in polygons]
|
||||
else:
|
||||
warnings.warn(
|
||||
"Skipping COCO polygon segmentation for annotation "
|
||||
@@ -292,6 +322,22 @@ def detections_to_coco_annotations(
|
||||
)
|
||||
else:
|
||||
iscrowd = int(np.asarray(data.get("iscrowd", 0)).item())
|
||||
# When masks were not decoded during loading, fall back to the raw
|
||||
# polygon/RLE stored in data["segmentation"] for a lossless round-trip.
|
||||
raw_seg = data.get(COCO_RAW_SEGMENTATION)
|
||||
if raw_seg is not None and bool(raw_seg):
|
||||
if isinstance(raw_seg, dict):
|
||||
# RLE format — pass through unchanged
|
||||
segmentation = raw_seg
|
||||
elif (
|
||||
isinstance(raw_seg, list)
|
||||
and raw_seg
|
||||
and not isinstance(raw_seg[0], (list, tuple))
|
||||
):
|
||||
# Flat list shorthand [x1,y1,...] — wrap to list-of-lists
|
||||
segmentation = [list(raw_seg)]
|
||||
else:
|
||||
segmentation = list(raw_seg)
|
||||
|
||||
area: float = float(np.asarray(data.get("area", box_width * box_height)).item())
|
||||
coco_annotation = {
|
||||
|
||||
@@ -21,13 +21,41 @@ 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
|
||||
|
||||
# https://github.com/roboflow/supervision/issues/144
|
||||
xyxy += 1
|
||||
# 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
|
||||
|
||||
bndbox = SubElement(root, "bndbox")
|
||||
xmin = SubElement(bndbox, "xmin")
|
||||
@@ -40,8 +68,8 @@ def object_to_pascal_voc(
|
||||
ymax.text = str(int(xyxy[3]))
|
||||
|
||||
if polygon is not None:
|
||||
# https://github.com/roboflow/supervision/issues/144
|
||||
polygon += 1
|
||||
# 1-indexed, rebound to avoid mutating the caller's array (see above).
|
||||
polygon = polygon + 1
|
||||
object_polygon = SubElement(root, "polygon")
|
||||
for index, point in enumerate(polygon, start=1):
|
||||
x_coordinate, y_coordinate = point
|
||||
@@ -81,6 +109,11 @@ 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
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ from supervision.detection.tools.transformers import (
|
||||
process_transformers_v4_segmentation_result,
|
||||
process_transformers_v5_segmentation_result,
|
||||
)
|
||||
from supervision.detection.utils.boxes import obb_polygon_area
|
||||
from supervision.detection.utils.boxes import obb_polygon_area, xyxyxyxy_to_xyxy
|
||||
from supervision.detection.utils.converters import (
|
||||
mask_to_xyxy,
|
||||
polygon_to_mask,
|
||||
@@ -2562,6 +2562,17 @@ class Detections:
|
||||
A new Detections object containing the subset of detections
|
||||
after non-maximum merging.
|
||||
|
||||
Note:
|
||||
For detections carrying oriented bounding box data
|
||||
(``data[ORIENTED_BOX_COORDINATES]``), each merge group's output OBB
|
||||
is the tightest rectangle at the winner's orientation enclosing all
|
||||
corners contributed by every detection in the group. The winner is
|
||||
the highest-confidence detection in the group. The axis-aligned
|
||||
``xyxy`` field is updated to the tight bounding box of that rect.
|
||||
For zero-rotation OBBs this equals the axis-aligned union exactly;
|
||||
for rotated OBBs the merged rect inherits the winner's rotation angle.
|
||||
Groups of size 1 keep the original OBB unchanged.
|
||||
|
||||
Raises:
|
||||
AssertionError: If `confidence` is None or `class_id` is None and
|
||||
class_agnostic is False.
|
||||
@@ -2615,30 +2626,144 @@ class Detections:
|
||||
|
||||
result: list[Detections] = []
|
||||
for merge_group in merge_groups:
|
||||
unmerged_detections = [cast(Detections, self[i]) for i in merge_group]
|
||||
merged_detections = merge_inner_detections_objects_without_iou(
|
||||
unmerged_detections
|
||||
)
|
||||
if (
|
||||
len(merge_group) > 1
|
||||
and ORIENTED_BOX_COORDINATES in merged_detections.data
|
||||
):
|
||||
obb = merged_detections.data[ORIENTED_BOX_COORDINATES][0] # (4, 2)
|
||||
merged_detections.xyxy = np.array(
|
||||
[
|
||||
[
|
||||
float(obb[:, 0].min()),
|
||||
float(obb[:, 1].min()),
|
||||
float(obb[:, 0].max()),
|
||||
float(obb[:, 1].max()),
|
||||
]
|
||||
]
|
||||
)
|
||||
result.append(merged_detections)
|
||||
group = [cast(Detections, self[i]) for i in merge_group]
|
||||
result.append(_merge_detection_group(group))
|
||||
|
||||
return Detections.merge(result)
|
||||
|
||||
|
||||
def _merge_obb_corners(
|
||||
corners_list: list[npt.NDArray[np.floating]],
|
||||
) -> npt.NDArray[np.floating]:
|
||||
"""Merge multiple OBB corner arrays using winner-angle projection.
|
||||
|
||||
The first entry in *corners_list* is the winner. Its orientation angle
|
||||
(derived from its first edge) defines the local frame in which the
|
||||
tightest enclosing axis-aligned rectangle is computed. That rectangle is
|
||||
then rotated back to produce the merged OBB.
|
||||
|
||||
Args:
|
||||
corners_list: List of (4, 2) corner arrays. First is the winner.
|
||||
|
||||
Returns:
|
||||
Merged OBB corners as a (4, 2) float32 array.
|
||||
"""
|
||||
all_corners = np.concatenate(corners_list, axis=0).astype(np.float32)
|
||||
# Use winner's first edge to derive orientation angle -- avoids
|
||||
# cv2.minAreaRect surprises (e.g. 90-degree flip for wide rects).
|
||||
winner_edge = corners_list[0][1] - corners_list[0][0]
|
||||
angle = float(np.arctan2(winner_edge[1], winner_edge[0]))
|
||||
cos, sin = float(np.cos(angle)), float(np.sin(angle))
|
||||
|
||||
# De-rotate all corners into the winner's local frame
|
||||
to_local = np.array([[cos, -sin], [sin, cos]], dtype=np.float32)
|
||||
local_corners = all_corners @ to_local
|
||||
x_min = float(local_corners[:, 0].min())
|
||||
x_max = float(local_corners[:, 0].max())
|
||||
y_min = float(local_corners[:, 1].min())
|
||||
y_max = float(local_corners[:, 1].max())
|
||||
|
||||
# Rotate the enclosing AABB back to world frame
|
||||
to_world = np.array([[cos, sin], [-sin, cos]], dtype=np.float32)
|
||||
merged: npt.NDArray[np.floating] = (
|
||||
np.array(
|
||||
[[x_min, y_min], [x_max, y_min], [x_max, y_max], [x_min, y_max]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
@ to_world
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
def _merge_detection_group(detections: list[Detections]) -> Detections:
|
||||
"""Merge a group of single-object Detections into one merged detection.
|
||||
|
||||
Used internally by :meth:`Detections.with_nmm` to combine each merge group
|
||||
into a single output detection. The highest-confidence detection is the
|
||||
"winner" whose class_id, tracker_id, and data fields are preserved.
|
||||
|
||||
Args:
|
||||
detections: List of Detections, each containing exactly one object.
|
||||
|
||||
Returns:
|
||||
A single merged Detections object of length 1.
|
||||
"""
|
||||
if len(detections) == 1:
|
||||
return detections[0]
|
||||
|
||||
confidences = np.array(
|
||||
[d.confidence[0] if d.confidence is not None else 0.0 for d in detections],
|
||||
dtype=np.float64,
|
||||
)
|
||||
winner_idx = int(np.argmax(confidences))
|
||||
winner = detections[winner_idx]
|
||||
|
||||
# Area-weighted confidence: each box contributes proportionally to its
|
||||
# footprint so large overlapping boxes dominate over small slivers.
|
||||
all_xyxy = np.array([d.xyxy[0] for d in detections], dtype=np.float32)
|
||||
areas = (all_xyxy[:, 2] - all_xyxy[:, 0]) * (all_xyxy[:, 3] - all_xyxy[:, 1])
|
||||
|
||||
if winner.confidence is not None:
|
||||
total_area = float(areas.sum())
|
||||
if total_area > 0:
|
||||
confidence = np.array(
|
||||
[float(np.dot(areas, confidences) / total_area)],
|
||||
dtype=np.float32,
|
||||
)
|
||||
else:
|
||||
confidence = winner.confidence
|
||||
else:
|
||||
confidence = None
|
||||
|
||||
# OBB path: merge corners via winner-angle projection, then derive xyxy
|
||||
# from the merged OBB so both stay consistent.
|
||||
has_obb = ORIENTED_BOX_COORDINATES in winner.data
|
||||
if has_obb:
|
||||
corners_list = []
|
||||
for det in detections:
|
||||
c = np.asarray(det.data[ORIENTED_BOX_COORDINATES])
|
||||
if c.ndim != 3 or c.shape[1:] != (4, 2):
|
||||
raise ValueError(f"corners must have shape (N, 4, 2); got {c.shape}")
|
||||
corners_list.append(c[0].reshape(4, 2))
|
||||
merged_corners = _merge_obb_corners(corners_list)
|
||||
xyxy = xyxyxyxy_to_xyxy(merged_corners[np.newaxis])
|
||||
data = {**winner.data, ORIENTED_BOX_COORDINATES: merged_corners[np.newaxis]}
|
||||
else:
|
||||
# AABB path: union bounding box across all group members
|
||||
xyxy = np.array(
|
||||
[
|
||||
[
|
||||
all_xyxy[:, 0].min(),
|
||||
all_xyxy[:, 1].min(),
|
||||
all_xyxy[:, 2].max(),
|
||||
all_xyxy[:, 3].max(),
|
||||
]
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
data = winner.data
|
||||
|
||||
# Mask union via logical OR
|
||||
masks = [d.mask for d in detections if d.mask is not None]
|
||||
if masks:
|
||||
mask = np.logical_or.reduce(np.concatenate(masks, axis=0))[np.newaxis]
|
||||
else:
|
||||
mask = None
|
||||
|
||||
metadata = merge_metadata([d.metadata for d in detections])
|
||||
|
||||
return Detections(
|
||||
xyxy=xyxy,
|
||||
mask=mask,
|
||||
confidence=confidence,
|
||||
class_id=winner.class_id,
|
||||
tracker_id=winner.tracker_id,
|
||||
data=data,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
@deprecated(deprecated_in="0.29.0", remove_in="0.32.0") # type: ignore[untyped-decorator]
|
||||
def merge_inner_detection_object_pair(
|
||||
detections_1: Detections, detections_2: Detections
|
||||
) -> Detections:
|
||||
@@ -2731,6 +2856,7 @@ def merge_inner_detection_object_pair(
|
||||
)
|
||||
|
||||
|
||||
@deprecated(deprecated_in="0.29.0", remove_in="0.32.0") # type: ignore[untyped-decorator]
|
||||
def merge_inner_detections_objects(
|
||||
detections: list[Detections],
|
||||
threshold: float = 0.5,
|
||||
@@ -2758,6 +2884,7 @@ def merge_inner_detections_objects(
|
||||
return detections_1
|
||||
|
||||
|
||||
@deprecated(deprecated_in="0.29.0", remove_in="0.32.0") # type: ignore[untyped-decorator]
|
||||
def merge_inner_detections_objects_without_iou(
|
||||
detections: list[Detections],
|
||||
) -> Detections:
|
||||
@@ -2799,7 +2926,7 @@ def _validate_fields_both_defined_or_none(
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_fields_both_defined_or_none,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_fields_both_defined_or_none(
|
||||
detections_1: Detections, detections_2: Detections
|
||||
|
||||
@@ -24,6 +24,8 @@ 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.
|
||||
@@ -79,13 +81,42 @@ 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:
|
||||
json.dump(self.data, self.file, indent=4)
|
||||
self.file.close()
|
||||
try:
|
||||
json.dump(
|
||||
self.data, self.file, indent=4, default=JSONSink._json_default
|
||||
)
|
||||
finally:
|
||||
self.file.close()
|
||||
|
||||
@staticmethod
|
||||
def _slice_value(value: Any, i: int, n: int) -> Any:
|
||||
|
||||
@@ -29,6 +29,9 @@ 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
|
||||
@@ -128,6 +131,20 @@ 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
|
||||
@@ -138,7 +155,11 @@ class DetectionsSmoother:
|
||||
|
||||
ret = deepcopy(valid[0])
|
||||
ret.xyxy = np.mean([d.xyxy for d in valid], axis=0)
|
||||
ret.confidence = np.mean([d.confidence 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
|
||||
|
||||
return ret
|
||||
|
||||
@@ -149,6 +170,13 @@ 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)
|
||||
|
||||
@@ -269,6 +269,46 @@ def obb_polygon_area(corners: npt.NDArray) -> npt.NDArray[np.float64]:
|
||||
return 0.5 * np.abs(np.sum(cross, axis=-1))
|
||||
|
||||
|
||||
def xyxyxyxy_to_xyxy(
|
||||
xyxyxyxy: npt.NDArray[np.number],
|
||||
) -> npt.NDArray[np.number]:
|
||||
"""Convert oriented bounding box corners to axis-aligned bounding boxes.
|
||||
|
||||
Args:
|
||||
xyxyxyxy: OBB corner coordinates with shape `(N, 4, 2)` where each
|
||||
box is represented as `[[x1, y1], [x2, y2], [x3, y3], [x4, y4]]`.
|
||||
|
||||
Returns:
|
||||
Axis-aligned bounding boxes as an array of shape `(N, 4)`
|
||||
in `(x_min, y_min, x_max, y_max)` format.
|
||||
|
||||
Raises:
|
||||
ValueError: If `xyxyxyxy` does not have shape `(N, 4, 2)`.
|
||||
|
||||
Examples:
|
||||
```pycon
|
||||
>>> import numpy as np
|
||||
>>> import supervision as sv
|
||||
>>> corners = np.array([
|
||||
... [[0, 0], [10, 0], [10, 5], [0, 5]],
|
||||
... [[5, 5], [15, 5], [15, 10], [5, 10]],
|
||||
... ], dtype=np.float32)
|
||||
>>> sv.xyxyxyxy_to_xyxy(corners)
|
||||
array([[ 0., 0., 10., 5.],
|
||||
[ 5., 5., 15., 10.]], dtype=float32)
|
||||
|
||||
```
|
||||
"""
|
||||
xyxyxyxy = np.asarray(xyxyxyxy)
|
||||
if xyxyxyxy.ndim != 3 or xyxyxyxy.shape[-2:] != (4, 2):
|
||||
raise ValueError(f"xyxyxyxy must have shape (N, 4, 2); got {xyxyxyxy.shape}")
|
||||
x_min = xyxyxyxy[..., 0].min(axis=-1)
|
||||
y_min = xyxyxyxy[..., 1].min(axis=-1)
|
||||
x_max = xyxyxyxy[..., 0].max(axis=-1)
|
||||
y_max = xyxyxyxy[..., 1].max(axis=-1)
|
||||
return np.stack([x_min, y_min, x_max, y_max], axis=-1)
|
||||
|
||||
|
||||
def scale_boxes(
|
||||
xyxy: npt.NDArray[np.float64], factor: float
|
||||
) -> npt.NDArray[np.float64]:
|
||||
|
||||
@@ -204,23 +204,44 @@ 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, W, H)` containing 2D bool masks.
|
||||
masks: A 3D `np.array` of shape `(N, H, W)` 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 = masks.shape[0]
|
||||
xyxy = np.zeros((n, 4), dtype=int)
|
||||
n, height, width = masks.shape
|
||||
if masks.size == 0:
|
||||
return np.zeros((n, 4), dtype=int)
|
||||
|
||||
for i, mask in enumerate(masks):
|
||||
rows, cols = np.where(mask)
|
||||
# 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)
|
||||
|
||||
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]
|
||||
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)
|
||||
|
||||
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,14 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from enum import Enum
|
||||
from typing import Any, cast
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from supervision.detection.compact_mask import CompactMask
|
||||
from supervision.detection.utils.converters import polygon_to_mask
|
||||
from supervision.detection.utils.masks import resize_masks
|
||||
|
||||
|
||||
@@ -86,13 +87,6 @@ class OverlapMetric(Enum):
|
||||
)
|
||||
|
||||
|
||||
# Upper bound on the rasterization canvas used by ``oriented_box_iou_batch``.
|
||||
# IoU is invariant under uniform scaling (up to rasterization quantization),
|
||||
# so boxes whose extent exceeds this cap are scaled down uniformly. Bounds the
|
||||
# memory cost of the ``(N, H, W)`` mask array regardless of source resolution.
|
||||
_MAX_IOU_CANVAS_DIM = 1024
|
||||
|
||||
|
||||
def box_iou(
|
||||
box_true: list[float] | npt.NDArray[np.floating],
|
||||
box_detection: list[float] | npt.NDArray[np.floating],
|
||||
@@ -366,6 +360,69 @@ def box_iou_batch_with_jaccard(
|
||||
return ious
|
||||
|
||||
|
||||
def _polygon_areas(polygons: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
|
||||
"""Compute the area of each oriented-box polygon using the shoelace formula.
|
||||
|
||||
Args:
|
||||
polygons: ``(N, 4, 2)`` array of polygon corners.
|
||||
|
||||
Returns:
|
||||
``(N,)`` array of polygon areas.
|
||||
"""
|
||||
x = polygons[:, :, 0]
|
||||
y = polygons[:, :, 1]
|
||||
cross = x * np.roll(y, -1, axis=1) - np.roll(x, -1, axis=1) * y
|
||||
return cast(npt.NDArray[np.floating], 0.5 * np.abs(cross.sum(axis=1)))
|
||||
|
||||
|
||||
def _aabb_envelopes(polygons: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
|
||||
"""Compute the axis-aligned bounding envelope of each oriented box.
|
||||
|
||||
Args:
|
||||
polygons: ``(N, 4, 2)`` array of polygon corners.
|
||||
|
||||
Returns:
|
||||
``(N, 4)`` array of ``(x_min, y_min, x_max, y_max)`` envelopes.
|
||||
"""
|
||||
xs = polygons[:, :, 0]
|
||||
ys = polygons[:, :, 1]
|
||||
return np.stack(
|
||||
[xs.min(axis=1), ys.min(axis=1), xs.max(axis=1), ys.max(axis=1)], axis=1
|
||||
)
|
||||
|
||||
|
||||
def _overlapping_envelope_pairs(
|
||||
envelopes_true: npt.NDArray[np.floating],
|
||||
envelopes_detection: npt.NDArray[np.floating],
|
||||
) -> tuple[npt.NDArray[np.intp], npt.NDArray[np.intp]]:
|
||||
"""Return index pairs ``(i, j)`` whose axis-aligned envelopes overlap.
|
||||
|
||||
Uses a fused boolean evaluation to halve peak transient memory compared to
|
||||
named-intermediate form (4 separate NxM float64 arrays vs 1 boolean array).
|
||||
|
||||
Note:
|
||||
This gate is a correctness guarantee, not an approximation: if two
|
||||
axis-aligned bounding boxes do not overlap, the convex polygons they
|
||||
contain cannot overlap either.
|
||||
|
||||
Args:
|
||||
envelopes_true: ``(N, 4)`` array of ``(x_min, y_min, x_max, y_max)``
|
||||
envelopes for the ground-truth boxes.
|
||||
envelopes_detection: ``(M, 4)`` array of ``(x_min, y_min, x_max, y_max)``
|
||||
envelopes for the detection boxes.
|
||||
|
||||
Returns:
|
||||
A pair of 1-D index arrays ``(rows, cols)`` identifying the overlapping
|
||||
pairs.
|
||||
"""
|
||||
et = envelopes_true[:, None, :]
|
||||
ed = envelopes_detection[None, :, :]
|
||||
overlap = (
|
||||
np.minimum(et[..., 2], ed[..., 2]) > np.maximum(et[..., 0], ed[..., 0])
|
||||
) & (np.minimum(et[..., 3], ed[..., 3]) > np.maximum(et[..., 1], ed[..., 1]))
|
||||
return cast(tuple[npt.NDArray[np.intp], npt.NDArray[np.intp]], np.nonzero(overlap))
|
||||
|
||||
|
||||
def oriented_box_iou_batch(
|
||||
boxes_true: npt.NDArray[np.number],
|
||||
boxes_detection: npt.NDArray[np.number],
|
||||
@@ -375,9 +432,27 @@ def oriented_box_iou_batch(
|
||||
Compute pairwise overlap scores between two sets of oriented bounding boxes
|
||||
using the configured `overlap_metric`.
|
||||
|
||||
Overlap areas are computed exactly via convex-polygon intersection, gated by
|
||||
a cheap axis-aligned envelope pre-filter — no rasterization is involved, so
|
||||
the result is exact (free of pixel-quantization error) and independent of the
|
||||
coordinate magnitudes.
|
||||
|
||||
`boxes_true` and `boxes_detection` are expected to be in
|
||||
`((x1, y1), (x2, y2), (x3, y3), (x4, y4))` format.
|
||||
|
||||
Note:
|
||||
Inputs must be **convex** quads with finite coordinates. Self-intersecting
|
||||
or non-convex polygons produce undefined results via
|
||||
``cv2.intersectConvexConvex``. NaN or Inf coordinates propagate silently
|
||||
as ``0.0`` — validate inputs before calling if needed.
|
||||
|
||||
When ``boxes_true is boxes_detection`` (the same Python object, not just
|
||||
equal values), the function computes only the upper triangle of the
|
||||
matrix and mirrors it. This optimization is used automatically by the
|
||||
NMS/NMM callers that pass the same array twice. A defensive ``.copy()``
|
||||
at the call site would disable the optimization silently — see the
|
||||
NMS caller comment for context.
|
||||
|
||||
Args:
|
||||
boxes_true: A `np.ndarray` representing ground-truth boxes.
|
||||
`shape = (N, 4, 2)` where `N` is number of true objects.
|
||||
@@ -391,10 +466,27 @@ def oriented_box_iou_batch(
|
||||
between pairs of oriented boxes (e.g., IoU, IoS).
|
||||
|
||||
Returns:
|
||||
Pairwise overlap scores of boxes from `boxes_true` and
|
||||
`boxes_detection`, using the configured :attr:`overlap_metric`.
|
||||
`shape = (N, M)` where `N` is number of true objects and
|
||||
`M` is number of detected objects.
|
||||
Overlap matrix of shape `(N, M)`, where entry `[i, j]` is the overlap
|
||||
score between `boxes_true[i]` and `boxes_detection[j]`, in the range
|
||||
`[0, 1]` under the configured :attr:`overlap_metric`.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``boxes_true`` or ``boxes_detection`` is 3-D with inner
|
||||
dimensions other than ``(4, 2)``.
|
||||
ValueError: If ``boxes_true`` or ``boxes_detection`` is 2-D with a
|
||||
column count other than 8.
|
||||
ValueError: If ``boxes_true`` or ``boxes_detection`` is not 2-D or 3-D.
|
||||
ValueError: If ``overlap_metric`` is not
|
||||
:attr:`~supervision.config.OverlapMetric.IOU` or
|
||||
:attr:`~supervision.config.OverlapMetric.IOS`.
|
||||
|
||||
Examples:
|
||||
>>> import numpy as np
|
||||
>>> import supervision as sv
|
||||
>>> a = np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32)
|
||||
>>> b = np.array([[[1, 0], [3, 0], [3, 2], [1, 2]]], dtype=np.float32)
|
||||
>>> sv.oriented_box_iou_batch(a, b) # doctest: +ELLIPSIS
|
||||
array([[0.333...]])
|
||||
"""
|
||||
|
||||
for name, arr in (("boxes_true", boxes_true), ("boxes_detection", boxes_detection)):
|
||||
@@ -413,53 +505,65 @@ def oriented_box_iou_batch(
|
||||
f"`{name}` must be 2-D (N, 8) or 3-D (N, 4, 2), got shape {arr.shape}."
|
||||
)
|
||||
|
||||
boxes_true = boxes_true.reshape(-1, 4, 2)
|
||||
boxes_detection = boxes_detection.reshape(-1, 4, 2)
|
||||
|
||||
if len(boxes_true) == 0 or len(boxes_detection) == 0:
|
||||
return np.zeros((len(boxes_true), len(boxes_detection)), dtype=np.float64)
|
||||
|
||||
# IoU is invariant under translation and uniform scaling. Shift boxes to
|
||||
# the origin so the canvas only covers their bounding region (avoids dead
|
||||
# space when boxes sit in a corner of the input frame) and shrink them
|
||||
# uniformly when the bounding region exceeds the cap (keeps memory
|
||||
# bounded regardless of input resolution).
|
||||
# Axis convention: [..., 0] = x → canvas width, [..., 1] = y → height.
|
||||
min_x = float(min(boxes_true[:, :, 0].min(), boxes_detection[:, :, 0].min()))
|
||||
min_y = float(min(boxes_true[:, :, 1].min(), boxes_detection[:, :, 1].min()))
|
||||
extent_x = (
|
||||
float(max(boxes_true[:, :, 0].max(), boxes_detection[:, :, 0].max())) - min_x
|
||||
)
|
||||
extent_y = (
|
||||
float(max(boxes_true[:, :, 1].max(), boxes_detection[:, :, 1].max())) - min_y
|
||||
)
|
||||
|
||||
canvas_dim = max(extent_x, extent_y)
|
||||
scale = (
|
||||
_MAX_IOU_CANVAS_DIM / canvas_dim if canvas_dim > _MAX_IOU_CANVAS_DIM else 1.0
|
||||
)
|
||||
offset = np.array([min_x, min_y], dtype=np.float64)
|
||||
boxes_true = (boxes_true - offset) * scale
|
||||
boxes_detection = (boxes_detection - offset) * scale
|
||||
|
||||
# adding 1 because we are 0-indexed
|
||||
max_width = int(extent_x * scale + 1)
|
||||
max_height = int(extent_y * scale + 1)
|
||||
|
||||
mask_true = np.zeros((boxes_true.shape[0], max_height, max_width), dtype=np.uint8)
|
||||
for box_idx, box_true in enumerate(boxes_true):
|
||||
mask_true[box_idx] = polygon_to_mask(box_true, (max_width, max_height))
|
||||
|
||||
mask_detection = np.zeros(
|
||||
(boxes_detection.shape[0], max_height, max_width), dtype=np.uint8
|
||||
)
|
||||
for box_idx, box_detection in enumerate(boxes_detection):
|
||||
mask_detection[box_idx] = polygon_to_mask(
|
||||
box_detection, (max_width, max_height)
|
||||
if overlap_metric == OverlapMetric.IOU:
|
||||
normalize_by_union = True
|
||||
elif overlap_metric == OverlapMetric.IOS:
|
||||
normalize_by_union = False
|
||||
else:
|
||||
raise ValueError(
|
||||
f"overlap_metric {overlap_metric} is not supported, "
|
||||
"only 'IOU' and 'IOS' are supported"
|
||||
)
|
||||
|
||||
ious = mask_iou_batch(mask_true, mask_detection, overlap_metric)
|
||||
return ious
|
||||
# Capture identity before reshape: NMS / NMM pass the same array twice, so
|
||||
# the matrix is symmetric and we can compute only its upper triangle.
|
||||
is_self_comparison = boxes_true is boxes_detection
|
||||
boxes_true = boxes_true.reshape(-1, 4, 2).astype(np.float64)
|
||||
boxes_detection = boxes_detection.reshape(-1, 4, 2).astype(np.float64)
|
||||
|
||||
n, m = len(boxes_true), len(boxes_detection)
|
||||
if n == 0 or m == 0:
|
||||
return np.zeros((n, m), dtype=np.float64)
|
||||
|
||||
areas_true = _polygon_areas(boxes_true)
|
||||
areas_detection = _polygon_areas(boxes_detection)
|
||||
|
||||
envelopes_true = _aabb_envelopes(boxes_true)
|
||||
envelopes_detection = (
|
||||
envelopes_true if is_self_comparison else _aabb_envelopes(boxes_detection)
|
||||
)
|
||||
rows, cols = _overlapping_envelope_pairs(envelopes_true, envelopes_detection)
|
||||
if is_self_comparison:
|
||||
upper = rows <= cols
|
||||
rows, cols = rows[upper], cols[upper]
|
||||
|
||||
polygons_true = [box.astype(np.float32) for box in boxes_true]
|
||||
polygons_detection = [box.astype(np.float32) for box in boxes_detection]
|
||||
|
||||
ious: npt.NDArray[np.float64] = np.zeros((n, m), dtype=np.float64)
|
||||
for i, j in zip(rows, cols):
|
||||
intersection, _ = cv2.intersectConvexConvex(
|
||||
polygons_true[i], polygons_detection[j]
|
||||
)
|
||||
if intersection <= 0:
|
||||
continue
|
||||
denominator = (
|
||||
areas_true[i] + areas_detection[j] - intersection
|
||||
if normalize_by_union
|
||||
else min(areas_true[i], areas_detection[j])
|
||||
)
|
||||
if denominator > 0:
|
||||
score = intersection / denominator
|
||||
ious[i, j] = score
|
||||
if is_self_comparison:
|
||||
ious[j, i] = score
|
||||
|
||||
# DO NOT remove this clip. cv2.intersectConvexConvex computes in float32
|
||||
# internally while polygon areas are computed in float64; the intersection
|
||||
# area can exceed the float64 area by ~25 ULP (~1e-7), producing raw IoU
|
||||
# or IoS values microscopically above 1.0 for identical boxes. The clip is
|
||||
# load-bearing, not defensive duplication.
|
||||
return cast(npt.NDArray[np.floating], np.clip(ious, 0.0, 1.0))
|
||||
|
||||
|
||||
def compact_mask_iou_batch(
|
||||
@@ -584,12 +688,23 @@ def _mask_iou_batch_split(
|
||||
Returns:
|
||||
Pairwise IoU of masks from `masks_true` and `masks_detection`.
|
||||
"""
|
||||
intersection_area = np.logical_and(masks_true[:, None], masks_detection).sum(
|
||||
axis=(2, 3)
|
||||
# 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
|
||||
)
|
||||
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 = masks_true.sum(axis=(1, 2)) # (area1, area2, ...)
|
||||
masks_detection_area = masks_detection.sum(axis=(1, 2)) # (area1)
|
||||
masks_true_area = true_flat.sum(axis=1)
|
||||
masks_detection_area = detection_flat.sum(axis=1)
|
||||
|
||||
if overlap_metric == OverlapMetric.IOU:
|
||||
union_area = masks_true_area[:, None] + masks_detection_area - intersection_area
|
||||
@@ -640,10 +755,20 @@ 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).
|
||||
Ignored when both inputs are CompactMask.
|
||||
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`.
|
||||
|
||||
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):
|
||||
@@ -655,29 +780,43 @@ def mask_iou_batch(
|
||||
if isinstance(masks_detection, CompactMask):
|
||||
masks_detection = np.asarray(masks_detection)
|
||||
|
||||
memory = (
|
||||
masks_true.shape[0]
|
||||
* masks_true.shape[1]
|
||||
* masks_true.shape[2]
|
||||
* masks_detection.shape[0]
|
||||
/ 1024
|
||||
/ 1024
|
||||
)
|
||||
if memory <= memory_limit:
|
||||
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:
|
||||
return _mask_iou_batch_split(masks_true, masks_detection, overlap_metric)
|
||||
|
||||
ious = []
|
||||
step = max(
|
||||
memory_limit
|
||||
* 1024
|
||||
* 1024
|
||||
// (
|
||||
masks_detection.shape[0]
|
||||
* masks_detection.shape[1]
|
||||
* masks_detection.shape[2]
|
||||
),
|
||||
1,
|
||||
)
|
||||
step = max((limit_bytes - detection_bytes) // per_true_row, 1)
|
||||
for chunk_start in range(0, masks_true.shape[0], step):
|
||||
ious.append(
|
||||
_mask_iou_batch_split(
|
||||
@@ -1185,6 +1324,7 @@ def oriented_box_non_max_suppression(
|
||||
)
|
||||
sort_index, _, categories = _prepare_predictions_for_nms(predictions)
|
||||
oriented_boxes = oriented_boxes[sort_index]
|
||||
# same object intentional — triggers upper-triangle optimization
|
||||
ious = oriented_box_iou_batch(oriented_boxes, oriented_boxes, overlap_metric)
|
||||
keep = _nms_loop_from_iou_matrix(ious, categories, iou_threshold)
|
||||
return cast(npt.NDArray[np.bool_], keep[sort_index.argsort()])
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
@@ -57,19 +55,47 @@ 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.
|
||||
epsilon_step: Approximation accuracy step, must be positive.
|
||||
Epsilon is the maximum distance between the original curve
|
||||
and its approximation.
|
||||
|
||||
Returns:
|
||||
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.
|
||||
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
|
||||
"""
|
||||
|
||||
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)
|
||||
|
||||
@@ -78,12 +104,13 @@ def approximate_polygon(
|
||||
|
||||
epsilon: float = 0
|
||||
approximated_points = polygon
|
||||
while True:
|
||||
while len(approximated_points) > target_points:
|
||||
epsilon += epsilon_step
|
||||
new_approximated_points = cv2.approxPolyDP(polygon, epsilon, closed=True)
|
||||
if len(new_approximated_points) > target_points:
|
||||
approximated_points = new_approximated_points
|
||||
else:
|
||||
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:
|
||||
break
|
||||
approximated_points = candidate
|
||||
|
||||
return cast(npt.NDArray[np.number], np.squeeze(approximated_points, axis=1))
|
||||
return approximated_points
|
||||
|
||||
@@ -206,7 +206,7 @@ def _validate_vlm_parameters(
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_vlm_parameters,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_vlm_parameters(vlm: VLM | str, result: Any, kwargs: dict[str, Any]) -> VLM:
|
||||
return void(vlm, result, kwargs) # type: ignore[no-any-return]
|
||||
@@ -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], npt.NDArray[Any]]:
|
||||
) -> tuple[npt.NDArray[Any], npt.NDArray[Any] | None, 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 = np.array(matches) if matches else np.empty((0, 5))
|
||||
matches_arr: npt.NDArray[Any] = np.array(matches) if matches else np.empty((0, 5))
|
||||
|
||||
if matches.shape[0] == 0:
|
||||
if matches_arr.shape[0] == 0:
|
||||
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0, dtype=str)
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
if classes is not None:
|
||||
mask = np.array([name in classes for name in class_name], dtype=bool)
|
||||
xyxy = xyxy[mask]
|
||||
xyxy_arr = xyxy_arr[mask]
|
||||
class_name = class_name[mask]
|
||||
class_id = np.array([classes.index(name) for name in class_name])
|
||||
|
||||
return xyxy, class_id, class_name
|
||||
return xyxy_arr, 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 "bbox_2d" not in item or "label" not in item:
|
||||
if not isinstance(item, dict) or "bbox_2d" not in item or "label" not in item:
|
||||
continue
|
||||
boxes_list.append(item["bbox_2d"])
|
||||
labels_list.append(item["label"])
|
||||
@@ -460,12 +460,13 @@ def from_deepseek_vl_2(
|
||||
f"and det tags ({len(detection_segments)}) in the result must be equal."
|
||||
)
|
||||
|
||||
xyxy, class_name_list = [], []
|
||||
xyxy_list: list[list[float]] = []
|
||||
class_name_list: list[str] = []
|
||||
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.append(
|
||||
xyxy_list.append(
|
||||
[
|
||||
(x1 / 999 * width),
|
||||
(y1 / 999 * height),
|
||||
@@ -475,7 +476,7 @@ def from_deepseek_vl_2(
|
||||
)
|
||||
class_name_list.append(current_class_name)
|
||||
|
||||
xyxy = np.array(xyxy, dtype=np.float32)
|
||||
xyxy = np.array(xyxy_list, dtype=np.float32)
|
||||
class_name = np.array(class_name_list)
|
||||
|
||||
if classes is not None:
|
||||
@@ -541,15 +542,15 @@ def from_florence_2(
|
||||
return xyxy, labels, None, xyxyxyxy
|
||||
|
||||
if task in ["<REFERRING_EXPRESSION_SEGMENTATION>", "<REGION_TO_SEGMENTATION>"]:
|
||||
xyxy_list = []
|
||||
masks_list = []
|
||||
xyxy_list: list[npt.NDArray[Any]] = []
|
||||
masks_list: list[npt.NDArray[Any]] = []
|
||||
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 = polygon_to_xyxy(polygon)
|
||||
xyxy_list.append(xyxy)
|
||||
xyxy_box = polygon_to_xyxy(polygon)
|
||||
xyxy_list.append(xyxy_box)
|
||||
# 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"])
|
||||
@@ -640,11 +641,14 @@ 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 "box_2d" not in item or "label" not in item:
|
||||
if not isinstance(item, dict) or "box_2d" not in item or "label" not in item:
|
||||
continue
|
||||
labels.append(item["label"])
|
||||
box = item["box_2d"]
|
||||
@@ -734,13 +738,22 @@ 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 "box_2d" not in item or "label" not in item:
|
||||
if not isinstance(item, dict) or "box_2d" not in item or "label" not in item:
|
||||
continue
|
||||
labels_list.append(item["label"])
|
||||
box = item["box_2d"]
|
||||
@@ -755,29 +768,39 @@ def from_google_gemini_2_5(
|
||||
if "mask" in item:
|
||||
if masks_list is not None:
|
||||
png_str = item["mask"]
|
||||
if not png_str.startswith("data:image/png;base64,"):
|
||||
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).
|
||||
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:
|
||||
masks_list.append(np.zeros((h, w), dtype=bool))
|
||||
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))
|
||||
else:
|
||||
masks_list = None
|
||||
|
||||
|
||||
@@ -329,10 +329,12 @@ class _BaseVertexEllipseAnnotator(BaseKeyPointAnnotator):
|
||||
|
||||
def _iter_ellipse_params(
|
||||
self, key_points: KeyPoints
|
||||
) -> list[tuple[tuple[int, int], tuple[int, int], float, float, Color]]:
|
||||
"""Yield (center, axis_lengths, angle, sigma, color) for each visible point."""
|
||||
) -> list[list[tuple[tuple[int, int], tuple[int, int], float, float, Color]]]:
|
||||
"""Return ellipse params grouped by sigma level (outermost first)."""
|
||||
covariances = self._get_covariances(key_points)
|
||||
results: list[tuple[tuple[int, int], tuple[int, int], float, float, Color]] = []
|
||||
levels: list[
|
||||
list[tuple[tuple[int, int], tuple[int, int], float, float, Color]]
|
||||
] = [[] for _ in self.sigma]
|
||||
for detection_index, xy in enumerate(key_points.xy):
|
||||
for point_index, (x, y) in enumerate(xy):
|
||||
if np.allclose((x, y), 0):
|
||||
@@ -351,7 +353,7 @@ class _BaseVertexEllipseAnnotator(BaseKeyPointAnnotator):
|
||||
np.degrees(np.arctan2(eigenvectors[1, 0], eigenvectors[0, 0]))
|
||||
)
|
||||
center = (round(x), round(y))
|
||||
for sigma, color in zip(self.sigma, self.color):
|
||||
for level_idx, (sigma, color) in enumerate(zip(self.sigma, self.color)):
|
||||
axes = sigma * np.sqrt(eigenvalues)
|
||||
if self.max_axis is not None:
|
||||
axes = np.minimum(axes, self.max_axis)
|
||||
@@ -359,8 +361,10 @@ class _BaseVertexEllipseAnnotator(BaseKeyPointAnnotator):
|
||||
max(1, round(axes[0])),
|
||||
max(1, round(axes[1])),
|
||||
)
|
||||
results.append((center, axis_lengths, angle, sigma, color))
|
||||
return results
|
||||
levels[level_idx].append(
|
||||
(center, axis_lengths, angle, sigma, color)
|
||||
)
|
||||
return levels
|
||||
|
||||
|
||||
class VertexEllipseAreaAnnotator(_BaseVertexEllipseAnnotator):
|
||||
@@ -446,20 +450,19 @@ class VertexEllipseAreaAnnotator(_BaseVertexEllipseAnnotator):
|
||||
return scene
|
||||
|
||||
overlay = scene.copy()
|
||||
for center, axis_lengths, angle, _sigma, color in self._iter_ellipse_params(
|
||||
key_points
|
||||
):
|
||||
cv2.ellipse(
|
||||
img=overlay,
|
||||
center=center,
|
||||
axes=axis_lengths,
|
||||
angle=angle,
|
||||
startAngle=0,
|
||||
endAngle=360,
|
||||
color=color.as_bgr(),
|
||||
thickness=-1,
|
||||
lineType=cv2.LINE_AA,
|
||||
)
|
||||
for level in self._iter_ellipse_params(key_points):
|
||||
for center, axis_lengths, angle, _sigma, color in level:
|
||||
cv2.ellipse(
|
||||
img=overlay,
|
||||
center=center,
|
||||
axes=axis_lengths,
|
||||
angle=angle,
|
||||
startAngle=0,
|
||||
endAngle=360,
|
||||
color=color.as_bgr(),
|
||||
thickness=-1,
|
||||
lineType=cv2.LINE_AA,
|
||||
)
|
||||
|
||||
cv2.addWeighted(overlay, self.opacity, scene, 1 - self.opacity, 0, dst=scene)
|
||||
return scene
|
||||
@@ -545,20 +548,19 @@ class VertexEllipseOutlineAnnotator(_BaseVertexEllipseAnnotator):
|
||||
if len(key_points) == 0:
|
||||
return scene
|
||||
|
||||
for center, axis_lengths, angle, _sigma, color in self._iter_ellipse_params(
|
||||
key_points
|
||||
):
|
||||
cv2.ellipse(
|
||||
img=scene,
|
||||
center=center,
|
||||
axes=axis_lengths,
|
||||
angle=angle,
|
||||
startAngle=0,
|
||||
endAngle=360,
|
||||
color=color.as_bgr(),
|
||||
thickness=self.thickness,
|
||||
lineType=cv2.LINE_AA,
|
||||
)
|
||||
for level in self._iter_ellipse_params(key_points):
|
||||
for center, axis_lengths, angle, _sigma, color in level:
|
||||
cv2.ellipse(
|
||||
img=scene,
|
||||
center=center,
|
||||
axes=axis_lengths,
|
||||
angle=angle,
|
||||
startAngle=0,
|
||||
endAngle=360,
|
||||
color=color.as_bgr(),
|
||||
thickness=self.thickness,
|
||||
lineType=cv2.LINE_AA,
|
||||
)
|
||||
|
||||
return scene
|
||||
|
||||
@@ -650,47 +652,46 @@ class VertexEllipseHaloAnnotator(_BaseVertexEllipseAnnotator):
|
||||
h, w = scene.shape[:2]
|
||||
composite: npt.NDArray[np.float32] = scene.astype(np.float32)
|
||||
|
||||
for center, axis_lengths, angle, _sigma, color in self._iter_ellipse_params(
|
||||
key_points
|
||||
):
|
||||
ax, ay = axis_lengths
|
||||
if ax == 0 or ay == 0:
|
||||
continue
|
||||
for level in self._iter_ellipse_params(key_points):
|
||||
for center, axis_lengths, angle, _sigma, color in level:
|
||||
ax, ay = axis_lengths
|
||||
if ax == 0 or ay == 0:
|
||||
continue
|
||||
|
||||
pad = 2
|
||||
roi_half_w = ax + pad
|
||||
roi_half_h = ay + pad
|
||||
cx, cy = center
|
||||
pad = 2
|
||||
roi_half_w = ax + pad
|
||||
roi_half_h = ay + pad
|
||||
cx, cy = center
|
||||
|
||||
x_min = max(cx - roi_half_w, 0)
|
||||
x_max = min(cx + roi_half_w, w)
|
||||
y_min = max(cy - roi_half_h, 0)
|
||||
y_max = min(cy + roi_half_h, h)
|
||||
if x_min >= x_max or y_min >= y_max:
|
||||
continue
|
||||
x_min = max(cx - roi_half_w, 0)
|
||||
x_max = min(cx + roi_half_w, w)
|
||||
y_min = max(cy - roi_half_h, 0)
|
||||
y_max = min(cy + roi_half_h, h)
|
||||
if x_min >= x_max or y_min >= y_max:
|
||||
continue
|
||||
|
||||
ys = np.arange(y_min, y_max, dtype=np.float32) - cy
|
||||
xs = np.arange(x_min, x_max, dtype=np.float32) - cx
|
||||
grid_x, grid_y = np.meshgrid(xs, ys)
|
||||
ys = np.arange(y_min, y_max, dtype=np.float32) - cy
|
||||
xs = np.arange(x_min, x_max, dtype=np.float32) - cx
|
||||
grid_x, grid_y = np.meshgrid(xs, ys)
|
||||
|
||||
angle_rad = np.radians(-angle)
|
||||
cos_a = np.cos(angle_rad)
|
||||
sin_a = np.sin(angle_rad)
|
||||
rx = grid_x * cos_a - grid_y * sin_a
|
||||
ry = grid_x * sin_a + grid_y * cos_a
|
||||
angle_rad = np.radians(-angle)
|
||||
cos_a = np.cos(angle_rad)
|
||||
sin_a = np.sin(angle_rad)
|
||||
rx = grid_x * cos_a - grid_y * sin_a
|
||||
ry = grid_x * sin_a + grid_y * cos_a
|
||||
|
||||
dist_sq = (rx / ax) ** 2 + (ry / ay) ** 2
|
||||
inside = dist_sq <= 1.0
|
||||
dist_sq = (rx / ax) ** 2 + (ry / ay) ** 2
|
||||
inside = dist_sq <= 1.0
|
||||
|
||||
falloff = np.zeros_like(dist_sq)
|
||||
falloff[inside] = (1.0 - dist_sq[inside]) ** self._DECAY
|
||||
falloff = np.zeros_like(dist_sq)
|
||||
falloff[inside] = (1.0 - dist_sq[inside]) ** self._DECAY
|
||||
|
||||
scaled_alpha = falloff * self.opacity
|
||||
scaled_alpha = falloff * self.opacity
|
||||
|
||||
bgr = np.array(color.as_bgr(), dtype=np.float32)
|
||||
roi = composite[y_min:y_max, x_min:x_max]
|
||||
alpha_3 = scaled_alpha[:, :, np.newaxis]
|
||||
roi[:] = roi * (1 - alpha_3) + bgr * alpha_3
|
||||
bgr = np.array(color.as_bgr(), dtype=np.float32)
|
||||
roi = composite[y_min:y_max, x_min:x_max]
|
||||
alpha_3 = scaled_alpha[:, :, np.newaxis]
|
||||
roi[:] = roi * (1 - alpha_3) + bgr * alpha_3
|
||||
|
||||
np.copyto(scene, composite.astype(np.uint8))
|
||||
return scene
|
||||
|
||||
@@ -11,6 +11,10 @@ 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
|
||||
|
||||
@@ -67,7 +71,7 @@ def _normalize_row_index(
|
||||
return i
|
||||
|
||||
|
||||
@dataclass
|
||||
@dataclass(init=False)
|
||||
class KeyPoints:
|
||||
"""
|
||||
The `sv.KeyPoints` class in the Supervision library standardizes results from
|
||||
@@ -238,6 +242,59 @@ class KeyPoints:
|
||||
visible: npt.NDArray[np.bool_] | None = None
|
||||
data: dict[str, npt.NDArray[np.generic] | list[Any]] = field(default_factory=dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
xy: npt.NDArray[np.float32],
|
||||
class_id: npt.NDArray[np.int_] | None = None,
|
||||
keypoint_confidence: npt.NDArray[np.float32] | None = None,
|
||||
detection_confidence: npt.NDArray[np.float32] | None = None,
|
||||
visible: npt.NDArray[np.bool_] | None = None,
|
||||
data: dict[str, npt.NDArray[np.generic] | list[Any]] | None = None,
|
||||
*,
|
||||
confidence: npt.NDArray[np.float32] | None = None,
|
||||
) -> None:
|
||||
"""Initialize KeyPoints.
|
||||
|
||||
Args:
|
||||
xy: Array of shape `(n, m, 2)` with keypoint coordinates.
|
||||
class_id: Array of shape `(n,)` with class IDs. Defaults to None.
|
||||
keypoint_confidence: Array of shape `(n, m)` with per-keypoint
|
||||
confidence scores. Defaults to None.
|
||||
detection_confidence: Array of shape `(n,)` with detection-level
|
||||
confidence scores. Defaults to None.
|
||||
visible: Boolean array of shape `(n, m)` indicating visible
|
||||
keypoints. Defaults to None.
|
||||
data: Dictionary of additional per-detection data arrays.
|
||||
Defaults to an empty dict.
|
||||
confidence: Deprecated since `0.29.0`, removed in `0.32.0`.
|
||||
Use ``keypoint_confidence`` instead. Raises ``ValueError``
|
||||
if passed together with ``keypoint_confidence``.
|
||||
|
||||
Raises:
|
||||
ValueError: If both ``confidence`` and ``keypoint_confidence``
|
||||
are provided.
|
||||
"""
|
||||
if confidence is not None:
|
||||
if keypoint_confidence is not None:
|
||||
raise ValueError(
|
||||
"Cannot pass both 'confidence' and 'keypoint_confidence'. "
|
||||
"'confidence' is deprecated — use 'keypoint_confidence' only."
|
||||
)
|
||||
warn_deprecated(
|
||||
"'confidence' parameter in `KeyPoints()` is deprecated since "
|
||||
"`0.29.0` and will be removed in `0.32.0`. Use "
|
||||
"'keypoint_confidence' instead."
|
||||
)
|
||||
keypoint_confidence = confidence
|
||||
|
||||
self.xy = xy
|
||||
self.class_id = class_id
|
||||
self.keypoint_confidence = keypoint_confidence
|
||||
self.detection_confidence = detection_confidence
|
||||
self.visible = visible
|
||||
self.data = data if data is not None else {}
|
||||
self.__post_init__()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_keypoints_fields(
|
||||
xy=self.xy,
|
||||
@@ -1076,6 +1133,94 @@ 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:
|
||||
@@ -1088,7 +1233,9 @@ 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.
|
||||
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.
|
||||
|
||||
Returns:
|
||||
detections: The converted detections object.
|
||||
@@ -1109,40 +1256,37 @@ class KeyPoints:
|
||||
if self.is_empty():
|
||||
return Detections.empty()
|
||||
|
||||
detections_list = []
|
||||
for i, xy in enumerate(self.xy):
|
||||
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
|
||||
if selected_keypoint_indices:
|
||||
xy = xy[selected_keypoint_indices]
|
||||
keypoint_confidence = keypoint_confidence[:, indices]
|
||||
confidence = keypoint_confidence.mean(axis=1).astype(np.float32)
|
||||
else:
|
||||
confidence = None
|
||||
|
||||
# [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 = Detections(xyxy=xyxy, confidence=confidence)
|
||||
detections.class_id = self.class_id
|
||||
detections.data = self.data
|
||||
detections = cast(Detections, detections[cast(Any, detections.area) > 0])
|
||||
|
||||
@@ -187,7 +187,7 @@ def _validate_input_tensors(
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_input_tensors,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_input_tensors(
|
||||
predictions: list[npt.NDArray[np.float32]],
|
||||
|
||||
@@ -152,6 +152,14 @@ 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] = []
|
||||
|
||||
@@ -159,7 +167,22 @@ class F1Score(Metric):
|
||||
prediction_contents = self._detections_content(predictions)
|
||||
target_contents = self._detections_content(targets)
|
||||
|
||||
if len(targets) > 0:
|
||||
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(predictions) == 0:
|
||||
stats.append(
|
||||
(
|
||||
@@ -244,10 +267,23 @@ 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]
|
||||
unique_classes, class_counts = np.unique(true_class_ids, return_counts=True)
|
||||
# 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
|
||||
|
||||
# Shape: PxTh,P,C,C -> CxThx3
|
||||
confusion_matrix = self._compute_confusion_matrix(
|
||||
@@ -265,7 +301,13 @@ class F1Score(Metric):
|
||||
f1_scores = self._compute_f1(confusion_matrix_merged)
|
||||
elif self.averaging_method == AveragingMethod.WEIGHTED:
|
||||
class_counts = class_counts.astype(np.float32)
|
||||
f1_scores = np.average(f1_per_class, axis=0, weights=class_counts)
|
||||
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)
|
||||
|
||||
return f1_scores, f1_per_class, unique_classes
|
||||
|
||||
@@ -483,10 +525,12 @@ 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_target_classes, num_iou_thresholds)`
|
||||
Shape: `(num_classes, num_iou_thresholds)`
|
||||
iou_thresholds: the IoU thresholds used in the calculations.
|
||||
matched_classes: the class IDs of all matched classes.
|
||||
Corresponds to the rows of `f1_per_class`.
|
||||
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`.
|
||||
small_objects: the F1 metric results
|
||||
for small objects (area < 32²).
|
||||
medium_objects: the F1 metric results
|
||||
|
||||
@@ -155,6 +155,14 @@ 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] = []
|
||||
|
||||
@@ -162,7 +170,22 @@ class Precision(Metric):
|
||||
prediction_contents = self._detections_content(predictions)
|
||||
target_contents = self._detections_content(targets)
|
||||
|
||||
if len(targets) > 0:
|
||||
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(predictions) == 0:
|
||||
stats.append(
|
||||
(
|
||||
@@ -247,10 +270,23 @@ 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]
|
||||
unique_classes, class_counts = np.unique(true_class_ids, return_counts=True)
|
||||
# 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
|
||||
|
||||
# Shape: PxTh,P,C,C -> CxThx3
|
||||
confusion_matrix = self._compute_confusion_matrix(
|
||||
@@ -268,9 +304,15 @@ class Precision(Metric):
|
||||
precision_scores = self._compute_precision(confusion_matrix_merged)
|
||||
elif self.averaging_method == AveragingMethod.WEIGHTED:
|
||||
class_counts = class_counts.astype(np.float32)
|
||||
precision_scores = np.average(
|
||||
precision_per_class, axis=0, weights=class_counts
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
return precision_scores, precision_per_class, unique_classes
|
||||
|
||||
@@ -428,8 +470,6 @@ 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:
|
||||
@@ -494,10 +534,12 @@ 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_target_classes, num_iou_thresholds)`
|
||||
IoU threshold. Shape: `(num_classes, num_iou_thresholds)`
|
||||
iou_thresholds: the IoU thresholds used in the calculations.
|
||||
matched_classes: the class IDs of all matched classes.
|
||||
Corresponds to the rows of `precision_per_class`.
|
||||
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`.
|
||||
small_objects: the Precision metric results
|
||||
for small objects (area < 32²).
|
||||
medium_objects: the Precision metric results
|
||||
|
||||
@@ -158,7 +158,9 @@ def pillow_to_cv2(image: Image.Image) -> npt.NDArray[np.uint8]:
|
||||
"""
|
||||
scene = np.array(image)
|
||||
scene = cv2.cvtColor(scene, cv2.COLOR_RGB2BGR)
|
||||
return scene.astype(np.uint8)
|
||||
# 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)
|
||||
|
||||
|
||||
def cv2_to_pillow(image: npt.NDArray[np.uint8]) -> Image.Image:
|
||||
|
||||
@@ -182,7 +182,7 @@ def _validate_tracker_id(tracker_id: Any, n: int) -> None:
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_tracker_id,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_tracker_id(tracker_id: Any, n: int) -> None:
|
||||
void(tracker_id, n)
|
||||
@@ -207,7 +207,7 @@ def _validate_data(data: dict[str, Any], n: int) -> None:
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_data,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_data(data: dict[str, Any], n: int) -> None:
|
||||
void(data, n)
|
||||
@@ -227,7 +227,7 @@ def _validate_xy(xy: Any, n: int, m: int) -> None:
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_xy,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_xy(xy: Any, n: int, m: int) -> None:
|
||||
void(xy, n, m)
|
||||
@@ -276,7 +276,7 @@ def _validate_detections_fields(
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_detections_fields,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_detections_fields(
|
||||
xyxy: Any,
|
||||
@@ -312,7 +312,7 @@ def _validate_keypoints_fields(
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_keypoints_fields,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_key_points_fields(
|
||||
xy: Any, class_id: Any, confidence: Any, data: dict[str, Any]
|
||||
@@ -357,7 +357,7 @@ def _validate_resolution(resolution: Any) -> tuple[int, int]:
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_resolution,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_resolution(resolution: Any) -> tuple[int, int]:
|
||||
return void(resolution) # type: ignore[no-any-return]
|
||||
|
||||
@@ -30,8 +30,10 @@ 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
|
||||
@@ -363,6 +365,173 @@ 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,6 +13,7 @@ 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,
|
||||
@@ -43,6 +44,14 @@ def mock_coco_annotation(
|
||||
}
|
||||
|
||||
|
||||
def _empty_raw_segs(n: int) -> np.ndarray:
|
||||
"""Object-dtype array of n empty lists for coco_raw_segmentation (bbox-only)."""
|
||||
arr = np.empty(n, dtype=object)
|
||||
for i in range(n):
|
||||
arr[i] = []
|
||||
return arr
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def coco_data_with_and_without_segmentation() -> dict[str, object]:
|
||||
return {
|
||||
@@ -282,6 +291,7 @@ def test_group_coco_annotations_by_image_id(
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 100, 100]], dtype=np.float32),
|
||||
class_id=np.array([0], dtype=int),
|
||||
data={"coco_raw_segmentation": _empty_raw_segs(1)},
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # single image annotations
|
||||
@@ -300,6 +310,7 @@ def test_group_coco_annotations_by_image_id(
|
||||
data={
|
||||
"iscrowd": np.array([0], dtype=int),
|
||||
"area": np.array([100 * 100]),
|
||||
"coco_raw_segmentation": _empty_raw_segs(1),
|
||||
},
|
||||
),
|
||||
DoesNotRaise(),
|
||||
@@ -321,6 +332,7 @@ def test_group_coco_annotations_by_image_id(
|
||||
[[0, 0, 100, 100], [100, 100, 200, 200]], dtype=np.float32
|
||||
),
|
||||
class_id=np.array([0, 0], dtype=int),
|
||||
data={"coco_raw_segmentation": _empty_raw_segs(2)},
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # two image annotations
|
||||
@@ -344,6 +356,7 @@ def test_group_coco_annotations_by_image_id(
|
||||
data={
|
||||
"iscrowd": np.array([0, 0], dtype=int),
|
||||
"area": np.array([100 * 100, 100 * 100]),
|
||||
"coco_raw_segmentation": _empty_raw_segs(2),
|
||||
},
|
||||
),
|
||||
DoesNotRaise(),
|
||||
@@ -983,6 +996,80 @@ 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(
|
||||
@@ -1845,3 +1932,173 @@ def test_coco_round_trip_multi_class_single_image(tmp_path) -> None:
|
||||
dets = loaded.annotations[img_path]
|
||||
assert dets.class_id is not None
|
||||
assert sorted(dets.class_id.tolist()) == [0, 1]
|
||||
|
||||
|
||||
# --- Regression: segmentation round-trip (#2285) ---
|
||||
|
||||
|
||||
def _coco_annotation_with_segmentation(
|
||||
segmentation: list[list[int]],
|
||||
bbox: tuple[float, float, float, float] = (0, 0, 5, 5),
|
||||
area: float = 25,
|
||||
) -> dict:
|
||||
return mock_coco_annotation(
|
||||
annotation_id=1,
|
||||
image_id=1,
|
||||
category_id=1,
|
||||
bbox=bbox,
|
||||
area=area,
|
||||
segmentation=segmentation,
|
||||
)
|
||||
|
||||
|
||||
def _single_image_coco_data(annotation: dict) -> dict[str, object]:
|
||||
return {
|
||||
"info": {},
|
||||
"licenses": [],
|
||||
"categories": [{"id": 1, "name": "cat", "supercategory": ""}],
|
||||
"images": [{"id": 1, "file_name": "img.jpg", "width": 10, "height": 10}],
|
||||
"annotations": [annotation],
|
||||
}
|
||||
|
||||
|
||||
def test_detections_to_coco_annotations_exports_all_polygons() -> None:
|
||||
"""All polygons from a multi-component mask must be exported, not just the first."""
|
||||
# Build a mask with two separate rectangles (disjoint components)
|
||||
mask = np.zeros((20, 20), dtype=bool)
|
||||
mask[1:4, 1:4] = True # top-left component
|
||||
mask[14:18, 14:18] = True # bottom-right component
|
||||
|
||||
detections = Detections(
|
||||
xyxy=np.array([[1, 1, 4, 4]], dtype=np.float32),
|
||||
class_id=np.array([0], dtype=int),
|
||||
mask=np.array([mask]),
|
||||
data={"iscrowd": np.array([0], dtype=int)},
|
||||
)
|
||||
annotations, _ = detections_to_coco_annotations(
|
||||
detections=detections, image_id=1, annotation_id=1
|
||||
)
|
||||
assert len(annotations) == 1
|
||||
seg = annotations[0]["segmentation"]
|
||||
# Both components must appear as separate polygon entries (list of lists)
|
||||
assert isinstance(seg, list), "segmentation must be a list of polygons"
|
||||
assert len(seg) >= 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("segmentation", "bbox", "area", "expected_min_polygon_count"),
|
||||
[
|
||||
pytest.param(
|
||||
[[0, 0, 4, 0, 4, 4, 0, 4]],
|
||||
(0, 0, 5, 5),
|
||||
25,
|
||||
1,
|
||||
id="single-polygon",
|
||||
),
|
||||
pytest.param(
|
||||
[[0, 0, 4, 0, 4, 4, 0, 4], [6, 6, 9, 6, 9, 9, 6, 9]],
|
||||
(0, 0, 9, 9),
|
||||
32,
|
||||
2,
|
||||
id="multi-polygon",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_coco_polygon_segmentation_survives_roundtrip(
|
||||
tmp_path,
|
||||
segmentation: list[list[int]],
|
||||
bbox: tuple[float, float, float, float],
|
||||
area: float,
|
||||
expected_min_polygon_count: int,
|
||||
) -> None:
|
||||
"""COCO polygon segmentation survives the load/export sequence.
|
||||
|
||||
1. Write source COCO JSON with polygon segmentation.
|
||||
2. Load it through DetectionDataset.from_coco().
|
||||
3. Export it back to COCO JSON with as_coco().
|
||||
4. Assert the exported segmentation keeps the expected polygon component count.
|
||||
"""
|
||||
images_dir = tmp_path / "images"
|
||||
images_dir.mkdir()
|
||||
|
||||
img_path = images_dir / "img.jpg"
|
||||
assert cv2.imwrite(str(img_path), np.zeros((10, 10, 3), dtype=np.uint8))
|
||||
|
||||
# 1. Write source COCO JSON with polygon segmentation.
|
||||
ann_path = tmp_path / "annotations.json"
|
||||
ann_path.write_text(
|
||||
json.dumps(
|
||||
_single_image_coco_data(
|
||||
_coco_annotation_with_segmentation(
|
||||
segmentation=segmentation, bbox=bbox, area=area
|
||||
)
|
||||
)
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# 2. Load it through the internal DetectionDataset representation.
|
||||
ds = DetectionDataset.from_coco(
|
||||
images_directory_path=str(images_dir),
|
||||
annotations_path=str(ann_path),
|
||||
)
|
||||
|
||||
# 3. Export it back to COCO JSON.
|
||||
out_ann_path = tmp_path / "out_annotations.json"
|
||||
ds.as_coco(annotations_path=str(out_ann_path))
|
||||
|
||||
with open(out_ann_path) as f:
|
||||
out = json.load(f)
|
||||
|
||||
# 4. Assert polygon component count survives the load/export sequence.
|
||||
assert len(out["annotations"]) == 1
|
||||
seg = out["annotations"][0]["segmentation"]
|
||||
assert isinstance(seg, list)
|
||||
assert len(seg) >= expected_min_polygon_count
|
||||
|
||||
|
||||
def test_coco_raw_segmentation_preserved_when_masks_not_decoded() -> None:
|
||||
"""When masks are NOT decoded (with_masks=False), raw polygon data stored in
|
||||
data['segmentation'] is used as a lossless fallback so as_coco() still emits
|
||||
non-empty segmentation."""
|
||||
image_annotations = [
|
||||
_coco_annotation_with_segmentation(segmentation=[[0, 0, 4, 0, 4, 4, 0, 4]])
|
||||
]
|
||||
|
||||
# Load WITHOUT mask decoding — mask must be None
|
||||
detections = coco_annotations_to_detections(
|
||||
image_annotations=image_annotations,
|
||||
resolution_wh=(10, 10),
|
||||
with_masks=False,
|
||||
)
|
||||
assert detections.mask is None
|
||||
# Raw segmentation must be stored in data for fallback
|
||||
assert "coco_raw_segmentation" in detections.data
|
||||
|
||||
# Export must still produce non-empty segmentation via fallback
|
||||
annotations, _ = detections_to_coco_annotations(
|
||||
detections=detections, image_id=1, annotation_id=1
|
||||
)
|
||||
assert len(annotations) == 1
|
||||
assert annotations[0]["segmentation"] != []
|
||||
|
||||
|
||||
def test_coco_iscrowd_mask_exports_as_rle() -> None:
|
||||
"""Multi-segment mask exports segmentation as RLE dict (iscrowd inferred as 1)."""
|
||||
mask = np.zeros((10, 10), dtype=bool)
|
||||
mask[1:3, 1:3] = True # top-left component
|
||||
mask[7:9, 7:9] = True # bottom-right component (two separate regions)
|
||||
|
||||
detections = Detections(
|
||||
xyxy=np.array([[1, 1, 8, 8]], dtype=np.float32),
|
||||
class_id=np.array([0], dtype=int),
|
||||
mask=np.array([mask]),
|
||||
)
|
||||
annotations, _ = detections_to_coco_annotations(
|
||||
detections=detections, image_id=1, annotation_id=1
|
||||
)
|
||||
assert len(annotations) == 1
|
||||
seg = annotations[0]["segmentation"]
|
||||
assert isinstance(seg, dict), "multi-segment mask must export as RLE dict, not list"
|
||||
assert "counts" in seg
|
||||
assert "size" in seg
|
||||
|
||||
@@ -8,6 +8,7 @@ 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,
|
||||
)
|
||||
@@ -72,6 +73,47 @@ 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"),
|
||||
[
|
||||
|
||||
+716
-22
@@ -7,7 +7,14 @@ import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.config import ORIENTED_BOX_COORDINATES
|
||||
from supervision.detection.core import Detections, merge_inner_detection_object_pair
|
||||
from supervision.detection.core import (
|
||||
Detections,
|
||||
_merge_detection_group,
|
||||
_merge_obb_corners,
|
||||
merge_inner_detection_object_pair,
|
||||
)
|
||||
from supervision.detection.utils.boxes import xyxyxyxy_to_xyxy
|
||||
from supervision.detection.utils.iou_and_nms import OverlapMetric
|
||||
from supervision.geometry.core import Position
|
||||
from supervision.utils.internal import SupervisionWarnings
|
||||
from tests.helpers import _create_detections
|
||||
@@ -1002,10 +1009,7 @@ def _make_obb_detections(
|
||||
) -> Detections:
|
||||
"""Build OBB Detections from a list of (4, 2) corner arrays."""
|
||||
oriented_boxes = np.stack(quads)
|
||||
xyxy = np.array(
|
||||
[[q[:, 0].min(), q[:, 1].min(), q[:, 0].max(), q[:, 1].max()] for q in quads],
|
||||
dtype=np.float32,
|
||||
)
|
||||
xyxy = xyxyxyxy_to_xyxy(oriented_boxes)
|
||||
return Detections(
|
||||
xyxy=xyxy,
|
||||
confidence=np.array(scores, dtype=np.float32),
|
||||
@@ -1056,31 +1060,721 @@ class TestDetectionsObbDispatch:
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestDetectionsWithNmm:
|
||||
class TestMergeObbCorners:
|
||||
"""_merge_obb_corners"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("corners_list", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
[np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32)],
|
||||
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
|
||||
id="single-box-passthrough",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
|
||||
np.array([[2, 2], [12, 2], [12, 7], [2, 7]], dtype=np.float32),
|
||||
],
|
||||
np.array([[0, 0], [12, 0], [12, 7], [0, 7]], dtype=np.float32),
|
||||
id="two-axis-aligned",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
_rotated_rect(50, 50, 40, 10, 45),
|
||||
_rotated_rect(55, 55, 40, 10, 45),
|
||||
],
|
||||
None,
|
||||
id="two-same-angle",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
_rotated_rect(50, 50, 40, 10, 30),
|
||||
_rotated_rect(55, 50, 40, 10, -15),
|
||||
],
|
||||
None,
|
||||
id="two-different-angles",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
np.array([[0, 0], [20, 0], [20, 10], [0, 10]], dtype=np.float32),
|
||||
np.array([[5, 5], [25, 5], [25, 15], [5, 15]], dtype=np.float32),
|
||||
np.array([[10, 0], [30, 0], [30, 10], [10, 10]], dtype=np.float32),
|
||||
],
|
||||
np.array([[0, 0], [30, 0], [30, 15], [0, 15]], dtype=np.float32),
|
||||
id="three-boxes-axis-aligned",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
|
||||
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
|
||||
],
|
||||
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
|
||||
id="identical-boxes",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
|
||||
np.array([[3, 3], [7, 3], [7, 3], [3, 3]], dtype=np.float32),
|
||||
],
|
||||
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
|
||||
id="degenerate-collinear",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_merge(
|
||||
self, corners_list: list[np.ndarray], expected: np.ndarray | None
|
||||
) -> None:
|
||||
"""Produces correct merged OBB corners."""
|
||||
result = _merge_obb_corners(corners_list)
|
||||
assert result.shape == (4, 2)
|
||||
if expected is not None:
|
||||
assert np.allclose(result, expected, atol=0.5)
|
||||
else:
|
||||
assert result.dtype == np.float32
|
||||
|
||||
|
||||
class TestMergeDetectionGroup:
|
||||
"""_merge_detection_group"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("detections", "expected_detections"),
|
||||
[
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([1]),
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([1]),
|
||||
),
|
||||
id="single-passthrough",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.7], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.8], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
id="two-aabb-merge",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.8], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[10, 10, 20, 20]], dtype=np.float32),
|
||||
confidence=np.array([0.7], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 20, 20]], dtype=np.float32),
|
||||
confidence=np.array([0.8], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
id="three-aabb-merge",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
mask=np.array([[[True, False], [False, False]]], dtype=bool),
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.7], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
mask=np.array([[[False, True], [False, False]]], dtype=bool),
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.8], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
mask=np.array([[[True, True], [False, False]]], dtype=bool),
|
||||
),
|
||||
id="two-aabb-with-mask",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
_make_obb_detections(
|
||||
[
|
||||
np.array(
|
||||
[[0, 0], [10, 0], [10, 5], [0, 5]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
],
|
||||
[0.9],
|
||||
[0],
|
||||
),
|
||||
_make_obb_detections(
|
||||
[
|
||||
np.array(
|
||||
[[2, 2], [12, 2], [12, 7], [2, 7]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
],
|
||||
[0.7],
|
||||
[0],
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 12, 7]], dtype=np.float32),
|
||||
confidence=np.array([0.8], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
id="two-obb-axis-aligned",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
_make_obb_detections(
|
||||
[_rotated_rect(50, 50, 40, 10, 45)], [0.9], [0]
|
||||
),
|
||||
_make_obb_detections(
|
||||
[_rotated_rect(55, 55, 40, 10, 45)], [0.8], [0]
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[32.32, 32.32, 72.68, 72.68]], dtype=np.float32),
|
||||
confidence=np.array([0.85], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
id="two-obb-rotated",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([1]),
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.5], dtype=np.float32),
|
||||
class_id=np.array([2]),
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.7], dtype=np.float32),
|
||||
class_id=np.array([1]),
|
||||
),
|
||||
id="winner-takes-class-id",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
tracker_id=np.array([42]),
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.5], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
tracker_id=np.array([99]),
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.7], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
tracker_id=np.array([42]),
|
||||
),
|
||||
id="winner-takes-tracker-id",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
data={"class_name": np.array(["cat"])},
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.5], dtype=np.float32),
|
||||
class_id=np.array([1]),
|
||||
data={"class_name": np.array(["dog"])},
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.7], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
data={"class_name": np.array(["cat"])},
|
||||
),
|
||||
id="winner-takes-data",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=None,
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
|
||||
confidence=None,
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
|
||||
confidence=None,
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
id="no-confidence",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=None,
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.7], dtype=np.float32),
|
||||
class_id=None,
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.8], dtype=np.float32),
|
||||
class_id=None,
|
||||
),
|
||||
id="no-class-id",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
data={"score": np.array([1.5])},
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.5], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
data={"score": np.array([2.5])},
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.7], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
data={"score": np.array([1.5])},
|
||||
),
|
||||
id="custom-data-field-preserved",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 5, 5]], dtype=np.float32),
|
||||
confidence=np.array([0.7], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
id="zero-area-box-in-group",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
metadata={"source": "model_a"},
|
||||
),
|
||||
Detections(
|
||||
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.7], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
metadata={"source": "model_a"},
|
||||
),
|
||||
],
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
|
||||
confidence=np.array([0.8], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
),
|
||||
id="metadata-merge",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_merge(
|
||||
self,
|
||||
detections: list[Detections],
|
||||
expected_detections: Detections,
|
||||
) -> None:
|
||||
"""Merges detection group correctly."""
|
||||
result = _merge_detection_group(detections)
|
||||
assert len(result) == 1
|
||||
assert np.allclose(result.xyxy, expected_detections.xyxy, atol=0.5)
|
||||
if expected_detections.confidence is not None:
|
||||
assert np.allclose(
|
||||
result.confidence, expected_detections.confidence, atol=1e-3
|
||||
)
|
||||
else:
|
||||
assert result.confidence is None
|
||||
if expected_detections.class_id is not None:
|
||||
assert np.array_equal(result.class_id, expected_detections.class_id)
|
||||
else:
|
||||
assert result.class_id is None
|
||||
if expected_detections.tracker_id is not None:
|
||||
assert np.array_equal(result.tracker_id, expected_detections.tracker_id)
|
||||
else:
|
||||
assert result.tracker_id is None
|
||||
if expected_detections.mask is not None:
|
||||
assert np.array_equal(result.mask, expected_detections.mask)
|
||||
else:
|
||||
assert result.mask is None
|
||||
for key, val in expected_detections.data.items():
|
||||
assert np.array_equal(result.data[key], val)
|
||||
if ORIENTED_BOX_COORDINATES in result.data:
|
||||
corners = result.data[ORIENTED_BOX_COORDINATES]
|
||||
assert np.allclose(result.xyxy, xyxyxyxy_to_xyxy(corners), atol=1e-5)
|
||||
|
||||
|
||||
class TestDetectionsWithNMM:
|
||||
"""NMM-specific behaviour tests for `Detections.with_nmm`."""
|
||||
|
||||
def test_obb_merged_xyxy_matches_winner_aabb(self) -> None:
|
||||
"""OBB merge group: merged xyxy must equal winner's AABB, not union AABB.
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"corners",
|
||||
"confidence",
|
||||
"class_ids",
|
||||
"iou_threshold",
|
||||
"class_agnostic",
|
||||
"overlap_metric",
|
||||
"expected_corners",
|
||||
"expected_confidence",
|
||||
"exception",
|
||||
),
|
||||
[
|
||||
pytest.param(
|
||||
[
|
||||
[[10, 10], [50, 10], [50, 30], [10, 30]],
|
||||
[[11, 11], [51, 11], [51, 31], [11, 31]],
|
||||
],
|
||||
[0.9, 0.85],
|
||||
[0, 0],
|
||||
0.5,
|
||||
False,
|
||||
OverlapMetric.IOU,
|
||||
[[[10, 10], [51, 10], [51, 31], [10, 31]]],
|
||||
[0.875],
|
||||
DoesNotRaise(),
|
||||
id="axis-aligned-merge",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
_rotated_rect(50, 50, 40, 10, 45).tolist(),
|
||||
_rotated_rect(55, 55, 40, 10, 45).tolist(),
|
||||
],
|
||||
[0.9, 0.8],
|
||||
[0, 0],
|
||||
0.3,
|
||||
False,
|
||||
OverlapMetric.IOU,
|
||||
[[[39.39, 32.32], [72.68, 65.61], [65.61, 72.68], [32.32, 39.39]]],
|
||||
[0.85],
|
||||
DoesNotRaise(),
|
||||
id="rotated-45deg-merge",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
[[0, 0], [20, 0], [20, 10], [0, 10]],
|
||||
[[5, 5], [25, 5], [25, 15], [5, 15]],
|
||||
[[10, 0], [30, 0], [30, 10], [10, 10]],
|
||||
],
|
||||
[0.9, 0.8, 0.7],
|
||||
[0, 0, 0],
|
||||
0.2,
|
||||
False,
|
||||
OverlapMetric.IOU,
|
||||
[[[0, 0], [30, 0], [30, 15], [0, 15]]],
|
||||
[0.8],
|
||||
DoesNotRaise(),
|
||||
id="three-group-merge",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
[[10, 10], [50, 10], [50, 30], [10, 30]],
|
||||
],
|
||||
[0.9],
|
||||
[0],
|
||||
0.5,
|
||||
False,
|
||||
OverlapMetric.IOU,
|
||||
[[[10, 10], [50, 10], [50, 30], [10, 30]]],
|
||||
[0.9],
|
||||
DoesNotRaise(),
|
||||
id="single-passthrough",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
[[0, 0], [30, 0], [30, 20], [0, 20]],
|
||||
[[5, 5], [35, 5], [35, 25], [5, 25]],
|
||||
],
|
||||
[0.9, 0.8],
|
||||
[0, 1],
|
||||
0.3,
|
||||
True,
|
||||
OverlapMetric.IOU,
|
||||
[[[0, 0], [35, 0], [35, 25], [0, 25]]],
|
||||
[0.85],
|
||||
DoesNotRaise(),
|
||||
id="class-agnostic",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
[[0, 0], [40, 0], [40, 30], [0, 30]],
|
||||
[[10, 10], [30, 10], [30, 20], [10, 20]],
|
||||
],
|
||||
[0.9, 0.8],
|
||||
[0, 0],
|
||||
0.3,
|
||||
False,
|
||||
OverlapMetric.IOS,
|
||||
[[[0, 0], [40, 0], [40, 30], [0, 30]]],
|
||||
[0.885714],
|
||||
DoesNotRaise(),
|
||||
id="ios-metric",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
_rotated_rect(50, 50, 40, 15, 30).tolist(),
|
||||
_rotated_rect(55, 50, 40, 15, -15).tolist(),
|
||||
],
|
||||
[0.9, 0.7],
|
||||
[0, 0],
|
||||
0.2,
|
||||
False,
|
||||
OverlapMetric.IOU,
|
||||
[[[43.65, 20.99], [81.56, 42.88], [62.12, 76.56], [24.21, 54.68]]],
|
||||
[0.813652],
|
||||
DoesNotRaise(),
|
||||
id="mixed-angle-merge",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
[[0, 0], [30, 0], [30, 20], [0, 20]],
|
||||
[[5, 5], [35, 5], [35, 25], [5, 25]],
|
||||
[[200, 200], [240, 200], [240, 220], [200, 220]],
|
||||
[[205, 205], [245, 205], [245, 225], [205, 225]],
|
||||
],
|
||||
[0.9, 0.7, 0.85, 0.6],
|
||||
[0, 0, 0, 0],
|
||||
0.2,
|
||||
False,
|
||||
OverlapMetric.IOU,
|
||||
[
|
||||
[[0, 0], [35, 0], [35, 25], [0, 25]],
|
||||
[[200, 200], [245, 200], [245, 225], [200, 225]],
|
||||
],
|
||||
[0.8, 0.725],
|
||||
DoesNotRaise(),
|
||||
id="two-separate-groups",
|
||||
),
|
||||
pytest.param(
|
||||
[
|
||||
[[0, 0], [30, 0], [30, 20], [0, 20]],
|
||||
[[5, 10], [25, 10], [25, 10], [5, 10]],
|
||||
],
|
||||
[0.9, 0.7],
|
||||
[0, 0],
|
||||
0.01,
|
||||
False,
|
||||
OverlapMetric.IOU,
|
||||
# A zero-area (collinear) OBB scores IoU 0 (see
|
||||
# test_degenerate_boxes_score_zero), so it cannot group and the
|
||||
# two detections are not merged.
|
||||
[
|
||||
[[0, 0], [30, 0], [30, 20], [0, 20]],
|
||||
[[5, 10], [25, 10], [25, 10], [5, 10]],
|
||||
],
|
||||
[0.9, 0.7],
|
||||
DoesNotRaise(),
|
||||
id="degenerate-collinear-obb",
|
||||
),
|
||||
pytest.param(
|
||||
None,
|
||||
[0.9, 0.8],
|
||||
[0, 0],
|
||||
0.4,
|
||||
False,
|
||||
OverlapMetric.IOU,
|
||||
None,
|
||||
None,
|
||||
pytest.raises(ValueError, match="corners must have shape"),
|
||||
id="flat-n8-raises",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_obb_nmm_merge(
|
||||
self,
|
||||
corners: list[list[list[float]]] | None,
|
||||
confidence: list[float],
|
||||
class_ids: list[int],
|
||||
iou_threshold: float,
|
||||
class_agnostic: bool,
|
||||
overlap_metric: OverlapMetric,
|
||||
expected_corners: list[list[list[float]]] | None,
|
||||
expected_confidence: list[float] | None,
|
||||
exception: DoesNotRaise,
|
||||
) -> None:
|
||||
"""OBB NMM produces correct geometry and confidence."""
|
||||
if corners is None:
|
||||
xyxy = np.array(
|
||||
[[0, 0, 30, 20], [5, 5, 35, 25]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
flat = np.array(
|
||||
[
|
||||
[0, 0, 30, 0, 30, 20, 0, 20],
|
||||
[5, 5, 35, 5, 35, 25, 5, 25],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
detections = Detections(
|
||||
xyxy=xyxy,
|
||||
confidence=np.array(confidence, dtype=np.float32),
|
||||
class_id=np.array(class_ids),
|
||||
data={ORIENTED_BOX_COORDINATES: flat},
|
||||
)
|
||||
else:
|
||||
corner_arrays = [np.array(corner, dtype=np.float32) for corner in corners]
|
||||
detections = _make_obb_detections(corner_arrays, confidence, class_ids)
|
||||
|
||||
Two near-identical OBBs merge into one group. The winner (score 0.9) occupies
|
||||
[10, 10, 50, 30]; the lower-score box [20, 20, 60, 40] is offset. Union AABB
|
||||
would be [10, 10, 60, 40]; fix must produce winner's AABB [10, 10, 50, 30].
|
||||
"""
|
||||
quad_winner = np.array(
|
||||
[[10, 10], [50, 10], [50, 30], [10, 30]], dtype=np.float32
|
||||
with exception:
|
||||
result = detections.with_nmm(
|
||||
threshold=iou_threshold,
|
||||
class_agnostic=class_agnostic,
|
||||
overlap_metric=overlap_metric,
|
||||
)
|
||||
|
||||
assert expected_confidence is not None
|
||||
assert expected_corners is not None
|
||||
assert len(result) == len(expected_confidence)
|
||||
for i, exp_c in enumerate(expected_confidence):
|
||||
assert result.confidence[i] == pytest.approx(exp_c, abs=1e-3)
|
||||
result_corners = result.data[ORIENTED_BOX_COORDINATES]
|
||||
expected_corner_array = np.array(expected_corners, dtype=np.float32)
|
||||
assert np.allclose(
|
||||
result_corners,
|
||||
expected_corner_array,
|
||||
atol=0.5,
|
||||
)
|
||||
|
||||
def test_obb_nmm_matches_aabb_for_axis_aligned(self) -> None:
|
||||
"""Axis-aligned OBB NMM produces same envelope as AABB NMM."""
|
||||
xyxy = np.array([[0, 0, 30, 20], [5, 5, 35, 25]], dtype=np.float32)
|
||||
confidence = np.array([0.9, 0.5], dtype=np.float32)
|
||||
class_id = np.array([0, 0])
|
||||
|
||||
aabb_detections = Detections(
|
||||
xyxy=xyxy,
|
||||
confidence=confidence,
|
||||
class_id=class_id,
|
||||
)
|
||||
quad_other = np.array(
|
||||
[[11, 11], [51, 11], [51, 31], [11, 31]], dtype=np.float32
|
||||
)
|
||||
detections = _make_obb_detections(
|
||||
[quad_winner, quad_other], [0.9, 0.85], [0, 0]
|
||||
obb_detections = _make_obb_detections(
|
||||
[
|
||||
np.array(
|
||||
[[0, 0], [30, 0], [30, 20], [0, 20]],
|
||||
dtype=np.float32,
|
||||
),
|
||||
np.array(
|
||||
[[5, 5], [35, 5], [35, 25], [5, 25]],
|
||||
dtype=np.float32,
|
||||
),
|
||||
],
|
||||
confidence.tolist(),
|
||||
class_id.tolist(),
|
||||
)
|
||||
|
||||
result = detections.with_nmm(threshold=0.5)
|
||||
aabb_result = aabb_detections.with_nmm(threshold=0.4)
|
||||
obb_result = obb_detections.with_nmm(threshold=0.4)
|
||||
|
||||
assert len(aabb_result) == 1
|
||||
assert len(obb_result) == 1
|
||||
assert np.allclose(aabb_result.xyxy, obb_result.xyxy, atol=1e-4)
|
||||
|
||||
def test_staircase_obb_merge_within_union(self) -> None:
|
||||
"""Diagonal staircase OBBs: merged AABB equals axis-aligned union."""
|
||||
quads = [
|
||||
np.array(
|
||||
[[0, 0], [20, 0], [20, 20], [0, 20]],
|
||||
dtype=np.float32,
|
||||
),
|
||||
np.array(
|
||||
[[12, 12], [32, 12], [32, 32], [12, 32]],
|
||||
dtype=np.float32,
|
||||
),
|
||||
np.array(
|
||||
[[24, 24], [44, 24], [44, 44], [24, 44]],
|
||||
dtype=np.float32,
|
||||
),
|
||||
]
|
||||
detections = _make_obb_detections(quads, [0.7, 0.9, 0.8], [0, 0, 0])
|
||||
|
||||
result = detections.with_nmm(threshold=0.05)
|
||||
|
||||
assert len(result) == 1
|
||||
expected_xyxy = np.array([[10.0, 10.0, 50.0, 30.0]], dtype=np.float32)
|
||||
assert np.allclose(result.xyxy, expected_xyxy, atol=0.5)
|
||||
assert np.allclose(result.xyxy, [[0.0, 0.0, 44.0, 44.0]], atol=0.5)
|
||||
|
||||
def test_obb_nmm_empty_detections(self) -> None:
|
||||
"""Empty OBB detections return empty result."""
|
||||
dets = Detections(
|
||||
xyxy=np.empty((0, 4), dtype=np.float32),
|
||||
confidence=np.array([], dtype=np.float32),
|
||||
class_id=np.array([], dtype=int),
|
||||
data={ORIENTED_BOX_COORDINATES: np.empty((0, 4, 2), dtype=np.float32)},
|
||||
)
|
||||
|
||||
result = dets.with_nmm(threshold=0.5)
|
||||
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
class TestDetectionsArea:
|
||||
|
||||
@@ -395,6 +395,53 @@ 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,3 +1297,49 @@ 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)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""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)
|
||||
@@ -10,6 +10,7 @@ from supervision.detection.utils.boxes import (
|
||||
denormalize_boxes,
|
||||
move_boxes,
|
||||
scale_boxes,
|
||||
xyxyxyxy_to_xyxy,
|
||||
)
|
||||
|
||||
|
||||
@@ -232,3 +233,40 @@ def test_denormalize_boxes(
|
||||
normalization_factor=normalization_factor,
|
||||
)
|
||||
assert np.allclose(result, expected_result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("corners", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
np.array([[[0, 0], [10, 0], [10, 5], [0, 5]]], dtype=np.float32),
|
||||
np.array([[0, 0, 10, 5]], dtype=np.float32),
|
||||
id="single-axis-aligned",
|
||||
),
|
||||
pytest.param(
|
||||
np.array(
|
||||
[
|
||||
[[0, 0], [10, 0], [10, 5], [0, 5]],
|
||||
[[5, 5], [15, 5], [15, 10], [5, 10]],
|
||||
],
|
||||
dtype=np.float32,
|
||||
),
|
||||
np.array([[0, 0, 10, 5], [5, 5, 15, 10]], dtype=np.float32),
|
||||
id="batch-axis-aligned",
|
||||
),
|
||||
pytest.param(
|
||||
np.array([[[5, 0], [10, 5], [5, 10], [0, 5]]], dtype=np.float32),
|
||||
np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
id="rotated-diamond",
|
||||
),
|
||||
pytest.param(
|
||||
np.empty((0, 4, 2), dtype=np.float32),
|
||||
np.empty((0, 4), dtype=np.float32),
|
||||
id="empty-input",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_xyxyxyxy_to_xyxy(corners: np.ndarray, expected: np.ndarray) -> None:
|
||||
"""Converts OBB corners to axis-aligned bounding boxes."""
|
||||
result = xyxyxyxy_to_xyxy(corners)
|
||||
assert np.allclose(result, expected, atol=1e-5)
|
||||
|
||||
@@ -15,6 +15,7 @@ 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,
|
||||
@@ -315,6 +316,86 @@ 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,6 +11,7 @@ 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,
|
||||
@@ -1162,34 +1163,30 @@ def test_box_iou_batch_symmetric_large(
|
||||
@pytest.mark.parametrize(
|
||||
"scale",
|
||||
[
|
||||
np.array([[10, 1]], dtype=np.float32), # x-dominant (wide box)
|
||||
np.array([[1, 10]], dtype=np.float32), # y-dominant (tall box)
|
||||
pytest.param(np.array([[10, 1]], dtype=np.float32), id="x-dominant"),
|
||||
pytest.param(np.array([[1, 10]], dtype=np.float32), id="y-dominant"),
|
||||
],
|
||||
)
|
||||
def test_oriented_box_iou_batch_is_invariant_to_non_square_scaling(
|
||||
scale: np.ndarray,
|
||||
) -> None:
|
||||
"""IoU is stable when boxes are scaled uniformly along one axis.
|
||||
"""IoU is invariant under per-axis (anisotropic) scaling.
|
||||
|
||||
Regression guard for the canvas x/y swap bug: before the fix, a 10x
|
||||
x-scale produced an undersized height dimension, truncating the polygon
|
||||
and yielding a different IoU. Tolerance reflects rasterization discretization.
|
||||
An affine map multiplies every area — intersection and union alike — by the
|
||||
same determinant, so exact polygon IoU is unchanged. Equality is exact
|
||||
because the computation is rasterization-free; the analytic IoU of these two
|
||||
rectangles is 0.25.
|
||||
"""
|
||||
boxes_true = np.array([[[1, 0], [0, 1], [3, 4], [4, 3]]], dtype=np.float32)
|
||||
boxes_detection = np.array([[[1, 1], [2, 0], [4, 2], [3, 3]]], dtype=np.float32)
|
||||
|
||||
baseline_iou = oriented_box_iou_batch(boxes_true, boxes_detection)
|
||||
scaled_iou = oriented_box_iou_batch(
|
||||
boxes_true * scale,
|
||||
boxes_detection * scale,
|
||||
)
|
||||
scaled_iou = oriented_box_iou_batch(boxes_true * scale, boxes_detection * scale)
|
||||
|
||||
assert baseline_iou.shape == (1, 1)
|
||||
assert scaled_iou.shape == (1, 1)
|
||||
assert baseline_iou[0, 0] > 0.35
|
||||
# rtol=0.03, atol=0.02: rasterization discretization introduces small
|
||||
# coordinate-dependent error; exact equality is not achievable via pixel IoU.
|
||||
assert np.allclose(scaled_iou, baseline_iou, rtol=0.03, atol=0.02)
|
||||
assert baseline_iou[0, 0] == pytest.approx(0.25)
|
||||
assert np.allclose(scaled_iou, baseline_iou, rtol=1e-5, atol=1e-7)
|
||||
|
||||
|
||||
class TestOrientedBoxIouBatch:
|
||||
@@ -1198,20 +1195,19 @@ class TestOrientedBoxIouBatch:
|
||||
@pytest.mark.parametrize(
|
||||
("scale", "offset"),
|
||||
[
|
||||
(80.0, 0.0), # HD/4K-scale coords — exercises canvas cap
|
||||
(1.0, 3000.0), # far-corner coords — exercises canvas anchoring
|
||||
(80.0, 3000.0), # both — large coordinates far from origin
|
||||
pytest.param(80.0, 0.0, id="large-scale"),
|
||||
pytest.param(1.0, 3000.0, id="far-from-origin"),
|
||||
pytest.param(80.0, 3000.0, id="large-and-far"),
|
||||
],
|
||||
)
|
||||
def test_is_invariant_to_canvas_transforms(
|
||||
self, scale: float, offset: float
|
||||
) -> None:
|
||||
"""IoU matches the small-coordinate baseline regardless of where in the
|
||||
frame the boxes sit or how large their coordinates are.
|
||||
"""IoU is invariant under uniform scaling and translation.
|
||||
|
||||
The function must internally translate-and-scale boxes onto a bounded
|
||||
rasterization canvas (IoU is invariant under both), so memory stays
|
||||
roughly constant across input resolutions and box positions."""
|
||||
Both are affine maps, so exact polygon IoU matches the small-coordinate
|
||||
baseline exactly — free of the pixel-quantization error that the prior
|
||||
rasterization-based implementation exhibited."""
|
||||
boxes_true = _rotated_rect(50, 50, 40, 20, 30)[None]
|
||||
boxes_detection = _rotated_rect(52, 48, 40, 20, 35)[None]
|
||||
baseline = oriented_box_iou_batch(boxes_true, boxes_detection)
|
||||
@@ -1224,20 +1220,159 @@ class TestOrientedBoxIouBatch:
|
||||
assert baseline.shape == (1, 1)
|
||||
assert transformed.shape == (1, 1)
|
||||
assert baseline[0, 0] > 0.4
|
||||
assert np.allclose(transformed, baseline, rtol=0.03, atol=0.02)
|
||||
assert np.allclose(transformed, baseline, rtol=1e-5, atol=1e-7)
|
||||
|
||||
def test_supports_overlap_metric(self) -> None:
|
||||
"""`overlap_metric=IOS` divides by the smaller area, so a small box fully
|
||||
contained in a larger one scores 1.0, while IoU is smaller."""
|
||||
contained in a larger one scores exactly 1.0, while IoU is the area ratio."""
|
||||
small = _rotated_rect(50, 50, 20, 20, 0)[None]
|
||||
large = _rotated_rect(50, 50, 60, 60, 0)[None]
|
||||
|
||||
iou = oriented_box_iou_batch(small, large, OverlapMetric.IOU)[0, 0]
|
||||
ios = oriented_box_iou_batch(small, large, OverlapMetric.IOS)[0, 0]
|
||||
|
||||
# Small (~20x20) inside large (~60x60): IoU ≈ 400/3600 ≈ 0.11, IoS = 1.0
|
||||
assert iou < 0.2
|
||||
assert ios > 0.98
|
||||
# 20x20 inside 60x60: IoU = 400 / 3600 = 1/9, IoS = 400 / 400 = 1.0.
|
||||
assert iou == pytest.approx(1 / 9, rel=1e-3)
|
||||
assert ios == pytest.approx(1.0)
|
||||
|
||||
def test_half_overlap_matches_analytic_value(self) -> None:
|
||||
"""Two unit-height 2x2 squares offset by 1 share half their area: IoU=1/3."""
|
||||
boxes_true = np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32)
|
||||
boxes_detection = np.array([[[1, 0], [3, 0], [3, 2], [1, 2]]], dtype=np.float32)
|
||||
|
||||
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
|
||||
|
||||
# intersection = 2, union = 4 + 4 - 2 = 6, IoU = 2/6 = 1/3.
|
||||
assert iou[0, 0] == pytest.approx(1 / 3)
|
||||
|
||||
def test_disjoint_boxes_score_zero(self) -> None:
|
||||
"""Boxes whose envelopes do not overlap are pruned by the gate to 0.0."""
|
||||
boxes_true = _rotated_rect(10, 10, 8, 4, 20)[None]
|
||||
boxes_detection = _rotated_rect(500, 500, 8, 4, 70)[None]
|
||||
|
||||
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
|
||||
|
||||
assert iou[0, 0] == 0.0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"boxes",
|
||||
[
|
||||
pytest.param(
|
||||
np.stack([_rotated_rect(10, 10, 8, 4, 20)]),
|
||||
id="n=1",
|
||||
),
|
||||
pytest.param(
|
||||
np.stack(
|
||||
[_rotated_rect(10, 10, 8, 4, 20), _rotated_rect(40, 40, 8, 4, 70)]
|
||||
),
|
||||
id="n=2",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_self_comparison_is_symmetric_with_unit_diagonal(
|
||||
self, boxes: np.ndarray
|
||||
) -> None:
|
||||
"""Comparing a set with itself yields a symmetric matrix and a 1.0 diagonal."""
|
||||
iou = oriented_box_iou_batch(boxes, boxes)
|
||||
|
||||
assert np.allclose(iou, iou.T)
|
||||
for i in range(len(boxes)):
|
||||
assert iou[i, i] == pytest.approx(1.0)
|
||||
|
||||
def test_envelope_overlap_without_polygon_overlap_scores_zero(self) -> None:
|
||||
"""Parallel rotated bars share an envelope but not a body, so they score 0.
|
||||
|
||||
Exercises the path where a pair passes the axis-aligned gate yet has no
|
||||
exact polygon intersection.
|
||||
"""
|
||||
boxes_true = _rotated_rect(50, 50, 100, 4, 45)[None]
|
||||
boxes_detection = _rotated_rect(72, 28, 100, 4, 45)[None]
|
||||
|
||||
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
|
||||
|
||||
assert iou[0, 0] == 0.0
|
||||
|
||||
def test_rejects_unsupported_overlap_metric(self) -> None:
|
||||
"""An overlap metric other than IOU or IOS raises ValueError."""
|
||||
boxes = _rotated_rect(50, 50, 20, 20, 0)[None]
|
||||
|
||||
with pytest.raises(ValueError, match="is not supported"):
|
||||
oriented_box_iou_batch(boxes, boxes, "invalid") # type: ignore[arg-type]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("boxes_a", "boxes_b", "expected"),
|
||||
[
|
||||
pytest.param(
|
||||
np.array([[[5, 5], [5, 5], [5, 5], [5, 5]]], dtype=np.float32),
|
||||
np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32),
|
||||
0.0,
|
||||
id="collapsed-box-vs-normal",
|
||||
),
|
||||
pytest.param(
|
||||
np.array([[[0, 0], [1, 0], [2, 0], [3, 0]]], dtype=np.float32),
|
||||
np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32),
|
||||
0.0,
|
||||
id="collinear-box-vs-normal",
|
||||
),
|
||||
pytest.param(
|
||||
np.array([[[5, 5], [5, 5], [5, 5], [5, 5]]], dtype=np.float32),
|
||||
np.array([[[5, 5], [5, 5], [5, 5], [5, 5]]], dtype=np.float32),
|
||||
0.0,
|
||||
id="self-comparison-zero-area",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_degenerate_boxes_score_zero(
|
||||
self,
|
||||
boxes_a: np.ndarray,
|
||||
boxes_b: np.ndarray,
|
||||
expected: float,
|
||||
) -> None:
|
||||
"""Degenerate boxes (zero-area collapsed or collinear) always score 0.
|
||||
|
||||
Note: self-comparison of a zero-area box returns 0.0, not 1.0 — this
|
||||
diverges from ``box_iou_batch`` which returns 1.0 for degenerate AABB
|
||||
self-comparison.
|
||||
"""
|
||||
iou = oriented_box_iou_batch(boxes_a, boxes_b)
|
||||
assert iou[0, 0] == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("n", "m"),
|
||||
[
|
||||
pytest.param(0, 3, id="zero-true"),
|
||||
pytest.param(3, 0, id="zero-detection"),
|
||||
pytest.param(0, 0, id="both-zero"),
|
||||
],
|
||||
)
|
||||
def test_empty_input_returns_correct_shape(self, n: int, m: int) -> None:
|
||||
"""Empty inputs trigger the early-return path; result is zero-filled."""
|
||||
boxes_true = np.empty((n, 4, 2), dtype=np.float32)
|
||||
boxes_detection = np.empty((m, 4, 2), dtype=np.float32)
|
||||
|
||||
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
|
||||
|
||||
assert iou.shape == (n, m)
|
||||
assert iou.dtype == np.float64
|
||||
assert np.all(iou == 0.0)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("bad_shape", "match"),
|
||||
[
|
||||
pytest.param((2, 3, 2), r"expected \(N, 4, 2\)", id="3d-wrong-inner-dims"),
|
||||
pytest.param((2, 5), r"expected \(N, 8\)", id="2d-wrong-columns"),
|
||||
pytest.param((8,), r"must be 2-D", id="1d-input"),
|
||||
],
|
||||
)
|
||||
def test_invalid_shape_raises_value_error(
|
||||
self, bad_shape: tuple, match: str
|
||||
) -> None:
|
||||
"""Inputs with wrong shape raise ValueError before any computation."""
|
||||
boxes_bad = np.zeros(bad_shape, dtype=np.float32)
|
||||
boxes_ok = np.zeros((2, 4, 2), dtype=np.float32)
|
||||
|
||||
with pytest.raises(ValueError, match=match):
|
||||
oriented_box_iou_batch(boxes_bad, boxes_ok)
|
||||
|
||||
|
||||
class TestOrientedBoxNonMaxSuppression:
|
||||
@@ -1446,3 +1581,161 @@ 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,7 +5,10 @@ from contextlib import ExitStack as DoesNotRaise
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from supervision.detection.utils.polygons import filter_polygons_by_area
|
||||
from supervision.detection.utils.polygons import (
|
||||
approximate_polygon,
|
||||
filter_polygons_by_area,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -100,3 +103,69 @@ 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,6 +706,75 @@ 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(
|
||||
@@ -925,3 +994,447 @@ def test_from_mediapipe_input(mediapipe_results, resolution_wh, expected_key_poi
|
||||
mediapipe_results, resolution_wh=resolution_wh
|
||||
)
|
||||
assert key_points == expected_key_points
|
||||
|
||||
|
||||
class TestDeprecatedConfidenceConstructor:
|
||||
"""Tests for backward-compatible `confidence=` kwarg in KeyPoints()."""
|
||||
|
||||
def test_constructor_accepts_and_warns_on_deprecated_confidence_kwarg(self):
|
||||
"""Deprecated confidence= warns and maps value to keypoint_confidence."""
|
||||
from supervision.utils.internal import SupervisionWarnings
|
||||
|
||||
xy = np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=np.float32)
|
||||
confidence = np.array([[0.9, 0.8]], dtype=np.float32)
|
||||
|
||||
with pytest.warns(SupervisionWarnings, match="deprecated since"):
|
||||
key_points = KeyPoints(xy=xy, confidence=confidence)
|
||||
|
||||
np.testing.assert_array_equal(key_points.keypoint_confidence, confidence)
|
||||
assert key_points.xy is xy
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
pytest.param(
|
||||
{"confidence": None, "keypoint_confidence": None},
|
||||
id="confidence-first",
|
||||
),
|
||||
pytest.param(
|
||||
{"keypoint_confidence": None, "confidence": None},
|
||||
id="keypoint-confidence-first",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_constructor_rejects_both_confidence_and_keypoint_confidence(
|
||||
self, kwargs: dict
|
||||
):
|
||||
"""ValueError raised regardless of kwarg order when both are passed."""
|
||||
xy = np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=np.float32)
|
||||
confidence_arr = np.array([[0.9, 0.8]], dtype=np.float32)
|
||||
actual_kwargs = {k: confidence_arr for k in kwargs}
|
||||
|
||||
with pytest.raises(ValueError, match="Cannot pass both"):
|
||||
KeyPoints(xy=xy, **actual_kwargs)
|
||||
|
||||
def test_constructor_normal_keypoint_confidence_path(self):
|
||||
"""Normal keypoint_confidence= path works and emits no deprecation warning."""
|
||||
import warnings
|
||||
|
||||
xy = np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=np.float32)
|
||||
kp_conf = np.array([[0.9, 0.8]], dtype=np.float32)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
kp = KeyPoints(xy=xy, keypoint_confidence=kp_conf)
|
||||
|
||||
np.testing.assert_array_equal(kp.keypoint_confidence, kp_conf)
|
||||
assert kp.data == {}
|
||||
|
||||
def test_constructor_confidence_none_does_not_warn(self):
|
||||
"""Explicit confidence=None is silently ignored — no warning emitted."""
|
||||
import warnings
|
||||
|
||||
xy = np.array([[[1.0, 2.0], [3.0, 4.0]]], dtype=np.float32)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
kp = KeyPoints(xy=xy, confidence=None)
|
||||
|
||||
assert kp.keypoint_confidence is None
|
||||
|
||||
def test_constructor_data_none_defaults_to_empty_dict(self):
|
||||
"""Explicit data=None normalizes to empty dict, not None."""
|
||||
xy = np.array([[[1.0, 2.0]]], dtype=np.float32)
|
||||
|
||||
assert KeyPoints(xy=xy).data == {}
|
||||
assert KeyPoints(xy=xy, data=None).data == {}
|
||||
|
||||
def test_keypoints_init_covers_all_dataclass_fields(self):
|
||||
"""Custom __init__ must assign every dataclass field — guards against drift."""
|
||||
import dataclasses
|
||||
import inspect
|
||||
|
||||
field_names = {f.name for f in dataclasses.fields(KeyPoints)}
|
||||
init_params = set(inspect.signature(KeyPoints.__init__).parameters) - {
|
||||
"self",
|
||||
"confidence",
|
||||
}
|
||||
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,6 +124,86 @@ 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,6 +115,86 @@ 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()
|
||||
|
||||
@@ -12,8 +12,10 @@ resolution-markers = [
|
||||
"python_full_version == '3.10.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version == '3.10.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.10.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -280,8 +282,10 @@ resolution-markers = [
|
||||
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version == '3.10.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version == '3.10.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.10.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "(python_full_version < '3.10' and implementation_name != 'PyPy' and platform_machine != 'arm64') or (implementation_name != 'PyPy' and sys_platform != 'darwin')" },
|
||||
@@ -379,8 +383,10 @@ version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114, upload-time = "2023-08-12T20:38:17.776Z" }
|
||||
wheels = [
|
||||
@@ -496,8 +502,10 @@ version = "8.1.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
|
||||
@@ -557,8 +565,10 @@ version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
@@ -716,8 +726,10 @@ version = "7.10.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
|
||||
wheels = [
|
||||
@@ -948,51 +960,45 @@ toml = [
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.7"
|
||||
version = "48.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.10' and platform_machine != 'arm64' and platform_python_implementation != 'PyPy') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin')" },
|
||||
{ name = "typing-extensions", marker = "(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform != 'darwin')" },
|
||||
{ name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and platform_python_implementation != 'PyPy') or (python_full_version > '3.9' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin')" },
|
||||
{ name = "typing-extensions", marker = "(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.11' and sys_platform != 'darwin')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1118,8 +1124,10 @@ version = "3.18.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" }
|
||||
wheels = [
|
||||
@@ -1323,8 +1331,10 @@ version = "2.6.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
|
||||
wheels = [
|
||||
@@ -1425,8 +1435,10 @@ version = "8.18.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
|
||||
@@ -1550,7 +1562,7 @@ name = "jaraco-classes"
|
||||
version = "3.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "more-itertools" },
|
||||
{ name = "more-itertools", marker = "(python_full_version > '3.9' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
|
||||
wheels = [
|
||||
@@ -1562,7 +1574,7 @@ name = "jaraco-context"
|
||||
version = "6.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backports-tarfile", marker = "python_full_version < '3.12'" },
|
||||
{ name = "backports-tarfile", marker = "(python_full_version > '3.9' and python_full_version < '3.12' and platform_machine != 'arm64') or (python_full_version > '3.9' and python_full_version < '3.12' and sys_platform != 'darwin') or (python_full_version < '3.12' and platform_machine == 'arm64' and sys_platform == 'darwin')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" }
|
||||
wheels = [
|
||||
@@ -1574,7 +1586,7 @@ name = "jaraco-functools"
|
||||
version = "4.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "more-itertools" },
|
||||
{ name = "more-itertools", marker = "(python_full_version > '3.9' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/1c/831faaaa0f090b711c355c6d8b2abf277c72133aab472b6932b03322294c/jaraco_functools-4.2.1.tar.gz", hash = "sha256:be634abfccabce56fa3053f8c7ebe37b682683a4ee7793670ced17bab0087353", size = 19661, upload-time = "2025-06-21T19:22:03.201Z" }
|
||||
wheels = [
|
||||
@@ -1779,7 +1791,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "jupyterlab"
|
||||
version = "4.5.7"
|
||||
version = "4.5.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "async-lru" },
|
||||
@@ -1798,9 +1810,9 @@ dependencies = [
|
||||
{ name = "tornado" },
|
||||
{ name = "traitlets" },
|
||||
]
|
||||
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" }
|
||||
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" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ 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" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1857,18 +1869,49 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/f1/82ea8e783433707cafd9790099a2d19f113c22f32a31c8bb5abdc7a61dbb/jupytext-1.17.2-py3-none-any.whl", hash = "sha256:4f85dc43bb6a24b75491c5c434001ad5ef563932f68f15dd3e1c8ce12a4a426b", size = 164401, upload-time = "2025-06-01T21:31:46.319Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "21.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "pywin32-ctypes", marker = "python_full_version <= '3.9' and sys_platform == 'win32'" },
|
||||
{ name = "secretstorage", version = "2.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9' and sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/93/b2/66cab0195a41415567ccfbf0e02c3ecc7f423729ca0c385819afbcd8e256/keyring-21.0.0.tar.gz", hash = "sha256:5f5f92327b6c7432bebc18a1b60cb3797d99b08db1f5b919b8187c37a01f1ccc", size = 53474, upload-time = "2019-12-24T20:01:38.041Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/03/4f57f7b28bfd21a0dc05aec33e012fbaa06cff56d8af63bf2f1451a12ead/keyring-21.0.0-py2.py3-none-any.whl", hash = "sha256:ad84f7fe26ab51731f089eaf1c44ebf4c5fae323653c908888a3a6212ad0bbe7", size = 31095, upload-time = "2019-12-24T20:01:35.715Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyring"
|
||||
version = "25.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12' and sys_platform == 'darwin'",
|
||||
"python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version == '3.11.*' and sys_platform == 'darwin'",
|
||||
"python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version == '3.10.*' and sys_platform == 'darwin'",
|
||||
"python_full_version == '3.10.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version == '3.10.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.10.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
|
||||
{ name = "jaraco-classes" },
|
||||
{ name = "jaraco-context" },
|
||||
{ name = "jaraco-functools" },
|
||||
{ name = "jeepney", marker = "sys_platform == 'linux'" },
|
||||
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
|
||||
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
|
||||
{ name = "importlib-metadata", marker = "(python_full_version > '3.9' and python_full_version < '3.12' and platform_machine != 'arm64') or (python_full_version > '3.9' and python_full_version < '3.12' and sys_platform != 'darwin') or (python_full_version < '3.12' and platform_machine == 'arm64' and sys_platform == 'darwin')" },
|
||||
{ name = "jaraco-classes", marker = "(python_full_version > '3.9' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
|
||||
{ name = "jaraco-context", marker = "(python_full_version > '3.9' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
|
||||
{ name = "jaraco-functools", marker = "(python_full_version > '3.9' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
|
||||
{ name = "jeepney", marker = "python_full_version > '3.9' and sys_platform == 'linux'" },
|
||||
{ name = "pywin32-ctypes", marker = "python_full_version > '3.9' and sys_platform == 'win32'" },
|
||||
{ name = "secretstorage", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and sys_platform == 'linux'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" }
|
||||
wheels = [
|
||||
@@ -1881,8 +1924,10 @@ version = "1.4.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/85/4d/2255e1c76304cbd60b48cee302b66d1dde4468dc5b1160e4b7cb43778f2a/kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60", size = 97286, upload-time = "2024-09-04T09:39:44.302Z" }
|
||||
wheels = [
|
||||
@@ -2176,8 +2221,10 @@ version = "3.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "contourpy", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
@@ -2691,8 +2738,10 @@ version = "2.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" }
|
||||
wheels = [
|
||||
@@ -3154,8 +3203,10 @@ version = "4.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
@@ -3259,13 +3310,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycrypto"
|
||||
version = "2.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/60/db/645aa9af249f059cc3a368b118de33889219e0362141e75d4eaf6f80f163/pycrypto-2.6.1.tar.gz", hash = "sha256:f2ce1e989b272cfcb677616763e0a2e7ec659effa67a88aa92b3a65528f60a3c", size = 446240, upload-time = "2014-06-20T08:10:20.813Z" }
|
||||
|
||||
[[package]]
|
||||
name = "pydeprecate"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/1e/b069ea704e42fefeb523835ba3d712347ff672743e5fa9428f4201525328/pydeprecate-0.9.0.tar.gz", hash = "sha256:ed49a36506f63a8d4ae84ffedff1e2879733d536c7a6aaafdf8bfcd39520059b", size = 151667, upload-time = "2026-06-05T19:51:17.430153Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d4/1e/b069ea704e42fefeb523835ba3d712347ff672743e5fa9428f4201525328/pydeprecate-0.9.0.tar.gz", hash = "sha256:ed49a36506f63a8d4ae84ffedff1e2879733d536c7a6aaafdf8bfcd39520059b", size = 151667, upload-time = "2026-06-05T19:51:17.43Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/87/a155f16c1c17adccaa2ed2256484fe1c6ad37b925e8e39695ed8860bc0b5/pydeprecate-0.9.0-py3-none-any.whl", hash = "sha256:c9501dae1a2567a3c7f7b04221ad5a97d1ad972a02ddb729a04299b435fedd7c", size = 102725, upload-time = "2026-06-05T19:51:16.235752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/87/a155f16c1c17adccaa2ed2256484fe1c6ad37b925e8e39695ed8860bc0b5/pydeprecate-0.9.0-py3-none-any.whl", hash = "sha256:c9501dae1a2567a3c7f7b04221ad5a97d1ad972a02ddb729a04299b435fedd7c", size = 102725, upload-time = "2026-06-05T19:51:16.235Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3828,8 +3885,10 @@ version = "1.13.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
|
||||
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
@@ -3978,13 +4037,36 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c4/231cac7a8385394ebbbb4f1ca662203e9d8c332825ab4f36ffc3ead09a42/scipy-1.16.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f56296fefca67ba605fd74d12f7bd23636267731a72cb3947963e76b8c0a25db", size = 38515076, upload-time = "2025-06-22T16:21:45.694Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secretstorage"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "pycrypto", marker = "(python_full_version <= '3.9' and platform_machine != 'arm64') or (python_full_version <= '3.9' and sys_platform != 'darwin')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/af/50f2324784ee65e2b752624144249c7710e761be437dd82815ff86d3021f/SecretStorage-2.2.1.tar.gz", hash = "sha256:d771e99ada09e39aceb75c4bb2d460895627b2260ab24c9cb790fb8aad1ecab6", size = 15972, upload-time = "2016-06-27T19:37:45.887Z" }
|
||||
|
||||
[[package]]
|
||||
name = "secretstorage"
|
||||
version = "3.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version == '3.10.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version == '3.10.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.10.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
|
||||
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "cryptography", marker = "(python_full_version < '3.10' and platform_machine != 'arm64') or sys_platform != 'darwin'" },
|
||||
{ name = "jeepney", marker = "(python_full_version < '3.10' and platform_machine != 'arm64') or sys_platform != 'darwin'" },
|
||||
{ name = "cryptography", marker = "(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin')" },
|
||||
{ name = "jeepney", marker = "(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin')" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" }
|
||||
wheels = [
|
||||
@@ -4061,7 +4143,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "supervision"
|
||||
version = "0.29.0.dev0"
|
||||
version = "0.29.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "defusedxml" },
|
||||
@@ -4224,19 +4306,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tornado"
|
||||
version = "6.5.5"
|
||||
version = "6.5.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4290,7 +4372,8 @@ source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "id" },
|
||||
{ name = "importlib-metadata", marker = "python_full_version < '3.10'" },
|
||||
{ name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
|
||||
{ name = "keyring", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version <= '3.9' and platform_machine != 'arm64' and platform_machine != 'ppc64le' and platform_machine != 's390x') or (python_full_version <= '3.9' and platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform != 'darwin')" },
|
||||
{ name = "keyring", version = "25.6.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version > '3.9' and platform_machine != 'arm64' and platform_machine != 'ppc64le' and platform_machine != 's390x') or (python_full_version > '3.9' and platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
|
||||
{ name = "packaging" },
|
||||
{ name = "readme-renderer" },
|
||||
{ name = "requests" },
|
||||
|
||||
Reference in New Issue
Block a user