Merge branch 'develop' into fix/docs/track_objects

This commit is contained in:
Soumik Rakshit 2025-07-08 16:12:17 +05:30 committed by GitHub
commit 8a905ce79a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1577 additions and 455 deletions

View File

@ -11,6 +11,12 @@ status: new
:::supervision.detection.utils.box_iou_batch
<div class="md-typeset">
<h2><a href="#supervision.detection.utils.box_iou_batch_with_jaccard">box_iou_batch_with_jaccard</a></h2>
</div>
:::supervision.detection.utils.box_iou_batch_with_jaccard
<div class="md-typeset">
<h2><a href="#supervision.detection.utils.mask_iou_batch">mask_iou_batch</a></h2>
</div>

View File

@ -16,3 +16,9 @@ status: new
</div>
:::supervision.metrics.mean_average_precision.MeanAveragePrecisionResult
<div class="md-typeset">
<h2><a href="#supervision.dataset.formats.coco.get_coco_class_index_mapping">get_coco_class_index_mapping</a></h2>
</div>
:::supervision.dataset.formats.coco.get_coco_class_index_mapping

View File

@ -38,6 +38,7 @@ from supervision.dataset.core import (
ClassificationDataset,
DetectionDataset,
)
from supervision.dataset.formats.coco import get_coco_class_index_mapping
from supervision.dataset.utils import mask_to_rle, rle_to_mask
from supervision.detection.core import Detections
from supervision.detection.line_zone import (
@ -58,6 +59,7 @@ from supervision.detection.tools.polygon_zone import PolygonZone, PolygonZoneAnn
from supervision.detection.tools.smoother import DetectionsSmoother
from supervision.detection.utils import (
box_iou_batch,
box_iou_batch_with_jaccard,
calculate_masks_centroids,
clip_boxes,
contains_holes,
@ -180,6 +182,7 @@ __all__ = [
"VideoInfo",
"VideoSink",
"box_iou_batch",
"box_iou_batch_with_jaccard",
"box_non_max_merge",
"box_non_max_suppression",
"calculate_masks_centroids",
@ -199,6 +202,7 @@ __all__ = [
"draw_rectangle",
"draw_text",
"filter_polygons_by_area",
"get_coco_class_index_mapping",
"get_polygon_center",
"get_video_frames_generator",
"letterbox_image",

View File

@ -574,7 +574,6 @@ class DetectionDataset(BaseDataset):
force_masks (bool): If True,
forces masks to be loaded for all annotations,
regardless of whether they are present.
Returns:
DetectionDataset: A DetectionDataset instance containing
the loaded images and annotations.

View File

@ -90,7 +90,10 @@ def coco_annotations_to_masks(
def coco_annotations_to_detections(
image_annotations: List[dict], resolution_wh: Tuple[int, int], with_masks: bool
image_annotations: List[dict],
resolution_wh: Tuple[int, int],
with_masks: bool,
use_iscrowd: bool = True,
) -> Detections:
if not image_annotations:
return Detections.empty()
@ -102,15 +105,26 @@ def coco_annotations_to_detections(
xyxy = np.asarray(xyxy)
xyxy[:, 2:4] += xyxy[:, 0:2]
data = dict()
if use_iscrowd:
iscrowd = [
image_annotation["iscrowd"] for image_annotation in image_annotations
]
area = [image_annotation["area"] for image_annotation in image_annotations]
data = dict(
iscrowd=np.asarray(iscrowd, dtype=int), area=np.asarray(area, dtype=float)
)
if with_masks:
mask = coco_annotations_to_masks(
image_annotations=image_annotations, resolution_wh=resolution_wh
)
return Detections(
class_id=np.asarray(class_ids, dtype=int), xyxy=xyxy, mask=mask
)
else:
mask = None
return Detections(xyxy=xyxy, class_id=np.asarray(class_ids, dtype=int))
return Detections(
class_id=np.asarray(class_ids, dtype=int), xyxy=xyxy, mask=mask, data=data
)
def detections_to_coco_annotations(
@ -159,16 +173,58 @@ def detections_to_coco_annotations(
return coco_annotations, annotation_id
def get_coco_class_index_mapping(annotations_path: str) -> Dict[int, int]:
"""
Generates a mapping from sequential class indices to original COCO class ids.
This function is essential when working with models that expect class ids to be
zero-indexed and sequential (0 to 79), as opposed to the original COCO
dataset where category ids are non-contiguous ranging from 1 to 90 but skipping some
ids.
Use Cases:
- Evaluating models trained with COCO-style annotations where class ids
are sequential ranging from 0 to 79.
- Ensuring consistent class indexing across training, inference and evaluation,
when using different tools or datasets with COCO format.
- Reproducing results from models that assume sequential class ids (0 to 79).
How it Works:
- Reads the COCO annotation file in its original format (`annotations_path`).
- Extracts and sorts all class names by their original COCO id (1 to 90).
- Builds a mapping from COCO class ids (not sequential with skipped ids) to
new class ids (sequential ranging from 0 to 79).
- Returns a dictionary mapping: `{new_class_id: original_COCO_class_id}`.
Args:
annotations_path (str): Path to COCO JSON annotations file
(e.g., `instances_val2017.json`).
Returns:
Dict[int, int]: A mapping from new class id (sequential ranging from 0 to 79)
to original COCO class id (1 to 90 with skipped ids).
"""
coco_data = read_json_file(annotations_path)
classes = coco_categories_to_classes(coco_categories=coco_data["categories"])
class_mapping = build_coco_class_index_mapping(
coco_categories=coco_data["categories"], target_classes=classes
)
return {v: k for k, v in class_mapping.items()}
def load_coco_annotations(
images_directory_path: str,
annotations_path: str,
force_masks: bool = False,
use_iscrowd: bool = True,
) -> Tuple[List[str], List[str], Dict[str, Detections]]:
coco_data = read_json_file(file_path=annotations_path)
classes = coco_categories_to_classes(coco_categories=coco_data["categories"])
class_index_mapping = build_coco_class_index_mapping(
coco_categories=coco_data["categories"], target_classes=classes
)
coco_images = coco_data["images"]
coco_annotations_groups = group_coco_annotations_by_image_id(
coco_annotations=coco_data["annotations"]
@ -190,7 +246,9 @@ def load_coco_annotations(
image_annotations=image_annotations,
resolution_wh=(image_width, image_height),
with_masks=force_masks,
use_iscrowd=use_iscrowd,
)
annotation = map_detections_class_id(
source_to_target_mapping=class_index_mapping,
detections=annotation,

View File

@ -1325,3 +1325,103 @@ def spread_out_boxes(
xyxy_padded[:, [2, 3]] += force_vectors
return pad_boxes(xyxy_padded, px=-1)
def _jaccard(box_a: List[float], box_b: List[float], is_crowd: bool) -> float:
"""
Calculate the Jaccard index (intersection over union) between two bounding boxes.
If a gt object is marked as "iscrowd", a dt is allowed to match any subregion
of the gt. Choosing gt' in the crowd gt that best matches the dt can be done using
gt'=intersect(dt,gt). Since by definition union(gt',dt)=dt, computing
iou(gt,dt,iscrowd) = iou(gt',dt) = area(intersect(gt,dt)) / area(dt)
Args:
box_a (List[float]): Box coordinates in the format [x, y, width, height].
box_b (List[float]): Box coordinates in the format [x, y, width, height].
iscrowd (bool): Flag indicating if the second box is a crowd region or not.
Returns:
float: Jaccard index between the two bounding boxes.
"""
# Smallest number to avoid division by zero
EPS = np.spacing(1)
xa, ya, x2a, y2a = box_a[0], box_a[1], box_a[0] + box_a[2], box_a[1] + box_a[3]
xb, yb, x2b, y2b = box_b[0], box_b[1], box_b[0] + box_b[2], box_b[1] + box_b[3]
# Innermost left x
xi = max(xa, xb)
# Innermost right x
x2i = min(x2a, x2b)
# Same for y
yi = max(ya, yb)
y2i = min(y2a, y2b)
# Calculate areas
Aa = max(x2a - xa, 0.0) * max(y2a - ya, 0.0)
Ab = max(x2b - xb, 0.0) * max(y2b - yb, 0.0)
Ai = max(x2i - xi, 0.0) * max(y2i - yi, 0.0)
if is_crowd:
return Ai / (Aa + EPS)
return Ai / (Aa + Ab - Ai + EPS)
def box_iou_batch_with_jaccard(
boxes_true: List[List[float]],
boxes_detection: List[List[float]],
is_crowd: List[bool],
) -> np.ndarray:
"""
Calculate the intersection over union (IoU) between detection bounding boxes (dt)
and ground-truth bounding boxes (gt).
Reference: https://github.com/rafaelpadilla/review_object_detection_metrics
Args:
boxes_true (List[List[float]]): List of ground-truth bounding boxes in the \
format [x, y, width, height].
boxes_detection (List[List[float]]): List of detection bounding boxes in the \
format [x, y, width, height].
is_crowd (List[bool]): List indicating if each ground-truth bounding box \
is a crowd region or not.
Returns:
np.ndarray: Array of IoU values of shape (len(dt), len(gt)).
Examples:
```python
import numpy as np
import supervision as sv
boxes_true = [
[10, 20, 30, 40], # x, y, w, h
[15, 25, 35, 45]
]
boxes_detection = [
[12, 22, 28, 38],
[16, 26, 36, 46]
]
is_crowd = [False, False]
ious = sv.box_iou_batch_with_jaccard(
boxes_true=boxes_true,
boxes_detection=boxes_detection,
is_crowd=is_crowd
)
# array([
# [0.8866..., 0.4960...],
# [0.4000..., 0.8622...]
# ])
```
"""
assert len(is_crowd) == len(boxes_true), (
"`is_crowd` must have the same length as `boxes_true`"
)
if len(boxes_detection) == 0 or len(boxes_true) == 0:
return np.array([])
ious = np.zeros((len(boxes_detection), len(boxes_true)), dtype=np.float64)
for g_idx, g in enumerate(boxes_true):
for d_idx, d in enumerate(boxes_detection):
ious[d_idx, g_idx] = _jaccard(d, g, is_crowd[g_idx])
return ious

File diff suppressed because it is too large Load Diff

View File

@ -162,12 +162,22 @@ def test_group_coco_annotations_by_image_id(
@pytest.mark.parametrize(
"image_annotations, resolution_wh, with_masks, expected_result, exception",
"image_annotations, resolution_wh, with_masks, use_iscrowd, "
"expected_result, exception",
[
(
[],
(1000, 1000),
False,
False,
Detections.empty(),
DoesNotRaise(),
), # empty image annotations
(
[],
(1000, 1000),
False,
True,
Detections.empty(),
DoesNotRaise(),
), # empty image annotations
@ -179,12 +189,32 @@ def test_group_coco_annotations_by_image_id(
],
(1000, 1000),
False,
False,
Detections(
xyxy=np.array([[0, 0, 100, 100]], dtype=np.float32),
class_id=np.array([0], dtype=int),
),
DoesNotRaise(),
), # single image annotations
(
[
mock_coco_annotation(
category_id=0, bbox=(0, 0, 100, 100), area=100 * 100
)
],
(1000, 1000),
False,
True,
Detections(
xyxy=np.array([[0, 0, 100, 100]], dtype=np.float32),
class_id=np.array([0], dtype=int),
data={
"iscrowd": np.array([0], dtype=int),
"area": np.array([100 * 100]),
},
),
DoesNotRaise(),
),
(
[
mock_coco_annotation(
@ -196,6 +226,7 @@ def test_group_coco_annotations_by_image_id(
],
(1000, 1000),
False,
False,
Detections(
xyxy=np.array(
[[0, 0, 100, 100], [100, 100, 200, 200]], dtype=np.float32
@ -204,6 +235,30 @@ def test_group_coco_annotations_by_image_id(
),
DoesNotRaise(),
), # two image annotations
(
[
mock_coco_annotation(
category_id=0, bbox=(0, 0, 100, 100), area=100 * 100
),
mock_coco_annotation(
category_id=0, bbox=(100, 100, 100, 100), area=100 * 100
),
],
(1000, 1000),
False,
True,
Detections(
xyxy=np.array(
[[0, 0, 100, 100], [100, 100, 200, 200]], dtype=np.float32
),
class_id=np.array([0, 0], dtype=int),
data={
"iscrowd": np.array([0, 0], dtype=int),
"area": np.array([100 * 100, 100 * 100]),
},
),
DoesNotRaise(),
),
(
[
mock_coco_annotation(
@ -215,6 +270,7 @@ def test_group_coco_annotations_by_image_id(
],
(5, 5),
True,
False,
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
@ -232,6 +288,36 @@ def test_group_coco_annotations_by_image_id(
),
DoesNotRaise(),
), # single image annotations with mask as polygon
(
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation=[[0, 0, 2, 0, 2, 2, 4, 2, 4, 4, 0, 4]],
)
],
(5, 5),
True,
True,
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
]
]
),
data={"iscrowd": np.array([0], dtype=int), "area": np.array([25])},
),
DoesNotRaise(),
),
(
[
mock_coco_annotation(
@ -247,6 +333,7 @@ def test_group_coco_annotations_by_image_id(
],
(5, 5),
True,
False,
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
@ -264,6 +351,40 @@ def test_group_coco_annotations_by_image_id(
),
DoesNotRaise(),
), # single image annotations with mask, RLE segmentation mask
(
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation={
"size": [5, 5],
"counts": [0, 15, 2, 3, 2, 3],
},
iscrowd=True,
)
],
(5, 5),
True,
True,
Detections(
xyxy=np.array([[0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
]
]
),
data={"iscrowd": np.array([1], dtype=int), "area": np.array([25])},
),
DoesNotRaise(),
),
(
[
mock_coco_annotation(
@ -285,6 +406,7 @@ def test_group_coco_annotations_by_image_id(
],
(5, 5),
True,
False,
Detections(
xyxy=np.array([[0, 0, 5, 5], [3, 0, 5, 2]], dtype=np.float32),
class_id=np.array([0, 0], dtype=int),
@ -309,6 +431,57 @@ def test_group_coco_annotations_by_image_id(
),
DoesNotRaise(),
), # two image annotations with mask, one mask as polygon and second as RLE
(
[
mock_coco_annotation(
category_id=0,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation=[[0, 0, 2, 0, 2, 2, 4, 2, 4, 4, 0, 4]],
),
mock_coco_annotation(
category_id=0,
bbox=(3, 0, 2, 2),
area=2 * 2,
segmentation={
"size": [5, 5],
"counts": [15, 2, 3, 2, 3],
},
iscrowd=True,
),
],
(5, 5),
True,
True,
Detections(
xyxy=np.array([[0, 0, 5, 5], [3, 0, 5, 2]], dtype=np.float32),
class_id=np.array([0, 0], dtype=int),
mask=np.array(
[
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
],
[
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
],
]
),
data={
"iscrowd": np.array([0, 1], dtype=int),
"area": np.array([25, 4]),
},
),
DoesNotRaise(),
), # two image annotations with mask, one mask as polygon with iscrowd,
# and second as RLE without iscrowd
(
[
mock_coco_annotation(
@ -330,6 +503,7 @@ def test_group_coco_annotations_by_image_id(
],
(5, 5),
True,
False,
Detections(
xyxy=np.array([[3, 0, 5, 2], [0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0, 1], dtype=int),
@ -354,12 +528,64 @@ def test_group_coco_annotations_by_image_id(
),
DoesNotRaise(),
), # two image annotations with mask, first mask as RLE and second as polygon
(
[
mock_coco_annotation(
category_id=0,
bbox=(3, 0, 2, 2),
area=2 * 2,
segmentation={
"size": [5, 5],
"counts": [15, 2, 3, 2, 3],
},
iscrowd=True,
),
mock_coco_annotation(
category_id=1,
bbox=(0, 0, 5, 5),
area=5 * 5,
segmentation=[[0, 0, 2, 0, 2, 2, 4, 2, 4, 4, 0, 4]],
),
],
(5, 5),
True,
True,
Detections(
xyxy=np.array([[3, 0, 5, 2], [0, 0, 5, 5]], dtype=np.float32),
class_id=np.array([0, 1], dtype=int),
mask=np.array(
[
[
[0, 0, 0, 1, 1],
[0, 0, 0, 1, 1],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
[0, 0, 0, 0, 0],
],
[
[1, 1, 1, 0, 0],
[1, 1, 1, 0, 0],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
[1, 1, 1, 1, 1],
],
]
),
data={
"iscrowd": np.array([1, 0], dtype=int),
"area": np.array([4, 25]),
},
),
DoesNotRaise(),
), # two image annotations with mask, first mask as RLE with is crowd,
# and second as polygon without iscrowd
],
)
def test_coco_annotations_to_detections(
image_annotations: List[dict],
resolution_wh: Tuple[int, int],
with_masks: bool,
use_iscrowd: bool,
expected_result: Detections,
exception: Exception,
) -> None:
@ -368,6 +594,7 @@ def test_coco_annotations_to_detections(
image_annotations=image_annotations,
resolution_wh=resolution_wh,
with_masks=with_masks,
use_iscrowd=use_iscrowd,
)
assert result == expected_result