WorkTimer commited on
Commit
ce1057b
·
verified ·
1 Parent(s): e2058ed

Add PanCancerSeg Gradio inference app

Browse files
.gitattributes CHANGED
@@ -33,3 +33,8 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ example/FLARE23Ts_0005_overlay.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ example/FLARE23Ts_0005_slice_centroid.png filter=lfs diff=lfs merge=lfs -text
38
+ example/FLARE23Ts_0005_slice_extent25.png filter=lfs diff=lfs merge=lfs -text
39
+ example/FLARE23Ts_0005_slice_extent75.png filter=lfs diff=lfs merge=lfs -text
40
+ example/FLARE23Ts_0005_slice_max_area.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+
3
+ PanCancerSeg-Specialized-weights/
4
+
5
+ output_kidney_test/
6
+
7
+ sample_input/
README.md CHANGED
@@ -1,13 +1,131 @@
1
  ---
2
  title: PanCancerSeg Specialist Inference
3
- emoji: 🌍
4
- colorFrom: pink
5
- colorTo: green
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: PanCancerSeg Specialist Inference
3
+ emoji: 🩻
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.12.0
 
8
  app_file: app.py
9
  pinned: false
10
+ license: apache-2.0
11
+ short_description: CT tumour segmentation for 4 cancer types
12
  ---
13
 
14
+ # PanCancerSeg Inference
15
+
16
+ Run one cancer-specific PanCancerSeg nnUNet model on a single CT NIfTI image and save a segmentation mask, slice PNG previews, and an MP4 overlay video.
17
+
18
+ > On Hugging Face Spaces the trained weights are downloaded automatically at first
19
+ > run from [`KS987/PanCancerSeg-Specialized-weights`](https://huggingface.co/KS987/PanCancerSeg-Specialized-weights).
20
+ > Inference is GPU-recommended; the free CPU tier may be slow or run out of memory on large volumes.
21
+
22
+ ## Model Weights
23
+
24
+ Download the trained nnUNet weights from Hugging Face: [KS987/PanCancerSeg-Specialized-weights](https://huggingface.co/KS987/PanCancerSeg-Specialized-weights)
25
+
26
+ ```bash
27
+ git lfs install
28
+ git clone https://huggingface.co/KS987/PanCancerSeg-Specialized-weights
29
+ ```
30
+
31
+ ## Setup
32
+
33
+ Create an environment and install the Python dependencies:
34
+
35
+ ```bash
36
+ pip install -r requirements.txt
37
+ ```
38
+
39
+ Download the trained nnUNet model weights to a local directory. Inference resampling can require about 64 GB RAM for large 3D volumes.
40
+
41
+ Expected model layout:
42
+
43
+ ```text
44
+ nnUNet_results/
45
+ |-- Dataset102_Kidney/
46
+ | `-- nnUNetTrainerWandb2000__nnUNetResEncUNetMPlans__3d_fullres/
47
+ | `-- fold_0/
48
+ | `-- checkpoint_best.pth
49
+ |-- Dataset103_Liver/
50
+ |-- Dataset104_Pancreas/
51
+ `-- Dataset105_Lung/
52
+ ```
53
+
54
+ ## Usage
55
+
56
+ Input images can be named either `case.nii.gz` or `case_0000.nii.gz`; the script handles both.
57
+
58
+ `--cancer_type` values are `kidney_cancer`, `liver_cancer`, `pancreatic_cancer`, and `lung_cancer`.
59
+
60
+ Kidney cancer:
61
+
62
+ ```bash
63
+ python predict.py --input /path/to/case.nii.gz --cancer_type kidney_cancer --model_dir /path/to/nnUNet_results --output_dir ./output
64
+ ```
65
+
66
+ Liver cancer:
67
+
68
+ ```bash
69
+ python predict.py --input /path/to/case.nii.gz --cancer_type liver_cancer --model_dir /path/to/nnUNet_results --output_dir ./output
70
+ ```
71
+
72
+ Pancreatic cancer:
73
+
74
+ ```bash
75
+ python predict.py --input /path/to/case.nii.gz --cancer_type pancreatic_cancer --model_dir /path/to/nnUNet_results --output_dir ./output
76
+ ```
77
+
78
+ Lung cancer:
79
+
80
+ ```bash
81
+ python predict.py --input /path/to/case.nii.gz --cancer_type lung_cancer --model_dir /path/to/nnUNet_results --output_dir ./output
82
+ ```
83
+
84
+ Use CPU only when CUDA is unavailable:
85
+
86
+ ```bash
87
+ python predict.py --input /path/to/case.nii.gz --cancer_type kidney_cancer --model_dir /path/to/nnUNet_results --output_dir ./output --device cpu
88
+ ```
89
+
90
+ ## Output Files
91
+
92
+ The output directory contains:
93
+
94
+ - `{case_id}_seg.nii.gz`: predicted segmentation mask
95
+ - `{case_id}_slice_centroid.png`: centroid slice preview
96
+ - `{case_id}_slice_max_area.png`: max predicted area slice preview
97
+ - `{case_id}_slice_extent25.png`: 25% through predicted z-extent preview
98
+ - `{case_id}_slice_extent75.png`: 75% through predicted z-extent preview
99
+ - `{case_id}_overlay.mp4`: scroll-through overlay video
100
+
101
+ ## Supported Cancer Types
102
+
103
+ | `--cancer_type` | Dataset | Window level | Window width |
104
+ |---------------------------|---------|-------------:|-------------:|
105
+ | kidney_cancer | Dataset102_Kidney | 40 | 400 |
106
+ | liver_cancer | Dataset103_Liver | 40 | 400 |
107
+ | pancreatic_cancer | Dataset104_Pancreas | 40 | 400 |
108
+ | lung_cancer | Dataset105_Lung | -600 | 1500 |
109
+
110
+ ## Example Output
111
+
112
+ The `example/` folder contains sample output from running the kidney cancer model on a validation case (`FLARE23Ts_0005`), including slice PNGs, an overlay video, and the segmentation mask.
113
+
114
+ ## Troubleshooting
115
+
116
+ CUDA unavailable: run with `--device cpu` or install CUDA-enabled PyTorch.
117
+
118
+ Missing checkpoint: check that `--model_dir` points to the directory containing the `DatasetXXX_*` model folders and that `fold_0/checkpoint_best.pth` exists.
119
+
120
+ Missing custom trainer: make sure the cloned repository still has this layout:
121
+
122
+ ```text
123
+ PanCancerSeg-Inference/
124
+ |-- predict.py
125
+ `-- trainers/
126
+ `-- nnUNetTrainerWandb2000.py
127
+ ```
128
+
129
+ Do not move the `trainers/` directory out of the repository. `predict.py` automatically registers the trainer with nnUNet when inference starts.
130
+
131
+ MP4 video creation failed: the segmentation mask and PNG files may still have been created. Try running the command on another machine or ask technical support to install video support for OpenCV.
app.py ADDED
@@ -0,0 +1,264 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Gradio UI for PanCancerSeg single-case CT tumour segmentation."""
2
+
3
+ import shutil
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import gradio as gr
8
+
9
+ from predict import (
10
+ CANCER_CONFIGS,
11
+ install_custom_trainer,
12
+ resolve_case_id,
13
+ resolve_model_folder,
14
+ run_nnunet_prediction,
15
+ summarize_segmentation,
16
+ )
17
+ from visualize import generate_outputs
18
+
19
+ # ── Constants ──────────────────────────────────────────────────────────────────
20
+
21
+ CANCER_TYPE_CHOICES = {
22
+ "Kidney Cancer": "kidney_cancer",
23
+ "Liver Cancer": "liver_cancer",
24
+ "Pancreatic Cancer": "pancreatic_cancer",
25
+ "Lung Cancer": "lung_cancer",
26
+ }
27
+
28
+ DEFAULT_MODEL_DIR = str(Path(__file__).parent / "PanCancerSeg-Specialized-weights")
29
+ DEFAULT_DEVICE = "cuda"
30
+
31
+ # Hugging Face Hub repo that hosts the trained nnUNet weights. On Spaces (where the
32
+ # local weights folder is absent) we download them on first use.
33
+ MODEL_REPO_ID = "KS987/PanCancerSeg-Specialized-weights"
34
+
35
+
36
+ def resolve_weights_dir() -> Path:
37
+ """Return a directory containing the DatasetXXX_* model folders.
38
+
39
+ Prefer a local checkout (fast local dev); otherwise download the weights
40
+ from the Hugging Face Hub and cache them.
41
+ """
42
+ local_dir = Path(DEFAULT_MODEL_DIR).expanduser().resolve()
43
+ if local_dir.exists() and any(local_dir.glob("Dataset*")):
44
+ return local_dir
45
+
46
+ from huggingface_hub import snapshot_download
47
+
48
+ downloaded = snapshot_download(
49
+ repo_id=MODEL_REPO_ID,
50
+ repo_type="model",
51
+ allow_patterns=["Dataset*/**"],
52
+ )
53
+ return Path(downloaded)
54
+
55
+ _SAMPLE_DIR = Path(__file__).parent / "sample_input"
56
+ _CANCER_TYPE_TO_FOLDER = {
57
+ "Kidney Cancer": "kidney",
58
+ "Liver Cancer": "liver",
59
+ "Pancreatic Cancer": "pancreas",
60
+ "Lung Cancer": "lung",
61
+ }
62
+
63
+ def load_example(cancer_type_label: str, index: int) -> str:
64
+ """Return the index-th (1-based) example _0000.nii.gz for the given cancer type."""
65
+ folder = _SAMPLE_DIR / _CANCER_TYPE_TO_FOLDER[cancer_type_label]
66
+ files = sorted(folder.glob("*_0000.nii.gz"))
67
+ if len(files) < index:
68
+ raise gr.Error(f"Example {index} not found for {cancer_type_label} in {folder}")
69
+ return str(files[index - 1])
70
+
71
+
72
+ # ── Inference ──────────────────────────────────────────────────────────────────
73
+
74
+ def run_inference(
75
+ input_file,
76
+ cancer_type_label,
77
+ fps,
78
+ progress=gr.Progress(track_tqdm=True),
79
+ ):
80
+ import torch
81
+
82
+ if input_file is None:
83
+ raise gr.Error("Please upload a .nii.gz CT image first.")
84
+
85
+ input_path = Path(input_file)
86
+ if not input_path.name.endswith(".nii.gz"):
87
+ raise gr.Error(f"File must be .nii.gz format. Got: {input_path.name}")
88
+
89
+ device = DEFAULT_DEVICE if torch.cuda.is_available() else "cpu"
90
+
91
+ progress(0.02, desc="Resolving model weights...")
92
+ try:
93
+ model_dir_path = resolve_weights_dir()
94
+ except Exception as e:
95
+ raise gr.Error(f"Failed to obtain model weights from '{MODEL_REPO_ID}': {e}")
96
+
97
+ cancer_key = CANCER_TYPE_CHOICES[cancer_type_label]
98
+ config = CANCER_CONFIGS[cancer_key]
99
+ case_id = resolve_case_id(input_path)
100
+
101
+ progress(0.05, desc="Installing custom trainer...")
102
+ install_custom_trainer()
103
+
104
+ progress(0.10, desc="Loading model weights...")
105
+ model_folder = resolve_model_folder(model_dir_path, config["dataset_name"])
106
+
107
+ output_dir = Path(tempfile.mkdtemp(prefix="pancancerseg_out_"))
108
+
109
+ try:
110
+ with tempfile.TemporaryDirectory(prefix="pancancerseg_in_") as tmp:
111
+ tmp_path = Path(tmp)
112
+ tmp_input_dir = tmp_path / "input"
113
+ tmp_output_dir = tmp_path / "prediction"
114
+ tmp_input_dir.mkdir()
115
+ tmp_output_dir.mkdir()
116
+
117
+ nnunet_input = tmp_input_dir / f"{case_id}_0000.nii.gz"
118
+ try:
119
+ nnunet_input.symlink_to(input_path.resolve())
120
+ except (OSError, NotImplementedError):
121
+ shutil.copy2(input_path, nnunet_input)
122
+
123
+ progress(0.20, desc="Running nnUNet inference (this may take a few minutes)...")
124
+ run_nnunet_prediction(
125
+ model_folder=model_folder,
126
+ input_dir=tmp_input_dir,
127
+ output_dir=tmp_output_dir,
128
+ device=device,
129
+ )
130
+
131
+ raw_seg = tmp_output_dir / f"{case_id}.nii.gz"
132
+ if not raw_seg.exists():
133
+ produced = [p.name for p in tmp_output_dir.glob("*.nii.gz")]
134
+ raise RuntimeError(
135
+ f"nnUNet did not produce the expected segmentation. Found: {produced}"
136
+ )
137
+
138
+ seg_path = output_dir / f"{case_id}_seg.nii.gz"
139
+ shutil.copy2(raw_seg, seg_path)
140
+
141
+ progress(0.80, desc="Generating slice images and overlay video...")
142
+ viz = generate_outputs(
143
+ image_path=input_path,
144
+ mask_path=seg_path,
145
+ output_dir=output_dir,
146
+ case_name=case_id,
147
+ cancer_type=config["display_name"],
148
+ wl=config["wl"],
149
+ ww=config["ww"],
150
+ color=config["color"],
151
+ alpha=0.5,
152
+ fps=int(fps),
153
+ )
154
+
155
+ progress(0.95, desc="Computing tumour volume...")
156
+ positive_voxels, tumor_volume_ml = summarize_segmentation(seg_path)
157
+
158
+ stats = (
159
+ f"Case ID : {case_id}\n"
160
+ f"Cancer type : {config['display_name']}\n"
161
+ f"Positive voxels: {positive_voxels:,}\n"
162
+ f"Tumour volume : {tumor_volume_ml:.3f} mL"
163
+ )
164
+
165
+ slices = viz["slices"]
166
+ video_path = viz["video"]
167
+ video_out = (
168
+ str(video_path)
169
+ if video_path.exists() and video_path.stat().st_size > 0
170
+ else None
171
+ )
172
+
173
+ progress(1.0, desc="Done!")
174
+ return (
175
+ stats,
176
+ str(seg_path),
177
+ str(slices.get("centroid")),
178
+ str(slices.get("max_area")),
179
+ str(slices.get("extent25")),
180
+ str(slices.get("extent75")),
181
+ video_out,
182
+ )
183
+
184
+ except Exception as e:
185
+ shutil.rmtree(output_dir, ignore_errors=True)
186
+ raise gr.Error(str(e))
187
+
188
+
189
+ # ── UI ─────────────────────────────────────────────────────────────────────────
190
+
191
+ def build_ui():
192
+ with gr.Blocks(title="PanCancerSeg Inference") as demo:
193
+ gr.Markdown(
194
+ """
195
+ # PanCancerSeg — Specialist CT Tumour Segmentation
196
+ Upload a `.nii.gz` CT image, select the cancer type, and click **Run Inference** to obtain
197
+ a segmentation mask and visualisations.
198
+ """
199
+ )
200
+
201
+ with gr.Row():
202
+ # ── Left panel: inputs ─────────────────────────────────────────────
203
+ with gr.Column(scale=1, min_width=300):
204
+ input_file = gr.File(
205
+ label="CT Image (.nii.gz)",
206
+ file_types=[".gz"],
207
+ )
208
+ cancer_type = gr.Dropdown(
209
+ choices=list(CANCER_TYPE_CHOICES.keys()),
210
+ value="Kidney Cancer",
211
+ label="Cancer Type",
212
+ )
213
+ fps = gr.Slider(
214
+ minimum=1,
215
+ maximum=30,
216
+ value=10,
217
+ step=1,
218
+ label="Video FPS",
219
+ )
220
+ with gr.Row():
221
+ load_btn_1 = gr.Button("Load Example 1", size="lg")
222
+ load_btn_2 = gr.Button("Load Example 2", size="lg")
223
+ run_btn = gr.Button("Run Inference", variant="primary", size="lg")
224
+ video_out = gr.Video(label="Overlay Video")
225
+
226
+ # ── Right panel: outputs ───────────────────────────────────────────
227
+ with gr.Column(scale=2):
228
+ with gr.Row():
229
+ stats_box = gr.Textbox(
230
+ label="Inference Summary",
231
+ lines=4,
232
+ interactive=False,
233
+ )
234
+ seg_file = gr.File(label="Download Segmentation Mask (.nii.gz)")
235
+ with gr.Row():
236
+ img_centroid = gr.Image(label="Centroid Slice", type="filepath")
237
+ img_max_area = gr.Image(label="Max Area Slice", type="filepath")
238
+ with gr.Row():
239
+ img_ext25 = gr.Image(label="Extent 25% Slice", type="filepath")
240
+ img_ext75 = gr.Image(label="Extent 75% Slice", type="filepath")
241
+
242
+ load_btn_1.click(fn=lambda ct: load_example(ct, 1), inputs=[cancer_type], outputs=[input_file])
243
+ load_btn_2.click(fn=lambda ct: load_example(ct, 2), inputs=[cancer_type], outputs=[input_file])
244
+
245
+ run_btn.click(
246
+ fn=run_inference,
247
+ inputs=[input_file, cancer_type, fps],
248
+ outputs=[
249
+ stats_box,
250
+ seg_file,
251
+ img_centroid,
252
+ img_max_area,
253
+ img_ext25,
254
+ img_ext75,
255
+ video_out,
256
+ ],
257
+ )
258
+
259
+ return demo
260
+
261
+
262
+ if __name__ == "__main__":
263
+ demo = build_ui()
264
+ demo.launch( server_name="0.0.0.0", server_port=7869, share=False, theme=gr.themes.Soft())
example/FLARE23Ts_0005_overlay.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:43ebf31d8273a196239094c346237a5d625321de5c910a16efb03573c2528329
3
+ size 636620
example/FLARE23Ts_0005_seg.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:43f517d68c958000fbb62434af187237fdef9485c1178ba5765fe9419d894fa5
3
+ size 35795
example/FLARE23Ts_0005_slice_centroid.png ADDED

Git LFS Details

  • SHA256: eec8dce076929ded1583f908795f12540734b1df675c46d14f39ce46cfcad37a
  • Pointer size: 131 Bytes
  • Size of remote file: 221 kB
example/FLARE23Ts_0005_slice_extent25.png ADDED

Git LFS Details

  • SHA256: 46eac4ec5d8d726cc57c34eed37a97910af1b79537e0cb66f266dac4a0ea4360
  • Pointer size: 131 Bytes
  • Size of remote file: 222 kB
example/FLARE23Ts_0005_slice_extent75.png ADDED

Git LFS Details

  • SHA256: f9059660f4e40526c91e25e566a3b7893dbd4e3e5fc1885fb0428c8d7fabf954
  • Pointer size: 131 Bytes
  • Size of remote file: 270 kB
example/FLARE23Ts_0005_slice_max_area.png ADDED

Git LFS Details

  • SHA256: b0a75f3bf558de3c47d3cdf167a0d0cd16b9768659c667b6e83379d942d85f5f
  • Pointer size: 131 Bytes
  • Size of remote file: 228 kB
predict.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run single-case PanCancerSeg nnUNet CT inference and visualization."""
2
+
3
+ import argparse
4
+ import shutil
5
+ import tempfile
6
+ from pathlib import Path
7
+
8
+ import numpy as np
9
+ import SimpleITK as sitk
10
+ import torch
11
+
12
+ from visualize import generate_outputs
13
+
14
+
15
+ CANCER_CONFIGS = {
16
+ "kidney_cancer": {
17
+ "dataset_id": 102,
18
+ "dataset_name": "Dataset102_Kidney",
19
+ "display_name": "Kidney cancer",
20
+ "wl": 40,
21
+ "ww": 400,
22
+ "color": (255, 0, 0),
23
+ },
24
+ "liver_cancer": {
25
+ "dataset_id": 103,
26
+ "dataset_name": "Dataset103_Liver",
27
+ "display_name": "Liver cancer",
28
+ "wl": 40,
29
+ "ww": 400,
30
+ "color": (255, 0, 0),
31
+ },
32
+ "pancreatic_cancer": {
33
+ "dataset_id": 104,
34
+ "dataset_name": "Dataset104_Pancreas",
35
+ "display_name": "Pancreatic cancer",
36
+ "wl": 40,
37
+ "ww": 400,
38
+ "color": (255, 0, 0),
39
+ },
40
+ "lung_cancer": {
41
+ "dataset_id": 105,
42
+ "dataset_name": "Dataset105_Lung",
43
+ "display_name": "Lung cancer",
44
+ "wl": -600,
45
+ "ww": 1500,
46
+ "color": (255, 0, 0),
47
+ },
48
+ }
49
+
50
+ CANCER_TYPE_ALIASES = {
51
+ "kidney": "kidney_cancer",
52
+ "liver": "liver_cancer",
53
+ "pancreas": "pancreatic_cancer",
54
+ "lung": "lung_cancer",
55
+ }
56
+
57
+ TRAINER_NAME = "nnUNetTrainerWandb2000"
58
+ PLANS_NAME = "nnUNetResEncUNetMPlans"
59
+ CONFIGURATION = "3d_fullres"
60
+ CHECKPOINT_NAME = "checkpoint_best.pth"
61
+
62
+
63
+ def parse_args():
64
+ parser = argparse.ArgumentParser(
65
+ description="Run one PanCancerSeg cancer-specific nnUNet model on a single NIfTI image."
66
+ )
67
+ parser.add_argument("--input", required=True, help="Path to a single .nii.gz image")
68
+ parser.add_argument(
69
+ "--cancer_type",
70
+ required=True,
71
+ help=(
72
+ "Cancer-specific model to use. "
73
+ f"Canonical values: {', '.join(sorted(CANCER_CONFIGS))}. "
74
+ f"Legacy aliases still accepted: {', '.join(sorted(CANCER_TYPE_ALIASES))}."
75
+ ),
76
+ )
77
+ parser.add_argument(
78
+ "--model_dir",
79
+ required=True,
80
+ help="Path to nnUNet results directory containing DatasetXXX_* folders",
81
+ )
82
+ parser.add_argument("--output_dir", default="./output", help="Where to save results")
83
+ parser.add_argument("--fps", type=int, default=10, help="Video frames per second")
84
+ parser.add_argument("--device", choices=["cuda", "cpu"], default="cuda")
85
+ return parser.parse_args()
86
+
87
+
88
+ def main():
89
+ args = parse_args()
90
+ args.cancer_type = normalize_cancer_type(args.cancer_type)
91
+ input_path = Path(args.input).expanduser().resolve()
92
+ model_dir = Path(args.model_dir).expanduser().resolve()
93
+ output_dir = Path(args.output_dir).expanduser().resolve()
94
+
95
+ if not input_path.exists():
96
+ raise FileNotFoundError(f"Input image not found: {input_path}")
97
+ if input_path.name.startswith("._") or not input_path.name.endswith(".nii.gz"):
98
+ raise ValueError(f"Expected a .nii.gz image, got: {input_path.name}")
99
+ if not model_dir.exists():
100
+ raise FileNotFoundError(f"Model directory not found: {model_dir}")
101
+ if args.device == "cuda" and not torch.cuda.is_available():
102
+ raise RuntimeError(
103
+ "CUDA was requested but torch.cuda.is_available() is False. "
104
+ "Use --device cpu or install CUDA-enabled PyTorch."
105
+ )
106
+ if args.fps <= 0:
107
+ raise ValueError("--fps must be a positive integer")
108
+
109
+ output_dir.mkdir(parents=True, exist_ok=True)
110
+ config = CANCER_CONFIGS[args.cancer_type]
111
+ case_id = resolve_case_id(input_path)
112
+
113
+ install_custom_trainer()
114
+ model_folder = resolve_model_folder(model_dir, config["dataset_name"])
115
+
116
+ with tempfile.TemporaryDirectory(prefix="pancancerseg_") as tmp:
117
+ tmp_path = Path(tmp)
118
+ tmp_input_dir = tmp_path / "input"
119
+ tmp_output_dir = tmp_path / "prediction"
120
+ tmp_input_dir.mkdir()
121
+ tmp_output_dir.mkdir()
122
+
123
+ nnunet_input = tmp_input_dir / f"{case_id}_0000.nii.gz"
124
+ symlink_or_copy(input_path, nnunet_input)
125
+
126
+ run_nnunet_prediction(
127
+ model_folder=model_folder,
128
+ input_dir=tmp_input_dir,
129
+ output_dir=tmp_output_dir,
130
+ device=args.device,
131
+ )
132
+
133
+ raw_seg = tmp_output_dir / f"{case_id}.nii.gz"
134
+ if not raw_seg.exists():
135
+ produced = sorted(tmp_output_dir.glob("*.nii.gz"))
136
+ raise FileNotFoundError(
137
+ f"nnUNet did not write the expected segmentation {raw_seg}. "
138
+ f"Found: {[p.name for p in produced]}"
139
+ )
140
+
141
+ seg_path = output_dir / f"{case_id}_seg.nii.gz"
142
+ shutil.copy2(raw_seg, seg_path)
143
+
144
+ viz_outputs = generate_outputs(
145
+ image_path=input_path,
146
+ mask_path=seg_path,
147
+ output_dir=output_dir,
148
+ case_name=case_id,
149
+ cancer_type=config["display_name"],
150
+ wl=config["wl"],
151
+ ww=config["ww"],
152
+ color=config["color"],
153
+ alpha=0.5,
154
+ fps=args.fps,
155
+ )
156
+
157
+ positive_voxels, tumor_volume_ml = summarize_segmentation(seg_path)
158
+ print_summary(seg_path, viz_outputs, positive_voxels, tumor_volume_ml)
159
+
160
+
161
+ def resolve_case_id(input_path):
162
+ name = input_path.name
163
+ if not name.endswith(".nii.gz"):
164
+ raise ValueError(f"Expected a .nii.gz image, got: {name}")
165
+ case_id = name[: -len(".nii.gz")]
166
+ if case_id.endswith("_0000"):
167
+ case_id = case_id[: -len("_0000")]
168
+ if not case_id:
169
+ raise ValueError(f"Could not resolve a case ID from: {input_path}")
170
+ return case_id
171
+
172
+
173
+ def normalize_cancer_type(cancer_type):
174
+ cancer_type = cancer_type.strip().lower()
175
+ normalized = CANCER_TYPE_ALIASES.get(cancer_type, cancer_type)
176
+ if normalized not in CANCER_CONFIGS:
177
+ valid = sorted(list(CANCER_CONFIGS) + list(CANCER_TYPE_ALIASES))
178
+ raise ValueError(
179
+ f"Unsupported --cancer_type '{cancer_type}'. Valid values: {', '.join(valid)}"
180
+ )
181
+ return normalized
182
+
183
+
184
+ def install_custom_trainer():
185
+ import nnunetv2
186
+
187
+ src = Path(__file__).resolve().parent / "trainers" / f"{TRAINER_NAME}.py"
188
+ if not src.exists():
189
+ raise FileNotFoundError(f"Custom trainer file is missing: {src}")
190
+
191
+ variants_dir = Path(nnunetv2.__path__[0]) / "training" / "nnUNetTrainer" / "variants"
192
+ variants_dir.mkdir(parents=True, exist_ok=True)
193
+ dst = variants_dir / src.name
194
+
195
+ if dst.exists() or dst.is_symlink():
196
+ try:
197
+ if dst.resolve() == src.resolve():
198
+ return dst
199
+ except OSError:
200
+ pass
201
+ dst.unlink()
202
+
203
+ try:
204
+ dst.symlink_to(src.resolve())
205
+ except (OSError, NotImplementedError):
206
+ shutil.copy2(src, dst)
207
+ print(f"Installed custom trainer: {dst}")
208
+ return dst
209
+
210
+
211
+ def resolve_model_folder(model_dir, dataset_name):
212
+ model_folder = (
213
+ model_dir
214
+ / dataset_name
215
+ / f"{TRAINER_NAME}__{PLANS_NAME}__{CONFIGURATION}"
216
+ )
217
+ checkpoint = model_folder / "fold_0" / CHECKPOINT_NAME
218
+ if not checkpoint.exists():
219
+ raise FileNotFoundError(
220
+ f"Expected checkpoint not found: {checkpoint}. "
221
+ "Check --model_dir and make sure the trained weights are downloaded."
222
+ )
223
+ return model_folder
224
+
225
+
226
+ def symlink_or_copy(src, dst):
227
+ try:
228
+ dst.symlink_to(src.resolve())
229
+ except (OSError, NotImplementedError):
230
+ shutil.copy2(src, dst)
231
+
232
+
233
+ def run_nnunet_prediction(model_folder, input_dir, output_dir, device):
234
+ from nnunetv2.inference.predict_from_raw_data import nnUNetPredictor
235
+
236
+ predictor = nnUNetPredictor(
237
+ tile_step_size=0.5,
238
+ use_gaussian=True,
239
+ use_mirroring=False,
240
+ perform_everything_on_device=(device == "cuda"),
241
+ device=torch.device(device),
242
+ verbose=False,
243
+ verbose_preprocessing=False,
244
+ allow_tqdm=True,
245
+ )
246
+ predictor.initialize_from_trained_model_folder(
247
+ str(model_folder),
248
+ use_folds=(0,),
249
+ checkpoint_name=CHECKPOINT_NAME,
250
+ )
251
+ predictor.predict_from_files(
252
+ str(input_dir),
253
+ str(output_dir),
254
+ save_probabilities=False,
255
+ overwrite=True,
256
+ num_processes_preprocessing=1,
257
+ num_processes_segmentation_export=1,
258
+ folder_with_segs_from_prev_stage=None,
259
+ num_parts=1,
260
+ part_id=0,
261
+ )
262
+
263
+
264
+ def summarize_segmentation(seg_path):
265
+ seg = sitk.ReadImage(str(seg_path))
266
+ seg_arr = sitk.GetArrayFromImage(seg)
267
+ positive_voxels = int(np.count_nonzero(seg_arr))
268
+ spacing_x, spacing_y, spacing_z = seg.GetSpacing()
269
+ tumor_volume_ml = positive_voxels * spacing_x * spacing_y * spacing_z / 1000.0
270
+ return positive_voxels, tumor_volume_ml
271
+
272
+
273
+ def print_summary(seg_path, viz_outputs, positive_voxels, tumor_volume_ml):
274
+ print("\nPanCancerSeg inference complete")
275
+ print(f"Segmentation mask : {seg_path}")
276
+ print("Slice PNGs :")
277
+ for label, path in viz_outputs["slices"].items():
278
+ print(f" {label:9s} : {path}")
279
+ print(f"Overlay video : {viz_outputs['video']}")
280
+ print(f"Positive voxels : {positive_voxels}")
281
+ print(f"Tumor volume : {tumor_volume_ml:.3f} mL")
282
+
283
+
284
+ if __name__ == "__main__":
285
+ main()
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ nnunetv2>=2.6
2
+ torch>=2.0
3
+ nibabel>=5.0
4
+ numpy
5
+ opencv-python>=4.8
6
+ matplotlib>=3.7
7
+ scipy
8
+ SimpleITK>=2.3
9
+ wandb
10
+ huggingface_hub>=1.18.0
11
+ gradio>=6.12.0
sample_input/kidney/FLARE_02510.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4ec4a227cdc87e12dafefef8b9e30d50ba1e9cad4009e85f800f86a389bdf578
3
+ size 179654
sample_input/kidney/FLARE_02510_0000.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d940ad9a3d755805da08b1b4786f23caa98682772081d9376b5a3ecb69ab3a02
3
+ size 67890233
sample_input/kidney/FLARE_10805.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:657dec731a6ea6999a363b2fa76d90002396355f0084b71b3610d3633f9f4a08
3
+ size 128226
sample_input/kidney/FLARE_10805_0000.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ac97bf77bb7f9f7ba8529acccc7c1b70b5deafc18843ef9d24c0f5582bf40327
3
+ size 31061757
sample_input/liver/FLARE_04886.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4df10362b993deb3212a5237a80d1f401e7597ca37857a2e8fbe9fcd4b92aaf5
3
+ size 280798
sample_input/liver/FLARE_04886_0000.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1e278b9fe65f07e64e082483628ab3d67dc8600fed7f4800ba2b3660cc8e072f
3
+ size 60722851
sample_input/liver/FLARE_04936.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8b9ff3330033547c791cec7404f8f25a995c73f4dbd83a2fbdcd950680425e00
3
+ size 169617
sample_input/liver/FLARE_04936_0000.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:188c7a53f915b75683832c502971b6a6a1c0e906b62f6be4cbe7a95d6eb3a8e0
3
+ size 37128731
sample_input/lung/FLARE_00673.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7c68081f35c440226d643ed84c2c0cbb7b3a5ade47994a431bb25d3368555522
3
+ size 124957
sample_input/lung/FLARE_00673_0000.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:80133f3f2b1050104e5c9648e74f5f9b3e2f17a3161c4da929c3d4c053ee82a2
3
+ size 32107618
sample_input/lung/FLARE_01254.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f2d83988abaade63a312d685dbbb8d8ee80b6aba2d4f182bcf2b1caadb498809
3
+ size 113595
sample_input/lung/FLARE_01254_0000.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bfd866b4946d1f5c02f40e5afb5c0b5014d5a97686624a2af7c6a27f7fcf6589
3
+ size 26016284
sample_input/pancreas/FLARE_04341.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2d3322995071231be35a04d4c093a8d694467c1e11d0c1b1da5367e297fbf635
3
+ size 133192
sample_input/pancreas/FLARE_04341_0000.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0dceb0ba212fa70af1aae56e46d7732b85590bb68872ad61845995777aefe501
3
+ size 45721827
sample_input/pancreas/FLARE_04346.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b5fa43f03e86dbd41eee6b9133f2916dadc8f21ec5b5be406a014dd91a4f16b3
3
+ size 123189
sample_input/pancreas/FLARE_04346_0000.nii.gz ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e0a99d97a4a68b6b0d4890051e23c380cb0b9f996bc312f01456c194f7ae6c10
3
+ size 35878370
trainers/nnUNetTrainerWandb2000.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """nnUNet trainer with Weights & Biases logging and 2000 epochs.
2
+
3
+ Place this file (or symlink it) into the nnUNet trainer variants directory
4
+ so that nnUNet can discover it via the -tr flag:
5
+
6
+ VARIANTS_DIR=$(python -c "import nnunetv2; print(nnunetv2.__path__[0])")/training/nnUNetTrainer/variants
7
+ ln -sf $(realpath nnUNetTrainerWandb2000.py) "$VARIANTS_DIR/nnUNetTrainerWandb2000.py"
8
+
9
+ Then train with:
10
+ nnUNetv2_train DATASET CONFIG FOLD -tr nnUNetTrainerWandb2000 ...
11
+ """
12
+
13
+ import os
14
+
15
+ import torch
16
+ import wandb
17
+ from nnunetv2.training.nnUNetTrainer.nnUNetTrainer import nnUNetTrainer
18
+
19
+
20
+ class nnUNetTrainerWandb2000(nnUNetTrainer):
21
+ def __init__(self, plans: dict, configuration: str, fold: int,
22
+ dataset_json: dict,
23
+ device: torch.device = torch.device("cuda")):
24
+ super().__init__(plans, configuration, fold, dataset_json, device)
25
+ self.num_epochs = 2000
26
+
27
+ def on_train_start(self):
28
+ super().on_train_start()
29
+ wandb.init(
30
+ project=os.environ.get("WANDB_PROJECT", "CVPR2026-PanCancerSeg"),
31
+ name=f"{self.plans_manager.dataset_name}_fold{self.fold}",
32
+ config={
33
+ "dataset": self.plans_manager.dataset_name,
34
+ "configuration": self.configuration_name,
35
+ "fold": self.fold,
36
+ "num_epochs": self.num_epochs,
37
+ "batch_size": self.batch_size,
38
+ "patch_size": list(self.configuration_manager.patch_size),
39
+ },
40
+ resume="allow",
41
+ )
42
+
43
+ def on_epoch_end(self):
44
+ super().on_epoch_end()
45
+
46
+ # Save periodic checkpoint every 200 epochs after epoch 1000
47
+ if self.current_epoch > 1000 and self.current_epoch % 200 == 0:
48
+ self.save_checkpoint(
49
+ os.path.join(self.output_folder, f"checkpoint_epoch{self.current_epoch}.pth")
50
+ )
51
+
52
+ logs = self.logger.my_fantastic_logging
53
+ metrics = {"epoch": self.current_epoch}
54
+ if logs["train_losses"]:
55
+ metrics["train_loss"] = logs["train_losses"][-1]
56
+ if logs["val_losses"]:
57
+ metrics["val_loss"] = logs["val_losses"][-1]
58
+ if logs["ema_fg_dice"]:
59
+ metrics["ema_fg_dice"] = logs["ema_fg_dice"][-1]
60
+ if logs["dice_per_class_or_region"]:
61
+ latest = logs["dice_per_class_or_region"][-1]
62
+ for i, d in enumerate(latest):
63
+ metrics[f"dice_class_{i}"] = d
64
+ metrics["learning_rate"] = self.optimizer.param_groups[0]["lr"]
65
+ metrics["epoch_time"] = self.logger.my_fantastic_logging.get(
66
+ "epoch_end_timestamps", [0])[-1] - self.logger.my_fantastic_logging.get(
67
+ "epoch_start_timestamps", [0])[-1]
68
+ wandb.log(metrics, step=self.current_epoch)
69
+
70
+ def on_train_end(self):
71
+ super().on_train_end()
72
+ wandb.finish()
visualize.py ADDED
@@ -0,0 +1,287 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visualization helpers for single-case PanCancerSeg inference."""
2
+
3
+ from pathlib import Path
4
+
5
+ import cv2
6
+ import numpy as np
7
+ import SimpleITK as sitk
8
+
9
+ import matplotlib
10
+
11
+ matplotlib.use("Agg")
12
+ import matplotlib.pyplot as plt
13
+
14
+
15
+ DEFAULT_OVERLAY_COLOR = (255, 0, 0)
16
+
17
+
18
+ def preprocess_volume(volume, wl, ww):
19
+ """Apply CT windowing and return uint8 data in [0, 255]."""
20
+ volume = volume.astype(np.float32, copy=False)
21
+ lower_bound = wl - ww / 2
22
+ upper_bound = wl + ww / 2
23
+ clipped = np.clip(volume, lower_bound, upper_bound)
24
+ return _normalize_to_uint8(clipped)
25
+
26
+
27
+ def overlay_mask(gray_slice, mask_slice, color=DEFAULT_OVERLAY_COLOR, alpha=0.5):
28
+ """Apply a semi-transparent RGB overlay to one grayscale slice."""
29
+ gray_slice = np.asarray(gray_slice, dtype=np.uint8)
30
+ if gray_slice.ndim != 2:
31
+ raise ValueError(f"Expected a 2D grayscale slice, got shape {gray_slice.shape}")
32
+
33
+ rgb = np.stack([gray_slice] * 3, axis=-1)
34
+ mask = mask_slice > 0
35
+ if not np.any(mask):
36
+ return rgb
37
+
38
+ out = rgb.copy()
39
+ color_arr = np.asarray(color, dtype=np.float32)
40
+ blended = out[mask].astype(np.float32) * (1 - alpha) + color_arr * alpha
41
+ out[mask] = np.clip(blended, 0, 255).astype(np.uint8)
42
+ return out
43
+
44
+
45
+ def find_key_slices(mask_vol):
46
+ """Return named representative z-slices for a mask in [z, y, x] order."""
47
+ if mask_vol.ndim != 3:
48
+ raise ValueError(f"Expected a 3D mask volume, got shape {mask_vol.shape}")
49
+
50
+ depth = mask_vol.shape[0]
51
+ if depth == 0:
52
+ raise ValueError("Cannot select key slices from an empty z-dimension")
53
+
54
+ mask = mask_vol > 0
55
+ if np.any(mask):
56
+ z_indices = np.where(np.any(mask, axis=(1, 2)))[0]
57
+ areas = mask.reshape(depth, -1).sum(axis=1)
58
+ coords = np.argwhere(mask)
59
+ centroid_z = int(round(float(coords[:, 0].mean())))
60
+ min_z = int(z_indices.min())
61
+ max_z = int(z_indices.max())
62
+ return {
63
+ "centroid": _clip_slice(centroid_z, depth),
64
+ "max_area": int(areas.argmax()),
65
+ "extent25": _clip_slice(round(min_z + 0.25 * (max_z - min_z)), depth),
66
+ "extent75": _clip_slice(round(min_z + 0.75 * (max_z - min_z)), depth),
67
+ }
68
+
69
+ middle = depth // 2
70
+ offset = max(1, depth // 10)
71
+ return {
72
+ "centroid": middle,
73
+ "max_area": _clip_slice(middle - offset, depth),
74
+ "extent25": _clip_slice(middle + offset, depth),
75
+ "extent75": _clip_slice(middle + 2 * offset, depth),
76
+ }
77
+
78
+
79
+ def generate_slice_images(
80
+ image_uint8,
81
+ mask_vol,
82
+ output_dir,
83
+ case_name,
84
+ color=DEFAULT_OVERLAY_COLOR,
85
+ alpha=0.5,
86
+ ):
87
+ """Save side-by-side PNGs for representative slices."""
88
+ output_dir = Path(output_dir)
89
+ output_dir.mkdir(parents=True, exist_ok=True)
90
+
91
+ key_slices = find_key_slices(mask_vol)
92
+ outputs = {}
93
+
94
+ for label, z_idx in key_slices.items():
95
+ gray_slice = image_uint8[z_idx]
96
+ mask_slice = mask_vol[z_idx] > 0
97
+ overlay = overlay_mask(gray_slice, mask_slice, color=color, alpha=alpha)
98
+
99
+ fig, axes = plt.subplots(1, 2, figsize=(10, 5), dpi=150)
100
+ axes[0].imshow(gray_slice, cmap="gray", vmin=0, vmax=255)
101
+ axes[0].set_title("Image")
102
+ axes[0].axis("off")
103
+ axes[1].imshow(overlay)
104
+ axes[1].set_title("Segmentation overlay")
105
+ axes[1].axis("off")
106
+ fig.suptitle(f"{case_name} | z={z_idx}")
107
+ fig.tight_layout()
108
+
109
+ out_path = output_dir / f"{case_name}_slice_{label}.png"
110
+ fig.savefig(out_path, dpi=150, bbox_inches="tight", facecolor="white")
111
+ plt.close(fig)
112
+ outputs[label] = out_path
113
+
114
+ return outputs
115
+
116
+
117
+ def generate_video(
118
+ image_uint8,
119
+ mask_vol,
120
+ output_dir,
121
+ case_name,
122
+ cancer_type,
123
+ color=DEFAULT_OVERLAY_COLOR,
124
+ alpha=0.5,
125
+ fps=10,
126
+ ):
127
+ """Generate an MP4 scroll-through overlay video."""
128
+ output_dir = Path(output_dir)
129
+ output_dir.mkdir(parents=True, exist_ok=True)
130
+ video_path = output_dir / f"{case_name}_overlay.mp4"
131
+
132
+ start_z, end_z = _video_z_range(mask_vol)
133
+ first_frame = _make_video_frame(
134
+ image_uint8[start_z],
135
+ mask_vol[start_z],
136
+ color,
137
+ alpha,
138
+ start_z,
139
+ image_uint8.shape[0],
140
+ cancer_type,
141
+ )
142
+ height, width = first_frame.shape[:2]
143
+
144
+ writer = _open_video_writer(video_path, fps, width, height)
145
+ # Frame annotations are drawn in RGB space; convert only when writing to OpenCV.
146
+ writer.write(cv2.cvtColor(first_frame, cv2.COLOR_RGB2BGR))
147
+
148
+ for z_idx in range(start_z + 1, end_z + 1):
149
+ frame = _make_video_frame(
150
+ image_uint8[z_idx],
151
+ mask_vol[z_idx],
152
+ color,
153
+ alpha,
154
+ z_idx,
155
+ image_uint8.shape[0],
156
+ cancer_type,
157
+ )
158
+ writer.write(cv2.cvtColor(frame, cv2.COLOR_RGB2BGR))
159
+
160
+ writer.release()
161
+ return video_path
162
+
163
+
164
+ def generate_outputs(
165
+ image_path,
166
+ mask_path,
167
+ output_dir,
168
+ case_name,
169
+ cancer_type,
170
+ wl,
171
+ ww,
172
+ color=DEFAULT_OVERLAY_COLOR,
173
+ alpha=0.5,
174
+ fps=10,
175
+ ):
176
+ """Read image and mask volumes, then write PNG previews and MP4 video."""
177
+ image = sitk.ReadImage(str(image_path))
178
+ mask = sitk.ReadImage(str(mask_path))
179
+ image_vol = sitk.GetArrayFromImage(image)
180
+ mask_vol = sitk.GetArrayFromImage(mask)
181
+
182
+ if image_vol.shape != mask_vol.shape:
183
+ raise ValueError(
184
+ "Image and segmentation shapes do not match: "
185
+ f"image={image_vol.shape}, segmentation={mask_vol.shape}. "
186
+ "Both arrays are expected in [z, y, x] order."
187
+ )
188
+
189
+ image_uint8 = preprocess_volume(image_vol, wl, ww)
190
+ slice_paths = generate_slice_images(
191
+ image_uint8,
192
+ mask_vol,
193
+ output_dir,
194
+ case_name,
195
+ color,
196
+ alpha,
197
+ )
198
+ video_path = generate_video(
199
+ image_uint8,
200
+ mask_vol,
201
+ output_dir,
202
+ case_name,
203
+ cancer_type,
204
+ color,
205
+ alpha,
206
+ fps,
207
+ )
208
+ return {"slices": slice_paths, "video": video_path}
209
+
210
+
211
+ def _normalize_to_uint8(volume):
212
+ v_min = float(np.min(volume))
213
+ v_max = float(np.max(volume))
214
+ if not np.isfinite(v_min) or not np.isfinite(v_max) or v_max <= v_min:
215
+ return np.zeros(volume.shape, dtype=np.uint8)
216
+ normalized = (volume - v_min) / (v_max - v_min) * 255.0
217
+ return np.clip(normalized, 0, 255).astype(np.uint8)
218
+
219
+
220
+ def _clip_slice(index, depth):
221
+ return int(np.clip(index, 0, depth - 1))
222
+
223
+
224
+ def _video_z_range(mask_vol, padding=10, empty_window=80):
225
+ depth = mask_vol.shape[0]
226
+ mask = mask_vol > 0
227
+ if np.any(mask):
228
+ z_indices = np.where(np.any(mask, axis=(1, 2)))[0]
229
+ return (
230
+ max(0, int(z_indices.min()) - padding),
231
+ min(depth - 1, int(z_indices.max()) + padding),
232
+ )
233
+
234
+ if depth <= empty_window:
235
+ return 0, depth - 1
236
+ middle = depth // 2
237
+ half = empty_window // 2
238
+ return max(0, middle - half), min(depth - 1, middle + half)
239
+
240
+
241
+ def _make_video_frame(gray_slice, mask_slice, color, alpha, z_idx, depth, cancer_type):
242
+ frame = overlay_mask(gray_slice, mask_slice, color=color, alpha=alpha)
243
+ frame = _upscale_if_small(frame)
244
+
245
+ annotation = f"Slice {z_idx + 1}/{depth} | {cancer_type}"
246
+ font = cv2.FONT_HERSHEY_SIMPLEX
247
+ font_scale = max(0.6, min(frame.shape[:2]) / 900)
248
+ thickness = max(1, int(round(font_scale * 2)))
249
+ text_size, baseline = cv2.getTextSize(annotation, font, font_scale, thickness)
250
+ x, y = 12, 12 + text_size[1]
251
+ cv2.rectangle(
252
+ frame,
253
+ (x - 6, y - text_size[1] - 6),
254
+ (x + text_size[0] + 6, y + baseline + 6),
255
+ (0, 0, 0),
256
+ thickness=-1,
257
+ )
258
+ cv2.putText(frame, annotation, (x, y), font, font_scale, (255, 255, 255), thickness, cv2.LINE_AA)
259
+ return frame
260
+
261
+
262
+ def _upscale_if_small(frame, min_short_side=512):
263
+ height, width = frame.shape[:2]
264
+ short_side = min(height, width)
265
+ if short_side >= min_short_side:
266
+ return frame
267
+ scale = min_short_side / short_side
268
+ new_size = (int(round(width * scale)), int(round(height * scale)))
269
+ return cv2.resize(frame, new_size, interpolation=cv2.INTER_LINEAR)
270
+
271
+
272
+ def _open_video_writer(video_path, fps, width, height):
273
+ attempts = [
274
+ ("avc1", "H.264/avc1"),
275
+ ("mp4v", "MPEG-4/mp4v"),
276
+ ]
277
+ for fourcc_text, label in attempts:
278
+ fourcc = cv2.VideoWriter_fourcc(*fourcc_text)
279
+ writer = cv2.VideoWriter(str(video_path), fourcc, float(fps), (width, height))
280
+ if writer.isOpened():
281
+ return writer
282
+ writer.release()
283
+ raise RuntimeError(
284
+ f"Could not open MP4 writer at {video_path}. Tried "
285
+ + ", ".join(label for _, label in attempts)
286
+ + ". Install an OpenCV build with MP4 codec support or try another machine."
287
+ )