461bf6fd40
CI / lint (3.11) (push) Has been cancelled
CI / lint (3.12) (push) Has been cancelled
CI / lint (3.13) (push) Has been cancelled
CI / shellcheck (push) Has been cancelled
CI / shfmt (push) Has been cancelled
CI / setup (3.11) (push) Has been cancelled
CI / setup (3.12) (push) Has been cancelled
CI / setup (3.13) (push) Has been cancelled
CI / check-licenses (3.12) (push) Has been cancelled
CI / test_unit (3.11) (push) Has been cancelled
CI / test_unit (3.12) (push) Has been cancelled
CI / test_unit (3.13) (push) Has been cancelled
CI / test_unit_no_extras (3.11) (push) Has been cancelled
CI / test_unit_no_extras (3.12) (push) Has been cancelled
CI / test_json_to_html (3.12) (push) Has been cancelled
CI / test_unit_no_extras (3.13) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.12, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.11, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.12, --extra xlsx) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.11, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (csv, 3.13, --extra csv) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.11, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.12, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (docx, 3.13, --extra docx) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.11, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.12, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (markdown, 3.13, --extra md) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.11, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.12, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (odt, 3.13, --extra odt) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.11, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.12, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pdf-image, 3.13, --extra pdf --extra image --extra paddleocr) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.11, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.12, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pptx, 3.13, --extra pptx) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.11, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.12, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
CI / test_unit_dependency_extras (pypandoc, 3.13, --extra epub --extra org --extra rtf --extra rst) (push) Has been cancelled
Build And Push Docker Image / set-short-sha (push) Has been cancelled
Partition Benchmark / setup (push) Has been cancelled
Partition Benchmark / Measure and compare partition() runtime (push) Has been cancelled
CI / test_unit_dependency_extras (xlsx, 3.13, --extra xlsx) (push) Has been cancelled
CI / test_ingest_src (3.12) (push) Has been cancelled
CI / test_json_to_markdown (3.12) (push) Has been cancelled
CI / changelog (push) Has been cancelled
CI / test_dockerfile (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/amd64, opensource-linux-8core) (push) Has been cancelled
Build And Push Docker Image / build-images (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Build And Push Docker Image / publish-images (push) Has been cancelled
722 lines
30 KiB
Python
722 lines
30 KiB
Python
"""
|
|
Implements object detection metrics: average precision, precision, recall, and f1 score.
|
|
"""
|
|
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import torch
|
|
|
|
IOU_THRESHOLDS = torch.tensor(
|
|
[0.5000, 0.5500, 0.6000, 0.6500, 0.7000, 0.7500, 0.8000, 0.8500, 0.9000, 0.9500]
|
|
)
|
|
SCORE_THRESHOLD = 0.1
|
|
RECALL_THRESHOLDS = torch.arange(0, 1.01, 0.01)
|
|
|
|
|
|
@dataclass
|
|
class ObjectDetectionAggregatedEvaluation:
|
|
"""Class representing a gathered class-aggregated object detection metrics"""
|
|
|
|
f1_score: float
|
|
precision: float
|
|
recall: float
|
|
m_ap: float
|
|
|
|
|
|
@dataclass
|
|
class ObjectDetectionPerClassEvaluation:
|
|
"""Class representing a gathered object detection metrics per-class"""
|
|
|
|
f1_score: dict[str, float]
|
|
precision: dict[str, float]
|
|
recall: dict[str, float]
|
|
m_ap: dict[str, float]
|
|
|
|
@classmethod
|
|
def from_tensors(cls, ap, precision, recall, f1, class_labels):
|
|
f1_score = {class_labels[i]: f1[i] for i in range(len(class_labels))}
|
|
precision = {class_labels[i]: precision[i] for i in range(len(class_labels))}
|
|
recall = {class_labels[i]: recall[i] for i in range(len(class_labels))}
|
|
m_ap = {class_labels[i]: ap[i] for i in range(len(class_labels))}
|
|
|
|
return cls(f1_score, precision, recall, m_ap)
|
|
|
|
|
|
class ObjectDetectionEvalProcessor:
|
|
iou_thresholds = IOU_THRESHOLDS
|
|
score_threshold = SCORE_THRESHOLD
|
|
recall_thresholds = RECALL_THRESHOLDS
|
|
|
|
def __init__(
|
|
self,
|
|
document_preds: list[torch.Tensor],
|
|
document_targets: list[torch.Tensor],
|
|
pages_height: list[int],
|
|
pages_width: list[int],
|
|
class_labels: list[str],
|
|
device: str = "cpu",
|
|
):
|
|
"""
|
|
Initializes the ObjectDetection prediction and ground truth.
|
|
|
|
Args:
|
|
document_preds (list): list (of length pages of document) of
|
|
Tensors of shape (num_predictions, 6)
|
|
format: (x1, y1, x2, y2, confidence,class_label)
|
|
where x1,y1,x2,y2 are according to image size
|
|
document_targets (list): list (of length pages of document) of
|
|
Tensors of shape (num_targets, 6)
|
|
format: (label, x1, y1, x2, y2)
|
|
where x,y,w,h are according to image size
|
|
pages_height (list): list of height of each page in the document
|
|
pages_width (list): list of width of each page in the document
|
|
class_labels (list): list of class labels
|
|
"""
|
|
self.device = device
|
|
self.document_preds = [pred.to(device) for pred in document_preds]
|
|
self.document_targets = [target.to(device) for target in document_targets]
|
|
self.pages_height = pages_height
|
|
self.pages_width = pages_width
|
|
self.class_labels = class_labels
|
|
|
|
@classmethod
|
|
def from_json_files(
|
|
cls,
|
|
prediction_file_path: Path,
|
|
ground_truth_file_path: Path,
|
|
) -> "ObjectDetectionEvalProcessor":
|
|
"""
|
|
Initializes the ObjectDetection prediction and ground truth,
|
|
and converts the data to the required format.
|
|
|
|
Args:
|
|
prediction_file_path (Path): path to json file with predictions dump from OD model
|
|
ground_truth_file_path (Path): path to json file with OD ground truth data
|
|
"""
|
|
# TODO: Test after https://unstructured-ai.atlassian.net/browse/ML-92
|
|
# is done.
|
|
with open(prediction_file_path) as f:
|
|
predictions_data = json.load(f)
|
|
with open(ground_truth_file_path) as f:
|
|
ground_truth_data = json.load(f)
|
|
|
|
assert sorted(predictions_data["object_detection_classes"]) == sorted(
|
|
ground_truth_data["object_detection_classes"]
|
|
), "Classes in predictions and ground truth do not match."
|
|
assert len(predictions_data["pages"]) == len(ground_truth_data["pages"]), (
|
|
"Pages number in predictions and ground truth do not match."
|
|
)
|
|
for pred_page, gt_page in zip(
|
|
sorted(predictions_data["pages"], key=lambda p: p["number"]),
|
|
sorted(ground_truth_data["pages"], key=lambda p: p["number"]),
|
|
):
|
|
assert pred_page["number"] == gt_page["number"], (
|
|
f"Page numbers in predictions {prediction_file_path.name} "
|
|
f"({pred_page['number']}) and ground truth {ground_truth_file_path.name} "
|
|
f"({gt_page['number']}) do not match."
|
|
)
|
|
page_num = pred_page["number"]
|
|
|
|
# TODO: translate the bboxes instead of raising error
|
|
assert pred_page["size"] == gt_page["size"], (
|
|
f"Page sizes in predictions {prediction_file_path.name} "
|
|
f"({pred_page['size'][0]} x {pred_page['size'][1]}) "
|
|
f"and ground truth {ground_truth_file_path.name} ({gt_page['size'][0]} x "
|
|
f"{gt_page['size'][1]}) do not match for page {page_num}."
|
|
)
|
|
|
|
class_labels = predictions_data["object_detection_classes"]
|
|
document_preds = cls._process_data(predictions_data, class_labels, prediction=True)
|
|
document_targets = cls._process_data(ground_truth_data, class_labels)
|
|
pages_height, pages_width = cls._parse_page_dimensions(predictions_data)
|
|
|
|
return cls(document_preds, document_targets, pages_height, pages_width, class_labels)
|
|
|
|
def get_metrics(
|
|
self,
|
|
) -> tuple[ObjectDetectionAggregatedEvaluation, ObjectDetectionPerClassEvaluation]:
|
|
"""Get per document OD metrics.
|
|
|
|
Returns:
|
|
tuple: Tuple of ObjectDetectionAggregatedEvaluation and
|
|
ObjectDetectionPerClassEvaluation
|
|
"""
|
|
document_matchings = []
|
|
for preds, targets, height, width in zip(
|
|
self.document_preds, self.document_targets, self.pages_height, self.pages_width
|
|
):
|
|
# iterate over each page
|
|
page_matching_tensors = self._compute_page_detection_matching(
|
|
preds=preds,
|
|
targets=targets,
|
|
height=height,
|
|
width=width,
|
|
)
|
|
document_matchings.append(page_matching_tensors)
|
|
|
|
# compute metrics for all detections and targets
|
|
mean_ap, mean_precision, mean_recall, mean_f1 = (
|
|
-1.0,
|
|
-1.0,
|
|
-1.0,
|
|
-1.0,
|
|
)
|
|
|
|
num_cls = len(self.class_labels)
|
|
mean_ap_per_class = np.full(num_cls, np.nan)
|
|
mean_precision_per_class = np.full(num_cls, np.nan)
|
|
mean_recall_per_class = np.full(num_cls, np.nan)
|
|
mean_f1_per_class = np.full(num_cls, np.nan)
|
|
|
|
if len(document_matchings):
|
|
matching_info_tensors = [torch.cat(x, 0) for x in list(zip(*document_matchings))]
|
|
|
|
# shape (n_class, nb_iou_thresh)
|
|
(
|
|
ap_per_present_classes,
|
|
precision_per_present_classes,
|
|
recall_per_present_classes,
|
|
f1_per_present_classes,
|
|
present_classes,
|
|
) = self._compute_detection_metrics(
|
|
*matching_info_tensors,
|
|
)
|
|
|
|
# Precision, recall and f1 are computed for IoU threshold range, averaged over classes
|
|
# results before version 3.0.4 (Dec 11 2022) were computed only for smallest value
|
|
# (i.e IoU 0.5 if metric is @0.5:0.95)
|
|
mean_precision, mean_recall, mean_f1 = (
|
|
precision_per_present_classes.mean(),
|
|
recall_per_present_classes.mean(),
|
|
f1_per_present_classes.mean(),
|
|
)
|
|
|
|
# MaP is averaged over IoU thresholds and over classes
|
|
mean_ap = ap_per_present_classes.mean()
|
|
|
|
# Fill array of per-class AP scores with values for classes that were present in the
|
|
# dataset
|
|
ap_per_class = ap_per_present_classes.mean(1)
|
|
precision_per_class = precision_per_present_classes.mean(1)
|
|
recall_per_class = recall_per_present_classes.mean(1)
|
|
f1_per_class = f1_per_present_classes.mean(1)
|
|
for i, class_index in enumerate(present_classes):
|
|
mean_ap_per_class[class_index] = float(ap_per_class[i])
|
|
|
|
mean_precision_per_class[class_index] = float(precision_per_class[i])
|
|
mean_recall_per_class[class_index] = float(recall_per_class[i])
|
|
mean_f1_per_class[class_index] = float(f1_per_class[i])
|
|
|
|
od_per_class_evaluation = ObjectDetectionPerClassEvaluation.from_tensors(
|
|
ap=mean_ap_per_class,
|
|
precision=mean_precision_per_class,
|
|
recall=mean_recall_per_class,
|
|
f1=mean_f1_per_class,
|
|
class_labels=self.class_labels,
|
|
)
|
|
|
|
od_evaluation = ObjectDetectionAggregatedEvaluation(
|
|
f1_score=float(mean_f1),
|
|
precision=float(mean_precision),
|
|
recall=float(mean_recall),
|
|
m_ap=float(mean_ap),
|
|
)
|
|
|
|
return od_evaluation, od_per_class_evaluation
|
|
|
|
@staticmethod
|
|
def _parse_page_dimensions(data: dict) -> tuple[list, list]:
|
|
"""
|
|
Process the page dimensions from the json file to the required format.
|
|
"""
|
|
pages_height = []
|
|
pages_width = []
|
|
for page in data["pages"]:
|
|
pages_height.append(page["size"]["height"])
|
|
pages_width.append(page["size"]["width"])
|
|
return pages_height, pages_width
|
|
|
|
@staticmethod
|
|
def _process_data(data: dict, class_labels, prediction: bool = False) -> list[dict]:
|
|
"""
|
|
Process the elements from the json file to the required format.
|
|
"""
|
|
pages_list = []
|
|
for page in data["pages"]:
|
|
page_elements = []
|
|
for element in page["elements"]:
|
|
# Extract coordinates, confidence, and class label from each prediction
|
|
class_label = element["type"]
|
|
class_idx = class_labels.index(class_label)
|
|
x1, y1, x2, y2 = element["bbox"]
|
|
if prediction:
|
|
confidence = element["prob"]
|
|
page_elements.append([x1, y1, x2, y2, confidence, class_idx])
|
|
else:
|
|
page_elements.append([class_idx, x1, y1, x2, y2])
|
|
page_tensor = torch.tensor(page_elements)
|
|
pages_list.append(page_tensor)
|
|
|
|
return pages_list
|
|
|
|
@staticmethod
|
|
def _get_top_k_idx_per_cls(
|
|
preds_scores: torch.Tensor, preds_cls: torch.Tensor, top_k: int
|
|
) -> torch.Tensor:
|
|
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
|
"""
|
|
Get the indexes of all the top k predictions for every class
|
|
|
|
Args:
|
|
preds_scores: The confidence scores, vector of shape (n_pred)
|
|
preds_cls: The predicted class, vector of shape (n_pred)
|
|
top_k: Number of predictions to keep per class, ordered by confidence score
|
|
|
|
Returns:
|
|
top_k_idx: Indexes of the top k predictions. length <= (k * n_unique_class)
|
|
"""
|
|
n_unique_cls = torch.max(preds_cls)
|
|
mask = preds_cls.view(-1, 1) == torch.arange(
|
|
n_unique_cls + 1, device=preds_scores.device
|
|
).view(1, -1)
|
|
preds_scores_per_cls = preds_scores.view(-1, 1) * mask
|
|
|
|
sorted_scores_per_cls, sorting_idx = preds_scores_per_cls.sort(0, descending=True)
|
|
idx_with_satisfying_scores = sorted_scores_per_cls[:top_k, :].nonzero(as_tuple=False)
|
|
top_k_idx = sorting_idx[idx_with_satisfying_scores.split(1, dim=1)]
|
|
return top_k_idx.view(-1)
|
|
|
|
@staticmethod
|
|
def _change_bbox_bounds_for_image_size(
|
|
boxes: np.ndarray, img_shape: tuple[int, int]
|
|
) -> np.ndarray:
|
|
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
|
"""
|
|
Clips bboxes to image boundaries.
|
|
|
|
Args:
|
|
bboxes: Input bounding boxes in XYXY format of [..., 4] shape
|
|
img_shape: Image shape (height, width).
|
|
Returns:
|
|
clipped_boxes: Clipped bboxes in XYXY format of [..., 4] shape
|
|
"""
|
|
boxes[..., [0, 2]] = boxes[..., [0, 2]].clip(min=0, max=img_shape[1])
|
|
boxes[..., [1, 3]] = boxes[..., [1, 3]].clip(min=0, max=img_shape[0])
|
|
return boxes
|
|
|
|
@staticmethod
|
|
def _box_iou(box1: torch.Tensor, box2: torch.Tensor) -> torch.Tensor:
|
|
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
|
"""
|
|
Return intersection-over-union (Jaccard index) of boxes.
|
|
Both sets of boxes are expected to be in (x1, y1, x2, y2) format.
|
|
|
|
Args:
|
|
box1: Tensor of shape [N, 4]
|
|
box2: Tensor of shape [M, 4]
|
|
|
|
Returns:
|
|
iou: Tensor of shape [N, M]: the NxM matrix containing the pairwise IoU values
|
|
for every element in boxes1 and boxes2
|
|
"""
|
|
|
|
def box_area(box):
|
|
# box = 4xn
|
|
return (box[2] - box[0]) * (box[3] - box[1])
|
|
|
|
area1 = box_area(box1.T)
|
|
area2 = box_area(box2.T)
|
|
|
|
# inter(N,M) = (rb(N,M,2) - lt(N,M,2)).clamp(0).prod(2)
|
|
inter = (
|
|
(torch.min(box1[:, None, 2:], box2[:, 2:]) - torch.max(box1[:, None, :2], box2[:, :2]))
|
|
.clamp(0)
|
|
.prod(2)
|
|
)
|
|
return inter / (area1[:, None] + area2 - inter) # iou = inter / (area1 + area2 - inter)
|
|
|
|
def _compute_targets(
|
|
self,
|
|
preds_box_xyxy: torch.Tensor,
|
|
preds_cls: torch.Tensor,
|
|
targets_box_xyxy: torch.Tensor,
|
|
targets_cls: torch.Tensor,
|
|
preds_matched: torch.Tensor,
|
|
targets_matched: torch.Tensor,
|
|
preds_idx_to_use: torch.Tensor,
|
|
iou_thresholds: torch.Tensor,
|
|
) -> torch.Tensor:
|
|
# From: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
|
"""
|
|
Computes the matching targets based on IoU for regular scenarios.
|
|
|
|
Args:
|
|
preds_box_xyxy: (torch.Tensor) Predicted bounding boxes in XYXY format.
|
|
preds_cls: (torch.Tensor) Predicted classes.
|
|
targets_box_xyxy: (torch.Tensor) Target bounding boxes in XYXY format.
|
|
targets_cls: (torch.Tensor) Target classes.
|
|
preds_matched: (torch.Tensor) Tensor indicating which predictions are matched.
|
|
targets_matched: (torch.Tensor) Tensor indicating which targets are matched.
|
|
preds_idx_to_use: (torch.Tensor) Indices of predictions to use.
|
|
|
|
Returns:
|
|
targets: Computed matching targets.
|
|
"""
|
|
# shape = (n_preds x n_targets)
|
|
iou = self._box_iou(preds_box_xyxy[preds_idx_to_use], targets_box_xyxy)
|
|
|
|
# Fill IoU values at index (i, j) with 0 when the prediction (i) and target(j)
|
|
# are of different class
|
|
# Filling with 0 is equivalent to ignore these values
|
|
# since with want IoU > iou_threshold > 0
|
|
cls_mismatch = preds_cls[preds_idx_to_use].view(-1, 1) != targets_cls.view(1, -1)
|
|
iou[cls_mismatch] = 0
|
|
|
|
# The matching priority is first detection confidence and then IoU value.
|
|
# The detection is already sorted by confidence in NMS,
|
|
# so here for each prediction we order the targets by iou.
|
|
sorted_iou, target_sorted = iou.sort(descending=True, stable=True)
|
|
|
|
# Only iterate over IoU values higher than min threshold to speed up the process
|
|
for pred_selected_i, target_sorted_i in (sorted_iou > iou_thresholds[0]).nonzero(
|
|
as_tuple=False
|
|
):
|
|
# pred_selected_i and target_sorted_i are relative to filters/sorting,
|
|
# so we extract their absolute indexes
|
|
pred_i = preds_idx_to_use[pred_selected_i]
|
|
target_i = target_sorted[pred_selected_i, target_sorted_i]
|
|
|
|
# Vector[j], True when IoU(pred_i, target_i) is above the (j)th threshold
|
|
is_iou_above_threshold = sorted_iou[pred_selected_i, target_sorted_i] > iou_thresholds
|
|
|
|
# Vector[j], True when both pred_i and target_i are not matched yet
|
|
# for the (j)th threshold
|
|
are_candidates_free = torch.logical_and(
|
|
~preds_matched[pred_i, :], ~targets_matched[target_i, :]
|
|
)
|
|
|
|
# Vector[j], True when (pred_i, target_i) can be matched for the (j)th threshold
|
|
are_candidates_good = torch.logical_and(is_iou_above_threshold, are_candidates_free)
|
|
|
|
# For every threshold (j) where target_i and pred_i can be matched together
|
|
# ( are_candidates_good[j]==True )
|
|
# fill the matching placeholders with True
|
|
targets_matched[target_i, are_candidates_good] = True
|
|
preds_matched[pred_i, are_candidates_good] = True
|
|
|
|
# When all the targets are matched with a prediction for every IoU Threshold, stop.
|
|
if targets_matched.all():
|
|
break
|
|
|
|
return preds_matched
|
|
|
|
def _compute_page_detection_matching(
|
|
self,
|
|
preds: torch.Tensor,
|
|
targets: torch.Tensor,
|
|
height: int,
|
|
width: int,
|
|
top_k: int = 100,
|
|
return_on_cpu: bool = True,
|
|
) -> tuple:
|
|
# Adapted from: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
|
"""
|
|
Match predictions (NMS output) and the targets (ground truth) with respect to metric
|
|
and confidence score for a given image.
|
|
|
|
Args:
|
|
preds: Tensor of shape (num_img_predictions, 6)
|
|
format: (x1, y1, x2, y2, confidence, class_label)
|
|
where x1,y1,x2,y2 are according to image size
|
|
targets: targets for this image of shape (num_img_targets, 5)
|
|
format: (label, x1, y1, x2, y2)
|
|
where x1,y1,x2,y2 are according to image size
|
|
height: dimensions of the image
|
|
width: dimensions of the image
|
|
top_k: Number of predictions to keep per class, ordered by confidence score
|
|
return_on_cpu: If True, the output will be returned on "CPU", otherwise it will be
|
|
returned on "device"
|
|
|
|
Returns:
|
|
preds_matched: Tensor of shape (num_img_predictions, n_thresholds)
|
|
True when prediction (i) is matched with a target with respect to
|
|
the (j)th threshold
|
|
preds_to_ignore: Tensor of shape (num_img_predictions, n_thresholds)
|
|
True when prediction (i) is matched with a crowd target with
|
|
respect to the (j)th threshold
|
|
preds_scores: Tensor of shape (num_img_predictions),
|
|
confidence score for every prediction
|
|
preds_cls: Tensor of shape (num_img_predictions),
|
|
predicted class for every prediction
|
|
targets_cls: Tensor of shape (num_img_targets),
|
|
ground truth class for every target
|
|
"""
|
|
thresholds = self.iou_thresholds.to(device=self.device)
|
|
num_thresholds = len(thresholds)
|
|
|
|
if preds is None or len(preds) == 0:
|
|
preds_matched = torch.zeros((0, num_thresholds), dtype=torch.bool, device=self.device)
|
|
preds_to_ignore = torch.zeros((0, num_thresholds), dtype=torch.bool, device=self.device)
|
|
preds_scores = torch.tensor([], dtype=torch.float32, device=self.device)
|
|
preds_cls = torch.tensor([], dtype=torch.float32, device=self.device)
|
|
targets_cls = targets[:, 0].to(device=self.device)
|
|
return preds_matched, preds_to_ignore, preds_scores, preds_cls, targets_cls
|
|
|
|
preds_matched = torch.zeros(
|
|
len(preds), num_thresholds, dtype=torch.bool, device=self.device
|
|
)
|
|
targets_matched = torch.zeros(
|
|
len(targets), num_thresholds, dtype=torch.bool, device=self.device
|
|
)
|
|
preds_to_ignore = torch.zeros(
|
|
len(preds), num_thresholds, dtype=torch.bool, device=self.device
|
|
)
|
|
|
|
preds_cls, preds_box, preds_scores = preds[:, -1], preds[:, 0:4], preds[:, 4]
|
|
targets_cls, targets_box = targets[:, 0], targets[:, 1:5]
|
|
|
|
# Ignore all but the predictions that were top_k for their class
|
|
preds_idx_to_use = self._get_top_k_idx_per_cls(preds_scores, preds_cls, top_k)
|
|
preds_to_ignore[:, :] = True
|
|
preds_to_ignore[preds_idx_to_use] = False
|
|
|
|
if len(targets) > 0: # or len(crowd_targets) > 0:
|
|
self._change_bbox_bounds_for_image_size(preds, (height, width))
|
|
|
|
preds_matched = self._compute_targets(
|
|
preds_box,
|
|
preds_cls,
|
|
targets_box,
|
|
targets_cls,
|
|
preds_matched,
|
|
targets_matched,
|
|
preds_idx_to_use,
|
|
thresholds,
|
|
)
|
|
|
|
return preds_matched, preds_to_ignore, preds_scores, preds_cls, targets_cls
|
|
|
|
def _compute_detection_metrics(
|
|
self,
|
|
preds_matched: torch.Tensor,
|
|
preds_to_ignore: torch.Tensor,
|
|
preds_scores: torch.Tensor,
|
|
preds_cls: torch.Tensor,
|
|
targets_cls: torch.Tensor,
|
|
) -> tuple:
|
|
# Adapted from: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
|
"""
|
|
Compute the list of precision, recall, MaP and f1 for every class.
|
|
|
|
Args:
|
|
preds_matched: Tensor of shape (num_predictions, n_iou_thresholds)
|
|
True when prediction (i) is matched with a target with respect
|
|
to the (j)th IoU threshold
|
|
preds_to_ignore Tensor of shape (num_predictions, n_iou_thresholds)
|
|
True when prediction (i) is matched with a crowd target with
|
|
respect to the (j)th IoU threshold
|
|
preds_scores: Tensor of shape (num_predictions),
|
|
confidence score for every prediction
|
|
preds_cls: Tensor of shape (num_predictions),
|
|
predicted class for every prediction
|
|
targets_cls: Tensor of shape (num_targets),
|
|
ground truth class for every target box to be detected
|
|
|
|
Returns:
|
|
ap, precision, recall, f1: Tensors of shape (n_class, nb_iou_thrs)
|
|
unique_classes: Vector with all unique target classes
|
|
"""
|
|
|
|
preds_matched, preds_to_ignore = (
|
|
preds_matched.to(self.device),
|
|
preds_to_ignore.to(self.device),
|
|
)
|
|
preds_scores, preds_cls, targets_cls = (
|
|
preds_scores.to(self.device),
|
|
preds_cls.to(self.device),
|
|
targets_cls.to(self.device),
|
|
)
|
|
|
|
recall_thresholds = self.recall_thresholds.to(self.device)
|
|
score_threshold = self.score_threshold
|
|
|
|
unique_classes = torch.unique(targets_cls).long()
|
|
|
|
n_class, nb_iou_thrs = len(unique_classes), preds_matched.shape[-1]
|
|
|
|
ap = torch.zeros((n_class, nb_iou_thrs), device=self.device)
|
|
precision = torch.zeros((n_class, nb_iou_thrs), device=self.device)
|
|
recall = torch.zeros((n_class, nb_iou_thrs), device=self.device)
|
|
|
|
for cls_i, class_value in enumerate(unique_classes):
|
|
cls_preds_idx, cls_targets_idx = (
|
|
(preds_cls == class_value),
|
|
(targets_cls == class_value),
|
|
)
|
|
(
|
|
cls_ap,
|
|
cls_precision,
|
|
cls_recall,
|
|
) = self._compute_detection_metrics_per_cls(
|
|
preds_matched=preds_matched[cls_preds_idx],
|
|
preds_to_ignore=preds_to_ignore[cls_preds_idx],
|
|
preds_scores=preds_scores[cls_preds_idx],
|
|
n_targets=cls_targets_idx.sum(),
|
|
recall_thresholds=recall_thresholds,
|
|
score_threshold=score_threshold,
|
|
)
|
|
ap[cls_i, :] = cls_ap
|
|
precision[cls_i, :] = cls_precision
|
|
recall[cls_i, :] = cls_recall
|
|
|
|
f1 = 2 * precision * recall / (precision + recall + 1e-16)
|
|
return ap, precision, recall, f1, unique_classes
|
|
|
|
def _compute_detection_metrics_per_cls(
|
|
self,
|
|
preds_matched: torch.Tensor,
|
|
preds_to_ignore: torch.Tensor,
|
|
preds_scores: torch.Tensor,
|
|
n_targets: int,
|
|
recall_thresholds: torch.Tensor,
|
|
score_threshold: float,
|
|
):
|
|
# Adapted from: https://github.com/Deci-AI/super-gradients/blob/master/src/super_gradients/training/utils/detection_utils.py # noqa E501
|
|
"""
|
|
Compute the list of precision, recall and MaP of a given class for every recall threshold.
|
|
|
|
Args:
|
|
preds_matched: Tensor of shape (num_predictions, n_thresholds)
|
|
True when prediction (i) is matched with a target
|
|
with respect to the(j)th threshold
|
|
preds_to_ignore Tensor of shape (num_predictions, n_thresholds)
|
|
True when prediction (i) is matched with a crowd target
|
|
with respect to the (j)th threshold
|
|
preds_scores: Tensor of shape (num_predictions),
|
|
confidence score for every prediction
|
|
n_targets: Number of target boxes of this class
|
|
recall_thresholds: Tensor of shape (max_n_rec_thresh)
|
|
list of recall thresholds used to compute MaP
|
|
score_threshold: Minimum confidence score to consider a prediction
|
|
for the computation of precision and recall (not MaP)
|
|
|
|
Returns:
|
|
ap, precision, recall: Tensors of shape (nb_thrs)
|
|
"""
|
|
|
|
nb_iou_thrs = preds_matched.shape[-1]
|
|
|
|
tps = preds_matched
|
|
fps = torch.logical_and(
|
|
torch.logical_not(preds_matched), torch.logical_not(preds_to_ignore)
|
|
)
|
|
|
|
if len(tps) == 0:
|
|
return (
|
|
torch.zeros(nb_iou_thrs, device=self.device),
|
|
torch.zeros(nb_iou_thrs, device=self.device),
|
|
torch.zeros(nb_iou_thrs, device=self.device),
|
|
)
|
|
|
|
# Sort by decreasing score
|
|
dtype = (
|
|
torch.uint8
|
|
if preds_scores.is_cuda and preds_scores.dtype is torch.bool
|
|
else preds_scores.dtype
|
|
)
|
|
sort_ind = torch.argsort(preds_scores.to(dtype), descending=True)
|
|
tps = tps[sort_ind, :]
|
|
fps = fps[sort_ind, :]
|
|
preds_scores = preds_scores[sort_ind].contiguous()
|
|
|
|
# Rolling sum over the predictions
|
|
rolling_tps = torch.cumsum(tps, axis=0, dtype=torch.float)
|
|
rolling_fps = torch.cumsum(fps, axis=0, dtype=torch.float)
|
|
|
|
rolling_recalls = rolling_tps / n_targets
|
|
rolling_precisions = rolling_tps / (
|
|
rolling_tps + rolling_fps + torch.finfo(torch.float64).eps
|
|
)
|
|
|
|
# Reversed cummax to only have decreasing values
|
|
rolling_precisions = rolling_precisions.flip(0).cummax(0).values.flip(0)
|
|
|
|
# ==================
|
|
# RECALL & PRECISION
|
|
|
|
# We want the rolling precision/recall at index i so that:
|
|
# preds_scores[i-1] >= score_threshold > preds_scores[i]
|
|
# Note: torch.searchsorted works on increasing sequence and preds_scores is decreasing,
|
|
# so we work with "-"
|
|
# Note2: right=True due to negation
|
|
lowest_score_above_threshold = torch.searchsorted(
|
|
-preds_scores, -score_threshold, right=True
|
|
)
|
|
|
|
if (
|
|
lowest_score_above_threshold == 0
|
|
): # Here score_threshold > preds_scores[0], so no pred is above the threshold
|
|
recall = torch.zeros(nb_iou_thrs, device=self.device)
|
|
precision = torch.zeros(
|
|
nb_iou_thrs, device=self.device
|
|
) # the precision is not really defined when no pred but we need to give it a value
|
|
else:
|
|
recall = rolling_recalls[lowest_score_above_threshold - 1]
|
|
precision = rolling_precisions[lowest_score_above_threshold - 1]
|
|
|
|
# ==================
|
|
# AVERAGE PRECISION
|
|
|
|
# shape = (nb_iou_thrs, n_recall_thresholds)
|
|
recall_thresholds = recall_thresholds.view(1, -1).repeat(nb_iou_thrs, 1)
|
|
|
|
# We want the index i so that:
|
|
# rolling_recalls[i-1] < recall_thresholds[k] <= rolling_recalls[i]
|
|
# Note: when recall_thresholds[k] > max(rolling_recalls), i = len(rolling_recalls)
|
|
# Note2: we work with transpose (.T) to apply torch.searchsorted on first dim
|
|
# instead of the last one
|
|
recall_threshold_idx = torch.searchsorted(
|
|
rolling_recalls.T.contiguous(), recall_thresholds, right=False
|
|
).T
|
|
|
|
# When recall_thresholds[k] > max(rolling_recalls),
|
|
# rolling_precisions[i] is not defined, and we want precision = 0
|
|
rolling_precisions = torch.cat(
|
|
(rolling_precisions, torch.zeros(1, nb_iou_thrs, device=self.device)), dim=0
|
|
)
|
|
|
|
# shape = (n_recall_thresholds, nb_iou_thrs)
|
|
sampled_precision_points = torch.gather(
|
|
input=rolling_precisions, index=recall_threshold_idx, dim=0
|
|
)
|
|
|
|
# Average over the recall_thresholds
|
|
ap = sampled_precision_points.mean(0)
|
|
|
|
return ap, precision, recall
|
|
|
|
|
|
if __name__ == "__main__":
|
|
from dataclasses import asdict
|
|
|
|
# Example usage
|
|
prediction_file_paths = [Path("pths/to/predictions.json"), Path("pths/to/predictions2.json")]
|
|
ground_truth_file_paths = [
|
|
Path("pths/to/ground_truth.json"),
|
|
Path("pths/to/ground_truth2.json"),
|
|
]
|
|
|
|
for prediction_file_path, ground_truth_file_path in zip(
|
|
prediction_file_paths, ground_truth_file_paths
|
|
):
|
|
eval_processor = ObjectDetectionEvalProcessor.from_json_files(
|
|
prediction_file_path, ground_truth_file_path
|
|
)
|
|
|
|
metrics, per_class_metrics = eval_processor.get_metrics()
|
|
print(f"Metrics for {ground_truth_file_path.name}:\n{asdict(metrics)}")
|
|
print(f"Per class Metrics for {ground_truth_file_path.name}:\n{asdict(per_class_metrics)}")
|