SACFlow / sacflow /utils /metrics.py
sathiiii's picture
Add SACFlow source code
d65ae7d verified
Raw
History Blame Contribute Delete
4.44 kB
from __future__ import annotations
from typing import Dict, List
import numpy as np
import torch
from scipy import ndimage
def dice_per_class(pred: np.ndarray, target: np.ndarray, num_classes: int, include_bg: bool = False) -> Dict[str, float]:
out = {}
start = 0 if include_bg else 1
vals = []
for c in range(start, num_classes):
p = pred == c
t = target == c
denom = p.sum() + t.sum()
if denom == 0:
d = np.nan
else:
d = 2.0 * np.logical_and(p, t).sum() / denom
out[f"dice_c{c}"] = float(d) if not np.isnan(d) else np.nan
if not np.isnan(d):
vals.append(d)
out["dice_mean"] = float(np.mean(vals)) if vals else np.nan
return out
def _surface(mask: np.ndarray) -> np.ndarray:
if mask.sum() == 0:
return mask.astype(bool)
eroded = ndimage.binary_erosion(mask, iterations=1, border_value=0)
return np.logical_xor(mask, eroded)
def hd95_per_class(pred: np.ndarray, target: np.ndarray, num_classes: int, spacing=None, include_bg: bool = False, empty_penalty: str = "diagonal") -> Dict[str, float]:
"""Compute per-class HD95.
If one mask is empty and the other is not, assigning NaN and dropping the
class makes failed segmentations look artificially good. We instead use the
physical image diagonal as a conservative finite penalty. If both masks are
empty, the class is not present and is excluded from the mean.
"""
out = {}
start = 0 if include_bg else 1
vals = []
if spacing is None:
spacing = (1.0, 1.0, 1.0)
spacing = tuple(float(x) for x in spacing)
diag = float(np.sqrt(sum(((s * max(1, n - 1)) ** 2) for s, n in zip(spacing, target.shape))))
for c in range(start, num_classes):
p = pred == c
t = target == c
p_sum = int(p.sum())
t_sum = int(t.sum())
if p_sum == 0 and t_sum == 0:
hd = np.nan
elif p_sum == 0 or t_sum == 0:
hd = diag if empty_penalty == "diagonal" else np.nan
else:
ps = _surface(p)
ts = _surface(t)
if ps.sum() == 0 or ts.sum() == 0:
hd = diag if empty_penalty == "diagonal" else np.nan
else:
dt_t = ndimage.distance_transform_edt(~ts, sampling=spacing)
dt_p = ndimage.distance_transform_edt(~ps, sampling=spacing)
dists = np.concatenate([dt_t[ps], dt_p[ts]])
hd = np.percentile(dists, 95) if dists.size else np.nan
out[f"hd95_c{c}"] = float(hd) if not np.isnan(hd) else np.nan
if not np.isnan(hd):
vals.append(hd)
out["hd95_mean"] = float(np.mean(vals)) if vals else np.nan
return out
def torch_soft_dice_loss(logits: torch.Tensor, target: torch.Tensor, num_classes: int, ignore_index: int | None = None, eps: float = 1e-5):
probs = torch.softmax(logits, dim=1)
if target.ndim == logits.ndim:
onehot = target.float()
else:
target_clamped = target.clamp(0, num_classes - 1).long()
onehot = torch.nn.functional.one_hot(target_clamped, num_classes).permute(0, 4, 1, 2, 3).float()
if ignore_index is not None:
mask = target != ignore_index
probs = probs * mask.unsqueeze(1)
onehot = onehot * mask.unsqueeze(1)
dims = tuple(range(2, logits.ndim))
inter = (probs * onehot).sum(dims)
denom = probs.sum(dims) + onehot.sum(dims)
dice = (2 * inter + eps) / (denom + eps)
return 1.0 - dice[:, 1:].mean()
def entropy_loss(logits: torch.Tensor, eps: float = 1e-8):
p = torch.softmax(logits, dim=1)
return -(p * (p + eps).log()).sum(dim=1).mean()
def confidence_and_margin(probs: torch.Tensor):
vals, inds = probs.topk(k=2, dim=1)
conf = vals[:, 0]
margin = vals[:, 0] - vals[:, 1]
pred = inds[:, 0]
return conf, margin, pred
def finite_difference_boundary(x: torch.Tensor) -> torch.Tensor:
# x: [B,C,H,W,D] probabilities or one-hot maps
dx = torch.abs(x[:, :, 1:] - x[:, :, :-1]).mean(dim=1, keepdim=True)
dx = torch.nn.functional.pad(dx, (0,0,0,0,1,0))
dy = torch.abs(x[:, :, :, 1:] - x[:, :, :, :-1]).mean(dim=1, keepdim=True)
dy = torch.nn.functional.pad(dy, (0,0,1,0,0,0))
dz = torch.abs(x[:, :, :, :, 1:] - x[:, :, :, :, :-1]).mean(dim=1, keepdim=True)
dz = torch.nn.functional.pad(dz, (1,0,0,0,0,0))
return (dx + dy + dz) / 3.0