Add OBB support to ConfusionMatrix via MetricTarget (#2247)
* feat(metrics): OBB support in ConfusionMatrix, MASKS guard, shape validation * refactor(tests): extract reusable ConfusionMatrix MASKS test cases * refine(metrics): exclude metric_target from ConfusionMatrix.__eq__ * refactor(metrics): consolidate _assert_supported_target call sites * fix(metrics): normalise OBB (N,4,2) shape explicitly in detections_to_tensor * test(metrics): add OBB wrong-target-cols case to validate_input_tensors * test(metrics): add OBB edge-case parametrize cases to detections_to_tensor * test(metrics): replace validate_input_tensors with _validate_input_tensors in test_detection * refactor(metrics): make ConfusionMatrix unhashable * fix(metrics): add shape validation to evaluate_detection_batch * fix(metrics): cast class_id to float32 in detections_to_tensor * test(metrics): fix test_evaluate_detection_batch targets shape --------- Co-authored-by: jirka <6035284+Borda@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
cb9d3dccb8
commit
a549f44792
|
|
@ -41,6 +41,8 @@ date_modified: 2026-06-09
|
|||
|
||||
- Added [#932](https://github.com/roboflow/supervision/pull/932): [`sv.ImageAssets`](https://supervision.roboflow.com/latest/assets/) for downloading sample images alongside existing video assets, useful for examples and tutorials.
|
||||
|
||||
- Added [#2247](https://github.com/roboflow/supervision/pull/2247): [`sv.ConfusionMatrix`](https://supervision.roboflow.com/metrics/detection/#confusionmatrix) now accepts `metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES`, computing IoU via `oriented_box_iou_batch` over `xyxyxyxy` coordinates. Previously, OBB inputs silently fell back to axis-aligned bounding-box IoU, producing incorrect match scores for rotated detections.
|
||||
|
||||
- Changed [#2169](https://github.com/roboflow/supervision/pull/2169): [`sv.MeanAveragePrecisionResult`](https://supervision.roboflow.com/latest/metrics/mean_average_precision/) and related metric arrays (`mAP_scores`, `ap_per_class`, `iou_thresholds`, precision/recall) are now `float32` instead of `float64`. Reduces memory and speeds up computation; numerical results may differ in the last few digits.
|
||||
|
||||
- Changed [#2178](https://github.com/roboflow/supervision/pull/2178): [`sv.rle_to_mask`](https://supervision.roboflow.com/latest/detection/utils/converters/#supervision.detection.utils.converters.rle_to_mask) and [`sv.mask_to_rle`](https://supervision.roboflow.com/latest/detection/utils/converters/#supervision.detection.utils.converters.mask_to_rle) moved to `supervision.detection.utils.converters`. The old import path `supervision.dataset.utils` continues to work but is deprecated.
|
||||
|
|
|
|||
|
|
@ -9,30 +9,128 @@ import numpy as np
|
|||
import numpy.typing as npt
|
||||
from deprecate import TargetMode, deprecated, deprecated_class, void
|
||||
|
||||
from supervision.config import ORIENTED_BOX_COORDINATES
|
||||
from supervision.dataset.core import DetectionDataset
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.detection.utils.iou_and_nms import box_iou_batch
|
||||
from supervision.detection.utils.iou_and_nms import (
|
||||
box_iou_batch,
|
||||
oriented_box_iou_batch,
|
||||
)
|
||||
from supervision.metrics.core import MetricTarget
|
||||
|
||||
|
||||
def _assert_supported_target(metric_target: MetricTarget) -> None:
|
||||
if metric_target == MetricTarget.MASKS:
|
||||
raise ValueError(
|
||||
"MetricTarget.MASKS is not currently supported for ConfusionMatrix."
|
||||
)
|
||||
|
||||
|
||||
def detections_to_tensor(
|
||||
detections: Detections, with_confidence: bool = False
|
||||
detections: Detections,
|
||||
with_confidence: bool = False,
|
||||
metric_target: MetricTarget = MetricTarget.BOXES,
|
||||
) -> npt.NDArray[np.float32]:
|
||||
"""
|
||||
Convert Supervision Detections to numpy tensors for further computation
|
||||
Convert Supervision Detections to a numpy tensor for metric computation.
|
||||
|
||||
Args:
|
||||
detections: Detections/Targets in the format of sv.Detections
|
||||
with_confidence: Whether to include confidence in the tensor
|
||||
detections: Detections/Targets in the format of sv.Detections.
|
||||
with_confidence: Whether to include confidence as the last column.
|
||||
metric_target: The type of detection data to use.
|
||||
Supports `MetricTarget.BOXES` and
|
||||
`MetricTarget.ORIENTED_BOUNDING_BOXES`.
|
||||
|
||||
Returns:
|
||||
Detections as numpy tensors as in (xyxy, class_id, confidence) order
|
||||
Detections as a float32 numpy array. Shape depends on `metric_target`
|
||||
and `with_confidence`:
|
||||
|
||||
| `metric_target` | `with_confidence` | shape |
|
||||
|----------------------------------------|-------------------|-----------|
|
||||
| `MetricTarget.BOXES` | `False` | `(N, 5)` |
|
||||
| `MetricTarget.BOXES` | `True` | `(N, 6)` |
|
||||
| `MetricTarget.ORIENTED_BOUNDING_BOXES` | `False` | `(N, 9)` |
|
||||
| `MetricTarget.ORIENTED_BOUNDING_BOXES` | `True` | `(N, 10)` |
|
||||
|
||||
Column layout:
|
||||
|
||||
- `BOXES`: ``[x_min, y_min, x_max, y_max, class_id [, confidence]]``
|
||||
- `ORIENTED_BOUNDING_BOXES`:
|
||||
``[x1, y1, x2, y2, x3, y3, x4, y4, class_id [, confidence]]``
|
||||
|
||||
Raises:
|
||||
ValueError: If `metric_target` is `MetricTarget.MASKS`.
|
||||
ValueError: If `detections.class_id` is `None`.
|
||||
ValueError: If `with_confidence=True` and `detections.confidence` is `None`.
|
||||
ValueError: If `metric_target` is `MetricTarget.ORIENTED_BOUNDING_BOXES`
|
||||
and `detections.data` does not contain `ORIENTED_BOX_COORDINATES`,
|
||||
or if the stored array does not have exactly `N * 8` elements.
|
||||
|
||||
Examples:
|
||||
```pycon
|
||||
>>> import numpy as np
|
||||
>>> import supervision as sv
|
||||
>>> from supervision.metrics.core import MetricTarget
|
||||
>>> from supervision.config import ORIENTED_BOX_COORDINATES
|
||||
>>> detections = sv.Detections(
|
||||
... xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
... class_id=np.array([0]),
|
||||
... confidence=np.array([0.9]),
|
||||
... )
|
||||
>>> tensor = detections_to_tensor(detections, with_confidence=True)
|
||||
>>> tensor.shape
|
||||
(1, 6)
|
||||
>>> obb_coords = np.array([[0, 0, 10, 0, 10, 10, 0, 10]], dtype=np.float32)
|
||||
>>> det_obb = sv.Detections(
|
||||
... xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
... class_id=np.array([0]),
|
||||
... data={ORIENTED_BOX_COORDINATES: obb_coords},
|
||||
... )
|
||||
>>> tensor_obb = detections_to_tensor(
|
||||
... det_obb, metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES
|
||||
... )
|
||||
>>> tensor_obb.shape
|
||||
(1, 9)
|
||||
|
||||
```
|
||||
"""
|
||||
_assert_supported_target(metric_target)
|
||||
|
||||
if detections.class_id is None:
|
||||
raise ValueError(
|
||||
"ConfusionMatrix can only be calculated for Detections with class_id"
|
||||
)
|
||||
|
||||
arrays_to_concat = [detections.xyxy, np.expand_dims(detections.class_id, 1)]
|
||||
if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
|
||||
obb = detections.data.get(ORIENTED_BOX_COORDINATES)
|
||||
if obb is None:
|
||||
if len(detections) > 0:
|
||||
raise ValueError(
|
||||
"ORIENTED_BOUNDING_BOXES requested, but "
|
||||
f"{ORIENTED_BOX_COORDINATES} is missing from detections.data"
|
||||
)
|
||||
box_data = np.empty((0, 8), dtype=np.float32)
|
||||
else:
|
||||
obb_arr = np.asarray(obb, dtype=np.float32)
|
||||
# Normalize (N, 4, 2) → (N, 8) as produced by from_ultralytics.
|
||||
if obb_arr.ndim == 3 and obb_arr.shape[1:] == (4, 2):
|
||||
obb_arr = obb_arr.reshape(-1, 8)
|
||||
if obb_arr.size != len(detections) * 8:
|
||||
raise ValueError(
|
||||
f"Expected {ORIENTED_BOX_COORDINATES} to contain "
|
||||
f"{len(detections) * 8} elements "
|
||||
f"(N={len(detections)} detections x 8 coordinates), "
|
||||
f"but got {obb_arr.size}. "
|
||||
"Each OBB must be stored as [x1, y1, x2, y2, x3, y3, x4, y4]."
|
||||
)
|
||||
box_data = obb_arr.reshape(-1, 8)
|
||||
else:
|
||||
box_data = detections.xyxy
|
||||
|
||||
arrays_to_concat = [
|
||||
box_data,
|
||||
np.expand_dims(detections.class_id.astype(np.float32), 1),
|
||||
]
|
||||
|
||||
if with_confidence:
|
||||
if detections.confidence is None:
|
||||
|
|
@ -48,6 +146,7 @@ def detections_to_tensor(
|
|||
def _validate_input_tensors(
|
||||
predictions: list[npt.NDArray[np.float32]],
|
||||
targets: list[npt.NDArray[np.float32]],
|
||||
metric_target: MetricTarget = MetricTarget.BOXES,
|
||||
) -> None:
|
||||
"""
|
||||
Checks for shape consistency of input tensors.
|
||||
|
|
@ -62,17 +161,26 @@ def _validate_input_tensors(
|
|||
targets[0], np.ndarray
|
||||
):
|
||||
raise ValueError(
|
||||
f"Predictions and targets must be lists of numpy arrays."
|
||||
"Predictions and targets must be lists of numpy arrays. "
|
||||
f"Got {type(predictions[0])} and {type(targets[0])} instead."
|
||||
)
|
||||
if predictions[0].shape[1] != 6:
|
||||
|
||||
expected_pred_cols = (
|
||||
10 if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES else 6
|
||||
)
|
||||
expected_target_cols = (
|
||||
9 if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES else 5
|
||||
)
|
||||
|
||||
if predictions[0].shape[1] != expected_pred_cols:
|
||||
raise ValueError(
|
||||
f"Predictions must have shape (N, 6)."
|
||||
f"Predictions must have shape (N, {expected_pred_cols}). "
|
||||
f"Got {predictions[0].shape} instead."
|
||||
)
|
||||
if targets[0].shape[1] != 5:
|
||||
if targets[0].shape[1] != expected_target_cols:
|
||||
raise ValueError(
|
||||
f"Targets must have shape (N, 5). Got {targets[0].shape} instead."
|
||||
f"Targets must have shape (N, {expected_target_cols}). "
|
||||
f"Got {targets[0].shape} instead."
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -101,12 +209,30 @@ class ConfusionMatrix:
|
|||
Detections with lower confidence will be excluded from the matrix.
|
||||
iou_threshold: Detection IoU threshold between `0` and `1`.
|
||||
Detections with lower IoU will be classified as `FP`.
|
||||
metric_target: The type of detection data used for IoU computation.
|
||||
Informational metadata set by `from_detections` and `from_tensors`.
|
||||
Excluded from `__eq__` comparisons — two `ConfusionMatrix` instances
|
||||
with identical `matrix`, `classes`, `conf_threshold`, and
|
||||
`iou_threshold` compare as equal regardless of `metric_target`.
|
||||
"""
|
||||
|
||||
matrix: npt.NDArray[np.int32]
|
||||
classes: list[str]
|
||||
conf_threshold: float
|
||||
iou_threshold: float
|
||||
metric_target: MetricTarget = MetricTarget.BOXES
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, ConfusionMatrix):
|
||||
return NotImplemented
|
||||
return (
|
||||
np.array_equal(self.matrix, other.matrix)
|
||||
and self.classes == other.classes
|
||||
and self.conf_threshold == other.conf_threshold
|
||||
and self.iou_threshold == other.iou_threshold
|
||||
)
|
||||
|
||||
__hash__ = None # type: ignore[assignment]
|
||||
|
||||
@classmethod
|
||||
def from_detections(
|
||||
|
|
@ -116,6 +242,7 @@ class ConfusionMatrix:
|
|||
classes: list[str],
|
||||
conf_threshold: float = 0.3,
|
||||
iou_threshold: float = 0.5,
|
||||
metric_target: MetricTarget = MetricTarget.BOXES,
|
||||
) -> ConfusionMatrix:
|
||||
"""
|
||||
Calculate confusion matrix based on predicted and ground-truth detections.
|
||||
|
|
@ -128,6 +255,15 @@ class ConfusionMatrix:
|
|||
Detections with lower confidence will be excluded.
|
||||
iou_threshold: Detection IoU threshold between `0` and `1`.
|
||||
Detections with lower IoU will be classified as `FP`.
|
||||
metric_target: The type of detection data to use.
|
||||
Supports `MetricTarget.BOXES` (default) and
|
||||
`MetricTarget.ORIENTED_BOUNDING_BOXES`. When using
|
||||
`MetricTarget.ORIENTED_BOUNDING_BOXES`, each `Detections`
|
||||
object must include OBB coordinates in
|
||||
`detections.data[ORIENTED_BOX_COORDINATES]` as a float32
|
||||
array of shape `(N, 8)` (flat) or `(N, 4, 2)` (as stored by
|
||||
`from_ultralytics`); both are normalised to `(N, 8)` internally.
|
||||
`MetricTarget.MASKS` is not supported.
|
||||
|
||||
Returns:
|
||||
New instance of ConfusionMatrix.
|
||||
|
|
@ -138,15 +274,15 @@ class ConfusionMatrix:
|
|||
>>> import supervision as sv
|
||||
>>> targets = [
|
||||
... sv.Detections(
|
||||
... xyxy=np.array([[0, 0, 10, 10]]),
|
||||
... class_id=np.array([0])
|
||||
... xyxy=np.array([[0, 0, 10, 10], [50, 50, 60, 60]]),
|
||||
... class_id=np.array([0, 0])
|
||||
... )
|
||||
... ]
|
||||
>>> predictions = [
|
||||
... sv.Detections(
|
||||
... xyxy=np.array([[0, 0, 10, 10]]),
|
||||
... class_id=np.array([0]),
|
||||
... confidence=np.array([0.9])
|
||||
... xyxy=np.array([[0, 0, 10, 10], [100, 100, 110, 110]]),
|
||||
... class_id=np.array([0, 0]),
|
||||
... confidence=np.array([0.9, 0.8])
|
||||
... )
|
||||
... ]
|
||||
>>> confusion_matrix = sv.ConfusionMatrix.from_detections(
|
||||
|
|
@ -155,8 +291,8 @@ class ConfusionMatrix:
|
|||
... classes=['person']
|
||||
... )
|
||||
>>> confusion_matrix.matrix
|
||||
array([[1., 0.],
|
||||
[0., 0.]])
|
||||
array([[1., 1.],
|
||||
[1., 0.]])
|
||||
|
||||
```
|
||||
"""
|
||||
|
|
@ -164,15 +300,22 @@ class ConfusionMatrix:
|
|||
target_tensors = []
|
||||
for prediction, target in zip(predictions, targets):
|
||||
prediction_tensors.append(
|
||||
detections_to_tensor(prediction, with_confidence=True)
|
||||
detections_to_tensor(
|
||||
prediction, with_confidence=True, metric_target=metric_target
|
||||
)
|
||||
)
|
||||
target_tensors.append(
|
||||
detections_to_tensor(
|
||||
target, with_confidence=False, metric_target=metric_target
|
||||
)
|
||||
)
|
||||
target_tensors.append(detections_to_tensor(target, with_confidence=False))
|
||||
return cls.from_tensors(
|
||||
predictions=prediction_tensors,
|
||||
targets=target_tensors,
|
||||
classes=classes,
|
||||
conf_threshold=conf_threshold,
|
||||
iou_threshold=iou_threshold,
|
||||
metric_target=metric_target,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -183,24 +326,34 @@ class ConfusionMatrix:
|
|||
classes: list[str],
|
||||
conf_threshold: float = 0.3,
|
||||
iou_threshold: float = 0.5,
|
||||
metric_target: MetricTarget = MetricTarget.BOXES,
|
||||
) -> ConfusionMatrix:
|
||||
"""
|
||||
Calculate confusion matrix based on predicted and ground-truth detections.
|
||||
|
||||
Args:
|
||||
predictions: Each element of the list describes a single
|
||||
image and has `shape = (M, 6)` where `M` is the number of detected
|
||||
objects. Each row is expected to be in
|
||||
image and has `shape = (M, 6)` or `shape = (M, 10)` depending on
|
||||
`metric_target`.
|
||||
If `MetricTarget.BOXES`, each row is in
|
||||
`(x_min, y_min, x_max, y_max, class, conf)` format.
|
||||
If `MetricTarget.ORIENTED_BOUNDING_BOXES`, each row is in
|
||||
`(x1, y1, x2, y2, x3, y3, x4, y4, class, conf)` format.
|
||||
targets: Each element of the list describes a single
|
||||
image and has `shape = (N, 5)` where `N` is the number of
|
||||
ground-truth objects. Each row is expected to be in
|
||||
image and has `shape = (N, 5)` or `shape = (N, 9)` depending on
|
||||
`metric_target`.
|
||||
If `MetricTarget.BOXES`, each row is in
|
||||
`(x_min, y_min, x_max, y_max, class)` format.
|
||||
If `MetricTarget.ORIENTED_BOUNDING_BOXES`, each row is in
|
||||
`(x1, y1, x2, y2, x3, y3, x4, y4, class)` format.
|
||||
classes: Model class names.
|
||||
conf_threshold: Detection confidence threshold between `0` and `1`.
|
||||
Detections with lower confidence will be excluded.
|
||||
iou_threshold: Detection iou threshold between `0` and `1`.
|
||||
iou_threshold: Detection iou threshold between `0` and `1`.
|
||||
Detections with lower iou will be classified as `FP`.
|
||||
metric_target: The type of detection data to use.
|
||||
Determines expected tensor shapes (see Args above for column
|
||||
layouts). `MetricTarget.MASKS` is not supported.
|
||||
|
||||
Returns:
|
||||
New instance of ConfusionMatrix.
|
||||
|
|
@ -235,7 +388,8 @@ class ConfusionMatrix:
|
|||
|
||||
```
|
||||
"""
|
||||
_validate_input_tensors(predictions, targets)
|
||||
_assert_supported_target(metric_target)
|
||||
_validate_input_tensors(predictions, targets, metric_target=metric_target)
|
||||
|
||||
num_classes = len(classes)
|
||||
matrix = np.zeros((num_classes + 1, num_classes + 1))
|
||||
|
|
@ -246,12 +400,14 @@ class ConfusionMatrix:
|
|||
num_classes=num_classes,
|
||||
conf_threshold=conf_threshold,
|
||||
iou_threshold=iou_threshold,
|
||||
metric_target=metric_target,
|
||||
)
|
||||
return cls(
|
||||
matrix=matrix,
|
||||
classes=classes,
|
||||
conf_threshold=conf_threshold,
|
||||
iou_threshold=iou_threshold,
|
||||
metric_target=metric_target,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -261,38 +417,70 @@ class ConfusionMatrix:
|
|||
num_classes: int,
|
||||
conf_threshold: float,
|
||||
iou_threshold: float,
|
||||
metric_target: MetricTarget = MetricTarget.BOXES,
|
||||
) -> npt.NDArray[np.int32]:
|
||||
"""
|
||||
Calculate confusion matrix for a batch of detections for a single image.
|
||||
|
||||
Args:
|
||||
predictions: Batch prediction. Describes a single image and
|
||||
has `shape = (M, 6)` where `M` is the number of detected objects.
|
||||
Each row is expected to be in
|
||||
has `shape = (M, 6)` or `shape = (M, 10)` depending on
|
||||
`metric_target`.
|
||||
If `MetricTarget.BOXES`, each row is in
|
||||
`(x_min, y_min, x_max, y_max, class, conf)` format.
|
||||
If `MetricTarget.ORIENTED_BOUNDING_BOXES`, each row is in
|
||||
`(x1, y1, x2, y2, x3, y3, x4, y4, class, conf)` format.
|
||||
targets: Batch target labels. Describes a single image and
|
||||
has `shape = (N, 5)` where `N` is the number of ground-truth objects.
|
||||
Each row is expected to be in
|
||||
has `shape = (N, 5)` or `shape = (N, 9)` depending on
|
||||
`metric_target`.
|
||||
If `MetricTarget.BOXES`, each row is in
|
||||
`(x_min, y_min, x_max, y_max, class)` format.
|
||||
If `MetricTarget.ORIENTED_BOUNDING_BOXES`, each row is in
|
||||
`(x1, y1, x2, y2, x3, y3, x4, y4, class)` format.
|
||||
num_classes: Number of classes.
|
||||
conf_threshold: Detection confidence threshold between `0` and `1`.
|
||||
Detections with lower confidence will be excluded.
|
||||
iou_threshold: Detection iou threshold between `0` and `1`.
|
||||
iou_threshold: Detection iou threshold between `0` and `1`.
|
||||
Detections with lower iou will be classified as `FP`.
|
||||
metric_target: The type of detection data to use.
|
||||
Determines IoU function (`box_iou_batch` vs
|
||||
`oriented_box_iou_batch`) and coordinate column count.
|
||||
`MetricTarget.MASKS` is not supported.
|
||||
|
||||
Returns:
|
||||
Confusion matrix based on a single image.
|
||||
"""
|
||||
_assert_supported_target(metric_target)
|
||||
|
||||
expected_pred_cols = (
|
||||
10 if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES else 6
|
||||
)
|
||||
expected_target_cols = (
|
||||
9 if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES else 5
|
||||
)
|
||||
if predictions.ndim != 2 or predictions.shape[1] != expected_pred_cols:
|
||||
raise ValueError(
|
||||
f"Predictions must have shape (M, {expected_pred_cols}). "
|
||||
f"Got {predictions.shape} instead."
|
||||
)
|
||||
if targets.ndim != 2 or targets.shape[1] != expected_target_cols:
|
||||
raise ValueError(
|
||||
f"Targets must have shape (N, {expected_target_cols}). "
|
||||
f"Got {targets.shape} instead."
|
||||
)
|
||||
|
||||
result_matrix = np.zeros((num_classes + 1, num_classes + 1))
|
||||
|
||||
# Filter predictions by confidence threshold
|
||||
conf_idx = 5
|
||||
coords_dim = 8 if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES else 4
|
||||
class_id_idx = coords_dim
|
||||
conf_idx = coords_dim + 1
|
||||
|
||||
confidence = predictions[:, conf_idx]
|
||||
detection_batch_filtered = predictions[confidence >= conf_threshold]
|
||||
|
||||
if len(detection_batch_filtered) == 0:
|
||||
# No detections pass confidence threshold - all GT are FN
|
||||
class_id_idx = 4
|
||||
true_classes = np.array(targets[:, class_id_idx], dtype=np.int16)
|
||||
for gt_class in true_classes:
|
||||
result_matrix[gt_class, num_classes] += 1
|
||||
|
|
@ -300,7 +488,6 @@ class ConfusionMatrix:
|
|||
|
||||
if len(targets) == 0:
|
||||
# No ground truth - all detections are FP
|
||||
class_id_idx = 4
|
||||
detection_classes = np.array(
|
||||
detection_batch_filtered[:, class_id_idx], dtype=np.int16
|
||||
)
|
||||
|
|
@ -308,18 +495,22 @@ class ConfusionMatrix:
|
|||
result_matrix[num_classes, det_class] += 1
|
||||
return result_matrix
|
||||
|
||||
class_id_idx = 4
|
||||
true_classes = np.array(targets[:, class_id_idx], dtype=np.int16)
|
||||
detection_classes = np.array(
|
||||
detection_batch_filtered[:, class_id_idx], dtype=np.int16
|
||||
)
|
||||
true_boxes = targets[:, :class_id_idx]
|
||||
detection_boxes = detection_batch_filtered[:, :class_id_idx]
|
||||
true_boxes = targets[:, :coords_dim]
|
||||
detection_boxes = detection_batch_filtered[:, :coords_dim]
|
||||
|
||||
# Calculate IoU matrix
|
||||
iou_batch = box_iou_batch(
|
||||
boxes_true=true_boxes, boxes_detection=detection_boxes
|
||||
)
|
||||
if metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
|
||||
iou_batch = oriented_box_iou_batch(
|
||||
boxes_true=true_boxes, boxes_detection=detection_boxes
|
||||
)
|
||||
else:
|
||||
iou_batch = box_iou_batch(
|
||||
boxes_true=true_boxes, boxes_detection=detection_boxes
|
||||
)
|
||||
|
||||
# Find all valid matches (IoU > threshold, regardless of class)
|
||||
# Use vectorized operations to avoid nested Python loops
|
||||
|
|
@ -405,6 +596,7 @@ class ConfusionMatrix:
|
|||
callback: Callable[[npt.NDArray[np.uint8]], Detections],
|
||||
conf_threshold: float = 0.3,
|
||||
iou_threshold: float = 0.5,
|
||||
metric_target: MetricTarget = MetricTarget.BOXES,
|
||||
) -> ConfusionMatrix:
|
||||
"""
|
||||
Calculate confusion matrix from dataset and callback function.
|
||||
|
|
@ -417,6 +609,10 @@ class ConfusionMatrix:
|
|||
Detections with lower confidence will be excluded.
|
||||
iou_threshold: Detection IoU threshold between `0` and `1`.
|
||||
Detections with lower IoU will be classified as `FP`.
|
||||
metric_target: The type of detection data to use.
|
||||
Supports `MetricTarget.BOXES` and
|
||||
`MetricTarget.ORIENTED_BOUNDING_BOXES`. Passed through to
|
||||
`from_detections`. `MetricTarget.MASKS` is not supported.
|
||||
|
||||
Returns:
|
||||
New instance of ConfusionMatrix.
|
||||
|
|
@ -458,6 +654,7 @@ class ConfusionMatrix:
|
|||
classes=dataset.classes,
|
||||
conf_threshold=conf_threshold,
|
||||
iou_threshold=iou_threshold,
|
||||
metric_target=metric_target,
|
||||
)
|
||||
|
||||
def plot(
|
||||
|
|
|
|||
|
|
@ -8,9 +8,11 @@ import pytest
|
|||
|
||||
from supervision.dataset.core import DetectionDataset
|
||||
from supervision.detection.core import Detections
|
||||
from supervision.metrics.core import MetricTarget
|
||||
from supervision.metrics.detection import (
|
||||
ConfusionMatrix,
|
||||
MeanAveragePrecision,
|
||||
_validate_input_tensors,
|
||||
detections_to_tensor,
|
||||
)
|
||||
from tests.helpers import (
|
||||
|
|
@ -20,6 +22,46 @@ from tests.helpers import (
|
|||
)
|
||||
|
||||
|
||||
def _call_confusion_matrix_from_detections_masks() -> None:
|
||||
ConfusionMatrix.from_detections(
|
||||
predictions=[
|
||||
Detections(
|
||||
xyxy=np.zeros((1, 4), dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.9]),
|
||||
)
|
||||
],
|
||||
targets=[
|
||||
Detections(
|
||||
xyxy=np.zeros((1, 4), dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
)
|
||||
],
|
||||
classes=["box"],
|
||||
metric_target=MetricTarget.MASKS,
|
||||
)
|
||||
|
||||
|
||||
def _call_confusion_matrix_from_tensors_masks() -> None:
|
||||
ConfusionMatrix.from_tensors(
|
||||
predictions=[np.zeros((1, 6), dtype=np.float32)],
|
||||
targets=[np.zeros((1, 5), dtype=np.float32)],
|
||||
classes=["box"],
|
||||
metric_target=MetricTarget.MASKS,
|
||||
)
|
||||
|
||||
|
||||
def _call_confusion_matrix_evaluate_detection_batch_masks() -> None:
|
||||
ConfusionMatrix.evaluate_detection_batch(
|
||||
predictions=np.zeros((1, 6), dtype=np.float32),
|
||||
targets=np.zeros((1, 5), dtype=np.float32),
|
||||
num_classes=1,
|
||||
conf_threshold=0.3,
|
||||
iou_threshold=0.5,
|
||||
metric_target=MetricTarget.MASKS,
|
||||
)
|
||||
|
||||
|
||||
class TestDetectionMetrics:
|
||||
"""
|
||||
Verify that detection metrics are computed accurately.
|
||||
|
|
@ -141,25 +183,48 @@ class TestDetectionMetrics:
|
|||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("detections", "with_confidence", "expected_result", "exception"),
|
||||
(
|
||||
"detections",
|
||||
"with_confidence",
|
||||
"metric_target",
|
||||
"expected_result",
|
||||
"exception",
|
||||
),
|
||||
[
|
||||
(
|
||||
Detections.empty(),
|
||||
False,
|
||||
MetricTarget.BOXES,
|
||||
np.empty((0, 5), dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
), # empty detections; no confidence
|
||||
(
|
||||
Detections.empty(),
|
||||
True,
|
||||
MetricTarget.BOXES,
|
||||
np.empty((0, 6), dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
), # empty detections; with confidence
|
||||
(
|
||||
Detections.empty(),
|
||||
False,
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
np.empty((0, 9), dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
), # empty OBB detections; no confidence
|
||||
(
|
||||
Detections.empty(),
|
||||
True,
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
np.empty((0, 10), dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
), # empty OBB detections; with confidence
|
||||
(
|
||||
_create_detections(
|
||||
xyxy=[[0, 0, 10, 10]], class_id=[0], confidence=[0.5]
|
||||
),
|
||||
False,
|
||||
MetricTarget.BOXES,
|
||||
np.array([[0, 0, 10, 10, 0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
), # single detection; no confidence
|
||||
|
|
@ -168,9 +233,64 @@ class TestDetectionMetrics:
|
|||
xyxy=[[0, 0, 10, 10]], class_id=[0], confidence=[0.5]
|
||||
),
|
||||
True,
|
||||
MetricTarget.BOXES,
|
||||
np.array([[0, 0, 10, 10, 0, 0.5]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
), # single detection; with confidence
|
||||
(
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.5], dtype=np.float32),
|
||||
data={
|
||||
"xyxyxyxy": np.array(
|
||||
[[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=np.float32
|
||||
)
|
||||
},
|
||||
),
|
||||
False,
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
np.array([[0, 0, 10, 0, 10, 10, 0, 10, 0]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
), # single OBB detection; no confidence
|
||||
(
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.5], dtype=np.float32),
|
||||
data={
|
||||
"xyxyxyxy": np.array(
|
||||
[[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=np.float32
|
||||
)
|
||||
},
|
||||
),
|
||||
True,
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
np.array([[0, 0, 10, 0, 10, 10, 0, 10, 0, 0.5]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
), # single OBB detection; with confidence
|
||||
(
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.5], dtype=np.float32),
|
||||
),
|
||||
False,
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
None,
|
||||
pytest.raises(ValueError, match="ORIENTED_BOUNDING_BOXES requested"),
|
||||
), # OBB requested but data missing
|
||||
(
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.5], dtype=np.float32),
|
||||
),
|
||||
False,
|
||||
MetricTarget.MASKS,
|
||||
None,
|
||||
pytest.raises(ValueError, match=r"MetricTarget\.MASKS"),
|
||||
), # MASKS requested but not supported
|
||||
(
|
||||
_create_detections(
|
||||
xyxy=[[0, 0, 10, 10], [0, 0, 20, 20]],
|
||||
|
|
@ -178,6 +298,7 @@ class TestDetectionMetrics:
|
|||
confidence=[0.5, 0.2],
|
||||
),
|
||||
False,
|
||||
MetricTarget.BOXES,
|
||||
np.array([[0, 0, 10, 10, 0], [0, 0, 20, 20, 1]], dtype=np.float32),
|
||||
DoesNotRaise(),
|
||||
), # multiple detections; no confidence
|
||||
|
|
@ -188,17 +309,63 @@ class TestDetectionMetrics:
|
|||
confidence=[0.5, 0.2],
|
||||
),
|
||||
True,
|
||||
MetricTarget.BOXES,
|
||||
np.array(
|
||||
[[0, 0, 10, 10, 0, 0.5], [0, 0, 20, 20, 1, 0.2]], dtype=np.float32
|
||||
),
|
||||
DoesNotRaise(),
|
||||
), # multiple detections; with confidence
|
||||
pytest.param(
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
data={"xyxyxyxy": np.zeros((1, 4), dtype=np.float32)},
|
||||
),
|
||||
False,
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
None,
|
||||
pytest.raises(ValueError, match="Expected xyxyxyxy to contain"),
|
||||
id="obb-malformed-element-count",
|
||||
), # OBB data present but wrong element count (4 instead of 8)
|
||||
pytest.param(
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
data={
|
||||
"xyxyxyxy": np.array(
|
||||
[[0, 0, 10, 0, 10, 10, 0, 10]], dtype=np.float32
|
||||
)
|
||||
},
|
||||
),
|
||||
True,
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
None,
|
||||
pytest.raises(ValueError, match="Detections with confidence"),
|
||||
id="obb-with-confidence-but-confidence-none",
|
||||
), # OBB + with_confidence=True but confidence is None
|
||||
pytest.param(
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
class_id=None,
|
||||
data={
|
||||
"xyxyxyxy": np.array(
|
||||
[[0, 0, 10, 0, 10, 10, 0, 10]], dtype=np.float32
|
||||
)
|
||||
},
|
||||
),
|
||||
False,
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
None,
|
||||
pytest.raises(ValueError, match="class_id"),
|
||||
id="obb-class-id-none",
|
||||
), # OBB + class_id=None
|
||||
],
|
||||
)
|
||||
def test_detections_to_tensor(
|
||||
self,
|
||||
detections: Detections,
|
||||
with_confidence: bool,
|
||||
metric_target: MetricTarget,
|
||||
expected_result: np.ndarray | None,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
|
|
@ -211,9 +378,11 @@ class TestDetectionMetrics:
|
|||
"""
|
||||
with exception:
|
||||
result = detections_to_tensor(
|
||||
detections=detections, with_confidence=with_confidence
|
||||
detections=detections,
|
||||
with_confidence=with_confidence,
|
||||
metric_target=metric_target,
|
||||
)
|
||||
assert np.array_equal(result, expected_result)
|
||||
np.testing.assert_allclose(result, expected_result, atol=1e-5)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
|
|
@ -368,7 +537,7 @@ class TestDetectionMetrics:
|
|||
[
|
||||
(
|
||||
DETECTION_TENSORS[0],
|
||||
CERTAIN_DETECTION_TENSORS[0],
|
||||
TARGET_TENSORS[0],
|
||||
NUM_CLASSES,
|
||||
0.2,
|
||||
0.5,
|
||||
|
|
@ -1033,9 +1202,353 @@ class TestDetectionMetrics:
|
|||
total_fp = confusion_matrix.matrix[: len(classes), -1].sum()
|
||||
assert total_fp >= 0, f"FP count negative ({total_fp}), computation bug"
|
||||
|
||||
# Verify IoU+class fix: wrong-class preds should become FPs, not match GTs
|
||||
# Verify IoU+class fix: wrong-class preds should become FPs,
|
||||
# not match GTs
|
||||
assert total_fp > 0 or total_tp == total_gt, (
|
||||
f"Expected FPs from wrong-class preds (got {total_fp}) or all GTs "
|
||||
f"matched (TP={total_tp}, GT={total_gt}). IoU+class fix may be broken: "
|
||||
f"wrong-class preds with high IoU might incorrectly match GTs."
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("predictions", "targets", "metric_target", "exception"),
|
||||
[
|
||||
(
|
||||
[np.zeros((1, 10), dtype=np.float32)],
|
||||
[np.zeros((1, 9), dtype=np.float32)],
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
DoesNotRaise(),
|
||||
),
|
||||
(
|
||||
[np.zeros((1, 6), dtype=np.float32)],
|
||||
[np.zeros((1, 5), dtype=np.float32)],
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
pytest.raises(ValueError, match="Predictions must have shape"),
|
||||
),
|
||||
(
|
||||
[np.zeros((1, 10), dtype=np.float32)],
|
||||
[np.zeros((1, 9), dtype=np.float32)],
|
||||
MetricTarget.BOXES,
|
||||
pytest.raises(ValueError, match="Predictions must have shape"),
|
||||
),
|
||||
pytest.param(
|
||||
[np.zeros((1, 10), dtype=np.float32)],
|
||||
[np.zeros((1, 5), dtype=np.float32)],
|
||||
MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
pytest.raises(ValueError, match="Targets must have shape"),
|
||||
id="obb-correct-pred-cols-wrong-target-cols",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_validate_input_tensors_obb(
|
||||
self, predictions, targets, metric_target, exception
|
||||
):
|
||||
|
||||
with exception:
|
||||
_validate_input_tensors(predictions, targets, metric_target=metric_target)
|
||||
|
||||
def test_confusion_matrix_obb(self):
|
||||
"""
|
||||
Verify OBB support in ConfusionMatrix.
|
||||
Test scenarios:
|
||||
1. Perfect OBB overlap (Rotation Match)
|
||||
2. Rotation Sensitivity (Same AABB, different rotation)
|
||||
3. Regression (BOXES mode)
|
||||
"""
|
||||
classes = ["box"]
|
||||
|
||||
# Perfect OBB overlap
|
||||
# 45 degree rotated box
|
||||
obb_coords = np.array(
|
||||
[[[5, 0], [10, 5], [5, 10], [0, 5]]], dtype=np.float32
|
||||
) # Diamond shape
|
||||
gt = [
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
data={"xyxyxyxy": obb_coords},
|
||||
)
|
||||
]
|
||||
pred = [
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.9]),
|
||||
data={"xyxyxyxy": obb_coords},
|
||||
)
|
||||
]
|
||||
|
||||
cm_obb = ConfusionMatrix.from_detections(
|
||||
predictions=pred,
|
||||
targets=gt,
|
||||
classes=classes,
|
||||
metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
)
|
||||
# Expected TP = 1
|
||||
assert cm_obb.matrix[0, 0] == 1
|
||||
assert cm_obb.matrix.sum() == 1
|
||||
|
||||
# Rotation Sensitivity
|
||||
# GT is 45 deg, Pred is axis-aligned box with same AABB
|
||||
aabb_coords = np.array([[[0, 0], [10, 0], [10, 10], [0, 10]]], dtype=np.float32)
|
||||
pred_aabb = [
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10]], dtype=np.float32),
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.9]),
|
||||
data={"xyxyxyxy": aabb_coords},
|
||||
)
|
||||
]
|
||||
|
||||
# In OBB mode, IoU between diamond and square is 0.5
|
||||
cm_sensitivity_obb = ConfusionMatrix.from_detections(
|
||||
predictions=pred_aabb,
|
||||
targets=gt,
|
||||
classes=classes,
|
||||
iou_threshold=0.6, # Threshold higher than 0.5
|
||||
metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
)
|
||||
# Expected FN=1, FP=1 (no match because IoU=0.5 < 0.6)
|
||||
assert cm_sensitivity_obb.matrix[0, 1] == 1 # FN
|
||||
assert cm_sensitivity_obb.matrix[1, 0] == 1 # FP
|
||||
|
||||
# In BOXES mode, they match perfectly
|
||||
cm_sensitivity_boxes = ConfusionMatrix.from_detections(
|
||||
predictions=pred_aabb,
|
||||
targets=gt,
|
||||
classes=classes,
|
||||
iou_threshold=0.6,
|
||||
metric_target=MetricTarget.BOXES,
|
||||
)
|
||||
# Expected TP = 1
|
||||
assert cm_sensitivity_boxes.matrix[0, 0] == 1
|
||||
|
||||
# Deterministic comparison: OBB IoU should be less than AABB IoU
|
||||
# Here OBB IoU is 0.5, AABB IoU is 1.0
|
||||
# We can verify this by checking that a threshold between them
|
||||
# differentiates behavior
|
||||
assert cm_sensitivity_obb.matrix[0, 0] == 0
|
||||
assert cm_sensitivity_boxes.matrix[0, 0] == 1
|
||||
|
||||
def test_confusion_matrix_obb_regression_1760(self):
|
||||
"""Regression for #1760: thin OBBs with same AABB must not match.
|
||||
|
||||
Two thin bars (100 px long, 10 px wide) at 45° and -45° share an identical
|
||||
AABB (AABB IoU = 1.0), but their actual OBB overlap is only at the crossing
|
||||
centre (OBB IoU ≈ 0.05).
|
||||
|
||||
With iou_threshold=0.5:
|
||||
- ORIENTED_BOUNDING_BOXES mode: no match → FP + FN (bug before fix: TP)
|
||||
- BOXES mode: AABB IoU=1.0 → TP (controls: confirms AABB path unbroken)
|
||||
|
||||
A regression that swaps oriented_box_iou_batch back to box_iou_batch would
|
||||
flip the OBB assertion to TP, surfacing the exact bug from issue #1760.
|
||||
"""
|
||||
classes = ["bar"]
|
||||
sq2 = np.float32(1.0 / np.sqrt(2))
|
||||
cx, cy = np.float32(100.0), np.float32(100.0)
|
||||
hl, hw = np.float32(50.0), np.float32(5.0)
|
||||
|
||||
# Bar at 45°: length direction (sq2, sq2), width direction (-sq2, sq2)
|
||||
bar_45 = np.array(
|
||||
[
|
||||
[cx + (hl - hw) * sq2, cy + (hl + hw) * sq2],
|
||||
[cx + (hl + hw) * sq2, cy + (hl - hw) * sq2],
|
||||
[cx - (hl - hw) * sq2, cy - (hl + hw) * sq2],
|
||||
[cx - (hl + hw) * sq2, cy - (hl - hw) * sq2],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
# Bar at -45°: length direction (sq2, -sq2), width direction (sq2, sq2)
|
||||
bar_neg45 = np.array(
|
||||
[
|
||||
[cx + (hl + hw) * sq2, cy - (hl - hw) * sq2],
|
||||
[cx + (hl - hw) * sq2, cy - (hl + hw) * sq2],
|
||||
[cx - (hl + hw) * sq2, cy + (hl - hw) * sq2],
|
||||
[cx - (hl - hw) * sq2, cy + (hl + hw) * sq2],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
# Both bars share the same axis-aligned bounding box
|
||||
half_aabb = (hl + hw) * sq2
|
||||
shared_xyxy = np.array(
|
||||
[[cx - half_aabb, cy - half_aabb, cx + half_aabb, cy + half_aabb]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
gt = [
|
||||
Detections(
|
||||
xyxy=shared_xyxy,
|
||||
class_id=np.array([0]),
|
||||
data={"xyxyxyxy": bar_45[np.newaxis]},
|
||||
)
|
||||
]
|
||||
pred = [
|
||||
Detections(
|
||||
xyxy=shared_xyxy,
|
||||
class_id=np.array([0]),
|
||||
confidence=np.array([0.9], dtype=np.float32),
|
||||
data={"xyxyxyxy": bar_neg45[np.newaxis]},
|
||||
)
|
||||
]
|
||||
|
||||
# OBB mode: orthogonal bars barely overlap → FP and FN, not TP
|
||||
cm_obb = ConfusionMatrix.from_detections(
|
||||
predictions=pred,
|
||||
targets=gt,
|
||||
classes=classes,
|
||||
iou_threshold=0.5,
|
||||
metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
)
|
||||
assert cm_obb.matrix[0, 0] == 0 # no TP
|
||||
assert cm_obb.matrix[0, 1] == 1 # FN
|
||||
assert cm_obb.matrix[1, 0] == 1 # FP
|
||||
|
||||
# BOXES mode: identical AABB → TP (controls that AABB path is intact)
|
||||
cm_boxes = ConfusionMatrix.from_detections(
|
||||
predictions=pred,
|
||||
targets=gt,
|
||||
classes=classes,
|
||||
iou_threshold=0.5,
|
||||
metric_target=MetricTarget.BOXES,
|
||||
)
|
||||
assert cm_boxes.matrix[0, 0] == 1
|
||||
assert cm_boxes.matrix.sum() == 1
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pred_tensor", "target_tensor", "iou_threshold", "expected_matrix"),
|
||||
[
|
||||
pytest.param(
|
||||
np.array([[5, 0, 10, 5, 5, 10, 0, 5, 0, 0.9]], dtype=np.float32),
|
||||
np.array([[5, 0, 10, 5, 5, 10, 0, 5, 0]], dtype=np.float32),
|
||||
0.5,
|
||||
np.array([[1.0, 0.0], [0.0, 0.0]]),
|
||||
id="perfect_match_tp",
|
||||
),
|
||||
pytest.param(
|
||||
np.array([[0, 0, 10, 0, 10, 10, 0, 10, 0, 0.9]], dtype=np.float32),
|
||||
np.array([[5, 0, 10, 5, 5, 10, 0, 5, 0]], dtype=np.float32),
|
||||
0.3,
|
||||
np.array([[1.0, 0.0], [0.0, 0.0]]),
|
||||
id="partial_obb_match_tp_at_threshold_0_3",
|
||||
),
|
||||
pytest.param(
|
||||
np.array([[0, 0, 10, 0, 10, 10, 0, 10, 0, 0.9]], dtype=np.float32),
|
||||
np.array([[5, 0, 10, 5, 5, 10, 0, 5, 0]], dtype=np.float32),
|
||||
0.7,
|
||||
np.array([[0.0, 1.0], [1.0, 0.0]]),
|
||||
id="partial_obb_no_match_at_threshold_0_7",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_confusion_matrix_from_tensors_obb(
|
||||
self, pred_tensor, target_tensor, iou_threshold, expected_matrix
|
||||
):
|
||||
"""Direct from_tensors OBB end-to-end coverage with pre-built tensors."""
|
||||
cm = ConfusionMatrix.from_tensors(
|
||||
predictions=[pred_tensor],
|
||||
targets=[target_tensor],
|
||||
classes=["box"],
|
||||
iou_threshold=iou_threshold,
|
||||
metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
)
|
||||
np.testing.assert_array_equal(cm.matrix, expected_matrix)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call",
|
||||
[
|
||||
pytest.param(
|
||||
_call_confusion_matrix_from_detections_masks,
|
||||
id="from_detections",
|
||||
),
|
||||
pytest.param(
|
||||
_call_confusion_matrix_from_tensors_masks,
|
||||
id="from_tensors",
|
||||
),
|
||||
pytest.param(
|
||||
_call_confusion_matrix_evaluate_detection_batch_masks,
|
||||
id="evaluate_detection_batch",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_confusion_matrix_masks_rejection(self, call):
|
||||
"""MetricTarget.MASKS raises ValueError at every public entry point."""
|
||||
with pytest.raises(ValueError, match=r"MetricTarget\.MASKS"):
|
||||
call()
|
||||
|
||||
def test_confusion_matrix_multiclass_obb(self):
|
||||
"""Multi-class OBB: TP for exact match, FP+FN for rotation mismatch."""
|
||||
classes = ["box", "circle"]
|
||||
|
||||
# Ground truth: class-0 diamond at [0,0,10,10], class-1 diamond at [20,20,30,30]
|
||||
gt = [
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
|
||||
class_id=np.array([0, 1]),
|
||||
data={
|
||||
"xyxyxyxy": np.array(
|
||||
[
|
||||
[[5, 0], [10, 5], [5, 10], [0, 5]],
|
||||
[[25, 20], [30, 25], [25, 30], [20, 25]],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
},
|
||||
)
|
||||
]
|
||||
# Predictions: class-0 same diamond (OBB IoU=1.0 → TP);
|
||||
# class-1 axis-aligned square (OBB IoU=0.5 < threshold=0.6 → FP+FN)
|
||||
pred = [
|
||||
Detections(
|
||||
xyxy=np.array([[0, 0, 10, 10], [20, 20, 30, 30]], dtype=np.float32),
|
||||
class_id=np.array([0, 1]),
|
||||
confidence=np.array([0.9, 0.8], dtype=np.float32),
|
||||
data={
|
||||
"xyxyxyxy": np.array(
|
||||
[
|
||||
[[5, 0], [10, 5], [5, 10], [0, 5]],
|
||||
[[20, 20], [30, 20], [30, 30], [20, 30]],
|
||||
],
|
||||
dtype=np.float32,
|
||||
)
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
cm = ConfusionMatrix.from_detections(
|
||||
predictions=pred,
|
||||
targets=gt,
|
||||
classes=classes,
|
||||
iou_threshold=0.6,
|
||||
metric_target=MetricTarget.ORIENTED_BOUNDING_BOXES,
|
||||
)
|
||||
|
||||
assert cm.matrix[0, 0] == 1 # TP class 0
|
||||
assert cm.matrix[1, 2] == 1 # FN class 1 (unmatched GT)
|
||||
assert cm.matrix[2, 1] == 1 # FP class 1 (unmatched pred)
|
||||
|
||||
def test_confusion_matrix_metric_target_persistence_from_detections(self):
|
||||
"""metric_target field reflects the value passed to from_detections."""
|
||||
xyxy = np.array([[0, 0, 10, 10]], dtype=np.float32)
|
||||
cm = ConfusionMatrix.from_detections(
|
||||
predictions=[
|
||||
Detections(
|
||||
xyxy=xyxy, class_id=np.array([0]), confidence=np.array([0.9])
|
||||
)
|
||||
],
|
||||
targets=[Detections(xyxy=xyxy, class_id=np.array([0]))],
|
||||
classes=["box"],
|
||||
metric_target=MetricTarget.BOXES,
|
||||
)
|
||||
assert cm.metric_target == MetricTarget.BOXES
|
||||
|
||||
def test_confusion_matrix_metric_target_persistence_from_tensors(self):
|
||||
"""metric_target field reflects the value passed to from_tensors."""
|
||||
cm = ConfusionMatrix.from_tensors(
|
||||
predictions=[np.array([[0, 0, 10, 10, 0, 0.9]], dtype=np.float32)],
|
||||
targets=[np.array([[0, 0, 10, 10, 0]], dtype=np.float32)],
|
||||
classes=["box"],
|
||||
metric_target=MetricTarget.BOXES,
|
||||
)
|
||||
assert cm.metric_target == MetricTarget.BOXES
|
||||
|
|
|
|||
Loading…
Reference in New Issue