Four model families, benchmarked honestly β with a test suite, CI and a containerised inference service.
Results Β· Where it fails Β· Install Β· Serving Β· Testing Β· Limitations
A medical imaging pipeline covering tumor segmentation, detection, and through-plane slice interpolation, built end-to-end on one 4 GB laptop GPU.
The starting point was a published reference implementation (sdsubhajitdas/Brain-Tumor-Segmentation, 2-D U-Net, 0.745 Dice on the Cheng dataset). Phase 0 reproduces that result in a clean codebase as an honest baseline; the rest builds on it.
Warning
Not a clinical tool. Trained on public research data, never validated prospectively, never reviewed by a radiologist. See Limitations.
| Reference | NeuroSeg | |
|---|---|---|
| Data | 2-D, single modality, 3,064 slices | 3-D multimodal BraTS + the 2-D Cheng set |
| Architectures | One 2-D U-Net | U-Net β Attention U-Net β 3-D U-Net β UNETR |
| Detection | β | YOLOv8 on mask-derived boxes |
| Interpolation | β | Residual CNN for anisotropic volumes |
| Metrics | Dice | Dice, IoU, Hausdorff, sens/spec, mAP, PSNR/SSIM |
| Splits | slice-level | patient-disjoint (harder, and correct) |
| Engineering | notebook + one script | 354 tests Β· CI Β· Docker Β· FastAPI Β· MLflow Β· DVC |
graph LR
subgraph Data
D1["Cheng Dataset<br/>2D T1ce slices"]
D2["BraTS / MSD<br/>3D multimodal"]
end
D1 --> Prep["Preprocessing<br/>patient-disjoint splits"]
D2 --> Prep
Prep --> Seg2D["2D Segmentation<br/>U-Net / Attention U-Net"]
Prep --> Seg3D["3D Segmentation<br/>3D U-Net / UNETR"]
Seg2D --> Det["Detection<br/>YOLOv8 on mask-derived boxes"]
Prep --> Interp["Slice Interpolation<br/>Residual CNN"]
Seg2D --> Eval["Evaluation<br/>Dice Β· IoU Β· Hausdorff Β· mAP Β· PSNR/SSIM"]
Seg3D --> Eval
Det --> Eval
Interp --> Eval
Eval --> Gate["Regression Gate (CI)"]
Seg2D --> API["FastAPI Serving<br/>Dockerized"]
Det --> API
Interp --> API
Every number was produced on an RTX 3050 Laptop (4 GB) and is reproducible from this repo. Regenerate the whole table with
python evaluate.py --task all --split test.
| Task | Model | Test set | Headline | Claim |
|---|---|---|---|---|
| 2-D segmentation | U-Net | 462 slices | 0.8244 Dice | β verified |
| 2-D segmentation | Attention U-Net | 462 slices | 0.8202 Dice | βͺ not significant |
| 3-D segmentation | 3-D U-Net | 9 volumes | 0.8321 Dice | π‘ subset |
| 3-D segmentation | UNETR | 9 volumes | 0.7348 Dice | π‘ under-trained |
| Detection | YOLOv8n | 462 images | 0.913 mAP@0.5 | β verified |
| Interpolation | Residual CNN | 360 slices | 32.47 dB | β beats baseline |
The four tasks are not comparable to one another β Dice, mAP and PSNR measure different things β which is why the harness emits one table per family rather than one wide table.
| Model | Params | Dice | IoU | HD95 px | Sens. | Spec. | ms/case |
|---|---|---|---|---|---|---|---|
| Reference (published) | ~7.8 M | 0.745 | β | β | β | β | β |
| U-Net | 7.76 M | 0.8244 Β±.178 | 0.7315 | 8.27 | 0.8556 | 0.9974 | 9.85 |
| Attention U-Net | 7.89 M | 0.8202 Β±.184 | 0.7274 | 8.63 | 0.8617 | 0.9972 | 11.02 |
Important
The comparison against the reference is harder than it looks β in this repo's disfavour. The reference splits at the slice level. Cheng has 3,064 slices from only 233 patients β ~13 near-duplicate slices each β so a slice-level split puts near-identical images in both train and test and inflates the score. Every split here is patient-disjoint. The gain over 0.745 comes from the training setup, not an easier evaluation.
Attention gates: a negative result, reported as one
Both runs are identical in depth, width, loss, optimiser, schedule and split β only the gates differ. Attention led on validation (0.7727 vs 0.7682) and trailed on test. A ranking that flips between splits is the signature of noise, so it was tested rather than asserted:
mean difference (U-Net β Attention) +0.0041
95% CI of difference [-0.0047, +0.0131] β includes zero
Wilcoxon signed-rank p 0.3125
paired t-test p 0.3638
Attention better on 217 / 462 cases (47%)
The interval spans zero and the win rate is a coin flip: the extra 0.13 M parameters buy nothing measurable here. Reporting only the validation number β where attention "wins" β would have been misleading.
python scripts/compare_models.py \
--a artifacts/phase0_unet2d/best.pt \
--b artifacts/phase2_attention_unet/best.pt
60 Decathlon cases, split by case (42 / 9 / 9). Patch-based training at 96Β³ with foreground oversampling; evaluated by sliding-window inference over whole 240Γ240Γ155 volumes.
| Model | Params | Whole | Core | Enhancing | Mean |
|---|---|---|---|---|---|
| 3-D U-Net | 1.40 M | 0.8344 | 0.8217 | 0.8402 | 0.8321 |
| UNETR (transformer) | 102.24 M | 0.7655 | 0.6566 | 0.7823 | 0.7348 |
Note
A 1.4 M CNN beats a 102 M transformer β because of data scale, not architecture. A U-Net has locality and translation equivariance built in; a ViT must learn them from data. This experiment has 42 training cases. UNETR's published results use the full BraTS set with far longer schedules. Both models' validation Dice was still rising at epoch 40 β both are under-trained, the transformer more severely. Ranking architectures from this would be a mistake.
| Metric | Ultralytics | This repo's implementation |
|---|---|---|
| mAP@0.5 | 0.9131 | 0.8967 |
| mAP@0.5:0.95 | 0.5473 | 0.5100 |
| Precision @0.25 | 0.8949 | 0.7822 |
| Recall @0.25 | 0.8528 | 0.8939 |
| TP / FP / FN | β | 413 / 115 / 49 |
Both columns shown deliberately: the second is an independent implementation validated against hand-computed cases in the test suite. Agreeing to within ~0.02 mAP is a sanity check on both.
The gap between mAP@0.5 (0.91) and mAP@0.5:0.95 (0.55) is the real finding β the detector reliably finds the tumor but draws loose boxes, and at stricter IoU the score collapses.
Eight worst detections by IoU. π© ground truth Β· π¨ prediction with confidence. Most errors are loose boxes on the right lesion, not misses.
360 held-out slices, stride 2 β every second axial slice withheld and reconstructed from neighbours two apart.
| Method | PSNR (dB) | SSIM |
|---|---|---|
| Linear average of neighbours | 30.836 Β±2.710 | 0.89911 |
| Residual CNN | 32.469 Β±2.484 | 0.92298 |
| Gain | +1.633 | +0.02387 |
The baseline is the point of this table. Averaging two neighbouring slices is already strong, so a standalone 32 dB means nothing without knowing averaging alone reaches 30.8. The model predicts a residual on the linear average through a zero-initialised head β it starts training at exactly the baseline, so every parameter is spent on what averaging gets wrong.
71.9% of cases score Dice β₯ 0.8, and the median (0.8837) sits well above the mean (0.8244) β the average is dragged down by a small tail of hard cases, not by broad mediocrity.
| Tumor type | n | Dice | Lesion size | n | Median px | Dice | |
|---|---|---|---|---|---|---|---|
| Meningioma | 90 | 0.8928 | Q1 smallest | 116 | 260 | 0.7951 | |
| Pituitary | 162 | 0.8743 | Q2 | 115 | 664 | 0.8423 | |
| Glioma | 210 | 0.7566 | Q3 | 116 | 1133 | 0.8211 | |
| Q4 largest | 115 | 1906 | 0.8393 |
Gliomas are hardest by a wide margin β which is what a radiologist would predict. They are infiltrative with genuinely ambiguous margins, whereas meningiomas and pituitary adenomas are well-circumscribed. Lesion size matters much less than expected.
Caution
All five total failures predicted a tumor in the wrong place β none declined to predict. Five cases (1.1%) scored exactly 0.000 Dice. In every one, the model produced a confident, plausible-looking mask at the wrong location β in one case the contralateral hemisphere. That is the more dangerous failure: an empty prediction is obviously useless, but a well-formed mask in the wrong place is the kind of output a reader could act on.
Six worst test cases. π© true positive Β· π₯ false negative Β· π¦ false positive
# 1 Β· Install β GPU users: torch from the CUDA index FIRST (see warning below)
pip install torch==2.7.1+cu118 torchvision==0.22.1+cu118 \
--index-url https://download.pytorch.org/whl/cu118
pip install -r requirements.txt
# 2 Β· Data β both public, no registration
python scripts/prepare_cheng.py # ~880 MB
python scripts/fetch_msd_labels.py # ~24 MB, not 7.6 GB (see below)
# 3 Β· Train
python -m src.train --config configs/phase0_unet2d.yaml
python scripts/train_brats3d.py --model unet3d --n-cases 60 --epochs 40
python scripts/train_yolo.py --epochs 40
python scripts/train_interpolation.py --n-cases 60 --epochs 25 --stride 2
# 4 Β· Benchmark, figures, error analysis
python evaluate.py --task all --split test
python scripts/visualize.py --checkpoint artifacts/phase0_unet2d/best.pt
python scripts/error_analysis.pyWarning
Install order matters. Installing MONAI without pinning torch lets pip resolve the CPU-only wheel over a CUDA build of the same version. Training then silently falls back to CPU β no error, ~30Γ slower. Verify with python -c "import torch; print(torch.version.cuda)" β must not be None.
Getting the BraTS labels: 24 MB instead of 7.6 GB
BraTS 2021 needs registration, so this uses the Medical Segmentation Decathlon archive β same data, redistributable, but one 7.6 GB tar. The obvious approach wastes hours:
curl -sL "$URL" | tar -x Task01_BrainTumour/labelsTr # ran 1 h, extracted nothingThe assumption underneath it β that the archive is ordered imagesTr, labelsTr, imagesTs β is wrong. Probing with HTTP range requests shows the real layout:
dataset.json β imagesTr (484 vols, ~4.5 GB)
β imagesTs (266 vols, ~2.9 GB)
β labelsTr (~15 MB) β LAST
The labels are the last 15 MB of the file. Since S3 honours range requests, the whole problem collapses to a tail fetch: pull the last 24 MB, find the first 512-byte-aligned labelsTr tar header, and stream the remainder through Python's tarfile. All 484 labels, 24 MB instead of 7.6 GB β seconds instead of hours. That is scripts/fetch_msd_labels.py.
docker build -t neuroseg:latest .
docker run --rm -p 8000:8000 -v "$(pwd)/artifacts:/app/artifacts:ro" neuroseg:latest
curl localhost:8000/health| Endpoint | Purpose |
|---|---|
GET /health |
liveness + which capabilities actually loaded |
GET /models |
metadata for loaded checkpoints |
POST /segment |
slice β mask (JSON with RLE, or a PNG) |
POST /detect |
slice β bounding boxes derived from the mask |
POST /interpolate |
.npy stack β reconstructed intermediate slices |
Verified end-to-end: image builds (2.2 GB), container healthy ~5 s after start, and posting a real test slice to /segment returned 1,032 predicted tumor pixels against 928 in the ground truth, with /detect returning one coherent box.
Missing checkpoints yield 503 on the affected endpoint, not a crash on boot β the container stays deployable while models are still training. Uploads accept .npy as well as PNG, because 8-bit images cannot represent the dynamic range of MRI.
pytest tests -m "not data" # 354 tests, 82% coverage, no dataset required
pytest tests # adds assertions against the real cache| Module | Tests | Module | Tests | |
|---|---|---|---|---|
test_detection.py |
49 | test_benchmark_gate.py |
31 | |
test_models.py |
47 | test_interpolation.py |
29 | |
test_msd_and_config.py |
42 | test_api.py |
23 | |
test_patching.py |
38 | test_inference.py |
16 | |
test_data.py |
37 | test_patch_sampler.py |
9 | |
test_metrics.py |
35 |
Everything runs on synthetic fixtures, so CI needs no dataset. Metrics are checked against hand-computed values, not the implementation's own output β Dice on a known half-overlap, IoU as 4/12, Hausdorff on a square shifted exactly 3 px, AP on a curve worked out by hand.
Six bugs found β four produced no error at all
| Bug | Why it was invisible |
|---|---|
| Patch sampler replayed identical patches every epoch | Seeded seed + index; the loader walks the same range each epoch, so training saw ~40Γ less data than intended. Loss fell normally. |
| MONAI silently replaced CUDA torch with a CPU build | pip resolved the CPU wheel over the CUDA build of the same version. ~30Γ slowdown, no error. |
| mAP computed at the wrong confidence floor | Filtering at 0.25 truncates the PR curve β understated mAP by ~0.04. |
| Ultralytics wrote weights elsewhere | A relative project path resolves against Ultralytics' own runs_dir. |
| UNETR diverged at the shared learning rate | 1e-3 works for the CNNs; the 102 M ViT went non-finite in one epoch. Now per-architecture LR + warmup. |
| 2-D benchmark evaluated 3-D checkpoints | Once artifacts/ held both, the 2-D evaluator fed a 3-D model 2-D slices. |
Two tests exist because the obvious assumption is wrong: Tversky(0.5, 0.5) equals Dice only at twice the smoothing constant, and a duplicate detection ranked below a true positive does not reduce AP β it only costs precision at a fixed operating point.
The regression gate handles metric direction explicitly: a fall in Dice is a regression, a rise in Hausdorff distance is. Both directions are tested β a gate never observed to fail is indistinguishable from one wired to always pass.
graph TD
Root["NeuroSeg/"]
Root --> Src["src/"]
Src --> Data["data/ β cheng Β· brats Β· msd loaders, transforms, patching, splits"]
Src --> Models["models/ β unet2d Β· attention_unet Β· unet3d Β· unetr Β· losses Β· registry"]
Src --> Det["detection/ β maskβbbox conversion, YOLO export"]
Src --> Interp["interpolation/ β residual slice-interpolation net + triplet dataset"]
Src --> Eval["eval/ β metrics, detection metrics, inference, report generation"]
Src --> Serve["serving/ β FastAPI app"]
Src --> Train["train.py β config-driven training entrypoint"]
Root --> Scripts["scripts/ β prepare_cheng Β· fetch_msd_labels Β· train_yolo/brats3d/interpolation Β· visualize Β· error_analysis Β· compare_models"]
Root --> Configs["configs/ β YAML experiment configs"]
Root --> Tests["tests/ β 354 tests"]
Root --> EvalPy["evaluate.py β benchmarking harness + regression gate"]
Architectures are built through src/models/registry.py, so a YAML config names a model and nothing downstream needs to know the class β which is what makes the benchmark a loop over configs rather than a chain of if-statements.
Cheng (Figshare) β 3,064 T1ce slices, 233 patients, 708 meningioma / 1,426 glioma / 930 pituitary, with pixel-level masks. Public, no registration. Tumor occupies 1.69% of all pixels, which is why every loss here is imbalance-aware: plain cross-entropy is minimised well by predicting background everywhere, scoring >98% pixel accuracy and 0.0 Dice.
Medical Segmentation Decathlon Task01 β BraTS 2016/2017, 484 cases, 4 co-registered sequences at 1 mm isotropic. Two format differences are reconciled in src/data/msd.py, both silent failure modes if missed:
- Modality order β Decathlon packs
FLAIR, T1w, T1gd, T2w; this codebase usest1, t1ce, t2, flair. Getting it wrong trains on FLAIR while labelling the channel T1. - Label codes β Decathlon uses
1=edema, 2=non-enhancing, 3=enhancing; BraTS uses1=necrotic, 2=edema, 4=enhancing.
Results are reported on the three nested BraTS sub-regions (whole / core / enhancing), treated as independent binary channels because they overlap.
- Not clinically validated. Public research data, retrospective, no prospective evaluation, no radiologist review. Nothing here is a medical device.
- The confident-wrong-location failure mode (1.1% of cases) is the most serious practical issue and is not mitigated in this codebase.
- Glioma performance (0.757) is meaningfully worse than the headline 0.824 β and gliomas are the hardest, most common malignant case.
- Cheng is single-modality, single-slice. A radiologist reads multiple sequences and adjacent slices together.
- The volumetric results use 60 of 484 cases with a 9-volume test set. Indicative, not comparable to published BraTS results.
- Both 3-D models are under-trained β validation Dice still climbing at epoch 40.
- Interpolation is evaluated on simulated anisotropy. BraTS volumes were already resampled to 1 mm isotropic, so neighbours are smoother than genuinely thick-slice clinical data; PSNR here is optimistic.
- Detection boxes are derived from masks, so the detector inherits every segmentation error rather than providing an independent check.
- No histology / whole-slide imaging β a different modality with different tooling. An adjacent interest, not covered work.
- Ronneberger et al. (2015), U-Net: Convolutional Networks for Biomedical Image Segmentation
- ΓiΓ§ek et al. (2016), 3D U-Net: Learning Dense Volumetric Segmentation from Sparse Annotation
- Oktay et al. (2018), Attention U-Net: Learning Where to Look for the Pancreas
- Hatamizadeh et al. (2021), UNETR: Transformers for 3D Medical Image Segmentation
- Cheng et al. (2015), brain tumor dataset β Figshare
- Antonelli et al. (2022), The Medical Segmentation Decathlon
- Baid et al. (2021), The RSNA-ASNR-MICCAI BraTS 2021 Benchmark
Built on one RTX 3050 Laptop GPU (4 GB). Every figure and number in this README is reproducible from the commands above.



