77from copy import deepcopy
88from dataclasses import dataclass
99from enum import Enum
10- from typing import TYPE_CHECKING , TypeAlias , TypedDict
10+ from typing import TYPE_CHECKING , Any , TypeAlias , TypedDict
1111
1212import numpy as np
1313import numpy .typing as npt
1414from matplotlib import pyplot as plt
1515
16+ from supervision .config import ORIENTED_BOX_COORDINATES
1617from 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+ )
1823from supervision .draw .color import LEGACY_COLOR_PALETTE
1924from supervision .metrics .core import Metric , MetricTarget
2025from 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
569579EPS = 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+
572630class 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