Skip to content

Commit 615bd0a

Browse files
committed
2 parents bce5f5c + d5960ac commit 615bd0a

18 files changed

Lines changed: 580 additions & 294 deletions

README.md

Lines changed: 57 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -26,17 +26,19 @@
2626
Your algorithm receives the files under `features/` for each subject and must output a predicted CT volume as a NIfTI file in Hounsfield units (HU). Predictions are evaluated two ways:
2727

2828
1. **CT accuracy** — predicted CT is compared directly against the ground-truth CT
29-
2. **PET accuracy** — predicted CT is fed into the reconstruction pipeline to produce an attenuation-corrected PET (ACPET) image, which is then compared against the ground-truth PET
29+
2. **PET accuracy** — predicted CT is fed into the reconstruction pipeline to produce an attenuation-corrected PET image, which is then compared against the ground-truth PET
3030

31-
The dataset (100 subjects, Siemens Biograph Vision Quadra + MAGNETOM Vida) is split as follows:
31+
Note that no PET reconstruction experience is needed to participate in the challenge, and the main purpose of the reconstruction is to enable clinically meaningful metrics.
32+
33+
The dataset comprises 99 subject-unique cases, with 20 reserved for testing and the remaining 79 available on huggingface and split as follows:
3234

3335
| Split | Subjects | Contents |
3436
|-------|----------|----------|
3537
| `train/` (full) | 8 | `features/` + `ct-label/` + `recon/` + `pet-label/` |
36-
| `train/` (no recon) | 68 | `features/` + `ct-label/` |
38+
| `train/` (no recon) | 67 | `features/` + `ct-label/` |
3739
| `val/` | 4 | `features/` + `recon/` |
3840

39-
All train subjects have CT labels. The 8 fully-equipped subjects additionally include sinogram data and PET labels, enabling closed-loop local evaluation. Validation subjects have sinogram data but no labels — submit reconstructed PET to Codabench.
41+
All train cases have CT labels, but due to the size of the sinograms, only 8 include the recon and pet-label folders needed for closed loop reconstruction. Validation subjects have sinogram data but no labels — submit predicted CTs and reconstructed PET to Codabench to get live leaderboard metrics throughout the challenge.
4042

4143
---
4244

@@ -64,38 +66,34 @@ uv sync
6466
---
6567

6668
## 🗂️ Data Format
67-
69+
All images are resampled to the label CT image (tensor size: 512x512x531, voxel size 1.52x1.52,2.00mm^3) and structured in four folders per case.
70+
- `features/` All the files you can use as input to your generative CT model at inference.
71+
-
6872
```
73+
74+
6975
train/
7076
└── sub-000/
71-
├── features/ # model inputs (all subjects)
72-
│ ├── nacpet.nii.gz # non-attenuation-corrected PET
73-
│ ├── topogram.nii.gz # 2D scout X-ray resampled to CT grid
74-
│ ├── mri_chunk_0_in_phase.nii.gz # DIXON MRI bed position 0, in-phase
75-
│ ├── mri_chunk_0_out_phase.nii.gz # DIXON MRI bed position 0, out-of-phase
76-
│ ├── mri_chunk_1_in_phase.nii.gz # ... (chunks 0–3 for each phase)
77-
│ ├── mri_chunk_1_out_phase.nii.gz
78-
│ ├── mri_chunk_2_in_phase.nii.gz
79-
│ ├── mri_chunk_2_out_phase.nii.gz
80-
│ ├── mri_chunk_3_in_phase.nii.gz
81-
│ ├── mri_chunk_3_out_phase.nii.gz
82-
│ ├── mri_combined_in_phase.nii.gz # stitched whole-body MRI, in-phase
83-
│ ├── mri_combined_out_phase.nii.gz # stitched whole-body MRI, out-of-phase
84-
│ ├── face_seg.nii.gz # face mask (MRI space)
77+
├── features/ # generative model inputs
78+
│ ├── nacpet.nii.gz # non-attenuation-corrected PET.
79+
│ ├── topogram.nii.gz # 2D scout X-ray
80+
│ ├── mri_chunk_{0-3}_{in/out}_phase.nii.gz # DIXON MRI bed position (0-3), in-phase and out-phase
81+
│ ├── mri_combined_{in/out}_phase.nii.gz # stitched whole-body MRI, out-of-phase
82+
│ ├── mri_face_mask.nii.gz # binary anonymization mask
8583
│ └── metadata.json # {sex, age, height, weight}
86-
├── ct-label/ # ground-truth CT (train only)
87-
│ ├── ct.nii.gz # anonymized CT in HU
88-
│ ├── body_seg.nii.gz # body mask
89-
│ ├── organ_seg.nii.gz # TotalSegmentator organ labels
90-
│ └── face_seg.nii.gz # face mask
91-
├── recon/ # sinogram data (labeled train + val)
92-
│ ├── mult_factors_forSTIR_SSRB.hs/s # multiplicative correction sinogram
93-
│ ├── additive_term_SSRB.hs/s # additive correction sinogram (scatter + randoms)
94-
│ ├── prompts_SSRB.hs/s # prompt (raw) sinogram
84+
├── ct-label/ # ground-truth CT
85+
│ ├── ct.nii.gz # in HU this is what your algorithm should predict
86+
│ ├── body_seg.nii.gz # TotalSegmentator body seg.
87+
│ ├── organ_seg.nii.gz # TotalSegmentator organ seg.
88+
│ └── prediction_mask.nii.gz # The generative model should focus only on these voxels (face + scanner are excluded)
89+
├── recon/ # sinogram data
90+
│ ├── mult_nac_rd85.hs/.s # multiplicative sinogram
91+
│ ├── add_nac_rd85.hs/.s # additive sinogram
92+
│ ├── prompts_rd85.hs/.s # raw sinogram
9593
│ ├── offset.json # bed position and gantry offset
96-
│ ├── ct_face.nii.gz # GT CT face region (for face swap)
97-
│ └── face_mask.nii.gz # face mask
98-
└── pet-label/ # ground-truth PET (labeled train only)
94+
│ ├── ct_face_and_bed.nii.gz # GT CT values at face + scanner bed (automatically superimposed on your prediction before reconstruction)
95+
│ └── face_and_bed_mask.nii.gz # binary face + scanner bed mask
96+
└── pet-label/ # ground-truth PET
9997
├── pet.nii.gz # CT-attenuation-corrected PET (reference)
10098
├── body_seg.nii.gz # body mask in PET space
10199
└── organ_seg.nii.gz # organ labels in PET space
@@ -124,14 +122,15 @@ python src/baseline/model.py data/sub-000/features/ results/sub-000/ct_pred.nii.
124122
Converts a predicted pseudo-CT into a reconstructed ACPET image using [STIR](http://stir.sourceforge.net/) (Software for Tomographic Image Reconstruction). The pipeline:
125123

126124
1. Validates CT shape, affine, and HU range
127-
2. Converts HU → linear attenuation coefficients (μ-map) at 511 keV using the Carney et al. (2006) bilinear model
128-
3. Smooths the μ-map (4mm FWHM Gaussian)
129-
4. Resamples the μ-map to STIR sinogram format
130-
5. Computes the ACF (attenuation correction factor) sinogram
131-
6. Applies ACF to multiplicative/additive sinograms
132-
7. Reconstructs using OSEM (ordered subsets expectation maximisation)
133-
8. Applies 4mm post-reconstruction filter
134-
9. Converts to NIfTI with correct bed/gantry offset origin
125+
2. Swaps face and scanner bed region back from ground-truth CT (to avoid evaluating face/bed prediction)
126+
3. Converts HU → linear attenuation coefficients (μ-map) at 511 keV using the Carney et al. (2006) bilinear model
127+
4. Smooths the μ-map (4mm FWHM Gaussian)
128+
5. Resamples the μ-map to STIR format (ring spacing 3.29114 mm)
129+
6. Computes the ACF (attenuation correction factor) sinogram
130+
7. Applies ACF to the additive sinogram
131+
8. Applies ACF to the multiplicative sinogram
132+
9. Reconstructs using OSEM (ordered subsets expectation maximisation, with post-filter)
133+
10. Converts to NIfTI with correct bed/gantry offset origin
135134

136135
### Option 1: Docker (recommended)
137136

@@ -147,26 +146,29 @@ docker run --rm \
147146
ghcr.io/bic-mac-challenge/recon:latest
148147
```
149148

150-
The reconstructed PET is written to `/data/output/pet.nii.gz`.
149+
The reconstructed PET is written to `/data/output/pet.nii.gz`. Intermediate files (mu-map, ACF sinogram, etc.) are written to `/data/output/intermediates/` and a full debug log to `/data/output/intermediates/recon.log`.
151150

152-
Optionally mount a local directory to `/data/intermediates` to persist intermediate files (mu-map, ACF sinogram, etc.). When mounted, the pipeline resumes from any existing intermediates rather than recomputing them; set `OVERWRITE=1` to forcefully restart from scratch instead.
151+
The pipeline resumes from any existing intermediates automatically; set `OVERWRITE=1` to forcefully restart from scratch:
153152

154153
```bash
155154
docker run --rm \
155+
-e OVERWRITE=1 \
156156
-v /path/to/sub-000/recon:/data/recon \
157157
-v /path/to/ct_pred.nii.gz:/data/ct/ct.nii.gz \
158158
-v /path/to/output:/data/output \
159-
-v /path/to/intermediates:/data/intermediates \
160159
ghcr.io/bic-mac-challenge/recon:latest
161160
```
162161

162+
Set `VERBOSE=1` to stream STIR subprocess output to the terminal in addition to the log file.
163+
163164
### Option 2: Direct Python (requires local STIR)
164165

165166
```bash
166-
python src/recon/main.py <recon_dir> <ct.nii.gz> <pet_out.nii.gz> \
167-
[--overwrite] [--intermediates_dir <dir>]
167+
python src/recon/main.py <recon_dir> <ct.nii.gz> <output_dir> [-w] [-v]
168168
```
169169

170+
`pet.nii.gz` and `intermediates/` are written inside `output_dir`. Use `-w`/`--overwrite` to rerun from scratch and `-v`/`--verbose` to stream STIR output to the terminal.
171+
170172
---
171173

172174
## 📊 Evaluation (`src/evaluation/`)
@@ -209,12 +211,22 @@ The exact command used to run your container is:
209211

210212
```bash
211213
docker run --rm \
214+
--memory 128g \
215+
--network none \
212216
-v /path/to/sub-XXX/features:/data/features:ro \
213217
-v /path/to/output:/data/output \
214218
<your-image>
215219
```
216220

217-
No other files or directories are mounted. Your container must not require network access at inference time.
221+
**Constraints enforced at evaluation time:**
222+
223+
| Resource | Limit |
224+
|----------|-------|
225+
| RAM | 128 GB |
226+
| Wall-clock time | 5 minutes |
227+
| Network access | None (`--network none`) |
228+
229+
No other files or directories are mounted. Make sure all model weights and dependencies are baked into your image — no downloads at inference time.
218230

219231
Submit your image name and tag via Codabench (see [website](https://bic-mac-challenge.github.io/) for registration and submission instructions).
220232

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,5 @@ readme = "README.md"
66
requires-python = ">=3.12"
77
dependencies = [
88
"nibabel>=5.3.3",
9+
"tqdm>=4.67.3",
910
]

src/evaluation/eval.py

Lines changed: 61 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -14,146 +14,107 @@
1414
import argparse
1515
import os
1616
import numpy as np
17-
import nibabel as nib
1817

1918
from metrics import (
2019
compute_whole_body_suv_mae,
21-
compute_brain_outlier_score,
2220
compute_organ_bias_from_totalseg,
2321
compute_whole_body_mu_mae,
2422
)
2523

2624

27-
def main():
28-
29-
parser = argparse.ArgumentParser(
30-
description="PET Attenuation Correction Challenge — Evaluation"
31-
)
25+
def evaluate_subject(subject_path, pred_pet_path=None, pred_ct_path=None):
26+
"""
27+
Run metrics for a single subject.
3228
33-
parser.add_argument(
34-
"--subject_path",
35-
required=True,
36-
help="Path to subject directory"
37-
)
29+
Parameters
30+
----------
31+
subject_path : str
32+
Path to the subject directory (must contain ct-label/ and pet-label/).
33+
pred_pet_path : str or None
34+
Path to predicted PET NIfTI. If given, runs PET metrics (SUV MAE, Organ Bias).
35+
pred_ct_path : str or None
36+
Path to predicted CT NIfTI. If given, runs CT MAE.
3837
39-
parser.add_argument(
40-
"--pred_pet",
41-
required=True,
42-
help="Path to predicted PET NIfTI"
43-
)
38+
Note
39+
----
40+
Brain Outlier Score is a dataset-level metric and cannot be computed per-subject.
41+
Use compute_brain_outlier_score() directly with paths from multiple subjects.
4442
45-
parser.add_argument(
46-
"--pred_ct",
47-
required=True,
48-
help="Path to predicted CT NIfTI"
49-
)
43+
Returns
44+
-------
45+
dict
46+
{metric_name: float}
47+
"""
5048

51-
parser.add_argument(
52-
"-all",
53-
action="store_true",
54-
help="Run all metrics"
55-
)
49+
if pred_pet_path is None and pred_ct_path is None:
50+
raise ValueError("At least one of pred_pet_path or pred_ct_path must be provided.")
5651

57-
parser.add_argument(
58-
"-specific_metric",
59-
choices=[
60-
"whole_body_mae",
61-
"brain_outlier",
62-
"organ_bias",
63-
"ct_mae",
64-
],
65-
help="Run specific metric only"
66-
)
67-
68-
args = parser.parse_args()
69-
70-
subject_path = args.subject_path
7152
ct_label_dir = os.path.join(subject_path, "ct-label")
7253
pet_label_dir = os.path.join(subject_path, "pet-label")
73-
features_dir = os.path.join(subject_path, "features")
74-
75-
gt_pet = os.path.join(pet_label_dir, "pet.nii.gz")
76-
gt_ct = os.path.join(ct_label_dir, "ct.nii.gz")
77-
body_seg_pet = os.path.join(pet_label_dir, "body_seg.nii.gz")
78-
organ_seg_pet = os.path.join(pet_label_dir, "organ_seg.nii.gz")
79-
body_seg_ct = os.path.join(ct_label_dir, "body_seg.nii.gz")
80-
meta_json = os.path.join(features_dir, "metadata.json")
8154

8255
results = {}
8356

84-
# =====================================================
85-
# 1. Whole-body SUV MAE
86-
# =====================================================
87-
88-
if args.all or args.specific_metric == "whole_body_mae":
57+
if pred_pet_path is not None:
58+
gt_pet = os.path.join(pet_label_dir, "pet.nii.gz")
59+
body_seg_pet = os.path.join(pet_label_dir, "body_seg.nii.gz")
60+
organ_seg_pet = os.path.join(pet_label_dir, "organ_seg.nii.gz")
8961

9062
results["Whole-body SUV MAE"] = compute_whole_body_suv_mae(
91-
pred_pet_path=args.pred_pet,
63+
pred_pet_path=pred_pet_path,
9264
gt_pet_path=gt_pet,
9365
body_mask_path=body_seg_pet,
94-
liver_mask_path=organ_seg_pet,
95-
json_path=meta_json,
96-
)
97-
98-
# =====================================================
99-
# 2. Brain Outlier Score
100-
# =====================================================
101-
102-
if args.all or args.specific_metric == "brain_outlier":
103-
104-
results["Brain Outlier Score"] = compute_brain_outlier_score(
105-
pred_paths=[args.pred_pet],
106-
gt_paths=[gt_pet],
107-
totalseg_paths=[organ_seg_pet],
66+
organ_seg_path=organ_seg_pet,
10867
)
10968

110-
# =====================================================
111-
# 3. Organ Bias
112-
# =====================================================
113-
114-
if args.all or args.specific_metric == "organ_bias":
115-
116-
11769
results["Organ Bias"] = compute_organ_bias_from_totalseg(
118-
pred_path=args.pred_pet,
70+
pred_path=pred_pet_path,
11971
gt_path=gt_pet,
12072
totalseg_path=organ_seg_pet,
121-
json_path=meta_json,
73+
body_mask_path=body_seg_pet,
12274
)
12375

124-
125-
# =====================================================
126-
# 4. CT MAE
127-
# =====================================================
128-
129-
if args.all or args.specific_metric == "ct_mae":
76+
if pred_ct_path is not None:
77+
gt_ct = os.path.join(ct_label_dir, "ct.nii.gz")
78+
body_seg_ct = os.path.join(ct_label_dir, "body_seg.nii.gz")
79+
organ_seg_ct = os.path.join(ct_label_dir, "organ_seg.nii.gz")
13080

13181
results["CT MAE"] = compute_whole_body_mu_mae(
132-
pred_ct_path=args.pred_ct,
82+
pred_ct_path=pred_ct_path,
13383
gt_ct_path=gt_ct,
13484
body_mask_path=body_seg_ct,
135-
liver_mask_path=organ_seg_pet,
85+
organ_seg_path=organ_seg_ct,
13686
)
13787

138-
# =====================================================
139-
# Print Results
140-
# =====================================================
88+
return results
14189

142-
print("\n================ Evaluation Results ================")
143-
print(f"Subject: {os.path.basename(subject_path)}")
144-
print("----------------------------------------------------")
14590

146-
if not results:
147-
print("No metric selected.")
148-
else:
149-
for name, value in results.items():
150-
if name == "Organ Bias":
151-
print(f"{name:<25}: {value:.6f}%")
152-
else:
153-
print(f"{name:<25}: {value:.6f}")
91+
def main():
15492

93+
parser = argparse.ArgumentParser(
94+
description="PET Attenuation Correction Challenge — Evaluation"
95+
)
96+
97+
parser.add_argument("--subject_path", required=True, help="Path to subject directory")
98+
parser.add_argument("--pred_pet", default=None, help="Path to predicted PET NIfTI")
99+
parser.add_argument("--pred_ct", default=None, help="Path to predicted CT NIfTI")
100+
101+
args = parser.parse_args()
102+
103+
if args.pred_pet is None and args.pred_ct is None:
104+
parser.error("At least one of --pred_pet or --pred_ct must be provided.")
105+
106+
results = evaluate_subject(args.subject_path, args.pred_pet, args.pred_ct)
107+
108+
print("\n================ Evaluation Results ================")
109+
print(f"Subject: {os.path.basename(args.subject_path)}")
110+
print("----------------------------------------------------")
111+
for name, value in results.items():
112+
unit = "%" if name == "Organ Bias" else ""
113+
print(f"{name:<25}: {value:.6f}{unit}")
155114
print("====================================================\n")
156115

116+
return results
117+
157118

158119
if __name__ == "__main__":
159120
main()

0 commit comments

Comments
 (0)