Source code for otx.api.usecases.evaluation.basic_operations

"""This module contains functions for basic operations."""

# Copyright (C) 2021-2022 Intel Corporation
# SPDX-License-Identifier: Apache-2.0
#


from typing import Dict, List, Optional, Tuple

import numpy as np

from otx.api.entities.label import LabelEntity
from otx.api.entities.shapes.rectangle import Rectangle

#: Dictionary storing a number for each label. The ``None`` key represents "all labels"
NumberPerLabel = Dict[Optional[LabelEntity], int]


[docs] def get_intersections_and_cardinalities( references: List[np.ndarray], predictions: List[np.ndarray], labels: List[LabelEntity], ) -> Tuple[NumberPerLabel, NumberPerLabel]: """Returns all intersections and cardinalities between reference masks and prediction masks. Intersections and cardinalities are each returned in a dictionary mapping each label to its corresponding number of intersection/cardinality pixels Args: references (List[np.ndarray]): reference masks,s one mask per image predictions (List[np.ndarray]): prediction masks, one mask per image labels (List[LabelEntity]): labels in input masks Returns: Tuple[NumberPerLabel, NumberPerLabel]: (all_intersections, all_cardinalities) """ # TODO [Soobee] : Add score for background label and align the calculation method with validation all_intersections: NumberPerLabel = {label: 0 for label in labels} all_intersections[None] = 0 all_cardinalities: NumberPerLabel = {label: 0 for label in labels} all_cardinalities[None] = 0 for reference, prediction in zip(references, predictions): intersection = np.where(reference == prediction, reference, 0) all_intersections[None] += np.count_nonzero(intersection) all_cardinalities[None] += np.count_nonzero(reference) + np.count_nonzero(prediction) for i, label in enumerate(labels): label_num = i + 1 all_intersections[label] += np.count_nonzero(intersection == label_num) reference_area = np.count_nonzero(reference == label_num) prediction_area = np.count_nonzero(prediction == label_num) all_cardinalities[label] += reference_area + prediction_area return all_intersections, all_cardinalities
[docs] def intersection_box(box1: Rectangle, box2: Rectangle) -> Optional[List[float]]: """Calculate the intersection box of two bounding boxes. Args: box1: a Rectangle that represents the first bounding box box2: a Rectangle that represents the second bounding box Returns: a Rectangle that represents the intersection box if inputs have a valid intersection, else None """ x_left = max(box1.x1, box2.x1) y_top = max(box1.y1, box2.y1) x_right = min(box1.x2, box2.x2) y_bottom = min(box1.y2, box2.y2) if x_right <= x_left or y_bottom <= y_top: return None return [x_left, y_top, x_right, y_bottom]
[docs] def intersection_over_union(box1: Rectangle, box2: Rectangle, intersection: Optional[List[float]] = None) -> float: """Calculate the Intersection over Union (IoU) of two bounding boxes. Args: box1: a Rectangle representing a bounding box box2: a Rectangle representing a second bounding box intersection: precomputed intersection between two boxes (see intersection_box function), if exists. Returns: intersection-over-union of box1 and box2 """ iou = 0.0 if intersection is None: intersection = intersection_box(box1, box2) if intersection is not None: intersection_area = (intersection[2] - intersection[0]) * (intersection[3] - intersection[1]) box1_area = (box1.x2 - box1.x1) * (box1.y2 - box1.y1) box2_area = (box2.x2 - box2.x1) * (box2.y2 - box2.y1) union_area = float(box1_area + box2_area - intersection_area) if union_area != 0: iou = intersection_area / union_area if iou < 0.0 or iou > 1.0: raise ValueError(f"intersection over union should be in range [0,1], instead got iou={iou}") return iou
[docs] def precision_per_class(matrix: np.ndarray) -> np.ndarray: """Compute the precision per class based on the confusion matrix. Args: matrix: the computed confusion matrix Returns: the precision (per class), defined as TP/(TP+FP) """ if not matrix.shape[0] == matrix.shape[1]: # If the matrix is not square (there is a column for "other" label), the "other" column is deleted. # Otherwise, there will be 3 elements in TP and 4 in TP+FP meaning they can't be divided. matrix = np.delete(matrix, -1, 1) tp_per_class = matrix.diagonal() sum_tp_fp_per_class = matrix.sum(0) return divide_arrays_with_possible_zeros(tp_per_class, sum_tp_fp_per_class)
[docs] def recall_per_class(matrix: np.ndarray) -> np.ndarray: """Compute the recall per class based on the confusion matrix. Args: matrix: the computed confusion matrix Returns: the recall (per class), defined as TP/(TP+FN) """ tp_per_class = matrix.diagonal() sum_tp_fn_per_class = matrix.sum(1) return divide_arrays_with_possible_zeros(tp_per_class, sum_tp_fn_per_class)
def divide_arrays_with_possible_zeros(array1: np.ndarray, array2: np.ndarray) -> np.ndarray: """Sometimes the denominator in the precision or recall computation can contain a zero. In that case, a zero is returned for that element (https://stackoverflow.com/a/32106804). Args: array1: the numerator array2: the denominator Returns: the divided arrays (numerator/denominator) with a value of zero where the denominator was zero. """ with np.errstate(divide="ignore", invalid="ignore"): result = np.true_divide(array1, array2) result[result == np.inf] = 0 # If the denominator is a float, np.inf is returned result = np.nan_to_num(result) # If the denominator is an int, np.nan is returned return result