PANSEGX / postprocess.py
EOLP's picture
feat: add pan-cancer post-processing for false-positive reduction
6e1a64f
Raw
History Blame Contribute Delete
15.7 kB
"""
Post-processing of PANSEGX probability maps (pan-cancer CT segmentation).
Context
-------
The network (nnU-Net ResEnc-M, `3d_fullres`, patch 64x192x192, TTA disabled)
suffers from a dominant, documented failure mode: **scattered false positives**
(91% of the <0.5 DSC cases are fragmented, 74% over-segmented). There is *no*
official post-processing (no `postprocessing.pkl` in the checkpoint): this step
must therefore be added downstream, on the **full reassembled volume**, never
patch by patch.
Guiding idea
------------
A plain `argmax` (prob >= 0.5) keeps the whole "fog" of false positives, which
precisely crossed 0.5. The right answer is not a higher global threshold (which
erodes the boundary of true lesions -> hurts NSD), but a **connected-component
hysteresis**:
- a LOW threshold `t_low` defines the candidate extent (preserves boundaries);
- a HIGH threshold `t_high` defines confident "seeds";
- a component of the low mask is kept only if it contains at least one seed
(a confident core).
True lesions almost always have a highly confident core; the false-positive fog
does not. Two physical guards are added: minimum volume (in mm³, via the spacing)
and minimum core confidence. No single-lesion assumption is made (multiple
metastatic lesions are legitimate) -> multi-lesion by default.
This module depends only on numpy + scipy (SimpleITK/cc3d optional for I/O and
acceleration). It is deterministic and stateless.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Optional, Sequence
import numpy as np
from scipy import ndimage as ndi
# cc3d speeds up labelling on large volumes; scipy is the universal fallback.
try:
import cc3d # type: ignore
_HAS_CC3D = True
except Exception: # pragma: no cover
_HAS_CC3D = False
# --------------------------------------------------------------------------- #
# Configuration
# --------------------------------------------------------------------------- #
@dataclass(frozen=True)
class PostProcConfig:
"""Post-processing parameters.
Defaults = reasonable starting points, **to be calibrated** on the 50
validation-public cases (see `calibrate`). All probabilities are those of the
tumor (foreground) class in [0, 1].
"""
t_low: float = 0.50 # low threshold: candidate extent (preserves boundaries)
t_high: float = 0.90 # high threshold: confident seed core (hysteresis)
min_volume_mm3: float = 50.0 # smaller components -> removed
min_core_prob: float = 0.90 # minimum peak probability required within a component
min_mean_prob: float = 0.0 # minimum mean probability (0 = disabled)
connectivity: int = 26 # 6 | 18 | 26 (3D neighbourhood)
fill_holes: bool = True # fills internal holes (DSC gain, boundary-neutral)
keep_top_k: Optional[int] = None # keep only the k largest (None = all)
drop_border_components: bool = False # drop components touching the FOV border
def __post_init__(self):
if not (0.0 <= self.t_low <= self.t_high <= 1.0):
raise ValueError(f"Requires 0 <= t_low ({self.t_low}) <= t_high ({self.t_high}) <= 1.")
if self.connectivity not in (6, 18, 26):
raise ValueError("connectivity must be 6, 18 or 26.")
# --------------------------------------------------------------------------- #
# Low-level helpers
# --------------------------------------------------------------------------- #
def foreground_prob(logits_or_prob: np.ndarray) -> np.ndarray:
"""Extract the tumor probability (Z, Y, X) as float32.
Accepts:
- an already-reduced probability map, shape (Z, Y, X);
- 2-class logits, shape (2, Z, Y, X) or (1, 2, Z, Y, X).
The 2-class softmax reduces to a stable sigmoid on (l1 - l0).
"""
a = np.asarray(logits_or_prob)
if a.ndim == 5 and a.shape[0] == 1:
a = a[0]
if a.ndim == 4:
if a.shape[0] != 2:
raise ValueError(f"Expected logits with 2 channels, got shape {a.shape}.")
diff = a[1].astype(np.float64) - a[0].astype(np.float64)
return _sigmoid(diff).astype(np.float32)
if a.ndim == 3:
p = a.astype(np.float32)
if p.min() < 0.0 or p.max() > 1.0:
raise ValueError("3D map provided but outside [0,1]: pass logits (2,Z,Y,X) instead.")
return p
raise ValueError(f"Unsupported shape: {a.shape}.")
def _sigmoid(x: np.ndarray) -> np.ndarray:
return np.where(x >= 0, 1.0 / (1.0 + np.exp(-x)),
np.exp(x) / (1.0 + np.exp(x)))
def _label(mask: np.ndarray, connectivity: int) -> tuple[np.ndarray, int]:
"""3D connected-component labelling (cc3d if available, else scipy)."""
if _HAS_CC3D:
lab = cc3d.connected_components(mask.astype(np.uint8), connectivity=connectivity)
return lab, int(lab.max())
# scipy: structure = connectivity rank derived from the requested connectivity
rank = {6: 1, 18: 2, 26: 3}[connectivity]
structure = ndi.generate_binary_structure(3, rank)
lab, n = ndi.label(mask, structure=structure)
return lab, n
# --------------------------------------------------------------------------- #
# Core: per-component hysteresis + guards
# --------------------------------------------------------------------------- #
def postprocess(
logits_or_prob: np.ndarray,
spacing_zyx: Sequence[float],
config: PostProcConfig = PostProcConfig(),
return_report: bool = False,
):
"""Apply post-processing and return a binary uint8 mask (Z, Y, X).
If `return_report=True`, returns `(mask, report)` where `report` is a dict per
candidate component (volume mm³, peak, mean, kept/rejected + reason) — useful
for analysis and threshold tuning.
"""
prob = foreground_prob(logits_or_prob)
spacing = np.asarray(spacing_zyx, dtype=np.float64)
if spacing.shape != (3,):
raise ValueError("spacing_zyx must contain 3 values (z, y, x) in mm.")
voxel_vol = float(np.prod(spacing)) # mm³ / voxel
low = prob >= config.t_low
labels, n = _label(low, config.connectivity)
if n == 0:
mask = np.zeros(prob.shape, dtype=np.uint8)
return (mask, {"components": [], "kept": 0, "config": asdict(config)}) if return_report else mask
idx = np.arange(1, n + 1)
# Per-component statistics, fully vectorized.
counts = np.bincount(labels.ravel(), minlength=n + 1)[1:] # voxels
volumes = counts * voxel_vol # mm³
peak = ndi.maximum(prob, labels, index=idx) # core probability
mean = ndi.mean(prob, labels, index=idx) # mean probability
keep = (
(peak >= config.t_high)
& (peak >= config.min_core_prob)
& (volumes >= config.min_volume_mm3)
& (mean >= config.min_mean_prob)
)
if config.drop_border_components:
keep &= ~_border_labels_mask(labels, n)
if config.keep_top_k is not None and keep.sum() > config.keep_top_k:
# score = "probability mass" (sum of probabilities): favours lesions that
# are both large and confident, without assuming a single lesion.
mass = ndi.sum(prob, labels, index=idx)
order = np.argsort(mass)[::-1]
allowed = np.zeros(n, dtype=bool)
allowed[order[keep[order]][: config.keep_top_k]] = True
keep &= allowed
keep_ids = idx[keep]
mask = np.isin(labels, keep_ids)
if config.fill_holes and mask.any():
mask = ndi.binary_fill_holes(mask)
mask = mask.astype(np.uint8)
if not return_report:
return mask
report = {"config": asdict(config), "voxel_vol_mm3": voxel_vol,
"n_candidates": int(n), "kept": int(keep.sum()), "components": []}
for i in range(n):
report["components"].append({
"label": int(idx[i]),
"volume_mm3": round(float(volumes[i]), 2),
"peak_prob": round(float(peak[i]), 4),
"mean_prob": round(float(mean[i]), 4),
"kept": bool(keep[i]),
"reason": _reject_reason(peak[i], mean[i], volumes[i], config) if not keep[i] else "kept",
})
return mask, report
def _reject_reason(peak, mean, vol, cfg: PostProcConfig) -> str:
if peak < max(cfg.t_high, cfg.min_core_prob):
return "low_core_confidence"
if vol < cfg.min_volume_mm3:
return "too_small"
if mean < cfg.min_mean_prob:
return "low_mean_confidence"
return "trimmed_by_top_k_or_border"
def _border_labels_mask(labels: np.ndarray, n: int) -> np.ndarray:
"""Boolean mask (length n) of labels touching a face of the volume."""
faces = np.concatenate([
labels[0].ravel(), labels[-1].ravel(),
labels[:, 0].ravel(), labels[:, -1].ravel(),
labels[:, :, 0].ravel(), labels[:, :, -1].ravel(),
])
border_ids = np.unique(faces)
border_ids = border_ids[border_ids > 0]
out = np.zeros(n, dtype=bool)
out[border_ids - 1] = True
return out
# --------------------------------------------------------------------------- #
# Metrics (for calibration)
# --------------------------------------------------------------------------- #
def dice(pred: np.ndarray, gt: np.ndarray) -> float:
pred = pred.astype(bool); gt = gt.astype(bool)
denom = pred.sum() + gt.sum()
if denom == 0:
return 1.0 # two empty masks: perfect agreement (convention)
return 2.0 * np.logical_and(pred, gt).sum() / denom
def surface_dice(pred: np.ndarray, gt: np.ndarray, spacing_zyx: Sequence[float],
tol_mm: float = 2.0) -> float:
"""NSD (Normalized Surface Dice) at tolerance `tol_mm`.
Reference implementation (boundaries via erosion, distances via anisotropic
EDT). Verify against the official FLARE scorer before publishing numbers.
"""
pred = pred.astype(bool); gt = gt.astype(bool)
sp = np.asarray(spacing_zyx, dtype=np.float64)
if pred.sum() == 0 and gt.sum() == 0:
return 1.0
if pred.sum() == 0 or gt.sum() == 0:
return 0.0
s_pred = pred ^ ndi.binary_erosion(pred)
s_gt = gt ^ ndi.binary_erosion(gt)
# distance to the GT boundary / to the pred boundary (EDT on the surface complement)
dt_gt = ndi.distance_transform_edt(~s_gt, sampling=sp)
dt_pred = ndi.distance_transform_edt(~s_pred, sampling=sp)
pred_ok = (dt_gt[s_pred] <= tol_mm).sum()
gt_ok = (dt_pred[s_gt] <= tol_mm).sum()
return (pred_ok + gt_ok) / (s_pred.sum() + s_gt.sum())
# --------------------------------------------------------------------------- #
# Calibration on the 50 validation-public cases
# --------------------------------------------------------------------------- #
def calibrate(
prob_maps: Sequence[np.ndarray],
gts: Sequence[np.ndarray],
spacings: Sequence[Sequence[float]],
grid: Optional[dict] = None,
metric: str = "dsc", # "dsc" | "nsd" | "mean"
base: PostProcConfig = PostProcConfig(),
):
"""Sweep a threshold grid and return (best_config, scores_table).
`prob_maps` are probability maps (or logits) aligned with `gts` (binary masks)
and `spacings`. Maximizes the mean metric over the cohort.
"""
if not (len(prob_maps) == len(gts) == len(spacings)):
raise ValueError("prob_maps, gts and spacings must have the same length.")
grid = grid or {
"t_high": [0.70, 0.80, 0.90, 0.95],
"min_volume_mm3": [0.0, 50.0, 100.0, 250.0, 500.0],
"min_core_prob": [0.80, 0.90, 0.95],
}
keys = list(grid.keys())
from itertools import product
results = []
best = None
probs_fg = [foreground_prob(p) for p in prob_maps] # compute once
for combo in product(*(grid[k] for k in keys)):
overrides = dict(zip(keys, combo))
# min_core_prob cannot exceed t_high in our logic -> align it
cfg = _with(base, **overrides)
dscs, nsds = [], []
for p, gt, sp in zip(probs_fg, gts, spacings):
m = postprocess(p, sp, cfg)
dscs.append(dice(m, gt))
if metric in ("nsd", "mean"):
nsds.append(surface_dice(m, gt, sp, tol_mm=2.0))
d = float(np.mean(dscs))
s = float(np.mean(nsds)) if nsds else float("nan")
score = {"dsc": d, "nsd": s, "mean": np.nanmean([d, s])}[metric]
row = {**overrides, "dsc": round(d, 4), "nsd": round(s, 4), "score": round(score, 4)}
results.append(row)
if best is None or score > best[0]:
best = (score, cfg)
results.sort(key=lambda r: r["score"], reverse=True)
return best[1], results
def _with(cfg: PostProcConfig, **overrides) -> PostProcConfig:
d = asdict(cfg); d.update(overrides)
# consistency: the required core never exceeds the high threshold
d["min_core_prob"] = min(d["min_core_prob"], d["t_high"])
return PostProcConfig(**d)
# --------------------------------------------------------------------------- #
# NIfTI I/O + CLI
# --------------------------------------------------------------------------- #
def _read_nifti(path: str):
"""Return (array Z,Y,X ; spacing z,y,x). Handles a 3D mask or prob/logits."""
import SimpleITK as sitk
img = sitk.ReadImage(str(path))
arr = sitk.GetArrayFromImage(img) # (Z, Y, X) for a scalar image
spacing = tuple(reversed(img.GetSpacing())) # sitk returns (x, y, z)
return arr, spacing, img
def _write_nifti(mask: np.ndarray, ref_img, out_path: str):
import SimpleITK as sitk
out = sitk.GetImageFromArray(mask.astype(np.uint8))
out.CopyInformation(ref_img)
sitk.WriteImage(out, str(out_path))
def main():
p = argparse.ArgumentParser(description="PANSEGX post-processing (hysteresis + physical guards).")
p.add_argument("--input", required=True, help="NIfTI: tumor probability map (Z,Y,X) OR logits.")
p.add_argument("--output", required=True, help="Output NIfTI (binary mask).")
p.add_argument("--t-low", type=float, default=PostProcConfig.t_low)
p.add_argument("--t-high", type=float, default=PostProcConfig.t_high)
p.add_argument("--min-volume-mm3", type=float, default=PostProcConfig.min_volume_mm3)
p.add_argument("--min-core-prob", type=float, default=PostProcConfig.min_core_prob)
p.add_argument("--min-mean-prob", type=float, default=PostProcConfig.min_mean_prob)
p.add_argument("--connectivity", type=int, choices=(6, 18, 26), default=PostProcConfig.connectivity)
p.add_argument("--no-fill-holes", action="store_true")
p.add_argument("--keep-top-k", type=int, default=None)
p.add_argument("--drop-border", action="store_true")
p.add_argument("--report", action="store_true", help="Print the per-component report.")
args = p.parse_args()
cfg = PostProcConfig(
t_low=args.t_low, t_high=args.t_high, min_volume_mm3=args.min_volume_mm3,
min_core_prob=args.min_core_prob, min_mean_prob=args.min_mean_prob,
connectivity=args.connectivity, fill_holes=not args.no_fill_holes,
keep_top_k=args.keep_top_k, drop_border_components=args.drop_border,
)
arr, spacing, ref = _read_nifti(args.input)
result = postprocess(arr, spacing, cfg, return_report=args.report)
mask, report = result if args.report else (result, None)
_write_nifti(mask, ref, args.output)
kept = int(mask.max() and _label(mask, cfg.connectivity)[1])
print(f"[postprocess] spacing(z,y,x)={spacing} -> {kept} component(s) kept. Written: {args.output}")
if report is not None:
import json
print(json.dumps(report, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main()