Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e551ec9c0 | ||
|
|
1fa735d0d9 | ||
|
|
62a24d7960 | ||
|
|
3d07099cf9 | ||
|
|
483e3e9335 | ||
|
|
e0e1abd865 | ||
|
|
117a9baab8 | ||
|
|
497f336d13 |
@@ -252,6 +252,18 @@ Type hints are required on all new code. mypy is enforced by the pre-commit hook
|
||||
- Prefer vectorized operations over Python loops in hot paths.
|
||||
- Lazy-import heavy framework dependencies (`torch`, `transformers`, `ultralytics`) inside the function that needs them — never at module top level.
|
||||
|
||||
### Deprecation policy
|
||||
|
||||
**Minimum window**: deprecated APIs must remain for at least **3 minor releases** before removal. Example: deprecated in `0.29.0` → removed in `0.32.0`.
|
||||
|
||||
Use the appropriate mechanism depending on what is being deprecated:
|
||||
|
||||
- **Module-level alias**: `supervision.utils.internal.warn_deprecated` in the deprecated module's `__init__.py`
|
||||
- **Renamed parameter**: `supervision.utils.internal.deprecated_parameter` decorator
|
||||
- **Public function, method, or class**: `@deprecated` from `pydeprecate`
|
||||
|
||||
Always specify both the deprecation version and the planned removal version in the message or decorator arguments.
|
||||
|
||||
### Deprecated module aliases
|
||||
|
||||
`supervision.keypoint` is deprecated since `0.27.0` and will be removed in `0.30.0`. Always import from `supervision.key_points`:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -226,7 +226,7 @@ def _validate_labels(labels: list[str] | None, detections: Detections) -> None:
|
||||
@deprecated( # type: ignore[untyped-decorator]
|
||||
target=_validate_labels,
|
||||
deprecated_in="0.29.0",
|
||||
remove_in="0.31.0",
|
||||
remove_in="0.32.0",
|
||||
)
|
||||
def validate_labels(labels: list[str] | None, detections: Detections) -> None:
|
||||
void(labels, detections)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
CLASS_NAME_DATA_FIELD: str = "class_name"
|
||||
COCO_RAW_SEGMENTATION: str = "coco_raw_segmentation"
|
||||
#: Key for oriented bounding-box corner coordinates in ``Detections.data``.
|
||||
#:
|
||||
#: Value layout: ``np.ndarray`` of shape ``(N, 4, 2)``, dtype ``float32``, pixel
|
||||
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Any, Union, cast
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from supervision.config import COCO_RAW_SEGMENTATION
|
||||
from supervision.dataset.utils import (
|
||||
approximate_mask_with_polygons,
|
||||
map_detections_class_id,
|
||||
@@ -168,6 +169,10 @@ def coco_annotations_to_detections(
|
||||
Returns:
|
||||
Detections with ``class_id`` set to raw COCO ``category_id`` values.
|
||||
Call :func:`map_detections_class_id` on the result before use.
|
||||
When ``with_masks=False``, ``detections.data[COCO_RAW_SEGMENTATION]`` is
|
||||
populated as an object array (shape ``(N,)``) holding the raw polygon list or
|
||||
RLE dict per annotation; consumed by :func:`detections_to_coco_annotations`
|
||||
for a coordinate-preserving round-trip.
|
||||
"""
|
||||
if not image_annotations:
|
||||
return Detections.empty()
|
||||
@@ -175,11 +180,11 @@ def coco_annotations_to_detections(
|
||||
class_ids = [
|
||||
image_annotation["category_id"] for image_annotation in image_annotations
|
||||
]
|
||||
xyxy = [image_annotation["bbox"] for image_annotation in image_annotations]
|
||||
xyxy = np.asarray(xyxy, dtype=np.float32)
|
||||
xyxy_list = [image_annotation["bbox"] for image_annotation in image_annotations]
|
||||
xyxy: npt.NDArray[np.float32] = np.asarray(xyxy_list, dtype=np.float32)
|
||||
xyxy[:, 2:4] += xyxy[:, 0:2]
|
||||
|
||||
data: dict[str, npt.NDArray[np.generic]] = {}
|
||||
data: dict[str, Union[npt.NDArray[np.generic], list[Any]]] = {}
|
||||
if use_iscrowd:
|
||||
iscrowd = [
|
||||
image_annotation["iscrowd"] for image_annotation in image_annotations
|
||||
@@ -195,6 +200,14 @@ def coco_annotations_to_detections(
|
||||
)
|
||||
else:
|
||||
mask = None
|
||||
# Preserve raw polygon/RLE data so as_coco() can round-trip without
|
||||
# binary-mask encoding. Stored as an object array (one entry per detection).
|
||||
raw_segs: npt.NDArray[np.object_] = np.empty(
|
||||
len(image_annotations), dtype=object
|
||||
)
|
||||
for k, _ann in enumerate(image_annotations):
|
||||
raw_segs[k] = _ann.get("segmentation", [])
|
||||
data[COCO_RAW_SEGMENTATION] = raw_segs
|
||||
|
||||
return Detections(
|
||||
class_id=np.asarray(class_ids, dtype=int), xyxy=xyxy, mask=mask, data=data
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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()])
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user