THE CODE READER / PYTHON

cv_metrics.py

Your snippet, in context. Explore the file or visualize its recorded example.

Download raw .py ↓
Complete file
PYTHON / LINE NUMBERS
  1"""Binary 2-D metrics with explicit empty-mask and pixel-center conventions.
  2
  3Distances use all inner boundary pixel centers, four-neighbor erosion with
  4outside background. HD95 is max(directional 95th percentiles), method='linear'.
  5Pairwise distances are a small-mask teaching reference, not a volume backend.
  6"""
  7from __future__ import annotations
  8
  9import cv2
 10import numpy as np
 11from numpy.typing import NDArray
 12
 13Mask = NDArray[np.bool_]
 14FloatArray = NDArray[np.float64]
 15
 16
 17def validate_pair(pred: Mask, target: Mask) -> None:
 18    """Require matching, nonempty 2-D boolean grids; foreground may be empty."""
 19    if pred.dtype != np.bool_ or target.dtype != np.bool_:
 20        raise ValueError("Masks must have bool dtype")
 21    if pred.ndim != 2 or pred.shape != target.shape or pred.size == 0:
 22        raise ValueError("Masks must be matching nonempty 2-D grids")
 23
 24# region overlap
 25def overlap(pred: Mask, target: Mask) -> tuple[float, float]:
 26    """Return IoU, Dice; both empty=1, exactly one empty=0."""
 27    validate_pair(pred, target)
 28    intersection: int = int(np.count_nonzero(pred & target))
 29    union: int = int(np.count_nonzero(pred | target))
 30    total: int = int(np.count_nonzero(pred)) + int(np.count_nonzero(target))
 31    if union == 0:
 32        return 1.0, 1.0
 33    iou: float = intersection / union
 34    dice: float = 2.0 * intersection / total
 35    return iou, dice
 36# endregion overlap
 37
 38# region boundary
 39def boundary(mask: Mask) -> Mask:
 40    """Return inner boundary pixels using a center+four-neighbor cross."""
 41    validate_pair(mask, mask)
 42    cross: NDArray[np.uint8] = np.array(
 43        [[0, 1, 0], [1, 1, 1], [0, 1, 0]], dtype=np.uint8,
 44    )
 45    interior: Mask = cv2.erode(
 46        mask.astype(np.uint8), cross,
 47        borderType=cv2.BORDER_CONSTANT, borderValue=0,
 48    ) != 0
 49    edge: Mask = mask & ~interior
 50    return edge
 51# endregion boundary
 52
 53# region distances
 54def directed_distances(
 55    source: Mask, destination: Mask, spacing: tuple[float, float] = (1.0, 1.0),
 56) -> FloatArray:
 57    """Each source boundary center -> nearest destination center distance.
 58
 59    spacing=(row, column), in pixels or a declared physical unit. This dense
 60    reference rejects products above 2 million pairs before allocating them.
 61    Caller handles empty boundaries; output float64 has shape (source_count,).
 62    """
 63    validate_pair(source, destination)
 64    scale: FloatArray = np.asarray(spacing, dtype=np.float64)
 65    if scale.shape != (2,) or not np.isfinite(scale).all() or np.any(scale <= 0):
 66        raise ValueError("Need positive finite row/column spacing")
 67    a: FloatArray = np.argwhere(boundary(source)).astype(np.float64) * scale
 68    b: FloatArray = np.argwhere(boundary(destination)).astype(np.float64) * scale
 69    if len(a) == 0 or len(b) == 0:
 70        raise ValueError("Directed distances need two nonempty boundaries")
 71    if len(a) * len(b) > 2_000_000:
 72        raise ValueError("Use a distance-transform backend for large masks")
 73    delta: FloatArray = a[:, None, :] - b[None, :, :]
 74    squared: FloatArray = np.sum(delta * delta, axis=-1)
 75    nearest: FloatArray = np.sqrt(squared.min(axis=1))
 76    return nearest
 77# endregion distances
 78
 79# region hausdorff
 80def hausdorff(
 81    pred: Mask, target: Mask, spacing: tuple[float, float] = (1.0, 1.0),
 82) -> tuple[float, float]:
 83    """Return symmetric HD and max-directional HD95; empty=0 or +inf."""
 84    validate_pair(pred, target)
 85    if len(spacing) != 2 or not all(np.isfinite(s) and s > 0 for s in spacing):
 86        raise ValueError("Need positive finite row/column spacing")
 87    if not pred.any() and not target.any():
 88        return 0.0, 0.0
 89    if not pred.any() or not target.any():
 90        return float("inf"), float("inf")
 91    forward: FloatArray = directed_distances(pred, target, spacing)
 92    backward: FloatArray = directed_distances(target, pred, spacing)
 93    hd: float = float(max(forward.max(), backward.max()))
 94    hd95: float = float(max(
 95        np.percentile(forward, 95, method="linear"),
 96        np.percentile(backward, 95, method="linear"),
 97    ))
 98    return hd, hd95
 99# endregion hausdorff
100
101
102def metric_cases() -> dict[str, tuple[Mask, Mask]]:
103    """All pairs are (prediction, target); fixed 12x12 grids, no randomness."""
104    target: Mask = np.zeros((12, 12), dtype=np.bool_)
105    target[3:9, 3:9] = True
106    shifted: Mask = np.zeros_like(target)
107    shifted[3:9, 4:10] = True
108    outlier: Mask = target.copy()
109    outlier[0, 0] = True
110    disjoint: Mask = np.zeros_like(target)
111    disjoint[:2, :2] = True
112    empty: Mask = np.zeros_like(target)
113    return {
114        "exact": (target.copy(), target), "shifted": (shifted, target),
115        "outlier": (outlier, target), "disjoint": (disjoint, target),
116        "both-empty": (empty.copy(), empty), "one-empty": (empty, target),
117    }