Skip to content

Commit 99049d8

Browse files
Bordaclaude[bot]
andauthored
Fix: resolve major complex review (roboflow#2388)
- Fixed in-memory dict-form `DetectionDataset` image access, iteration, equality, and merge behavior, with deprecation messaging retained - Fixed mAP to honor `metric_target` for mask and oriented-bounding-box evaluation, including correct IoU routing, area handling, crowd semantics, and missing-content errors - Fixed `ConfusionMatrix.plot()` when plotting raw counts with default normalization disabled - Improved mask mAP crowd handling performance and memory usage - Updated the count-in-zone guide to use current APIs --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
1 parent 8692148 commit 99049d8

8 files changed

Lines changed: 582 additions & 48 deletions

File tree

docs/how_to/count_in_zone.md

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -24,21 +24,21 @@ download_assets(VideoAssets.VEHICLES_2)
2424

2525
First, we need to initialize a model. Let's use a YOLOv8 model with the default COCO checkpoint. We also need to load a video on which to run inference.
2626

27-
Create a YOLO model instance and load the source video using supervision's `VideoInfo` helper. The model will process each frame during inference, while `VideoInfo` extracts resolution and frame-rate metadata needed by the polygon zone annotator. A shared color palette ensures consistent zone coloring throughout the output video.
27+
Create a YOLO model instance and download the source video. The model will process each frame during inference. A shared color palette ensures consistent zone coloring throughout the output video.
2828

2929
```python
3030
import numpy as np
3131
import supervision as sv
3232
import cv2
3333

3434
from ultralytics import YOLO
35+
from supervision.assets import VideoAssets, download_assets
3536

3637
model = YOLO("yolov8s.pt")
3738

38-
VIDEO = str(VideoAssets.VEHICLES_2)
39+
VIDEO = download_assets(VideoAssets.VEHICLES_2)
3940

40-
colors = sv.ColorPalette.default()
41-
video_info = sv.VideoInfo.from_video_path(VIDEO)
41+
colors = sv.ColorPalette.DEFAULT
4242
```
4343

4444
## Calculate Coordinates
@@ -80,10 +80,7 @@ With the coordinates of the zones to draw ready, we can set up our zones:
8080
Instantiate a `PolygonZone` for each polygon array, pairing it with a `PolygonZoneAnnotator` for visual overlay and a `BoxAnnotator` for drawing detection boxes. Each zone will later trigger on incoming detections to determine which objects fall inside its boundaries, enabling per-zone counting in the inference callback.
8181

8282
```python
83-
zones = [
84-
sv.PolygonZone(polygon=polygon, frame_resolution_wh=video_info.resolution_wh)
85-
for polygon in polygons
86-
]
83+
zones = [sv.PolygonZone(polygon=polygon) for polygon in polygons]
8784
zone_annotators = [
8885
sv.PolygonZoneAnnotator(
8986
zone=zone,
@@ -98,8 +95,6 @@ box_annotators = [
9895
sv.BoxAnnotator(
9996
color=colors.by_idx(index),
10097
thickness=4,
101-
text_thickness=4,
102-
text_scale=2,
10398
)
10499
for index in range(len(polygons))
105100
]
@@ -121,9 +116,7 @@ def process_frame(frame: np.ndarray, i) -> np.ndarray:
121116
):
122117
mask = zone.trigger(detections=detections)
123118
detections_filtered = detections[mask]
124-
frame = box_annotator.annotate(
125-
scene=frame, detections=detections_filtered, skip_label=True
126-
)
119+
frame = box_annotator.annotate(scene=frame, detections=detections_filtered)
127120
frame = zone_annotator.annotate(scene=frame)
128121

129122
return frame

src/supervision/dataset/core.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ class DetectionDataset(BaseDataset):
7272
Attributes:
7373
classes: List containing dataset class names.
7474
images:
75-
Accepts a list of image paths, or dictionaries of loaded cv2 images
76-
with paths as keys. If you pass a list of paths, the dataset will
77-
lazily load images on demand, which is much more memory-efficient.
75+
Accepts a list of image paths. Passing a dict
76+
(``Dict[str, np.ndarray]``) is deprecated in ``0.30.0`` and will
77+
be removed in ``0.33.0``; use a list of paths instead.
78+
When a list of paths is provided, images are loaded lazily on
79+
demand, which is more memory-efficient.
7880
annotations: Dictionary mapping
7981
image path to annotations. The dictionary keys match
8082
match the keys in `images` or entries in the list of
@@ -107,6 +109,13 @@ def __init__(
107109
self.image_paths = list(dict.fromkeys(images))
108110

109111
self._images_in_memory: dict[str, npt.NDArray[np.uint8]] = {}
112+
if isinstance(images, dict):
113+
self._images_in_memory = images
114+
warn_deprecated(
115+
"Passing a `Dict[str, np.ndarray]` into `DetectionDataset` is "
116+
"deprecated in `0.30.0` and will be removed in `0.33.0`. Use "
117+
"a list of paths `List[str]` instead."
118+
)
110119

111120
def _get_image(self, image_path: str) -> npt.NDArray[np.uint8]:
112121
"""Assumes that image is in dataset."""

src/supervision/metrics/detection.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1142,7 +1142,9 @@ def plot(
11421142
Confusion matrix plot.
11431143
"""
11441144

1145-
array = self.matrix.copy()
1145+
# Cast to float so that the NaN masking below never hits an integer
1146+
# matrix (assigning NaN into an int array raises ValueError).
1147+
array = self.matrix.astype(np.float64)
11461148

11471149
if normalize:
11481150
eps = 1e-8

src/supervision/metrics/mean_average_precision.py

Lines changed: 141 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@
77
from copy import deepcopy
88
from dataclasses import dataclass
99
from enum import Enum
10-
from typing import TYPE_CHECKING, TypeAlias, TypedDict
10+
from typing import TYPE_CHECKING, Any, TypeAlias, TypedDict
1111

1212
import numpy as np
1313
import numpy.typing as npt
1414
from matplotlib import pyplot as plt
1515

16+
from supervision.config import ORIENTED_BOX_COORDINATES
1617
from supervision.detection.core import Detections
17-
from supervision.detection.utils.iou_and_nms import box_iou_batch_with_jaccard
18+
from supervision.detection.utils.iou_and_nms import (
19+
box_iou_batch_with_jaccard,
20+
mask_iou_batch,
21+
oriented_box_iou_batch,
22+
)
1823
from supervision.draw.color import LEGACY_COLOR_PALETTE
1924
from supervision.metrics.core import Metric, MetricTarget
2025
from supervision.metrics.utils.utils import ensure_pandas_installed
@@ -41,6 +46,11 @@ class _TypeCocoDict(TypedDict, total=False):
4146
supercategory: str
4247
caption: str
4348
keypoints: list[float]
49+
# Metric-target-specific content: a boolean mask of shape (H, W) for
50+
# `MetricTarget.MASKS` or an oriented box of shape (4, 2) for
51+
# `MetricTarget.ORIENTED_BOUNDING_BOXES`. Absent for `MetricTarget.BOXES`.
52+
# Invariant: shape/dtype match `metric_target` of the owning COCOEvaluator.
53+
content: npt.NDArray[Any]
4454

4555

4656
_TypeCocoDataset: TypeAlias = dict[str, list[_TypeCocoDict]]
@@ -569,6 +579,54 @@ def load_predictions(self, predictions: list[_TypeCocoDict]) -> EvaluationDatase
569579
EPS = np.finfo(np.float32).eps
570580

571581

582+
def _mask_iou_with_jaccard(
583+
masks_true: list[npt.NDArray[np.bool_]],
584+
masks_detection: list[npt.NDArray[np.bool_]],
585+
is_crowd: list[bool],
586+
) -> npt.NDArray[np.float64]:
587+
"""
588+
Calculate the IoU between detection masks (dt) and ground-truth masks (gt),
589+
following the COCO convention: a detection may match any subregion of a
590+
crowd ground truth, so for crowd rows the union collapses to the detection
591+
area (mask counterpart of `box_iou_batch_with_jaccard`).
592+
593+
Args:
594+
masks_true: List of ground-truth masks of shape `(H, W)`.
595+
masks_detection: List of detection masks of shape `(H, W)`.
596+
is_crowd: List indicating if each ground-truth mask is a crowd region.
597+
598+
Returns:
599+
Array of IoU values of shape `(len(masks_detection), len(masks_true))`.
600+
"""
601+
if len(masks_detection) == 0 or len(masks_true) == 0:
602+
return np.empty((len(masks_detection), len(masks_true)), dtype=np.float64)
603+
604+
gt_masks = np.stack(masks_true).astype(bool)
605+
dt_masks = np.stack(masks_detection).astype(bool)
606+
crowd = np.asarray(is_crowd, dtype=bool)
607+
608+
# Compute base IoU via the optimised path (float32 + memory-chunked).
609+
# mask_iou_batch returns (gt, dt); the evaluator expects (dt, gt).
610+
iou: npt.NDArray[np.float64] = mask_iou_batch(gt_masks, dt_masks).T.astype(
611+
np.float64
612+
)
613+
614+
if not np.any(crowd):
615+
return iou
616+
617+
# Override crowd columns: COCO convention collapses the union to the
618+
# detection area, so a small detection inside a large crowd region scores
619+
# IoU ≈ 1. Recompute only the crowd columns to avoid a full float64 matmul.
620+
eps = np.spacing(1)
621+
crowd_idx = np.where(crowd)[0]
622+
gt_flat = gt_masks[crowd_idx].reshape(len(crowd_idx), -1).astype(np.float32)
623+
dt_flat = dt_masks.reshape(dt_masks.shape[0], -1).astype(np.float32)
624+
area_inter = (dt_flat @ gt_flat.T).astype(np.float64) # (dt, N_crowd)
625+
area_dt = dt_flat.sum(axis=1).astype(np.float64) # (dt,)
626+
iou[:, crowd_idx] = area_inter / (area_dt[:, None] + eps)
627+
return iou
628+
629+
572630
class ObjectSize(Enum):
573631
"""
574632
Enum for object size.
@@ -624,14 +682,19 @@ class COCOEvaluator:
624682
"""
625683

626684
def __init__(
627-
self, coco_targets: EvaluationDataset, coco_predictions: EvaluationDataset
685+
self,
686+
coco_targets: EvaluationDataset,
687+
coco_predictions: EvaluationDataset,
688+
metric_target: MetricTarget = MetricTarget.BOXES,
628689
) -> None:
629690
"""
630691
Constructor of COCOEvaluator object.
631692
632693
Args:
633694
coco_targets: The dataset with the ground truths.
634695
coco_predictions: The dataset with the predictions.
696+
metric_target: The type of detection data used to compute the IoU -
697+
boxes, masks or oriented bounding boxes.
635698
"""
636699
if coco_targets is None:
637700
raise ValueError("coco_targets must be provided")
@@ -640,6 +703,7 @@ def __init__(
640703

641704
self.coco_targets = coco_targets
642705
self.coco_predictions = coco_predictions
706+
self.metric_target = metric_target
643707
# List of dictionaries containing the evaluation results
644708
# len(eval_imgs) = (categories) * (area_ranges) * (images)
645709
# For COCO 2017: len(eval_images) = 80 * 4 * 5000 = 1600000
@@ -700,7 +764,8 @@ def _prepare_targets_and_predictions(self) -> None:
700764
def _compute_iou(self, img_id: int, cat_id: int) -> npt.NDArray[np.float32]:
701765
"""
702766
Compute the IoU between the targets and predictions for a given image and
703-
category.
767+
category, using boxes, masks or oriented bounding boxes depending on the
768+
configured metric target.
704769
705770
Args:
706771
img_id: The image id.
@@ -727,16 +792,30 @@ def _compute_iou(self, img_id: int, cat_id: int) -> npt.NDArray[np.float32]:
727792
if len(dt) > self.params.max_dets[-1]:
728793
dt = dt[0 : self.params.max_dets[-1]]
729794

730-
gt_boxes = [g["bbox"] for g in gt]
731-
dt_boxes = [d["bbox"] for d in dt]
732-
733795
# Get the iscrowd flag for each gt
734796
is_crowd = [bool(o["iscrowd"]) for o in gt]
735-
# Compute iou between each prediction a and gt region
736-
iou = box_iou_batch_with_jaccard(gt_boxes, dt_boxes, is_crowd).astype(
737-
np.float32
738-
)
739-
return iou
797+
798+
# Compute iou between each prediction and gt region
799+
if self.metric_target == MetricTarget.MASKS:
800+
iou = _mask_iou_with_jaccard(
801+
[g["content"] for g in gt], [d["content"] for d in dt], is_crowd
802+
)
803+
elif self.metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
804+
# Crowd regions are not supported for oriented boxes; the standard
805+
# IoU is used for every ground truth.
806+
if len(gt) == 0 or len(dt) == 0:
807+
iou = np.empty((len(dt), len(gt)), dtype=np.float64)
808+
else:
809+
gt_obb = np.stack([g["content"] for g in gt])
810+
dt_obb = np.stack([d["content"] for d in dt])
811+
# oriented_box_iou_batch returns (gt, dt);
812+
# the evaluator expects (dt, gt).
813+
iou = oriented_box_iou_batch(gt_obb, dt_obb).T.astype(np.float64)
814+
else:
815+
gt_boxes = [g["bbox"] for g in gt]
816+
dt_boxes = [d["bbox"] for d in dt]
817+
iou = box_iou_batch_with_jaccard(gt_boxes, dt_boxes, is_crowd)
818+
return iou.astype(np.float32)
740819

741820
def _evaluate_image(
742821
self,
@@ -1379,6 +1458,43 @@ def update(
13791458

13801459
return self
13811460

1461+
def _detections_content(self, detections: Detections) -> npt.NDArray[Any] | None:
1462+
"""Return per-detection masks or oriented boxes for the metric target,
1463+
or `None` for the box target and for empty detections."""
1464+
if self._metric_target == MetricTarget.BOXES or len(detections) == 0:
1465+
return None
1466+
if self._metric_target == MetricTarget.MASKS:
1467+
if detections.mask is None:
1468+
raise ValueError(
1469+
"MeanAveragePrecision with `MetricTarget.MASKS` requires"
1470+
" masks on both predictions and targets."
1471+
)
1472+
return np.asarray(detections.mask).astype(bool)
1473+
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
1474+
obb = detections.data.get(ORIENTED_BOX_COORDINATES)
1475+
if obb is None:
1476+
raise ValueError(
1477+
"MeanAveragePrecision with"
1478+
" `MetricTarget.ORIENTED_BOUNDING_BOXES` requires"
1479+
f" `{ORIENTED_BOX_COORDINATES}` in `data` on both"
1480+
" predictions and targets."
1481+
)
1482+
return np.asarray(obb, dtype=np.float32).reshape(-1, 4, 2)
1483+
raise ValueError(f"Invalid metric target: {self._metric_target}")
1484+
1485+
def _content_area(
1486+
self, xywh: list[float], content: npt.NDArray[Any] | None, idx: int
1487+
) -> float:
1488+
"""Compute the default annotation area for the metric target: bbox area
1489+
for boxes, pixel count for masks, polygon area for oriented boxes."""
1490+
if content is None:
1491+
return float(xywh[2] * xywh[3])
1492+
if self._metric_target == MetricTarget.MASKS:
1493+
return float(np.count_nonzero(content[idx]))
1494+
x, y = content[idx, :, 0], content[idx, :, 1]
1495+
# Shoelace formula
1496+
return float(0.5 * abs(np.sum(x * np.roll(y, -1) - np.roll(x, -1) * y)))
1497+
13821498
def _prepare_targets(
13831499
self, targets: list[Detections]
13841500
) -> dict[str, list[_TypeCocoDict]]:
@@ -1396,6 +1512,7 @@ def _prepare_targets(
13961512
if image_targets.xyxy is None:
13971513
continue
13981514

1515+
content = self._detections_content(image_targets)
13991516
for target_idx, xyxy in enumerate(image_targets.xyxy):
14001517
xywh = [xyxy[0], xyxy[1], xyxy[2] - xyxy[0], xyxy[3] - xyxy[1]]
14011518

@@ -1409,7 +1526,8 @@ def _prepare_targets(
14091526
else:
14101527
category_id = int(cls_id)
14111528

1412-
# Use area from data if available, otherwise calculate from bbox
1529+
# Use area from data if available, otherwise calculate from the
1530+
# metric target content (bbox, mask or oriented box)
14131531
area = None
14141532
if image_targets.data is not None and "area" in image_targets.data:
14151533
area_data: npt.NDArray[np.float32] = np.asarray(
@@ -1418,7 +1536,7 @@ def _prepare_targets(
14181536
area = float(area_data[target_idx])
14191537

14201538
if area is None:
1421-
area = xywh[2] * xywh[3]
1539+
area = self._content_area(xywh, content, target_idx)
14221540

14231541
iscrowd = 0
14241542
if image_targets.data is not None and "iscrowd" in image_targets.data:
@@ -1436,6 +1554,8 @@ def _prepare_targets(
14361554
"id": len(annotations) + 1, # Start IDs from 1 (0 means no match)
14371555
"ignore": 0,
14381556
}
1557+
if content is not None:
1558+
dict_annotation["content"] = content[target_idx]
14391559
annotations.append(dict_annotation)
14401560
# Category list
14411561
all_cat_ids = {annotation["category_id"] for annotation in annotations}
@@ -1460,6 +1580,7 @@ def _prepare_predictions(
14601580
if image_predictions.xyxy is None:
14611581
continue
14621582

1583+
content = self._detections_content(image_predictions)
14631584
for pred_idx, xyxy in enumerate(image_predictions.xyxy):
14641585
xywh = [xyxy[0], xyxy[1], xyxy[2] - xyxy[0], xyxy[3] - xyxy[1]]
14651586

@@ -1476,7 +1597,8 @@ def _prepare_predictions(
14761597
if image_predictions.confidence is not None:
14771598
score = float(image_predictions.confidence[pred_idx])
14781599

1479-
# Use area from data if available, otherwise calculate from bbox
1600+
# Use area from data if available, otherwise calculate from the
1601+
# metric target content (bbox, mask or oriented box)
14801602
area = None
14811603
if (
14821604
image_predictions.data is not None
@@ -1488,7 +1610,7 @@ def _prepare_predictions(
14881610
area = float(area_data[pred_idx])
14891611

14901612
if area is None:
1491-
area = xywh[2] * xywh[3]
1613+
area = self._content_area(xywh, content, pred_idx)
14921614

14931615
dict_prediction: _TypeCocoDict = {
14941616
"image_id": image_id,
@@ -1498,6 +1620,8 @@ def _prepare_predictions(
14981620
"area": area,
14991621
"id": len(coco_predictions) + 1,
15001622
}
1623+
if content is not None:
1624+
dict_prediction["content"] = content[pred_idx]
15011625
coco_predictions.append(dict_prediction)
15021626
return coco_predictions
15031627

@@ -1526,7 +1650,7 @@ def compute(self) -> MeanAveragePrecisionResult:
15261650
# Include the predictions to coco object
15271651
coco_det = coco_gt.load_predictions(lst_predictions)
15281652
# Create a coco evaluator with the predictions
1529-
cocoEval = COCOEvaluator(coco_gt, coco_det)
1653+
cocoEval = COCOEvaluator(coco_gt, coco_det, metric_target=self._metric_target)
15301654

15311655
# Evaluate on all images
15321656
cocoEval.evaluate()

0 commit comments

Comments
 (0)