Compare commits

...
8 Commits
Author SHA1 Message Date
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
22 changed files with 1646 additions and 242 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`:
+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'.")
```
---
+44 -8
View File
@@ -1,25 +1,61 @@
---
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-15
---
# Changelog
### UnReleased
- 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.
### 0.29.0 <small>Jun 15, 2026</small>
- 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.
- 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).
- 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.
- 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.
- 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.
- Added [#2247](https://github.com/roboflow/supervision/pull/2247): [`sv.ConfusionMatrix`](https://supervision.roboflow.com/0.29.0/metrics/detection/#supervision.metrics.detection.ConfusionMatrix) now supports `MetricTarget.ORIENTED_BOUNDING_BOXES`, computing IoU via `oriented_box_iou_batch` on `xyxyxyxy` corners.
- 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.
- 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 [#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.
- Added [#2302](https://github.com/roboflow/supervision/pull/2302), [#2289](https://github.com/roboflow/supervision/pull/2289): [`sv.DetectionDataset.as_yolo`](https://supervision.roboflow.com/0.29.0/datasets/#supervision.dataset.core.DetectionDataset.as_yolo) gains an `is_obb` parameter for exporting oriented bounding box annotations in the YOLO OBB format (9-token lines with 4 corner coordinates).
- 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.
- 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.
- Changed [#2312](https://github.com/roboflow/supervision/pull/2312): [`sv.Detections.with_nmm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.with_nmm) now computes the merged oriented bounding box as the tightest rectangle at the winner's orientation enclosing all corners from every detection in a merge group.
- Changed [#2306](https://github.com/roboflow/supervision/pull/2306): [`sv.Detections.area`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.area) now returns the polygon area of the rotated bounding box (via the shoelace formula) when oriented box coordinates are present, instead of the axis-aligned box area.
- Changed [#2256](https://github.com/roboflow/supervision/pull/2256): [`sv.InferenceSlicer`](https://supervision.roboflow.com/0.29.0/detection/tools/inference_slicer/#supervision.detection.tools.inference_slicer.InferenceSlicer) now detects OBB outputs from callbacks and automatically falls back to sequential processing to avoid thread-safety issues when `thread_workers > 1`.
- Fixed [#2282](https://github.com/roboflow/supervision/pull/2282): [`sv.oriented_box_iou_batch`](https://supervision.roboflow.com/0.29.0/detection/utils/iou_and_nms/#supervision.detection.utils.iou_and_nms.oriented_box_iou_batch) now correctly handles non-square canvases. Previously, rasterization assumed square dimensions, leading to incorrect IoU values for tall or wide images.
- 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 [#2239](https://github.com/roboflow/supervision/pull/2239): [`sv.Detections.from_vlm`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.from_vlm) no longer returns `None` for `class_id` on empty VLM parses; now returns an empty int ndarray.
- Fixed [#2270](https://github.com/roboflow/supervision/pull/2270): [`sv.Detections.from_inference`](https://supervision.roboflow.com/0.29.0/detection/core/#supervision.detection.core.Detections.from_inference) now preserves `class_name` as a string-dtype array when predictions are empty.
- Fixed [#2269](https://github.com/roboflow/supervision/pull/2269): [`sv.HeatMapAnnotator`](https://supervision.roboflow.com/0.29.0/annotators/#supervision.annotators.core.HeatMapAnnotator) no longer crashes with a divide-by-zero when called with empty detections.
- Fixed [#2276](https://github.com/roboflow/supervision/pull/2276): COCO export now emits 1-indexed `category_id` values as required by the COCO specification.
- Fixed [#2267](https://github.com/roboflow/supervision/pull/2267): COCO annotation and image IDs are now sequential across train/val/test splits via `starting_image_id` and `starting_annotation_id` parameters.
- Fixed [#2289](https://github.com/roboflow/supervision/pull/2289): [`sv.DetectionDataset.as_yolo`](https://supervision.roboflow.com/0.29.0/datasets/#supervision.dataset.core.DetectionDataset.as_yolo) no longer loses OBB rotation when exporting oriented bounding boxes.
- Fixed [#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`).
- 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>
+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
+1 -1
View File
@@ -4,7 +4,7 @@ requires = [ "setuptools>=61" ]
[project]
name = "supervision"
version = "0.29.0.rc0"
version = "0.29.0"
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,
+1 -1
View File
@@ -2746,7 +2746,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
+41 -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
@@ -260,21 +273,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 +299,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 +310,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 = {
+147 -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,142 @@ 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[np.ndarray]) -> 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 +2854,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 +2882,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 +2924,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
+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]:
+161 -57
View File
@@ -4,11 +4,11 @@ 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 +86,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 +359,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 +431,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 +465,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 +504,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(
@@ -1185,6 +1288,7 @@ def oriented_box_non_max_suppression(
)
sort_index, _, categories = _prepare_predictions_for_nms(predictions)
oriented_boxes = oriented_boxes[sort_index]
# same object intentional — triggers upper-triangle optimization
ious = oriented_box_iou_batch(oriented_boxes, oriented_boxes, overlap_metric)
keep = _nms_loop_from_iou_matrix(ious, categories, iou_threshold)
return cast(npt.NDArray[np.bool_], keep[sort_index.argsort()])
+1 -1
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]
+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
+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]],
+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]
+182
View File
@@ -43,6 +43,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 +290,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 +309,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 +331,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 +355,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(),
@@ -1845,3 +1857,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
+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:
+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)
+161 -27
View File
@@ -1162,34 +1162,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 +1194,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 +1219,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:
Generated
+14 -20
View File
@@ -959,33 +959,27 @@ 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" },
@@ -3263,9 +3257,9 @@ wheels = [
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]]
@@ -4061,7 +4055,7 @@ wheels = [
[[package]]
name = "supervision"
version = "0.29.0.dev0"
version = "0.29.0rc0"
source = { editable = "." }
dependencies = [
{ name = "defusedxml" },
@@ -4224,19 +4218,19 @@ wheels = [
[[package]]
name = "tornado"
version = "6.5.5"
version = "6.5.6"
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/50/57/6d7303a77ae439d9189108f76c0c4fd89ee5e2cc8387bffb55232565c4ed/tornado-6.5.6.tar.gz", hash = "sha256:9a365179fe8ff6b8766f602c0f67c185d778193e9bdd828b19f0b6ed7764177d", size = 518139, upload-time = "2026-05-27T15:35:54.646Z" }
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/1b/0d/b4f481e18c5a51864e6d12b9a05ecf72919696680b747c958c3fc1f4fbae/tornado-6.5.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:65fcfaafb079435c2c19dc9e07c0f1cf0fa9051759ed0a7d0a3ba7ea7f64919c", size = 447737, upload-time = "2026-05-27T15:35:38.122Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9c/5430c39fcab1144d35860f457b15e9c08b4bc7ac86764354204e983d6183/tornado-6.5.6-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:38bc01b4acacded2de63ae78023548e41ebe6fbed3ec05a796d7ae3ad893887e", size = 445899, upload-time = "2026-05-27T15:35:40.519Z" },
{ url = "https://files.pythonhosted.org/packages/8b/79/fa7e14a2f939c807a8d30619b4eb604eab219601b78792516ebe22d40cf9/tornado-6.5.6-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b942e6a137fda31ff54bf8e6e2c8d1c37f1f50583f3ed53fb840b53b9601d104", size = 448964, upload-time = "2026-05-27T15:35:42.106Z" },
{ url = "https://files.pythonhosted.org/packages/a7/71/bd67d5f5199f937dafe03a49a37989f60f600ff6fef34c79412a829d97bd/tornado-6.5.6-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8666946e70171b8c3f1fc9b7876fac492e84822c4c7f3746f4e8f8bc9ac92a79", size = 449935, upload-time = "2026-05-27T15:35:43.906Z" },
{ url = "https://files.pythonhosted.org/packages/cc/a4/c24388c9cf5b3c3a513b56a158af9f23092c9a2810d789e294310797df21/tornado-6.5.6-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c34cfab7ad6d104f052f55de06d39bbafc5885cfeb4da688803308dbcfa90b7", size = 449767, upload-time = "2026-05-27T15:35:45.793Z" },
{ url = "https://files.pythonhosted.org/packages/a5/eb/6a07ad550c3f7b37244bd0becdf293ec3d3e961783d8b720a97df50de1b2/tornado-6.5.6-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:385f35e4e22fb52551dfcda4cdc8c30c61c2c001aef5ddad99cdfe116952efd3", size = 449174, upload-time = "2026-05-27T15:35:47.485Z" },
{ url = "https://files.pythonhosted.org/packages/bb/84/3469e098dccdb6763130e06aacd786bb4363fca7b590a55c101ddf34ed30/tornado-6.5.6-cp39-abi3-win32.whl", hash = "sha256:db475f1b67b2809b10bb16264829087724ca8d24fe4ed47f7b8675cae453ef86", size = 450230, upload-time = "2026-05-27T15:35:49.322Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3c/273a04e0b9dd9016f1685cca0c1c8795a71ac88a34a8c889a0b443483226/tornado-6.5.6-cp39-abi3-win_amd64.whl", hash = "sha256:6739bf1e8eb09230f1280ddbd3236f0309db70f2c551a8dbc40f62babdf82f79", size = 450667, upload-time = "2026-05-27T15:35:51.194Z" },
{ url = "https://files.pythonhosted.org/packages/02/98/0cffe22a224f60c5fb1e3aa0b76f9da2e1ca78b0e9545e3d077c68ce60a7/tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac", size = 449690, upload-time = "2026-05-27T15:35:52.902Z" },
]
[[package]]