| """ |
| 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 |
|
|
| |
| try: |
| import cc3d |
| _HAS_CC3D = True |
| except Exception: |
| _HAS_CC3D = False |
|
|
|
|
| |
| |
| |
| @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 |
| t_high: float = 0.90 |
| min_volume_mm3: float = 50.0 |
| min_core_prob: float = 0.90 |
| min_mean_prob: float = 0.0 |
| connectivity: int = 26 |
| fill_holes: bool = True |
| keep_top_k: Optional[int] = None |
| drop_border_components: bool = False |
|
|
| 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.") |
|
|
|
|
| |
| |
| |
| 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()) |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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)) |
|
|
| 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) |
| |
| counts = np.bincount(labels.ravel(), minlength=n + 1)[1:] |
| volumes = counts * voxel_vol |
| peak = ndi.maximum(prob, labels, index=idx) |
| mean = ndi.mean(prob, labels, index=idx) |
|
|
| 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: |
| |
| |
| 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 |
|
|
|
|
| |
| |
| |
| 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 |
| 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) |
| |
| 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()) |
|
|
|
|
| |
| |
| |
| def calibrate( |
| prob_maps: Sequence[np.ndarray], |
| gts: Sequence[np.ndarray], |
| spacings: Sequence[Sequence[float]], |
| grid: Optional[dict] = None, |
| metric: str = "dsc", |
| 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] |
| for combo in product(*(grid[k] for k in keys)): |
| overrides = dict(zip(keys, combo)) |
| |
| 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) |
| |
| d["min_core_prob"] = min(d["min_core_prob"], d["t_high"]) |
| return PostProcConfig(**d) |
|
|
|
|
| |
| |
| |
| 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) |
| spacing = tuple(reversed(img.GetSpacing())) |
| 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() |
|
|