MaybeRichard commited on
Commit
b8fae22
·
verified ·
1 Parent(s): 7d32c4e

Upload folder using huggingface_hub

Browse files
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))