Simplified core metrics

* Refactored code
* Removed None returns from InternalDataStore
* Removed support for np.ndarray inputs in IntersectionOverUnion
This commit is contained in:
LinasKo 2024-08-15 14:48:04 +03:00
parent 0e8365ff80
commit 66758448cb
4 changed files with 177 additions and 107 deletions

View File

@ -2,7 +2,7 @@ from __future__ import annotations
from abc import ABC, abstractmethod
from enum import Enum
from typing import Any, Dict, Iterator, Optional, Tuple, Union
from typing import Any, Dict, Iterator, Tuple, Union
import numpy as np
import numpy.typing as npt
@ -10,6 +10,7 @@ from typing_extensions import Self
from supervision import config
from supervision.detection.core import Detections
from supervision.metrics.utils import len0_like, pad_mask
CLASS_ID_NONE = -1
"""Used by metrics module as class ID, when none is present"""
@ -95,19 +96,13 @@ class InternalMetricDataStore:
self._class_agnostic = class_agnostic
self._data_1: Dict[int, npt.NDArray]
self._data_2: Dict[int, npt.NDArray]
self._datapoint_shape: Optional[Tuple[int, ...]]
self._mask_shape: Tuple[int, int]
self.reset()
def reset(self) -> None:
self._data_1 = {}
self._data_2 = {}
if self._metric_target == MetricTarget.BOXES:
self._datapoint_shape = (4,)
elif self._metric_target == MetricTarget.MASKS:
# Determined when adding data
self._datapoint_shape = None
elif self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
self._datapoint_shape = (8,)
self._mask_shape = (0, 0)
def update(
self,
@ -116,47 +111,45 @@ class InternalMetricDataStore:
) -> None:
content_1 = self._get_content(data_1)
content_2 = self._get_content(data_2)
self._validate_shape(content_1)
self._validate_shape(content_2)
class_ids_1 = self._get_class_ids(data_1)
class_ids_2 = self._get_class_ids(data_2)
self._validate_class_ids(class_ids_1, class_ids_2)
if content_1 is not None and len(content_1) > 0:
assert len(content_1) == len(class_ids_1)
for class_id in set(class_ids_1):
content_of_class = content_1[class_ids_1 == class_id]
if class_id not in self._data_1:
self._data_1[class_id] = content_of_class
continue
self._data_1[class_id] = np.vstack(
(self._data_1[class_id], content_of_class)
)
if content_2 is not None and len(content_2) > 0:
assert len(content_2) == len(class_ids_2)
for class_id in set(class_ids_2):
content_of_class = content_2[class_ids_2 == class_id]
if class_id not in self._data_2:
self._data_2[class_id] = content_of_class
continue
self._data_2[class_id] = np.vstack(
(self._data_2[class_id], content_of_class)
)
assert len(content_1) == len(class_ids_1) and len(content_2) == len(class_ids_2)
if self._metric_target == MetricTarget.MASKS:
content_1 = self._expand_mask_shape(content_1)
content_2 = self._expand_mask_shape(content_2)
for class_id in set(class_ids_1):
content_of_class = content_1[class_ids_1 == class_id]
stored_content_of_class = self._data_1.get(class_id, len0_like(content_1))
self._data_1[class_id] = np.vstack(
(stored_content_of_class, content_of_class)
)
for class_id in set(class_ids_2):
content_of_class = content_2[class_ids_2 == class_id]
stored_content_of_class = self._data_2.get(class_id, len0_like(content_2))
self._data_2[class_id] = np.vstack(
(stored_content_of_class, content_of_class)
)
def __iter__(
self,
) -> Iterator[Tuple[int, Optional[npt.NDArray], Optional[npt.NDArray]]]:
class_ids = sorted(
set.union(set(self._data_1.keys()), set(self._data_2.keys()))
)
) -> Iterator[Tuple[int, npt.NDArray, npt.NDArray]]:
class_ids = sorted(set(self._data_1.keys()) | set(self._data_2.keys()))
for class_id in class_ids:
yield (
class_id,
self._data_1.get(class_id, None),
self._data_2.get(class_id, None),
self._data_1.get(class_id, self._make_empty()),
self._data_2.get(class_id, self._make_empty()),
)
def _get_content(
self, data: Union[npt.NDArray, Detections]
) -> Optional[npt.NDArray]:
def _get_content(self, data: Union[npt.NDArray, Detections]) -> npt.NDArray:
"""Return boxes, masks or oriented bounding boxes from the data."""
if not isinstance(data, (Detections, np.ndarray)):
raise ValueError(
@ -169,21 +162,24 @@ class InternalMetricDataStore:
if self._metric_target == MetricTarget.BOXES:
return data.xyxy
if self._metric_target == MetricTarget.MASKS:
return data.mask
return (
data.mask if data.mask is not None else np.zeros((0, 0, 0), dtype=bool)
)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
obb = data.data.get(config.ORIENTED_BOX_COORDINATES, None)
if isinstance(obb, list):
obb = np.array(obb, dtype=np.float32)
return obb
obb = data.data.get(
config.ORIENTED_BOX_COORDINATES, np.zeros((0, 8), dtype=np.float32)
)
return np.array(obb, dtype=np.float32)
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _get_class_ids(
self, data: Union[npt.NDArray, Detections]
) -> npt.NDArray[np.int_]:
if self._class_agnostic or isinstance(data, np.ndarray):
return np.array([CLASS_ID_NONE] * len(data), dtype=int)
assert isinstance(data, Detections)
if data.class_id is None:
if (
self._class_agnostic
or isinstance(data, np.ndarray)
or data.class_id is None
):
return np.array([CLASS_ID_NONE] * len(data), dtype=int)
return data.class_id
@ -197,12 +193,43 @@ class InternalMetricDataStore:
)
def _validate_shape(self, data: npt.NDArray) -> None:
if self._datapoint_shape is None:
assert self._metric_target == MetricTarget.MASKS
self._datapoint_shape = data.shape[1:]
return
if data.shape[1:] != self._datapoint_shape:
raise ValueError(
f"Invalid data shape: {data.shape}."
f" Expected: (N, {self._datapoint_shape})"
)
shape = data.shape
if self._metric_target == MetricTarget.BOXES:
if len(shape) != 2 or shape[1] != 4:
raise ValueError(f"Invalid xyxy shape: {shape}. Expected: (N, 4)")
elif self._metric_target == MetricTarget.MASKS:
if len(shape) != 3:
raise ValueError(f"Invalid mask shape: {shape}. Expected: (N, H, W)")
elif self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
if len(shape) != 2 or shape[1] != 8:
raise ValueError(f"Invalid obb shape: {shape}. Expected: (N, 8)")
else:
raise ValueError(f"Invalid metric target: {self._metric_target}")
def _expand_mask_shape(self, data: npt.NDArray) -> npt.NDArray:
"""Pad the stored and new data to the same shape."""
if self._metric_target != MetricTarget.MASKS:
return data
new_width = max(self._mask_shape[0], data.shape[1])
new_height = max(self._mask_shape[1], data.shape[2])
self._mask_shape = (new_width, new_height)
data = pad_mask(data, self._mask_shape)
for class_id, prev_data in self._data_1.items():
self._data_1[class_id] = pad_mask(prev_data, self._mask_shape)
for class_id, prev_data in self._data_2.items():
self._data_2[class_id] = pad_mask(prev_data, self._mask_shape)
return data
def _make_empty(self) -> npt.NDArray:
"""Create an empty data object with the best-known shape for the target."""
if self._metric_target == MetricTarget.BOXES:
return np.empty((0, 4), dtype=np.float32)
if self._metric_target == MetricTarget.MASKS:
return np.empty((0, *self._mask_shape), dtype=bool)
if self._metric_target == MetricTarget.ORIENTED_BOUNDING_BOXES:
return np.empty((0, 8), dtype=np.float32)
raise ValueError(f"Invalid metric target: {self._metric_target}")

View File

@ -1,4 +1,4 @@
from typing import Dict, Union
from typing import Dict, List, Union
import numpy as np
import numpy.typing as npt
@ -33,27 +33,40 @@ class IntersectionOverUnion(Metric):
def update(
self,
data_1: Union[npt.NDArray, Detections],
data_2: Union[npt.NDArray, Detections],
data_1: Union[Detections, List[Detections]],
data_2: Union[Detections, List[Detections]],
) -> Self:
"""
Add data to the metric, without computing the result.
The arguments can be:
* Boxes of shape `(N, 4)`, `float32`,
* Masks of shape `(N, H, W)`, `bool`
* Oriented bounding boxes of shape `(N, 8)`, `float32`.
* Detections object.
Args:
data_1 (Union[npt.NDArray, Detection]): The first set of data.
data_2 (Union[npt.NDArray, Detection]): The second set of data.
data_1 (Union[Detection, List[Detections]]): The first set of data.
data_2 (Union[Detection, List[Detections]]): The second set of data.
Returns:
Metric: The metric object itself. You can get the metric result
by calling the `compute` method.
"""
if isinstance(data_1, list):
for d1 in data_1:
self.update(d1, Detections.empty())
else:
self._update(data_1, Detections.empty())
if isinstance(data_2, list):
for d2 in data_2:
self.update(Detections.empty(), d2)
else:
self._update(Detections.empty(), data_2)
return self
def _update(
self,
data_1: Union[Detections],
data_2: Union[Detections],
) -> Self:
self._store.update(data_1, data_2)
return self
@ -66,7 +79,6 @@ class IntersectionOverUnion(Metric):
Dict[int, npt.NDArray[np.float32]]: A dictionary with class IDs as keys.
If no class ID is provided, the key is the value CLASS_ID_NONE.
"""
# TODO: cache computed result.
ious = {}
for class_id, array_1, array_2 in self._store:
if self._metric_target == MetricTarget.BOXES:
@ -78,7 +90,7 @@ class IntersectionOverUnion(Metric):
else:
raise NotImplementedError(
"Intersection over union is not implemented"
" for {self._metric_target}."
f" for {self._metric_target}."
)
ious[class_id] = iou
return ious

View File

@ -0,0 +1,28 @@
from typing import Tuple
import numpy as np
import numpy.typing as npt
def pad_mask(mask: npt.NDArray, new_shape: Tuple[int, int]) -> npt.NDArray:
"""Pad a mask to a new shape, inserting zeros on the right and bottom."""
if len(mask.shape) != 3:
raise ValueError(f"Invalid mask shape: {mask.shape}. Expected: (N, H, W)")
new_mask = np.pad(
mask,
(
(0, 0),
(0, new_shape[0] - mask.shape[1]),
(0, new_shape[1] - mask.shape[2]),
),
mode="constant",
constant_values=0,
)
return new_mask
def len0_like(data: npt.NDArray) -> npt.NDArray:
"""Create an empty array with the same shape as input, but with 0 rows."""
return np.empty((0, *data.shape[1:]), dtype=data.dtype)

View File

@ -24,6 +24,9 @@ def mock_xyxy(*box_index: int, box_width=10) -> npt.NDArray[np.float32]:
For each index in `box_index`, a box is generated with the top-left corner at
(i, i) and the bottom-right corner at (i + box_width, i + box_width).
"""
if len(box_index) == 0:
return np.zeros((0, 4), dtype=np.float32)
box_list = []
for i in box_index:
x0 = y0 = i
@ -112,49 +115,49 @@ def helper_test_store(
(
mock_detections(1),
mock_detections(),
[(CLASS_ID_NONE, mock_xyxy(1), None)],
[(CLASS_ID_NONE, mock_xyxy(1), mock_xyxy())],
DoesNotRaise(),
),
(
mock_detections(1),
mock_xyxy(),
[(CLASS_ID_NONE, mock_xyxy(1), None)],
[(CLASS_ID_NONE, mock_xyxy(1), mock_xyxy())],
DoesNotRaise(),
),
(
mock_xyxy(1),
mock_detections(),
[(CLASS_ID_NONE, mock_xyxy(1), None)],
[(CLASS_ID_NONE, mock_xyxy(1), mock_xyxy())],
DoesNotRaise(),
),
(
mock_xyxy(1),
mock_xyxy(),
[(CLASS_ID_NONE, mock_xyxy(1), None)],
[(CLASS_ID_NONE, mock_xyxy(1), mock_xyxy())],
DoesNotRaise(),
),
(
mock_detections(),
mock_detections(1),
[(CLASS_ID_NONE, None, mock_xyxy(1))],
[(CLASS_ID_NONE, mock_xyxy(), mock_xyxy(1))],
DoesNotRaise(),
),
(
mock_detections(),
mock_xyxy(1),
[(CLASS_ID_NONE, None, mock_xyxy(1))],
[(CLASS_ID_NONE, mock_xyxy(), mock_xyxy(1))],
DoesNotRaise(),
),
(
mock_xyxy(),
mock_detections(1),
[(CLASS_ID_NONE, None, mock_xyxy(1))],
[(CLASS_ID_NONE, mock_xyxy(), mock_xyxy(1))],
DoesNotRaise(),
),
(
mock_xyxy(),
mock_xyxy(1),
[(CLASS_ID_NONE, None, mock_xyxy(1))],
[(CLASS_ID_NONE, mock_xyxy(), mock_xyxy(1))],
DoesNotRaise(),
),
# More boxes
@ -165,7 +168,7 @@ def helper_test_store(
(
CLASS_ID_NONE,
mock_xyxy(1, 2),
None,
mock_xyxy(),
)
],
DoesNotRaise(),
@ -192,7 +195,7 @@ def helper_test_store(
(
mock_detections(1, 2, class_id=[1, 2]),
mock_detections(),
[(CLASS_ID_NONE, mock_xyxy(1, 2), None)],
[(CLASS_ID_NONE, mock_xyxy(1, 2), mock_xyxy())],
DoesNotRaise(),
),
(
@ -231,49 +234,49 @@ def test_store_boxes_class_agnostic(
(
mock_detections(1),
mock_detections(),
[(CLASS_ID_NONE, mock_xyxy(1), None)],
[(CLASS_ID_NONE, mock_xyxy(1), mock_xyxy())],
DoesNotRaise(),
),
(
mock_detections(1),
mock_xyxy(),
[(CLASS_ID_NONE, mock_xyxy(1), None)],
[(CLASS_ID_NONE, mock_xyxy(1), mock_xyxy())],
DoesNotRaise(),
),
(
mock_xyxy(1),
mock_detections(),
[(CLASS_ID_NONE, mock_xyxy(1), None)],
[(CLASS_ID_NONE, mock_xyxy(1), mock_xyxy())],
DoesNotRaise(),
),
(
mock_xyxy(1),
mock_xyxy(),
[(CLASS_ID_NONE, mock_xyxy(1), None)],
[(CLASS_ID_NONE, mock_xyxy(1), mock_xyxy())],
DoesNotRaise(),
),
(
mock_detections(),
mock_detections(1),
[(CLASS_ID_NONE, None, mock_xyxy(1))],
[(CLASS_ID_NONE, mock_xyxy(), mock_xyxy(1))],
DoesNotRaise(),
),
(
mock_detections(),
mock_xyxy(1),
[(CLASS_ID_NONE, None, mock_xyxy(1))],
[(CLASS_ID_NONE, mock_xyxy(), mock_xyxy(1))],
DoesNotRaise(),
),
(
mock_xyxy(),
mock_detections(1),
[(CLASS_ID_NONE, None, mock_xyxy(1))],
[(CLASS_ID_NONE, mock_xyxy(), mock_xyxy(1))],
DoesNotRaise(),
),
(
mock_xyxy(),
mock_xyxy(1),
[(CLASS_ID_NONE, None, mock_xyxy(1))],
[(CLASS_ID_NONE, mock_xyxy(), mock_xyxy(1))],
DoesNotRaise(),
),
# More boxes
@ -284,7 +287,7 @@ def test_store_boxes_class_agnostic(
(
CLASS_ID_NONE,
mock_xyxy(1, 2),
None,
mock_xyxy(),
)
],
DoesNotRaise(),
@ -333,13 +336,13 @@ def test_store_boxes_by_class_regression(
(
mock_detections(1, class_id=[1]),
mock_detections(),
[(1, mock_xyxy(1), None)],
[(1, mock_xyxy(1), mock_xyxy())],
DoesNotRaise(),
),
(
mock_detections(),
mock_detections(1, class_id=[1]),
[(1, None, mock_xyxy(1))],
[(1, mock_xyxy(), mock_xyxy(1))],
DoesNotRaise(),
),
# Multiple classes
@ -347,8 +350,8 @@ def test_store_boxes_by_class_regression(
mock_detections(1, 2, class_id=[1, 2]),
mock_detections(),
[
(1, mock_xyxy(1), None),
(2, mock_xyxy(2), None),
(1, mock_xyxy(1), mock_xyxy()),
(2, mock_xyxy(2), mock_xyxy()),
],
DoesNotRaise(),
),
@ -356,13 +359,13 @@ def test_store_boxes_by_class_regression(
mock_detections(1, 2, class_id=[1, 2]),
mock_detections(3, 4, 5, class_id=[2, 3, 3]),
[
(1, mock_xyxy(1), None),
(1, mock_xyxy(1), mock_xyxy()),
(
2,
mock_xyxy(2),
mock_xyxy(3),
),
(3, None, mock_xyxy(4, 5)),
(3, mock_xyxy(), mock_xyxy(4, 5)),
],
DoesNotRaise(),
),
@ -391,13 +394,13 @@ def test_store_boxes_by_class(
(
[],
mock_detections(),
[(1, mock_xyxy(), None)],
[(1, mock_xyxy(), mock_xyxy())],
pytest.raises(ValueError),
),
(
mock_detections(),
[],
[(1, None, mock_xyxy())],
[(1, mock_xyxy(), mock_xyxy())],
pytest.raises(ValueError),
),
],
@ -438,49 +441,49 @@ def test_store_boxes_invalid_args(
(
mock_detections(1, with_mask=True),
mock_detections(with_mask=True),
[(CLASS_ID_NONE, mock_mask(1), None)],
[(CLASS_ID_NONE, mock_mask(1), mock_mask())],
DoesNotRaise(),
),
(
mock_detections(1, with_mask=True),
mock_mask(),
[(CLASS_ID_NONE, mock_mask(1), None)],
[(CLASS_ID_NONE, mock_mask(1), mock_mask())],
DoesNotRaise(),
),
(
mock_mask(1),
mock_detections(with_mask=True),
[(CLASS_ID_NONE, mock_mask(1), None)],
[(CLASS_ID_NONE, mock_mask(1), mock_mask())],
DoesNotRaise(),
),
(
mock_mask(1),
mock_mask(),
[(CLASS_ID_NONE, mock_mask(1), None)],
[(CLASS_ID_NONE, mock_mask(1), mock_mask())],
DoesNotRaise(),
),
(
mock_detections(with_mask=True),
mock_detections(1, with_mask=True),
[(CLASS_ID_NONE, None, mock_mask(1))],
[(CLASS_ID_NONE, mock_mask(), mock_mask(1))],
DoesNotRaise(),
),
(
mock_detections(with_mask=True),
mock_mask(1),
[(CLASS_ID_NONE, None, mock_mask(1))],
[(CLASS_ID_NONE, mock_mask(), mock_mask(1))],
DoesNotRaise(),
),
(
mock_mask(),
mock_detections(1, with_mask=True),
[(CLASS_ID_NONE, None, mock_mask(1))],
[(CLASS_ID_NONE, mock_mask(), mock_mask(1))],
DoesNotRaise(),
),
(
mock_mask(),
mock_mask(1),
[(CLASS_ID_NONE, None, mock_mask(1))],
[(CLASS_ID_NONE, mock_mask(), mock_mask(1))],
DoesNotRaise(),
),
# More masks
@ -491,7 +494,7 @@ def test_store_boxes_invalid_args(
(
CLASS_ID_NONE,
mock_mask(1, 2),
None,
mock_mask(),
)
],
DoesNotRaise(),
@ -518,7 +521,7 @@ def test_store_boxes_invalid_args(
(
mock_detections(1, 2, class_id=[1, 2], with_mask=True),
mock_detections(with_mask=True),
[(CLASS_ID_NONE, mock_mask(1, 2), None)],
[(CLASS_ID_NONE, mock_mask(1, 2), mock_mask())],
DoesNotRaise(),
),
(