Compare commits

...
31 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
SkalskiP 1e551ec9c0 releasing 0.29.0 2026-06-15 23:36:28 +02:00
SkalskiP 1fa735d0d9 releasing 0.29.0rc1 2026-06-15 23:24:53 +02:00
Piotr Skalski 62a24d7960 Unify deprecation policy: enforce 3 minor release minimum window (#2324)
* Unify deprecation policy: 3 minor releases minimum

* Add 0.29.0 changelog entry

* Bump version to 0.29.0
2026-06-15 23:04:55 +02:00
Piotr Skalski 3d07099cf9 Fix ellipse annotators to draw level-by-level instead of point-by-point (#2325) 2026-06-15 22:51:06 +02:00
483e3e9335 fix(detection): make oriented_box_iou_batch exact and gate non-overlapping pairs (#2317)
* fix(detection): make oriented_box_iou_batch exact and gate non-overlapping pairs
* test(detection): cover gate passthrough and invalid metric in oriented IoU
* test(detection): add edge-case OBB coverage for oriented_box_iou_batch

- Parametrize test_self_comparison_is_symmetric_with_unit_diagonal with N=1
  and N=2 (R6: N=1 path exercising triangular-mirror with single (0,0) pair)
- Add test_degenerate_boxes_score_zero: collapsed, collinear, zero-area
  self-comparison → 0.0 (documents divergence from box_iou_batch semantics)
- Add test_empty_input_returns_correct_shape: (0,M), (N,0), (0,0) variants
  exercising early-return path at TestOrientedBoxIouBatch level
- Add test_invalid_shape_raises_value_error: 3-D wrong inner dims, 2-D wrong
  columns, 1-D input — matches exact error messages from implementation
- Fix test_is_invariant_to_canvas_transforms: remove stale pixel-IoU /
  canvas reference; tighten tolerances to rtol=1e-5 / atol=1e-7 (exact
  arithmetic no longer has quantization noise)
- Add Raises: section to oriented_box_iou_batch documenting all four
  ValueError paths (3-D wrong inner dims, 2-D wrong columns, wrong ndim,
  unsupported overlap_metric)
- Add Note: block documenting is_self_comparison identity-based contract
  (disabled by upstream .copy()), convexity precondition, and NaN/Inf
  silent-zero behavior
- Align Returns: style with box_iou_batch sibling (named entry semantics)
- Add Examples: doctest block (doctest: +ELLIPSIS for IoU value)
- Strengthen np.clip comment: explicitly mark as load-bearing; explains
  that cv2 intersection in float32 can exceed float64 area by ~25 ULP
- Add one-line comment at NMS caller: is_self_comparison trigger context
- Add Args:/Returns: to _polygon_areas and _aabb_envelopes private helpers
- Expand _overlapping_envelope_pairs docstring: Note (correctness guarantee
  — not an approximation), Args: and Returns: blocks

* refactor(detection): fuse _overlapping_envelope_pairs to halve peak memory
* test(detection): fix degenerate-collinear OBB NMM expectation for exact IoU

---------

Co-authored-by: jirka <6035284+Borda@users.noreply.github.com>
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-06-15 20:54:06 +02:00
e0e1abd865 fix(obb): NMM now computes geometric union via min-area rotated rect (#2312)
* fix(detection): OBB NMM now computes geometric union via min-area rotated rect

Previously with_nmm for OBB detections kept the winner's OBB geometry unchanged
(only confidence was merged), making it inconsistent with AABB NMM which expands
to the union envelope. Now computes cv2.minAreaRect over all N×4 corners from
the merge group — the MARC degenerates to the axis-aligned union for zero-rotation
OBBs, preserving full consistency with AABB NMM.

- Replace winner-OBB xyxy patch with MARC of all merged corners
- Update ORIENTED_BOX_COORDINATES in data to reflect merged geometry
- Rename test to reflect new expected behaviour (union, not winner AABB)
- Add consistency test asserting axis-aligned OBB NMM == AABB NMM xyxy

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* code(detection): defensive reshape + clarify xyxy-override intent in OBB NMM

- Add .reshape(4, 2) to OBB corner extraction loop so flat-adjacent shapes are normalised before cv2.minAreaRect
- Add inline comment at xyxy override: OBB groups intentionally discard AABB-union xyxy from reduce() to stay consistent with MARC corners

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* test(detection): expand OBB NMM coverage — rotated, 3-group, passthrough, class-agnostic, IOS, flat-format

- Add test_rotated_obb_merge_produces_marc: two 45-degree OBBs, assert MARC encompasses all corners
- Add test_three_detection_group_merge: three overlapping OBBs, assert merged len==1 and envelope spans all inputs
- Add test_single_detection_passthrough_preserves_obb: non-overlapping OBB passes through unchanged
- Add test_class_agnostic_obb_merge: class_agnostic=True merges cross-class OBBs
- Add test_overlap_metric_ios_obb_merge: IOS metric merges contained OBBs
- Add test_flat_n8_obb_format_raises_value_error: documents that (N,8) flat format is unsupported (canonical is (N,4,2))
- Import OverlapMetric for IOS test

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* docs(detection): document OBB NMM MARC semantics in with_nmm + changelog entry

- Add Note section to with_nmm docstring explaining MARC behavior: union for zero-rotation OBBs, MARC for rotated OBBs, single-group passthrough
- Add changelog UnReleased entry for #2312 behavioral change

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* fix(detection): OBB NMM uses winner's angle instead of free MARC to avoid overshoot

cv2.minAreaRect picks a 45-degree rect for diagonal staircase arrangements of
axis-aligned boxes, producing an AABB like [-10,-10,54,54] that extends outside
every input. Fix: lock merged OBB to winner's angle by projecting all corners
onto the winner's principal axes (from first edge vector), computing AABB there,
and back-rotating — for zero-rotation inputs this gives exactly the axis-aligned
union; for same-angle groups the result equals the prior MARC.

- Remove cv2 dependency from the OBB merge block (pure numpy now)
- Add test_diagonal_staircase_obb_merge_stays_within_union regression test
- Rename test to reflect winner-angle semantics

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* fix(detection): fix changelog wording + add explicit OBB shape guard in NMM

- docs/changelog.md: replace stale MARC/cv2.minAreaRect wording with
  winner's-angle description matching the actual implementation
- core.py: validate ORIENTED_BOX_COORDINATES shape is (N, 4, 2) at the
  start of the OBB merge block; raises ValueError("corners must have
  shape (N, 4, 2)") for flat (N, 8) input instead of silently mis-reshaping

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* refactor: parametrize OBB NMM tests in TestDetectionsWithNmm Consolidate 7 individual OBB NMM test methods into a single parametrized test_obb_nmm_merge with explicit expected_confidence and expected_corners assertions. Add cases for mixed-angle merges, multiple merge groups, and degenerate collinear OBBs. Add standalone test_obb_nmm_empty_detections for empty inputs.

* refactor(tests): simplify OBB NMM test cases by replacing `np.array` usage with nested lists

- Update test parameters to use plain Python lists instead of `numpy` arrays for corner definitions.
- Adjust the `_make_obb_detections` setup to preprocess corners into `numpy` arrays.
- Add explicit conversion of `expected_corners` to `numpy` arrays in the assertions.

* feat: add xyxyxyxy_to_xyxy utility for OBB-to-AABB conversion Vectorized conversion of oriented bounding box corners (N, 4, 2) to axis-aligned bounding boxes (N, 4). Used internally in with_nmm and exposed via top-level import.

* deprecate: mark merge_inner_detections_objects for removal in 0.34.0 Function is unused dead code with no external callers. Decorator emits FutureWarning while preserving existing behavior.

* refactor: extract _merge_obb_corners and _merge_detection_group from with_nmm Replace inline OBB post-processing and reduce-based merging with two private helpers using single-pass area-weighted confidence. Deprecate merge_inner_detection_object_pair and merge_inner_detections_objects_without_iou (0.29.0 -> 0.34.0). Rename TestDetectionsWithNmm -> TestDetectionsWithNMM and expand TestMergeDetectionGroup to assert all output fields via expected_detections.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: SkalskiP <piotr.skalski92@gmail.com>
2026-06-15 16:23:31 +02:00
117a9baab8 fix(coco): preserve segmentation in as_coco() round-trip (#2321)
* fix(coco): preserve segmentation in as_coco() round-trip (#2285)
* fix(coco): address Copilot review — normalize flat polygon lists, tighten test assertions
* fix(coco): type annotation, config constant, merge-compat, lint fixes
* test(coco): add two-polygon and RLE round-trip tests
* fix(tests): update expected_results for always-store coco_raw_segmentation
* fix(coco): use COCO_RAW_SEGMENTATION key in export fallback
* fix(coco): adjust mask_bool assignment to ensure proper usage in iscrowd logic
* test: simplify COCO segmentation regression cases
* fix(coco): add type annotation for raw_segs to ensure clarity
* test(coco): improve docstring and add comments for segmentation round-trip validation

---------

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>
Co-authored-by: Codex <codex@openai.com>
2026-06-15 15:00:27 +02:00
dependabot[bot] 497f336d13 ⬆️ Bump tornado from 6.5.5 to 6.5.6 in the uv group across 1 directory (#2315)
Bumps the uv group with 1 update in the / directory: [tornado](https://github.com/tornadoweb/tornado).


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

---
updated-dependencies:
- dependency-name: tornado
  dependency-version: 6.5.6
  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-15 01:03:43 +02:00
50 changed files with 4364 additions and 540 deletions
+12
View File
@@ -252,6 +252,18 @@ Type hints are required on all new code. mypy is enforced by the pre-commit hook
- Prefer vectorized operations over Python loops in hot paths.
- Lazy-import heavy framework dependencies (`torch`, `transformers`, `ultralytics`) inside the function that needs them — never at module top level.
### Deprecation policy
**Minimum window**: deprecated APIs must remain for at least **3 minor releases** before removal. Example: deprecated in `0.29.0` → removed in `0.32.0`.
Use the appropriate mechanism depending on what is being deprecated:
- **Module-level alias**: `supervision.utils.internal.warn_deprecated` in the deprecated module's `__init__.py`
- **Renamed parameter**: `supervision.utils.internal.deprecated_parameter` decorator
- **Public function, method, or class**: `@deprecated` from `pydeprecate`
Always specify both the deprecation version and the planned removal version in the message or decorator arguments.
### Deprecated module aliases
`supervision.keypoint` is deprecated since `0.27.0` and will be removed in `0.30.0`. Always import from `supervision.key_points`:
+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
+3 -1
View File
@@ -75,6 +75,8 @@ For branching, commit, code style, and API design conventions see [CONTRIBUTING.
## 5. Deprecating APIs
**Minimum window**: deprecated APIs must remain for at least **3 minor releases** before removal. Example: deprecated in `0.29.0` → removed in `0.32.0`.
- Module-level: `supervision.utils.internal.warn_deprecated` in the deprecated module's own `__init__.py`
- Parameter renamed (old→new): `supervision.utils.internal.deprecated_parameter` decorator
- Public function, method, or class: `@deprecated` from `pydeprecate`
@@ -82,7 +84,7 @@ For branching, commit, code style, and API design conventions see [CONTRIBUTING.
Always name the version introduced and the removal version:
```python
warn_deprecated("'foo' deprecated in `0.27.0`, removed in `0.30.0`. Use 'bar'.")
warn_deprecated("'foo' deprecated in `0.29.0`, removed in `0.32.0`. Use 'bar'.")
```
---
+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>
+81 -11
View File
@@ -1,25 +1,97 @@
---
description: "Full version history of the supervision Python library — release notes, breaking changes, new features, and deprecations for every version."
date_modified: 2026-06-09
date_modified: 2026-06-16
---
# Changelog
### UnReleased
### 0.29.1 <small>Jun 23, 2026</small>
- Fixed [#2306](https://github.com/roboflow/supervision/pull/2306): [`sv.Detections.area`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.area) now returns the rotated body's area for detections carrying `data["xyxyxyxy"]` (oriented box corners) instead of the area of the derived axis-aligned bounding box, which overestimates by up to ~2x at 45° rotation. Affects annotator z-ordering inside [`MaskAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.MaskAnnotator) and [`HaloAnnotator`](https://supervision.roboflow.com/latest/detection/annotators/#supervision.annotators.core.HaloAnnotator), and any user code that filters or sorts OBB detections by area. The mask path and the non-OBB AABB fallback are unchanged.
- Added [#2338](https://github.com/roboflow/supervision/pull/2338): [`sv.KeyPoints.with_nms`](https://supervision.roboflow.com/latest/keypoint/core/#supervision.key_points.core.KeyPoints.with_nms) — non-maximum suppression for keypoint detections. Derives axis-aligned bounding boxes from valid (non-zero and visible) keypoints and applies `box_non_max_suppression`. Requires `detection_confidence`; supports class-aware and class-agnostic modes via `threshold`, `class_agnostic`, and `overlap_metric`.
- Fixed [#2289](https://github.com/roboflow/supervision/pull/2289): [`DetectionDataset.as_yolo`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.as_yolo) now accepts `is_obb=True` to round-trip oriented bounding box datasets without losing the rotation. Previously the save path had no OBB option and silently wrote 5-token axis-aligned bbox lines for datasets loaded with `from_yolo(..., is_obb=True)`, dropping the four corners stored in `detections.data["xyxyxyxy"]`. Re-loading those saved files with `is_obb=True` then crashed the validator. Mirrors the existing `from_yolo(..., is_obb=True)` load path.
- Fixed [#2342](https://github.com/roboflow/supervision/pull/2342): `sv.Detections.from_vlm` with `sv.VLM.GOOGLE_GEMINI_2_0`, `sv.VLM.GOOGLE_GEMINI_2_5`, and `sv.VLM.QWEN_2_5_VL` no longer raises when the model returns valid JSON of the wrong shape (non-list top-level or non-dict elements). A non-string or malformed `"mask"` value in Gemini 2.5 output no longer triggers `AttributeError`; invalid base64 or non-PNG mask data falls back to an empty mask, keeping `xyxy`, `confidence`, and `masks` arrays aligned.
- Deprecated: public `validate_*` helper functions now delegate to private `_validate_*` implementations via `pydeprecate` shims. Existing imports continue to work with a `FutureWarning`; internal Supervision code uses the private helpers directly.
- Fixed [#2341](https://github.com/roboflow/supervision/pull/2341): `sv.DetectionDataset.as_pascal_voc` no longer mutates the source `Detections.xyxy` by the 1-index offset on every call. Previously, repeated exports accumulated a `+1` shift in the caller's bounding boxes.
- Fixed [#2282](https://github.com/roboflow/supervision/pull/2282): [`oriented_box_iou_batch`](https://supervision.roboflow.com/latest/detection/utils/iou_and_nms/) now allocates the rasterization canvas with correct dimensions. Previously x-extents were mapped to canvas height and y-extents to width, causing IoU to be computed on a transposed canvas for non-square oriented bounding boxes. Metrics using OBB IoU (Precision, Recall, F1Score, MeanAverageRecall with `metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES`) now return correct scores.
- Fixed [#2334](https://github.com/roboflow/supervision/pull/2334): `sv.JSONSink` now serializes NumPy scalars (e.g. `np.int64` frame indices) in `custom_data` as JSON numbers instead of raising `TypeError` at close time. File handle is now guaranteed to close even when serialization fails.
- Fixed [#2276](https://github.com/roboflow/supervision/pull/2276): [`DetectionDataset.as_coco`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.as_coco) now emits 1-indexed `category_id` and `categories[].id` values as required by the COCO spec, fixing CVAT import failures reported in [#1181](https://github.com/roboflow/supervision/issues/1181). Files generated by earlier supervision versions used 0-indexed category ids; they can still be loaded correctly by `from_coco` because the read path maps categories by name, not by id value.
- Fixed [#2333](https://github.com/roboflow/supervision/pull/2333): [`sv.DetectionsSmoother`](https://supervision.roboflow.com/latest/detection/tools/smoother/#supervision.detection.tools.smoother.DetectionsSmoother) no longer raises when smoothing detections without `confidence`. Confidence is now averaged over the frames that carry it; when tracks in the same frame disagree on confidence presence, `confidence` is set to `None` for all smoothed detections.
- Added [#2267](https://github.com/roboflow/supervision/pull/2267): [`DetectionDataset.as_coco`](https://supervision.roboflow.com/latest/datasets/core/#supervision.dataset.core.DetectionDataset.as_coco) and `save_coco_annotations` now accept `starting_image_id` and `starting_annotation_id` parameters (both default to `1`, preserving existing behavior) and return a `(next_image_id, next_annotation_id)` tuple. Feed the returned values into the next split's call to produce globally unique COCO ids across train/valid/test exports. Fixes id collisions reported in [#768](https://github.com/roboflow/supervision/issues/768). **Note**: the return type changes from `None` to `tuple[int, int]` — callers that assert `result is None` must be updated.
- Fixed [#2332](https://github.com/roboflow/supervision/pull/2332): `sv.approximate_polygon` now returns a polygon within the requested point-count budget (at most `floor(N * (1 - percentage))` points, minimum 3). The function now also validates that `epsilon_step > 0`.
- Changed [#2303](https://github.com/roboflow/supervision/pull/2303): [`sv.Detections.with_nms`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.with_nms) and [`sv.Detections.with_nmm`](https://supervision.roboflow.com/latest/detection/core/#supervision.detection.core.Detections.with_nmm) now use oriented-box IoU when `data["xyxyxyxy"]` coordinates are present, fixing silent suppression of crossed/rotated OBB detections (reported in [#1679](https://github.com/roboflow/supervision/issues/1679)). Previously, both methods always used axis-aligned bounding-box IoU even for OBB inputs, causing the X-pattern AABB IoU ≈ 1.0 to suppress valid detections at any practical threshold. Callers relying on the old AABB behaviour (e.g. to force AABB suppression on OBB data) should remove `data["xyxyxyxy"]` before calling these methods. Threshold values calibrated against AABB IoU may need adjustment when OBB IoU is substantially different.
- Fixed [#2331](https://github.com/roboflow/supervision/pull/2331): `sv.Precision` and `sv.F1Score` now count predictions on background images (empty target set) as false positives, and count predictions of classes absent from ground truth as false positives under `MICRO` and `MACRO` averaging. Previously both edge cases were silently ignored, inflating scores. `WEIGHTED` averaging is unchanged — absent classes retain weight 0, consistent with scikit-learn. Users relying on previous scores should re-evaluate after upgrading; no API change is required.
- Fixed [#2322](https://github.com/roboflow/supervision/pull/2322): COCO export now preserves all polygon parts for multi-component masks. Previously, only the first polygon was written when a non-crowd mask had disjoint segments; all parts are now included.
- Performance [#2339](https://github.com/roboflow/supervision/pull/2339): `sv.HaloAnnotator` now uses the same CompactMask painting path as `sv.MaskAnnotator` via a shared `_paint_masks_by_area` helper. On a 1080p frame with 30 CompactMask detections, `HaloAnnotator` runs approximately 4× faster; annotated output is unchanged.
- Performance [#2330](https://github.com/roboflow/supervision/pull/2330): `sv.mask_to_xyxy` and `sv.KeyPoints.as_detections` are now vectorized. `mask_to_xyxy` uses batched occupancy-profile reductions instead of per-mask pixel scans; `KeyPoints.as_detections` computes all bounding boxes in a single batch operation. Both produce bit-identical results.
- Performance [#2323](https://github.com/roboflow/supervision/pull/2323): Mask IoU computation now uses matrix multiplication on flattened masks instead of an explicit `(N, M, H, W)` intersection tensor, reducing peak memory for large mask sets. For masks larger than 4096×4096 pixels, computation automatically promotes to float64 to preserve exact pixel counts. Results are numerically identical.
### 0.29.0.post0 <small>Jun 17, 2026</small>
- Fixed [#2335](https://github.com/roboflow/supervision/pull/2335): `sv.KeyPoints(confidence=...)` now works again. The `0.29.0` refactor accidentally dropped the deprecated `confidence` constructor kwarg; it is now accepted and mapped to `keypoint_confidence` with a deprecation warning.
### 0.29.0 <small>Jun 15, 2026</small>
- Added [#2314](https://github.com/roboflow/supervision/pull/2314): new cookbook **Oriented Bounding Boxes** showing how an oriented box differs from an axis-aligned one on a marina of boats: DOTA-pretrained detection, the effect on [`with_nms`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nms) and [`Detections.area`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.area), and YOLO OBB dataset export.
- Fixed [#2306](https://github.com/roboflow/supervision/pull/2306): [`sv.Detections.area`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.area) now returns the rotated body's area for detections carrying `data["xyxyxyxy"]` (oriented box corners) instead of the area of the derived axis-aligned bounding box, which overestimates by up to ~2x at 45° rotation. Affects annotator z-ordering inside [`MaskAnnotator`](https://supervision.roboflow.com/0.29.0/detection/annotators/#supervision.annotators.core.MaskAnnotator) and [`HaloAnnotator`](https://supervision.roboflow.com/0.29.0/detection/annotators/#supervision.annotators.core.HaloAnnotator), and any user code that filters or sorts OBB detections by area. The mask path and the non-OBB AABB fallback are unchanged.
- Added [#2277](https://github.com/roboflow/supervision/pull/2277), [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.VertexEllipseAreaAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseAreaAnnotator), [`sv.VertexEllipseOutlineAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseOutlineAnnotator), and [`sv.VertexEllipseHaloAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseHaloAnnotator) for visualizing keypoint uncertainty as covariance ellipses. Requires models that output keypoint uncertainty (e.g. RF-DETR keypoint models).
- Added [#2303](https://github.com/roboflow/supervision/pull/2303): [`sv.oriented_box_non_max_suppression`](https://supervision.roboflow.com/0.29.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.oriented_box_non_max_suppression) and [`sv.oriented_box_non_max_merge`](https://supervision.roboflow.com/0.29.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.oriented_box_non_max_merge) for performing NMS and NMM directly on oriented bounding boxes using oriented-box IoU instead of axis-aligned IoU.
- Added [#2247](https://github.com/roboflow/supervision/pull/2247): [`sv.ConfusionMatrix`](https://supervision.roboflow.com/0.29.0/detection/metrics/#supervision.metrics.detection.ConfusionMatrix) now supports `MetricTarget.ORIENTED_BOUNDING_BOXES`, computing IoU via `oriented_box_iou_batch` on `xyxyxyxy` corners. Previously, OBB inputs silently fell back to axis-aligned bounding-box IoU, producing incorrect match scores for rotated detections.
- Added [#2252](https://github.com/roboflow/supervision/pull/2252): [`sv.process_video`](https://supervision.roboflow.com/0.29.0/utils/video/#supervision.utils.video.process_video) gains a `preserve_audio` parameter. When enabled, the audio stream from the source video is muxed into the output using ffmpeg.
- Added [#2302](https://github.com/roboflow/supervision/pull/2302), [#2289](https://github.com/roboflow/supervision/pull/2289): [`sv.DetectionDataset.as_yolo`](https://supervision.roboflow.com/0.29.0/datasets/core/#supervision.dataset.core.DetectionDataset.as_yolo) gains an `is_obb` parameter for exporting oriented bounding box annotations in the YOLO OBB format (9-token lines with 4 corner coordinates).
- Added [#2312](https://github.com/roboflow/supervision/pull/2312): [`sv.xyxyxyxy_to_xyxy`](https://supervision.roboflow.com/0.29.0/detection/utils/boxes/#supervision.detection.utils.boxes.xyxyxyxy_to_xyxy) — vectorised utility that converts oriented bounding box corners `(N, 4, 2)` to axis-aligned bounding boxes `(N, 4)`.
- Changed [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.KeyPoints`](https://supervision.roboflow.com/0.29.0/keypoint/core/#supervision.key_points.core.KeyPoints) now separates keypoint-level and detection-level confidence into distinct fields: `keypoint_confidence` (shape `(n, m)`) and `detection_confidence` (shape `(n,)`). A new `visible` mask (shape `(n, m)`) controls per-keypoint visibility. The legacy `KeyPoints.confidence` property still works but is deprecated.
- Changed [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.EdgeAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.EdgeAnnotator) and [`sv.VertexAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexAnnotator) now respect the `visible` mask. Invisible keypoints and their edges are skipped during rendering.
- Changed [#2286](https://github.com/roboflow/supervision/pull/2286): [`sv.EdgeAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.EdgeAnnotator) and [`sv.VertexLabelAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexLabelAnnotator) now support per-class skeleton definitions, enabling correct rendering when multiple skeleton topologies (e.g. person + animal) coexist in one frame.
- Changed [#2303](https://github.com/roboflow/supervision/pull/2303): [`sv.Detections.with_nms`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nms) and [`sv.Detections.with_nmm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nmm) now use oriented-box IoU when `data["xyxyxyxy"]` coordinates are present, instead of axis-aligned box IoU. Callers relying on the previous axis-aligned behaviour should remove `data["xyxyxyxy"]` before calling, or recalibrate any IoU thresholds.
- Changed [#2312](https://github.com/roboflow/supervision/pull/2312): [`sv.Detections.with_nmm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nmm) now computes the merged oriented bounding box as the tightest rectangle at the winner's orientation enclosing all corners from every detection in a merge group.
- Changed [#2325](https://github.com/roboflow/supervision/pull/2325): [`sv.VertexEllipseAreaAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseAreaAnnotator), [`sv.VertexEllipseOutlineAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseOutlineAnnotator), and [`sv.VertexEllipseHaloAnnotator`](https://supervision.roboflow.com/0.29.0/keypoint/annotators/#supervision.key_points.annotators.VertexEllipseHaloAnnotator) now draw sigma levels level-by-level (outermost first) across all points, ensuring correct visual layering when ellipses overlap.
- Changed [#2256](https://github.com/roboflow/supervision/pull/2256): [`sv.InferenceSlicer`](https://supervision.roboflow.com/0.29.0/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer) now detects OBB outputs from callbacks and automatically falls back to sequential processing to avoid thread-safety issues when `thread_workers > 1`.
- Changed [#2324](https://github.com/roboflow/supervision/pull/2324): Project-wide deprecation policy unified to a minimum 3-minor-release window. All current deprecations (including `KeyPoints.confidence` and `validate_*` helpers) are scheduled for removal in `0.32.0`.
- Fixed [#2252](https://github.com/roboflow/supervision/pull/2252): [`sv.process_video`](https://supervision.roboflow.com/0.29.0/utils/video/#supervision.utils.video.process_video) audio muxing path now correctly creates temp files on the same filesystem, decodes ffmpeg errors, and avoids muxing incomplete output.
- Fixed [#2282](https://github.com/roboflow/supervision/pull/2282), [#2317](https://github.com/roboflow/supervision/pull/2317): [`sv.oriented_box_iou_batch`](https://supervision.roboflow.com/0.29.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.oriented_box_iou_batch) now computes exact IoU via convex polygon intersection (`cv2.intersectConvexConvex`) and uses an axis-aligned bounding box envelope gate to skip pairs that cannot overlap, improving both accuracy and performance. Previously, rasterization on a discrete grid was used, which assumed square dimensions and introduced quantisation noise.
- Fixed [#2239](https://github.com/roboflow/supervision/pull/2239): [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.from_vlm) no longer returns `None` for `class_id` on empty VLM parses; now returns an empty int ndarray.
- Fixed [#2270](https://github.com/roboflow/supervision/pull/2270): [`sv.Detections.from_inference`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.from_inference) now preserves `class_name` as a string-dtype array when predictions are empty.
- Fixed [#2269](https://github.com/roboflow/supervision/pull/2269): [`sv.HeatMapAnnotator`](https://supervision.roboflow.com/0.29.0/detection/annotators/#supervision.annotators.core.HeatMapAnnotator) no longer crashes with a divide-by-zero when called with empty detections.
- Fixed [#2276](https://github.com/roboflow/supervision/pull/2276): COCO export now emits 1-indexed `category_id` values as required by the COCO specification.
- Fixed [#2267](https://github.com/roboflow/supervision/pull/2267): COCO annotation and image IDs are now sequential across train/val/test splits via `starting_image_id` and `starting_annotation_id` parameters.
- Fixed [#2289](https://github.com/roboflow/supervision/pull/2289): [`sv.DetectionDataset.as_yolo`](https://supervision.roboflow.com/0.29.0/datasets/core/#supervision.dataset.core.DetectionDataset.as_yolo) no longer loses OBB rotation when exporting oriented bounding boxes.
- Fixed [#2296](https://github.com/roboflow/supervision/pull/2296): YOLO dataset loading now sorts class names by numeric keys when `data.yaml` uses integer class IDs.
- Fixed [#2297](https://github.com/roboflow/supervision/pull/2297): Letterbox utility now supports grayscale images.
- Fixed [#2298](https://github.com/roboflow/supervision/pull/2298): File extension filters now normalize casing (e.g. `.JPG` matches `.jpg`).
- Fixed [#2321](https://github.com/roboflow/supervision/pull/2321): [`sv.DetectionDataset.as_coco()`](https://supervision.roboflow.com/0.29.0/datasets/core/#supervision.dataset.core.DetectionDataset.as_coco) now round-trips polygon and RLE segmentation data. Segmentations loaded from COCO annotations are preserved in `detections.data["coco_raw_segmentation"]` and written back on export, preventing data loss in train/val/test split workflows.
- Deprecated: `KeyPoints.confidence` (use `KeyPoints.keypoint_confidence`), `merge_inner_detection_object_pair`, `merge_inner_detections_objects`, `merge_inner_detections_objects_without_iou`, `validate_detections_fields`, `validate_vlm_parameters`, `validate_fields_both_defined_or_none`, `validate_xyxy`, `validate_mask`, `validate_class_id`, `validate_confidence`, `validate_tracker_id`, `validate_data`, `validate_xy`, `validate_key_point_confidence`, `validate_key_points_fields`, `validate_resolution`, `validate_custom_values`, `validate_input_tensors`, and `validate_labels` are deprecated in `0.29.0` and will be removed in `0.32.0`.
### 0.28.0 <small>Apr 30, 2026</small>
@@ -41,8 +113,6 @@ date_modified: 2026-06-09
- Added [#932](https://github.com/roboflow/supervision/pull/932): [`sv.ImageAssets`](https://supervision.roboflow.com/latest/assets/) for downloading sample images alongside existing video assets, useful for examples and tutorials.
- Added [#2247](https://github.com/roboflow/supervision/pull/2247): [`sv.ConfusionMatrix`](https://supervision.roboflow.com/metrics/detection/#confusionmatrix) now accepts `metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES`, computing IoU via `oriented_box_iou_batch` over `xyxyxyxy` coordinates. Previously, OBB inputs silently fell back to axis-aligned bounding-box IoU, producing incorrect match scores for rotated detections.
- Changed [#2169](https://github.com/roboflow/supervision/pull/2169): [`sv.MeanAveragePrecisionResult`](https://supervision.roboflow.com/latest/metrics/mean_average_precision/) and related metric arrays (`mAP_scores`, `ap_per_class`, `iou_thresholds`, precision/recall) are now `float32` instead of `float64`. Reduces memory and speeds up computation; numerical results may differ in the last few digits.
- Changed [#2178](https://github.com/roboflow/supervision/pull/2178): [`sv.rle_to_mask`](https://supervision.roboflow.com/latest/detection/utils/converters/#supervision.detection.utils.converters.rle_to_mask) and [`sv.mask_to_rle`](https://supervision.roboflow.com/latest/detection/utils/converters/#supervision.detection.utils.converters.mask_to_rle) moved to `supervision.detection.utils.converters`. The old import path `supervision.dataset.utils` continues to work but is deprecated.
+6
View File
@@ -33,3 +33,9 @@ comments: true
</div>
:::supervision.detection.utils.boxes.denormalize_boxes
<div class="md-typeset">
<h2><a href="#supervision.detection.utils.boxes.xyxyxyxy_to_xyxy">xyxyxyxy_to_xyxy</a></h2>
</div>
:::supervision.detection.utils.boxes.xyxyxyxy_to_xyxy
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.rc0"
version = "0.29.1"
description = "A set of easy-to-use utils that will come in handy in any Computer Vision project"
readme = "README.md"
keywords = [
+1
View File
@@ -62,6 +62,7 @@ from supervision.detection.utils.boxes import (
move_boxes,
pad_boxes,
scale_boxes,
xyxyxyxy_to_xyxy,
)
from supervision.detection.utils.converters import (
is_compressed_rle,
+79 -47
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)
@@ -2746,7 +2778,7 @@ class PercentageBarAnnotator(BaseAnnotator):
@deprecated( # type: ignore[untyped-decorator]
target=_validate_custom_values.__func__, # type: ignore[attr-defined]
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_custom_values(
custom_values: npt.NDArray[np.float64] | list[float] | None,
+1 -1
View File
@@ -226,7 +226,7 @@ def _validate_labels(labels: list[str] | None, detections: Detections) -> None:
@deprecated( # type: ignore[untyped-decorator]
target=_validate_labels,
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_labels(labels: list[str] | None, detections: Detections) -> None:
void(labels, detections)
+1
View File
@@ -1,4 +1,5 @@
CLASS_NAME_DATA_FIELD: str = "class_name"
COCO_RAW_SEGMENTATION: str = "coco_raw_segmentation"
#: Key for oriented bounding-box corner coordinates in ``Detections.data``.
#:
#: Value layout: ``np.ndarray`` of shape ``(N, 4, 2)``, dtype ``float32``, pixel
+53 -7
View File
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Union, cast
import numpy as np
import numpy.typing as npt
from supervision.config import COCO_RAW_SEGMENTATION
from supervision.dataset.utils import (
approximate_mask_with_polygons,
map_detections_class_id,
@@ -168,6 +169,10 @@ def coco_annotations_to_detections(
Returns:
Detections with ``class_id`` set to raw COCO ``category_id`` values.
Call :func:`map_detections_class_id` on the result before use.
When ``with_masks=False``, ``detections.data[COCO_RAW_SEGMENTATION]`` is
populated as an object array (shape ``(N,)``) holding the raw polygon list or
RLE dict per annotation; consumed by :func:`detections_to_coco_annotations`
for a coordinate-preserving round-trip.
"""
if not image_annotations:
return Detections.empty()
@@ -175,11 +180,11 @@ def coco_annotations_to_detections(
class_ids = [
image_annotation["category_id"] for image_annotation in image_annotations
]
xyxy = [image_annotation["bbox"] for image_annotation in image_annotations]
xyxy = np.asarray(xyxy, dtype=np.float32)
xyxy_list = [image_annotation["bbox"] for image_annotation in image_annotations]
xyxy: npt.NDArray[np.float32] = np.asarray(xyxy_list, dtype=np.float32)
xyxy[:, 2:4] += xyxy[:, 0:2]
data: dict[str, npt.NDArray[np.generic]] = {}
data: dict[str, Union[npt.NDArray[np.generic], list[Any]]] = {}
if use_iscrowd:
iscrowd = [
image_annotation["iscrowd"] for image_annotation in image_annotations
@@ -195,6 +200,14 @@ def coco_annotations_to_detections(
)
else:
mask = None
# Preserve raw polygon/RLE data so as_coco() can round-trip without
# binary-mask encoding. Stored as an object array (one entry per detection).
raw_segs: npt.NDArray[np.object_] = np.empty(
len(image_annotations), dtype=object
)
for k, _ann in enumerate(image_annotations):
raw_segs[k] = _ann.get("segmentation", [])
data[COCO_RAW_SEGMENTATION] = raw_segs
return Detections(
class_id=np.asarray(class_ids, dtype=int), xyxy=xyxy, mask=mask, data=data
@@ -234,6 +247,18 @@ def detections_to_coco_annotations(
Raises:
ValueError: If any detection has ``class_id`` equal to ``None``.
Note:
For ``iscrowd=0`` annotations, ``segmentation`` is a
``list[list[float]]`` where each inner list encodes one polygon
part as flat ``[x1, y1, x2, y2, ...]`` coordinates. A single
object with *N* disjoint parts produces *N* inner lists.
When ``iscrowd`` is not in ``detections.data``, masks with holes
or multiple disjoint segments are auto-encoded as RLE
(``iscrowd=1``); simple single-region masks use polygon format
(``iscrowd=0``). Supply ``data={"iscrowd": np.array([0])}`` to
force polygon output regardless of mask topology.
Examples:
```python
import numpy as np
@@ -260,21 +285,25 @@ def detections_to_coco_annotations(
box_width, box_height = xyxy[2] - xyxy[0], xyxy[3] - xyxy[1]
segmentation: Union[list[list[float]], dict[str, list[int]]] = []
if mask is not None:
mask_bool = cast(npt.NDArray[np.bool_], mask)
if "iscrowd" in data:
iscrowd = int(np.asarray(data["iscrowd"]).item())
else:
iscrowd = int(
contains_holes(mask=mask) or contains_multiple_segments(mask=mask)
contains_holes(mask=mask_bool)
or contains_multiple_segments(mask=mask_bool)
)
if iscrowd:
segmentation = {
"counts": cast(list[int], mask_to_rle(mask=mask, compressed=False)),
"counts": cast(
list[int], mask_to_rle(mask=mask_bool, compressed=False)
),
"size": list(mask.shape[:2]),
}
else:
polygons = approximate_mask_with_polygons(
mask=mask,
mask=mask_bool,
min_image_area_percentage=min_image_area_percentage,
max_image_area_percentage=max_image_area_percentage,
approximation_percentage=approximation_percentage,
@@ -282,7 +311,8 @@ def detections_to_coco_annotations(
# Small/noisy masks can be filtered out by approximation settings.
# Guard against empty output and keep a valid COCO annotation record.
if polygons:
segmentation = [list(polygons[0].flatten())]
# Export ALL polygons so disjoint mask components are preserved.
segmentation = [list(p.flatten()) for p in polygons]
else:
warnings.warn(
"Skipping COCO polygon segmentation for annotation "
@@ -292,6 +322,22 @@ def detections_to_coco_annotations(
)
else:
iscrowd = int(np.asarray(data.get("iscrowd", 0)).item())
# When masks were not decoded during loading, fall back to the raw
# polygon/RLE stored in data["segmentation"] for a lossless round-trip.
raw_seg = data.get(COCO_RAW_SEGMENTATION)
if raw_seg is not None and bool(raw_seg):
if isinstance(raw_seg, dict):
# RLE format — pass through unchanged
segmentation = raw_seg
elif (
isinstance(raw_seg, list)
and raw_seg
and not isinstance(raw_seg[0], (list, tuple))
):
# Flat list shorthand [x1,y1,...] — wrap to list-of-lists
segmentation = [list(raw_seg)]
else:
segmentation = list(raw_seg)
area: float = float(np.asarray(data.get("area", box_width * box_height)).item())
coco_annotation = {
+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
+149 -22
View File
@@ -19,7 +19,7 @@ from supervision.detection.tools.transformers import (
process_transformers_v4_segmentation_result,
process_transformers_v5_segmentation_result,
)
from supervision.detection.utils.boxes import obb_polygon_area
from supervision.detection.utils.boxes import obb_polygon_area, xyxyxyxy_to_xyxy
from supervision.detection.utils.converters import (
mask_to_xyxy,
polygon_to_mask,
@@ -2562,6 +2562,17 @@ class Detections:
A new Detections object containing the subset of detections
after non-maximum merging.
Note:
For detections carrying oriented bounding box data
(``data[ORIENTED_BOX_COORDINATES]``), each merge group's output OBB
is the tightest rectangle at the winner's orientation enclosing all
corners contributed by every detection in the group. The winner is
the highest-confidence detection in the group. The axis-aligned
``xyxy`` field is updated to the tight bounding box of that rect.
For zero-rotation OBBs this equals the axis-aligned union exactly;
for rotated OBBs the merged rect inherits the winner's rotation angle.
Groups of size 1 keep the original OBB unchanged.
Raises:
AssertionError: If `confidence` is None or `class_id` is None and
class_agnostic is False.
@@ -2615,30 +2626,144 @@ class Detections:
result: list[Detections] = []
for merge_group in merge_groups:
unmerged_detections = [cast(Detections, self[i]) for i in merge_group]
merged_detections = merge_inner_detections_objects_without_iou(
unmerged_detections
)
if (
len(merge_group) > 1
and ORIENTED_BOX_COORDINATES in merged_detections.data
):
obb = merged_detections.data[ORIENTED_BOX_COORDINATES][0] # (4, 2)
merged_detections.xyxy = np.array(
[
[
float(obb[:, 0].min()),
float(obb[:, 1].min()),
float(obb[:, 0].max()),
float(obb[:, 1].max()),
]
]
)
result.append(merged_detections)
group = [cast(Detections, self[i]) for i in merge_group]
result.append(_merge_detection_group(group))
return Detections.merge(result)
def _merge_obb_corners(
corners_list: list[npt.NDArray[np.floating]],
) -> npt.NDArray[np.floating]:
"""Merge multiple OBB corner arrays using winner-angle projection.
The first entry in *corners_list* is the winner. Its orientation angle
(derived from its first edge) defines the local frame in which the
tightest enclosing axis-aligned rectangle is computed. That rectangle is
then rotated back to produce the merged OBB.
Args:
corners_list: List of (4, 2) corner arrays. First is the winner.
Returns:
Merged OBB corners as a (4, 2) float32 array.
"""
all_corners = np.concatenate(corners_list, axis=0).astype(np.float32)
# Use winner's first edge to derive orientation angle -- avoids
# cv2.minAreaRect surprises (e.g. 90-degree flip for wide rects).
winner_edge = corners_list[0][1] - corners_list[0][0]
angle = float(np.arctan2(winner_edge[1], winner_edge[0]))
cos, sin = float(np.cos(angle)), float(np.sin(angle))
# De-rotate all corners into the winner's local frame
to_local = np.array([[cos, -sin], [sin, cos]], dtype=np.float32)
local_corners = all_corners @ to_local
x_min = float(local_corners[:, 0].min())
x_max = float(local_corners[:, 0].max())
y_min = float(local_corners[:, 1].min())
y_max = float(local_corners[:, 1].max())
# Rotate the enclosing AABB back to world frame
to_world = np.array([[cos, sin], [-sin, cos]], dtype=np.float32)
merged: npt.NDArray[np.floating] = (
np.array(
[[x_min, y_min], [x_max, y_min], [x_max, y_max], [x_min, y_max]],
dtype=np.float32,
)
@ to_world
)
return merged
def _merge_detection_group(detections: list[Detections]) -> Detections:
"""Merge a group of single-object Detections into one merged detection.
Used internally by :meth:`Detections.with_nmm` to combine each merge group
into a single output detection. The highest-confidence detection is the
"winner" whose class_id, tracker_id, and data fields are preserved.
Args:
detections: List of Detections, each containing exactly one object.
Returns:
A single merged Detections object of length 1.
"""
if len(detections) == 1:
return detections[0]
confidences = np.array(
[d.confidence[0] if d.confidence is not None else 0.0 for d in detections],
dtype=np.float64,
)
winner_idx = int(np.argmax(confidences))
winner = detections[winner_idx]
# Area-weighted confidence: each box contributes proportionally to its
# footprint so large overlapping boxes dominate over small slivers.
all_xyxy = np.array([d.xyxy[0] for d in detections], dtype=np.float32)
areas = (all_xyxy[:, 2] - all_xyxy[:, 0]) * (all_xyxy[:, 3] - all_xyxy[:, 1])
if winner.confidence is not None:
total_area = float(areas.sum())
if total_area > 0:
confidence = np.array(
[float(np.dot(areas, confidences) / total_area)],
dtype=np.float32,
)
else:
confidence = winner.confidence
else:
confidence = None
# OBB path: merge corners via winner-angle projection, then derive xyxy
# from the merged OBB so both stay consistent.
has_obb = ORIENTED_BOX_COORDINATES in winner.data
if has_obb:
corners_list = []
for det in detections:
c = np.asarray(det.data[ORIENTED_BOX_COORDINATES])
if c.ndim != 3 or c.shape[1:] != (4, 2):
raise ValueError(f"corners must have shape (N, 4, 2); got {c.shape}")
corners_list.append(c[0].reshape(4, 2))
merged_corners = _merge_obb_corners(corners_list)
xyxy = xyxyxyxy_to_xyxy(merged_corners[np.newaxis])
data = {**winner.data, ORIENTED_BOX_COORDINATES: merged_corners[np.newaxis]}
else:
# AABB path: union bounding box across all group members
xyxy = np.array(
[
[
all_xyxy[:, 0].min(),
all_xyxy[:, 1].min(),
all_xyxy[:, 2].max(),
all_xyxy[:, 3].max(),
]
],
dtype=np.float32,
)
data = winner.data
# Mask union via logical OR
masks = [d.mask for d in detections if d.mask is not None]
if masks:
mask = np.logical_or.reduce(np.concatenate(masks, axis=0))[np.newaxis]
else:
mask = None
metadata = merge_metadata([d.metadata for d in detections])
return Detections(
xyxy=xyxy,
mask=mask,
confidence=confidence,
class_id=winner.class_id,
tracker_id=winner.tracker_id,
data=data,
metadata=metadata,
)
@deprecated(deprecated_in="0.29.0", remove_in="0.32.0") # type: ignore[untyped-decorator]
def merge_inner_detection_object_pair(
detections_1: Detections, detections_2: Detections
) -> Detections:
@@ -2731,6 +2856,7 @@ def merge_inner_detection_object_pair(
)
@deprecated(deprecated_in="0.29.0", remove_in="0.32.0") # type: ignore[untyped-decorator]
def merge_inner_detections_objects(
detections: list[Detections],
threshold: float = 0.5,
@@ -2758,6 +2884,7 @@ def merge_inner_detections_objects(
return detections_1
@deprecated(deprecated_in="0.29.0", remove_in="0.32.0") # type: ignore[untyped-decorator]
def merge_inner_detections_objects_without_iou(
detections: list[Detections],
) -> Detections:
@@ -2799,7 +2926,7 @@ def _validate_fields_both_defined_or_none(
@deprecated( # type: ignore[untyped-decorator]
target=_validate_fields_both_defined_or_none,
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_fields_both_defined_or_none(
detections_1: Detections, detections_2: Detections
+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)
+40
View File
@@ -269,6 +269,46 @@ def obb_polygon_area(corners: npt.NDArray) -> npt.NDArray[np.float64]:
return 0.5 * np.abs(np.sum(cross, axis=-1))
def xyxyxyxy_to_xyxy(
xyxyxyxy: npt.NDArray[np.number],
) -> npt.NDArray[np.number]:
"""Convert oriented bounding box corners to axis-aligned bounding boxes.
Args:
xyxyxyxy: OBB corner coordinates with shape `(N, 4, 2)` where each
box is represented as `[[x1, y1], [x2, y2], [x3, y3], [x4, y4]]`.
Returns:
Axis-aligned bounding boxes as an array of shape `(N, 4)`
in `(x_min, y_min, x_max, y_max)` format.
Raises:
ValueError: If `xyxyxyxy` does not have shape `(N, 4, 2)`.
Examples:
```pycon
>>> import numpy as np
>>> import supervision as sv
>>> corners = np.array([
... [[0, 0], [10, 0], [10, 5], [0, 5]],
... [[5, 5], [15, 5], [15, 10], [5, 10]],
... ], dtype=np.float32)
>>> sv.xyxyxyxy_to_xyxy(corners)
array([[ 0., 0., 10., 5.],
[ 5., 5., 15., 10.]], dtype=float32)
```
"""
xyxyxyxy = np.asarray(xyxyxyxy)
if xyxyxyxy.ndim != 3 or xyxyxyxy.shape[-2:] != (4, 2):
raise ValueError(f"xyxyxyxy must have shape (N, 4, 2); got {xyxyxyxy.shape}")
x_min = xyxyxyxy[..., 0].min(axis=-1)
y_min = xyxyxyxy[..., 1].min(axis=-1)
x_max = xyxyxyxy[..., 0].max(axis=-1)
y_max = xyxyxyxy[..., 1].max(axis=-1)
return np.stack([x_min, y_min, x_max, y_max], axis=-1)
def scale_boxes(
xyxy: npt.NDArray[np.float64], factor: float
) -> npt.NDArray[np.float64]:
+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
+222 -82
View File
@@ -1,14 +1,15 @@
from __future__ import annotations
import warnings
from collections.abc import Callable
from enum import Enum
from typing import Any, cast
import cv2
import numpy as np
import numpy.typing as npt
from supervision.detection.compact_mask import CompactMask
from supervision.detection.utils.converters import polygon_to_mask
from supervision.detection.utils.masks import resize_masks
@@ -86,13 +87,6 @@ class OverlapMetric(Enum):
)
# Upper bound on the rasterization canvas used by ``oriented_box_iou_batch``.
# IoU is invariant under uniform scaling (up to rasterization quantization),
# so boxes whose extent exceeds this cap are scaled down uniformly. Bounds the
# memory cost of the ``(N, H, W)`` mask array regardless of source resolution.
_MAX_IOU_CANVAS_DIM = 1024
def box_iou(
box_true: list[float] | npt.NDArray[np.floating],
box_detection: list[float] | npt.NDArray[np.floating],
@@ -366,6 +360,69 @@ def box_iou_batch_with_jaccard(
return ious
def _polygon_areas(polygons: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
"""Compute the area of each oriented-box polygon using the shoelace formula.
Args:
polygons: ``(N, 4, 2)`` array of polygon corners.
Returns:
``(N,)`` array of polygon areas.
"""
x = polygons[:, :, 0]
y = polygons[:, :, 1]
cross = x * np.roll(y, -1, axis=1) - np.roll(x, -1, axis=1) * y
return cast(npt.NDArray[np.floating], 0.5 * np.abs(cross.sum(axis=1)))
def _aabb_envelopes(polygons: npt.NDArray[np.floating]) -> npt.NDArray[np.floating]:
"""Compute the axis-aligned bounding envelope of each oriented box.
Args:
polygons: ``(N, 4, 2)`` array of polygon corners.
Returns:
``(N, 4)`` array of ``(x_min, y_min, x_max, y_max)`` envelopes.
"""
xs = polygons[:, :, 0]
ys = polygons[:, :, 1]
return np.stack(
[xs.min(axis=1), ys.min(axis=1), xs.max(axis=1), ys.max(axis=1)], axis=1
)
def _overlapping_envelope_pairs(
envelopes_true: npt.NDArray[np.floating],
envelopes_detection: npt.NDArray[np.floating],
) -> tuple[npt.NDArray[np.intp], npt.NDArray[np.intp]]:
"""Return index pairs ``(i, j)`` whose axis-aligned envelopes overlap.
Uses a fused boolean evaluation to halve peak transient memory compared to
named-intermediate form (4 separate NxM float64 arrays vs 1 boolean array).
Note:
This gate is a correctness guarantee, not an approximation: if two
axis-aligned bounding boxes do not overlap, the convex polygons they
contain cannot overlap either.
Args:
envelopes_true: ``(N, 4)`` array of ``(x_min, y_min, x_max, y_max)``
envelopes for the ground-truth boxes.
envelopes_detection: ``(M, 4)`` array of ``(x_min, y_min, x_max, y_max)``
envelopes for the detection boxes.
Returns:
A pair of 1-D index arrays ``(rows, cols)`` identifying the overlapping
pairs.
"""
et = envelopes_true[:, None, :]
ed = envelopes_detection[None, :, :]
overlap = (
np.minimum(et[..., 2], ed[..., 2]) > np.maximum(et[..., 0], ed[..., 0])
) & (np.minimum(et[..., 3], ed[..., 3]) > np.maximum(et[..., 1], ed[..., 1]))
return cast(tuple[npt.NDArray[np.intp], npt.NDArray[np.intp]], np.nonzero(overlap))
def oriented_box_iou_batch(
boxes_true: npt.NDArray[np.number],
boxes_detection: npt.NDArray[np.number],
@@ -375,9 +432,27 @@ def oriented_box_iou_batch(
Compute pairwise overlap scores between two sets of oriented bounding boxes
using the configured `overlap_metric`.
Overlap areas are computed exactly via convex-polygon intersection, gated by
a cheap axis-aligned envelope pre-filter — no rasterization is involved, so
the result is exact (free of pixel-quantization error) and independent of the
coordinate magnitudes.
`boxes_true` and `boxes_detection` are expected to be in
`((x1, y1), (x2, y2), (x3, y3), (x4, y4))` format.
Note:
Inputs must be **convex** quads with finite coordinates. Self-intersecting
or non-convex polygons produce undefined results via
``cv2.intersectConvexConvex``. NaN or Inf coordinates propagate silently
as ``0.0`` — validate inputs before calling if needed.
When ``boxes_true is boxes_detection`` (the same Python object, not just
equal values), the function computes only the upper triangle of the
matrix and mirrors it. This optimization is used automatically by the
NMS/NMM callers that pass the same array twice. A defensive ``.copy()``
at the call site would disable the optimization silently — see the
NMS caller comment for context.
Args:
boxes_true: A `np.ndarray` representing ground-truth boxes.
`shape = (N, 4, 2)` where `N` is number of true objects.
@@ -391,10 +466,27 @@ def oriented_box_iou_batch(
between pairs of oriented boxes (e.g., IoU, IoS).
Returns:
Pairwise overlap scores of boxes from `boxes_true` and
`boxes_detection`, using the configured :attr:`overlap_metric`.
`shape = (N, M)` where `N` is number of true objects and
`M` is number of detected objects.
Overlap matrix of shape `(N, M)`, where entry `[i, j]` is the overlap
score between `boxes_true[i]` and `boxes_detection[j]`, in the range
`[0, 1]` under the configured :attr:`overlap_metric`.
Raises:
ValueError: If ``boxes_true`` or ``boxes_detection`` is 3-D with inner
dimensions other than ``(4, 2)``.
ValueError: If ``boxes_true`` or ``boxes_detection`` is 2-D with a
column count other than 8.
ValueError: If ``boxes_true`` or ``boxes_detection`` is not 2-D or 3-D.
ValueError: If ``overlap_metric`` is not
:attr:`~supervision.config.OverlapMetric.IOU` or
:attr:`~supervision.config.OverlapMetric.IOS`.
Examples:
>>> import numpy as np
>>> import supervision as sv
>>> a = np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32)
>>> b = np.array([[[1, 0], [3, 0], [3, 2], [1, 2]]], dtype=np.float32)
>>> sv.oriented_box_iou_batch(a, b) # doctest: +ELLIPSIS
array([[0.333...]])
"""
for name, arr in (("boxes_true", boxes_true), ("boxes_detection", boxes_detection)):
@@ -413,53 +505,65 @@ def oriented_box_iou_batch(
f"`{name}` must be 2-D (N, 8) or 3-D (N, 4, 2), got shape {arr.shape}."
)
boxes_true = boxes_true.reshape(-1, 4, 2)
boxes_detection = boxes_detection.reshape(-1, 4, 2)
if len(boxes_true) == 0 or len(boxes_detection) == 0:
return np.zeros((len(boxes_true), len(boxes_detection)), dtype=np.float64)
# IoU is invariant under translation and uniform scaling. Shift boxes to
# the origin so the canvas only covers their bounding region (avoids dead
# space when boxes sit in a corner of the input frame) and shrink them
# uniformly when the bounding region exceeds the cap (keeps memory
# bounded regardless of input resolution).
# Axis convention: [..., 0] = x → canvas width, [..., 1] = y → height.
min_x = float(min(boxes_true[:, :, 0].min(), boxes_detection[:, :, 0].min()))
min_y = float(min(boxes_true[:, :, 1].min(), boxes_detection[:, :, 1].min()))
extent_x = (
float(max(boxes_true[:, :, 0].max(), boxes_detection[:, :, 0].max())) - min_x
)
extent_y = (
float(max(boxes_true[:, :, 1].max(), boxes_detection[:, :, 1].max())) - min_y
)
canvas_dim = max(extent_x, extent_y)
scale = (
_MAX_IOU_CANVAS_DIM / canvas_dim if canvas_dim > _MAX_IOU_CANVAS_DIM else 1.0
)
offset = np.array([min_x, min_y], dtype=np.float64)
boxes_true = (boxes_true - offset) * scale
boxes_detection = (boxes_detection - offset) * scale
# adding 1 because we are 0-indexed
max_width = int(extent_x * scale + 1)
max_height = int(extent_y * scale + 1)
mask_true = np.zeros((boxes_true.shape[0], max_height, max_width), dtype=np.uint8)
for box_idx, box_true in enumerate(boxes_true):
mask_true[box_idx] = polygon_to_mask(box_true, (max_width, max_height))
mask_detection = np.zeros(
(boxes_detection.shape[0], max_height, max_width), dtype=np.uint8
)
for box_idx, box_detection in enumerate(boxes_detection):
mask_detection[box_idx] = polygon_to_mask(
box_detection, (max_width, max_height)
if overlap_metric == OverlapMetric.IOU:
normalize_by_union = True
elif overlap_metric == OverlapMetric.IOS:
normalize_by_union = False
else:
raise ValueError(
f"overlap_metric {overlap_metric} is not supported, "
"only 'IOU' and 'IOS' are supported"
)
ious = mask_iou_batch(mask_true, mask_detection, overlap_metric)
return ious
# Capture identity before reshape: NMS / NMM pass the same array twice, so
# the matrix is symmetric and we can compute only its upper triangle.
is_self_comparison = boxes_true is boxes_detection
boxes_true = boxes_true.reshape(-1, 4, 2).astype(np.float64)
boxes_detection = boxes_detection.reshape(-1, 4, 2).astype(np.float64)
n, m = len(boxes_true), len(boxes_detection)
if n == 0 or m == 0:
return np.zeros((n, m), dtype=np.float64)
areas_true = _polygon_areas(boxes_true)
areas_detection = _polygon_areas(boxes_detection)
envelopes_true = _aabb_envelopes(boxes_true)
envelopes_detection = (
envelopes_true if is_self_comparison else _aabb_envelopes(boxes_detection)
)
rows, cols = _overlapping_envelope_pairs(envelopes_true, envelopes_detection)
if is_self_comparison:
upper = rows <= cols
rows, cols = rows[upper], cols[upper]
polygons_true = [box.astype(np.float32) for box in boxes_true]
polygons_detection = [box.astype(np.float32) for box in boxes_detection]
ious: npt.NDArray[np.float64] = np.zeros((n, m), dtype=np.float64)
for i, j in zip(rows, cols):
intersection, _ = cv2.intersectConvexConvex(
polygons_true[i], polygons_detection[j]
)
if intersection <= 0:
continue
denominator = (
areas_true[i] + areas_detection[j] - intersection
if normalize_by_union
else min(areas_true[i], areas_detection[j])
)
if denominator > 0:
score = intersection / denominator
ious[i, j] = score
if is_self_comparison:
ious[j, i] = score
# DO NOT remove this clip. cv2.intersectConvexConvex computes in float32
# internally while polygon areas are computed in float64; the intersection
# area can exceed the float64 area by ~25 ULP (~1e-7), producing raw IoU
# or IoS values microscopically above 1.0 for identical boxes. The clip is
# load-bearing, not defensive duplication.
return cast(npt.NDArray[np.floating], np.clip(ious, 0.0, 1.0))
def compact_mask_iou_batch(
@@ -584,12 +688,23 @@ def _mask_iou_batch_split(
Returns:
Pairwise IoU of masks from `masks_true` and `masks_detection`.
"""
intersection_area = np.logical_and(masks_true[:, None], masks_detection).sum(
axis=(2, 3)
# The overlap of two binary masks is the dot product of their flattened
# pixels, so the whole (N, M) intersection matrix is a single matmul.
# float32 counts pixels exactly up to 2**24; for larger masks (beyond
# ~4096x4096) we promote to float64 so the counts stay exact.
pixels = int(np.prod(masks_true.shape[1:]))
count_dtype = np.float32 if pixels <= 2**24 else np.float64
true_flat = masks_true.reshape(masks_true.shape[0], pixels).astype(
count_dtype, copy=False
)
detection_flat = masks_detection.reshape(masks_detection.shape[0], pixels).astype(
count_dtype, copy=False
)
with np.errstate(divide="ignore", over="ignore", invalid="ignore"):
intersection_area: npt.NDArray[np.floating[Any]] = true_flat @ detection_flat.T
masks_true_area = masks_true.sum(axis=(1, 2)) # (area1, area2, ...)
masks_detection_area = masks_detection.sum(axis=(1, 2)) # (area1)
masks_true_area = true_flat.sum(axis=1)
masks_detection_area = detection_flat.sum(axis=1)
if overlap_metric == OverlapMetric.IOU:
union_area = masks_true_area[:, None] + masks_detection_area - intersection_area
@@ -640,10 +755,20 @@ def mask_iou_batch(
overlap_metric: Metric used to compute the degree of overlap
between pairs of masks (e.g., IoU, IoS).
memory_limit: Memory limit in MB, default is 1024 * 5 MB (5GB).
Ignored when both inputs are CompactMask.
Controls chunking of ``masks_true`` so that flattened detection
masks plus each chunk's buffers stay within this limit. A
``UserWarning`` is raised when ``masks_detection`` alone
exceeds the limit, as chunking cannot reduce peak memory
below that floor. Ignored when both inputs are
:class:`~supervision.detection.compact_mask.CompactMask`.
Returns:
Pairwise IoU of masks from `masks_true` and `masks_detection`.
Raises:
ValueError: If ``masks_true`` or ``masks_detection`` are not 3D
``(N, H, W)`` arrays, or if they do not share the same
spatial dimensions ``(H, W)``.
"""
if isinstance(masks_true, CompactMask) and isinstance(masks_detection, CompactMask):
@@ -655,29 +780,43 @@ def mask_iou_batch(
if isinstance(masks_detection, CompactMask):
masks_detection = np.asarray(masks_detection)
memory = (
masks_true.shape[0]
* masks_true.shape[1]
* masks_true.shape[2]
* masks_detection.shape[0]
/ 1024
/ 1024
)
if memory <= memory_limit:
if masks_true.ndim != 3 or masks_detection.ndim != 3:
raise ValueError(
"masks_true and masks_detection must be 3D (N, H, W); got "
f"ndim={masks_true.ndim} and ndim={masks_detection.ndim}."
)
if masks_true.shape[1:] != masks_detection.shape[1:]:
raise ValueError(
"masks_true and masks_detection must share the same (H, W); got "
f"{masks_true.shape[1:]} and {masks_detection.shape[1:]}."
)
# A single pass already handles empty inputs and avoids np.vstack([]) below.
if masks_true.shape[0] == 0 or masks_detection.shape[0] == 0:
return _mask_iou_batch_split(masks_true, masks_detection, overlap_metric)
# Peak memory of a single matmul pass: the flattened detection masks (shared
# across chunks) plus, per true-mask row, its flattened pixels and the three
# (N, M) matrices it touches (intersection, denominator and output). The
# previous (N, M, H, W) estimate overcounted by a factor of M and forced
# needless chunking now that the intersection is a matmul.
pixels = masks_true.shape[1] * masks_true.shape[2]
itemsize = 4 if pixels <= 2**24 else 8
limit_bytes = memory_limit * 1024 * 1024
detection_bytes = masks_detection.shape[0] * pixels * itemsize
per_true_row = pixels * itemsize + 3 * masks_detection.shape[0] * 8
if detection_bytes > limit_bytes > 0:
warnings.warn(
f"detection masks ({detection_bytes // 1024 // 1024} MB) exceed "
f"memory_limit ({memory_limit} MB); chunking cannot reduce peak "
"memory below this floor.",
UserWarning,
stacklevel=2,
)
if detection_bytes + masks_true.shape[0] * per_true_row <= limit_bytes:
return _mask_iou_batch_split(masks_true, masks_detection, overlap_metric)
ious = []
step = max(
memory_limit
* 1024
* 1024
// (
masks_detection.shape[0]
* masks_detection.shape[1]
* masks_detection.shape[2]
),
1,
)
step = max((limit_bytes - detection_bytes) // per_true_row, 1)
for chunk_start in range(0, masks_true.shape[0], step):
ious.append(
_mask_iou_batch_split(
@@ -1185,6 +1324,7 @@ def oriented_box_non_max_suppression(
)
sort_index, _, categories = _prepare_predictions_for_nms(predictions)
oriented_boxes = oriented_boxes[sort_index]
# same object intentional — triggers upper-triangle optimization
ious = oriented_box_iou_batch(oriented_boxes, oriented_boxes, overlap_metric)
keep = _nms_loop_from_iou_matrix(ious, categories, iou_threshold)
return cast(npt.NDArray[np.bool_], keep[sort_index.argsort()])
+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
+64 -41
View File
@@ -206,7 +206,7 @@ def _validate_vlm_parameters(
@deprecated( # type: ignore[untyped-decorator]
target=_validate_vlm_parameters,
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_vlm_parameters(vlm: VLM | str, result: Any, kwargs: dict[str, Any]) -> VLM:
return void(vlm, result, kwargs) # type: ignore[no-any-return]
@@ -214,7 +214,7 @@ def validate_vlm_parameters(vlm: VLM | str, result: Any, kwargs: dict[str, Any])
def from_paligemma(
result: str, resolution_wh: tuple[int, int], classes: list[str] | None = None
) -> tuple[npt.NDArray[Any], npt.NDArray[Any], npt.NDArray[Any]]:
) -> tuple[npt.NDArray[Any], npt.NDArray[Any] | None, npt.NDArray[Any]]:
"""
Parse bounding boxes from paligemma-formatted text, scale them to the specified
resolution, and optionally filter by classes.
@@ -238,23 +238,23 @@ def from_paligemma(
r"(?<!<loc\d{4}>)<loc(\d{4})><loc(\d{4})><loc(\d{4})><loc(\d{4})> ([\w\s\-]+)"
)
matches = pattern.findall(result)
matches = np.array(matches) if matches else np.empty((0, 5))
matches_arr: npt.NDArray[Any] = np.array(matches) if matches else np.empty((0, 5))
if matches.shape[0] == 0:
if matches_arr.shape[0] == 0:
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty(0, dtype=str)
xyxy, class_name = matches[:, [1, 0, 3, 2]], matches[:, 4]
xyxy = xyxy.astype(int) / 1024 * np.array([w, h, w, h])
class_name = np.char.strip(class_name.astype(str))
class_id = None
xyxy_arr = np.array(matches_arr[:, [1, 0, 3, 2]], dtype=float)
xyxy_arr = xyxy_arr.astype(int) / 1024 * np.array([w, h, w, h])
class_name = np.char.strip(matches_arr[:, 4].astype(str))
class_id: npt.NDArray[Any] | None = None
if classes is not None:
mask = np.array([name in classes for name in class_name], dtype=bool)
xyxy = xyxy[mask]
xyxy_arr = xyxy_arr[mask]
class_name = class_name[mask]
class_id = np.array([classes.index(name) for name in class_name])
return xyxy, class_id, class_name
return xyxy_arr, class_id, class_name
def recover_truncated_qwen_2_5_vl_response(text: str) -> Any | None:
@@ -369,7 +369,7 @@ def from_qwen_2_5_vl(
labels_list = []
for item in data:
if "bbox_2d" not in item or "label" not in item:
if not isinstance(item, dict) or "bbox_2d" not in item or "label" not in item:
continue
boxes_list.append(item["bbox_2d"])
labels_list.append(item["label"])
@@ -460,12 +460,13 @@ def from_deepseek_vl_2(
f"and det tags ({len(detection_segments)}) in the result must be equal."
)
xyxy, class_name_list = [], []
xyxy_list: list[list[float]] = []
class_name_list: list[str] = []
for label, detection_blob in zip(label_segments, detection_segments):
current_class_name = label.strip()
for box in re.findall(r"\[(.*?)\]", detection_blob):
x1, y1, x2, y2 = map(float, box.strip("[]").split(","))
xyxy.append(
xyxy_list.append(
[
(x1 / 999 * width),
(y1 / 999 * height),
@@ -475,7 +476,7 @@ def from_deepseek_vl_2(
)
class_name_list.append(current_class_name)
xyxy = np.array(xyxy, dtype=np.float32)
xyxy = np.array(xyxy_list, dtype=np.float32)
class_name = np.array(class_name_list)
if classes is not None:
@@ -541,15 +542,15 @@ def from_florence_2(
return xyxy, labels, None, xyxyxyxy
if task in ["<REFERRING_EXPRESSION_SEGMENTATION>", "<REGION_TO_SEGMENTATION>"]:
xyxy_list = []
masks_list = []
xyxy_list: list[npt.NDArray[Any]] = []
masks_list: list[npt.NDArray[Any]] = []
for polygons_of_same_class in result["polygons"]:
for polygon in polygons_of_same_class:
polygon = np.reshape(polygon, (-1, 2)).astype(np.int32)
mask = polygon_to_mask(polygon, resolution_wh).astype(bool)
masks_list.append(mask)
xyxy = polygon_to_xyxy(polygon)
xyxy_list.append(xyxy)
xyxy_box = polygon_to_xyxy(polygon)
xyxy_list.append(xyxy_box)
# per-class labels also provided, but they are ["", "", "", ...]
# when we figure out how to set class names, we can do
# zip(result["labels"], result["polygons"])
@@ -640,11 +641,14 @@ def from_google_gemini_2_0(
except json.JSONDecodeError:
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty((0,), dtype=str)
if not isinstance(data, list):
return np.empty((0, 4)), np.empty((0,), dtype=int), np.empty((0,), dtype=str)
labels = []
xyxy = []
for item in data:
if "box_2d" not in item or "label" not in item:
if not isinstance(item, dict) or "box_2d" not in item or "label" not in item:
continue
labels.append(item["label"])
box = item["box_2d"]
@@ -734,13 +738,22 @@ def from_google_gemini_2_5(
None,
)
if not isinstance(data, list):
return (
np.empty((0, 4)),
np.array([], dtype=int),
np.array([], dtype=str),
np.array([], dtype=float),
None,
)
boxes_list: list[Any] = []
labels_list: list[str] = []
confidence_list: list[float] | None = []
masks_list: list[npt.NDArray[Any]] | None = []
for item in data:
if "box_2d" not in item or "label" not in item:
if not isinstance(item, dict) or "box_2d" not in item or "label" not in item:
continue
labels_list.append(item["label"])
box = item["box_2d"]
@@ -755,29 +768,39 @@ def from_google_gemini_2_5(
if "mask" in item:
if masks_list is not None:
png_str = item["mask"]
if not png_str.startswith("data:image/png;base64,"):
if not isinstance(png_str, str) or not png_str.startswith(
"data:image/png;base64,"
):
# Malformed mask: keep an empty mask but still fall through to
# the confidence handling below, so the per-item arrays stay
# aligned (a `continue` here desynced confidence vs boxes).
masks_list.append(np.zeros((h, w), dtype=bool))
continue
png_str = png_str.removeprefix("data:image/png;base64,")
png_str = base64.b64decode(png_str)
mask_img = Image.open(io.BytesIO(png_str))
y_min, y_max = int(absolute_bbox[1]), int(absolute_bbox[3])
x_min, x_max = int(absolute_bbox[0]), int(absolute_bbox[2])
bbox_height = y_max - y_min
bbox_width = x_max - x_min
if bbox_height > 0 and bbox_width > 0:
mask_img = mask_img.resize(
(bbox_width, bbox_height), resample=Image.Resampling.BILINEAR
)
np_mask: npt.NDArray[np.bool_] = np.zeros((h, w), dtype=bool)
np_mask[y_min:y_max, x_min:x_max] = np.array(mask_img) > 0
masks_list.append(np_mask)
else:
masks_list.append(np.zeros((h, w), dtype=bool))
png_str = png_str.removeprefix("data:image/png;base64,")
try:
png_bytes = base64.b64decode(png_str)
mask_img = Image.open(io.BytesIO(png_bytes)).convert("L")
except Exception:
masks_list.append(np.zeros((h, w), dtype=bool))
else:
y_min, y_max = int(absolute_bbox[1]), int(absolute_bbox[3])
x_min, x_max = int(absolute_bbox[0]), int(absolute_bbox[2])
bbox_height = y_max - y_min
bbox_width = x_max - x_min
if bbox_height > 0 and bbox_width > 0:
mask_img = mask_img.resize(
(bbox_width, bbox_height),
resample=Image.Resampling.BILINEAR,
)
np_mask: npt.NDArray[np.bool_] = np.zeros(
(h, w), dtype=bool
)
np_mask[y_min:y_max, x_min:x_max] = np.array(mask_img) > 0
masks_list.append(np_mask)
else:
masks_list.append(np.zeros((h, w), dtype=bool))
else:
masks_list = None
+68 -67
View File
@@ -329,10 +329,12 @@ class _BaseVertexEllipseAnnotator(BaseKeyPointAnnotator):
def _iter_ellipse_params(
self, key_points: KeyPoints
) -> list[tuple[tuple[int, int], tuple[int, int], float, float, Color]]:
"""Yield (center, axis_lengths, angle, sigma, color) for each visible point."""
) -> list[list[tuple[tuple[int, int], tuple[int, int], float, float, Color]]]:
"""Return ellipse params grouped by sigma level (outermost first)."""
covariances = self._get_covariances(key_points)
results: list[tuple[tuple[int, int], tuple[int, int], float, float, Color]] = []
levels: list[
list[tuple[tuple[int, int], tuple[int, int], float, float, Color]]
] = [[] for _ in self.sigma]
for detection_index, xy in enumerate(key_points.xy):
for point_index, (x, y) in enumerate(xy):
if np.allclose((x, y), 0):
@@ -351,7 +353,7 @@ class _BaseVertexEllipseAnnotator(BaseKeyPointAnnotator):
np.degrees(np.arctan2(eigenvectors[1, 0], eigenvectors[0, 0]))
)
center = (round(x), round(y))
for sigma, color in zip(self.sigma, self.color):
for level_idx, (sigma, color) in enumerate(zip(self.sigma, self.color)):
axes = sigma * np.sqrt(eigenvalues)
if self.max_axis is not None:
axes = np.minimum(axes, self.max_axis)
@@ -359,8 +361,10 @@ class _BaseVertexEllipseAnnotator(BaseKeyPointAnnotator):
max(1, round(axes[0])),
max(1, round(axes[1])),
)
results.append((center, axis_lengths, angle, sigma, color))
return results
levels[level_idx].append(
(center, axis_lengths, angle, sigma, color)
)
return levels
class VertexEllipseAreaAnnotator(_BaseVertexEllipseAnnotator):
@@ -446,20 +450,19 @@ class VertexEllipseAreaAnnotator(_BaseVertexEllipseAnnotator):
return scene
overlay = scene.copy()
for center, axis_lengths, angle, _sigma, color in self._iter_ellipse_params(
key_points
):
cv2.ellipse(
img=overlay,
center=center,
axes=axis_lengths,
angle=angle,
startAngle=0,
endAngle=360,
color=color.as_bgr(),
thickness=-1,
lineType=cv2.LINE_AA,
)
for level in self._iter_ellipse_params(key_points):
for center, axis_lengths, angle, _sigma, color in level:
cv2.ellipse(
img=overlay,
center=center,
axes=axis_lengths,
angle=angle,
startAngle=0,
endAngle=360,
color=color.as_bgr(),
thickness=-1,
lineType=cv2.LINE_AA,
)
cv2.addWeighted(overlay, self.opacity, scene, 1 - self.opacity, 0, dst=scene)
return scene
@@ -545,20 +548,19 @@ class VertexEllipseOutlineAnnotator(_BaseVertexEllipseAnnotator):
if len(key_points) == 0:
return scene
for center, axis_lengths, angle, _sigma, color in self._iter_ellipse_params(
key_points
):
cv2.ellipse(
img=scene,
center=center,
axes=axis_lengths,
angle=angle,
startAngle=0,
endAngle=360,
color=color.as_bgr(),
thickness=self.thickness,
lineType=cv2.LINE_AA,
)
for level in self._iter_ellipse_params(key_points):
for center, axis_lengths, angle, _sigma, color in level:
cv2.ellipse(
img=scene,
center=center,
axes=axis_lengths,
angle=angle,
startAngle=0,
endAngle=360,
color=color.as_bgr(),
thickness=self.thickness,
lineType=cv2.LINE_AA,
)
return scene
@@ -650,47 +652,46 @@ class VertexEllipseHaloAnnotator(_BaseVertexEllipseAnnotator):
h, w = scene.shape[:2]
composite: npt.NDArray[np.float32] = scene.astype(np.float32)
for center, axis_lengths, angle, _sigma, color in self._iter_ellipse_params(
key_points
):
ax, ay = axis_lengths
if ax == 0 or ay == 0:
continue
for level in self._iter_ellipse_params(key_points):
for center, axis_lengths, angle, _sigma, color in level:
ax, ay = axis_lengths
if ax == 0 or ay == 0:
continue
pad = 2
roi_half_w = ax + pad
roi_half_h = ay + pad
cx, cy = center
pad = 2
roi_half_w = ax + pad
roi_half_h = ay + pad
cx, cy = center
x_min = max(cx - roi_half_w, 0)
x_max = min(cx + roi_half_w, w)
y_min = max(cy - roi_half_h, 0)
y_max = min(cy + roi_half_h, h)
if x_min >= x_max or y_min >= y_max:
continue
x_min = max(cx - roi_half_w, 0)
x_max = min(cx + roi_half_w, w)
y_min = max(cy - roi_half_h, 0)
y_max = min(cy + roi_half_h, h)
if x_min >= x_max or y_min >= y_max:
continue
ys = np.arange(y_min, y_max, dtype=np.float32) - cy
xs = np.arange(x_min, x_max, dtype=np.float32) - cx
grid_x, grid_y = np.meshgrid(xs, ys)
ys = np.arange(y_min, y_max, dtype=np.float32) - cy
xs = np.arange(x_min, x_max, dtype=np.float32) - cx
grid_x, grid_y = np.meshgrid(xs, ys)
angle_rad = np.radians(-angle)
cos_a = np.cos(angle_rad)
sin_a = np.sin(angle_rad)
rx = grid_x * cos_a - grid_y * sin_a
ry = grid_x * sin_a + grid_y * cos_a
angle_rad = np.radians(-angle)
cos_a = np.cos(angle_rad)
sin_a = np.sin(angle_rad)
rx = grid_x * cos_a - grid_y * sin_a
ry = grid_x * sin_a + grid_y * cos_a
dist_sq = (rx / ax) ** 2 + (ry / ay) ** 2
inside = dist_sq <= 1.0
dist_sq = (rx / ax) ** 2 + (ry / ay) ** 2
inside = dist_sq <= 1.0
falloff = np.zeros_like(dist_sq)
falloff[inside] = (1.0 - dist_sq[inside]) ** self._DECAY
falloff = np.zeros_like(dist_sq)
falloff[inside] = (1.0 - dist_sq[inside]) ** self._DECAY
scaled_alpha = falloff * self.opacity
scaled_alpha = falloff * self.opacity
bgr = np.array(color.as_bgr(), dtype=np.float32)
roi = composite[y_min:y_max, x_min:x_max]
alpha_3 = scaled_alpha[:, :, np.newaxis]
roi[:] = roi * (1 - alpha_3) + bgr * alpha_3
bgr = np.array(color.as_bgr(), dtype=np.float32)
roi = composite[y_min:y_max, x_min:x_max]
alpha_3 = scaled_alpha[:, :, np.newaxis]
roi[:] = roi * (1 - alpha_3) + bgr * alpha_3
np.copyto(scene, composite.astype(np.uint8))
return scene
+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])
+1 -1
View File
@@ -187,7 +187,7 @@ def _validate_input_tensors(
@deprecated( # type: ignore[untyped-decorator]
target=_validate_input_tensors,
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_input_tensors(
predictions: list[npt.NDArray[np.float32]],
+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:
+6 -6
View File
@@ -182,7 +182,7 @@ def _validate_tracker_id(tracker_id: Any, n: int) -> None:
@deprecated( # type: ignore[untyped-decorator]
target=_validate_tracker_id,
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_tracker_id(tracker_id: Any, n: int) -> None:
void(tracker_id, n)
@@ -207,7 +207,7 @@ def _validate_data(data: dict[str, Any], n: int) -> None:
@deprecated( # type: ignore[untyped-decorator]
target=_validate_data,
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_data(data: dict[str, Any], n: int) -> None:
void(data, n)
@@ -227,7 +227,7 @@ def _validate_xy(xy: Any, n: int, m: int) -> None:
@deprecated( # type: ignore[untyped-decorator]
target=_validate_xy,
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_xy(xy: Any, n: int, m: int) -> None:
void(xy, n, m)
@@ -276,7 +276,7 @@ def _validate_detections_fields(
@deprecated( # type: ignore[untyped-decorator]
target=_validate_detections_fields,
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_detections_fields(
xyxy: Any,
@@ -312,7 +312,7 @@ def _validate_keypoints_fields(
@deprecated( # type: ignore[untyped-decorator]
target=_validate_keypoints_fields,
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_key_points_fields(
xy: Any, class_id: Any, confidence: Any, data: dict[str, Any]
@@ -357,7 +357,7 @@ def _validate_resolution(resolution: Any) -> tuple[int, int]:
@deprecated( # type: ignore[untyped-decorator]
target=_validate_resolution,
deprecated_in="0.29.0",
remove_in="0.31.0",
remove_in="0.32.0",
)
def validate_resolution(resolution: Any) -> tuple[int, int]:
return void(resolution) # type: ignore[no-any-return]
+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"""
+257
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,
@@ -43,6 +44,14 @@ def mock_coco_annotation(
}
def _empty_raw_segs(n: int) -> np.ndarray:
"""Object-dtype array of n empty lists for coco_raw_segmentation (bbox-only)."""
arr = np.empty(n, dtype=object)
for i in range(n):
arr[i] = []
return arr
@pytest.fixture
def coco_data_with_and_without_segmentation() -> dict[str, object]:
return {
@@ -282,6 +291,7 @@ def test_group_coco_annotations_by_image_id(
Detections(
xyxy=np.array([[0, 0, 100, 100]], dtype=np.float32),
class_id=np.array([0], dtype=int),
data={"coco_raw_segmentation": _empty_raw_segs(1)},
),
DoesNotRaise(),
), # single image annotations
@@ -300,6 +310,7 @@ def test_group_coco_annotations_by_image_id(
data={
"iscrowd": np.array([0], dtype=int),
"area": np.array([100 * 100]),
"coco_raw_segmentation": _empty_raw_segs(1),
},
),
DoesNotRaise(),
@@ -321,6 +332,7 @@ def test_group_coco_annotations_by_image_id(
[[0, 0, 100, 100], [100, 100, 200, 200]], dtype=np.float32
),
class_id=np.array([0, 0], dtype=int),
data={"coco_raw_segmentation": _empty_raw_segs(2)},
),
DoesNotRaise(),
), # two image annotations
@@ -344,6 +356,7 @@ def test_group_coco_annotations_by_image_id(
data={
"iscrowd": np.array([0, 0], dtype=int),
"area": np.array([100 * 100, 100 * 100]),
"coco_raw_segmentation": _empty_raw_segs(2),
},
),
DoesNotRaise(),
@@ -983,6 +996,80 @@ def test_detections_to_coco_annotations_handles_empty_approximated_polygons() ->
assert annotations[0]["iscrowd"] == 0
_DISJOINT_2X2_MASK = np.array(
[
[
[1, 1, 0, 0, 0],
[1, 1, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
]
],
dtype=bool,
)
_SINGLE_COMPONENT_MASK = np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
]
],
dtype=bool,
)
def _make_iscrowd0_detections(mask: np.ndarray) -> Detections:
"""Build a single-detection Detections with iscrowd=0 from a (1, H, W) mask."""
_, h, w = mask.shape
return Detections(
xyxy=np.array([[0, 0, w, h]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=mask,
data={"iscrowd": np.array([0])},
)
@pytest.mark.parametrize(
("mask", "expected_segment_count"),
[
pytest.param(_DISJOINT_2X2_MASK, 2, id="disjoint-two-parts"),
pytest.param(_SINGLE_COMPONENT_MASK, 1, id="single-component"),
],
)
def test_detections_to_coco_annotations_segmentation_count(
mask: np.ndarray, expected_segment_count: int
) -> None:
"""Non-crowd mask export produces one polygon list entry per connected component."""
annotations, _ = detections_to_coco_annotations(
detections=_make_iscrowd0_detections(mask), image_id=0, annotation_id=0
)
segmentation = annotations[0]["segmentation"]
assert annotations[0]["iscrowd"] == 0
assert isinstance(segmentation, list)
assert len(segmentation) == expected_segment_count
assert all(len(part) >= 6 for part in segmentation)
assert all(np.isfinite(c) for part in segmentation for c in part)
def test_detections_to_coco_annotations_round_trip_disjoint_mask() -> None:
"""Two-part disjoint mask round-trips through COCO export and import unchanged."""
W, H = 5, 5
annotations, _ = detections_to_coco_annotations(
detections=_make_iscrowd0_detections(_DISJOINT_2X2_MASK),
image_id=0,
annotation_id=0,
)
reloaded = coco_annotations_to_masks([annotations[0]], resolution_wh=(W, H))
assert np.array_equal(reloaded[0], _DISJOINT_2X2_MASK[0])
def test_detections_to_coco_annotations_preserves_area_from_data() -> None:
"""area stored in detections.data should be used instead of bbox area."""
detections = Detections(
@@ -1845,3 +1932,173 @@ def test_coco_round_trip_multi_class_single_image(tmp_path) -> None:
dets = loaded.annotations[img_path]
assert dets.class_id is not None
assert sorted(dets.class_id.tolist()) == [0, 1]
# --- Regression: segmentation round-trip (#2285) ---
def _coco_annotation_with_segmentation(
segmentation: list[list[int]],
bbox: tuple[float, float, float, float] = (0, 0, 5, 5),
area: float = 25,
) -> dict:
return mock_coco_annotation(
annotation_id=1,
image_id=1,
category_id=1,
bbox=bbox,
area=area,
segmentation=segmentation,
)
def _single_image_coco_data(annotation: dict) -> dict[str, object]:
return {
"info": {},
"licenses": [],
"categories": [{"id": 1, "name": "cat", "supercategory": ""}],
"images": [{"id": 1, "file_name": "img.jpg", "width": 10, "height": 10}],
"annotations": [annotation],
}
def test_detections_to_coco_annotations_exports_all_polygons() -> None:
"""All polygons from a multi-component mask must be exported, not just the first."""
# Build a mask with two separate rectangles (disjoint components)
mask = np.zeros((20, 20), dtype=bool)
mask[1:4, 1:4] = True # top-left component
mask[14:18, 14:18] = True # bottom-right component
detections = Detections(
xyxy=np.array([[1, 1, 4, 4]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array([mask]),
data={"iscrowd": np.array([0], dtype=int)},
)
annotations, _ = detections_to_coco_annotations(
detections=detections, image_id=1, annotation_id=1
)
assert len(annotations) == 1
seg = annotations[0]["segmentation"]
# Both components must appear as separate polygon entries (list of lists)
assert isinstance(seg, list), "segmentation must be a list of polygons"
assert len(seg) >= 2
@pytest.mark.parametrize(
("segmentation", "bbox", "area", "expected_min_polygon_count"),
[
pytest.param(
[[0, 0, 4, 0, 4, 4, 0, 4]],
(0, 0, 5, 5),
25,
1,
id="single-polygon",
),
pytest.param(
[[0, 0, 4, 0, 4, 4, 0, 4], [6, 6, 9, 6, 9, 9, 6, 9]],
(0, 0, 9, 9),
32,
2,
id="multi-polygon",
),
],
)
def test_coco_polygon_segmentation_survives_roundtrip(
tmp_path,
segmentation: list[list[int]],
bbox: tuple[float, float, float, float],
area: float,
expected_min_polygon_count: int,
) -> None:
"""COCO polygon segmentation survives the load/export sequence.
1. Write source COCO JSON with polygon segmentation.
2. Load it through DetectionDataset.from_coco().
3. Export it back to COCO JSON with as_coco().
4. Assert the exported segmentation keeps the expected polygon component count.
"""
images_dir = tmp_path / "images"
images_dir.mkdir()
img_path = images_dir / "img.jpg"
assert cv2.imwrite(str(img_path), np.zeros((10, 10, 3), dtype=np.uint8))
# 1. Write source COCO JSON with polygon segmentation.
ann_path = tmp_path / "annotations.json"
ann_path.write_text(
json.dumps(
_single_image_coco_data(
_coco_annotation_with_segmentation(
segmentation=segmentation, bbox=bbox, area=area
)
)
),
encoding="utf-8",
)
# 2. Load it through the internal DetectionDataset representation.
ds = DetectionDataset.from_coco(
images_directory_path=str(images_dir),
annotations_path=str(ann_path),
)
# 3. Export it back to COCO JSON.
out_ann_path = tmp_path / "out_annotations.json"
ds.as_coco(annotations_path=str(out_ann_path))
with open(out_ann_path) as f:
out = json.load(f)
# 4. Assert polygon component count survives the load/export sequence.
assert len(out["annotations"]) == 1
seg = out["annotations"][0]["segmentation"]
assert isinstance(seg, list)
assert len(seg) >= expected_min_polygon_count
def test_coco_raw_segmentation_preserved_when_masks_not_decoded() -> None:
"""When masks are NOT decoded (with_masks=False), raw polygon data stored in
data['segmentation'] is used as a lossless fallback so as_coco() still emits
non-empty segmentation."""
image_annotations = [
_coco_annotation_with_segmentation(segmentation=[[0, 0, 4, 0, 4, 4, 0, 4]])
]
# Load WITHOUT mask decoding — mask must be None
detections = coco_annotations_to_detections(
image_annotations=image_annotations,
resolution_wh=(10, 10),
with_masks=False,
)
assert detections.mask is None
# Raw segmentation must be stored in data for fallback
assert "coco_raw_segmentation" in detections.data
# Export must still produce non-empty segmentation via fallback
annotations, _ = detections_to_coco_annotations(
detections=detections, image_id=1, annotation_id=1
)
assert len(annotations) == 1
assert annotations[0]["segmentation"] != []
def test_coco_iscrowd_mask_exports_as_rle() -> None:
"""Multi-segment mask exports segmentation as RLE dict (iscrowd inferred as 1)."""
mask = np.zeros((10, 10), dtype=bool)
mask[1:3, 1:3] = True # top-left component
mask[7:9, 7:9] = True # bottom-right component (two separate regions)
detections = Detections(
xyxy=np.array([[1, 1, 8, 8]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array([mask]),
)
annotations, _ = detections_to_coco_annotations(
detections=detections, image_id=1, annotation_id=1
)
assert len(annotations) == 1
seg = annotations[0]["segmentation"]
assert isinstance(seg, dict), "multi-segment mask must export as RLE dict, not list"
assert "counts" in seg
assert "size" in seg
+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"),
[
+716 -22
View File
@@ -7,7 +7,14 @@ import numpy as np
import pytest
from supervision.config import ORIENTED_BOX_COORDINATES
from supervision.detection.core import Detections, merge_inner_detection_object_pair
from supervision.detection.core import (
Detections,
_merge_detection_group,
_merge_obb_corners,
merge_inner_detection_object_pair,
)
from supervision.detection.utils.boxes import xyxyxyxy_to_xyxy
from supervision.detection.utils.iou_and_nms import OverlapMetric
from supervision.geometry.core import Position
from supervision.utils.internal import SupervisionWarnings
from tests.helpers import _create_detections
@@ -1002,10 +1009,7 @@ def _make_obb_detections(
) -> Detections:
"""Build OBB Detections from a list of (4, 2) corner arrays."""
oriented_boxes = np.stack(quads)
xyxy = np.array(
[[q[:, 0].min(), q[:, 1].min(), q[:, 0].max(), q[:, 1].max()] for q in quads],
dtype=np.float32,
)
xyxy = xyxyxyxy_to_xyxy(oriented_boxes)
return Detections(
xyxy=xyxy,
confidence=np.array(scores, dtype=np.float32),
@@ -1056,31 +1060,721 @@ class TestDetectionsObbDispatch:
assert len(result) == 1
class TestDetectionsWithNmm:
class TestMergeObbCorners:
"""_merge_obb_corners"""
@pytest.mark.parametrize(
("corners_list", "expected"),
[
pytest.param(
[np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32)],
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
id="single-box-passthrough",
),
pytest.param(
[
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
np.array([[2, 2], [12, 2], [12, 7], [2, 7]], dtype=np.float32),
],
np.array([[0, 0], [12, 0], [12, 7], [0, 7]], dtype=np.float32),
id="two-axis-aligned",
),
pytest.param(
[
_rotated_rect(50, 50, 40, 10, 45),
_rotated_rect(55, 55, 40, 10, 45),
],
None,
id="two-same-angle",
),
pytest.param(
[
_rotated_rect(50, 50, 40, 10, 30),
_rotated_rect(55, 50, 40, 10, -15),
],
None,
id="two-different-angles",
),
pytest.param(
[
np.array([[0, 0], [20, 0], [20, 10], [0, 10]], dtype=np.float32),
np.array([[5, 5], [25, 5], [25, 15], [5, 15]], dtype=np.float32),
np.array([[10, 0], [30, 0], [30, 10], [10, 10]], dtype=np.float32),
],
np.array([[0, 0], [30, 0], [30, 15], [0, 15]], dtype=np.float32),
id="three-boxes-axis-aligned",
),
pytest.param(
[
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
],
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
id="identical-boxes",
),
pytest.param(
[
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
np.array([[3, 3], [7, 3], [7, 3], [3, 3]], dtype=np.float32),
],
np.array([[0, 0], [10, 0], [10, 5], [0, 5]], dtype=np.float32),
id="degenerate-collinear",
),
],
)
def test_merge(
self, corners_list: list[np.ndarray], expected: np.ndarray | None
) -> None:
"""Produces correct merged OBB corners."""
result = _merge_obb_corners(corners_list)
assert result.shape == (4, 2)
if expected is not None:
assert np.allclose(result, expected, atol=0.5)
else:
assert result.dtype == np.float32
class TestMergeDetectionGroup:
"""_merge_detection_group"""
@pytest.mark.parametrize(
("detections", "expected_detections"),
[
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([1]),
),
],
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([1]),
),
id="single-passthrough",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
),
id="two-aabb-merge",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
),
Detections(
xyxy=np.array([[10, 10, 20, 20]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
),
],
Detections(
xyxy=np.array([[0, 0, 20, 20]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
),
id="three-aabb-merge",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
mask=np.array([[[True, False], [False, False]]], dtype=bool),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
mask=np.array([[[False, True], [False, False]]], dtype=bool),
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
mask=np.array([[[True, True], [False, False]]], dtype=bool),
),
id="two-aabb-with-mask",
),
pytest.param(
[
_make_obb_detections(
[
np.array(
[[0, 0], [10, 0], [10, 5], [0, 5]],
dtype=np.float32,
)
],
[0.9],
[0],
),
_make_obb_detections(
[
np.array(
[[2, 2], [12, 2], [12, 7], [2, 7]],
dtype=np.float32,
)
],
[0.7],
[0],
),
],
Detections(
xyxy=np.array([[0, 0, 12, 7]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
),
id="two-obb-axis-aligned",
),
pytest.param(
[
_make_obb_detections(
[_rotated_rect(50, 50, 40, 10, 45)], [0.9], [0]
),
_make_obb_detections(
[_rotated_rect(55, 55, 40, 10, 45)], [0.8], [0]
),
],
Detections(
xyxy=np.array([[32.32, 32.32, 72.68, 72.68]], dtype=np.float32),
confidence=np.array([0.85], dtype=np.float32),
class_id=np.array([0]),
),
id="two-obb-rotated",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([1]),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.5], dtype=np.float32),
class_id=np.array([2]),
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([1]),
),
id="winner-takes-class-id",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
tracker_id=np.array([42]),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.5], dtype=np.float32),
class_id=np.array([0]),
tracker_id=np.array([99]),
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
tracker_id=np.array([42]),
),
id="winner-takes-tracker-id",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
data={"class_name": np.array(["cat"])},
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.5], dtype=np.float32),
class_id=np.array([1]),
data={"class_name": np.array(["dog"])},
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
data={"class_name": np.array(["cat"])},
),
id="winner-takes-data",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=None,
class_id=np.array([0]),
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=None,
class_id=np.array([0]),
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=None,
class_id=np.array([0]),
),
id="no-confidence",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=None,
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=None,
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=None,
),
id="no-class-id",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
data={"score": np.array([1.5])},
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.5], dtype=np.float32),
class_id=np.array([0]),
data={"score": np.array([2.5])},
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
data={"score": np.array([1.5])},
),
id="custom-data-field-preserved",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
),
Detections(
xyxy=np.array([[5, 5, 5, 5]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
),
],
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
),
id="zero-area-box-in-group",
),
pytest.param(
[
Detections(
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
confidence=np.array([0.9], dtype=np.float32),
class_id=np.array([0]),
metadata={"source": "model_a"},
),
Detections(
xyxy=np.array([[5, 5, 15, 15]], dtype=np.float32),
confidence=np.array([0.7], dtype=np.float32),
class_id=np.array([0]),
metadata={"source": "model_a"},
),
],
Detections(
xyxy=np.array([[0, 0, 15, 15]], dtype=np.float32),
confidence=np.array([0.8], dtype=np.float32),
class_id=np.array([0]),
),
id="metadata-merge",
),
],
)
def test_merge(
self,
detections: list[Detections],
expected_detections: Detections,
) -> None:
"""Merges detection group correctly."""
result = _merge_detection_group(detections)
assert len(result) == 1
assert np.allclose(result.xyxy, expected_detections.xyxy, atol=0.5)
if expected_detections.confidence is not None:
assert np.allclose(
result.confidence, expected_detections.confidence, atol=1e-3
)
else:
assert result.confidence is None
if expected_detections.class_id is not None:
assert np.array_equal(result.class_id, expected_detections.class_id)
else:
assert result.class_id is None
if expected_detections.tracker_id is not None:
assert np.array_equal(result.tracker_id, expected_detections.tracker_id)
else:
assert result.tracker_id is None
if expected_detections.mask is not None:
assert np.array_equal(result.mask, expected_detections.mask)
else:
assert result.mask is None
for key, val in expected_detections.data.items():
assert np.array_equal(result.data[key], val)
if ORIENTED_BOX_COORDINATES in result.data:
corners = result.data[ORIENTED_BOX_COORDINATES]
assert np.allclose(result.xyxy, xyxyxyxy_to_xyxy(corners), atol=1e-5)
class TestDetectionsWithNMM:
"""NMM-specific behaviour tests for `Detections.with_nmm`."""
def test_obb_merged_xyxy_matches_winner_aabb(self) -> None:
"""OBB merge group: merged xyxy must equal winner's AABB, not union AABB.
@pytest.mark.parametrize(
(
"corners",
"confidence",
"class_ids",
"iou_threshold",
"class_agnostic",
"overlap_metric",
"expected_corners",
"expected_confidence",
"exception",
),
[
pytest.param(
[
[[10, 10], [50, 10], [50, 30], [10, 30]],
[[11, 11], [51, 11], [51, 31], [11, 31]],
],
[0.9, 0.85],
[0, 0],
0.5,
False,
OverlapMetric.IOU,
[[[10, 10], [51, 10], [51, 31], [10, 31]]],
[0.875],
DoesNotRaise(),
id="axis-aligned-merge",
),
pytest.param(
[
_rotated_rect(50, 50, 40, 10, 45).tolist(),
_rotated_rect(55, 55, 40, 10, 45).tolist(),
],
[0.9, 0.8],
[0, 0],
0.3,
False,
OverlapMetric.IOU,
[[[39.39, 32.32], [72.68, 65.61], [65.61, 72.68], [32.32, 39.39]]],
[0.85],
DoesNotRaise(),
id="rotated-45deg-merge",
),
pytest.param(
[
[[0, 0], [20, 0], [20, 10], [0, 10]],
[[5, 5], [25, 5], [25, 15], [5, 15]],
[[10, 0], [30, 0], [30, 10], [10, 10]],
],
[0.9, 0.8, 0.7],
[0, 0, 0],
0.2,
False,
OverlapMetric.IOU,
[[[0, 0], [30, 0], [30, 15], [0, 15]]],
[0.8],
DoesNotRaise(),
id="three-group-merge",
),
pytest.param(
[
[[10, 10], [50, 10], [50, 30], [10, 30]],
],
[0.9],
[0],
0.5,
False,
OverlapMetric.IOU,
[[[10, 10], [50, 10], [50, 30], [10, 30]]],
[0.9],
DoesNotRaise(),
id="single-passthrough",
),
pytest.param(
[
[[0, 0], [30, 0], [30, 20], [0, 20]],
[[5, 5], [35, 5], [35, 25], [5, 25]],
],
[0.9, 0.8],
[0, 1],
0.3,
True,
OverlapMetric.IOU,
[[[0, 0], [35, 0], [35, 25], [0, 25]]],
[0.85],
DoesNotRaise(),
id="class-agnostic",
),
pytest.param(
[
[[0, 0], [40, 0], [40, 30], [0, 30]],
[[10, 10], [30, 10], [30, 20], [10, 20]],
],
[0.9, 0.8],
[0, 0],
0.3,
False,
OverlapMetric.IOS,
[[[0, 0], [40, 0], [40, 30], [0, 30]]],
[0.885714],
DoesNotRaise(),
id="ios-metric",
),
pytest.param(
[
_rotated_rect(50, 50, 40, 15, 30).tolist(),
_rotated_rect(55, 50, 40, 15, -15).tolist(),
],
[0.9, 0.7],
[0, 0],
0.2,
False,
OverlapMetric.IOU,
[[[43.65, 20.99], [81.56, 42.88], [62.12, 76.56], [24.21, 54.68]]],
[0.813652],
DoesNotRaise(),
id="mixed-angle-merge",
),
pytest.param(
[
[[0, 0], [30, 0], [30, 20], [0, 20]],
[[5, 5], [35, 5], [35, 25], [5, 25]],
[[200, 200], [240, 200], [240, 220], [200, 220]],
[[205, 205], [245, 205], [245, 225], [205, 225]],
],
[0.9, 0.7, 0.85, 0.6],
[0, 0, 0, 0],
0.2,
False,
OverlapMetric.IOU,
[
[[0, 0], [35, 0], [35, 25], [0, 25]],
[[200, 200], [245, 200], [245, 225], [200, 225]],
],
[0.8, 0.725],
DoesNotRaise(),
id="two-separate-groups",
),
pytest.param(
[
[[0, 0], [30, 0], [30, 20], [0, 20]],
[[5, 10], [25, 10], [25, 10], [5, 10]],
],
[0.9, 0.7],
[0, 0],
0.01,
False,
OverlapMetric.IOU,
# A zero-area (collinear) OBB scores IoU 0 (see
# test_degenerate_boxes_score_zero), so it cannot group and the
# two detections are not merged.
[
[[0, 0], [30, 0], [30, 20], [0, 20]],
[[5, 10], [25, 10], [25, 10], [5, 10]],
],
[0.9, 0.7],
DoesNotRaise(),
id="degenerate-collinear-obb",
),
pytest.param(
None,
[0.9, 0.8],
[0, 0],
0.4,
False,
OverlapMetric.IOU,
None,
None,
pytest.raises(ValueError, match="corners must have shape"),
id="flat-n8-raises",
),
],
)
def test_obb_nmm_merge(
self,
corners: list[list[list[float]]] | None,
confidence: list[float],
class_ids: list[int],
iou_threshold: float,
class_agnostic: bool,
overlap_metric: OverlapMetric,
expected_corners: list[list[list[float]]] | None,
expected_confidence: list[float] | None,
exception: DoesNotRaise,
) -> None:
"""OBB NMM produces correct geometry and confidence."""
if corners is None:
xyxy = np.array(
[[0, 0, 30, 20], [5, 5, 35, 25]],
dtype=np.float32,
)
flat = np.array(
[
[0, 0, 30, 0, 30, 20, 0, 20],
[5, 5, 35, 5, 35, 25, 5, 25],
],
dtype=np.float32,
)
detections = Detections(
xyxy=xyxy,
confidence=np.array(confidence, dtype=np.float32),
class_id=np.array(class_ids),
data={ORIENTED_BOX_COORDINATES: flat},
)
else:
corner_arrays = [np.array(corner, dtype=np.float32) for corner in corners]
detections = _make_obb_detections(corner_arrays, confidence, class_ids)
Two near-identical OBBs merge into one group. The winner (score 0.9) occupies
[10, 10, 50, 30]; the lower-score box [20, 20, 60, 40] is offset. Union AABB
would be [10, 10, 60, 40]; fix must produce winner's AABB [10, 10, 50, 30].
"""
quad_winner = np.array(
[[10, 10], [50, 10], [50, 30], [10, 30]], dtype=np.float32
with exception:
result = detections.with_nmm(
threshold=iou_threshold,
class_agnostic=class_agnostic,
overlap_metric=overlap_metric,
)
assert expected_confidence is not None
assert expected_corners is not None
assert len(result) == len(expected_confidence)
for i, exp_c in enumerate(expected_confidence):
assert result.confidence[i] == pytest.approx(exp_c, abs=1e-3)
result_corners = result.data[ORIENTED_BOX_COORDINATES]
expected_corner_array = np.array(expected_corners, dtype=np.float32)
assert np.allclose(
result_corners,
expected_corner_array,
atol=0.5,
)
def test_obb_nmm_matches_aabb_for_axis_aligned(self) -> None:
"""Axis-aligned OBB NMM produces same envelope as AABB NMM."""
xyxy = np.array([[0, 0, 30, 20], [5, 5, 35, 25]], dtype=np.float32)
confidence = np.array([0.9, 0.5], dtype=np.float32)
class_id = np.array([0, 0])
aabb_detections = Detections(
xyxy=xyxy,
confidence=confidence,
class_id=class_id,
)
quad_other = np.array(
[[11, 11], [51, 11], [51, 31], [11, 31]], dtype=np.float32
)
detections = _make_obb_detections(
[quad_winner, quad_other], [0.9, 0.85], [0, 0]
obb_detections = _make_obb_detections(
[
np.array(
[[0, 0], [30, 0], [30, 20], [0, 20]],
dtype=np.float32,
),
np.array(
[[5, 5], [35, 5], [35, 25], [5, 25]],
dtype=np.float32,
),
],
confidence.tolist(),
class_id.tolist(),
)
result = detections.with_nmm(threshold=0.5)
aabb_result = aabb_detections.with_nmm(threshold=0.4)
obb_result = obb_detections.with_nmm(threshold=0.4)
assert len(aabb_result) == 1
assert len(obb_result) == 1
assert np.allclose(aabb_result.xyxy, obb_result.xyxy, atol=1e-4)
def test_staircase_obb_merge_within_union(self) -> None:
"""Diagonal staircase OBBs: merged AABB equals axis-aligned union."""
quads = [
np.array(
[[0, 0], [20, 0], [20, 20], [0, 20]],
dtype=np.float32,
),
np.array(
[[12, 12], [32, 12], [32, 32], [12, 32]],
dtype=np.float32,
),
np.array(
[[24, 24], [44, 24], [44, 44], [24, 44]],
dtype=np.float32,
),
]
detections = _make_obb_detections(quads, [0.7, 0.9, 0.8], [0, 0, 0])
result = detections.with_nmm(threshold=0.05)
assert len(result) == 1
expected_xyxy = np.array([[10.0, 10.0, 50.0, 30.0]], dtype=np.float32)
assert np.allclose(result.xyxy, expected_xyxy, atol=0.5)
assert np.allclose(result.xyxy, [[0.0, 0.0, 44.0, 44.0]], atol=0.5)
def test_obb_nmm_empty_detections(self) -> None:
"""Empty OBB detections return empty result."""
dets = Detections(
xyxy=np.empty((0, 4), dtype=np.float32),
confidence=np.array([], dtype=np.float32),
class_id=np.array([], dtype=int),
data={ORIENTED_BOX_COORDINATES: np.empty((0, 4, 2), dtype=np.float32)},
)
result = dets.with_nmm(threshold=0.5)
assert len(result) == 0
class TestDetectionsArea:
+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)
+38
View File
@@ -10,6 +10,7 @@ from supervision.detection.utils.boxes import (
denormalize_boxes,
move_boxes,
scale_boxes,
xyxyxyxy_to_xyxy,
)
@@ -232,3 +233,40 @@ def test_denormalize_boxes(
normalization_factor=normalization_factor,
)
assert np.allclose(result, expected_result)
@pytest.mark.parametrize(
("corners", "expected"),
[
pytest.param(
np.array([[[0, 0], [10, 0], [10, 5], [0, 5]]], dtype=np.float32),
np.array([[0, 0, 10, 5]], dtype=np.float32),
id="single-axis-aligned",
),
pytest.param(
np.array(
[
[[0, 0], [10, 0], [10, 5], [0, 5]],
[[5, 5], [15, 5], [15, 10], [5, 10]],
],
dtype=np.float32,
),
np.array([[0, 0, 10, 5], [5, 5, 15, 10]], dtype=np.float32),
id="batch-axis-aligned",
),
pytest.param(
np.array([[[5, 0], [10, 5], [5, 10], [0, 5]]], dtype=np.float32),
np.array([[0, 0, 10, 10]], dtype=np.float32),
id="rotated-diamond",
),
pytest.param(
np.empty((0, 4, 2), dtype=np.float32),
np.empty((0, 4), dtype=np.float32),
id="empty-input",
),
],
)
def test_xyxyxyxy_to_xyxy(corners: np.ndarray, expected: np.ndarray) -> None:
"""Converts OBB corners to axis-aligned bounding boxes."""
result = xyxyxyxy_to_xyxy(corners)
assert np.allclose(result, expected, atol=1e-5)
+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"),
[
+320 -27
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,
@@ -1162,34 +1163,30 @@ def test_box_iou_batch_symmetric_large(
@pytest.mark.parametrize(
"scale",
[
np.array([[10, 1]], dtype=np.float32), # x-dominant (wide box)
np.array([[1, 10]], dtype=np.float32), # y-dominant (tall box)
pytest.param(np.array([[10, 1]], dtype=np.float32), id="x-dominant"),
pytest.param(np.array([[1, 10]], dtype=np.float32), id="y-dominant"),
],
)
def test_oriented_box_iou_batch_is_invariant_to_non_square_scaling(
scale: np.ndarray,
) -> None:
"""IoU is stable when boxes are scaled uniformly along one axis.
"""IoU is invariant under per-axis (anisotropic) scaling.
Regression guard for the canvas x/y swap bug: before the fix, a 10x
x-scale produced an undersized height dimension, truncating the polygon
and yielding a different IoU. Tolerance reflects rasterization discretization.
An affine map multiplies every area — intersection and union alike — by the
same determinant, so exact polygon IoU is unchanged. Equality is exact
because the computation is rasterization-free; the analytic IoU of these two
rectangles is 0.25.
"""
boxes_true = np.array([[[1, 0], [0, 1], [3, 4], [4, 3]]], dtype=np.float32)
boxes_detection = np.array([[[1, 1], [2, 0], [4, 2], [3, 3]]], dtype=np.float32)
baseline_iou = oriented_box_iou_batch(boxes_true, boxes_detection)
scaled_iou = oriented_box_iou_batch(
boxes_true * scale,
boxes_detection * scale,
)
scaled_iou = oriented_box_iou_batch(boxes_true * scale, boxes_detection * scale)
assert baseline_iou.shape == (1, 1)
assert scaled_iou.shape == (1, 1)
assert baseline_iou[0, 0] > 0.35
# rtol=0.03, atol=0.02: rasterization discretization introduces small
# coordinate-dependent error; exact equality is not achievable via pixel IoU.
assert np.allclose(scaled_iou, baseline_iou, rtol=0.03, atol=0.02)
assert baseline_iou[0, 0] == pytest.approx(0.25)
assert np.allclose(scaled_iou, baseline_iou, rtol=1e-5, atol=1e-7)
class TestOrientedBoxIouBatch:
@@ -1198,20 +1195,19 @@ class TestOrientedBoxIouBatch:
@pytest.mark.parametrize(
("scale", "offset"),
[
(80.0, 0.0), # HD/4K-scale coords — exercises canvas cap
(1.0, 3000.0), # far-corner coords — exercises canvas anchoring
(80.0, 3000.0), # both — large coordinates far from origin
pytest.param(80.0, 0.0, id="large-scale"),
pytest.param(1.0, 3000.0, id="far-from-origin"),
pytest.param(80.0, 3000.0, id="large-and-far"),
],
)
def test_is_invariant_to_canvas_transforms(
self, scale: float, offset: float
) -> None:
"""IoU matches the small-coordinate baseline regardless of where in the
frame the boxes sit or how large their coordinates are.
"""IoU is invariant under uniform scaling and translation.
The function must internally translate-and-scale boxes onto a bounded
rasterization canvas (IoU is invariant under both), so memory stays
roughly constant across input resolutions and box positions."""
Both are affine maps, so exact polygon IoU matches the small-coordinate
baseline exactly — free of the pixel-quantization error that the prior
rasterization-based implementation exhibited."""
boxes_true = _rotated_rect(50, 50, 40, 20, 30)[None]
boxes_detection = _rotated_rect(52, 48, 40, 20, 35)[None]
baseline = oriented_box_iou_batch(boxes_true, boxes_detection)
@@ -1224,20 +1220,159 @@ class TestOrientedBoxIouBatch:
assert baseline.shape == (1, 1)
assert transformed.shape == (1, 1)
assert baseline[0, 0] > 0.4
assert np.allclose(transformed, baseline, rtol=0.03, atol=0.02)
assert np.allclose(transformed, baseline, rtol=1e-5, atol=1e-7)
def test_supports_overlap_metric(self) -> None:
"""`overlap_metric=IOS` divides by the smaller area, so a small box fully
contained in a larger one scores 1.0, while IoU is smaller."""
contained in a larger one scores exactly 1.0, while IoU is the area ratio."""
small = _rotated_rect(50, 50, 20, 20, 0)[None]
large = _rotated_rect(50, 50, 60, 60, 0)[None]
iou = oriented_box_iou_batch(small, large, OverlapMetric.IOU)[0, 0]
ios = oriented_box_iou_batch(small, large, OverlapMetric.IOS)[0, 0]
# Small (~20x20) inside large (~60x60): IoU ≈ 400/3600 ≈ 0.11, IoS = 1.0
assert iou < 0.2
assert ios > 0.98
# 20x20 inside 60x60: IoU = 400 / 3600 = 1/9, IoS = 400 / 400 = 1.0.
assert iou == pytest.approx(1 / 9, rel=1e-3)
assert ios == pytest.approx(1.0)
def test_half_overlap_matches_analytic_value(self) -> None:
"""Two unit-height 2x2 squares offset by 1 share half their area: IoU=1/3."""
boxes_true = np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32)
boxes_detection = np.array([[[1, 0], [3, 0], [3, 2], [1, 2]]], dtype=np.float32)
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
# intersection = 2, union = 4 + 4 - 2 = 6, IoU = 2/6 = 1/3.
assert iou[0, 0] == pytest.approx(1 / 3)
def test_disjoint_boxes_score_zero(self) -> None:
"""Boxes whose envelopes do not overlap are pruned by the gate to 0.0."""
boxes_true = _rotated_rect(10, 10, 8, 4, 20)[None]
boxes_detection = _rotated_rect(500, 500, 8, 4, 70)[None]
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
assert iou[0, 0] == 0.0
@pytest.mark.parametrize(
"boxes",
[
pytest.param(
np.stack([_rotated_rect(10, 10, 8, 4, 20)]),
id="n=1",
),
pytest.param(
np.stack(
[_rotated_rect(10, 10, 8, 4, 20), _rotated_rect(40, 40, 8, 4, 70)]
),
id="n=2",
),
],
)
def test_self_comparison_is_symmetric_with_unit_diagonal(
self, boxes: np.ndarray
) -> None:
"""Comparing a set with itself yields a symmetric matrix and a 1.0 diagonal."""
iou = oriented_box_iou_batch(boxes, boxes)
assert np.allclose(iou, iou.T)
for i in range(len(boxes)):
assert iou[i, i] == pytest.approx(1.0)
def test_envelope_overlap_without_polygon_overlap_scores_zero(self) -> None:
"""Parallel rotated bars share an envelope but not a body, so they score 0.
Exercises the path where a pair passes the axis-aligned gate yet has no
exact polygon intersection.
"""
boxes_true = _rotated_rect(50, 50, 100, 4, 45)[None]
boxes_detection = _rotated_rect(72, 28, 100, 4, 45)[None]
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
assert iou[0, 0] == 0.0
def test_rejects_unsupported_overlap_metric(self) -> None:
"""An overlap metric other than IOU or IOS raises ValueError."""
boxes = _rotated_rect(50, 50, 20, 20, 0)[None]
with pytest.raises(ValueError, match="is not supported"):
oriented_box_iou_batch(boxes, boxes, "invalid") # type: ignore[arg-type]
@pytest.mark.parametrize(
("boxes_a", "boxes_b", "expected"),
[
pytest.param(
np.array([[[5, 5], [5, 5], [5, 5], [5, 5]]], dtype=np.float32),
np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32),
0.0,
id="collapsed-box-vs-normal",
),
pytest.param(
np.array([[[0, 0], [1, 0], [2, 0], [3, 0]]], dtype=np.float32),
np.array([[[0, 0], [2, 0], [2, 2], [0, 2]]], dtype=np.float32),
0.0,
id="collinear-box-vs-normal",
),
pytest.param(
np.array([[[5, 5], [5, 5], [5, 5], [5, 5]]], dtype=np.float32),
np.array([[[5, 5], [5, 5], [5, 5], [5, 5]]], dtype=np.float32),
0.0,
id="self-comparison-zero-area",
),
],
)
def test_degenerate_boxes_score_zero(
self,
boxes_a: np.ndarray,
boxes_b: np.ndarray,
expected: float,
) -> None:
"""Degenerate boxes (zero-area collapsed or collinear) always score 0.
Note: self-comparison of a zero-area box returns 0.0, not 1.0 — this
diverges from ``box_iou_batch`` which returns 1.0 for degenerate AABB
self-comparison.
"""
iou = oriented_box_iou_batch(boxes_a, boxes_b)
assert iou[0, 0] == expected
@pytest.mark.parametrize(
("n", "m"),
[
pytest.param(0, 3, id="zero-true"),
pytest.param(3, 0, id="zero-detection"),
pytest.param(0, 0, id="both-zero"),
],
)
def test_empty_input_returns_correct_shape(self, n: int, m: int) -> None:
"""Empty inputs trigger the early-return path; result is zero-filled."""
boxes_true = np.empty((n, 4, 2), dtype=np.float32)
boxes_detection = np.empty((m, 4, 2), dtype=np.float32)
iou = oriented_box_iou_batch(boxes_true, boxes_detection)
assert iou.shape == (n, m)
assert iou.dtype == np.float64
assert np.all(iou == 0.0)
@pytest.mark.parametrize(
("bad_shape", "match"),
[
pytest.param((2, 3, 2), r"expected \(N, 4, 2\)", id="3d-wrong-inner-dims"),
pytest.param((2, 5), r"expected \(N, 8\)", id="2d-wrong-columns"),
pytest.param((8,), r"must be 2-D", id="1d-input"),
],
)
def test_invalid_shape_raises_value_error(
self, bad_shape: tuple, match: str
) -> None:
"""Inputs with wrong shape raise ValueError before any computation."""
boxes_bad = np.zeros(bad_shape, dtype=np.float32)
boxes_ok = np.zeros((2, 4, 2), dtype=np.float32)
with pytest.raises(ValueError, match=match):
oriented_box_iou_batch(boxes_bad, boxes_ok)
class TestOrientedBoxNonMaxSuppression:
@@ -1446,3 +1581,161 @@ class TestOrientedBoxNonMaxMerge:
sorted_groups = sorted(sorted(g) for g in groups)
assert sorted_groups == [[0, 1], [2]]
def _naive_mask_iou(
masks_true: np.ndarray,
masks_detection: np.ndarray,
overlap_metric: OverlapMetric = OverlapMetric.IOU,
) -> np.ndarray:
"""Reference IoU that materialises the (N, M, H, W) overlap explicitly."""
intersection = np.logical_and(masks_true[:, None], masks_detection).sum(axis=(2, 3))
area_true = masks_true.sum(axis=(1, 2))
area_detection = masks_detection.sum(axis=(1, 2))
if overlap_metric == OverlapMetric.IOU:
denominator = area_true[:, None] + area_detection - intersection
else:
denominator = np.minimum(area_true[:, None], area_detection)
return np.divide(
intersection,
denominator,
out=np.zeros((len(masks_true), len(masks_detection)), dtype=float),
where=denominator != 0,
)
class TestMaskIouBatch:
"""`mask_iou_batch` (matmul intersection) matches the naive boolean reference."""
@pytest.mark.parametrize(
"overlap_metric",
[
pytest.param(OverlapMetric.IOU, id="iou"),
pytest.param(OverlapMetric.IOS, id="ios"),
],
)
@pytest.mark.parametrize(
("n_true", "n_detection", "height", "width"),
[
pytest.param(1, 1, 20, 24, id="single"),
pytest.param(7, 7, 32, 40, id="several-square"),
pytest.param(5, 9, 28, 36, id="rectangular"),
],
)
def test_matches_naive_reference_on_random_masks(
self,
n_true: int,
n_detection: int,
height: int,
width: int,
overlap_metric: OverlapMetric,
) -> None:
"""Random masks give the same matrix as the explicit (N, M, H, W) reference."""
rng = np.random.default_rng(0)
masks_true = rng.random((n_true, height, width)) > 0.5
masks_detection = rng.random((n_detection, height, width)) > 0.5
result = mask_iou_batch(masks_true, masks_detection, overlap_metric)
expected = _naive_mask_iou(masks_true, masks_detection, overlap_metric)
np.testing.assert_allclose(result, expected)
def test_chunking_matches_single_pass(self) -> None:
"""A tiny memory_limit forces chunking yet returns the same matrix."""
rng = np.random.default_rng(1)
masks_true = rng.random((6, 16, 16)) > 0.5
masks_detection = rng.random((4, 16, 16)) > 0.5
single_pass = mask_iou_batch(masks_true, masks_detection)
chunked = mask_iou_batch(masks_true, masks_detection, memory_limit=0)
np.testing.assert_allclose(single_pass, chunked, rtol=1e-6)
# empty masks_true under a tiny limit must not hit np.vstack([])
empty = mask_iou_batch(
np.zeros((0, 16, 16), dtype=bool), masks_detection, memory_limit=0
)
assert empty.shape == (0, 4)
def test_mismatched_spatial_shape_raises(self) -> None:
"""Equal pixel counts but different (H, W) must raise, not silently mismatch."""
masks_true = np.zeros((1, 4, 9), dtype=bool)
masks_detection = np.zeros((1, 6, 6), dtype=bool) # 36 px, different (H, W)
with pytest.raises(ValueError, match="must share the same"):
mask_iou_batch(masks_true, masks_detection)
def test_invalid_ndim_raises(self) -> None:
"""2D and 4D inputs must raise ValueError before reaching shape[1:] check."""
mask_3d = np.zeros((1, 10, 10), dtype=bool)
with pytest.raises(ValueError, match="3D"):
mask_iou_batch(np.zeros((10, 10), dtype=bool), mask_3d)
with pytest.raises(ValueError, match="3D"):
mask_iou_batch(mask_3d, np.zeros((10, 10), dtype=bool))
with pytest.raises(ValueError, match="3D"):
mask_iou_batch(np.zeros((1, 10, 10, 1), dtype=bool), mask_3d)
def test_identical_disjoint_and_empty(self) -> None:
"""Identical/all-True → 1.0; all-False/disjoint → 0.0; empty keeps shape."""
mask = np.zeros((1, 10, 10), dtype=bool)
mask[0, 2:6, 2:6] = True
far = np.zeros((1, 10, 10), dtype=bool)
far[0, 7:9, 7:9] = True
assert mask_iou_batch(mask, mask)[0, 0] == pytest.approx(1.0)
assert mask_iou_batch(mask, far)[0, 0] == 0.0
assert mask_iou_batch(np.zeros((0, 10, 10), dtype=bool), mask).shape == (0, 1)
# all-True: denominator is non-zero, result must be exactly 1.0
all_true = np.ones((2, 5, 5), dtype=bool)
np.testing.assert_allclose(mask_iou_batch(all_true, all_true), np.ones((2, 2)))
# all-False: denominator is zero; result must be 0.0 (not NaN)
all_false = np.zeros((2, 5, 5), dtype=bool)
np.testing.assert_array_equal(
mask_iou_batch(all_false, all_false), np.zeros((2, 2))
)
def test_matmul_emits_no_runtime_warnings(
self, recwarn: pytest.WarningsChecker
) -> None:
"""mask_iou_batch on bool input must not emit any RuntimeWarning."""
rng = np.random.default_rng(2)
masks_true = rng.random((7, 32, 40)) > 0.5
masks_detection = rng.random((5, 32, 40)) > 0.5
mask_iou_batch(masks_true, masks_detection)
runtime = [w for w in recwarn.list if issubclass(w.category, RuntimeWarning)]
assert not runtime, (
f"Unexpected RuntimeWarnings: {[str(w.message) for w in runtime]}"
)
def test_float64_promotion_for_large_float32_masks(self) -> None:
"""float32 masks with pixel count > 2**24 must give exact IoU via float64."""
# 1x(2**24+1) pixels: float32 area sum rounds, float64 area sum is exact.
# Without CRIT-2 fix, IoU would exceed 1.0 due to dtype mismatch.
mask = np.ones((1, 1, 2**24 + 1), dtype=np.float32)
result = mask_iou_batch(mask, mask)
assert result[0, 0] == pytest.approx(1.0)
def test_compact_mask_mixed_input_gives_same_result(self) -> None:
"""CompactMask + dense mixed dispatch must match dense + dense."""
from supervision.detection.compact_mask import CompactMask
from supervision.detection.utils.converters import mask_to_xyxy
rng = np.random.default_rng(3)
masks = (rng.random((4, 20, 24)) > 0.3).astype(bool)
xyxy = mask_to_xyxy(masks)
cm = CompactMask.from_dense(masks, xyxy, image_shape=(20, 24))
dense_vs_dense = mask_iou_batch(masks, masks)
np.testing.assert_allclose(mask_iou_batch(cm, masks), dense_vs_dense, rtol=1e-5)
np.testing.assert_allclose(mask_iou_batch(masks, cm), dense_vs_dense, rtol=1e-5)
def test_detection_exceeds_limit_warns_and_completes(self) -> None:
"""detection > memory_limit emits UserWarning; result shape is still correct."""
rng = np.random.default_rng(4)
masks_true = rng.random((3, 32, 32)) > 0.5
# 500 x 1024 px x 4 bytes ~2 MB > memory_limit=1 MB triggers OOM warning.
masks_detection = rng.random((500, 32, 32)) > 0.5
with pytest.warns(UserWarning, match="exceed"):
result = mask_iou_batch(masks_true, masks_detection, memory_limit=1)
assert result.shape == (3, 500)
+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
+182 -99
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,51 +960,45 @@ toml = [
[[package]]
name = "cryptography"
version = "46.0.7"
version = "48.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version < '3.10' and platform_machine != 'arm64' and platform_python_implementation != 'PyPy') or (platform_python_implementation != 'PyPy' and sys_platform != 'darwin')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform != 'darwin')" },
{ name = "cffi", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and platform_python_implementation != 'PyPy') or (python_full_version > '3.9' and platform_python_implementation != 'PyPy' and sys_platform != 'darwin')" },
{ name = "typing-extensions", marker = "(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.11' and sys_platform != 'darwin')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
sdist = { url = "https://files.pythonhosted.org/packages/12/45/870e7f4bef50e5f53b9f51d4428aee5290eedf58ba443f16b1ebb7ab8e66/cryptography-48.0.1.tar.gz", hash = "sha256:266f4ee051abb2f725b74ef8072b521ce1feacf685a3364fa6a6b45548db791a", size = 832989, upload-time = "2026-06-09T22:32:31.8Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
{ url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
{ url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
{ url = "https://files.pythonhosted.org/packages/d5/85/6379d42181bfc713094f081360fc5784d6c816b599d45e7f082502d173ce/cryptography-48.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:32143b24adb918f078134e1e230f1eb8cc04886b92c28b5f0041aaf3e5699225", size = 4696243, upload-time = "2026-06-09T22:32:33.446Z" },
{ url = "https://files.pythonhosted.org/packages/9c/87/c85d147b53323c7eb4d850920c8901377323c2a0ff8d79c262d4fee89aa2/cryptography-48.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0d27a5696721ef7a672b8c810f6aded391058e0b9486e63e6d93baf765da691", size = 4713235, upload-time = "2026-06-09T22:31:40.141Z" },
{ url = "https://files.pythonhosted.org/packages/79/58/67cbf8cf1ee7c54b439ca07bbecf8362c07afc11a3724fea70f745784add/cryptography-48.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eb86ce1af36fe65041b6db9a8bb064ee621a7e5fded0f80d475ec243477cd242", size = 4702323, upload-time = "2026-06-09T22:31:42.191Z" },
{ url = "https://files.pythonhosted.org/packages/d2/bb/cc4b78784f97efc8c5874c2a9743708d172be6663024b34a0467885ae0c8/cryptography-48.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3752f2dbc8f07a30aad2932c986cea495b03bb554887828225da104f732852b6", size = 4746137, upload-time = "2026-06-09T22:31:31.01Z" },
{ url = "https://files.pythonhosted.org/packages/1f/52/0c44de3f5267f8fbe8e835138017522a333436166e406f0db9b9e6e3033f/cryptography-48.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:bd81490cd5801d755cf97bb68ac191f14b708470b1c7cf4580f669b9c9264cd8", size = 4333867, upload-time = "2026-06-09T22:32:28.096Z" },
{ url = "https://files.pythonhosted.org/packages/9a/2e/772d7adbfa931537bc401640b7cac9976bff689bda187833e5d63b428e49/cryptography-48.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:66fd0771e7b9c6dcd44cf1120690d2338d16d72795cf40cae2786a39eba65429", size = 4701805, upload-time = "2026-06-09T22:31:38.284Z" },
{ url = "https://files.pythonhosted.org/packages/9f/13/8b765e2e12b07c74941caadb9d1c8fdc006c4dfbf2b8f2d610519758954d/cryptography-48.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:0ee6ea481db1ab889cba043ec1eda17bb9c1ea79db6722f779c3667f9f70322f", size = 4745488, upload-time = "2026-06-09T22:32:30.07Z" },
{ url = "https://files.pythonhosted.org/packages/2e/aa/48972bce55049b32a94f4907eda4d75fa385aad8a39506cc2fc72196ecf0/cryptography-48.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f2ceef93cb096aa3c4cc4b5c94ca6131f9196d28c64d6111533402a9b2054d41", size = 4830256, upload-time = "2026-06-09T22:31:43.868Z" },
{ url = "https://files.pythonhosted.org/packages/47/a2/e5079a032fb85cf6005046ca92bbd78b0c82dad2b5751ab8c311659da06f/cryptography-48.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bd3f92d76217892b15df84ca256c2c113d386fdda7a7d8691aeeced976507c6", size = 4979117, upload-time = "2026-06-09T22:31:05.845Z" },
{ url = "https://files.pythonhosted.org/packages/8a/13/6476736484b94041110c8340a3eb63962fea4975baea8cb4a512adb44d4d/cryptography-48.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5d30989c6917b478b5817902e85fddaea2261efa8648383d965381ccb9e1ac4", size = 4689201, upload-time = "2026-06-09T22:31:09.745Z" },
{ url = "https://files.pythonhosted.org/packages/79/62/65a87f34d2a431546e2509b85d55e8c90df86d668f6731da64d538512ac2/cryptography-48.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:df637c05205ea7c1d7fbcbe54bbfea648a52951155f997af13d895d0ecc96991", size = 4702822, upload-time = "2026-06-09T22:32:24.409Z" },
{ url = "https://files.pythonhosted.org/packages/7f/59/810b5204b0a9b10f4b6bc06bd551a8b609803cd931806bc3b71884b225e5/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:869c3b8a53bfe27147832df48b32adadf558249d50e76cb3769d40e986b13265", size = 4694875, upload-time = "2026-06-09T22:32:08.737Z" },
{ url = "https://files.pythonhosted.org/packages/03/8c/3be6cb4da181f5bb6c19cf560c2359d60644a6b5fc5b57854e528f47b296/cryptography-48.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d069066deead00ac7f090be101be875a06855908f7ec004c27b8fefb4acfb411", size = 4737082, upload-time = "2026-06-09T22:32:22.66Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f6/d5f60a5a1434dbfd949e227fd0065d194c7e6b6ac526b17f5c06152b8231/cryptography-48.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:09f73a725d582cef64b91281a322cd798d14a33b2b6f2b7ad9531dc336d84c02", size = 4325328, upload-time = "2026-06-09T22:32:10.777Z" },
{ url = "https://files.pythonhosted.org/packages/17/b7/ba75dd947a14b6ad907b01ae8f6b5b348cdd1b48142f0063dee9e20c1d9d/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:15254441469dd6bf027039453288e2072124f8b6603563f5d759e1c9b69273fa", size = 4694530, upload-time = "2026-06-09T22:31:53.105Z" },
{ url = "https://files.pythonhosted.org/packages/9f/04/618f4115cfc0add0838c82507aa18a346089428da8653ad38b3ff36f5cb3/cryptography-48.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b4e391975f038e66432328639620a4aff2d307513b004f1ca06d6225bced815c", size = 4736660, upload-time = "2026-06-09T22:32:12.676Z" },
{ url = "https://files.pythonhosted.org/packages/24/9c/06e062462a0de28a3b3911322eded4c16deb9f441b1b7575d3dc59488ab5/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42fcd8e26fe555d9b3577a135f5091fefa0aa4e99129c23fb56787a1bd4ada72", size = 4822229, upload-time = "2026-06-09T22:31:17.062Z" },
{ url = "https://files.pythonhosted.org/packages/f4/be/0561971eaaee4b8a0e7d5113c536921063ab91aaf23278ac374eaf881e11/cryptography-48.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1400da5e32a43253392277eac7490a60e497d810a63dd5608d71bbd7af507c9", size = 4966364, upload-time = "2026-06-09T22:31:32.842Z" },
{ url = "https://files.pythonhosted.org/packages/b0/d9/45f309a7e4e5f3f8f121d6d3be9e94024a7726ec598d6e08ae04edb2f04d/cryptography-48.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48fe40804d4caa2288f24e70ca8c64c42dd826da0ad7e4f1b41b2128d679e6c8", size = 4690196, upload-time = "2026-06-09T22:31:54.74Z" },
{ url = "https://files.pythonhosted.org/packages/5f/9f/a1bc8bcc798811b8527eb374bbccf30a3f3e806829d967118222bf1125eb/cryptography-48.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:86be3b1b0b6bf09482fb50a979c508d2950ed95f5621ec77f4e385962006b83a", size = 4696782, upload-time = "2026-06-09T22:31:45.615Z" },
{ url = "https://files.pythonhosted.org/packages/66/c2/81a4fb4e4373c500bb526bc337ac5719dd31dd15b970b84a238168c6aa08/cryptography-48.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4ab0a343c807bbcd90c971cd1ecf072937cd01847a9e002bef88fb47ac6be577", size = 4696618, upload-time = "2026-06-09T22:31:11.564Z" },
{ url = "https://files.pythonhosted.org/packages/78/13/fba657f958d2af66ea959a4ba01212632089249d34af1ae48054136344d7/cryptography-48.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:88c852a0ae366e262e5a1744b685e6a433dc8788dd2a277e418bf4904203609d", size = 4731873, upload-time = "2026-06-09T22:31:22.253Z" },
{ url = "https://files.pythonhosted.org/packages/4c/4c/9a964756d24a26b3e34dfcb16f961b89838786e6700b635b0d1e3adff4b6/cryptography-48.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:43c5835e2cb98c8733d86f57d6fc879b613f5c3478607281c3e36daffc6dd8a6", size = 4330804, upload-time = "2026-06-09T22:31:36.56Z" },
{ url = "https://files.pythonhosted.org/packages/4b/0f/a10f3a6eb12950a10e3a874070283aa2dd5875b2bfd15fad8a3e17b3f13e/cryptography-48.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:fe0180af5bf9236518a087e35bf2d9a347d5f5f51e63c579d683ddff424e3d46", size = 4696217, upload-time = "2026-06-09T22:31:13.351Z" },
{ url = "https://files.pythonhosted.org/packages/68/ab/8aaa12e4516ec4464033ab79b6f3b592bd5a92102467c4ace8a0d970203f/cryptography-48.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b74ca3b8e5ecdd833bf6a002ca41b4793bb27fb8f1c06ffaf2643c9e9140e31b", size = 4731388, upload-time = "2026-06-09T22:32:04.019Z" },
{ url = "https://files.pythonhosted.org/packages/1b/24/50027ea4dca85ec1f40688f3c24fb32ccacd520583c9592c3cc95628e6fb/cryptography-48.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2c37f2461406063b417837f5f3daab668652acd82423efcd7f0a9f04be972de1", size = 4824186, upload-time = "2026-06-09T22:32:18.707Z" },
{ url = "https://files.pythonhosted.org/packages/52/41/04cb5eb17085ade6f50cc611fb657df6a0f5885350de8764ece89c050197/cryptography-48.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:86fe77abb1bd87afb251d4d02ada7ecf53a32cee9b67d976abb2e45a13297475", size = 4964539, upload-time = "2026-06-09T22:31:18.793Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/3f43451b4f858bfceaaaffc649e6e787e8d4fb332a1d443af39ab02cc8f1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:735824ec41b7f74a7c45fb1591349333e4c696cb6c044e5f46356e560143e4cd", size = 4641226, upload-time = "2026-06-09T22:31:02.532Z" },
{ url = "https://files.pythonhosted.org/packages/73/4e/855584c2c23b09e4ce2d3b9c30e983e679cd60b068c513c6bbdb91e11782/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:92a46e1d638daa264ba2971c0b0489c9409787943efae4d60ffda3d091ef832c", size = 4668958, upload-time = "2026-06-09T22:32:06.213Z" },
{ url = "https://files.pythonhosted.org/packages/42/3b/d35750e41d803d1e516fd6d6011f065424924da7af1748cef4cc9cb3ede1/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7e234ac052af99f2700826a5c29ea99d9c1b1f80341cde62d11c8154dc8e0bd9", size = 4640793, upload-time = "2026-06-09T22:32:26.331Z" },
{ url = "https://files.pythonhosted.org/packages/ca/aa/cdb7181fe865285e87e96825aaab239400f1de0c3bfba9bd9769b79f1a92/cryptography-48.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:33842cf0888951cef5bc7ac724ab844a42044c1727b967b7f8997289a0464f92", size = 4668505, upload-time = "2026-06-09T22:31:27.534Z" },
]
[[package]]
@@ -1118,8 +1124,10 @@ version = "3.18.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" }
wheels = [
@@ -1323,8 +1331,10 @@ version = "2.6.15"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
wheels = [
@@ -1425,8 +1435,10 @@ version = "8.18.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
@@ -1550,7 +1562,7 @@ name = "jaraco-classes"
version = "3.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
{ name = "more-itertools", marker = "(python_full_version > '3.9' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" }
wheels = [
@@ -1562,7 +1574,7 @@ name = "jaraco-context"
version = "6.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-tarfile", marker = "python_full_version < '3.12'" },
{ name = "backports-tarfile", marker = "(python_full_version > '3.9' and python_full_version < '3.12' and platform_machine != 'arm64') or (python_full_version > '3.9' and python_full_version < '3.12' and sys_platform != 'darwin') or (python_full_version < '3.12' and platform_machine == 'arm64' and sys_platform == 'darwin')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/ad/f3777b81bf0b6e7bc7514a1656d3e637b2e8e15fab2ce3235730b3e7a4e6/jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3", size = 13912, upload-time = "2024-08-20T03:39:27.358Z" }
wheels = [
@@ -1574,7 +1586,7 @@ name = "jaraco-functools"
version = "4.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "more-itertools" },
{ name = "more-itertools", marker = "(python_full_version > '3.9' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/49/1c/831faaaa0f090b711c355c6d8b2abf277c72133aab472b6932b03322294c/jaraco_functools-4.2.1.tar.gz", hash = "sha256:be634abfccabce56fa3053f8c7ebe37b682683a4ee7793670ced17bab0087353", size = 19661, upload-time = "2025-06-21T19:22:03.201Z" }
wheels = [
@@ -1779,7 +1791,7 @@ wheels = [
[[package]]
name = "jupyterlab"
version = "4.5.7"
version = "4.5.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "async-lru" },
@@ -1798,9 +1810,9 @@ dependencies = [
{ name = "tornado" },
{ name = "traitlets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/2b/22/8440ec827762146e7cdecf04335bd348795899d29dc6ae82238707353a2c/jupyterlab-4.5.7.tar.gz", hash = "sha256:55a9822c4754da305f41e113452c68383e214dcf96de760146af89ce5d5117b0", size = 23992763, upload-time = "2026-04-29T16:43:51.328Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e8/52/a8d4895bef501ffeb6af448e8bf7079541c7772978211963aa653518c2d9/jupyterlab-4.5.9.tar.gz", hash = "sha256:dd79a073fecae7a39066ea99e4627ed6c76269ac926e95a810e1e1df6358d865", size = 23994445, upload-time = "2026-06-17T15:42:16.406Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/aa/537b8f7d80e799af19af35fb3ddfc970b951088a13c57dd9387dcfbb7f61/jupyterlab-4.5.7-py3-none-any.whl", hash = "sha256:fba4cb0e2c44a52859669d8c98b45de029d5e515f8407bf8534d2a8fc5f0964d", size = 12450123, upload-time = "2026-04-29T16:43:46.639Z" },
{ url = "https://files.pythonhosted.org/packages/c6/bb/2f9b425062416fba58f580c9b89c3b07277ccdf0a292501fedbca8ea00ea/jupyterlab-4.5.9-py3-none-any.whl", hash = "sha256:5ff0f908e8ac0afbed32b106fdef360f101c0a6654d1bf4a81e98a293ae1b336", size = 12449803, upload-time = "2026-06-17T15:42:12.18Z" },
]
[[package]]
@@ -1857,18 +1869,49 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/f1/82ea8e783433707cafd9790099a2d19f113c22f32a31c8bb5abdc7a61dbb/jupytext-1.17.2-py3-none-any.whl", hash = "sha256:4f85dc43bb6a24b75491c5c434001ad5ef563932f68f15dd3e1c8ce12a4a426b", size = 164401, upload-time = "2025-06-01T21:31:46.319Z" },
]
[[package]]
name = "keyring"
version = "21.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "pywin32-ctypes", marker = "python_full_version <= '3.9' and sys_platform == 'win32'" },
{ name = "secretstorage", version = "2.2.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version <= '3.9' and sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/b2/66cab0195a41415567ccfbf0e02c3ecc7f423729ca0c385819afbcd8e256/keyring-21.0.0.tar.gz", hash = "sha256:5f5f92327b6c7432bebc18a1b60cb3797d99b08db1f5b919b8187c37a01f1ccc", size = 53474, upload-time = "2019-12-24T20:01:38.041Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/03/4f57f7b28bfd21a0dc05aec33e012fbaa06cff56d8af63bf2f1451a12ead/keyring-21.0.0-py2.py3-none-any.whl", hash = "sha256:ad84f7fe26ab51731f089eaf1c44ebf4c5fae323653c908888a3a6212ad0bbe7", size = 31095, upload-time = "2019-12-24T20:01:35.715Z" },
]
[[package]]
name = "keyring"
version = "25.6.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12' and sys_platform == 'darwin'",
"python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version == '3.11.*' and sys_platform == 'darwin'",
"python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version == '3.10.*' and sys_platform == 'darwin'",
"python_full_version == '3.10.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version == '3.10.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.10.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.12'" },
{ name = "jaraco-classes" },
{ name = "jaraco-context" },
{ name = "jaraco-functools" },
{ name = "jeepney", marker = "sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "sys_platform == 'win32'" },
{ name = "secretstorage", marker = "sys_platform == 'linux'" },
{ name = "importlib-metadata", marker = "(python_full_version > '3.9' and python_full_version < '3.12' and platform_machine != 'arm64') or (python_full_version > '3.9' and python_full_version < '3.12' and sys_platform != 'darwin') or (python_full_version < '3.12' and platform_machine == 'arm64' and sys_platform == 'darwin')" },
{ name = "jaraco-classes", marker = "(python_full_version > '3.9' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
{ name = "jaraco-context", marker = "(python_full_version > '3.9' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
{ name = "jaraco-functools", marker = "(python_full_version > '3.9' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
{ name = "jeepney", marker = "python_full_version > '3.9' and sys_platform == 'linux'" },
{ name = "pywin32-ctypes", marker = "python_full_version > '3.9' and sys_platform == 'win32'" },
{ name = "secretstorage", version = "3.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version > '3.9' and sys_platform == 'linux'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/09/d904a6e96f76ff214be59e7aa6ef7190008f52a0ab6689760a98de0bf37d/keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66", size = 62750, upload-time = "2024-12-25T15:26:45.782Z" }
wheels = [
@@ -1881,8 +1924,10 @@ version = "1.4.7"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
sdist = { url = "https://files.pythonhosted.org/packages/85/4d/2255e1c76304cbd60b48cee302b66d1dde4468dc5b1160e4b7cb43778f2a/kiwisolver-1.4.7.tar.gz", hash = "sha256:9893ff81bd7107f7b685d3017cc6583daadb4fc26e4a888350df530e41980a60", size = 97286, upload-time = "2024-09-04T09:39:44.302Z" }
wheels = [
@@ -2176,8 +2221,10 @@ version = "3.9.4"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "contourpy", version = "1.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
@@ -2691,8 +2738,10 @@ version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" }
wheels = [
@@ -3154,8 +3203,10 @@ version = "4.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "cfgv", version = "3.4.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
@@ -3259,13 +3310,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
]
[[package]]
name = "pycrypto"
version = "2.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/60/db/645aa9af249f059cc3a368b118de33889219e0362141e75d4eaf6f80f163/pycrypto-2.6.1.tar.gz", hash = "sha256:f2ce1e989b272cfcb677616763e0a2e7ec659effa67a88aa92b3a65528f60a3c", size = 446240, upload-time = "2014-06-20T08:10:20.813Z" }
[[package]]
name = "pydeprecate"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/1e/b069ea704e42fefeb523835ba3d712347ff672743e5fa9428f4201525328/pydeprecate-0.9.0.tar.gz", hash = "sha256:ed49a36506f63a8d4ae84ffedff1e2879733d536c7a6aaafdf8bfcd39520059b", size = 151667, upload-time = "2026-06-05T19:51:17.430153Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/1e/b069ea704e42fefeb523835ba3d712347ff672743e5fa9428f4201525328/pydeprecate-0.9.0.tar.gz", hash = "sha256:ed49a36506f63a8d4ae84ffedff1e2879733d536c7a6aaafdf8bfcd39520059b", size = 151667, upload-time = "2026-06-05T19:51:17.43Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/87/a155f16c1c17adccaa2ed2256484fe1c6ad37b925e8e39695ed8860bc0b5/pydeprecate-0.9.0-py3-none-any.whl", hash = "sha256:c9501dae1a2567a3c7f7b04221ad5a97d1ad972a02ddb729a04299b435fedd7c", size = 102725, upload-time = "2026-06-05T19:51:16.235752Z" },
{ url = "https://files.pythonhosted.org/packages/47/87/a155f16c1c17adccaa2ed2256484fe1c6ad37b925e8e39695ed8860bc0b5/pydeprecate-0.9.0-py3-none-any.whl", hash = "sha256:c9501dae1a2567a3c7f7b04221ad5a97d1ad972a02ddb729a04299b435fedd7c", size = 102725, upload-time = "2026-06-05T19:51:16.235Z" },
]
[[package]]
@@ -3828,8 +3885,10 @@ version = "1.13.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'",
"python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
@@ -3978,13 +4037,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/c4/231cac7a8385394ebbbb4f1ca662203e9d8c332825ab4f36ffc3ead09a42/scipy-1.16.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f56296fefca67ba605fd74d12f7bd23636267731a72cb3947963e76b8c0a25db", size = 38515076, upload-time = "2025-06-22T16:21:45.694Z" },
]
[[package]]
name = "secretstorage"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version <= '3.9' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version <= '3.9' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version <= '3.9' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version <= '3.9' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "pycrypto", marker = "(python_full_version <= '3.9' and platform_machine != 'arm64') or (python_full_version <= '3.9' and sys_platform != 'darwin')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/24/af/50f2324784ee65e2b752624144249c7710e761be437dd82815ff86d3021f/SecretStorage-2.2.1.tar.gz", hash = "sha256:d771e99ada09e39aceb75c4bb2d460895627b2260ab24c9cb790fb8aad1ecab6", size = 15972, upload-time = "2016-06-27T19:37:45.887Z" }
[[package]]
name = "secretstorage"
version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version == '3.10.*' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version == '3.10.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.10.*' and sys_platform != 'darwin' and sys_platform != 'linux')",
"python_full_version > '3.9' and python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'",
"(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version > '3.9' and python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')",
]
dependencies = [
{ name = "cryptography", marker = "(python_full_version < '3.10' and platform_machine != 'arm64') or sys_platform != 'darwin'" },
{ name = "jeepney", marker = "(python_full_version < '3.10' and platform_machine != 'arm64') or sys_platform != 'darwin'" },
{ name = "cryptography", marker = "(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin')" },
{ name = "jeepney", marker = "(python_full_version > '3.9' and python_full_version < '3.10' and platform_machine != 'arm64') or (python_full_version > '3.9' and sys_platform != 'darwin')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/53/a4/f48c9d79cb507ed1373477dbceaba7401fd8a23af63b837fa61f1dcd3691/SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77", size = 19739, upload-time = "2022-08-13T16:22:46.976Z" }
wheels = [
@@ -4061,7 +4143,7 @@ wheels = [
[[package]]
name = "supervision"
version = "0.29.0.dev0"
version = "0.29.1"
source = { editable = "." }
dependencies = [
{ name = "defusedxml" },
@@ -4224,19 +4306,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.5"
version = "6.5.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" }
sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" },
{ url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" },
{ url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" },
{ url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" },
{ url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" },
{ url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" },
{ url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" },
{ url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" },
{ url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" },
{ url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" },
{ url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" },
{ url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" },
{ url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" },
{ url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" },
{ url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" },
{ url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" },
]
[[package]]
@@ -4290,7 +4372,8 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "id" },
{ name = "importlib-metadata", marker = "python_full_version < '3.10'" },
{ name = "keyring", marker = "platform_machine != 'ppc64le' and platform_machine != 's390x'" },
{ name = "keyring", version = "21.0.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version <= '3.9' and platform_machine != 'arm64' and platform_machine != 'ppc64le' and platform_machine != 's390x') or (python_full_version <= '3.9' and platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform != 'darwin')" },
{ name = "keyring", version = "25.6.0", source = { registry = "https://pypi.org/simple" }, marker = "(python_full_version > '3.9' and platform_machine != 'arm64' and platform_machine != 'ppc64le' and platform_machine != 's390x') or (python_full_version > '3.9' and platform_machine != 'ppc64le' and platform_machine != 's390x' and sys_platform != 'darwin') or (platform_machine == 'arm64' and sys_platform == 'darwin')" },
{ name = "packaging" },
{ name = "readme-renderer" },
{ name = "requests" },