Upload folder using huggingface_hub
Browse files- code/framework/__init__.py +0 -0
- code/framework/common/__init__.py +0 -0
- code/framework/common/subset.py +40 -0
- code/framework/config.py +116 -0
- code/framework/config.py.bak +109 -0
- code/framework/data/__init__.py +0 -0
- code/framework/data/loaders.py +41 -0
- code/framework/data/loaders.py.bak +40 -0
- code/framework/data/transforms.py +78 -0
- code/framework/data/unified_dataset.py +187 -0
- code/framework/data/unified_dataset.py.bak +180 -0
- code/framework/efficiency.py +106 -0
- code/framework/engine/__init__.py +0 -0
- code/framework/engine/distributed.py +85 -0
- code/framework/engine/evaluator.py +74 -0
- code/framework/engine/losses.py +33 -0
- code/framework/engine/trainer.py +189 -0
- code/framework/eval_at_res.py +92 -0
- code/framework/eval_ckpt.py +120 -0
- code/framework/metrics/__init__.py +0 -0
- code/framework/metrics/boundary.py +68 -0
- code/framework/metrics/metrics.py +128 -0
- code/framework/models/__init__.py +0 -0
- code/framework/models/attention_unet.py +89 -0
- code/framework/models/registry.py +46 -0
- code/framework/models/smp_models.py +39 -0
- code/framework/models/swinunet_wrap.py +75 -0
- code/framework/models/transunet_wrap.py +42 -0
- code/framework/nnunet_convert.py +167 -0
- code/framework/nnunet_eval.py +94 -0
- code/framework/report/__init__.py +0 -0
- code/framework/report/aggregate.py +310 -0
- code/framework/test.py +44 -0
- code/framework/train.py +60 -0
- code/framework/visualize/__init__.py +0 -0
- code/framework/visualize/overlay.py +53 -0
code/framework/__init__.py
ADDED
|
File without changes
|
code/framework/common/__init__.py
ADDED
|
File without changes
|
code/framework/common/subset.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic low-data subset selection.
|
| 2 |
+
|
| 3 |
+
The SAME function is used by (a) the segmentation trainer's real-data loader and
|
| 4 |
+
(b) the pixel-diffusion generator's data loader, so that a low-data experiment at
|
| 5 |
+
fraction f trains the generator on EXACTLY the real images the segmenter sees —
|
| 6 |
+
no leakage, no mismatch, fully reproducible.
|
| 7 |
+
|
| 8 |
+
Selection is driven only by (fraction, fraction_seed) and the SORTED list of
|
| 9 |
+
items, so it is independent of the training seed: the 3 segmentation seeds all
|
| 10 |
+
use the identical real subset, only their weight init differs.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import math
|
| 15 |
+
import random
|
| 16 |
+
from typing import List, Sequence, TypeVar
|
| 17 |
+
|
| 18 |
+
T = TypeVar("T")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def select_fraction(items: Sequence[T], fraction: float, seed: int = 0) -> List[T]:
|
| 22 |
+
"""Return a deterministic subset of `items` of size ceil(fraction*N).
|
| 23 |
+
|
| 24 |
+
items: any sequence (e.g. list of (image_path, mask_path) tuples). It is
|
| 25 |
+
sorted by repr() first so order of discovery never affects the subset.
|
| 26 |
+
fraction: in (0, 1]. >=1 returns all items (sorted copy).
|
| 27 |
+
seed: subset seed (NOT the training seed). Fixed across training seeds.
|
| 28 |
+
"""
|
| 29 |
+
ordered = sorted(items, key=lambda x: repr(x))
|
| 30 |
+
n = len(ordered)
|
| 31 |
+
if fraction >= 1.0 or n == 0:
|
| 32 |
+
return ordered
|
| 33 |
+
if fraction <= 0.0:
|
| 34 |
+
raise ValueError(f"fraction must be in (0,1], got {fraction}")
|
| 35 |
+
k = max(1, math.ceil(fraction * n))
|
| 36 |
+
rng = random.Random(seed)
|
| 37 |
+
idx = list(range(n))
|
| 38 |
+
rng.shuffle(idx)
|
| 39 |
+
keep = sorted(idx[:k])
|
| 40 |
+
return [ordered[i] for i in keep]
|
code/framework/config.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified experiment configuration.
|
| 2 |
+
|
| 3 |
+
A single dataclass drives every run. Values can come from (in priority order):
|
| 4 |
+
1. command-line flags (argparse) 2. a YAML file (--config) 3. dataclass defaults.
|
| 5 |
+
|
| 6 |
+
The same config object is used by train.py / test.py so that a training run and
|
| 7 |
+
its evaluation are guaranteed to agree on dataset, model, image size, etc.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import dataclasses
|
| 13 |
+
from dataclasses import dataclass, field, asdict
|
| 14 |
+
from typing import Optional, List
|
| 15 |
+
|
| 16 |
+
import yaml
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class Config:
|
| 21 |
+
# ---- experiment identity ----
|
| 22 |
+
exp_name: str = "default" # results/<exp_name>/<dataset>/<arch>/seed<seed>/
|
| 23 |
+
seed: int = 0
|
| 24 |
+
|
| 25 |
+
# ---- data ----
|
| 26 |
+
data_root: str = "dataset/processed_unified"
|
| 27 |
+
dataset: str = "cvc_clinicdb" # folder name under data_root
|
| 28 |
+
protocol: str = "official" # e.g. official / fold01 ...
|
| 29 |
+
in_channels: int = 0 # 0 = auto-detect from metadata/first image
|
| 30 |
+
num_classes: int = 0 # 0 = auto-detect from metadata/masks (incl. background)
|
| 31 |
+
img_size: int = 256 # square resize target (Swin/TransUNet need 224)
|
| 32 |
+
# extra synthetic (image,mask) pairs to MERGE into the train split.
|
| 33 |
+
# Points at a dir laid out like a split: <synth_train_dir>/{images,masks}/.
|
| 34 |
+
synth_train_dir: str = "" # "" = real data only (no generative augmentation)
|
| 35 |
+
|
| 36 |
+
# low-data regime: train on a deterministic fraction of the REAL train split.
|
| 37 |
+
# The SAME subset is used by the PixDiff generator (framework.common.subset), so
|
| 38 |
+
# real and real+synth arms never disagree. fraction_seed is FIXED across the 3
|
| 39 |
+
# training seeds (only weight init varies), so the data subset stays constant.
|
| 40 |
+
train_fraction: float = 1.0 # 1.0 = full data; e.g. 0.1 = 10%
|
| 41 |
+
fraction_seed: int = 0 # subset seed (independent of `seed`)
|
| 42 |
+
|
| 43 |
+
# ---- augmentation (conventional baseline tier) ----
|
| 44 |
+
aug: str = "standard" # none | standard | strong (albumentations online)
|
| 45 |
+
aug_backend: str = "albumentations" # albumentations | monai
|
| 46 |
+
normalize: str = "auto" # auto(imagenet for RGB, 0.5 for gray) | imagenet | none
|
| 47 |
+
|
| 48 |
+
# ---- model ----
|
| 49 |
+
arch: str = "unet" # see models/registry.py REGISTRY
|
| 50 |
+
encoder: str = "resnet34" # SMP encoder name (ignored by non-SMP archs)
|
| 51 |
+
encoder_weights: str = "imagenet" # imagenet | none
|
| 52 |
+
pretrained_ckpt: str = "" # ViT/Swin pretrain for transunet/swinunet (optional)
|
| 53 |
+
|
| 54 |
+
# ---- optimization ----
|
| 55 |
+
epochs: int = 100
|
| 56 |
+
batch_size: int = 16 # per-GPU batch size
|
| 57 |
+
lr: float = 1e-4
|
| 58 |
+
weight_decay: float = 1e-4
|
| 59 |
+
optimizer: str = "adamw" # adamw | sgd
|
| 60 |
+
scheduler: str = "poly" # poly | cosine | none
|
| 61 |
+
warmup_epochs: int = 0
|
| 62 |
+
loss: str = "ce_dice" # ce_dice | ce | dice
|
| 63 |
+
num_workers: int = 8
|
| 64 |
+
grad_clip: float = 0.0 # 0 = disabled
|
| 65 |
+
|
| 66 |
+
# ---- precision / hardware ----
|
| 67 |
+
amp: str = "bf16" # bf16(A100+) | fp16(V100) | fp32
|
| 68 |
+
# DDP is driven by torchrun env vars (RANK/WORLD_SIZE/LOCAL_RANK); nothing to set here.
|
| 69 |
+
|
| 70 |
+
# ---- evaluation / logging ----
|
| 71 |
+
val_interval: int = 5 # epochs between validations
|
| 72 |
+
min_epochs: int = 0 # never early-stop before this many epochs
|
| 73 |
+
patience: int = 0 # early-stop after this many epochs w/o val improvement (0 = off)
|
| 74 |
+
save_interval: int = 0 # 0 = only save best + last
|
| 75 |
+
include_background: bool = False # include class 0 in reported Dice/IoU
|
| 76 |
+
compute_hd95: bool = True
|
| 77 |
+
out_root: str = "results"
|
| 78 |
+
resume: str = "" # path to checkpoint to resume from
|
| 79 |
+
visualize: bool = True # save overlays at test time
|
| 80 |
+
vis_max: int = 32 # max number of overlay images to save
|
| 81 |
+
|
| 82 |
+
def out_dir(self) -> str:
|
| 83 |
+
return f"{self.out_root}/{self.exp_name}/{self.dataset}_{self.protocol}/{self.arch}/seed{self.seed}"
|
| 84 |
+
|
| 85 |
+
def to_yaml(self, path: str) -> None:
|
| 86 |
+
with open(path, "w") as f:
|
| 87 |
+
yaml.safe_dump(asdict(self), f, sort_keys=False, allow_unicode=True)
|
| 88 |
+
|
| 89 |
+
@classmethod
|
| 90 |
+
def from_args(cls, argv: Optional[List[str]] = None) -> "Config":
|
| 91 |
+
# First pass: only grab --config so YAML can set defaults that flags then override.
|
| 92 |
+
pre = argparse.ArgumentParser(add_help=False)
|
| 93 |
+
pre.add_argument("--config", type=str, default="")
|
| 94 |
+
known, _ = pre.parse_known_args(argv)
|
| 95 |
+
|
| 96 |
+
base = cls()
|
| 97 |
+
if known.config:
|
| 98 |
+
with open(known.config) as f:
|
| 99 |
+
ydata = yaml.safe_load(f) or {}
|
| 100 |
+
base = dataclasses.replace(base, **{k: v for k, v in ydata.items()
|
| 101 |
+
if k in {f.name for f in dataclasses.fields(cls)}})
|
| 102 |
+
|
| 103 |
+
p = argparse.ArgumentParser(parents=[pre],
|
| 104 |
+
description="SegGen unified segmentation framework")
|
| 105 |
+
for f in dataclasses.fields(cls):
|
| 106 |
+
default = getattr(base, f.name)
|
| 107 |
+
if f.type is bool or isinstance(default, bool):
|
| 108 |
+
# support --flag / --no-flag
|
| 109 |
+
p.add_argument(f"--{f.name}", dest=f.name, action="store_true", default=default)
|
| 110 |
+
p.add_argument(f"--no-{f.name}", dest=f.name, action="store_false")
|
| 111 |
+
else:
|
| 112 |
+
p.add_argument(f"--{f.name}", type=type(default) if default is not None else str,
|
| 113 |
+
default=default)
|
| 114 |
+
ns = p.parse_args(argv)
|
| 115 |
+
kwargs = {f.name: getattr(ns, f.name) for f in dataclasses.fields(cls)}
|
| 116 |
+
return cls(**kwargs)
|
code/framework/config.py.bak
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified experiment configuration.
|
| 2 |
+
|
| 3 |
+
A single dataclass drives every run. Values can come from (in priority order):
|
| 4 |
+
1. command-line flags (argparse) 2. a YAML file (--config) 3. dataclass defaults.
|
| 5 |
+
|
| 6 |
+
The same config object is used by train.py / test.py so that a training run and
|
| 7 |
+
its evaluation are guaranteed to agree on dataset, model, image size, etc.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
import dataclasses
|
| 13 |
+
from dataclasses import dataclass, field, asdict
|
| 14 |
+
from typing import Optional, List
|
| 15 |
+
|
| 16 |
+
import yaml
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@dataclass
|
| 20 |
+
class Config:
|
| 21 |
+
# ---- experiment identity ----
|
| 22 |
+
exp_name: str = "default" # results/<exp_name>/<dataset>/<arch>/seed<seed>/
|
| 23 |
+
seed: int = 0
|
| 24 |
+
|
| 25 |
+
# ---- data ----
|
| 26 |
+
data_root: str = "dataset/processed_unified"
|
| 27 |
+
dataset: str = "cvc_clinicdb" # folder name under data_root
|
| 28 |
+
protocol: str = "official" # e.g. official / fold01 ...
|
| 29 |
+
in_channels: int = 0 # 0 = auto-detect from metadata/first image
|
| 30 |
+
num_classes: int = 0 # 0 = auto-detect from metadata/masks (incl. background)
|
| 31 |
+
img_size: int = 256 # square resize target (Swin/TransUNet need 224)
|
| 32 |
+
# extra synthetic (image,mask) pairs to MERGE into the train split.
|
| 33 |
+
# Points at a dir laid out like a split: <synth_train_dir>/{images,masks}/.
|
| 34 |
+
synth_train_dir: str = "" # "" = real data only (no generative augmentation)
|
| 35 |
+
|
| 36 |
+
# ---- augmentation (conventional baseline tier) ----
|
| 37 |
+
aug: str = "standard" # none | standard | strong (albumentations online)
|
| 38 |
+
aug_backend: str = "albumentations" # albumentations | monai
|
| 39 |
+
normalize: str = "auto" # auto(imagenet for RGB, 0.5 for gray) | imagenet | none
|
| 40 |
+
|
| 41 |
+
# ---- model ----
|
| 42 |
+
arch: str = "unet" # see models/registry.py REGISTRY
|
| 43 |
+
encoder: str = "resnet34" # SMP encoder name (ignored by non-SMP archs)
|
| 44 |
+
encoder_weights: str = "imagenet" # imagenet | none
|
| 45 |
+
pretrained_ckpt: str = "" # ViT/Swin pretrain for transunet/swinunet (optional)
|
| 46 |
+
|
| 47 |
+
# ---- optimization ----
|
| 48 |
+
epochs: int = 100
|
| 49 |
+
batch_size: int = 16 # per-GPU batch size
|
| 50 |
+
lr: float = 1e-4
|
| 51 |
+
weight_decay: float = 1e-4
|
| 52 |
+
optimizer: str = "adamw" # adamw | sgd
|
| 53 |
+
scheduler: str = "poly" # poly | cosine | none
|
| 54 |
+
warmup_epochs: int = 0
|
| 55 |
+
loss: str = "ce_dice" # ce_dice | ce | dice
|
| 56 |
+
num_workers: int = 8
|
| 57 |
+
grad_clip: float = 0.0 # 0 = disabled
|
| 58 |
+
|
| 59 |
+
# ---- precision / hardware ----
|
| 60 |
+
amp: str = "bf16" # bf16(A100+) | fp16(V100) | fp32
|
| 61 |
+
# DDP is driven by torchrun env vars (RANK/WORLD_SIZE/LOCAL_RANK); nothing to set here.
|
| 62 |
+
|
| 63 |
+
# ---- evaluation / logging ----
|
| 64 |
+
val_interval: int = 5 # epochs between validations
|
| 65 |
+
min_epochs: int = 0 # never early-stop before this many epochs
|
| 66 |
+
patience: int = 0 # early-stop after this many epochs w/o val improvement (0 = off)
|
| 67 |
+
save_interval: int = 0 # 0 = only save best + last
|
| 68 |
+
include_background: bool = False # include class 0 in reported Dice/IoU
|
| 69 |
+
compute_hd95: bool = True
|
| 70 |
+
out_root: str = "results"
|
| 71 |
+
resume: str = "" # path to checkpoint to resume from
|
| 72 |
+
visualize: bool = True # save overlays at test time
|
| 73 |
+
vis_max: int = 32 # max number of overlay images to save
|
| 74 |
+
|
| 75 |
+
def out_dir(self) -> str:
|
| 76 |
+
return f"{self.out_root}/{self.exp_name}/{self.dataset}_{self.protocol}/{self.arch}/seed{self.seed}"
|
| 77 |
+
|
| 78 |
+
def to_yaml(self, path: str) -> None:
|
| 79 |
+
with open(path, "w") as f:
|
| 80 |
+
yaml.safe_dump(asdict(self), f, sort_keys=False, allow_unicode=True)
|
| 81 |
+
|
| 82 |
+
@classmethod
|
| 83 |
+
def from_args(cls, argv: Optional[List[str]] = None) -> "Config":
|
| 84 |
+
# First pass: only grab --config so YAML can set defaults that flags then override.
|
| 85 |
+
pre = argparse.ArgumentParser(add_help=False)
|
| 86 |
+
pre.add_argument("--config", type=str, default="")
|
| 87 |
+
known, _ = pre.parse_known_args(argv)
|
| 88 |
+
|
| 89 |
+
base = cls()
|
| 90 |
+
if known.config:
|
| 91 |
+
with open(known.config) as f:
|
| 92 |
+
ydata = yaml.safe_load(f) or {}
|
| 93 |
+
base = dataclasses.replace(base, **{k: v for k, v in ydata.items()
|
| 94 |
+
if k in {f.name for f in dataclasses.fields(cls)}})
|
| 95 |
+
|
| 96 |
+
p = argparse.ArgumentParser(parents=[pre],
|
| 97 |
+
description="SegGen unified segmentation framework")
|
| 98 |
+
for f in dataclasses.fields(cls):
|
| 99 |
+
default = getattr(base, f.name)
|
| 100 |
+
if f.type is bool or isinstance(default, bool):
|
| 101 |
+
# support --flag / --no-flag
|
| 102 |
+
p.add_argument(f"--{f.name}", dest=f.name, action="store_true", default=default)
|
| 103 |
+
p.add_argument(f"--no-{f.name}", dest=f.name, action="store_false")
|
| 104 |
+
else:
|
| 105 |
+
p.add_argument(f"--{f.name}", type=type(default) if default is not None else str,
|
| 106 |
+
default=default)
|
| 107 |
+
ns = p.parse_args(argv)
|
| 108 |
+
kwargs = {f.name: getattr(ns, f.name) for f in dataclasses.fields(cls)}
|
| 109 |
+
return cls(**kwargs)
|
code/framework/data/__init__.py
ADDED
|
File without changes
|
code/framework/data/loaders.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build datasets / dataloaders from a Config, consistent across train & test."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from torch.utils.data import DataLoader
|
| 5 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 6 |
+
|
| 7 |
+
from .unified_dataset import UnifiedSegDataset
|
| 8 |
+
from .transforms import build_transform
|
| 9 |
+
from ..engine.distributed import is_dist
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def build_dataset(cfg, split: str) -> UnifiedSegDataset:
|
| 13 |
+
train = (split == "train")
|
| 14 |
+
synth = cfg.synth_train_dir if train else ""
|
| 15 |
+
# construct without transform first so in_channels/num_classes auto-detect runs
|
| 16 |
+
ds = UnifiedSegDataset(
|
| 17 |
+
data_root=cfg.data_root, dataset=cfg.dataset, protocol=cfg.protocol, split=split,
|
| 18 |
+
transform=None, in_channels=cfg.in_channels, num_classes=cfg.num_classes,
|
| 19 |
+
synth_dir=synth,
|
| 20 |
+
train_fraction=cfg.train_fraction, fraction_seed=cfg.fraction_seed,
|
| 21 |
+
)
|
| 22 |
+
ds.transform = build_transform(cfg.img_size, ds.in_channels, train=train,
|
| 23 |
+
aug=cfg.aug, normalize=cfg.normalize)
|
| 24 |
+
return ds
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_loader(cfg, split: str, ds: UnifiedSegDataset) -> DataLoader:
|
| 28 |
+
train = (split == "train")
|
| 29 |
+
sampler = None
|
| 30 |
+
if is_dist():
|
| 31 |
+
sampler = DistributedSampler(ds, shuffle=train, drop_last=train)
|
| 32 |
+
return DataLoader(
|
| 33 |
+
ds,
|
| 34 |
+
batch_size=cfg.batch_size,
|
| 35 |
+
shuffle=(train and sampler is None),
|
| 36 |
+
sampler=sampler,
|
| 37 |
+
num_workers=cfg.num_workers,
|
| 38 |
+
pin_memory=True,
|
| 39 |
+
drop_last=(train and sampler is None),
|
| 40 |
+
persistent_workers=cfg.num_workers > 0,
|
| 41 |
+
)
|
code/framework/data/loaders.py.bak
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build datasets / dataloaders from a Config, consistent across train & test."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from torch.utils.data import DataLoader
|
| 5 |
+
from torch.utils.data.distributed import DistributedSampler
|
| 6 |
+
|
| 7 |
+
from .unified_dataset import UnifiedSegDataset
|
| 8 |
+
from .transforms import build_transform
|
| 9 |
+
from ..engine.distributed import is_dist
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def build_dataset(cfg, split: str) -> UnifiedSegDataset:
|
| 13 |
+
train = (split == "train")
|
| 14 |
+
synth = cfg.synth_train_dir if train else ""
|
| 15 |
+
# construct without transform first so in_channels/num_classes auto-detect runs
|
| 16 |
+
ds = UnifiedSegDataset(
|
| 17 |
+
data_root=cfg.data_root, dataset=cfg.dataset, protocol=cfg.protocol, split=split,
|
| 18 |
+
transform=None, in_channels=cfg.in_channels, num_classes=cfg.num_classes,
|
| 19 |
+
synth_dir=synth,
|
| 20 |
+
)
|
| 21 |
+
ds.transform = build_transform(cfg.img_size, ds.in_channels, train=train,
|
| 22 |
+
aug=cfg.aug, normalize=cfg.normalize)
|
| 23 |
+
return ds
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_loader(cfg, split: str, ds: UnifiedSegDataset) -> DataLoader:
|
| 27 |
+
train = (split == "train")
|
| 28 |
+
sampler = None
|
| 29 |
+
if is_dist():
|
| 30 |
+
sampler = DistributedSampler(ds, shuffle=train, drop_last=train)
|
| 31 |
+
return DataLoader(
|
| 32 |
+
ds,
|
| 33 |
+
batch_size=cfg.batch_size,
|
| 34 |
+
shuffle=(train and sampler is None),
|
| 35 |
+
sampler=sampler,
|
| 36 |
+
num_workers=cfg.num_workers,
|
| 37 |
+
pin_memory=True,
|
| 38 |
+
drop_last=(train and sampler is None),
|
| 39 |
+
persistent_workers=cfg.num_workers > 0,
|
| 40 |
+
)
|
code/framework/data/transforms.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Albumentations-based transform builder (the conventional-augmentation tier).
|
| 2 |
+
|
| 3 |
+
Key correctness guarantees for segmentation:
|
| 4 |
+
* masks always use NEAREST interpolation (integer class ids never blended);
|
| 5 |
+
every geometric transform sets mask_interpolation=cv2.INTER_NEAREST.
|
| 6 |
+
* image and mask receive the SAME random spatial parameters (Albumentations
|
| 7 |
+
applies one transform jointly to image= and mask=).
|
| 8 |
+
|
| 9 |
+
`aug` presets: none (resize+normalize only) | standard | strong.
|
| 10 |
+
Returns a callable: (image HWC uint8, mask HW int) -> (FloatTensor[C,H,W], LongTensor[H,W]).
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Tuple
|
| 15 |
+
|
| 16 |
+
import cv2
|
| 17 |
+
import numpy as np
|
| 18 |
+
import torch
|
| 19 |
+
import albumentations as A
|
| 20 |
+
from albumentations.pytorch import ToTensorV2
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
_IMAGENET_MEAN = (0.485, 0.456, 0.406)
|
| 24 |
+
_IMAGENET_STD = (0.229, 0.224, 0.225)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _normalize(in_channels: int, normalize: str) -> A.Normalize:
|
| 28 |
+
if normalize == "none":
|
| 29 |
+
mean = (0.0,) * in_channels
|
| 30 |
+
std = (1.0,) * in_channels
|
| 31 |
+
elif normalize == "imagenet" and in_channels == 3:
|
| 32 |
+
mean, std = _IMAGENET_MEAN, _IMAGENET_STD
|
| 33 |
+
else: # auto
|
| 34 |
+
if in_channels == 3:
|
| 35 |
+
mean, std = _IMAGENET_MEAN, _IMAGENET_STD
|
| 36 |
+
else:
|
| 37 |
+
mean, std = (0.5,) * in_channels, (0.5,) * in_channels
|
| 38 |
+
return A.Normalize(mean=mean, std=std, max_pixel_value=255.0)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def build_transform(img_size: int, in_channels: int, train: bool,
|
| 42 |
+
aug: str = "standard", normalize: str = "auto"):
|
| 43 |
+
N = cv2.INTER_NEAREST
|
| 44 |
+
L = cv2.INTER_LINEAR
|
| 45 |
+
ops = []
|
| 46 |
+
|
| 47 |
+
if train and aug != "none":
|
| 48 |
+
ops += [
|
| 49 |
+
A.Resize(img_size, img_size, interpolation=L, mask_interpolation=N),
|
| 50 |
+
A.HorizontalFlip(p=0.5),
|
| 51 |
+
A.VerticalFlip(p=0.5),
|
| 52 |
+
A.Affine(scale=(0.9, 1.1), translate_percent=(0.0, 0.05),
|
| 53 |
+
rotate=(-15, 15), interpolation=L, mask_interpolation=N, p=0.5),
|
| 54 |
+
]
|
| 55 |
+
if aug == "strong":
|
| 56 |
+
ops += [
|
| 57 |
+
A.ElasticTransform(alpha=30, sigma=6, interpolation=L,
|
| 58 |
+
mask_interpolation=N, p=0.3),
|
| 59 |
+
A.GridDistortion(num_steps=5, distort_limit=0.2, interpolation=L,
|
| 60 |
+
mask_interpolation=N, p=0.3),
|
| 61 |
+
A.RandomBrightnessContrast(p=0.5),
|
| 62 |
+
A.GaussNoise(p=0.2),
|
| 63 |
+
]
|
| 64 |
+
if in_channels == 3:
|
| 65 |
+
ops.append(A.CLAHE(p=0.2))
|
| 66 |
+
else:
|
| 67 |
+
ops.append(A.Resize(img_size, img_size, interpolation=L, mask_interpolation=N))
|
| 68 |
+
|
| 69 |
+
ops += [_normalize(in_channels, normalize), ToTensorV2()]
|
| 70 |
+
compose = A.Compose(ops)
|
| 71 |
+
|
| 72 |
+
def _apply(image: np.ndarray, mask: np.ndarray) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 73 |
+
out = compose(image=image, mask=mask)
|
| 74 |
+
img = out["image"].float() # C,H,W
|
| 75 |
+
msk = out["mask"].long() # H,W
|
| 76 |
+
return img, msk
|
| 77 |
+
|
| 78 |
+
return _apply
|
code/framework/data/unified_dataset.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset reader for the standardized `processed_unified` layout.
|
| 2 |
+
|
| 3 |
+
Expected layout (see dataset/SEGMENTATION_WORKSPACE_README.md):
|
| 4 |
+
<data_root>/<dataset>/<protocol>/<split>/images/*.png
|
| 5 |
+
<data_root>/<dataset>/<protocol>/<split>/masks/*.png
|
| 6 |
+
<data_root>/<dataset>/metadata.json (optional, preferred)
|
| 7 |
+
<data_root>/<dataset>/manifest.jsonl (optional)
|
| 8 |
+
|
| 9 |
+
Returns per item: {"image": FloatTensor[C,H,W], "mask": LongTensor[H,W], "name": str}.
|
| 10 |
+
|
| 11 |
+
Binary and multi-class masks are both supported: masks keep their integer class
|
| 12 |
+
ids (0..C-1). Auto-detection of in_channels / num_classes falls back to scanning
|
| 13 |
+
files when metadata is absent, so the loader is robust to missing metadata.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
from glob import glob
|
| 20 |
+
from typing import Optional, Callable, List, Tuple
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import cv2
|
| 24 |
+
from torch.utils.data import Dataset
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
_MODALITY_CHANNELS = { # hint table; only used when metadata lacks in_channels
|
| 28 |
+
"rgb": 3, "fundus": 3, "colonoscopy": 3, "endoscopy": 3, "histopathology": 3,
|
| 29 |
+
"ultrasound": 1, "mri": 1, "ct": 1, "grayscale": 1,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
# Documented class counts (incl. background). metadata.json on the server has no
|
| 33 |
+
# num_classes field, so this table is the fast, reliable primary source; unknown
|
| 34 |
+
# datasets fall back to a FULL scan of the mask set (accurate but slower).
|
| 35 |
+
_KNOWN_NUM_CLASSES = {
|
| 36 |
+
"cvc_clinicdb": 2, "kvasir_seg": 2, "fives": 2, "busi": 2,
|
| 37 |
+
"refuge2": 3, "acdc_png": 4,
|
| 38 |
+
"idridd_segmentation": 6, "pannuke_semantic": 6,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _read_metadata(data_root: str, dataset: str) -> dict:
|
| 43 |
+
path = os.path.join(data_root, dataset, "metadata.json")
|
| 44 |
+
if os.path.isfile(path):
|
| 45 |
+
try:
|
| 46 |
+
with open(path) as f:
|
| 47 |
+
return json.load(f)
|
| 48 |
+
except Exception:
|
| 49 |
+
return {}
|
| 50 |
+
return {}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _pair_from_manifest(split_dir: str, manifest: str) -> Optional[List[Tuple[str, str]]]:
|
| 54 |
+
if not os.path.isfile(manifest):
|
| 55 |
+
return None
|
| 56 |
+
pairs = []
|
| 57 |
+
base = os.path.dirname(manifest)
|
| 58 |
+
with open(manifest) as f:
|
| 59 |
+
for line in f:
|
| 60 |
+
line = line.strip()
|
| 61 |
+
if not line:
|
| 62 |
+
continue
|
| 63 |
+
rec = json.loads(line)
|
| 64 |
+
img = rec.get("image") or rec.get("image_path") or rec.get("img")
|
| 65 |
+
msk = rec.get("mask") or rec.get("mask_path") or rec.get("label")
|
| 66 |
+
if img is None or msk is None:
|
| 67 |
+
return None
|
| 68 |
+
# manifest paths may be relative to dataset root or absolute
|
| 69 |
+
ip = img if os.path.isabs(img) else os.path.join(base, img)
|
| 70 |
+
mp = msk if os.path.isabs(msk) else os.path.join(base, msk)
|
| 71 |
+
# only keep entries that fall under this split dir
|
| 72 |
+
if os.path.normpath(split_dir) in os.path.normpath(ip):
|
| 73 |
+
pairs.append((ip, mp))
|
| 74 |
+
return pairs or None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _pair_by_glob(split_dir: str) -> List[Tuple[str, str]]:
|
| 78 |
+
img_dir = os.path.join(split_dir, "images")
|
| 79 |
+
msk_dir = os.path.join(split_dir, "masks")
|
| 80 |
+
imgs = sorted(glob(os.path.join(img_dir, "*")))
|
| 81 |
+
pairs = []
|
| 82 |
+
for ip in imgs:
|
| 83 |
+
stem = os.path.splitext(os.path.basename(ip))[0]
|
| 84 |
+
# mask may share extension or be .png
|
| 85 |
+
cands = glob(os.path.join(msk_dir, stem + ".*"))
|
| 86 |
+
if not cands:
|
| 87 |
+
continue
|
| 88 |
+
pairs.append((ip, cands[0]))
|
| 89 |
+
return pairs
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def detect_in_channels(meta: dict, sample_img: Optional[str]) -> int:
|
| 93 |
+
if meta.get("in_channels"):
|
| 94 |
+
return int(meta["in_channels"])
|
| 95 |
+
mod = str(meta.get("modality", "")).lower()
|
| 96 |
+
for k, v in _MODALITY_CHANNELS.items():
|
| 97 |
+
if k in mod:
|
| 98 |
+
return v
|
| 99 |
+
if sample_img and os.path.isfile(sample_img):
|
| 100 |
+
im = cv2.imread(sample_img, cv2.IMREAD_UNCHANGED)
|
| 101 |
+
if im is not None and im.ndim == 3 and im.shape[2] >= 3:
|
| 102 |
+
return 3
|
| 103 |
+
return 1
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def detect_num_classes(meta: dict, mask_paths: List[str], dataset: str = "") -> int:
|
| 107 |
+
if dataset in _KNOWN_NUM_CLASSES:
|
| 108 |
+
return _KNOWN_NUM_CLASSES[dataset]
|
| 109 |
+
if meta.get("num_classes"):
|
| 110 |
+
return int(meta["num_classes"])
|
| 111 |
+
# unknown dataset: scan ALL masks so a rare class is never missed
|
| 112 |
+
vals = set()
|
| 113 |
+
for mp in mask_paths:
|
| 114 |
+
m = cv2.imread(mp, cv2.IMREAD_GRAYSCALE)
|
| 115 |
+
if m is not None:
|
| 116 |
+
vals.update(np.unique(m).tolist())
|
| 117 |
+
if not vals:
|
| 118 |
+
return 2
|
| 119 |
+
maxv = max(vals)
|
| 120 |
+
return int(maxv) + 1 if maxv >= 1 else 2
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class UnifiedSegDataset(Dataset):
|
| 124 |
+
def __init__(self, data_root: str, dataset: str, protocol: str, split: str,
|
| 125 |
+
transform: Optional[Callable] = None,
|
| 126 |
+
in_channels: int = 0, num_classes: int = 0,
|
| 127 |
+
synth_dir: str = "",
|
| 128 |
+
train_fraction: float = 1.0, fraction_seed: int = 0):
|
| 129 |
+
self.data_root = data_root
|
| 130 |
+
self.dataset = dataset
|
| 131 |
+
self.split = split
|
| 132 |
+
self.transform = transform
|
| 133 |
+
|
| 134 |
+
split_dir = os.path.join(data_root, dataset, protocol, split)
|
| 135 |
+
if not os.path.isdir(split_dir):
|
| 136 |
+
raise FileNotFoundError(
|
| 137 |
+
f"split dir not found: {split_dir}\n"
|
| 138 |
+
f"(data is prepared separately; see dataset/ scripts)")
|
| 139 |
+
|
| 140 |
+
manifest = os.path.join(data_root, dataset, "manifest.jsonl")
|
| 141 |
+
pairs = _pair_from_manifest(split_dir, manifest) or _pair_by_glob(split_dir)
|
| 142 |
+
if not pairs:
|
| 143 |
+
raise RuntimeError(f"no (image,mask) pairs found in {split_dir}")
|
| 144 |
+
|
| 145 |
+
# low-data: deterministically subsample REAL train pairs (the generator uses
|
| 146 |
+
# the SAME select_fraction, so no leakage) BEFORE merging any synthetic data.
|
| 147 |
+
if split == "train" and train_fraction < 1.0:
|
| 148 |
+
from ..common.subset import select_fraction
|
| 149 |
+
pairs = select_fraction(pairs, train_fraction, fraction_seed)
|
| 150 |
+
|
| 151 |
+
# optionally merge synthetic (image,mask) pairs into the (train) split
|
| 152 |
+
if synth_dir and os.path.isdir(synth_dir):
|
| 153 |
+
sp = _pair_by_glob(synth_dir if os.path.isdir(os.path.join(synth_dir, "images"))
|
| 154 |
+
else os.path.dirname(synth_dir))
|
| 155 |
+
pairs = pairs + sp
|
| 156 |
+
|
| 157 |
+
self.pairs = pairs
|
| 158 |
+
meta = _read_metadata(data_root, dataset)
|
| 159 |
+
self.in_channels = in_channels or detect_in_channels(meta, pairs[0][0])
|
| 160 |
+
self.num_classes = num_classes or detect_num_classes(meta, [p[1] for p in pairs], dataset)
|
| 161 |
+
|
| 162 |
+
def __len__(self) -> int:
|
| 163 |
+
return len(self.pairs)
|
| 164 |
+
|
| 165 |
+
def _load_image(self, path: str) -> np.ndarray:
|
| 166 |
+
if self.in_channels == 1:
|
| 167 |
+
im = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
|
| 168 |
+
if im is None:
|
| 169 |
+
raise IOError(f"cannot read image {path}")
|
| 170 |
+
return im[:, :, None] # H,W,1
|
| 171 |
+
im = cv2.imread(path, cv2.IMREAD_COLOR) # BGR
|
| 172 |
+
if im is None:
|
| 173 |
+
raise IOError(f"cannot read image {path}")
|
| 174 |
+
return cv2.cvtColor(im, cv2.COLOR_BGR2RGB) # H,W,3
|
| 175 |
+
|
| 176 |
+
def __getitem__(self, idx: int):
|
| 177 |
+
ip, mp = self.pairs[idx]
|
| 178 |
+
image = self._load_image(ip)
|
| 179 |
+
mask = cv2.imread(mp, cv2.IMREAD_GRAYSCALE)
|
| 180 |
+
if mask is None:
|
| 181 |
+
raise IOError(f"cannot read mask {mp}")
|
| 182 |
+
mask = mask.astype(np.int64)
|
| 183 |
+
|
| 184 |
+
if self.transform is not None:
|
| 185 |
+
image, mask = self.transform(image, mask)
|
| 186 |
+
return {"image": image, "mask": mask,
|
| 187 |
+
"name": os.path.splitext(os.path.basename(ip))[0]}
|
code/framework/data/unified_dataset.py.bak
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dataset reader for the standardized `processed_unified` layout.
|
| 2 |
+
|
| 3 |
+
Expected layout (see dataset/SEGMENTATION_WORKSPACE_README.md):
|
| 4 |
+
<data_root>/<dataset>/<protocol>/<split>/images/*.png
|
| 5 |
+
<data_root>/<dataset>/<protocol>/<split>/masks/*.png
|
| 6 |
+
<data_root>/<dataset>/metadata.json (optional, preferred)
|
| 7 |
+
<data_root>/<dataset>/manifest.jsonl (optional)
|
| 8 |
+
|
| 9 |
+
Returns per item: {"image": FloatTensor[C,H,W], "mask": LongTensor[H,W], "name": str}.
|
| 10 |
+
|
| 11 |
+
Binary and multi-class masks are both supported: masks keep their integer class
|
| 12 |
+
ids (0..C-1). Auto-detection of in_channels / num_classes falls back to scanning
|
| 13 |
+
files when metadata is absent, so the loader is robust to missing metadata.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
from glob import glob
|
| 20 |
+
from typing import Optional, Callable, List, Tuple
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
import cv2
|
| 24 |
+
from torch.utils.data import Dataset
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
_MODALITY_CHANNELS = { # hint table; only used when metadata lacks in_channels
|
| 28 |
+
"rgb": 3, "fundus": 3, "colonoscopy": 3, "endoscopy": 3, "histopathology": 3,
|
| 29 |
+
"ultrasound": 1, "mri": 1, "ct": 1, "grayscale": 1,
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
# Documented class counts (incl. background). metadata.json on the server has no
|
| 33 |
+
# num_classes field, so this table is the fast, reliable primary source; unknown
|
| 34 |
+
# datasets fall back to a FULL scan of the mask set (accurate but slower).
|
| 35 |
+
_KNOWN_NUM_CLASSES = {
|
| 36 |
+
"cvc_clinicdb": 2, "kvasir_seg": 2, "fives": 2, "busi": 2,
|
| 37 |
+
"refuge2": 3, "acdc_png": 4,
|
| 38 |
+
"idridd_segmentation": 6, "pannuke_semantic": 6,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _read_metadata(data_root: str, dataset: str) -> dict:
|
| 43 |
+
path = os.path.join(data_root, dataset, "metadata.json")
|
| 44 |
+
if os.path.isfile(path):
|
| 45 |
+
try:
|
| 46 |
+
with open(path) as f:
|
| 47 |
+
return json.load(f)
|
| 48 |
+
except Exception:
|
| 49 |
+
return {}
|
| 50 |
+
return {}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _pair_from_manifest(split_dir: str, manifest: str) -> Optional[List[Tuple[str, str]]]:
|
| 54 |
+
if not os.path.isfile(manifest):
|
| 55 |
+
return None
|
| 56 |
+
pairs = []
|
| 57 |
+
base = os.path.dirname(manifest)
|
| 58 |
+
with open(manifest) as f:
|
| 59 |
+
for line in f:
|
| 60 |
+
line = line.strip()
|
| 61 |
+
if not line:
|
| 62 |
+
continue
|
| 63 |
+
rec = json.loads(line)
|
| 64 |
+
img = rec.get("image") or rec.get("image_path") or rec.get("img")
|
| 65 |
+
msk = rec.get("mask") or rec.get("mask_path") or rec.get("label")
|
| 66 |
+
if img is None or msk is None:
|
| 67 |
+
return None
|
| 68 |
+
# manifest paths may be relative to dataset root or absolute
|
| 69 |
+
ip = img if os.path.isabs(img) else os.path.join(base, img)
|
| 70 |
+
mp = msk if os.path.isabs(msk) else os.path.join(base, msk)
|
| 71 |
+
# only keep entries that fall under this split dir
|
| 72 |
+
if os.path.normpath(split_dir) in os.path.normpath(ip):
|
| 73 |
+
pairs.append((ip, mp))
|
| 74 |
+
return pairs or None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _pair_by_glob(split_dir: str) -> List[Tuple[str, str]]:
|
| 78 |
+
img_dir = os.path.join(split_dir, "images")
|
| 79 |
+
msk_dir = os.path.join(split_dir, "masks")
|
| 80 |
+
imgs = sorted(glob(os.path.join(img_dir, "*")))
|
| 81 |
+
pairs = []
|
| 82 |
+
for ip in imgs:
|
| 83 |
+
stem = os.path.splitext(os.path.basename(ip))[0]
|
| 84 |
+
# mask may share extension or be .png
|
| 85 |
+
cands = glob(os.path.join(msk_dir, stem + ".*"))
|
| 86 |
+
if not cands:
|
| 87 |
+
continue
|
| 88 |
+
pairs.append((ip, cands[0]))
|
| 89 |
+
return pairs
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def detect_in_channels(meta: dict, sample_img: Optional[str]) -> int:
|
| 93 |
+
if meta.get("in_channels"):
|
| 94 |
+
return int(meta["in_channels"])
|
| 95 |
+
mod = str(meta.get("modality", "")).lower()
|
| 96 |
+
for k, v in _MODALITY_CHANNELS.items():
|
| 97 |
+
if k in mod:
|
| 98 |
+
return v
|
| 99 |
+
if sample_img and os.path.isfile(sample_img):
|
| 100 |
+
im = cv2.imread(sample_img, cv2.IMREAD_UNCHANGED)
|
| 101 |
+
if im is not None and im.ndim == 3 and im.shape[2] >= 3:
|
| 102 |
+
return 3
|
| 103 |
+
return 1
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def detect_num_classes(meta: dict, mask_paths: List[str], dataset: str = "") -> int:
|
| 107 |
+
if dataset in _KNOWN_NUM_CLASSES:
|
| 108 |
+
return _KNOWN_NUM_CLASSES[dataset]
|
| 109 |
+
if meta.get("num_classes"):
|
| 110 |
+
return int(meta["num_classes"])
|
| 111 |
+
# unknown dataset: scan ALL masks so a rare class is never missed
|
| 112 |
+
vals = set()
|
| 113 |
+
for mp in mask_paths:
|
| 114 |
+
m = cv2.imread(mp, cv2.IMREAD_GRAYSCALE)
|
| 115 |
+
if m is not None:
|
| 116 |
+
vals.update(np.unique(m).tolist())
|
| 117 |
+
if not vals:
|
| 118 |
+
return 2
|
| 119 |
+
maxv = max(vals)
|
| 120 |
+
return int(maxv) + 1 if maxv >= 1 else 2
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class UnifiedSegDataset(Dataset):
|
| 124 |
+
def __init__(self, data_root: str, dataset: str, protocol: str, split: str,
|
| 125 |
+
transform: Optional[Callable] = None,
|
| 126 |
+
in_channels: int = 0, num_classes: int = 0,
|
| 127 |
+
synth_dir: str = ""):
|
| 128 |
+
self.data_root = data_root
|
| 129 |
+
self.dataset = dataset
|
| 130 |
+
self.split = split
|
| 131 |
+
self.transform = transform
|
| 132 |
+
|
| 133 |
+
split_dir = os.path.join(data_root, dataset, protocol, split)
|
| 134 |
+
if not os.path.isdir(split_dir):
|
| 135 |
+
raise FileNotFoundError(
|
| 136 |
+
f"split dir not found: {split_dir}\n"
|
| 137 |
+
f"(data is prepared separately; see dataset/ scripts)")
|
| 138 |
+
|
| 139 |
+
manifest = os.path.join(data_root, dataset, "manifest.jsonl")
|
| 140 |
+
pairs = _pair_from_manifest(split_dir, manifest) or _pair_by_glob(split_dir)
|
| 141 |
+
if not pairs:
|
| 142 |
+
raise RuntimeError(f"no (image,mask) pairs found in {split_dir}")
|
| 143 |
+
|
| 144 |
+
# optionally merge synthetic (image,mask) pairs into the (train) split
|
| 145 |
+
if synth_dir and os.path.isdir(synth_dir):
|
| 146 |
+
sp = _pair_by_glob(synth_dir if os.path.isdir(os.path.join(synth_dir, "images"))
|
| 147 |
+
else os.path.dirname(synth_dir))
|
| 148 |
+
pairs = pairs + sp
|
| 149 |
+
|
| 150 |
+
self.pairs = pairs
|
| 151 |
+
meta = _read_metadata(data_root, dataset)
|
| 152 |
+
self.in_channels = in_channels or detect_in_channels(meta, pairs[0][0])
|
| 153 |
+
self.num_classes = num_classes or detect_num_classes(meta, [p[1] for p in pairs], dataset)
|
| 154 |
+
|
| 155 |
+
def __len__(self) -> int:
|
| 156 |
+
return len(self.pairs)
|
| 157 |
+
|
| 158 |
+
def _load_image(self, path: str) -> np.ndarray:
|
| 159 |
+
if self.in_channels == 1:
|
| 160 |
+
im = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
|
| 161 |
+
if im is None:
|
| 162 |
+
raise IOError(f"cannot read image {path}")
|
| 163 |
+
return im[:, :, None] # H,W,1
|
| 164 |
+
im = cv2.imread(path, cv2.IMREAD_COLOR) # BGR
|
| 165 |
+
if im is None:
|
| 166 |
+
raise IOError(f"cannot read image {path}")
|
| 167 |
+
return cv2.cvtColor(im, cv2.COLOR_BGR2RGB) # H,W,3
|
| 168 |
+
|
| 169 |
+
def __getitem__(self, idx: int):
|
| 170 |
+
ip, mp = self.pairs[idx]
|
| 171 |
+
image = self._load_image(ip)
|
| 172 |
+
mask = cv2.imread(mp, cv2.IMREAD_GRAYSCALE)
|
| 173 |
+
if mask is None:
|
| 174 |
+
raise IOError(f"cannot read mask {mp}")
|
| 175 |
+
mask = mask.astype(np.int64)
|
| 176 |
+
|
| 177 |
+
if self.transform is not None:
|
| 178 |
+
image, mask = self.transform(image, mask)
|
| 179 |
+
return {"image": image, "mask": mask,
|
| 180 |
+
"name": os.path.splitext(os.path.basename(ip))[0]}
|
code/framework/efficiency.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-architecture efficiency table: #params, FLOPs (GMac), inference throughput.
|
| 2 |
+
|
| 3 |
+
Representative setting (in_channels=3, num_classes=2). FLOPs via thop -> fvcore ->
|
| 4 |
+
ptflops (whichever is installed); params and throughput always computed. Run once,
|
| 5 |
+
on a GPU (A100). Output: results/<exp>/efficiency.{md,csv}.
|
| 6 |
+
|
| 7 |
+
python framework/efficiency.py --img_size 256 --out_root results --exp_name baselines
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import sys
|
| 13 |
+
import time
|
| 14 |
+
import json
|
| 15 |
+
import argparse
|
| 16 |
+
|
| 17 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 18 |
+
import torch
|
| 19 |
+
|
| 20 |
+
from framework.models.registry import build_model, required_img_size
|
| 21 |
+
|
| 22 |
+
ARCHS = ["unet", "unetpp", "deeplabv3plus", "attention_unet", "transunet", "swinunet"]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def count_flops_gmac(model, x):
|
| 26 |
+
try:
|
| 27 |
+
from thop import profile
|
| 28 |
+
macs, _ = profile(model, inputs=(x,), verbose=False)
|
| 29 |
+
return macs / 1e9
|
| 30 |
+
except Exception:
|
| 31 |
+
pass
|
| 32 |
+
try:
|
| 33 |
+
from fvcore.nn import FlopCountAnalysis
|
| 34 |
+
return FlopCountAnalysis(model, x).total() / 1e9
|
| 35 |
+
except Exception:
|
| 36 |
+
pass
|
| 37 |
+
return float("nan")
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@torch.no_grad()
|
| 41 |
+
def throughput(model, x, iters=50, warmup=10):
|
| 42 |
+
for _ in range(warmup):
|
| 43 |
+
model(x)
|
| 44 |
+
if x.is_cuda:
|
| 45 |
+
torch.cuda.synchronize()
|
| 46 |
+
t0 = time.time()
|
| 47 |
+
for _ in range(iters):
|
| 48 |
+
model(x)
|
| 49 |
+
if x.is_cuda:
|
| 50 |
+
torch.cuda.synchronize()
|
| 51 |
+
dt = time.time() - t0
|
| 52 |
+
return iters * x.size(0) / dt # images / sec
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def encoder_for(arch):
|
| 56 |
+
if arch in ("unet", "unetpp", "deeplabv3plus"):
|
| 57 |
+
return "resnet50"
|
| 58 |
+
if arch == "transunet":
|
| 59 |
+
return "R50-ViT-B_16"
|
| 60 |
+
return "resnet34"
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def main():
|
| 64 |
+
ap = argparse.ArgumentParser()
|
| 65 |
+
ap.add_argument("--img_size", type=int, default=256)
|
| 66 |
+
ap.add_argument("--batch_size", type=int, default=8)
|
| 67 |
+
ap.add_argument("--out_root", default="results")
|
| 68 |
+
ap.add_argument("--exp_name", default="baselines")
|
| 69 |
+
args = ap.parse_args()
|
| 70 |
+
|
| 71 |
+
dev = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 72 |
+
rows = []
|
| 73 |
+
for arch in ARCHS:
|
| 74 |
+
sz = required_img_size(arch) or args.img_size
|
| 75 |
+
model = build_model(arch, in_channels=3, num_classes=2, img_size=sz,
|
| 76 |
+
encoder=encoder_for(arch), encoder_weights="none").to(dev).eval()
|
| 77 |
+
params_m = sum(p.numel() for p in model.parameters()) / 1e6
|
| 78 |
+
x1 = torch.randn(1, 3, sz, sz, device=dev)
|
| 79 |
+
gmac = count_flops_gmac(model, x1)
|
| 80 |
+
xb = torch.randn(args.batch_size, 3, sz, sz, device=dev)
|
| 81 |
+
try:
|
| 82 |
+
ips = throughput(model, xb)
|
| 83 |
+
except Exception as e:
|
| 84 |
+
ips = float("nan"); print(f"[warn] throughput {arch}: {e}")
|
| 85 |
+
rows.append({"arch": arch, "img": sz, "params_M": round(params_m, 2),
|
| 86 |
+
"gmac": round(gmac, 2) if gmac == gmac else None,
|
| 87 |
+
"imgs_per_s": round(ips, 1) if ips == ips else None})
|
| 88 |
+
print(f"{arch:16s} img={sz} params={params_m:.2f}M GMac={gmac:.2f} {ips:.1f} img/s")
|
| 89 |
+
del model, x1, xb
|
| 90 |
+
if dev.type == "cuda":
|
| 91 |
+
torch.cuda.empty_cache()
|
| 92 |
+
|
| 93 |
+
base = os.path.join(args.out_root, args.exp_name)
|
| 94 |
+
os.makedirs(base, exist_ok=True)
|
| 95 |
+
with open(os.path.join(base, "efficiency.json"), "w") as f:
|
| 96 |
+
json.dump(rows, f, indent=2)
|
| 97 |
+
md = "| Method | Img | Params(M) | GMac | Img/s |\n|---|---|---|---|---|\n"
|
| 98 |
+
for r in rows:
|
| 99 |
+
md += f"| {r['arch']} | {r['img']} | {r['params_M']} | {r['gmac']} | {r['imgs_per_s']} |\n"
|
| 100 |
+
open(os.path.join(base, "efficiency.md"), "w").write(md)
|
| 101 |
+
print(md)
|
| 102 |
+
print(f"written {base}/efficiency.{{json,md}}")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
if __name__ == "__main__":
|
| 106 |
+
main()
|
code/framework/engine/__init__.py
ADDED
|
File without changes
|
code/framework/engine/distributed.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Minimal DDP helpers driven entirely by torchrun environment variables.
|
| 2 |
+
|
| 3 |
+
Launch with: torchrun --nproc_per_node=<N> framework/train.py ...
|
| 4 |
+
Single-process (no torchrun) also works: world_size falls back to 1.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import random
|
| 10 |
+
from typing import List, Any
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
import torch.distributed as dist
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def is_dist() -> bool:
|
| 18 |
+
return dist.is_available() and dist.is_initialized()
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def get_rank() -> int:
|
| 22 |
+
return dist.get_rank() if is_dist() else 0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def get_world_size() -> int:
|
| 26 |
+
return dist.get_world_size() if is_dist() else 1
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def is_main() -> bool:
|
| 30 |
+
return get_rank() == 0
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def setup_distributed() -> int:
|
| 34 |
+
"""Init the process group if launched under torchrun. Returns local_rank."""
|
| 35 |
+
if "RANK" in os.environ and "WORLD_SIZE" in os.environ:
|
| 36 |
+
local_rank = int(os.environ.get("LOCAL_RANK", 0))
|
| 37 |
+
torch.cuda.set_device(local_rank)
|
| 38 |
+
# bind this rank's device to the PG so collectives/barrier don't guess
|
| 39 |
+
# GPU 0 (avoids the NCCL "devices unknown" warning + potential hang)
|
| 40 |
+
try:
|
| 41 |
+
dist.init_process_group(backend="nccl", init_method="env://",
|
| 42 |
+
device_id=torch.device("cuda", local_rank))
|
| 43 |
+
except TypeError: # older torch without device_id kwarg
|
| 44 |
+
dist.init_process_group(backend="nccl", init_method="env://")
|
| 45 |
+
dist.barrier(device_ids=[local_rank])
|
| 46 |
+
return local_rank
|
| 47 |
+
# single GPU / CPU fallback
|
| 48 |
+
if torch.cuda.is_available():
|
| 49 |
+
torch.cuda.set_device(0)
|
| 50 |
+
return 0
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def cleanup_distributed() -> None:
|
| 54 |
+
if is_dist():
|
| 55 |
+
dist.barrier()
|
| 56 |
+
dist.destroy_process_group()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def all_gather_object(obj: Any) -> List[Any]:
|
| 60 |
+
"""Gather arbitrary picklable objects from all ranks into a flat list."""
|
| 61 |
+
if not is_dist():
|
| 62 |
+
return [obj]
|
| 63 |
+
out: List[Any] = [None for _ in range(get_world_size())]
|
| 64 |
+
dist.all_gather_object(out, obj)
|
| 65 |
+
return out
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def set_seed(seed: int, rank: int = 0, deterministic: bool = False) -> None:
|
| 69 |
+
"""Seed all RNGs. Each rank gets a distinct stream (seed + rank) so DDP
|
| 70 |
+
workers don't draw identical augmentation noise, while staying reproducible."""
|
| 71 |
+
s = seed + rank
|
| 72 |
+
random.seed(s)
|
| 73 |
+
np.random.seed(s)
|
| 74 |
+
torch.manual_seed(s)
|
| 75 |
+
torch.cuda.manual_seed_all(s)
|
| 76 |
+
if deterministic:
|
| 77 |
+
torch.backends.cudnn.deterministic = True
|
| 78 |
+
torch.backends.cudnn.benchmark = False
|
| 79 |
+
else:
|
| 80 |
+
torch.backends.cudnn.benchmark = True
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def print_main(*args, **kwargs) -> None:
|
| 84 |
+
if is_main():
|
| 85 |
+
print(*args, **kwargs, flush=True)
|
code/framework/engine/evaluator.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Test-time evaluation: load best checkpoint, run on the test split, compute
|
| 2 |
+
Dice / IoU / HD95 (per image -> mean +- SD), optionally save overlay visualizations
|
| 3 |
+
and a metrics.json. Runs single-process (rank 0) for deterministic reporting.
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
from ..data.loaders import build_dataset, build_loader
|
| 14 |
+
from ..metrics.metrics import per_image_metrics, aggregate
|
| 15 |
+
from ..visualize.overlay import save_overlay
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@torch.no_grad()
|
| 19 |
+
def evaluate(cfg, model, device, ckpt_path: str = "") -> dict:
|
| 20 |
+
ds = build_dataset(cfg, "test")
|
| 21 |
+
num_classes = ds.num_classes
|
| 22 |
+
loader = build_loader(cfg, "test", ds) # single-process: no DistributedSampler
|
| 23 |
+
|
| 24 |
+
ckpt_path = ckpt_path or os.path.join(cfg.out_dir(), "best.pth")
|
| 25 |
+
if os.path.isfile(ckpt_path):
|
| 26 |
+
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 27 |
+
state = ckpt.get("model", ckpt)
|
| 28 |
+
model.load_state_dict(state)
|
| 29 |
+
print(f"[eval] loaded {ckpt_path}")
|
| 30 |
+
else:
|
| 31 |
+
print(f"[eval][warn] checkpoint not found: {ckpt_path} (evaluating current weights)")
|
| 32 |
+
|
| 33 |
+
model = model.to(device).eval()
|
| 34 |
+
use_amp = cfg.amp in ("bf16", "fp16")
|
| 35 |
+
amp_dtype = torch.bfloat16 if cfg.amp == "bf16" else torch.float16
|
| 36 |
+
|
| 37 |
+
records = []
|
| 38 |
+
vis_dir = os.path.join(cfg.out_dir(), "vis")
|
| 39 |
+
if cfg.visualize:
|
| 40 |
+
os.makedirs(vis_dir, exist_ok=True)
|
| 41 |
+
saved = 0
|
| 42 |
+
|
| 43 |
+
for batch in loader:
|
| 44 |
+
img = batch["image"].to(device, non_blocking=True)
|
| 45 |
+
msk = batch["mask"].numpy()
|
| 46 |
+
names = batch["name"]
|
| 47 |
+
with torch.autocast("cuda", dtype=amp_dtype, enabled=use_amp):
|
| 48 |
+
logits = model(img)
|
| 49 |
+
pred = logits.argmax(1).cpu().numpy()
|
| 50 |
+
for i in range(pred.shape[0]):
|
| 51 |
+
records.append(per_image_metrics(
|
| 52 |
+
pred[i], msk[i], num_classes,
|
| 53 |
+
include_background=cfg.include_background,
|
| 54 |
+
compute_hd95=cfg.compute_hd95))
|
| 55 |
+
if cfg.visualize and saved < cfg.vis_max:
|
| 56 |
+
save_overlay(img[i].cpu(), msk[i], pred[i], num_classes,
|
| 57 |
+
os.path.join(vis_dir, f"{names[i]}.png"))
|
| 58 |
+
saved += 1
|
| 59 |
+
|
| 60 |
+
agg = aggregate(records)
|
| 61 |
+
out = {
|
| 62 |
+
"dataset": cfg.dataset, "protocol": cfg.protocol, "arch": cfg.arch,
|
| 63 |
+
"seed": cfg.seed, "num_classes": num_classes,
|
| 64 |
+
"metrics": agg,
|
| 65 |
+
"per_image": records,
|
| 66 |
+
}
|
| 67 |
+
out_path = os.path.join(cfg.out_dir(), "metrics.json")
|
| 68 |
+
with open(out_path, "w") as f:
|
| 69 |
+
json.dump(out, f, indent=2)
|
| 70 |
+
print(f"[eval] dice={agg['dice_mean']:.4f}+-{agg['dice_std']:.4f} "
|
| 71 |
+
f"iou={agg['iou_mean']:.4f} hd95={agg['hd95_mean']:.3f} assd={agg['assd_mean']:.3f} "
|
| 72 |
+
f"sens={agg['sensitivity_mean']:.4f} spec={agg['specificity_mean']:.4f} "
|
| 73 |
+
f"prec={agg['precision_mean']:.4f} -> {out_path}")
|
| 74 |
+
return out
|
code/framework/engine/losses.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Loss builder. Everything is treated as MULTICLASS (binary == 2 classes),
|
| 2 |
+
which sidesteps the binary/multiclass mode pitfall and unifies all datasets.
|
| 3 |
+
|
| 4 |
+
ce_dice : CrossEntropy + multiclass Dice (default, robust for medical seg)
|
| 5 |
+
ce : CrossEntropy only
|
| 6 |
+
dice : multiclass Dice only
|
| 7 |
+
|
| 8 |
+
Inputs: logits [B,C,H,W], target [B,H,W] (long, ids 0..C-1).
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import torch.nn as nn
|
| 14 |
+
import segmentation_models_pytorch as smp
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class CEDiceLoss(nn.Module):
|
| 18 |
+
def __init__(self, mode: str = "ce_dice"):
|
| 19 |
+
super().__init__()
|
| 20 |
+
self.mode = mode
|
| 21 |
+
self.ce = nn.CrossEntropyLoss()
|
| 22 |
+
self.dice = smp.losses.DiceLoss(mode=smp.losses.MULTICLASS_MODE, from_logits=True)
|
| 23 |
+
|
| 24 |
+
def forward(self, logits: torch.Tensor, target: torch.Tensor) -> torch.Tensor:
|
| 25 |
+
if self.mode == "ce":
|
| 26 |
+
return self.ce(logits, target)
|
| 27 |
+
if self.mode == "dice":
|
| 28 |
+
return self.dice(logits, target)
|
| 29 |
+
return self.ce(logits, target) + self.dice(logits, target)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def build_loss(name: str = "ce_dice") -> nn.Module:
|
| 33 |
+
return CEDiceLoss(name)
|
code/framework/engine/trainer.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DDP + AMP training loop.
|
| 2 |
+
|
| 3 |
+
* DDP: launched via torchrun; uses DistributedSampler. Single-GPU also works.
|
| 4 |
+
* AMP: bf16 (A100+) / fp16 (V100, with GradScaler) / fp32.
|
| 5 |
+
* Best checkpoint chosen by mean foreground Dice on the val split.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import math
|
| 11 |
+
import json
|
| 12 |
+
import time
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
from torch.nn.parallel import DistributedDataParallel as DDP
|
| 18 |
+
|
| 19 |
+
from .distributed import (is_dist, is_main, get_rank, get_world_size,
|
| 20 |
+
all_gather_object, print_main)
|
| 21 |
+
from .losses import build_loss
|
| 22 |
+
from ..metrics.metrics import per_image_metrics
|
| 23 |
+
from ..data.loaders import build_dataset, build_loader
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
_AMP_DTYPE = {"bf16": torch.bfloat16, "fp16": torch.float16}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def build_optimizer(cfg, params):
|
| 30 |
+
if cfg.optimizer == "sgd":
|
| 31 |
+
return torch.optim.SGD(params, lr=cfg.lr, momentum=0.9,
|
| 32 |
+
weight_decay=cfg.weight_decay, nesterov=True)
|
| 33 |
+
return torch.optim.AdamW(params, lr=cfg.lr, weight_decay=cfg.weight_decay)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def lr_at(cfg, epoch: int) -> float:
|
| 37 |
+
if epoch < cfg.warmup_epochs:
|
| 38 |
+
return cfg.lr * (epoch + 1) / max(1, cfg.warmup_epochs)
|
| 39 |
+
e = epoch - cfg.warmup_epochs
|
| 40 |
+
total = max(1, cfg.epochs - cfg.warmup_epochs)
|
| 41 |
+
if cfg.scheduler == "poly":
|
| 42 |
+
return cfg.lr * (1 - e / total) ** 0.9
|
| 43 |
+
if cfg.scheduler == "cosine":
|
| 44 |
+
return cfg.lr * 0.5 * (1 + math.cos(math.pi * e / total))
|
| 45 |
+
return cfg.lr
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class Trainer:
|
| 49 |
+
def __init__(self, cfg, model: nn.Module, local_rank: int):
|
| 50 |
+
self.cfg = cfg
|
| 51 |
+
self.device = torch.device("cuda", local_rank) if torch.cuda.is_available() else torch.device("cpu")
|
| 52 |
+
self.local_rank = local_rank
|
| 53 |
+
|
| 54 |
+
self.train_ds = build_dataset(cfg, "train")
|
| 55 |
+
self.val_ds = build_dataset(cfg, "val")
|
| 56 |
+
self.num_classes = self.train_ds.num_classes
|
| 57 |
+
self.train_loader = build_loader(cfg, "train", self.train_ds)
|
| 58 |
+
self.val_loader = build_loader(cfg, "val", self.val_ds)
|
| 59 |
+
|
| 60 |
+
self.model = model.to(self.device)
|
| 61 |
+
if is_dist():
|
| 62 |
+
self.model = DDP(self.model, device_ids=[local_rank], output_device=local_rank,
|
| 63 |
+
find_unused_parameters=False)
|
| 64 |
+
|
| 65 |
+
self.criterion = build_loss(cfg.loss).to(self.device)
|
| 66 |
+
self.optimizer = build_optimizer(cfg, self.model.parameters())
|
| 67 |
+
self.amp = cfg.amp
|
| 68 |
+
self.use_amp = self.amp in _AMP_DTYPE
|
| 69 |
+
self.scaler = torch.amp.GradScaler("cuda", enabled=(self.amp == "fp16"))
|
| 70 |
+
self.best = -1.0
|
| 71 |
+
self.start_epoch = 0
|
| 72 |
+
self.out_dir = cfg.out_dir()
|
| 73 |
+
if is_main():
|
| 74 |
+
os.makedirs(self.out_dir, exist_ok=True)
|
| 75 |
+
cfg.to_yaml(os.path.join(self.out_dir, "config.yaml"))
|
| 76 |
+
if cfg.resume:
|
| 77 |
+
self._load(cfg.resume)
|
| 78 |
+
|
| 79 |
+
# ---- checkpoint ----
|
| 80 |
+
def _bare(self):
|
| 81 |
+
return self.model.module if is_dist() else self.model
|
| 82 |
+
|
| 83 |
+
def _save(self, name: str, epoch: int):
|
| 84 |
+
if not is_main():
|
| 85 |
+
return
|
| 86 |
+
torch.save({
|
| 87 |
+
"epoch": epoch,
|
| 88 |
+
"model": self._bare().state_dict(),
|
| 89 |
+
"optimizer": self.optimizer.state_dict(),
|
| 90 |
+
"best": self.best,
|
| 91 |
+
"num_classes": self.num_classes,
|
| 92 |
+
"config": self.cfg.__dict__,
|
| 93 |
+
}, os.path.join(self.out_dir, name))
|
| 94 |
+
|
| 95 |
+
def _load(self, path: str):
|
| 96 |
+
ckpt = torch.load(path, map_location="cpu", weights_only=False)
|
| 97 |
+
self._bare().load_state_dict(ckpt["model"])
|
| 98 |
+
if "optimizer" in ckpt:
|
| 99 |
+
self.optimizer.load_state_dict(ckpt["optimizer"])
|
| 100 |
+
self.best = ckpt.get("best", -1.0)
|
| 101 |
+
self.start_epoch = ckpt.get("epoch", -1) + 1
|
| 102 |
+
print_main(f"[resume] from {path} at epoch {self.start_epoch}")
|
| 103 |
+
|
| 104 |
+
# ---- loops ----
|
| 105 |
+
def _autocast(self):
|
| 106 |
+
if self.use_amp:
|
| 107 |
+
return torch.autocast("cuda", dtype=_AMP_DTYPE[self.amp])
|
| 108 |
+
return torch.autocast("cuda", enabled=False)
|
| 109 |
+
|
| 110 |
+
def train_one_epoch(self, epoch: int):
|
| 111 |
+
self.model.train()
|
| 112 |
+
if is_dist():
|
| 113 |
+
self.train_loader.sampler.set_epoch(epoch)
|
| 114 |
+
for g in self.optimizer.param_groups:
|
| 115 |
+
g["lr"] = lr_at(self.cfg, epoch)
|
| 116 |
+
|
| 117 |
+
running, n = 0.0, 0
|
| 118 |
+
t0 = time.time()
|
| 119 |
+
for it, batch in enumerate(self.train_loader):
|
| 120 |
+
img = batch["image"].to(self.device, non_blocking=True)
|
| 121 |
+
msk = batch["mask"].to(self.device, non_blocking=True)
|
| 122 |
+
self.optimizer.zero_grad(set_to_none=True)
|
| 123 |
+
with self._autocast():
|
| 124 |
+
logits = self.model(img)
|
| 125 |
+
loss = self.criterion(logits, msk)
|
| 126 |
+
if self.amp == "fp16":
|
| 127 |
+
self.scaler.scale(loss).backward()
|
| 128 |
+
if self.cfg.grad_clip > 0:
|
| 129 |
+
self.scaler.unscale_(self.optimizer)
|
| 130 |
+
nn.utils.clip_grad_norm_(self.model.parameters(), self.cfg.grad_clip)
|
| 131 |
+
self.scaler.step(self.optimizer)
|
| 132 |
+
self.scaler.update()
|
| 133 |
+
else:
|
| 134 |
+
loss.backward()
|
| 135 |
+
if self.cfg.grad_clip > 0:
|
| 136 |
+
nn.utils.clip_grad_norm_(self.model.parameters(), self.cfg.grad_clip)
|
| 137 |
+
self.optimizer.step()
|
| 138 |
+
running += loss.item() * img.size(0)
|
| 139 |
+
n += img.size(0)
|
| 140 |
+
if is_main():
|
| 141 |
+
print_main(f"[ep {epoch:03d}] loss={running/max(1,n):.4f} "
|
| 142 |
+
f"lr={self.optimizer.param_groups[0]['lr']:.2e} "
|
| 143 |
+
f"({time.time()-t0:.1f}s)")
|
| 144 |
+
|
| 145 |
+
@torch.no_grad()
|
| 146 |
+
def validate(self) -> float:
|
| 147 |
+
self.model.eval()
|
| 148 |
+
records = []
|
| 149 |
+
for batch in self.val_loader:
|
| 150 |
+
img = batch["image"].to(self.device, non_blocking=True)
|
| 151 |
+
msk = batch["mask"].numpy()
|
| 152 |
+
with self._autocast():
|
| 153 |
+
logits = self.model(img)
|
| 154 |
+
pred = logits.argmax(1).cpu().numpy()
|
| 155 |
+
for i in range(pred.shape[0]):
|
| 156 |
+
records.append(per_image_metrics(
|
| 157 |
+
pred[i], msk[i], self.num_classes,
|
| 158 |
+
include_background=self.cfg.include_background,
|
| 159 |
+
compute_hd95=False))
|
| 160 |
+
gathered = all_gather_object(records)
|
| 161 |
+
flat = [r for part in gathered for r in part]
|
| 162 |
+
dices = np.array([r["dice"] for r in flat], dtype=np.float64)
|
| 163 |
+
dices = dices[~np.isnan(dices)]
|
| 164 |
+
return float(dices.mean()) if dices.size else 0.0
|
| 165 |
+
|
| 166 |
+
def fit(self):
|
| 167 |
+
best_epoch = self.start_epoch - 1
|
| 168 |
+
for epoch in range(self.start_epoch, self.cfg.epochs):
|
| 169 |
+
self.train_one_epoch(epoch)
|
| 170 |
+
do_val = ((epoch + 1) % self.cfg.val_interval == 0) or (epoch + 1 == self.cfg.epochs)
|
| 171 |
+
if do_val:
|
| 172 |
+
dice = self.validate()
|
| 173 |
+
if dice > self.best:
|
| 174 |
+
self.best = dice
|
| 175 |
+
best_epoch = epoch
|
| 176 |
+
self._save("best.pth", epoch)
|
| 177 |
+
print_main(f"[ep {epoch:03d}] val_dice={dice:.4f} "
|
| 178 |
+
f"(best={self.best:.4f} @ep{best_epoch})")
|
| 179 |
+
# early stopping: stop if val Dice hasn't improved for `patience` epochs
|
| 180 |
+
if (self.cfg.patience > 0 and (epoch + 1) >= self.cfg.min_epochs
|
| 181 |
+
and (epoch - best_epoch) >= self.cfg.patience):
|
| 182 |
+
print_main(f"[early-stop] no val improvement for {epoch - best_epoch} epochs "
|
| 183 |
+
f"(patience={self.cfg.patience}); best={self.best:.4f} @ep{best_epoch}")
|
| 184 |
+
self._save("last.pth", epoch)
|
| 185 |
+
break
|
| 186 |
+
if self.cfg.save_interval and (epoch + 1) % self.cfg.save_interval == 0:
|
| 187 |
+
self._save(f"epoch{epoch+1}.pth", epoch)
|
| 188 |
+
self._save("last.pth", epoch)
|
| 189 |
+
print_main(f"[done] best val_dice={self.best:.4f} @ep{best_epoch} -> {self.out_dir}/best.pth")
|
code/framework/eval_at_res.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Re-score an existing framework checkpoint at a COMMON evaluation resolution R.
|
| 2 |
+
|
| 3 |
+
Needed for resolution-fair comparison: a model that takes a fixed input size
|
| 4 |
+
(SwinUNet=224, TransUNet=256) is run at its native input size, but its prediction
|
| 5 |
+
and the GROUND TRUTH (loaded at native, not the 256-degraded dataloader copy) are
|
| 6 |
+
both resized to R, and metrics are computed at R — matching how the conv methods
|
| 7 |
+
(trained at R) and nnU-Net/U-Mamba (re-scored with --eval_size R) are evaluated.
|
| 8 |
+
|
| 9 |
+
python framework/eval_at_res.py --data_root <root> --dataset fives --protocol official \
|
| 10 |
+
--arch swinunet --seed 0 --eval_size 768 --exp_name baselines
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
import json
|
| 17 |
+
import argparse
|
| 18 |
+
|
| 19 |
+
import numpy as np
|
| 20 |
+
import cv2
|
| 21 |
+
import torch
|
| 22 |
+
|
| 23 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 24 |
+
from framework.models.registry import build_model, required_img_size
|
| 25 |
+
from framework.metrics.metrics import per_image_metrics, aggregate
|
| 26 |
+
from framework.data.unified_dataset import UnifiedSegDataset
|
| 27 |
+
from framework.data.transforms import build_transform
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _to_R(arr, R):
|
| 31 |
+
return cv2.resize(arr.astype(np.uint8), (R, R), interpolation=cv2.INTER_NEAREST).astype(np.int64)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def main():
|
| 35 |
+
ap = argparse.ArgumentParser()
|
| 36 |
+
ap.add_argument("--data_root", required=True)
|
| 37 |
+
ap.add_argument("--dataset", required=True)
|
| 38 |
+
ap.add_argument("--protocol", required=True)
|
| 39 |
+
ap.add_argument("--arch", required=True)
|
| 40 |
+
ap.add_argument("--encoder", default="resnet50")
|
| 41 |
+
ap.add_argument("--seed", type=int, required=True)
|
| 42 |
+
ap.add_argument("--eval_size", type=int, required=True, help="common resolution R")
|
| 43 |
+
ap.add_argument("--exp_name", default="baselines")
|
| 44 |
+
ap.add_argument("--out_root", default="results")
|
| 45 |
+
ap.add_argument("--normalize", default="auto")
|
| 46 |
+
args = ap.parse_args()
|
| 47 |
+
|
| 48 |
+
R = args.eval_size
|
| 49 |
+
model_res = required_img_size(args.arch) or R # SwinUNet 224 / TransUNet 256 / conv -> R
|
| 50 |
+
|
| 51 |
+
ds = UnifiedSegDataset(args.data_root, args.dataset, args.protocol, "test", transform=None)
|
| 52 |
+
ds.transform = build_transform(model_res, ds.in_channels, train=False,
|
| 53 |
+
aug="none", normalize=args.normalize)
|
| 54 |
+
num_classes = ds.num_classes
|
| 55 |
+
|
| 56 |
+
out_dir = os.path.join(args.out_root, args.exp_name,
|
| 57 |
+
f"{args.dataset}_{args.protocol}", args.arch, f"seed{args.seed}")
|
| 58 |
+
ckpt_path = os.path.join(out_dir, "best.pth")
|
| 59 |
+
if not os.path.isfile(ckpt_path):
|
| 60 |
+
raise SystemExit(f"checkpoint not found: {ckpt_path}")
|
| 61 |
+
|
| 62 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 63 |
+
model = build_model(args.arch, in_channels=ds.in_channels, num_classes=num_classes,
|
| 64 |
+
img_size=model_res, encoder=args.encoder,
|
| 65 |
+
encoder_weights="none", pretrained_ckpt="")
|
| 66 |
+
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 67 |
+
model.load_state_dict(ckpt.get("model", ckpt))
|
| 68 |
+
model = model.to(device).eval()
|
| 69 |
+
|
| 70 |
+
records = []
|
| 71 |
+
with torch.no_grad():
|
| 72 |
+
for idx in range(len(ds)):
|
| 73 |
+
item = ds[idx]
|
| 74 |
+
img = item["image"].unsqueeze(0).to(device) # 1,C,model_res,model_res
|
| 75 |
+
pred = model(img).argmax(1)[0].cpu().numpy() # model_res x model_res
|
| 76 |
+
gt = cv2.imread(ds.pairs[idx][1], cv2.IMREAD_GRAYSCALE) # native H x W, values 0..C-1
|
| 77 |
+
records.append(per_image_metrics(_to_R(pred, R), _to_R(gt, R), num_classes,
|
| 78 |
+
include_background=False, compute_hd95=True))
|
| 79 |
+
|
| 80 |
+
agg = aggregate(records)
|
| 81 |
+
out = {"dataset": args.dataset, "protocol": args.protocol, "arch": args.arch,
|
| 82 |
+
"seed": args.seed, "num_classes": num_classes,
|
| 83 |
+
"eval_size": R, "metrics": agg, "per_image": records}
|
| 84 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 85 |
+
with open(os.path.join(out_dir, "metrics.json"), "w") as f:
|
| 86 |
+
json.dump(out, f, indent=2)
|
| 87 |
+
print(f"[eval_at_res] {args.dataset}/{args.protocol} {args.arch} seed{args.seed} @R={R}: "
|
| 88 |
+
f"n={len(records)} dice={agg['dice_mean']:.4f} hd95={agg['hd95_mean']:.2f} -> {out_dir}")
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
if __name__ == "__main__":
|
| 92 |
+
main()
|
code/framework/eval_ckpt.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluate a trained framework checkpoint on a chosen dataset/split, with the full
|
| 2 |
+
metric set INCLUDING boundary metrics. Two uses, one script (no retraining):
|
| 3 |
+
|
| 4 |
+
* C7 (boundary fidelity, in-domain): point --eval_dataset at the same dataset to
|
| 5 |
+
recompute boundary-Dice / NSD from a saved best.pth.
|
| 6 |
+
* C4 (cross-center): set --eval_dataset/--eval_protocol to a DIFFERENT but
|
| 7 |
+
label-compatible dataset (e.g. train busi? no — train cvc_clinicdb, eval
|
| 8 |
+
kvasir_seg; both binary polyp). num_classes/in_channels must match.
|
| 9 |
+
|
| 10 |
+
Run from project root (…/NPJ), env seggen:
|
| 11 |
+
CUDA_VISIBLE_DEVICES=5 python -m framework.eval_ckpt \
|
| 12 |
+
--ckpt results/baselines/cvc_clinicdb_official/unet/seed0/best.pth \
|
| 13 |
+
--arch unet --encoder resnet50 \
|
| 14 |
+
--data_root $DR --dataset cvc_clinicdb --protocol official \
|
| 15 |
+
--eval_dataset kvasir_seg --eval_protocol official \
|
| 16 |
+
--out_json results/crosscenter/cvc2kvasir_unet_seed0.json
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import argparse
|
| 21 |
+
import json
|
| 22 |
+
import os
|
| 23 |
+
import sys
|
| 24 |
+
|
| 25 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
import torch
|
| 29 |
+
from torch.utils.data import DataLoader
|
| 30 |
+
|
| 31 |
+
from framework.config import Config # noqa: F401 (kept for parity / future YAML use)
|
| 32 |
+
from framework.models.registry import build_model, required_img_size
|
| 33 |
+
from framework.data.unified_dataset import UnifiedSegDataset
|
| 34 |
+
from framework.data.transforms import build_transform
|
| 35 |
+
from framework.metrics.metrics import per_image_metrics
|
| 36 |
+
from framework.metrics.boundary import boundary_metrics
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_args():
|
| 40 |
+
p = argparse.ArgumentParser("Evaluate a checkpoint (in-domain or cross-center) + boundary metrics")
|
| 41 |
+
p.add_argument("--ckpt", required=True)
|
| 42 |
+
p.add_argument("--arch", default="unet")
|
| 43 |
+
p.add_argument("--encoder", default="resnet50")
|
| 44 |
+
p.add_argument("--data_root", required=True)
|
| 45 |
+
p.add_argument("--dataset", required=True, help="dataset the ckpt was TRAINED on (sets in_ch/num_classes)")
|
| 46 |
+
p.add_argument("--protocol", required=True)
|
| 47 |
+
p.add_argument("--eval_dataset", default="", help="dataset to evaluate ON (default = --dataset)")
|
| 48 |
+
p.add_argument("--eval_protocol", default="", help="default = --protocol")
|
| 49 |
+
p.add_argument("--split", default="test")
|
| 50 |
+
p.add_argument("--img_size", type=int, default=256)
|
| 51 |
+
p.add_argument("--normalize", default="auto")
|
| 52 |
+
p.add_argument("--tol", type=float, default=2.0)
|
| 53 |
+
p.add_argument("--batch_size", type=int, default=16)
|
| 54 |
+
p.add_argument("--num_workers", type=int, default=6)
|
| 55 |
+
p.add_argument("--out_json", default="")
|
| 56 |
+
return p.parse_args()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def main():
|
| 60 |
+
a = get_args()
|
| 61 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 62 |
+
img_size = required_img_size(a.arch) or a.img_size
|
| 63 |
+
eval_ds_name = a.eval_dataset or a.dataset
|
| 64 |
+
eval_proto = a.eval_protocol or a.protocol
|
| 65 |
+
|
| 66 |
+
# in_ch / num_classes are fixed by the TRAIN dataset (how the model was built)
|
| 67 |
+
train_probe = UnifiedSegDataset(a.data_root, a.dataset, a.protocol, "test", transform=None)
|
| 68 |
+
in_ch, n_cls = train_probe.in_channels, train_probe.num_classes
|
| 69 |
+
|
| 70 |
+
model = build_model(a.arch, in_channels=in_ch, num_classes=n_cls, img_size=img_size,
|
| 71 |
+
encoder=a.encoder, encoder_weights="none", pretrained_ckpt="")
|
| 72 |
+
ckpt = torch.load(a.ckpt, map_location="cpu", weights_only=False)
|
| 73 |
+
model.load_state_dict(ckpt["model"])
|
| 74 |
+
model.to(device).eval()
|
| 75 |
+
|
| 76 |
+
tf = build_transform(img_size, in_ch, train=False, aug="none", normalize=a.normalize)
|
| 77 |
+
ds = UnifiedSegDataset(a.data_root, eval_ds_name, eval_proto, a.split,
|
| 78 |
+
transform=tf, in_channels=in_ch, num_classes=n_cls)
|
| 79 |
+
if ds.num_classes != n_cls:
|
| 80 |
+
raise ValueError(f"label mismatch: train num_classes={n_cls} vs eval={ds.num_classes} "
|
| 81 |
+
f"({a.dataset}->{eval_ds_name}) — not label-compatible for cross-center.")
|
| 82 |
+
cross = (eval_ds_name != a.dataset) or (eval_proto != a.protocol)
|
| 83 |
+
print(f"[eval] ckpt={os.path.basename(a.ckpt)} train={a.dataset}/{a.protocol} "
|
| 84 |
+
f"eval={eval_ds_name}/{eval_proto}/{a.split} cross_center={cross} "
|
| 85 |
+
f"in_ch={in_ch} num_classes={n_cls} n={len(ds)}", flush=True)
|
| 86 |
+
|
| 87 |
+
loader = DataLoader(ds, batch_size=a.batch_size, shuffle=False, num_workers=a.num_workers)
|
| 88 |
+
recs = []
|
| 89 |
+
for batch in loader:
|
| 90 |
+
img = batch["image"].to(device, non_blocking=True)
|
| 91 |
+
msk = batch["mask"].numpy()
|
| 92 |
+
with torch.no_grad():
|
| 93 |
+
pred = model(img).argmax(1).cpu().numpy()
|
| 94 |
+
for i in range(pred.shape[0]):
|
| 95 |
+
m = per_image_metrics(pred[i], msk[i], n_cls,
|
| 96 |
+
include_background=False, compute_hd95=True)
|
| 97 |
+
b = boundary_metrics(pred[i], msk[i], n_cls, tol=a.tol)
|
| 98 |
+
m.update(b)
|
| 99 |
+
recs.append(m)
|
| 100 |
+
|
| 101 |
+
keys = [k for k, val in recs[0].items() if isinstance(val, (int, float))] # skip per_class dict
|
| 102 |
+
summary = {}
|
| 103 |
+
for k in keys:
|
| 104 |
+
v = np.array([r[k] for r in recs], dtype=np.float64)
|
| 105 |
+
v = v[~np.isnan(v)]
|
| 106 |
+
summary[k] = {"mean": round(float(v.mean()), 4) if v.size else None,
|
| 107 |
+
"std": round(float(v.std()), 4) if v.size else None, "n": int(v.size)}
|
| 108 |
+
out = {"ckpt": a.ckpt, "train": f"{a.dataset}/{a.protocol}",
|
| 109 |
+
"eval": f"{eval_ds_name}/{eval_proto}/{a.split}", "cross_center": cross,
|
| 110 |
+
"num_images": len(ds), "metrics": summary}
|
| 111 |
+
print(json.dumps(out, indent=2), flush=True)
|
| 112 |
+
if a.out_json:
|
| 113 |
+
os.makedirs(os.path.dirname(os.path.abspath(a.out_json)) or ".", exist_ok=True)
|
| 114 |
+
with open(a.out_json, "w") as f:
|
| 115 |
+
json.dump(out, f, indent=2)
|
| 116 |
+
print(f"[eval] wrote {a.out_json}", flush=True)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
if __name__ == "__main__":
|
| 120 |
+
main()
|
code/framework/metrics/__init__.py
ADDED
|
File without changes
|
code/framework/metrics/boundary.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Boundary-localized segmentation metrics (for the C7 boundary-fidelity analysis).
|
| 2 |
+
|
| 3 |
+
Two standard measures, computed per foreground class then averaged:
|
| 4 |
+
* Normalized Surface Dice (NSD) @ tol: fraction of pred/gt surface points within
|
| 5 |
+
`tol` pixels of the other surface (Nikolov et al.); the medical-standard
|
| 6 |
+
boundary metric.
|
| 7 |
+
* Boundary-Dice @ tol: Dice between the tol-dilated pred and gt boundaries.
|
| 8 |
+
|
| 9 |
+
Recomputable post-hoc from saved predictions/checkpoints, so adding it never
|
| 10 |
+
requires retraining (see framework/eval_ckpt.py).
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
from scipy.ndimage import binary_erosion, binary_dilation, distance_transform_edt
|
| 18 |
+
_HAVE_SCIPY = True
|
| 19 |
+
except Exception: # pragma: no cover
|
| 20 |
+
_HAVE_SCIPY = False
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _surface(bin_mask: np.ndarray) -> np.ndarray:
|
| 24 |
+
return bin_mask ^ binary_erosion(bin_mask)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _nsd_binary(pred: np.ndarray, gt: np.ndarray, tol: float) -> float:
|
| 28 |
+
sp, sg = _surface(pred), _surface(gt)
|
| 29 |
+
ssum = sp.sum() + sg.sum()
|
| 30 |
+
if sp.sum() == 0 and sg.sum() == 0:
|
| 31 |
+
return 1.0
|
| 32 |
+
if ssum == 0:
|
| 33 |
+
return 0.0
|
| 34 |
+
dt_to_gt = distance_transform_edt(~sg)
|
| 35 |
+
dt_to_pred = distance_transform_edt(~sp)
|
| 36 |
+
pred_close = (dt_to_gt[sp] <= tol).sum()
|
| 37 |
+
gt_close = (dt_to_pred[sg] <= tol).sum()
|
| 38 |
+
return float((pred_close + gt_close) / ssum)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _bdice_binary(pred: np.ndarray, gt: np.ndarray, tol: int) -> float:
|
| 42 |
+
sp, sg = _surface(pred), _surface(gt)
|
| 43 |
+
if sp.sum() == 0 and sg.sum() == 0:
|
| 44 |
+
return 1.0
|
| 45 |
+
spd = binary_dilation(sp, iterations=int(tol))
|
| 46 |
+
sgd = binary_dilation(sg, iterations=int(tol))
|
| 47 |
+
denom = spd.sum() + sgd.sum()
|
| 48 |
+
if denom == 0:
|
| 49 |
+
return 0.0
|
| 50 |
+
return float(2.0 * (spd & sgd).sum() / denom)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def boundary_metrics(pred: np.ndarray, gt: np.ndarray, num_classes: int,
|
| 54 |
+
tol: float = 2.0) -> dict:
|
| 55 |
+
"""Mean over foreground classes (1..num_classes-1). NaN if scipy missing."""
|
| 56 |
+
if not _HAVE_SCIPY:
|
| 57 |
+
return {"nsd": float("nan"), "boundary_dice": float("nan")}
|
| 58 |
+
nsds, bdices = [], []
|
| 59 |
+
for c in range(1, num_classes):
|
| 60 |
+
p, g = (pred == c), (gt == c)
|
| 61 |
+
if g.sum() == 0 and p.sum() == 0:
|
| 62 |
+
continue
|
| 63 |
+
nsds.append(_nsd_binary(p, g, tol))
|
| 64 |
+
bdices.append(_bdice_binary(p, g, int(round(tol))))
|
| 65 |
+
return {
|
| 66 |
+
"nsd": float(np.mean(nsds)) if nsds else float("nan"),
|
| 67 |
+
"boundary_dice": float(np.mean(bdices)) if bdices else float("nan"),
|
| 68 |
+
}
|
code/framework/metrics/metrics.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unified segmentation metrics.
|
| 2 |
+
|
| 3 |
+
Per-image (per-case), foreground-class macro-averaged unless noted:
|
| 4 |
+
* Dice (DSC) overlap (headline)
|
| 5 |
+
* IoU (Jaccard) overlap
|
| 6 |
+
* HD95 95th-percentile Hausdorff distance (boundary)
|
| 7 |
+
* ASSD average symmetric surface distance (boundary)
|
| 8 |
+
* Sensitivity / Recall TP/(TP+FN)
|
| 9 |
+
* Specificity TN/(TN+FP) (one-vs-rest, pixel-level)
|
| 10 |
+
* Precision TP/(TP+FP)
|
| 11 |
+
|
| 12 |
+
Convention: masks are integer maps 0..C-1 (0 = background); binary == 2 classes.
|
| 13 |
+
Per-class values are also recorded (per_class[c]) for per-class paper tables.
|
| 14 |
+
Surface metrics use MONAI if available, else medpy, else NaN.
|
| 15 |
+
Aggregation: per-image -> mean±SD over the test set; report/aggregate.py then
|
| 16 |
+
does mean±SD over seeds.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
from typing import Dict, List
|
| 21 |
+
import warnings
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
_SURF_BACKEND = None
|
| 26 |
+
_OVERLAP_KEYS = ("dice", "iou", "sensitivity", "specificity", "precision")
|
| 27 |
+
_SURFACE_KEYS = ("hd95", "assd")
|
| 28 |
+
SCALAR_KEYS = _OVERLAP_KEYS + _SURFACE_KEYS
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _select_surface_backend():
|
| 32 |
+
global _SURF_BACKEND
|
| 33 |
+
if _SURF_BACKEND is not None:
|
| 34 |
+
return _SURF_BACKEND
|
| 35 |
+
try:
|
| 36 |
+
from monai.metrics import compute_hausdorff_distance, compute_average_surface_distance # noqa
|
| 37 |
+
_SURF_BACKEND = "monai"
|
| 38 |
+
except Exception:
|
| 39 |
+
try:
|
| 40 |
+
from medpy.metric.binary import hd95, assd # noqa
|
| 41 |
+
_SURF_BACKEND = "medpy"
|
| 42 |
+
except Exception:
|
| 43 |
+
_SURF_BACKEND = "none"
|
| 44 |
+
warnings.warn("Neither MONAI nor medpy available -> HD95/ASSD will be NaN.")
|
| 45 |
+
return _SURF_BACKEND
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _surface_binary(pred: np.ndarray, gt: np.ndarray) -> Dict[str, float]:
|
| 49 |
+
"""HD95 and ASSD for one binary 2D mask pair. Handles empty masks."""
|
| 50 |
+
backend = _select_surface_backend()
|
| 51 |
+
p_any, g_any = pred.any(), gt.any()
|
| 52 |
+
if not p_any and not g_any:
|
| 53 |
+
return {"hd95": 0.0, "assd": 0.0}
|
| 54 |
+
if not p_any or not g_any:
|
| 55 |
+
return {"hd95": float("nan"), "assd": float("nan")}
|
| 56 |
+
if backend == "monai":
|
| 57 |
+
import torch
|
| 58 |
+
from monai.metrics import compute_hausdorff_distance, compute_average_surface_distance
|
| 59 |
+
p = torch.from_numpy(pred[None, None].astype(np.uint8))
|
| 60 |
+
g = torch.from_numpy(gt[None, None].astype(np.uint8))
|
| 61 |
+
hd = compute_hausdorff_distance(p, g, percentile=95).item()
|
| 62 |
+
asd = compute_average_surface_distance(p, g, symmetric=True).item()
|
| 63 |
+
return {"hd95": float(hd), "assd": float(asd)}
|
| 64 |
+
if backend == "medpy":
|
| 65 |
+
from medpy.metric.binary import hd95 as _hd95, assd as _assd
|
| 66 |
+
pb, gb = pred.astype(bool), gt.astype(bool)
|
| 67 |
+
return {"hd95": float(_hd95(pb, gb)), "assd": float(_assd(pb, gb))}
|
| 68 |
+
return {"hd95": float("nan"), "assd": float("nan")}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _nanmean(xs):
|
| 72 |
+
xs = [x for x in xs if not (isinstance(x, float) and np.isnan(x))]
|
| 73 |
+
return float(np.mean(xs)) if xs else float("nan")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def per_image_metrics(pred: np.ndarray, target: np.ndarray, num_classes: int,
|
| 77 |
+
include_background: bool = False,
|
| 78 |
+
compute_hd95: bool = True) -> Dict[str, object]:
|
| 79 |
+
"""Metrics for a single image (2D int class maps). Returns foreground-macro
|
| 80 |
+
scalars plus a per_class breakdown. Classes absent in BOTH pred and gt are
|
| 81 |
+
skipped so they don't dilute the average."""
|
| 82 |
+
start = 0 if include_background else 1
|
| 83 |
+
acc = {k: [] for k in SCALAR_KEYS}
|
| 84 |
+
per_class: Dict[str, Dict[str, float]] = {}
|
| 85 |
+
|
| 86 |
+
for c in range(start, num_classes):
|
| 87 |
+
p = pred == c
|
| 88 |
+
g = target == c
|
| 89 |
+
if not p.any() and not g.any():
|
| 90 |
+
continue
|
| 91 |
+
tp = float(np.logical_and(p, g).sum())
|
| 92 |
+
fp = float(np.logical_and(p, ~g).sum())
|
| 93 |
+
fn = float(np.logical_and(~p, g).sum())
|
| 94 |
+
tn = float(np.logical_and(~p, ~g).sum())
|
| 95 |
+
cls = {
|
| 96 |
+
"dice": (2 * tp) / (2 * tp + fp + fn + 1e-8),
|
| 97 |
+
"iou": tp / (tp + fp + fn + 1e-8),
|
| 98 |
+
"sensitivity": tp / (tp + fn + 1e-8),
|
| 99 |
+
"specificity": tn / (tn + fp + 1e-8),
|
| 100 |
+
"precision": tp / (tp + fp + 1e-8),
|
| 101 |
+
}
|
| 102 |
+
if compute_hd95:
|
| 103 |
+
cls.update(_surface_binary(p, g))
|
| 104 |
+
else:
|
| 105 |
+
cls.update({"hd95": float("nan"), "assd": float("nan")})
|
| 106 |
+
per_class[str(c)] = cls
|
| 107 |
+
for k in SCALAR_KEYS:
|
| 108 |
+
acc[k].append(cls[k])
|
| 109 |
+
|
| 110 |
+
out: Dict[str, object] = {}
|
| 111 |
+
for k in _OVERLAP_KEYS:
|
| 112 |
+
out[k] = float(np.mean(acc[k])) if acc[k] else float("nan")
|
| 113 |
+
for k in _SURFACE_KEYS:
|
| 114 |
+
out[k] = _nanmean(acc[k]) if acc[k] else float("nan")
|
| 115 |
+
out["per_class"] = per_class
|
| 116 |
+
return out
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def aggregate(records: List[Dict[str, object]]) -> Dict[str, float]:
|
| 120 |
+
"""Aggregate per-image metric dicts into mean/std over the set (per metric)."""
|
| 121 |
+
out: Dict[str, float] = {}
|
| 122 |
+
for key in SCALAR_KEYS:
|
| 123 |
+
vals = np.array([r[key] for r in records], dtype=np.float64)
|
| 124 |
+
vals = vals[~np.isnan(vals)]
|
| 125 |
+
out[f"{key}_mean"] = float(vals.mean()) if vals.size else float("nan")
|
| 126 |
+
out[f"{key}_std"] = float(vals.std()) if vals.size else float("nan")
|
| 127 |
+
out["n_images"] = float(len(records))
|
| 128 |
+
return out
|
code/framework/models/__init__.py
ADDED
|
File without changes
|
code/framework/models/attention_unet.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Attention U-Net (Oktay et al., 2018) — additive attention gates on skips.
|
| 2 |
+
|
| 3 |
+
Reimplemented cleanly inside the framework. The original sfczekalski/attention_unet
|
| 4 |
+
repo is unmaintained (2020), unlicensed, has DRIVE-specific hardcoded padding and a
|
| 5 |
+
commented-out training loop, so only the architecture idea is reused here. This is
|
| 6 |
+
the faithful additive gating attention (NOT SMP's scSE), configurable for arbitrary
|
| 7 |
+
in_channels / num_classes.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import torch
|
| 12 |
+
import torch.nn as nn
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class ConvBlock(nn.Module):
|
| 16 |
+
def __init__(self, cin, cout):
|
| 17 |
+
super().__init__()
|
| 18 |
+
self.block = nn.Sequential(
|
| 19 |
+
nn.Conv2d(cin, cout, 3, padding=1, bias=False),
|
| 20 |
+
nn.BatchNorm2d(cout), nn.ReLU(inplace=True),
|
| 21 |
+
nn.Conv2d(cout, cout, 3, padding=1, bias=False),
|
| 22 |
+
nn.BatchNorm2d(cout), nn.ReLU(inplace=True),
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
def forward(self, x):
|
| 26 |
+
return self.block(x)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class UpConv(nn.Module):
|
| 30 |
+
def __init__(self, cin, cout):
|
| 31 |
+
super().__init__()
|
| 32 |
+
self.up = nn.Sequential(
|
| 33 |
+
nn.Upsample(scale_factor=2, mode="bilinear", align_corners=True),
|
| 34 |
+
nn.Conv2d(cin, cout, 3, padding=1, bias=False),
|
| 35 |
+
nn.BatchNorm2d(cout), nn.ReLU(inplace=True),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
def forward(self, x):
|
| 39 |
+
return self.up(x)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class AttentionGate(nn.Module):
|
| 43 |
+
"""Additive attention gate: gating signal g (coarser) modulates skip x."""
|
| 44 |
+
def __init__(self, f_g, f_l, f_int):
|
| 45 |
+
super().__init__()
|
| 46 |
+
self.w_g = nn.Sequential(nn.Conv2d(f_g, f_int, 1, bias=True), nn.BatchNorm2d(f_int))
|
| 47 |
+
self.w_x = nn.Sequential(nn.Conv2d(f_l, f_int, 1, bias=True), nn.BatchNorm2d(f_int))
|
| 48 |
+
self.psi = nn.Sequential(nn.Conv2d(f_int, 1, 1, bias=True), nn.BatchNorm2d(1), nn.Sigmoid())
|
| 49 |
+
self.relu = nn.ReLU(inplace=True)
|
| 50 |
+
|
| 51 |
+
def forward(self, g, x):
|
| 52 |
+
a = self.relu(self.w_g(g) + self.w_x(x))
|
| 53 |
+
a = self.psi(a)
|
| 54 |
+
return x * a
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class AttentionUNet(nn.Module):
|
| 58 |
+
def __init__(self, in_channels: int = 3, num_classes: int = 2, base: int = 64):
|
| 59 |
+
super().__init__()
|
| 60 |
+
c = [base, base * 2, base * 4, base * 8, base * 16]
|
| 61 |
+
self.pool = nn.MaxPool2d(2, 2)
|
| 62 |
+
self.e1 = ConvBlock(in_channels, c[0])
|
| 63 |
+
self.e2 = ConvBlock(c[0], c[1])
|
| 64 |
+
self.e3 = ConvBlock(c[1], c[2])
|
| 65 |
+
self.e4 = ConvBlock(c[2], c[3])
|
| 66 |
+
self.e5 = ConvBlock(c[3], c[4])
|
| 67 |
+
|
| 68 |
+
self.u5 = UpConv(c[4], c[3]); self.a5 = AttentionGate(c[3], c[3], c[2]); self.d5 = ConvBlock(c[4], c[3])
|
| 69 |
+
self.u4 = UpConv(c[3], c[2]); self.a4 = AttentionGate(c[2], c[2], c[1]); self.d4 = ConvBlock(c[3], c[2])
|
| 70 |
+
self.u3 = UpConv(c[2], c[1]); self.a3 = AttentionGate(c[1], c[1], c[0]); self.d3 = ConvBlock(c[2], c[1])
|
| 71 |
+
self.u2 = UpConv(c[1], c[0]); self.a2 = AttentionGate(c[0], c[0], c[0] // 2); self.d2 = ConvBlock(c[1], c[0])
|
| 72 |
+
self.head = nn.Conv2d(c[0], num_classes, 1)
|
| 73 |
+
|
| 74 |
+
def forward(self, x):
|
| 75 |
+
x1 = self.e1(x)
|
| 76 |
+
x2 = self.e2(self.pool(x1))
|
| 77 |
+
x3 = self.e3(self.pool(x2))
|
| 78 |
+
x4 = self.e4(self.pool(x3))
|
| 79 |
+
x5 = self.e5(self.pool(x4))
|
| 80 |
+
|
| 81 |
+
d5 = self.u5(x5); d5 = self.d5(torch.cat([self.a5(d5, x4), d5], dim=1))
|
| 82 |
+
d4 = self.u4(d5); d4 = self.d4(torch.cat([self.a4(d4, x3), d4], dim=1))
|
| 83 |
+
d3 = self.u3(d4); d3 = self.d3(torch.cat([self.a3(d3, x2), d3], dim=1))
|
| 84 |
+
d2 = self.u2(d3); d2 = self.d2(torch.cat([self.a2(d2, x1), d2], dim=1))
|
| 85 |
+
return self.head(d2)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def build_attention_unet(in_channels: int, num_classes: int, **_):
|
| 89 |
+
return AttentionUNet(in_channels=in_channels, num_classes=num_classes)
|
code/framework/models/registry.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model registry: one entry point to build any in-framework segmenter.
|
| 2 |
+
|
| 3 |
+
build_model("unet", in_channels=3, num_classes=2, ...)
|
| 4 |
+
|
| 5 |
+
Architectures:
|
| 6 |
+
* SMP zoo: unet, unetpp, manet, linknet, fpn, pspnet, deeplabv3, deeplabv3plus, pan
|
| 7 |
+
* attention_unet : reimplemented Oktay attention U-Net
|
| 8 |
+
* transunet : sota/TransUNet model def (img_size divisible by 16; 224 canonical)
|
| 9 |
+
* swinunet : sota/Swin-Unet model def (img_size must be 224)
|
| 10 |
+
|
| 11 |
+
nnU-Net, U-Mamba (separate CLIs) and SAM-family (dropped) are intentionally NOT here.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import torch.nn as nn
|
| 16 |
+
|
| 17 |
+
from .smp_models import is_smp_arch, build_smp
|
| 18 |
+
from .attention_unet import build_attention_unet
|
| 19 |
+
from .transunet_wrap import build_transunet
|
| 20 |
+
from .swinunet_wrap import build_swinunet
|
| 21 |
+
|
| 22 |
+
_FIXED_INPUT = {"swinunet": 224} # archs that demand a specific img_size
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def required_img_size(arch: str):
|
| 26 |
+
return _FIXED_INPUT.get(arch.lower())
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def build_model(arch: str, in_channels: int, num_classes: int, img_size: int = 256,
|
| 30 |
+
encoder: str = "resnet34", encoder_weights: str = "imagenet",
|
| 31 |
+
pretrained_ckpt: str = "") -> nn.Module:
|
| 32 |
+
a = arch.lower()
|
| 33 |
+
if is_smp_arch(a):
|
| 34 |
+
return build_smp(a, in_channels, num_classes,
|
| 35 |
+
encoder=encoder, encoder_weights=encoder_weights)
|
| 36 |
+
if a == "attention_unet":
|
| 37 |
+
return build_attention_unet(in_channels, num_classes)
|
| 38 |
+
if a == "transunet":
|
| 39 |
+
return build_transunet(in_channels, num_classes, img_size=img_size,
|
| 40 |
+
encoder=encoder, pretrained_ckpt=pretrained_ckpt)
|
| 41 |
+
if a == "swinunet":
|
| 42 |
+
return build_swinunet(in_channels, num_classes, img_size=img_size,
|
| 43 |
+
pretrained_ckpt=pretrained_ckpt)
|
| 44 |
+
raise ValueError(f"unknown arch '{arch}'. "
|
| 45 |
+
f"SMP: unet/unetpp/manet/linknet/fpn/pspnet/deeplabv3/deeplabv3plus/pan; "
|
| 46 |
+
f"plus attention_unet/transunet/swinunet.")
|
code/framework/models/smp_models.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""segmentation_models.pytorch (SMP) backbones.
|
| 2 |
+
|
| 3 |
+
SMP gives us a large architecture x encoder zoo behind one constructor and is a
|
| 4 |
+
plain nn.Module (DDP- and bf16-friendly). Grayscale (in_channels=1) with ImageNet
|
| 5 |
+
encoder weights is handled by SMP's built-in channel adaptation.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import segmentation_models_pytorch as smp
|
| 10 |
+
|
| 11 |
+
# our arch name -> SMP architecture key
|
| 12 |
+
_SMP_ARCH = {
|
| 13 |
+
"unet": "Unet",
|
| 14 |
+
"unetpp": "UnetPlusPlus",
|
| 15 |
+
"unetplusplus": "UnetPlusPlus",
|
| 16 |
+
"manet": "MAnet",
|
| 17 |
+
"linknet": "Linknet",
|
| 18 |
+
"fpn": "FPN",
|
| 19 |
+
"pspnet": "PSPNet",
|
| 20 |
+
"deeplabv3": "DeepLabV3",
|
| 21 |
+
"deeplabv3plus": "DeepLabV3Plus",
|
| 22 |
+
"pan": "PAN",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def is_smp_arch(arch: str) -> bool:
|
| 27 |
+
return arch.lower() in _SMP_ARCH
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def build_smp(arch: str, in_channels: int, num_classes: int,
|
| 31 |
+
encoder: str = "resnet34", encoder_weights: str = "imagenet", **_):
|
| 32 |
+
weights = None if encoder_weights in ("", "none", None) else encoder_weights
|
| 33 |
+
return smp.create_model(
|
| 34 |
+
arch=_SMP_ARCH[arch.lower()],
|
| 35 |
+
encoder_name=encoder,
|
| 36 |
+
encoder_weights=weights,
|
| 37 |
+
in_channels=in_channels,
|
| 38 |
+
classes=num_classes,
|
| 39 |
+
)
|
code/framework/models/swinunet_wrap.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Swin-Unet wrapper.
|
| 2 |
+
|
| 3 |
+
Instantiates SwinTransformerSys from sota/Swin-Unet directly (bypassing the repo's
|
| 4 |
+
yacs config + .npz/.h5 Synapse pipeline). Handles grayscale by repeating 1->3.
|
| 5 |
+
|
| 6 |
+
Constraint: the windowed attention requires img_size = 224 (patches resolutions
|
| 7 |
+
56/28/14/7 are each divisible by window_size=7). Other sizes will assert; we keep
|
| 8 |
+
img_size=224 for this backbone.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn as nn
|
| 17 |
+
|
| 18 |
+
_REPO = os.path.join(os.path.dirname(__file__), "..", "..", "sota", "Swin-Unet")
|
| 19 |
+
_REPO = os.path.abspath(_REPO)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _ensure_path():
|
| 23 |
+
if _REPO not in sys.path:
|
| 24 |
+
sys.path.insert(0, _REPO)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class SwinUnetWrapper(nn.Module):
|
| 28 |
+
def __init__(self, in_channels: int, num_classes: int, img_size: int = 224,
|
| 29 |
+
pretrained_ckpt: str = ""):
|
| 30 |
+
super().__init__()
|
| 31 |
+
_ensure_path()
|
| 32 |
+
from networks.swin_transformer_unet_skip_expand_decoder_sys import SwinTransformerSys
|
| 33 |
+
if img_size != 224:
|
| 34 |
+
raise ValueError("Swin-Unet backbone requires img_size=224.")
|
| 35 |
+
self.net = SwinTransformerSys(
|
| 36 |
+
img_size=img_size, patch_size=4, in_chans=3, num_classes=num_classes,
|
| 37 |
+
embed_dim=96, depths=[2, 2, 2, 2], num_heads=[3, 6, 12, 24],
|
| 38 |
+
window_size=7, mlp_ratio=4.0, qkv_bias=True, drop_path_rate=0.1,
|
| 39 |
+
ape=False, patch_norm=True, use_checkpoint=False,
|
| 40 |
+
)
|
| 41 |
+
if pretrained_ckpt and os.path.isfile(pretrained_ckpt):
|
| 42 |
+
self._load_pretrained(pretrained_ckpt)
|
| 43 |
+
|
| 44 |
+
def _load_pretrained(self, path):
|
| 45 |
+
"""Port of Swin-Unet's load_from: load the ImageNet Swin-T encoder AND
|
| 46 |
+
mirror its `layers.X` weights into the decoder `layers_up.(3-X)` (the
|
| 47 |
+
scheme the paper uses to initialize the symmetric decoder)."""
|
| 48 |
+
import copy
|
| 49 |
+
ckpt = torch.load(path, map_location="cpu", weights_only=False)
|
| 50 |
+
if "model" not in ckpt:
|
| 51 |
+
self.net.load_state_dict(ckpt, strict=False)
|
| 52 |
+
return
|
| 53 |
+
pretrained = ckpt["model"]
|
| 54 |
+
model_dict = self.net.state_dict()
|
| 55 |
+
full = copy.deepcopy(pretrained)
|
| 56 |
+
for k, v in pretrained.items():
|
| 57 |
+
if "layers." in k:
|
| 58 |
+
n = 3 - int(k[7:8])
|
| 59 |
+
full["layers_up." + str(n) + k[8:]] = v
|
| 60 |
+
for k in list(full.keys()):
|
| 61 |
+
if k in model_dict and full[k].shape != model_dict[k].shape:
|
| 62 |
+
del full[k]
|
| 63 |
+
msg = self.net.load_state_dict(full, strict=False)
|
| 64 |
+
print(f"[swinunet] loaded pretrained {path}: "
|
| 65 |
+
f"missing={len(msg.missing_keys)} unexpected={len(msg.unexpected_keys)}")
|
| 66 |
+
|
| 67 |
+
def forward(self, x):
|
| 68 |
+
if x.size(1) == 1:
|
| 69 |
+
x = x.repeat(1, 3, 1, 1)
|
| 70 |
+
return self.net(x)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def build_swinunet(in_channels: int, num_classes: int, img_size: int = 224,
|
| 74 |
+
pretrained_ckpt: str = "", **_):
|
| 75 |
+
return SwinUnetWrapper(in_channels, num_classes, img_size, pretrained_ckpt)
|
code/framework/models/transunet_wrap.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""TransUNet wrapper.
|
| 2 |
+
|
| 3 |
+
Reuses ONLY the model definition from sota/TransUNet (networks/), not its
|
| 4 |
+
.npz/.h5 Synapse data pipeline. The model's forward already repeats 1->3 channels
|
| 5 |
+
for grayscale input, so it accepts our unified RGB or grayscale tensors.
|
| 6 |
+
|
| 7 |
+
Notes:
|
| 8 |
+
* img_size must be divisible by 16 (ViT patch grid). 224 is the canonical value.
|
| 9 |
+
* pretrained_ckpt should be the R50+ViT-B_16 .npz (ImageNet-21k); optional.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
_REPO = os.path.join(os.path.dirname(__file__), "..", "..", "sota", "TransUNet")
|
| 17 |
+
_REPO = os.path.abspath(_REPO)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _ensure_path():
|
| 21 |
+
if _REPO not in sys.path:
|
| 22 |
+
sys.path.insert(0, _REPO)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def build_transunet(in_channels: int, num_classes: int, img_size: int = 224,
|
| 26 |
+
encoder: str = "R50-ViT-B_16", pretrained_ckpt: str = "",
|
| 27 |
+
vit_patches_size: int = 16, **_):
|
| 28 |
+
_ensure_path()
|
| 29 |
+
import numpy as np
|
| 30 |
+
from networks.vit_seg_modeling import VisionTransformer, CONFIGS
|
| 31 |
+
|
| 32 |
+
vit_name = encoder if encoder in CONFIGS else "R50-ViT-B_16"
|
| 33 |
+
config_vit = CONFIGS[vit_name]
|
| 34 |
+
config_vit.n_classes = num_classes
|
| 35 |
+
config_vit.n_skip = 3
|
| 36 |
+
if "R50" in vit_name:
|
| 37 |
+
config_vit.patches.grid = (img_size // vit_patches_size,
|
| 38 |
+
img_size // vit_patches_size)
|
| 39 |
+
model = VisionTransformer(config_vit, img_size=img_size, num_classes=num_classes)
|
| 40 |
+
if pretrained_ckpt and os.path.isfile(pretrained_ckpt):
|
| 41 |
+
model.load_from(weights=np.load(pretrained_ckpt))
|
| 42 |
+
return model
|
code/framework/nnunet_convert.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Convert processed_unified/<dataset>/<protocol> -> nnU-Net v2 raw format.
|
| 2 |
+
|
| 3 |
+
Serves BOTH nnU-Net and U-Mamba (U-Mamba is nnU-Net v2 under the hood; same data
|
| 4 |
+
format, only a different trainer/env). Honors our FIXED train/val/test split:
|
| 5 |
+
* train + val images go into imagesTr/labelsTr (nnU-Net needs them together)
|
| 6 |
+
* the exact train/val partition is emitted as splits_final.json (fold 0)
|
| 7 |
+
* test goes into imagesTs/labelsTs (excluded from training; for our evaluation)
|
| 8 |
+
|
| 9 |
+
Key format rules (verified against sota/nnUNet):
|
| 10 |
+
* image file: <caseid>_0000.png (ONE file; RGB read as 3 channels, gray as 1)
|
| 11 |
+
* mask file : <caseid>.png (uint8, values 0..C-1, NEVER 0/255)
|
| 12 |
+
* channel_names: 3 entries (R,G,B) for RGB, 1 ("grayscale") for grayscale
|
| 13 |
+
* case ids are prefixed by split so train/val never collide inside imagesTr
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
python framework/nnunet_convert.py --data_root <processed_unified> \
|
| 17 |
+
--dataset cvc_clinicdb --protocol official --nnunet_raw <nnUNet_raw> --dataset_id 1
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
import json
|
| 23 |
+
import argparse
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import cv2
|
| 27 |
+
|
| 28 |
+
import sys
|
| 29 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 30 |
+
from framework.data.unified_dataset import (
|
| 31 |
+
_read_metadata, _pair_from_manifest, _pair_by_glob,
|
| 32 |
+
detect_in_channels, detect_num_classes,
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def get_pairs(data_root, dataset, protocol, split):
|
| 37 |
+
split_dir = os.path.join(data_root, dataset, protocol, split)
|
| 38 |
+
if not os.path.isdir(split_dir):
|
| 39 |
+
return []
|
| 40 |
+
manifest = os.path.join(data_root, dataset, "manifest.jsonl")
|
| 41 |
+
return _pair_from_manifest(split_dir, manifest) or _pair_by_glob(split_dir)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _link(src, dst):
|
| 45 |
+
if os.path.lexists(dst):
|
| 46 |
+
os.remove(dst)
|
| 47 |
+
os.symlink(os.path.abspath(src), dst)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _emit_image(src, dst_png, in_ch):
|
| 51 |
+
"""Place image as <...>_0000.png, ensuring a CONSISTENT channel count matching
|
| 52 |
+
in_ch (nnU-Net requires it). Grayscale datasets are re-encoded to true 1-channel
|
| 53 |
+
(some sources, e.g. kits19, store a mix of 1- and 3-channel PNGs); RGB .png are
|
| 54 |
+
symlinked (fast)."""
|
| 55 |
+
if os.path.lexists(dst_png):
|
| 56 |
+
os.remove(dst_png) # never write THROUGH an existing symlink (would corrupt source)
|
| 57 |
+
if in_ch == 1:
|
| 58 |
+
im = cv2.imread(src, cv2.IMREAD_GRAYSCALE)
|
| 59 |
+
if im is None:
|
| 60 |
+
raise IOError(f"cannot read image {src}")
|
| 61 |
+
cv2.imwrite(dst_png, im)
|
| 62 |
+
elif os.path.splitext(src)[1].lower() == ".png":
|
| 63 |
+
_link(src, dst_png)
|
| 64 |
+
else:
|
| 65 |
+
im = cv2.imread(src, cv2.IMREAD_COLOR)
|
| 66 |
+
cv2.imwrite(dst_png, cv2.cvtColor(im, cv2.COLOR_BGR2RGB))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _emit_mask(src, dst_png, num_classes):
|
| 70 |
+
"""Place mask as <...>.png with values 0..C-1 (remap 0/255 binary etc.)."""
|
| 71 |
+
m = cv2.imread(src, cv2.IMREAD_GRAYSCALE)
|
| 72 |
+
if m is None:
|
| 73 |
+
raise IOError(f"cannot read mask {src}")
|
| 74 |
+
uniq = set(int(v) for v in np.unique(m))
|
| 75 |
+
allowed = set(range(num_classes))
|
| 76 |
+
if uniq <= allowed and os.path.splitext(src)[1].lower() == ".png":
|
| 77 |
+
_link(src, dst_png)
|
| 78 |
+
return
|
| 79 |
+
if uniq <= {0, 255} and num_classes == 2:
|
| 80 |
+
m = (m > 0).astype(np.uint8)
|
| 81 |
+
elif len(uniq) <= num_classes:
|
| 82 |
+
remap = {v: i for i, v in enumerate(sorted(uniq))}
|
| 83 |
+
m = np.vectorize(remap.get)(m).astype(np.uint8)
|
| 84 |
+
else:
|
| 85 |
+
raise ValueError(f"mask {src} has values {sorted(uniq)} outside 0..{num_classes-1}")
|
| 86 |
+
cv2.imwrite(dst_png, m)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def main():
|
| 90 |
+
ap = argparse.ArgumentParser()
|
| 91 |
+
ap.add_argument("--data_root", required=True)
|
| 92 |
+
ap.add_argument("--dataset", required=True)
|
| 93 |
+
ap.add_argument("--protocol", required=True)
|
| 94 |
+
ap.add_argument("--nnunet_raw", required=True, help="output nnUNet_raw root")
|
| 95 |
+
ap.add_argument("--dataset_id", type=int, required=True)
|
| 96 |
+
ap.add_argument("--name", default="", help="override dataset name suffix")
|
| 97 |
+
args = ap.parse_args()
|
| 98 |
+
|
| 99 |
+
meta = _read_metadata(args.data_root, args.dataset)
|
| 100 |
+
tr = get_pairs(args.data_root, args.dataset, args.protocol, "train")
|
| 101 |
+
va = get_pairs(args.data_root, args.dataset, args.protocol, "val")
|
| 102 |
+
ts = get_pairs(args.data_root, args.dataset, args.protocol, "test")
|
| 103 |
+
if not tr:
|
| 104 |
+
raise SystemExit(f"no train pairs for {args.dataset}/{args.protocol}")
|
| 105 |
+
|
| 106 |
+
in_ch = detect_in_channels(meta, tr[0][0])
|
| 107 |
+
num_classes = detect_num_classes(meta, [p[1] for p in tr + va + ts], args.dataset)
|
| 108 |
+
|
| 109 |
+
name = args.name or f"{args.dataset}_{args.protocol}"
|
| 110 |
+
dsname = f"Dataset{args.dataset_id:03d}_{name}"
|
| 111 |
+
root = os.path.join(args.nnunet_raw, dsname)
|
| 112 |
+
for d in ("imagesTr", "labelsTr", "imagesTs", "labelsTs"):
|
| 113 |
+
os.makedirs(os.path.join(root, d), exist_ok=True)
|
| 114 |
+
|
| 115 |
+
def emit(pairs, split, img_dir, lab_dir):
|
| 116 |
+
ids = []
|
| 117 |
+
for ip, mp in pairs:
|
| 118 |
+
stem = os.path.splitext(os.path.basename(ip))[0]
|
| 119 |
+
cid = f"{split}_{stem}"
|
| 120 |
+
_emit_image(ip, os.path.join(root, img_dir, f"{cid}_0000.png"), in_ch)
|
| 121 |
+
_emit_mask(mp, os.path.join(root, lab_dir, f"{cid}.png"), num_classes)
|
| 122 |
+
ids.append(cid)
|
| 123 |
+
return ids
|
| 124 |
+
|
| 125 |
+
train_ids = emit(tr, "train", "imagesTr", "labelsTr")
|
| 126 |
+
val_ids = emit(va, "val", "imagesTr", "labelsTr") # val also in imagesTr
|
| 127 |
+
emit(ts, "test", "imagesTs", "labelsTs")
|
| 128 |
+
|
| 129 |
+
# dataset.json
|
| 130 |
+
if in_ch == 3:
|
| 131 |
+
channel_names = {"0": "R", "1": "G", "2": "B"}
|
| 132 |
+
else:
|
| 133 |
+
channel_names = {"0": "grayscale"}
|
| 134 |
+
labels = {"background": 0}
|
| 135 |
+
for c in range(1, num_classes):
|
| 136 |
+
labels[f"label{c}"] = c
|
| 137 |
+
dataset_json = {
|
| 138 |
+
"channel_names": channel_names,
|
| 139 |
+
"labels": labels,
|
| 140 |
+
"numTraining": len(train_ids) + len(val_ids),
|
| 141 |
+
"file_ending": ".png",
|
| 142 |
+
"name": dsname,
|
| 143 |
+
"description": f"converted from processed_unified/{args.dataset}/{args.protocol}",
|
| 144 |
+
}
|
| 145 |
+
with open(os.path.join(root, "dataset.json"), "w") as f:
|
| 146 |
+
json.dump(dataset_json, f, indent=2)
|
| 147 |
+
|
| 148 |
+
# staged splits_final.json (copy into nnUNet_preprocessed/<dsname>/ AFTER preprocessing).
|
| 149 |
+
# 3 IDENTICAL folds => train folds 0/1/2 = 3 runs of the SAME fixed split
|
| 150 |
+
# (run-to-run variance for mean±SD, matching the framework's 3-seed protocol).
|
| 151 |
+
splits = [{"train": train_ids, "val": val_ids} for _ in range(3)]
|
| 152 |
+
with open(os.path.join(root, "splits_final.json"), "w") as f:
|
| 153 |
+
json.dump(splits, f, indent=2)
|
| 154 |
+
|
| 155 |
+
print(f"[ok] {dsname}: in_ch={in_ch} num_classes={num_classes} "
|
| 156 |
+
f"train={len(train_ids)} val={len(val_ids)} test={len(ts)} -> {root}")
|
| 157 |
+
print("Next:")
|
| 158 |
+
print(f" export nnUNet_raw=$(dirname {root})")
|
| 159 |
+
print(" export nnUNet_preprocessed=<fast_dir> nnUNet_results=<dir>")
|
| 160 |
+
print(f" nnUNetv2_plan_and_preprocess -d {args.dataset_id} -c 2d --verify_dataset_integrity")
|
| 161 |
+
print(f" cp {root}/splits_final.json $nnUNet_preprocessed/{dsname}/splits_final.json")
|
| 162 |
+
print(f" nnUNetv2_train {args.dataset_id} 2d 0 # nnU-Net")
|
| 163 |
+
print(f" nnUNetv2_train {args.dataset_id} 2d 0 -tr nnUNetTrainerUMambaBot # U-Mamba")
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
if __name__ == "__main__":
|
| 167 |
+
main()
|
code/framework/nnunet_eval.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Compute framework-format metrics.json from nnU-Net / U-Mamba test predictions.
|
| 2 |
+
|
| 3 |
+
nnU-Net only reports validation Dice during training. To compare on the SAME
|
| 4 |
+
held-out test set with the SAME 7 metrics as the framework, we: predict on
|
| 5 |
+
imagesTs (done separately via nnUNetv2_predict), then run THIS script to score the
|
| 6 |
+
predicted masks against labelsTs using framework/metrics.py, writing a metrics.json
|
| 7 |
+
in the exact framework format so report/aggregate.py includes nnU-Net/U-Mamba rows.
|
| 8 |
+
|
| 9 |
+
python framework/nnunet_eval.py --data_root <processed_unified> --dataset <ds> \
|
| 10 |
+
--protocol <proto> --raw <nnUNet_raw> --dataset_id <ID> --fold <f> \
|
| 11 |
+
--pred_dir <predictions> --arch nnunet --exp_name baselines
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import sys
|
| 17 |
+
import json
|
| 18 |
+
import glob
|
| 19 |
+
import argparse
|
| 20 |
+
|
| 21 |
+
import numpy as np
|
| 22 |
+
import cv2
|
| 23 |
+
|
| 24 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 25 |
+
from framework.metrics.metrics import per_image_metrics, aggregate
|
| 26 |
+
from framework.data.unified_dataset import _read_metadata, detect_num_classes
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main():
|
| 30 |
+
ap = argparse.ArgumentParser()
|
| 31 |
+
ap.add_argument("--data_root", required=True)
|
| 32 |
+
ap.add_argument("--dataset", required=True)
|
| 33 |
+
ap.add_argument("--protocol", required=True)
|
| 34 |
+
ap.add_argument("--raw", required=True, help="nnUNet_raw root")
|
| 35 |
+
ap.add_argument("--dataset_id", type=int, required=True)
|
| 36 |
+
ap.add_argument("--name", default="", help="Dataset<DDD>_<name> suffix; default <dataset>_<protocol>")
|
| 37 |
+
ap.add_argument("--fold", type=int, required=True)
|
| 38 |
+
ap.add_argument("--pred_dir", required=True)
|
| 39 |
+
ap.add_argument("--arch", default="nnunet")
|
| 40 |
+
ap.add_argument("--exp_name", default="baselines")
|
| 41 |
+
ap.add_argument("--out_root", default="results")
|
| 42 |
+
ap.add_argument("--include_background", action="store_true")
|
| 43 |
+
ap.add_argument("--eval_size", type=int, default=0,
|
| 44 |
+
help="resize pred+gt to R×R (nearest) before scoring; 0 = native GT resolution")
|
| 45 |
+
args = ap.parse_args()
|
| 46 |
+
|
| 47 |
+
name = args.name or f"{args.dataset}_{args.protocol}"
|
| 48 |
+
dsname = f"Dataset{args.dataset_id:03d}_{name}"
|
| 49 |
+
lab_dir = os.path.join(args.raw, dsname, "labelsTs")
|
| 50 |
+
|
| 51 |
+
meta = _read_metadata(args.data_root, args.dataset)
|
| 52 |
+
gt_masks = sorted(glob.glob(os.path.join(lab_dir, "*.png")))
|
| 53 |
+
num_classes = detect_num_classes(meta, gt_masks, args.dataset)
|
| 54 |
+
|
| 55 |
+
records = []
|
| 56 |
+
n_missing = 0
|
| 57 |
+
for pp in sorted(glob.glob(os.path.join(args.pred_dir, "*.png"))):
|
| 58 |
+
base = os.path.basename(pp)
|
| 59 |
+
gp = os.path.join(lab_dir, base)
|
| 60 |
+
if not os.path.isfile(gp):
|
| 61 |
+
n_missing += 1
|
| 62 |
+
continue
|
| 63 |
+
pred = cv2.imread(pp, cv2.IMREAD_GRAYSCALE)
|
| 64 |
+
gt = cv2.imread(gp, cv2.IMREAD_GRAYSCALE)
|
| 65 |
+
if pred is None or gt is None:
|
| 66 |
+
n_missing += 1
|
| 67 |
+
continue
|
| 68 |
+
if pred.shape != gt.shape:
|
| 69 |
+
pred = cv2.resize(pred, (gt.shape[1], gt.shape[0]), interpolation=cv2.INTER_NEAREST)
|
| 70 |
+
if args.eval_size > 0: # resolution-fair common-R scoring
|
| 71 |
+
R = args.eval_size
|
| 72 |
+
pred = cv2.resize(pred, (R, R), interpolation=cv2.INTER_NEAREST)
|
| 73 |
+
gt = cv2.resize(gt, (R, R), interpolation=cv2.INTER_NEAREST)
|
| 74 |
+
records.append(per_image_metrics(pred.astype(np.int64), gt.astype(np.int64),
|
| 75 |
+
num_classes, include_background=args.include_background,
|
| 76 |
+
compute_hd95=True))
|
| 77 |
+
if not records:
|
| 78 |
+
raise SystemExit(f"no matched (pred,gt) pairs in {args.pred_dir} vs {lab_dir}")
|
| 79 |
+
|
| 80 |
+
agg = aggregate(records)
|
| 81 |
+
out = {"dataset": args.dataset, "protocol": args.protocol, "arch": args.arch,
|
| 82 |
+
"seed": args.fold, "num_classes": num_classes, "metrics": agg, "per_image": records}
|
| 83 |
+
out_dir = os.path.join(args.out_root, args.exp_name, f"{args.dataset}_{args.protocol}",
|
| 84 |
+
args.arch, f"seed{args.fold}")
|
| 85 |
+
os.makedirs(out_dir, exist_ok=True)
|
| 86 |
+
with open(os.path.join(out_dir, "metrics.json"), "w") as f:
|
| 87 |
+
json.dump(out, f, indent=2)
|
| 88 |
+
print(f"[nnunet_eval] {dsname} fold{args.fold}: n={len(records)} (missing {n_missing}) "
|
| 89 |
+
f"dice={agg['dice_mean']:.4f} iou={agg['iou_mean']:.4f} hd95={agg['hd95_mean']:.2f} "
|
| 90 |
+
f"-> {out_dir}/metrics.json")
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
if __name__ == "__main__":
|
| 94 |
+
main()
|
code/framework/report/__init__.py
ADDED
|
File without changes
|
code/framework/report/aggregate.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aggregate per-seed metrics.json files into mean +- SD tables (over seeds).
|
| 2 |
+
|
| 3 |
+
Scans results/<exp_name>/**/seed*/metrics.json, groups by (dataset, protocol,
|
| 4 |
+
arch), and reports mean +- SD across seeds for every metric. Emits CSV, Markdown,
|
| 5 |
+
LaTeX. Overlap/clf metrics shown as percentages; HD95/ASSD as raw distances.
|
| 6 |
+
|
| 7 |
+
python framework/report/aggregate.py --exp_name baselines [--out_root results]
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import json
|
| 13 |
+
import glob
|
| 14 |
+
import argparse
|
| 15 |
+
from collections import defaultdict
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
# (key, label, is_percent, higher_is_better)
|
| 20 |
+
METRICS = [
|
| 21 |
+
("dice", "Dice", True, True),
|
| 22 |
+
("iou", "IoU", True, True),
|
| 23 |
+
("hd95", "HD95", False, False),
|
| 24 |
+
("assd", "ASSD", False, False),
|
| 25 |
+
("sensitivity", "Sens", True, True),
|
| 26 |
+
("specificity", "Spec", True, True),
|
| 27 |
+
("precision", "Prec", True, True),
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_runs(out_root, exp_name):
|
| 32 |
+
runs = []
|
| 33 |
+
for path in glob.glob(os.path.join(out_root, exp_name, "**", "seed*", "metrics.json"), recursive=True):
|
| 34 |
+
try:
|
| 35 |
+
with open(path) as f:
|
| 36 |
+
runs.append(json.load(f))
|
| 37 |
+
except Exception:
|
| 38 |
+
pass
|
| 39 |
+
return runs
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
# Display labels for split protocols (replaces opaque "fold01" with the real
|
| 43 |
+
# reporting protocol). Datasets with an official split keep "official"/"holdout";
|
| 44 |
+
# fold-1-of-k single splits are labelled "single-split"; PanNuke is reported as
|
| 45 |
+
# its official k-fold cross-validation (handled in summarize via fold-merge).
|
| 46 |
+
_PROTO_LABEL = {
|
| 47 |
+
("idridd_segmentation", "fold01"): "official", # official 54/27; train re-split for val
|
| 48 |
+
("busi", "fold01"): "single-split",
|
| 49 |
+
("medsegdb_kits19", "fold01"): "single-split",
|
| 50 |
+
("pannuke_semantic", "fold01"): "single-split", # until the other folds are added
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
# Datasets reported as official k-fold cross-validation (mean±SD OVER folds, not
|
| 54 |
+
# over seeds): folds are merged into one row when >1 fold is present.
|
| 55 |
+
_CV_DATASETS = {"pannuke_semantic"}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _proto_label(dataset, protocol):
|
| 59 |
+
return _PROTO_LABEL.get((dataset, protocol), protocol)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _agg_over(items, key):
|
| 63 |
+
vals = np.array([it.get("metrics", {}).get(f"{key}_mean", np.nan) for it in items], np.float64)
|
| 64 |
+
vals = vals[~np.isnan(vals)]
|
| 65 |
+
return (float(vals.mean()), float(vals.std())) if vals.size else (float("nan"), float("nan"))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def summarize(runs):
|
| 69 |
+
# (dataset, arch) -> protocol -> [runs]
|
| 70 |
+
by_da = defaultdict(lambda: defaultdict(list))
|
| 71 |
+
for d in runs:
|
| 72 |
+
by_da[(d.get("dataset"), d.get("arch"))][d.get("protocol")].append(d)
|
| 73 |
+
rows = []
|
| 74 |
+
for (dataset, arch), proto_map in sorted(by_da.items()):
|
| 75 |
+
protos = sorted(p for p in proto_map if p is not None)
|
| 76 |
+
row = {"dataset": dataset, "arch": arch}
|
| 77 |
+
if dataset in _CV_DATASETS and len(protos) > 1:
|
| 78 |
+
# k-fold CV: collapse seeds within each fold, then mean±SD OVER folds
|
| 79 |
+
row["protocol"] = f"{len(protos)}-fold"
|
| 80 |
+
row["n_seeds"] = len(protos)
|
| 81 |
+
for key, _, _, _ in METRICS:
|
| 82 |
+
fold_means = [m for m in (_agg_over(proto_map[p], key)[0] for p in protos)
|
| 83 |
+
if not np.isnan(m)]
|
| 84 |
+
fm = np.array(fold_means, np.float64)
|
| 85 |
+
row[f"{key}_mean"] = float(fm.mean()) if fm.size else float("nan")
|
| 86 |
+
row[f"{key}_sd"] = float(fm.std()) if fm.size else float("nan")
|
| 87 |
+
else:
|
| 88 |
+
# single fixed split: mean±SD over seeds
|
| 89 |
+
proto = protos[0] if protos else None
|
| 90 |
+
items = proto_map.get(proto, [])
|
| 91 |
+
row["protocol"] = _proto_label(dataset, proto)
|
| 92 |
+
row["n_seeds"] = len(items)
|
| 93 |
+
for key, _, _, _ in METRICS:
|
| 94 |
+
row[f"{key}_mean"], row[f"{key}_sd"] = _agg_over(items, key)
|
| 95 |
+
rows.append(row)
|
| 96 |
+
return rows
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _fmt(row, key, pct):
|
| 100 |
+
m, s = row[f"{key}_mean"], row[f"{key}_sd"]
|
| 101 |
+
if np.isnan(m):
|
| 102 |
+
return "—"
|
| 103 |
+
return f"{m*100:.2f}±{s*100:.2f}" if pct else f"{m:.2f}±{s:.2f}"
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def to_markdown(rows):
|
| 107 |
+
cols = ["dataset", "protocol", "arch", "seeds"] + [lbl for _, lbl, _, _ in METRICS]
|
| 108 |
+
out = "| " + " | ".join(cols) + " |\n" + "|" + "---|" * len(cols) + "\n"
|
| 109 |
+
for r in rows:
|
| 110 |
+
cells = [r["dataset"], r["protocol"], r["arch"], str(r["n_seeds"])]
|
| 111 |
+
cells += [_fmt(r, k, p) for k, _, p, _ in METRICS]
|
| 112 |
+
out += "| " + " | ".join(cells) + " |\n"
|
| 113 |
+
return out
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def to_csv(rows):
|
| 117 |
+
cols = ["dataset", "protocol", "arch", "n_seeds"]
|
| 118 |
+
for k, _, _, _ in METRICS:
|
| 119 |
+
cols += [f"{k}_mean", f"{k}_sd"]
|
| 120 |
+
out = ",".join(cols) + "\n"
|
| 121 |
+
for r in rows:
|
| 122 |
+
out += ",".join(str(r[c]) for c in cols) + "\n"
|
| 123 |
+
return out
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def to_latex(rows):
|
| 127 |
+
spec = "lll c" + "c" * len(METRICS)
|
| 128 |
+
out = "\\begin{tabular}{" + spec + "}\n\\toprule\n"
|
| 129 |
+
out += "Dataset & Protocol & Method & Seeds & " + " & ".join(lbl for _, lbl, _, _ in METRICS) + " \\\\\n\\midrule\n"
|
| 130 |
+
for r in rows:
|
| 131 |
+
cells = [str(r["dataset"]), str(r["protocol"]), str(r["arch"]), str(r["n_seeds"])]
|
| 132 |
+
cells += [_fmt(r, k, p).replace("±", "$\\pm$") for k, _, p, _ in METRICS]
|
| 133 |
+
out += " & ".join(cells) + " \\\\\n"
|
| 134 |
+
out += "\\bottomrule\n\\end{tabular}\n"
|
| 135 |
+
return out
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
_ARCH_ORDER = ["unet", "unetpp", "deeplabv3plus", "attention_unet", "transunet", "swinunet",
|
| 139 |
+
"nnunet", "umamba"]
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# (#, dataset, modality, target, classes, channels, image size, reported protocol, train/val/test)
|
| 143 |
+
_DATASETS_INFO = [
|
| 144 |
+
("1", "CVC-ClinicDB", "Colonoscopy (endoscopy)", "Polyp", "2", "RGB", "384×288", "official", "490 / 61 / 61"),
|
| 145 |
+
("2", "Kvasir-SEG", "GI endoscopy", "Polyp", "2", "RGB", "~622×529 (var)", "official", "800 / 100 / 100"),
|
| 146 |
+
("3", "FIVES", "Retinal fundus", "Vessel", "2", "RGB", "2048×2048", "official", "480 / 120 / 200"),
|
| 147 |
+
("4", "BUSI", "Breast ultrasound", "Tumor", "2", "grayscale¹", "variable", "single-split²", "545 / 78 / 157"),
|
| 148 |
+
("5", "REFUGE2", "Retinal fundus", "Optic disc & cup", "3", "RGB", "~2124×2056", "official", "400 / 400 / 400"),
|
| 149 |
+
("6", "ACDC", "Cardiac MRI (2D slices)", "RV / Myo / LV", "4", "grayscale", "~240×256 (var)", "official", "136 / 210 / 380"),
|
| 150 |
+
("7", "IDRiD", "Retinal fundus", "DR lesions (4) + optic disc", "6", "RGB", "4288×2848", "official", "43 / 11 / 27"),
|
| 151 |
+
("8", "PanNuke", "Histopathology (H&E)", "Nuclei (5 types)", "6", "RGB", "256×256", "official 3-fold CV", "~2.7k / 2.6k / 2.6k per fold"),
|
| 152 |
+
("9", "ISIC2018", "Dermoscopy", "Skin lesion", "2", "RGB", "256×256", "holdout", "2582 / 369 / 737"),
|
| 153 |
+
("10", "KiTS19", "Kidney CT (2D slices)", "Kidney (binary)", "2", "grayscale¹", "256×256", "single-split²", "2832 / 479 / 705"),
|
| 154 |
+
]
|
| 155 |
+
|
| 156 |
+
_METHODS_INFO = [
|
| 157 |
+
("UNet", "CNN encoder–decoder", "SMP, ResNet-50 encoder (ImageNet-pretrained)"),
|
| 158 |
+
("UNet++", "Nested UNet", "SMP, ResNet-50 (ImageNet)"),
|
| 159 |
+
("DeepLabV3+", "Atrous CNN", "SMP, ResNet-50 (ImageNet)"),
|
| 160 |
+
("Attention-UNet", "Attention-gated UNet", "Re-implemented, trained from scratch"),
|
| 161 |
+
("TransUNet", "CNN–Transformer hybrid", "R50-ViT-B/16 (ImageNet-pretrained)"),
|
| 162 |
+
("Swin-UNet", "Pure-Transformer UNet", "Swin-Tiny (ImageNet-pretrained)"),
|
| 163 |
+
("nnU-Net (v2)", "Self-configuring CNN", "2D config, 250 epochs"),
|
| 164 |
+
("U-Mamba", "State-space (Mamba) UNet", "U-Mamba_Bot, 100 epochs"),
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
# (metric, formula/definition, direction, unit, chinese role)
|
| 168 |
+
_METRICS_INFO = [
|
| 169 |
+
("Dice (DSC)", "2·|P∩G| / (|P|+|G|) = 2TP / (2TP+FP+FN)", "↑ higher", "%",
|
| 170 |
+
"区域重叠度(分割主指标);对类别不平衡较鲁棒,综合反映分割整体准确度。"),
|
| 171 |
+
("IoU (Jaccard)", "|P∩G| / |P∪G| = TP / (TP+FP+FN)", "↑ higher", "%",
|
| 172 |
+
"交并比,同样衡量区域重叠但更严格(对错误惩罚更重),常与 Dice 并列报告。"),
|
| 173 |
+
("Sensitivity / Recall", "TP / (TP+FN)", "↑ higher", "%",
|
| 174 |
+
"召回率/敏感度:真值前景中被正确分出的比例,反映“漏检/漏分割”程度(越高漏得越少)。"),
|
| 175 |
+
("Specificity", "TN / (TN+FP) (pixel-level, one-vs-rest)", "↑ higher", "%",
|
| 176 |
+
"特异度:真值背景中被正确判为背景的比例,反映对背景区域的误报控制能力。"),
|
| 177 |
+
("Precision", "TP / (TP+FP)", "↑ higher", "%",
|
| 178 |
+
"精确率:预测为前景的像素中真正属于前景的比例,反映“过分割/误报”程度(越高误分越少)。"),
|
| 179 |
+
("HD95", "95th-percentile Hausdorff distance between predicted & GT boundaries", "↓ lower", "pixels",
|
| 180 |
+
"边界距离:预测与真值轮廓间偏差的 95% 分位 Hausdorff 距离,衡量最大边界误差(取95%分位以抑制离群点),越小边界越贴合。"),
|
| 181 |
+
("ASSD", "Average symmetric surface distance between boundaries", "↓ lower", "pixels",
|
| 182 |
+
"平均对称表面距离:两条轮廓间双向平均距离,衡量整体边界吻合度,越小边界越准。"),
|
| 183 |
+
]
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _intro_html():
|
| 187 |
+
h = ["<div class='ov'>"]
|
| 188 |
+
h.append("<h2>Project overview</h2>")
|
| 189 |
+
h.append("<p>A unified benchmark of <b>8 2D medical-image segmentation methods</b> across "
|
| 190 |
+
"<b>10 public datasets</b> spanning <b>7 imaging modalities</b> (endoscopy, retinal fundus, "
|
| 191 |
+
"ultrasound, cardiac MRI, dermoscopy, histopathology, abdominal CT). Every method is trained "
|
| 192 |
+
"and evaluated through one pipeline (bf16 AMP on A100, identical metrics). Reported as "
|
| 193 |
+
"<b>mean±SD</b> over <b>3 seeds</b> for fixed-split datasets and over <b>folds</b> for "
|
| 194 |
+
"cross-validation datasets (PanNuke: official 3-fold). Each (dataset,method) cell aggregates "
|
| 195 |
+
"~tens–thousands of test images; the suite totals ≈20k images. Per-method efficiency "
|
| 196 |
+
"(params / FLOPs / throughput) is in <code>efficiency.md</code>.</p>")
|
| 197 |
+
|
| 198 |
+
h.append("<h2>Datasets</h2>")
|
| 199 |
+
h.append("<table><tr><th>#</th><th>Dataset</th><th>Modality</th><th>Target</th><th>Classes</th>"
|
| 200 |
+
"<th>Channels</th><th>Image size</th><th>Reported protocol</th><th>Train / Val / Test</th></tr>")
|
| 201 |
+
for r in _DATASETS_INFO:
|
| 202 |
+
h.append("<tr><td>%s</td><td class='l'>%s</td><td class='l'>%s</td><td class='l'>%s</td>"
|
| 203 |
+
"<td>%s</td><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>" % r)
|
| 204 |
+
h.append("</table>")
|
| 205 |
+
h.append("<div class='cap'>¹ BUSI/KiTS19 are grayscale in content but stored as 3-channel PNG — read as "
|
| 206 |
+
"grayscale. ² BUSI/KiTS19 have no canonical split, so one fixed fold (of a 5-fold partition) is "
|
| 207 |
+
"reported with 3 seeds; datasets with an official split use it. Mask labels are 0…C-1 "
|
| 208 |
+
"(0=background); multi-class metrics are macro-averaged over foreground classes.</div>")
|
| 209 |
+
|
| 210 |
+
h.append("<h2>Segmentation methods</h2>")
|
| 211 |
+
h.append("<table><tr><th>Method</th><th>Family</th><th>Backbone / setup</th></tr>")
|
| 212 |
+
for m in _METHODS_INFO:
|
| 213 |
+
h.append("<tr><td class='l'>%s</td><td class='l'>%s</td><td class='l'>%s</td></tr>" % m)
|
| 214 |
+
h.append("</table>")
|
| 215 |
+
|
| 216 |
+
h.append("<h2>Evaluation metrics</h2>")
|
| 217 |
+
h.append("<table><tr><th>Metric</th><th>Definition</th><th>Direction</th><th>Unit</th>"
|
| 218 |
+
"<th>作用 / 含义(中文)</th></tr>")
|
| 219 |
+
for m in _METRICS_INFO:
|
| 220 |
+
h.append("<tr><td class='l'>%s</td><td class='l'>%s</td><td>%s</td><td>%s</td>"
|
| 221 |
+
"<td class='l'>%s</td></tr>" % m)
|
| 222 |
+
h.append("</table>")
|
| 223 |
+
h.append("<p style='font-size:12.5px'>P = predicted foreground, G = ground-truth foreground; "
|
| 224 |
+
"TP/FP/FN/TN are pixel counts. Overlap metrics (Dice/IoU/Sens/Spec/Prec) are exact pixel "
|
| 225 |
+
"computations; boundary metrics (HD95/ASSD) are computed with MONAI on the mask edges.</p>")
|
| 226 |
+
|
| 227 |
+
h.append("<h2>Evaluation protocol</h2>")
|
| 228 |
+
h.append("<p>For each test image, every metric is computed <b>per foreground class</b> (background "
|
| 229 |
+
"excluded) and macro-averaged over the classes present; classes absent in <i>both</i> "
|
| 230 |
+
"prediction and ground truth are skipped so they do not dilute the score. Per-image values are "
|
| 231 |
+
"averaged to one score per (dataset, method, run); we then report <b>mean±SD across runs</b> — "
|
| 232 |
+
"3 random seeds for fixed-split datasets, or across folds for CV datasets. For surface metrics, "
|
| 233 |
+
"an empty–empty (no foreground in either) pair scores 0, while empty-vs-non-empty is undefined "
|
| 234 |
+
"(NaN) and excluded from the average. All methods share the same held-out test images per "
|
| 235 |
+
"dataset, so comparisons are paired.</p>")
|
| 236 |
+
h.append("<hr style='margin:18px 0;border:none;border-top:1px solid #ddd'>")
|
| 237 |
+
h.append("</div>")
|
| 238 |
+
return "\n".join(h)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def to_html(rows, title="SegGen baselines"):
|
| 242 |
+
cell, dslist = {}, []
|
| 243 |
+
for r in rows:
|
| 244 |
+
ds = f"{r['dataset']}/{r['protocol']}"
|
| 245 |
+
if ds not in dslist:
|
| 246 |
+
dslist.append(ds)
|
| 247 |
+
cell[(ds, r["arch"])] = r
|
| 248 |
+
archs = [a for a in _ARCH_ORDER if any(r["arch"] == a for r in rows)] or \
|
| 249 |
+
sorted({r["arch"] for r in rows})
|
| 250 |
+
h = ["<!doctype html><html><head><meta charset='utf-8'><title>%s</title><style>" % title,
|
| 251 |
+
"body{font-family:Arial,Helvetica,sans-serif;margin:24px;color:#222}",
|
| 252 |
+
"h1{font-size:20px}h2{margin-top:28px;font-size:16px;color:#0a5}",
|
| 253 |
+
"table{border-collapse:collapse;margin:6px 0 18px}",
|
| 254 |
+
"th,td{border:1px solid #ccc;padding:5px 9px;text-align:center;font-size:12.5px}",
|
| 255 |
+
"th{background:#f3f3f3}td.ds{text-align:left;font-weight:bold;background:#fafafa}",
|
| 256 |
+
"td.l{text-align:left}.ov{font-size:12.5px;line-height:1.5;max-width:1100px}",
|
| 257 |
+
"b{color:#0a6}.cap{color:#777;font-size:12px;margin:2px 0 14px}</style></head><body>"]
|
| 258 |
+
nseeds = {f"{r['dataset']}/{r['protocol']}": r["n_seeds"] for r in rows}
|
| 259 |
+
h.append(f"<h1>{title} — mean±SD</h1>")
|
| 260 |
+
h.append("<div class='cap'>Higher is better for Dice/IoU/Sens/Spec/Prec (%); "
|
| 261 |
+
"lower for HD95/ASSD. <b>Bold</b>=best (highest/lowest mean) per dataset. "
|
| 262 |
+
"Variance is over seeds for single-split datasets and over folds for k-fold (CV) datasets. "
|
| 263 |
+
"'seeds' column = #seeds (single-split) or #folds (CV).</div>")
|
| 264 |
+
h.append(_intro_html())
|
| 265 |
+
h.append("<h1 style='font-size:18px'>Results</h1>")
|
| 266 |
+
for key, label, pct, hib in METRICS:
|
| 267 |
+
h.append(f"<h2>{label}{' (%)' if pct else ' (lower=better)'}</h2>")
|
| 268 |
+
h.append("<table><tr><th>Dataset</th>" + "".join(f"<th>{a}</th>" for a in archs)
|
| 269 |
+
+ "<th>seeds</th></tr>")
|
| 270 |
+
for ds in dslist:
|
| 271 |
+
vals = {a: cell[(ds, a)][f"{key}_mean"] for a in archs
|
| 272 |
+
if (ds, a) in cell and cell[(ds, a)][f"{key}_mean"] == cell[(ds, a)][f"{key}_mean"]}
|
| 273 |
+
best = (max if hib else min)(vals, key=vals.get) if vals else None
|
| 274 |
+
bold = {best} if best is not None else set()
|
| 275 |
+
tds = [f"<td class='ds'>{ds}</td>"]
|
| 276 |
+
for a in archs:
|
| 277 |
+
if (ds, a) in cell:
|
| 278 |
+
txt = _fmt(cell[(ds, a)], key, pct)
|
| 279 |
+
tds.append(f"<td>{'<b>'+txt+'</b>' if a in bold else txt}</td>")
|
| 280 |
+
else:
|
| 281 |
+
tds.append("<td>—</td>")
|
| 282 |
+
tds.append(f"<td>{nseeds.get(ds,'')}</td>")
|
| 283 |
+
h.append("<tr>" + "".join(tds) + "</tr>")
|
| 284 |
+
h.append("</table>")
|
| 285 |
+
h.append("</body></html>")
|
| 286 |
+
return "\n".join(h)
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def main():
|
| 290 |
+
p = argparse.ArgumentParser()
|
| 291 |
+
p.add_argument("--exp_name", required=True)
|
| 292 |
+
p.add_argument("--out_root", default="results")
|
| 293 |
+
args = p.parse_args()
|
| 294 |
+
|
| 295 |
+
runs = load_runs(args.out_root, args.exp_name)
|
| 296 |
+
if not runs:
|
| 297 |
+
print(f"no metrics.json under {args.out_root}/{args.exp_name}")
|
| 298 |
+
return
|
| 299 |
+
rows = summarize(runs)
|
| 300 |
+
base = os.path.join(args.out_root, args.exp_name)
|
| 301 |
+
open(os.path.join(base, "summary.csv"), "w").write(to_csv(rows))
|
| 302 |
+
open(os.path.join(base, "summary.md"), "w").write(to_markdown(rows))
|
| 303 |
+
open(os.path.join(base, "summary.tex"), "w").write(to_latex(rows))
|
| 304 |
+
open(os.path.join(base, "summary.html"), "w").write(to_html(rows, title=f"SegGen baselines ({args.exp_name})"))
|
| 305 |
+
print(to_markdown(rows))
|
| 306 |
+
print(f"{len(runs)} runs -> {len(rows)} (dataset,arch) cells; written {base}/summary.{{csv,md,tex,html}}")
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
if __name__ == "__main__":
|
| 310 |
+
main()
|
code/framework/test.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation entrypoint (single process).
|
| 2 |
+
|
| 3 |
+
python framework/test.py --dataset cvc_clinicdb --arch unet --exp_name myrun --seed 0
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 11 |
+
|
| 12 |
+
import torch
|
| 13 |
+
import cv2
|
| 14 |
+
|
| 15 |
+
# Single-threaded OpenCV per process (parallelism via num_workers); avoids the
|
| 16 |
+
# nproc-sized cv2 thread-pool oversubscription that starves the GPU at high res.
|
| 17 |
+
cv2.setNumThreads(1)
|
| 18 |
+
|
| 19 |
+
from framework.config import Config
|
| 20 |
+
from framework.models.registry import build_model, required_img_size
|
| 21 |
+
from framework.engine.evaluator import evaluate
|
| 22 |
+
from framework.data.loaders import build_dataset
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main():
|
| 26 |
+
cfg = Config.from_args()
|
| 27 |
+
req = required_img_size(cfg.arch)
|
| 28 |
+
if req and cfg.img_size != req:
|
| 29 |
+
cfg.img_size = req
|
| 30 |
+
|
| 31 |
+
if torch.cuda.is_available():
|
| 32 |
+
torch.cuda.set_device(0)
|
| 33 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 34 |
+
|
| 35 |
+
probe = build_dataset(cfg, "test")
|
| 36 |
+
model = build_model(cfg.arch, in_channels=probe.in_channels, num_classes=probe.num_classes,
|
| 37 |
+
img_size=cfg.img_size, encoder=cfg.encoder,
|
| 38 |
+
encoder_weights="none", # weights come from checkpoint
|
| 39 |
+
pretrained_ckpt="")
|
| 40 |
+
evaluate(cfg, model, device, ckpt_path=cfg.resume)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
if __name__ == "__main__":
|
| 44 |
+
main()
|
code/framework/train.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training entrypoint.
|
| 2 |
+
|
| 3 |
+
Single GPU: python framework/train.py --dataset cvc_clinicdb --arch unet ...
|
| 4 |
+
Multi-GPU : torchrun --nproc_per_node=4 framework/train.py --dataset ... --arch ...
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
|
| 11 |
+
# allow `python framework/train.py` (add repo root to path)
|
| 12 |
+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
import cv2
|
| 16 |
+
|
| 17 |
+
# Each DataLoader worker single-threaded for OpenCV; parallelism comes from num_workers.
|
| 18 |
+
# Without this, cv2 spawns an nproc-sized (~384) thread pool per worker, whose per-op
|
| 19 |
+
# dispatch overhead starves the GPU at high resolution (768) -> ~4x slower epochs.
|
| 20 |
+
cv2.setNumThreads(1)
|
| 21 |
+
|
| 22 |
+
from framework.config import Config
|
| 23 |
+
from framework.engine.distributed import setup_distributed, cleanup_distributed, set_seed, print_main
|
| 24 |
+
from framework.models.registry import build_model, required_img_size
|
| 25 |
+
from framework.engine.trainer import Trainer
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def main():
|
| 29 |
+
cfg = Config.from_args()
|
| 30 |
+
|
| 31 |
+
# some backbones require a fixed input size
|
| 32 |
+
req = required_img_size(cfg.arch)
|
| 33 |
+
if req and cfg.img_size != req:
|
| 34 |
+
print_main(f"[info] arch '{cfg.arch}' requires img_size={req}; overriding {cfg.img_size}.")
|
| 35 |
+
cfg.img_size = req
|
| 36 |
+
|
| 37 |
+
local_rank = setup_distributed()
|
| 38 |
+
set_seed(cfg.seed, rank=local_rank)
|
| 39 |
+
|
| 40 |
+
# peek dataset to get in/out channels before building the model
|
| 41 |
+
from framework.data.loaders import build_dataset
|
| 42 |
+
probe = build_dataset(cfg, "train")
|
| 43 |
+
in_ch, n_cls = probe.in_channels, probe.num_classes
|
| 44 |
+
print_main(f"[data] {cfg.dataset}/{cfg.protocol}: in_channels={in_ch} num_classes={n_cls} "
|
| 45 |
+
f"train={len(probe)}")
|
| 46 |
+
|
| 47 |
+
model = build_model(cfg.arch, in_channels=in_ch, num_classes=n_cls,
|
| 48 |
+
img_size=cfg.img_size, encoder=cfg.encoder,
|
| 49 |
+
encoder_weights=cfg.encoder_weights,
|
| 50 |
+
pretrained_ckpt=cfg.pretrained_ckpt)
|
| 51 |
+
print_main(f"[model] {cfg.arch} params={sum(p.numel() for p in model.parameters())/1e6:.1f}M "
|
| 52 |
+
f"amp={cfg.amp}")
|
| 53 |
+
|
| 54 |
+
trainer = Trainer(cfg, model, local_rank)
|
| 55 |
+
trainer.fit()
|
| 56 |
+
cleanup_distributed()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == "__main__":
|
| 60 |
+
main()
|
code/framework/visualize/__init__.py
ADDED
|
File without changes
|
code/framework/visualize/overlay.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Save a side-by-side overlay: input | ground-truth | prediction.
|
| 2 |
+
|
| 3 |
+
Used at test time to qualitatively inspect each method's output. Denormalizes the
|
| 4 |
+
input tensor back to a viewable image and color-codes class masks.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
import cv2
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406])
|
| 13 |
+
_IMAGENET_STD = np.array([0.229, 0.224, 0.225])
|
| 14 |
+
|
| 15 |
+
# distinct colors for up to 6 classes (0 = background -> transparent/black)
|
| 16 |
+
_PALETTE = np.array([
|
| 17 |
+
[0, 0, 0], [255, 0, 0], [0, 255, 0], [0, 0, 255],
|
| 18 |
+
[255, 255, 0], [255, 0, 255], [0, 255, 255],
|
| 19 |
+
], dtype=np.uint8)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _denorm(img: torch.Tensor) -> np.ndarray:
|
| 23 |
+
x = img.float().numpy() # C,H,W
|
| 24 |
+
c = x.shape[0]
|
| 25 |
+
x = np.transpose(x, (1, 2, 0)) # H,W,C
|
| 26 |
+
if c == 3:
|
| 27 |
+
x = x * _IMAGENET_STD + _IMAGENET_MEAN
|
| 28 |
+
else:
|
| 29 |
+
x = x * 0.5 + 0.5
|
| 30 |
+
x = np.repeat(x, 3, axis=2) if x.shape[2] == 1 else x
|
| 31 |
+
x = np.clip(x * 255.0, 0, 255).astype(np.uint8)
|
| 32 |
+
return x
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _colorize(mask: np.ndarray, num_classes: int) -> np.ndarray:
|
| 36 |
+
h, w = mask.shape
|
| 37 |
+
out = np.zeros((h, w, 3), dtype=np.uint8)
|
| 38 |
+
for c in range(1, num_classes):
|
| 39 |
+
out[mask == c] = _PALETTE[c % len(_PALETTE)]
|
| 40 |
+
return out
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def save_overlay(img: torch.Tensor, gt: np.ndarray, pred: np.ndarray,
|
| 44 |
+
num_classes: int, path: str, alpha: float = 0.5) -> None:
|
| 45 |
+
base = _denorm(img)
|
| 46 |
+
h, w = gt.shape
|
| 47 |
+
base = cv2.resize(base, (w, h), interpolation=cv2.INTER_LINEAR)
|
| 48 |
+
gt_c = _colorize(gt, num_classes)
|
| 49 |
+
pr_c = _colorize(pred, num_classes)
|
| 50 |
+
gt_o = cv2.addWeighted(base, 1 - alpha, gt_c, alpha, 0)
|
| 51 |
+
pr_o = cv2.addWeighted(base, 1 - alpha, pr_c, alpha, 0)
|
| 52 |
+
panel = np.concatenate([base, gt_o, pr_o], axis=1)
|
| 53 |
+
cv2.imwrite(path, cv2.cvtColor(panel, cv2.COLOR_RGB2BGR))
|