THE CODE READER / PYTHON
Download raw .py ↓cv_metrics.py
Your snippet, in context. Explore the file or visualize its recorded example.
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 }