Views
No views yet
| Input | Shared Encoder | Output |
|---|---|---|
| FLAIR (1ch) | 3D ResNet + CBAM + SE + ASPP | → Lesion Decoder (Attention-Gated) → Binary MS lesion mask |
| Input/Patch Size | Params | Accuracy | ROC-AUC | F1 Score | F1 Score (Median) | F1 Score (Lesion) | Recall | Precision | IoU | LTPR | F2 Score | HD95 (mean) | HD95 (median) | MCC | Specificity | FPR | FNR | Volumetric Similarity |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 96³ > 128³ (grid) (FLAIR) | 30.73M | %100 | %94.40 | %63.55 | %65.13 | %79.54 | %74.57 | %57.65 | %47.55 | %75.16 | %69.13 | 16.7309mm (128³ grid) | 12.5607mm (128³ grid) | %64.78 | %100 | %0.01< | %25.43 | %83.65 |
1"""
2==============================================================================
3Vbai-2.6MSS - Multiple Sclerosis Lesion Segmentation (standalone)
4==============================================================================
5Single-file, shareable inference + visualization script.
6
7Give a path to a brain FLAIR file -> the model marks the MS lesions ->
8the marked slices are displayed (red overlay) and saved as a PNG.
9
10Architecture (embedded below):
11 - Input : FLAIR (1 channel)
12 - Encoder: custom 3D ResNet (32->64->128->256, bottleneck 320)
13 with Squeeze-and-Excitation + CBAM + ASPP
14 - Decoder: single UNet-style decoder with attention gates
15 - Deep supervision: 3 auxiliary heads (training only)
16 - Inference: sliding window (96^3 patches on a 128^3 canvas, 50% overlap,
17 Gaussian blending)
18 - Single task, FLAIR-only, ~30.7M params
19
20Usage:
21 python Vbai-2.6MSS.py --flair "/path/to/patient_FLAIR.nii.gz"
22 optional: --ckpt <weights.pt> --out <result.png> --grid 128 --n-slices 6
23
24Requirements: torch, numpy, nibabel, scipy, matplotlib
25==============================================================================
26"""
27import os
28import argparse
29import numpy as np
30import torch
31import torch.nn as nn
32import torch.nn.functional as F
33import nibabel as nib
34from scipy.ndimage import zoom, label as cc_label
35
36# Detect Colab for inline display
37try:
38 import google.colab # noqa: F401
39 _IN_COLAB = True
40except Exception:
41 _IN_COLAB = False
42import matplotlib
43if not _IN_COLAB:
44 matplotlib.use("Agg")
45import matplotlib.pyplot as plt
46
47# ---- Inference settings (this is the 96^3-patch / 128^3-grid variant) -------
48GRID = (128, 128, 128) # canvas the brain is resampled to
49PATCH = (96, 96, 96) # model input patch (must be divisible by 16)
50OVERLAP = 0.5 # sliding-window overlap ratio
51THRESHOLD = 0.5 # probability threshold for the binary mask
52MIN_CC = 10 # drop predicted components smaller than this (noise)
53
54
55# ============================================================================
56# MODEL ARCHITECTURE (Vbai-2.6MSS)
57# NOTE: submodule attribute names must stay identical to the trained model
58# so the checkpoint state_dict loads correctly.
59# ============================================================================
60class SEBlock3D(nn.Module):
61 """Squeeze-and-Excitation: channel-wise recalibration."""
62 def __init__(self, ch, r=16):
63 super().__init__()
64 mid = max(ch // r, 4)
65 self.pool = nn.AdaptiveAvgPool3d(1)
66 self.fc = nn.Sequential(nn.Linear(ch, mid), nn.ReLU(True),
67 nn.Linear(mid, ch), nn.Sigmoid())
68
69 def forward(self, x):
70 b, c = x.shape[:2]
71 return x * self.fc(self.pool(x).view(b, c)).view(b, c, 1, 1, 1)
72
73
74class CBAM3D(nn.Module):
75 """Convolutional Block Attention Module (channel + spatial)."""
76 def __init__(self, ch, r=16, ks=7):
77 super().__init__()
78 mid = max(ch // r, 4)
79 self.avg = nn.AdaptiveAvgPool3d(1)
80 self.mx = nn.AdaptiveMaxPool3d(1)
81 self.ch_fc = nn.Sequential(nn.Linear(ch, mid), nn.ReLU(True), nn.Linear(mid, ch))
82 self.sp = nn.Sequential(nn.Conv3d(2, 1, ks, padding=ks // 2, bias=False), nn.BatchNorm3d(1))
83
84 def forward(self, x):
85 b, c = x.shape[:2]
86 ch = torch.sigmoid(self.ch_fc(self.avg(x).view(b, c)) +
87 self.ch_fc(self.mx(x).view(b, c))).view(b, c, 1, 1, 1)
88 x = x * ch
89 sp = torch.sigmoid(self.sp(torch.cat([x.mean(1, True), x.max(1, True).values], 1)))
90 return x * sp
91
92
93class ResBlock3D(nn.Module):
94 """Residual block with SE + CBAM attention."""
95 def __init__(self, ic, oc, stride=1, drop=0.1, se=True, cbam=True):
96 super().__init__()
97 self.conv = nn.Sequential(
98 nn.Conv3d(ic, oc, 3, stride, 1, bias=False), nn.BatchNorm3d(oc), nn.ReLU(True),
99 nn.Dropout3d(drop),
100 nn.Conv3d(oc, oc, 3, 1, 1, bias=False), nn.BatchNorm3d(oc))
101 self.skip = (nn.Sequential(nn.Conv3d(ic, oc, 1, stride, bias=False), nn.BatchNorm3d(oc))
102 if ic != oc or stride != 1 else nn.Identity())
103 self.se = SEBlock3D(oc) if se else nn.Identity()
104 self.cbam = CBAM3D(oc) if cbam else nn.Identity()
105 self.act = nn.ReLU(True)
106
107 def forward(self, x):
108 return self.act(self.cbam(self.se(self.conv(x))) + self.skip(x))
109
110
111class ASPP3D(nn.Module):
112 """Atrous Spatial Pyramid Pooling: multi-scale context."""
113 def __init__(self, ic, oc, dils=(1, 3, 6)):
114 super().__init__()
115 mid = oc // (len(dils) + 2)
116 self.branches = nn.ModuleList([
117 nn.Sequential(nn.Conv3d(ic, mid, 3, padding=d, dilation=d, bias=False),
118 nn.BatchNorm3d(mid), nn.ReLU(True)) for d in dils])
119 self.gp = nn.Sequential(nn.AdaptiveAvgPool3d(1),
120 nn.Conv3d(ic, mid, 1, bias=False), nn.ReLU(True))
121 self.pw = nn.Sequential(nn.Conv3d(ic, mid, 1, bias=False),
122 nn.BatchNorm3d(mid), nn.ReLU(True))
123 tot = mid * (len(dils) + 2)
124 self.proj = nn.Sequential(nn.Conv3d(tot, oc, 1, bias=False),
125 nn.BatchNorm3d(oc), nn.ReLU(True), nn.Dropout3d(0.1))
126
127 def forward(self, x):
128 sz = x.shape[2:]
129 fs = [b(x) for b in self.branches]
130 fs.append(F.interpolate(self.gp(x), sz, mode="trilinear", align_corners=False))
131 fs.append(self.pw(x))
132 return self.proj(torch.cat(fs, 1))
133
134
135class AttGate3D(nn.Module):
136 """Attention gate: filters skip features using the decoder gating signal."""
137 def __init__(self, fc, gc):
138 super().__init__()
139 ic = fc // 2
140 self.Wf = nn.Sequential(nn.Conv3d(fc, ic, 1, bias=False), nn.BatchNorm3d(ic))
141 self.Wg = nn.Sequential(nn.Conv3d(gc, ic, 1, bias=False), nn.BatchNorm3d(ic))
142 self.ps = nn.Sequential(nn.Conv3d(ic, 1, 1, bias=False), nn.BatchNorm3d(1), nn.Sigmoid())
143 self.r = nn.ReLU(True)
144
145 def forward(self, feat, gate):
146 if gate.shape[2:] != feat.shape[2:]:
147 gate = F.interpolate(gate, feat.shape[2:], mode="trilinear", align_corners=False)
148 return feat * self.ps(self.r(self.Wf(feat) + self.Wg(gate)))
149
150
151class EncBlock(nn.Module):
152 """Two residual blocks + strided downsample. Returns (skip, downsampled)."""
153 def __init__(self, ic, oc, drop=0.1):
154 super().__init__()
155 self.blk = nn.Sequential(ResBlock3D(ic, oc, drop=drop), ResBlock3D(oc, oc, drop=drop))
156 self.down = nn.Sequential(nn.Conv3d(oc, oc, 3, stride=2, padding=1, bias=False),
157 nn.BatchNorm3d(oc), nn.ReLU(True))
158
159 def forward(self, x):
160 s = self.blk(x)
161 return s, self.down(s)
162
163
164class DecBlock(nn.Module):
165 """Upsample + attention-gated skip fusion + two residual blocks."""
166 def __init__(self, ic, sc, oc, drop=0.1, ag=True):
167 super().__init__()
168 self.ag = AttGate3D(sc, ic) if ag else None
169 self.blk = nn.Sequential(ResBlock3D(ic + sc, oc, drop=drop), ResBlock3D(oc, oc, drop=drop))
170
171 def forward(self, x, skip):
172 x = F.interpolate(x, skip.shape[2:], mode="trilinear", align_corners=False)
173 if self.ag:
174 skip = self.ag(skip, x)
175 return self.blk(torch.cat([x, skip], 1))
176
177
178class Vbai26MSS(nn.Module):
179 """
180 Vbai-2.6MSS - single-task 3D UNet for MS lesion segmentation.
181 Input: FLAIR (in_ch=1) -> output: 1-channel lesion logit.
182 """
183 def __init__(self, in_ch=1, bc=32, mults=(1, 2, 4, 8, 10), drop=0.1, ds=True):
184 super().__init__()
185 ch = [bc * m for m in mults]
186 self.ds = ds
187 self.stem = nn.Sequential(nn.Conv3d(in_ch, ch[0], 3, 1, 1, bias=False),
188 nn.BatchNorm3d(ch[0]), nn.ReLU(True))
189 self.e0 = EncBlock(ch[0], ch[0], drop=drop)
190 self.e1 = EncBlock(ch[0], ch[1], drop=drop)
191 self.e2 = EncBlock(ch[1], ch[2], drop=drop)
192 self.e3 = EncBlock(ch[2], ch[3], drop=drop)
193 self.bn = nn.Sequential(ResBlock3D(ch[3], ch[4], drop=drop), ASPP3D(ch[4], ch[4]))
194 self.d0 = DecBlock(ch[4], ch[3], ch[3], drop=drop)
195 self.d1 = DecBlock(ch[3], ch[2], ch[2], drop=drop)
196 self.d2 = DecBlock(ch[2], ch[1], ch[1], drop=drop)
197 self.d3 = DecBlock(ch[1], ch[0], ch[0], drop=drop)
198 self.head = nn.Conv3d(ch[0], 1, 1)
199 if ds:
200 self.ds0 = nn.Conv3d(ch[3], 1, 1)
201 self.ds1 = nn.Conv3d(ch[2], 1, 1)
202 self.ds2 = nn.Conv3d(ch[1], 1, 1)
203
204 def forward(self, x, return_aux=False):
205 s = self.stem(x)
206 k0, d0 = self.e0(s)
207 k1, d1 = self.e1(d0)
208 k2, d2 = self.e2(d1)
209 k3, d3 = self.e3(d2)
210 bn = self.bn(d3)
211 u3 = self.d0(bn, k3)
212 u2 = self.d1(u3, k2)
213 u1 = self.d2(u2, k1)
214 u0 = self.d3(u1, k0)
215 return self.head(u0) # inference only: no aux heads needed
216
217
218# ============================================================================
219# PREPROCESS + SLIDING-WINDOW INFERENCE
220# ============================================================================
221def load_nii(path):
222 """Load a NIfTI volume as float32 (handles a .nii path that is actually a folder)."""
223 if os.path.isdir(path):
224 inner = [f for f in os.listdir(path) if f.lower().endswith((".nii", ".nii.gz"))]
225 path = os.path.join(path, inner[0])
226 v = np.asarray(nib.load(path).dataobj, dtype=np.float32)
227 return np.nan_to_num(v, nan=0., posinf=0., neginf=0.)
228
229
230def znorm(v):
231 """Z-score normalization over the foreground (v > 0)."""
232 m = v > 0
233 if not m.any():
234 return v.astype(np.float32)
235 out = np.zeros_like(v, dtype=np.float32)
236 out[m] = (v[m] - v[m].mean()) / max(v[m].std(), 1e-6)
237 return out
238
239
240def resamp(v, target, order=1):
241 return zoom(v, [t / c for t, c in zip(target, v.shape)], order=order).astype(np.float32)
242
243
244@torch.no_grad()
245def sliding_window_prob(model, x, patch=PATCH, overlap=OVERLAP):
246 """Run the model over overlapping patches and blend with a Gaussian window."""
247 _, _, D, H, W = x.shape
248 pd, ph, pw = patch
249 sd = max(1, int(pd * (1 - overlap)))
250 sh = max(1, int(ph * (1 - overlap)))
251 sw = max(1, int(pw * (1 - overlap)))
252
253 def starts(dim, p, s):
254 if dim <= p:
255 return [0]
256 st = list(range(0, dim - p + 1, s))
257 if st[-1] != dim - p:
258 st.append(dim - p)
259 return st
260
261 def gauss1d(n):
262 c = (n - 1) / 2.0
263 s = n * 0.125 + 1e-6
264 return np.exp(-0.5 * ((np.arange(n) - c) / s) ** 2)
265
266 win = torch.tensor((gauss1d(pd)[:, None, None] * gauss1d(ph)[None, :, None] *
267 gauss1d(pw)[None, None, :]).astype(np.float32),
268 device=x.device).clamp_min(1e-4)
269
270 acc = torch.zeros((D, H, W), device=x.device)
271 cnt = torch.zeros((D, H, W), device=x.device)
272 for z0 in starts(D, pd, sd):
273 for y0 in starts(H, ph, sh):
274 for x0 in starts(W, pw, sw):
275 patch_in = x[:, :, z0:z0 + pd, y0:y0 + ph, x0:x0 + pw]
276 p = torch.sigmoid(model(patch_in))[0, 0]
277 acc[z0:z0 + pd, y0:y0 + ph, x0:x0 + pw] += p * win
278 cnt[z0:z0 + pd, y0:y0 + ph, x0:x0 + pw] += win
279 return (acc / cnt.clamp_min(1e-6)).cpu().numpy()
280
281
282def clean_small(mask, min_size):
283 """Remove connected components smaller than min_size voxels."""
284 if min_size <= 1:
285 return mask
286 cc, n = cc_label(mask)
287 if n == 0:
288 return mask
289 sizes = np.bincount(cc.ravel())
290 sizes[0] = 0
291 return np.isin(cc, np.where(sizes >= min_size)[0]).astype(np.uint8)
292
293
294# ============================================================================
295# MAIN: mark a brain file and visualize
296# ============================================================================
297@torch.no_grad()
298def mark_brain(flair_path, ckpt_path, out_png, grid, n_slices, device):
299 # Build model and load weights (load only matching keys: aux/ds heads optional)
300 model = Vbai26MSS(in_ch=1, ds=True).to(device)
301 ck = torch.load(ckpt_path, map_location=device, weights_only=False)
302 state = ck["model"] if isinstance(ck, dict) and "model" in ck else ck
303 model.load_state_dict(state, strict=False)
304 model.eval()
305 print(f"Vbai-2.6MSS loaded: {os.path.basename(ckpt_path)}")
306
307 # Preprocess: foreground z-score + resample to canvas grid
308 raw = load_nii(flair_path)
309 flair = resamp(znorm(raw), grid)
310 disp = resamp(raw, grid)
311 if (disp > 0).any():
312 lo, hi = np.percentile(disp[disp > 0], [1, 99])
313 disp = np.clip((disp - lo) / (hi - lo + 1e-6), 0, 1)
314
315 # Inference -> probability -> binary mask -> noise cleanup
316 x = torch.tensor(flair[None, None], dtype=torch.float32).to(device)
317 prob = sliding_window_prob(model, x, PATCH, OVERLAP)
318 pred = clean_small((prob >= THRESHOLD).astype(np.uint8), MIN_CC)
319
320 n_vox = int(pred.sum())
321 n_les = int(cc_label(pred)[1])
322 print(f"Marked lesions: {n_les} (total {n_vox} voxels)")
323
324 # Pick the most-lesion axial slices; overlay prediction in red
325 scores = pred.sum(axis=(0, 1))
326 zs = sorted(np.argsort(scores)[-n_slices:]) if scores.sum() > 0 else [grid[2] // 2]
327 cols = len(zs)
328 fig, axes = plt.subplots(1, cols, figsize=(3 * cols, 3.4))
329 if cols == 1:
330 axes = [axes]
331 for ax, z in zip(axes, zs):
332 ax.imshow(disp[:, :, z].T, cmap="gray", origin="lower")
333 overlay = np.zeros((*pred[:, :, z].T.shape, 4), np.float32)
334 overlay[..., 0] = 1.0
335 overlay[..., 3] = (pred[:, :, z].T > 0) * 0.5
336 ax.imshow(overlay, origin="lower")
337 ax.set_title(f"z={z}", fontsize=8)
338 ax.axis("off")
339 fig.suptitle(f"Vbai-2.6MSS | {os.path.basename(flair_path)} | "
340 f"{n_les} lesion(s), {n_vox} voxels (red = prediction)", fontsize=11)
341 plt.tight_layout(rect=[0, 0, 1, 0.94])
342 plt.savefig(out_png, dpi=130, bbox_inches="tight")
343 print(f"Saved -> {out_png}")
344 if _IN_COLAB:
345 plt.show()
346 plt.close(fig)
347
348
349def main():
350 ap = argparse.ArgumentParser(description="Vbai-2.6MSS - mark MS lesions on a FLAIR brain scan")
351 ap.add_argument("--flair", required=True, help="path to a FLAIR .nii / .nii.gz file")
352 ap.add_argument("--ckpt", default="Vbai-2.6MSS.pt", help="path to model weights")
353 ap.add_argument("--out", default=None, help="output PNG path (default: next to input)")
354 ap.add_argument("--grid", type=int, default=GRID[0], help="canvas size (default 128)")
355 ap.add_argument("--n-slices", type=int, default=6, help="number of slices to display")
356 args = ap.parse_args()
357
358 device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
359 if not os.path.exists(args.ckpt):
360 print(f"Checkpoint not found: {args.ckpt}")
361 return
362 if not os.path.exists(args.flair):
363 print(f"FLAIR not found: {args.flair}")
364 return
365 out = args.out or (os.path.splitext(args.flair.replace(".nii.gz", ".nii"))[0] + "_Vbai-2.6MSS.png")
366 grid = (args.grid, args.grid, args.grid)
367 mark_brain(args.flair, args.ckpt, out, grid, args.n_slices, device)
368
369
370if __name__ == "__main__":
371 main()
372requirements.txt for full dependency list