Merge branch 'develop' into tracker_latest

This commit is contained in:
Piotr Skalski 2023-08-07 10:07:36 +02:00 committed by GitHub
commit 94f15a6e50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 762 additions and 114 deletions

View File

@ -6,3 +6,7 @@
## ConfusionMatrix
:::supervision.metrics.detection.ConfusionMatrix
## MeanAveragePrecision
:::supervision.metrics.detection.MeanAveragePrecision

View File

@ -30,12 +30,8 @@ from supervision.draw.color import Color, ColorPalette
from supervision.draw.utils import draw_filled_rectangle, draw_polygon, draw_text
from supervision.geometry.core import Point, Position, Rect
from supervision.geometry.utils import get_polygon_center
from supervision.metrics.detection import ConfusionMatrix
from supervision.tracker.byte_tracker.byte_tracker import (
ByteTrack,
detections2boxes,
match_detections_with_tracks,
)
from supervision.metrics.detection import ConfusionMatrix, MeanAveragePrecision
from supervision.tracker.byte_tracker.byte_tracker import ByteTrack
from supervision.utils.file import list_files_with_extensions
from supervision.utils.image import ImageSink, crop
from supervision.utils.notebook import plot_image, plot_images_grid

View File

@ -31,7 +31,6 @@ from supervision.dataset.utils import (
train_test_split,
)
from supervision.detection.core import Detections
from supervision.utils.file import list_files_with_extensions
@dataclass
@ -197,7 +196,10 @@ class DetectionDataset(BaseDataset):
@classmethod
def from_pascal_voc(
cls, images_directory_path: str, annotations_directory_path: str
cls,
images_directory_path: str,
annotations_directory_path: str,
force_masks: bool = False,
) -> DetectionDataset:
"""
Creates a Dataset instance from PASCAL VOC formatted data.
@ -205,6 +207,7 @@ class DetectionDataset(BaseDataset):
Args:
images_directory_path (str): The path to the directory containing the images.
annotations_directory_path (str): The path to the directory containing the PASCAL VOC XML annotations.
force_masks (bool, optional): 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.
@ -222,7 +225,7 @@ class DetectionDataset(BaseDataset):
>>> project = rf.workspace(WORKSPACE_ID).project(PROJECT_ID)
>>> dataset = project.version(PROJECT_VERSION).download("voc")
>>> ds = sv.DetectionDataset.from_yolo(
>>> ds = sv.DetectionDataset.from_pascal_voc(
... images_directory_path=f"{dataset.location}/train/images",
... annotations_directory_path=f"{dataset.location}/train/labels"
... )
@ -231,34 +234,13 @@ class DetectionDataset(BaseDataset):
['dog', 'person']
```
"""
image_paths = list_files_with_extensions(
directory=images_directory_path, extensions=["jpg", "jpeg", "png"]
)
annotation_paths = list_files_with_extensions(
directory=annotations_directory_path, extensions=["xml"]
classes, images, annotations = load_pascal_voc_annotations(
images_directory_path=images_directory_path,
annotations_directory_path=annotations_directory_path,
force_masks=force_masks,
)
raw_annotations: List[Tuple[str, Detections, List[str]]] = [
load_pascal_voc_annotations(annotation_path=str(annotation_path))
for annotation_path in annotation_paths
]
classes = []
for annotation in raw_annotations:
classes.extend(annotation[2])
classes = list(set(classes))
for annotation in raw_annotations:
class_id = [classes.index(class_name) for class_name in annotation[2]]
annotation[1].class_id = np.array(class_id)
images = {
image_path.name: cv2.imread(str(image_path)) for image_path in image_paths
}
annotations = {
image_name: detections for image_name, detections, _ in raw_annotations
}
return DetectionDataset(classes=classes, images=images, annotations=annotations)
@classmethod

View File

@ -1,12 +1,16 @@
from typing import List, Optional, Tuple
import os
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from xml.dom.minidom import parseString
from xml.etree.ElementTree import Element, SubElement, parse, tostring
import cv2
import numpy as np
from supervision.dataset.utils import approximate_mask_with_polygons
from supervision.detection.core import Detections
from supervision.detection.utils import polygon_to_xyxy
from supervision.detection.utils import polygon_to_mask, polygon_to_xyxy
from supervision.utils.file import list_files_with_extensions
def object_to_pascal_voc(
@ -120,24 +124,91 @@ def detections_to_pascal_voc(
def load_pascal_voc_annotations(
annotation_path: str,
) -> Tuple[str, Detections, List[str]]:
images_directory_path: str,
annotations_directory_path: str,
force_masks: bool = False,
) -> Tuple[List[str], Dict[str, np.ndarray], Dict[str, Detections]]:
"""
Loads PASCAL VOC XML annotations and returns the image name, a Detections instance, and a list of class names.
Loads PASCAL VOC annotations and returns class names, images, and their corresponding detections.
Args:
annotation_path (str): The path to the PASCAL VOC XML annotations file.
images_directory_path (str): The path to the directory containing the images.
annotations_directory_path (str): The path to the directory containing the PASCAL VOC annotation files.
force_masks (bool, optional): If True, forces masks to be loaded for all annotations, regardless of whether they are present.
Returns:
Tuple[str, Detections, List[str]]: A tuple containing the image name, a Detections instance, and a list of class names of objects in the detections.
Tuple[List[str], Dict[str, np.ndarray], Dict[str, Detections]]: A tuple containing a list of class names, a dictionary with image names as keys and images as values, and a dictionary with image names as keys and corresponding Detections instances as values.
"""
tree = parse(annotation_path)
root = tree.getroot()
image_name = root.find("filename").text
image_paths = list_files_with_extensions(
directory=images_directory_path, extensions=["jpg", "jpeg", "png"]
)
classes = []
images = {}
annotations = {}
for image_path in image_paths:
image_name = Path(image_path).stem
image = cv2.imread(str(image_path))
annotation_path = os.path.join(annotations_directory_path, f"{image_name}.xml")
if not os.path.exists(annotation_path):
images[image_path.name] = image
annotations[image_path.name] = Detections.empty()
continue
tree = parse(annotation_path)
root = tree.getroot()
resolution_wh = (image.shape[1], image.shape[0])
annotation, classes = detections_from_xml_obj(
root, classes, resolution_wh, force_masks
)
images[image_path.name] = image
annotations[image_path.name] = annotation
return classes, images, annotations
def detections_from_xml_obj(
root: Element, classes: List[str], resolution_wh, force_masks: bool = False
) -> Tuple[Detections, List[str]]:
"""
Converts an XML object in Pascal VOC format to a Detections object.
Expected XML format:
<annotation>
...
<object>
<name>dog</name>
<bndbox>
<xmin>48</xmin>
<ymin>240</ymin>
<xmax>195</xmax>
<ymax>371</ymax>
</bndbox>
<polygon>
<x1>48</x1>
<y1>240</y1>
<x2>195</x2>
<y2>240</y2>
<x3>195</x3>
<y3>371</y3>
<x4>48</x4>
<y4>371</y4>
</polygon>
</object>
</annotation>
Returns:
Tuple[Detections, List[str]]: A tuple containing a Detections object and an updated list of class names, extended with the class names from the XML object.
"""
xyxy = []
class_names = []
masks = []
with_masks = False
extended_classes = classes[:]
for obj in root.findall("object"):
class_name = obj.find("name").text
class_names.append(class_name)
@ -150,7 +221,40 @@ def load_pascal_voc_annotations(
xyxy.append([x1, y1, x2, y2])
xyxy = np.array(xyxy)
detections = Detections(xyxy=xyxy)
with_masks = obj.find("polygon") is not None
with_masks = force_masks if force_masks else with_masks
return image_name, detections, class_names
for polygon in obj.findall("polygon"):
polygon_points = parse_polygon_points(polygon)
mask_from_polygon = polygon_to_mask(
polygon=np.array(polygon_points),
resolution_wh=resolution_wh,
)
masks.append(mask_from_polygon)
xyxy = np.array(xyxy) if len(xyxy) > 0 else np.empty((0, 4))
for k in set(class_names):
if k not in extended_classes:
extended_classes.append(k)
class_id = np.array(
[extended_classes.index(class_name) for class_name in class_names]
)
if with_masks:
annotation = Detections(
xyxy=xyxy, mask=np.array(masks).astype(bool), class_id=class_id
)
else:
annotation = Detections(xyxy=xyxy, class_id=class_id)
return annotation, extended_classes
def parse_polygon_points(polygon: Element) -> List[List[int]]:
polygon_points = []
coords = polygon.findall(".//*")
for i in range(0, len(coords), 2):
x = int(coords[i].text)
y = int(coords[i + 1].text)
polygon_points.append([x, y])
return polygon_points

View File

@ -126,9 +126,6 @@ def load_yolo_annotations(
image_paths = list_files_with_extensions(
directory=images_directory_path, extensions=["jpg", "jpeg", "png"]
)
annotation_paths = list_files_with_extensions(
directory=annotations_directory_path, extensions=["txt"]
)
classes = _extract_class_names(file_path=data_yaml_path)
images = {}

View File

@ -7,12 +7,13 @@ import cv2
import numpy as np
from supervision.detection.utils import (
extract_yolov8_masks,
extract_ultralytics_masks,
non_max_suppression,
process_roboflow_result,
xywh_to_xyxy,
)
from supervision.geometry.core import Position
from supervision.utils.internal import deprecated
def _validate_xyxy(xyxy: Any, n: int) -> None:
@ -26,7 +27,7 @@ def _validate_mask(mask: Any, n: int) -> None:
isinstance(mask, np.ndarray) and len(mask.shape) == 3 and mask.shape[0] == n
)
if not is_valid:
raise ValueError("mask must be 3d np.ndarray with (n, W, H) shape")
raise ValueError("mask must be 3d np.ndarray with (n, H, W) shape")
def _validate_class_id(class_id: Any, n: int) -> None:
@ -59,7 +60,7 @@ class Detections:
Data class containing information about the detections in a video frame.
Attributes:
xyxy (np.ndarray): An array of shape `(n, 4)` containing the bounding boxes coordinates in format `[x1, y1, x2, y2]`
mask: (Optional[np.ndarray]): An array of shape `(n, W, H)` containing the segmentation masks.
mask: (Optional[np.ndarray]): An array of shape `(n, H, W)` containing the segmentation masks.
confidence (Optional[np.ndarray]): An array of shape `(n,)` containing the confidence scores of the detections.
class_id (Optional[np.ndarray]): An array of shape `(n,)` containing the class ids of the detections.
tracker_id (Optional[np.ndarray]): An array of shape `(n,)` containing the tracker ids of the detections.
@ -170,6 +171,9 @@ class Detections:
)
@classmethod
@deprecated(
"This method is deprecated and removed in 0.15.0 release. Use sv.Detections.from_ultralytics() instead."
)
def from_yolov8(cls, yolov8_results) -> Detections:
"""
Creates a Detections instance from a [YOLOv8](https://github.com/ultralytics/ultralytics) inference result.
@ -196,7 +200,42 @@ class Detections:
xyxy=yolov8_results.boxes.xyxy.cpu().numpy(),
confidence=yolov8_results.boxes.conf.cpu().numpy(),
class_id=yolov8_results.boxes.cls.cpu().numpy().astype(int),
mask=extract_yolov8_masks(yolov8_results),
mask=extract_ultralytics_masks(yolov8_results),
)
@classmethod
def from_ultralytics(cls, ultralytics_results) -> Detections:
"""
Creates a Detections instance from a [YOLOv8](https://github.com/ultralytics/ultralytics) inference result.
Args:
yolov8_results (ultralytics.yolo.engine.results.Results): The output Results instance from YOLOv8
Returns:
Detections: A new Detections object.
Example:
```python
>>> import cv2
>>> from ultralytics import YOLO, FastSAM, SAM, RTDETR
>>> import supervision as sv
>>> image = cv2.imread(SOURCE_IMAGE_PATH)
>>> model = YOLO('yolov8s.pt')
>>> model = SAM('sam_b.pt')
>>> model = SAM('mobile_sam.pt')
>>> model = FastSAM('FastSAM-s.pt')
>>> model = RTDETR('rtdetr-l.pt')
>>> result = model(image)[0]
>>> detections = sv.Detections.from_ultralytics(result)
```
"""
return cls(
xyxy=ultralytics_results.boxes.xyxy.cpu().numpy(),
confidence=ultralytics_results.boxes.conf.cpu().numpy(),
class_id=ultralytics_results.boxes.cls.cpu().numpy().astype(int),
mask=extract_ultralytics_masks(ultralytics_results),
)
@classmethod

View File

@ -17,7 +17,7 @@ def polygon_to_mask(polygon: np.ndarray, resolution_wh: Tuple[int, int]) -> np.n
np.ndarray: The generated 2D mask, where the polygon is marked with `1`'s and the rest is filled with `0`'s.
"""
width, height = resolution_wh
mask = np.zeros((height, width), dtype=np.uint8)
mask = np.zeros((height, width))
cv2.fillPoly(mask, [polygon], color=1)
return mask
@ -260,7 +260,7 @@ def approximate_polygon(
return np.squeeze(approximated_points, axis=1)
def extract_yolov8_masks(yolov8_results) -> Optional[np.ndarray]:
def extract_ultralytics_masks(yolov8_results) -> Optional[np.ndarray]:
if not yolov8_results.masks:
return None
@ -288,7 +288,10 @@ def extract_yolov8_masks(yolov8_results) -> Optional[np.ndarray]:
for i in range(masks.shape[0]):
mask = masks[i]
mask = mask[top:bottom, left:right]
mask = cv2.resize(mask, (orig_shape[1], orig_shape[0]))
if mask.shape != orig_shape:
mask = cv2.resize(mask, (orig_shape[1], orig_shape[0]))
mask_maps.append(mask)
return np.asarray(mask_maps, dtype=bool)

View File

@ -12,6 +12,59 @@ from supervision.detection.core import Detections
from supervision.detection.utils import box_iou_batch
def detections_to_tensor(
detections: Detections, with_confidence: bool = False
) -> np.ndarray:
"""
Convert Supervision Detections to numpy tensors for further computation
Args:
detections (sv.Detections): Detections/Targets in the format of sv.Detections
with_confidence (bool): Whether to include confidence in the tensor
Returns:
(np.ndarray): Detections as numpy tensors as in (xyxy, class_id, confidence) order
"""
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 with_confidence:
if detections.confidence is None:
raise ValueError(
"ConfusionMatrix can only be calculated for Detections with confidence"
)
arrays_to_concat.append(np.expand_dims(detections.confidence, 1))
return np.concatenate(arrays_to_concat, axis=1)
def validate_input_tensors(predictions: List[np.ndarray], targets: List[np.ndarray]):
"""
Checks for shape consistency of input tensors.
"""
if len(predictions) != len(targets):
raise ValueError(
f"Number of predictions ({len(predictions)}) and targets ({len(targets)}) must be equal."
)
if len(predictions) > 0:
if not isinstance(predictions[0], np.ndarray) or not isinstance(
targets[0], np.ndarray
):
raise ValueError(
f"Predictions and targets must be lists of numpy arrays. Got {type(predictions[0])} and {type(targets[0])} instead."
)
if predictions[0].shape[1] != 6:
raise ValueError(
f"Predictions must have shape (N, 6). Got {predictions[0].shape} instead."
)
if targets[0].shape[1] != 5:
raise ValueError(
f"Targets must have shape (N, 5). Got {targets[0].shape} instead."
)
@dataclass
class ConfusionMatrix:
"""
@ -85,11 +138,9 @@ class ConfusionMatrix:
target_tensors = []
for prediction, target in zip(predictions, targets):
prediction_tensors.append(
ConfusionMatrix.detections_to_tensor(prediction, with_confidence=True)
)
target_tensors.append(
ConfusionMatrix.detections_to_tensor(target, with_confidence=False)
detections_to_tensor(prediction, with_confidence=True)
)
target_tensors.append(detections_to_tensor(target, with_confidence=False))
return cls.from_tensors(
predictions=prediction_tensors,
targets=target_tensors,
@ -98,26 +149,6 @@ class ConfusionMatrix:
iou_threshold=iou_threshold,
)
@staticmethod
def detections_to_tensor(
detections: Detections, with_confidence: bool = False
) -> np.ndarray:
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 with_confidence:
if detections.confidence is None:
raise ValueError(
"ConfusionMatrix can only be calculated for Detections with confidence"
)
arrays_to_concat.append(np.expand_dims(detections.confidence, 1))
return np.concatenate(arrays_to_concat, axis=1)
@classmethod
def from_tensors(
cls,
@ -184,7 +215,7 @@ class ConfusionMatrix:
])
```
"""
cls._validate_input_tensors(predictions, targets)
validate_input_tensors(predictions, targets)
num_classes = len(classes)
matrix = np.zeros((num_classes + 1, num_classes + 1))
@ -203,33 +234,6 @@ class ConfusionMatrix:
iou_threshold=iou_threshold,
)
@classmethod
def _validate_input_tensors(
cls, predictions: List[np.ndarray], targets: List[np.ndarray]
):
"""
Checks for shape consistency of input tensors.
"""
if len(predictions) != len(targets):
raise ValueError(
f"Number of predictions ({len(predictions)}) and targets ({len(targets)}) must be equal."
)
if len(predictions) > 0:
if not isinstance(predictions[0], np.ndarray) or not isinstance(
targets[0], np.ndarray
):
raise ValueError(
f"Predictions and targets must be lists of numpy arrays. Got {type(predictions[0])} and {type(targets[0])} instead."
)
if predictions[0].shape[1] != 6:
raise ValueError(
f"Predictions must have shape (N, 6). Got {predictions[0].shape} instead."
)
if targets[0].shape[1] != 5:
raise ValueError(
f"Targets must have shape (N, 5). Got {targets[0].shape} instead."
)
@staticmethod
def evaluate_detection_batch(
predictions: np.ndarray,
@ -319,7 +323,7 @@ class ConfusionMatrix:
iou_threshold: float = 0.5,
) -> ConfusionMatrix:
"""
Create confusion matrix from dataset and callback function.
Calculate confusion matrix from dataset and callback function.
Args:
dataset (DetectionDataset): Object detection dataset used for evaluation.
@ -456,3 +460,331 @@ class ConfusionMatrix:
save_path, dpi=250, facecolor=fig.get_facecolor(), transparent=True
)
return fig
@dataclass(frozen=True)
class MeanAveragePrecision:
"""
Mean Average Precision for object detection tasks.
Attributes:
map (float): mAP value.
map50 (float): mAP value at IoU `threshold = 0.5`.
map75 (float): mAP value at IoU `threshold = 0.75`.
per_class_ap (np.ndarray): values for every classes.
"""
map: float
map50: float
map75: float
per_class_ap: np.ndarray
@classmethod
def from_detections(
cls,
predictions: List[Detections],
targets: List[Detections],
) -> MeanAveragePrecision:
"""
Calculate mean average precision based on predicted and ground-truth detections.
Args:
targets (List[Detections]): Detections objects from ground-truth.
predictions (List[Detections]): Detections objects predicted by the model.
Returns:
MeanAveragePrecision: New instance of ConfusionMatrix.
Example:
```python
>>> import supervision as sv
>>> targets = [
... sv.Detections(...),
... sv.Detections(...)
... ]
>>> predictions = [
... sv.Detections(...),
... sv.Detections(...)
... ]
>>> mean_average_precision = sv.MeanAveragePrecision.from_detections(
... predictions=predictions,
... targets=target,
... )
>>> mean_average_precison.map
0.2899
```
"""
prediction_tensors = []
target_tensors = []
for prediction, target in zip(predictions, targets):
prediction_tensors.append(
detections_to_tensor(prediction, with_confidence=True)
)
target_tensors.append(detections_to_tensor(target, with_confidence=False))
return cls.from_tensors(
predictions=prediction_tensors,
targets=target_tensors,
)
@classmethod
def benchmark(
cls,
dataset: DetectionDataset,
callback: Callable[[np.ndarray], Detections],
) -> MeanAveragePrecision:
"""
Calculate mean average precision from dataset and callback function.
Args:
dataset (DetectionDataset): Object detection dataset used for evaluation.
callback (Callable[[np.ndarray], Detections]): Function that takes an image as input and returns Detections object.
Returns:
MeanAveragePrecision: New instance of MeanAveragePrecision.
Example:
```python
>>> import supervision as sv
>>> from ultralytics import YOLO
>>> dataset = sv.DetectionDataset.from_yolo(...)
>>> model = YOLO(...)
>>> def callback(image: np.ndarray) -> sv.Detections:
... result = model(image)[0]
... return sv.Detections.from_yolov8(result)
>>> mean_average_precision = sv.MeanAveragePrecision.benchmark(
... dataset = dataset,
... callback = callback
... )
>>> mean_average_precision.map
0.433
```
"""
predictions, targets = [], []
for img_name, img in dataset.images.items():
predictions_batch = callback(img)
predictions.append(predictions_batch)
targets_batch = dataset.annotations[img_name]
targets.append(targets_batch)
return cls.from_detections(
predictions=predictions,
targets=targets,
)
@classmethod
def from_tensors(
cls,
predictions: List[np.ndarray],
targets: List[np.ndarray],
) -> MeanAveragePrecision:
"""
Calculate Mean Average Precision based on predicted and ground-truth detections at different threshold.
Args:
predictions (List[np.ndarray]): 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 `(x_min, y_min, x_max, y_max, class, conf)` format.
targets (List[np.ndarray]): 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 `(x_min, y_min, x_max, y_max, class)` format.
Returns:
MeanAveragePrecision: New instance of MeanAveragePrecision.
Example:
```python
>>> import supervision as sv
>>> targets = (
... [
... array(
... [
... [0.0, 0.0, 3.0, 3.0, 1],
... [2.0, 2.0, 5.0, 5.0, 1],
... [6.0, 1.0, 8.0, 3.0, 2],
... ]
... ),
... array([1.0, 1.0, 2.0, 2.0, 2]),
... ]
... )
>>> predictions = [
... array(
... [
... [0.0, 0.0, 3.0, 3.0, 1, 0.9],
... [0.1, 0.1, 3.0, 3.0, 0, 0.9],
... [6.0, 1.0, 8.0, 3.0, 1, 0.8],
... [1.0, 6.0, 2.0, 7.0, 1, 0.8],
... ]
... ),
... array([[1.0, 1.0, 2.0, 2.0, 2, 0.8]])
... ]
>>> mean_average_precison = sv.MeanAveragePrecision.from_tensors(
... predictions=predictions,
... targets=targets,
... )
>>> mean_average_precison.map
0.2899
```
"""
validate_input_tensors(predictions, targets)
map, map50, map75 = 0, 0, 0
class_index = 4
conf_index = 5
stats, average_precisions = [], []
iou_levels = np.linspace(0.5, 0.95, 10)
num_ious = iou_levels.size
for true_batch, detection_batch in zip(targets, predictions):
nl, npr = (
true_batch.shape[0],
detection_batch.shape[0],
)
correct = np.zeros((npr, num_ious), dtype=bool)
if npr == 0:
if nl:
stats.append((correct, *np.zeros((2, 0)), true_batch[:, 4]))
continue
if nl:
correct = MeanAveragePrecision._match_detection_batch(
predictions=detection_batch,
targets=true_batch,
iou_levels=iou_levels,
)
stats.append(
(
correct,
detection_batch[:, conf_index],
detection_batch[:, class_index],
true_batch[:, class_index],
)
)
stats = [np.concatenate(x, 0) for x in zip(*stats)]
if len(stats) and stats[0].any():
average_precisions = cls._average_precisions_per_class(*stats)
ap50, ap75, average_precisions = (
average_precisions[:, 0],
average_precisions[:, 5],
average_precisions.mean(1),
)
map50, map75, map = ap50.mean(), ap75.mean(), average_precisions.mean()
return cls(map=map, map50=map50, map75=map75, per_class_ap=average_precisions)
@staticmethod
def _match_detection_batch(
predictions: np.ndarray, targets: np.ndarray, iou_levels: np.ndarray
) -> np.ndarray:
"""
Args:
predictions (np.ndarray): batch prediction
targets (np.ndarray): batch target labels
iou_levels (np.ndarray): iou levels array contains different iou levels
Returns:
(np.ndarray): matched prediction with target lebels result
"""
correct = np.zeros((predictions.shape[0], iou_levels.shape[0])).astype(bool)
iou = box_iou_batch(targets[:, :4], predictions[:, :4])
correct_class = targets[:, 4:5] == predictions[:, 4]
for i in range(len(iou_levels)):
x = np.where((iou >= iou_levels[i]) & correct_class)
if x[0].shape[0]:
_X1 = np.concatenate(
[np.expand_dims(x[0], 1), np.expand_dims(x[1], 1)], axis=1
)
_x2 = iou[x[0], x[1]][:, None]
matches = np.concatenate([_X1, _x2], axis=1)
if x[0].shape[0] > 1:
matches = matches[matches[:, 2].argsort()[::-1]]
matches = matches[np.unique(matches[:, 1], return_index=True)[1]]
matches = matches[np.unique(matches[:, 0], return_index=True)[1]]
correct[matches[:, 1].astype(int), i] = True
correct[matches[:, 1].astype(int), i] = True
return correct
@staticmethod
def compute_average_precision(recall: np.ndarray, precision: np.ndarray) -> float:
"""
Compute the average precision using 101-point interpolation (COCO), given the recall and precision curves.
Args:
recall (np.ndarray): The recall curve.
precision (np.ndarray): The precision curve.
Returns:
float: Average precision.
"""
extended_recall = np.concatenate(([0.0], recall, [1.0]))
extended_precision = np.concatenate(([1.0], precision, [0.0]))
max_accumulated_precision = np.flip(
np.maximum.accumulate(np.flip(extended_precision))
)
interpolated_recall_levels = np.linspace(0, 1, 101)
interpolated_precision = np.interp(
interpolated_recall_levels, extended_recall, max_accumulated_precision
)
average_precision = np.trapz(interpolated_precision, interpolated_recall_levels)
return average_precision
@staticmethod
def _average_precisions_per_class(
matches: np.ndarray,
prediction_confidence: np.ndarray,
prediction_class_ids: np.ndarray,
true_batch_class_ids: np.ndarray,
eps: float = 1e-16,
) -> np.ndarray:
"""
Compute the average precision, given the recall and precision curves.
Source: https://github.com/rafaelpadilla/Object-Detection-Metrics.
Args:
matches (np.ndarray): True positives (nparray, nx1 or nx10).
prediction_confidence (np.ndarray): Objectness value from 0-1 (nparray).
prediction_class_ids (np.ndarray): Predicted object classes (nparray).
true_batch_class_ids (np.ndarray): True object classes (nparray).
Returns:
(np.ndarray): Average precision for different iou level array
"""
sorted_confidences = np.argsort(-prediction_confidence)
matches = matches[sorted_confidences]
prediction_class_ids = prediction_class_ids[sorted_confidences]
# Find unique classes
unique_classes, class_counts = np.unique(
true_batch_class_ids, return_counts=True
)
num_classes = unique_classes.shape[0] # number of classes, number of detections
average_precisions = np.zeros((num_classes, matches.shape[1]))
for ci, c in enumerate(unique_classes):
valid = prediction_class_ids == c
num_targets = class_counts[ci] # number of labels
num_predictions = valid.sum() # number of predictions
if num_predictions == 0 or num_targets == 0:
continue
fp_pool = (1 - matches[valid]).cumsum(0)
tp_pool = matches[valid].cumsum(0)
recall = tp_pool / (num_targets + eps)
precision = tp_pool / (tp_pool + fp_pool)
for j in range(matches.shape[1]):
average_precisions[
ci, j
] = MeanAveragePrecision.compute_average_precision(
recall[:, j], precision[:, j]
)
return average_precisions

View File

@ -0,0 +1,147 @@
import xml.etree.ElementTree as ET
from contextlib import ExitStack as DoesNotRaise
from test.utils import mock_detections
from typing import List, Optional
import numpy as np
import pytest
from supervision.dataset.formats.pascal_voc import (
detections_from_xml_obj,
object_to_pascal_voc,
parse_polygon_points,
)
def are_xml_elements_equal(elem1, elem2):
if (
elem1.tag != elem2.tag
or elem1.attrib != elem2.attrib
or elem1.text != elem2.text
or len(elem1) != len(elem2)
):
return False
for child1, child2 in zip(elem1, elem2):
if not are_xml_elements_equal(child1, child2):
return False
return True
@pytest.mark.parametrize(
"xyxy, name, polygon, expected_result, exception",
[
(
[0, 0, 10, 10],
"test",
None,
ET.fromstring(
"""<object><name>test</name><bndbox><xmin>0</xmin><ymin>0</ymin><xmax>10</xmax><ymax>10</ymax></bndbox></object>"""
),
DoesNotRaise(),
),
(
[0, 0, 10, 10],
"test",
[[0, 0], [10, 0], [10, 10], [0, 10]],
ET.fromstring(
"""<object><name>test</name><bndbox><xmin>0</xmin><ymin>0</ymin><xmax>10</xmax><ymax>10</ymax></bndbox><polygon><x1>0</x1><y1>0</y1><x2>10</x2><y2>0</y2><x3>10</x3><y3>10</y3><x4>0</x4><y4>10</y4></polygon></object>"""
),
DoesNotRaise(),
),
],
)
def test_object_to_pascal_voc(
xyxy: np.ndarray,
name: str,
polygon: Optional[np.ndarray],
expected_result,
exception: Exception,
):
with exception:
result = object_to_pascal_voc(xyxy=xyxy, name=name, polygon=polygon)
assert are_xml_elements_equal(result, expected_result)
@pytest.mark.parametrize(
"polygon_element, expected_result, exception",
[
(
ET.fromstring(
"""<polygon><x1>0</x1><y1>0</y1><x2>10</x2><y2>0</y2><x3>10</x3><y3>10</y3><x4>0</x4><y4>10</y4></polygon>"""
),
[[0, 0], [10, 0], [10, 10], [0, 10]],
DoesNotRaise(),
)
],
)
def test_parse_polygon_points(
polygon_element,
expected_result: List[list],
exception,
):
with exception:
result = parse_polygon_points(polygon_element)
assert result == expected_result
ONE_CLASS_N_BBOX = """<annotation><object><name>test</name><bndbox><xmin>0</xmin><ymin>0</ymin><xmax>10</xmax><ymax>10</ymax></bndbox></object><object><name>test</name><bndbox><xmin>10</xmin><ymin>10</ymin><xmax>20</xmax><ymax>20</ymax></bndbox></object></annotation>"""
ONE_CLASS_ONE_BBOX = """<annotation><object><name>test</name><bndbox><xmin>0</xmin><ymin>0</ymin><xmax>10</xmax><ymax>10</ymax></bndbox></object></annotation>"""
N_CLASS_N_BBOX = """<annotation><object><name>test</name><bndbox><xmin>0</xmin><ymin>0</ymin><xmax>10</xmax><ymax>10</ymax></bndbox></object><object><name>test</name><bndbox><xmin>20</xmin><ymin>30</ymin><xmax>30</xmax><ymax>40</ymax></bndbox></object><object><name>test2</name><bndbox><xmin>10</xmin><ymin>10</ymin><xmax>20</xmax><ymax>20</ymax></bndbox></object></annotation>"""
NO_DETECTIONS = """<annotation></annotation>"""
@pytest.mark.parametrize(
"xml_string, classes, resolution_wh, force_masks, expected_result, exception",
[
(
ONE_CLASS_ONE_BBOX,
["test"],
(100, 100),
False,
mock_detections(np.array([[0, 0, 10, 10]]), None, [0]),
DoesNotRaise(),
),
(
ONE_CLASS_N_BBOX,
["test"],
(100, 100),
False,
mock_detections(np.array([[0, 0, 10, 10], [10, 10, 20, 20]]), None, [0, 0]),
DoesNotRaise(),
),
(
N_CLASS_N_BBOX,
["test", "test2"],
(100, 100),
False,
mock_detections(
np.array([[0, 0, 10, 10], [20, 30, 30, 40], [10, 10, 20, 20]]),
None,
[0, 0, 1],
),
DoesNotRaise(),
),
(
NO_DETECTIONS,
[],
(100, 100),
False,
mock_detections(np.empty((0, 4)), None, []),
DoesNotRaise(),
),
],
)
def test_detections_from_xml_obj(
xml_string, classes, resolution_wh, force_masks, expected_result, exception
):
with exception:
root = ET.fromstring(xml_string)
result, _ = detections_from_xml_obj(root, classes, resolution_wh, force_masks)
assert result == expected_result

View File

@ -5,8 +5,8 @@ import numpy as np
import pytest
from supervision.detection.core import Detections
from supervision.metrics.detection import ConfusionMatrix
from test.utils import mock_detections
from supervision.metrics.detection import ConfusionMatrix, detections_to_tensor, MeanAveragePrecision
from test.utils import mock_detections, assert_almost_equal
CLASSES = np.arange(80)
NUM_CLASSES = len(CLASSES)
@ -167,7 +167,7 @@ def test_detections_to_tensor(
exception: Exception
):
with exception:
result = ConfusionMatrix.detections_to_tensor(
result = detections_to_tensor(
detections=detections,
with_confidence=with_confidence
)
@ -400,3 +400,43 @@ def test_drop_extra_matches(
result = ConfusionMatrix._drop_extra_matches(matches)
assert np.array_equal(result, expected_result)
@pytest.mark.parametrize(
'recall, precision, expected_result, exception',
[
(
np.array([1.0]),
np.array([1.0]),
1.0,
DoesNotRaise()
), # perfect recall and precision
(
np.array([0.0]),
np.array([0.0]),
0.0,
DoesNotRaise()
), # no recall and precision
(
np.array([0.0, 0.2, 0.2, 0.8, 0.8, 1.0]),
np.array([0.7, 0.8, 0.4, 0.5, 0.1, 0.2]),
0.5,
DoesNotRaise()
),
(
np.array([0.0, 0.5, 0.5, 1.0]),
np.array([0.75, 0.75, 0.75, 0.75]),
0.75,
DoesNotRaise()
)
]
)
def test_compute_average_precision(
recall: np.ndarray,
precision: np.ndarray,
expected_result: float,
exception: Exception
) -> None:
with exception:
result = MeanAveragePrecision.compute_average_precision(recall=recall, precision=precision)
assert_almost_equal(result, expected_result, tolerance=0.01)

View File

@ -21,3 +21,7 @@ def mock_detections(
if tracker_id is None
else np.array(tracker_id, dtype=int),
)
def assert_almost_equal(actual, expected, tolerance=1e-5):
assert abs(actual - expected) < tolerance, f"Expected {expected}, but got {actual}."