Compare commits

..
23 Commits
Author SHA1 Message Date
Jirka Borovec 49ecac0376 Releasing supervision 0.29.1 (#2350) 2026-06-23 21:53:06 +02:00
dependabot[bot] 9251893bd9 ⬆️ Bump jupyterlab from 4.5.7 to 4.5.9 in the uv group across 1 directory (#2348)
---
updated-dependencies:
- dependency-name: jupyterlab
  dependency-version: 4.5.9
  dependency-type: indirect
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:17:44 +02:00
dependabot[bot] ccd7098f2f ⬆️ Bump actions/checkout from 6 to 7 in the github-actions group (#2347)
Bumps the github-actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 08:55:18 +02:00
jirka 07d182be56 docs: refine Table of Contents formatting with collapsible details 2026-06-19 09:21:02 +02:00
Vikas sainiandpre-commit-ci[bot] cca3c14911 Docs: Refactor README formatting and fix pre-commit hook (#2316)
Updated README to improve formatting and structure.
Updated links, section headers, and example code paths in README.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-06-18 15:42:25 +02:00
44546a13f2 fix(vlm): handle malformed Gemini/Qwen model output without crashing (#2342)
Two ways the VLM parsers crashed on adversarial model output instead of
degrading gracefully (the contract they already honor for invalid JSON):

1. Gemini 2.5: a mask value that is not a 'data:image/png;base64,' string
   appended an empty mask and then 'continue'd, skipping the confidence
   handler at the bottom of the loop. The item's box was recorded but its
   confidence was not, so the confidence array ended up shorter than xyxy
   and Detections.from_vlm raised a shape ValueError. Replaced the
   'continue' with an if/else so the confidence handler always runs.

2. Gemini 2.0 / Gemini 2.5 / Qwen 2.5: valid JSON whose top level is not a
   list, or whose elements are not dicts (e.g. '[1, 2, 3]'), raised
   TypeError from the 'key not in item' membership test. Added a top-level
   list guard (Gemini 2.0/2.5; Qwen already had one) and a per-element
   dict guard so wrong-shaped JSON degrades to empty Detections.

Add regression tests for the mask/confidence alignment and for graceful
degradation across all three parsers.

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: OpenAI Codex <codex@openai.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 15:31:31 +02:00
4b60bbc9cc fix(dataset): stop Pascal VOC export from mutating source detections (#2341)
object_to_pascal_voc applied the 1-index offset in place (xyxy += 1).
Because Detections.__iter__ yields each row of xyxy as a view sharing
memory with detections.xyxy, detections_to_pascal_voc wrote the +1 shift
straight back into the caller's array. A single export shifted every box
by +1px; a second export compounded it, producing wrong XML. A single
export-then-reload happened to round-trip because from_pascal_voc
subtracts 1, which is why no test caught it.

Rebind to a new array (xyxy = xyxy + 1) instead of mutating in place.
On-disk output is unchanged; the source detections are left intact.

Add regression tests asserting object_to_pascal_voc does not mutate its
inputs and that two consecutive exports are identical and leave xyxy
unchanged.

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 15:29:37 +02:00
6918d44190 docs(cookbooks): add Oriented Bounding Boxes cookbook (#2314)
- Added an end-to-end cookbook focused on oriented bounding boxes (OBB), demonstrating how OBB detections differ from axis-aligned boxes, why oriented overlap and NMS matter, how to use footprint-based filtering with `Detections.area`, and how to export annotations in YOLO OBB format.
- Added visual examples that clearly compare axis-aligned and oriented boxes, including a close-up showing how axis-aligned envelopes can significantly overestimate object footprints for angled objects.
- Improved the cookbook narrative to center on the practical consequences of using oriented versus axis-aligned boxes, including tighter localization and more appropriate NMS behavior for densely packed, rotated objects.
- Updated cookbook references, naming, dependency versions, image attribution, and changelog links to align with the released 0.29.0 documentation.
- Fixed the 0.29.0 changelog by removing a duplicate `Detections.area` entry and keeping the more accurate correctness-fix classification.

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 13:33:30 +02:00
a48532f6f5 perf(annotators): share mask painting and give HaloAnnotator the CompactMask path (#2339)
MaskAnnotator paints CompactMask detections into their bounding-box crop, but
HaloAnnotator never got that path: it materialized every mask full-frame, painted
via full-frame boolean indexing, and built its foreground mask as a 2M-element
Python list per frame. Extract the shared compact/dense painting into a single
_paint_masks_by_area helper used by both annotators. On a 1080p frame with 30
masks, HaloAnnotator on CompactMask runs about 4x faster; output is unchanged.

- Replace in-place `union` param with `collect_union: bool` return value;
  HaloAnnotator now captures the returned union array
- Add Google-style Args/Returns to `_paint_masks_by_area` docstring
- Fix HaloAnnotator.annotate() example (was maskless → no-op) and scene arg wording
- Add section comment above shared helper for discoverability
- Add union accumulation tests (dense + CompactMask paths via collect_union=True)
- Add test documenting out-of-bbox True-pixel divergence between compact and dense
- Add image-edge bbox test for CompactMask annotators
- Move helper tests into TestPaintMasksByArea and TestCompactMaskParity classes
- Rename test_annotate_with_empty_masks → test_annotate_with_all_false_mask

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 12:37:11 +02:00
11a586c133 perf(detection): compute mask IoU via matmul instead of an (N, M, H, W) intermediate (#2323)
- Reimplemented dense mask IoU/IoS computation using matrix multiplication on flattened masks instead of constructing a full `(N, M, H, W)` overlap tensor
- Significantly reduced memory usage and improved performance for large mask sets while preserving identical IoU/IoS results
- Added automatic precision handling for large masks to keep intersection and area counts numerically accurate
- Improved memory-limit handling and chunking logic to reflect actual matmul memory usage
- Added validation that compared mask sets share the same spatial dimensions
- Added validation for invalid mask tensor ranks and input shapes
- Added safe handling for empty-mask inputs
- Added warnings when inputs exceed the minimum memory footprint that chunking cannot reduce
- Fixed large-mask area calculations that could produce incorrect IoU values
- Suppressed spurious runtime warnings during valid matrix-multiplication computations
- Added regression coverage for correctness, chunking, rectangular matrices, empty inputs, shape mismatches, large-mask precision, and memory-limit edge cases

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 10:45:56 +02:00
31e84f7909 feat: add KeyPoints.with_nms() method (#2338)
* feat: add with_nms() method to KeyPoints class Derive axis-aligned bounding boxes from valid keypoints and delegate to box_non_max_suppression for filtering. Requires detection_confidence; supports class-aware and class-agnostic modes.

- Add overlap_metric: OverlapMetric = OverlapMetric.IOU param to KeyPoints.with_nms() for API parity with Detections.with_nms()
- Integrate self.visible into keypoint validity: valid = valid & self.visible when visible is not None
- Pass overlap_metric through to box_non_max_suppression
- Fix docstring: add Defaults to for threshold/class_agnostic, threshold range constraint, overlap_metric arg
- Add UnReleased changelog entry
- Add 5 new test cases: all-zero-skeleton-passes-through, visible-mask-excludes-keypoints-from-bbox, single-valid-keypoint-zero-area-bbox, threshold boundary 0.0/1.0
- Add missing raises test: no-detection-confidence-class-agnostic
- Update `with_nms` method to raise `ValueError` instead of `AssertionError` for missing required fields (`detection_confidence`, `class_id` when `class_agnostic=False`).
- Adjust corresponding test to check for `ValueError` with match argument.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-18 10:42:43 +02:00
jirka 7e765eb054 docs: reformat entries in changelog for improved readability 2026-06-17 22:41:17 +02:00
393ff52954 fix(json_sink): serialize NumPy scalars in custom_data (#2334)
- Fixed `JSONSink` to correctly serialize NumPy scalar values stored in `custom_data`
- Added JSON serialization support for NumPy arrays by converting them to standard JSON-compatible lists
- Prevented buffered export failures caused by non-serializable NumPy values during `json.dump`
- Added regression coverage for multiple NumPy scalar types, NumPy arrays, and unsupported-object error handling
- Updated documentation and changelog to describe NumPy serialization behavior in `JSONSink`

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 22:27:03 +02:00
c9962c9262 fix(smoother): handle detections without confidence (#2333)
- Fixed `DetectionsSmoother` to work with detections that have no confidence scores
- Changed confidence aggregation to average only the confidence values that are present, leaving confidence as `None` when no values exist
- Fixed smoothing of mixed-confidence tracks (some frames with confidence, some without) while preserving available confidence information
- Fixed crashes when merging smoothed tracks that disagree on confidence availability by normalizing confidence fields before merge
- Added regression coverage for no-confidence, mixed-confidence, multi-track, full-window, and tracker-id-missing scenarios
- Updated documentation and changelog to reflect the new confidence-handling behavior

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 21:16:43 +02:00
04b6d45ea2 fix(polygons): honor target point count in approximate_polygon (#2332)
- Fixed `approximate_polygon` so the returned polygon respects the requested point-count reduction target instead of returning an over-budget approximation
- Preserved the minimum valid polygon size (3 points) while simplifying polygons
- Added validation requiring `epsilon_step > 0` to prevent invalid and non-terminating configurations
- Added regression tests covering target-point budgeting, polygon validity, invalid percentage values, and invalid `epsilon_step` values
- Improved documentation with clearer behavior guarantees, validation rules, examples, and edge-case explanations

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 21:11:26 +02:00
2854932965 fix(metrics): count false positives on background images and absent classes (#2331)
- Fixed Precision and F1Score metrics to correctly count false positives on images with no ground-truth objects
- Fixed Precision and F1Score metrics to include prediction-only classes when computing class statistics and confusion-matrix aggregates
- Corrected MICRO and MACRO averaging so false positives from absent classes affect the score as expected
- Preserved WEIGHTED averaging behavior by weighting only classes with ground-truth support
- Added safeguards for all-background evaluation batches, returning stable zero-valued weighted scores when no support exists
- Fixed handling of predictions with `class_id=None` in background-only evaluation paths
- Added regression coverage for background-image false positives, absent-class predictions, averaging modes, and zero-support edge cases
- Updated metric documentation and result metadata to reflect that tracked classes now include prediction-only classes

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 19:46:10 +02:00
bcc785fbb5 fix(ci): clear latest alias before deploying docs to release/latest (#2340)
mike deploy fails when target name exists as an alias; delete it first.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-17 19:36:25 +02:00
c0df72b84a perf: vectorize mask_to_xyxy and KeyPoints.as_detections (bit-identical) (#2330)
- Vectorized `mask_to_xyxy` by replacing per-mask pixel scans with batched occupancy-profile reductions, yielding large speedups while preserving identical outputs
- Added direct test coverage for `mask_to_xyxy`, including edge and corner-pixel mask cases
- Vectorized `KeyPoints.as_detections` by computing all bounding boxes in a single batch operation instead of constructing and merging per-skeleton `Detections`
- Vectorized keypoint-confidence aggregation in `KeyPoints.as_detections` using NumPy reductions
- Preserved exact output behavior for bounding boxes, confidence values, class IDs, metadata, selected-keypoint subsets, and missing-keypoint handling
- Added regression coverage for selected-keypoint indexing, mixed valid/invalid skeleton batches, detection-confidence paths, and confidence aggregation behavior
- Fixed strict mypy typing issues introduced by the vectorized implementations
- Improved documentation for `mask_to_xyxy` and `selected_keypoint_indices` behavior

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 18:58:47 +02:00
15cf80abac fix: accept deprecated confidence kwarg in KeyPoints() constructor (#2335)
- Move conflict ValueError before warn_deprecated so stray warning not
  emitted on invalid calls (both kwargs passed)
- Add backtick-quoted version numbers to deprecation message for style
  consistency with other warn_deprecated calls in the same class
- Make ValueError message actionable: name deprecated param and remedy
- Add Google-style docstring to __init__: Args + Raises sections
- Fix pre-existing mypy error in detection/core.py (_merge_obb_corners
  had bare np.ndarray without type args)
- Add docstrings to all test methods in TestDeprecatedConfidenceConstructor
- Parametrize ValueError test with both kwarg orderings (confidence-first
  and keypoint-confidence-first) to document order-independence
- Add test_constructor_normal_keypoint_confidence_path: confirms custom
  __init__ initialises all fields and emits no warning on normal path
- Add test_constructor_confidence_none_does_not_warn: guards the
  `if confidence is not None` branch against guard-condition typos
- Add test_constructor_data_none_defaults_to_empty_dict: tests the
  None->{} normalisation path introduced by the custom __init__
- Add test_keypoints_init_covers_all_dataclass_fields: drift guard that
  asserts dataclasses.fields(KeyPoints) == __init__ params, catching
  future field additions that forget to update __init__

---------

Co-authored-by: Jirka Borovec <6035284+Borda@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-17 16:15:34 +02:00
Jirka Borovec b9c24ddafd docs(changelog): backfill missing 0.29.0 entries and fix duplicate (#2329) 2026-06-16 12:16:38 +02:00
c2490e6015 chore: preserve all polygons when exporting multi-part masks to COCO (#2322)
* test(coco): strengthen multi-polygon export test coverage
* docs(coco): document segmentation shape and iscrowd routing in docstring
* test(coco): parametrize multipart polygon tests

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-16 00:43:19 +02:00
Jirka Borovec a914a00ab8 docs: fix broken links in changelog file (#2328) 2026-06-16 00:16:00 +02:00
dependabot[bot] d3a0a84dd5 ⬆️ Bump the uv group across 1 directory with 2 updates (#2326)
Bumps the uv group with 2 updates in the / directory: [cryptography](https://github.com/pyca/cryptography) and [tornado](https://github.com/tornadoweb/tornado).


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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 23:45:40 +02:00
38 changed files with 2740 additions and 320 deletions
+1
View File
@@ -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,
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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
+33 -19
View File
@@ -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>
+45 -11
View File
@@ -1,23 +1,55 @@
---
description: "Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version."
date_modified: 2026-06-15
date_modified: 2026-06-16
---
# Changelog
### UnReleased
### 0.29.1 <small>Jun 23, 2026</small>
- Added [#2338](https://github.com/roboflow/supervision/pull/2338): [`sv.KeyPoints.with_nms`](https://supervision.roboflow.com/latest/keypoint/core/#supervision.key_points.core.KeyPoints.with_nms) — non-maximum suppression for keypoint detections. Derives axis-aligned bounding boxes from valid (non-zero and visible) keypoints and applies `box_non_max_suppression`. Requires `detection_confidence`; supports class-aware and class-agnostic modes via `threshold`, `class_agnostic`, and `overlap_metric`.
- Fixed [#2342](https://github.com/roboflow/supervision/pull/2342): `sv.Detections.from_vlm` with `sv.VLM.GOOGLE_GEMINI_2_0`, `sv.VLM.GOOGLE_GEMINI_2_5`, and `sv.VLM.QWEN_2_5_VL` no longer raises when the model returns valid JSON of the wrong shape (non-list top-level or non-dict elements). A non-string or malformed `"mask"` value in Gemini 2.5 output no longer triggers `AttributeError`; invalid base64 or non-PNG mask data falls back to an empty mask, keeping `xyxy`, `confidence`, and `masks` arrays aligned.
- Fixed [#2341](https://github.com/roboflow/supervision/pull/2341): `sv.DetectionDataset.as_pascal_voc` no longer mutates the source `Detections.xyxy` by the 1-index offset on every call. Previously, repeated exports accumulated a `+1` shift in the caller's bounding boxes.
- Fixed [#2334](https://github.com/roboflow/supervision/pull/2334): `sv.JSONSink` now serializes NumPy scalars (e.g. `np.int64` frame indices) in `custom_data` as JSON numbers instead of raising `TypeError` at close time. File handle is now guaranteed to close even when serialization fails.
- Fixed [#2333](https://github.com/roboflow/supervision/pull/2333): [`sv.DetectionsSmoother`](https://supervision.roboflow.com/latest/detection/tools/smoother/#supervision.detection.tools.smoother.DetectionsSmoother) no longer raises when smoothing detections without `confidence`. Confidence is now averaged over the frames that carry it; when tracks in the same frame disagree on confidence presence, `confidence` is set to `None` for all smoothed detections.
- Fixed [#2332](https://github.com/roboflow/supervision/pull/2332): `sv.approximate_polygon` now returns a polygon within the requested point-count budget (at most `floor(N * (1 - percentage))` points, minimum 3). The function now also validates that `epsilon_step > 0`.
- Fixed [#2331](https://github.com/roboflow/supervision/pull/2331): `sv.Precision` and `sv.F1Score` now count predictions on background images (empty target set) as false positives, and count predictions of classes absent from ground truth as false positives under `MICRO` and `MACRO` averaging. Previously both edge cases were silently ignored, inflating scores. `WEIGHTED` averaging is unchanged — absent classes retain weight 0, consistent with scikit-learn. Users relying on previous scores should re-evaluate after upgrading; no API change is required.
- Fixed [#2322](https://github.com/roboflow/supervision/pull/2322): COCO export now preserves all polygon parts for multi-component masks. Previously, only the first polygon was written when a non-crowd mask had disjoint segments; all parts are now included.
- Performance [#2339](https://github.com/roboflow/supervision/pull/2339): `sv.HaloAnnotator` now uses the same CompactMask painting path as `sv.MaskAnnotator` via a shared `_paint_masks_by_area` helper. On a 1080p frame with 30 CompactMask detections, `HaloAnnotator` runs approximately 4× faster; annotated output is unchanged.
- Performance [#2330](https://github.com/roboflow/supervision/pull/2330): `sv.mask_to_xyxy` and `sv.KeyPoints.as_detections` are now vectorized. `mask_to_xyxy` uses batched occupancy-profile reductions instead of per-mask pixel scans; `KeyPoints.as_detections` computes all bounding boxes in a single batch operation. Both produce bit-identical results.
- Performance [#2323](https://github.com/roboflow/supervision/pull/2323): Mask IoU computation now uses matrix multiplication on flattened masks instead of an explicit `(N, M, H, W)` intersection tensor, reducing peak memory for large mask sets. For masks larger than 4096×4096 pixels, computation automatically promotes to float64 to preserve exact pixel counts. Results are numerically identical.
### 0.29.0.post0 <small>Jun 17, 2026</small>
- 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/metrics/detection/#supervision.metrics.detection.ConfusionMatrix) now supports `MetricTarget.ORIENTED_BOUNDING_BOXES`, computing IoU via `oriented_box_iou_batch` on `xyxyxyxy` corners.
- 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/#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 [#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.
@@ -25,29 +57,31 @@ date_modified: 2026-06-15
- 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.
- 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 [#2306](https://github.com/roboflow/supervision/pull/2306): [`sv.Detections.area`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.area) now returns the polygon area of the rotated bounding box (via the shoelace formula) when oriented box coordinates are present, instead of the axis-aligned box area.
- Changed [#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`.
- Fixed [#2282](https://github.com/roboflow/supervision/pull/2282): [`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 correctly handles non-square canvases. Previously, rasterization assumed square dimensions, leading to incorrect IoU values for tall or wide images.
- 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/annotators/#supervision.annotators.core.HeatMapAnnotator) no longer crashes with a divide-by-zero when called with empty detections.
- 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/#supervision.dataset.core.DetectionDataset.as_yolo) no longer loses OBB rotation when exporting oriented bounding boxes.
- 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.
@@ -55,6 +89,8 @@ date_modified: 2026-06-15
- 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>
@@ -77,8 +113,6 @@ date_modified: 2026-06-15
- 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.
File diff suppressed because one or more lines are too long
+4
View File
@@ -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
View File
@@ -4,7 +4,7 @@ requires = [ "setuptools>=61" ]
[project]
name = "supervision"
version = "0.29.0"
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 = [
+78 -46
View File
@@ -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)
+12
View File
@@ -247,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
+37 -4
View File
@@ -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
+3 -1
View File
@@ -2632,7 +2632,9 @@ class Detections:
return Detections.merge(result)
def _merge_obb_corners(corners_list: list[np.ndarray]) -> npt.NDArray[np.floating]:
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
+33 -2
View File
@@ -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 -1
View File
@@ -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)
+30 -9
View File
@@ -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
+61 -25
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import warnings
from collections.abc import Callable
from enum import Enum
from typing import Any, cast
@@ -687,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
@@ -743,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):
@@ -758,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(
+40 -13
View File
@@ -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
+63 -40
View File
@@ -214,7 +214,7 @@ def validate_vlm_parameters(vlm: VLM | str, result: Any, kwargs: dict[str, Any])
def from_paligemma(
result: str, resolution_wh: tuple[int, int], classes: list[str] | None = None
) -> tuple[npt.NDArray[Any], npt.NDArray[Any], 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
+178 -34
View File
@@ -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])
+50 -6
View File
@@ -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
+52 -10
View File
@@ -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
+3 -1
View File
@@ -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:
+169
View File
@@ -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"""
+75
View File
@@ -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,
@@ -995,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(
+42
View File
@@ -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"),
[
+47
View File
@@ -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)
+46
View 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)
+128
View File
@@ -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)
+81
View File
@@ -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"),
[
+159
View File
@@ -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,
@@ -1580,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)
+70 -1
View File
@@ -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)
+513
View File
@@ -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)
+80
View File
@@ -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
):
+80
View File
@@ -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()
Generated
+180 -91
View File
@@ -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,45 +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/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/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/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/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/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/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]]
@@ -1112,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 = [
@@ -1317,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 = [
@@ -1419,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'" },
@@ -1544,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 = [
@@ -1556,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 = [
@@ -1568,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 = [
@@ -1773,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" },
@@ -1792,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]]
@@ -1851,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 = [
@@ -1875,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 = [
@@ -2170,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'" },
@@ -2685,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 = [
@@ -3148,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'" },
@@ -3253,6 +3310,12 @@ 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"
@@ -3822,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'" },
@@ -3972,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 = [
@@ -4055,7 +4143,7 @@ wheels = [
[[package]]
name = "supervision"
version = "0.29.0rc0"
version = "0.29.1"
source = { editable = "." }
dependencies = [
{ name = "defusedxml" },
@@ -4218,19 +4306,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.6"
version = "6.5.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/50/57/6d7303a77ae439d9189108f76c0c4fd89ee5e2cc8387bffb55232565c4ed/tornado-6.5.6.tar.gz", hash = "sha256:9a365179fe8ff6b8766f602c0f67c185d778193e9bdd828b19f0b6ed7764177d", size = 518139, upload-time = "2026-05-27T15:35:54.646Z" }
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/1b/0d/b4f481e18c5a51864e6d12b9a05ecf72919696680b747c958c3fc1f4fbae/tornado-6.5.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:65fcfaafb079435c2c19dc9e07c0f1cf0fa9051759ed0a7d0a3ba7ea7f64919c", size = 447737, upload-time = "2026-05-27T15:35:38.122Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9c/5430c39fcab1144d35860f457b15e9c08b4bc7ac86764354204e983d6183/tornado-6.5.6-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:38bc01b4acacded2de63ae78023548e41ebe6fbed3ec05a796d7ae3ad893887e", size = 445899, upload-time = "2026-05-27T15:35:40.519Z" },
{ url = "https://files.pythonhosted.org/packages/8b/79/fa7e14a2f939c807a8d30619b4eb604eab219601b78792516ebe22d40cf9/tornado-6.5.6-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b942e6a137fda31ff54bf8e6e2c8d1c37f1f50583f3ed53fb840b53b9601d104", size = 448964, upload-time = "2026-05-27T15:35:42.106Z" },
{ url = "https://files.pythonhosted.org/packages/a7/71/bd67d5f5199f937dafe03a49a37989f60f600ff6fef34c79412a829d97bd/tornado-6.5.6-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8666946e70171b8c3f1fc9b7876fac492e84822c4c7f3746f4e8f8bc9ac92a79", size = 449935, upload-time = "2026-05-27T15:35:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a4/c24388c9cf5b3c3a513b56a158af9f23092c9a2810d789e294310797df21/tornado-6.5.6-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c34cfab7ad6d104f052f55de06d39bbafc5885cfeb4da688803308dbcfa90b7", size = 449767, upload-time = "2026-05-27T15:35:45.793Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/6a07ad550c3f7b37244bd0becdf293ec3d3e961783d8b720a97df50de1b2/tornado-6.5.6-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:385f35e4e22fb52551dfcda4cdc8c30c61c2c001aef5ddad99cdfe116952efd3", size = 449174, upload-time = "2026-05-27T15:35:47.485Z" },
{ url = "https://files.pythonhosted.org/packages/bb/84/3469e098dccdb6763130e06aacd786bb4363fca7b590a55c101ddf34ed30/tornado-6.5.6-cp39-abi3-win32.whl", hash = "sha256:db475f1b67b2809b10bb16264829087724ca8d24fe4ed47f7b8675cae453ef86", size = 450230, upload-time = "2026-05-27T15:35:49.322Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3c/273a04e0b9dd9016f1685cca0c1c8795a71ac88a34a8c889a0b443483226/tornado-6.5.6-cp39-abi3-win_amd64.whl", hash = "sha256:6739bf1e8eb09230f1280ddbd3236f0309db70f2c551a8dbc40f62babdf82f79", size = 450667, upload-time = "2026-05-27T15:35:51.194Z" },
{ url = "https://files.pythonhosted.org/packages/02/98/0cffe22a224f60c5fb1e3aa0b76f9da2e1ca78b0e9545e3d077c68ce60a7/tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac", size = 449690, upload-time = "2026-05-27T15:35:52.902Z" },
{ 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]]
@@ -4284,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" },