junma commited on
Commit
3f59825
·
verified ·
1 Parent(s): 87c710f

Single-cancer Space: Liver (Dataset103) with bundled weights

Browse files
PanCancerSeg-Specialized-weights/Dataset103_Liver/nnUNetTrainerWandb2000__nnUNetResEncUNetMPlans__3d_fullres/dataset.json ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "channel_names": {
3
+ "0": "CT"
4
+ },
5
+ "labels": {
6
+ "background": 0,
7
+ "tumor": 1
8
+ },
9
+ "numTraining": 866,
10
+ "file_ending": ".nii.gz"
11
+ }
PanCancerSeg-Specialized-weights/Dataset103_Liver/nnUNetTrainerWandb2000__nnUNetResEncUNetMPlans__3d_fullres/dataset_fingerprint.json ADDED
The diff for this file is too large to render. See raw diff
 
PanCancerSeg-Specialized-weights/Dataset103_Liver/nnUNetTrainerWandb2000__nnUNetResEncUNetMPlans__3d_fullres/fold_0/checkpoint_best.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6a1a76774614414b508873fafd9e422422c83c09033f96d29b4d1b4d888035eb
3
+ size 816371779
PanCancerSeg-Specialized-weights/Dataset103_Liver/nnUNetTrainerWandb2000__nnUNetResEncUNetMPlans__3d_fullres/plans.json ADDED
@@ -0,0 +1,532 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "dataset_name": "Dataset103_Liver",
3
+ "plans_name": "nnUNetResEncUNetMPlans",
4
+ "original_median_spacing_after_transp": [
5
+ 2.5,
6
+ 0.775390625,
7
+ 0.775390625
8
+ ],
9
+ "original_median_shape_after_transp": [
10
+ 102,
11
+ 512,
12
+ 512
13
+ ],
14
+ "image_reader_writer": "SimpleITKIO",
15
+ "transpose_forward": [
16
+ 0,
17
+ 1,
18
+ 2
19
+ ],
20
+ "transpose_backward": [
21
+ 0,
22
+ 1,
23
+ 2
24
+ ],
25
+ "configurations": {
26
+ "2d": {
27
+ "data_identifier": "nnUNetPlans_2d",
28
+ "preprocessor_name": "DefaultPreprocessor",
29
+ "batch_size": 13,
30
+ "patch_size": [
31
+ 512,
32
+ 512
33
+ ],
34
+ "median_image_size_in_voxels": [
35
+ 512.0,
36
+ 512.0
37
+ ],
38
+ "spacing": [
39
+ 0.775390625,
40
+ 0.775390625
41
+ ],
42
+ "normalization_schemes": [
43
+ "CTNormalization"
44
+ ],
45
+ "use_mask_for_norm": [
46
+ false
47
+ ],
48
+ "resampling_fn_data": "resample_data_or_seg_to_shape",
49
+ "resampling_fn_seg": "resample_data_or_seg_to_shape",
50
+ "resampling_fn_data_kwargs": {
51
+ "is_seg": false,
52
+ "order": 3,
53
+ "order_z": 0,
54
+ "force_separate_z": null
55
+ },
56
+ "resampling_fn_seg_kwargs": {
57
+ "is_seg": true,
58
+ "order": 1,
59
+ "order_z": 0,
60
+ "force_separate_z": null
61
+ },
62
+ "resampling_fn_probabilities": "resample_data_or_seg_to_shape",
63
+ "resampling_fn_probabilities_kwargs": {
64
+ "is_seg": false,
65
+ "order": 1,
66
+ "order_z": 0,
67
+ "force_separate_z": null
68
+ },
69
+ "architecture": {
70
+ "network_class_name": "dynamic_network_architectures.architectures.unet.ResidualEncoderUNet",
71
+ "arch_kwargs": {
72
+ "n_stages": 8,
73
+ "features_per_stage": [
74
+ 32,
75
+ 64,
76
+ 128,
77
+ 256,
78
+ 512,
79
+ 512,
80
+ 512,
81
+ 512
82
+ ],
83
+ "conv_op": "torch.nn.modules.conv.Conv2d",
84
+ "kernel_sizes": [
85
+ [
86
+ 3,
87
+ 3
88
+ ],
89
+ [
90
+ 3,
91
+ 3
92
+ ],
93
+ [
94
+ 3,
95
+ 3
96
+ ],
97
+ [
98
+ 3,
99
+ 3
100
+ ],
101
+ [
102
+ 3,
103
+ 3
104
+ ],
105
+ [
106
+ 3,
107
+ 3
108
+ ],
109
+ [
110
+ 3,
111
+ 3
112
+ ],
113
+ [
114
+ 3,
115
+ 3
116
+ ]
117
+ ],
118
+ "strides": [
119
+ [
120
+ 1,
121
+ 1
122
+ ],
123
+ [
124
+ 2,
125
+ 2
126
+ ],
127
+ [
128
+ 2,
129
+ 2
130
+ ],
131
+ [
132
+ 2,
133
+ 2
134
+ ],
135
+ [
136
+ 2,
137
+ 2
138
+ ],
139
+ [
140
+ 2,
141
+ 2
142
+ ],
143
+ [
144
+ 2,
145
+ 2
146
+ ],
147
+ [
148
+ 2,
149
+ 2
150
+ ]
151
+ ],
152
+ "n_blocks_per_stage": [
153
+ 1,
154
+ 3,
155
+ 4,
156
+ 6,
157
+ 6,
158
+ 6,
159
+ 6,
160
+ 6
161
+ ],
162
+ "n_conv_per_stage_decoder": [
163
+ 1,
164
+ 1,
165
+ 1,
166
+ 1,
167
+ 1,
168
+ 1,
169
+ 1
170
+ ],
171
+ "conv_bias": true,
172
+ "norm_op": "torch.nn.modules.instancenorm.InstanceNorm2d",
173
+ "norm_op_kwargs": {
174
+ "eps": 1e-05,
175
+ "affine": true
176
+ },
177
+ "dropout_op": null,
178
+ "dropout_op_kwargs": null,
179
+ "nonlin": "torch.nn.LeakyReLU",
180
+ "nonlin_kwargs": {
181
+ "inplace": true
182
+ }
183
+ },
184
+ "_kw_requires_import": [
185
+ "conv_op",
186
+ "norm_op",
187
+ "dropout_op",
188
+ "nonlin"
189
+ ]
190
+ },
191
+ "batch_dice": true
192
+ },
193
+ "3d_lowres": {
194
+ "data_identifier": "nnUNetResEncUNetMPlans_3d_lowres",
195
+ "preprocessor_name": "DefaultPreprocessor",
196
+ "batch_size": 2,
197
+ "patch_size": [
198
+ 96,
199
+ 160,
200
+ 160
201
+ ],
202
+ "median_image_size_in_voxels": [
203
+ 135,
204
+ 267,
205
+ 267
206
+ ],
207
+ "spacing": [
208
+ 1.9161034088607827,
209
+ 1.4857286197611925,
210
+ 1.4857286197611925
211
+ ],
212
+ "normalization_schemes": [
213
+ "CTNormalization"
214
+ ],
215
+ "use_mask_for_norm": [
216
+ false
217
+ ],
218
+ "resampling_fn_data": "resample_data_or_seg_to_shape",
219
+ "resampling_fn_seg": "resample_data_or_seg_to_shape",
220
+ "resampling_fn_data_kwargs": {
221
+ "is_seg": false,
222
+ "order": 3,
223
+ "order_z": 0,
224
+ "force_separate_z": null
225
+ },
226
+ "resampling_fn_seg_kwargs": {
227
+ "is_seg": true,
228
+ "order": 1,
229
+ "order_z": 0,
230
+ "force_separate_z": null
231
+ },
232
+ "resampling_fn_probabilities": "resample_data_or_seg_to_shape",
233
+ "resampling_fn_probabilities_kwargs": {
234
+ "is_seg": false,
235
+ "order": 1,
236
+ "order_z": 0,
237
+ "force_separate_z": null
238
+ },
239
+ "architecture": {
240
+ "network_class_name": "dynamic_network_architectures.architectures.unet.ResidualEncoderUNet",
241
+ "arch_kwargs": {
242
+ "n_stages": 6,
243
+ "features_per_stage": [
244
+ 32,
245
+ 64,
246
+ 128,
247
+ 256,
248
+ 320,
249
+ 320
250
+ ],
251
+ "conv_op": "torch.nn.modules.conv.Conv3d",
252
+ "kernel_sizes": [
253
+ [
254
+ 3,
255
+ 3,
256
+ 3
257
+ ],
258
+ [
259
+ 3,
260
+ 3,
261
+ 3
262
+ ],
263
+ [
264
+ 3,
265
+ 3,
266
+ 3
267
+ ],
268
+ [
269
+ 3,
270
+ 3,
271
+ 3
272
+ ],
273
+ [
274
+ 3,
275
+ 3,
276
+ 3
277
+ ],
278
+ [
279
+ 3,
280
+ 3,
281
+ 3
282
+ ]
283
+ ],
284
+ "strides": [
285
+ [
286
+ 1,
287
+ 1,
288
+ 1
289
+ ],
290
+ [
291
+ 2,
292
+ 2,
293
+ 2
294
+ ],
295
+ [
296
+ 2,
297
+ 2,
298
+ 2
299
+ ],
300
+ [
301
+ 2,
302
+ 2,
303
+ 2
304
+ ],
305
+ [
306
+ 2,
307
+ 2,
308
+ 2
309
+ ],
310
+ [
311
+ 1,
312
+ 2,
313
+ 2
314
+ ]
315
+ ],
316
+ "n_blocks_per_stage": [
317
+ 1,
318
+ 3,
319
+ 4,
320
+ 6,
321
+ 6,
322
+ 6
323
+ ],
324
+ "n_conv_per_stage_decoder": [
325
+ 1,
326
+ 1,
327
+ 1,
328
+ 1,
329
+ 1
330
+ ],
331
+ "conv_bias": true,
332
+ "norm_op": "torch.nn.modules.instancenorm.InstanceNorm3d",
333
+ "norm_op_kwargs": {
334
+ "eps": 1e-05,
335
+ "affine": true
336
+ },
337
+ "dropout_op": null,
338
+ "dropout_op_kwargs": null,
339
+ "nonlin": "torch.nn.LeakyReLU",
340
+ "nonlin_kwargs": {
341
+ "inplace": true
342
+ }
343
+ },
344
+ "_kw_requires_import": [
345
+ "conv_op",
346
+ "norm_op",
347
+ "dropout_op",
348
+ "nonlin"
349
+ ]
350
+ },
351
+ "batch_dice": false,
352
+ "next_stage": "3d_cascade_fullres"
353
+ },
354
+ "3d_fullres": {
355
+ "data_identifier": "nnUNetPlans_3d_fullres",
356
+ "preprocessor_name": "DefaultPreprocessor",
357
+ "batch_size": 2,
358
+ "patch_size": [
359
+ 96,
360
+ 160,
361
+ 160
362
+ ],
363
+ "median_image_size_in_voxels": [
364
+ 258.0,
365
+ 512.0,
366
+ 512.0
367
+ ],
368
+ "spacing": [
369
+ 1.0,
370
+ 0.775390625,
371
+ 0.775390625
372
+ ],
373
+ "normalization_schemes": [
374
+ "CTNormalization"
375
+ ],
376
+ "use_mask_for_norm": [
377
+ false
378
+ ],
379
+ "resampling_fn_data": "resample_data_or_seg_to_shape",
380
+ "resampling_fn_seg": "resample_data_or_seg_to_shape",
381
+ "resampling_fn_data_kwargs": {
382
+ "is_seg": false,
383
+ "order": 3,
384
+ "order_z": 0,
385
+ "force_separate_z": null
386
+ },
387
+ "resampling_fn_seg_kwargs": {
388
+ "is_seg": true,
389
+ "order": 1,
390
+ "order_z": 0,
391
+ "force_separate_z": null
392
+ },
393
+ "resampling_fn_probabilities": "resample_data_or_seg_to_shape",
394
+ "resampling_fn_probabilities_kwargs": {
395
+ "is_seg": false,
396
+ "order": 1,
397
+ "order_z": 0,
398
+ "force_separate_z": null
399
+ },
400
+ "architecture": {
401
+ "network_class_name": "dynamic_network_architectures.architectures.unet.ResidualEncoderUNet",
402
+ "arch_kwargs": {
403
+ "n_stages": 6,
404
+ "features_per_stage": [
405
+ 32,
406
+ 64,
407
+ 128,
408
+ 256,
409
+ 320,
410
+ 320
411
+ ],
412
+ "conv_op": "torch.nn.modules.conv.Conv3d",
413
+ "kernel_sizes": [
414
+ [
415
+ 3,
416
+ 3,
417
+ 3
418
+ ],
419
+ [
420
+ 3,
421
+ 3,
422
+ 3
423
+ ],
424
+ [
425
+ 3,
426
+ 3,
427
+ 3
428
+ ],
429
+ [
430
+ 3,
431
+ 3,
432
+ 3
433
+ ],
434
+ [
435
+ 3,
436
+ 3,
437
+ 3
438
+ ],
439
+ [
440
+ 3,
441
+ 3,
442
+ 3
443
+ ]
444
+ ],
445
+ "strides": [
446
+ [
447
+ 1,
448
+ 1,
449
+ 1
450
+ ],
451
+ [
452
+ 2,
453
+ 2,
454
+ 2
455
+ ],
456
+ [
457
+ 2,
458
+ 2,
459
+ 2
460
+ ],
461
+ [
462
+ 2,
463
+ 2,
464
+ 2
465
+ ],
466
+ [
467
+ 2,
468
+ 2,
469
+ 2
470
+ ],
471
+ [
472
+ 1,
473
+ 2,
474
+ 2
475
+ ]
476
+ ],
477
+ "n_blocks_per_stage": [
478
+ 1,
479
+ 3,
480
+ 4,
481
+ 6,
482
+ 6,
483
+ 6
484
+ ],
485
+ "n_conv_per_stage_decoder": [
486
+ 1,
487
+ 1,
488
+ 1,
489
+ 1,
490
+ 1
491
+ ],
492
+ "conv_bias": true,
493
+ "norm_op": "torch.nn.modules.instancenorm.InstanceNorm3d",
494
+ "norm_op_kwargs": {
495
+ "eps": 1e-05,
496
+ "affine": true
497
+ },
498
+ "dropout_op": null,
499
+ "dropout_op_kwargs": null,
500
+ "nonlin": "torch.nn.LeakyReLU",
501
+ "nonlin_kwargs": {
502
+ "inplace": true
503
+ }
504
+ },
505
+ "_kw_requires_import": [
506
+ "conv_op",
507
+ "norm_op",
508
+ "dropout_op",
509
+ "nonlin"
510
+ ]
511
+ },
512
+ "batch_dice": true
513
+ },
514
+ "3d_cascade_fullres": {
515
+ "inherits_from": "3d_fullres",
516
+ "previous_stage": "3d_lowres"
517
+ }
518
+ },
519
+ "experiment_planner_used": "nnUNetPlannerResEncM",
520
+ "label_manager": "LabelManager",
521
+ "foreground_intensity_properties_per_channel": {
522
+ "0": {
523
+ "max": 3525.0,
524
+ "mean": 77.27534484863281,
525
+ "median": 77.0,
526
+ "min": -1024.0,
527
+ "percentile_00_5": -40.0,
528
+ "percentile_99_5": 188.0,
529
+ "std": 47.19807434082031
530
+ }
531
+ }
532
+ }
README.md CHANGED
@@ -1,13 +1,131 @@
1
  ---
2
- title: PanCancerSeg Liver
3
- emoji: 📊
4
- colorFrom: green
5
  colorTo: indigo
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.12'
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,377 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_single,
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
+ # Resolved once per process; subsequent inferences reuse it (no re-download).
36
+ _WEIGHTS_DIR: Path | None = None
37
+
38
+
39
+ def resolve_weights_dir() -> Path:
40
+ """Return a directory containing the DatasetXXX_* model folders.
41
+
42
+ Prefer a local checkout (fast local dev); otherwise download the weights
43
+ from the Hugging Face Hub once and cache the resolved path in-process so we
44
+ never hit the Hub again on later inferences.
45
+ """
46
+ global _WEIGHTS_DIR
47
+ if _WEIGHTS_DIR is not None:
48
+ return _WEIGHTS_DIR
49
+
50
+ local_dir = Path(DEFAULT_MODEL_DIR).expanduser().resolve()
51
+ if local_dir.exists() and any(local_dir.glob("Dataset*")):
52
+ _WEIGHTS_DIR = local_dir
53
+ return _WEIGHTS_DIR
54
+
55
+ from huggingface_hub import snapshot_download
56
+
57
+ downloaded = snapshot_download(
58
+ repo_id=MODEL_REPO_ID,
59
+ repo_type="model",
60
+ allow_patterns=["Dataset*/**"],
61
+ )
62
+ _WEIGHTS_DIR = Path(downloaded)
63
+ return _WEIGHTS_DIR
64
+
65
+
66
+ # ── ZeroGPU support ──────────────────────────────────────────────────────────
67
+ # On Hugging Face ZeroGPU Spaces the `spaces` package is available, and any GPU
68
+ # work must run inside a function decorated with `@spaces.GPU`. Locally (or on a
69
+ # dedicated GPU Space) the package is absent, so we fall back to a no-op so the
70
+ # same code keeps working everywhere.
71
+ try:
72
+ import spaces # type: ignore
73
+
74
+ _HAS_ZEROGPU = True
75
+ except ImportError:
76
+ spaces = None
77
+ _HAS_ZEROGPU = False
78
+
79
+
80
+ def gpu_task(duration: int = 180):
81
+ if _HAS_ZEROGPU:
82
+ return spaces.GPU(duration=duration)
83
+
84
+ def _identity(fn):
85
+ return fn
86
+
87
+ return _identity
88
+
89
+
90
+ @gpu_task(duration=180)
91
+ def run_gpu_segmentation(model_folder_str: str, input_file_str: str, output_file_str: str) -> None:
92
+ """Run nnUNet inference on GPU. Executed inside the ZeroGPU worker process.
93
+
94
+ Uses the single-case, no-multiprocessing path because ZeroGPU runs this in a
95
+ daemon process that is not allowed to spawn child processes.
96
+ """
97
+ # The custom trainer must be registered inside the GPU worker process so that
98
+ # nnUNet can discover it when initialising from the trained model folder.
99
+ install_custom_trainer()
100
+ run_nnunet_prediction_single(
101
+ model_folder=model_folder_str,
102
+ input_file=input_file_str,
103
+ output_file=output_file_str,
104
+ device="cuda",
105
+ )
106
+
107
+
108
+ _SAMPLE_DIR = Path(__file__).parent / "sample_input"
109
+ _CANCER_TYPE_TO_FOLDER = {
110
+ "Kidney Cancer": "kidney",
111
+ "Liver Cancer": "liver",
112
+ "Pancreatic Cancer": "pancreas",
113
+ "Lung Cancer": "lung",
114
+ }
115
+
116
+ def load_example(cancer_type_label: str, index: int) -> str:
117
+ """Return the index-th (1-based) example _0000.nii.gz for the given cancer type."""
118
+ folder = _SAMPLE_DIR / _CANCER_TYPE_TO_FOLDER[cancer_type_label]
119
+ files = sorted(folder.glob("*_0000.nii.gz"))
120
+ if len(files) < index:
121
+ raise gr.Error(f"Example {index} not found for {cancer_type_label} in {folder}")
122
+ return str(files[index - 1])
123
+
124
+
125
+ def count_examples(cancer_type_label: str) -> int:
126
+ """Number of bundled example CT volumes for a cancer type."""
127
+ folder = _SAMPLE_DIR / _CANCER_TYPE_TO_FOLDER[cancer_type_label]
128
+ if not folder.exists():
129
+ return 0
130
+ return len(sorted(folder.glob("*_0000.nii.gz")))
131
+
132
+
133
+ def available_cancer_labels(weights_dir) -> list:
134
+ """Cancer labels whose DatasetXXX folder is present under ``weights_dir``.
135
+
136
+ A single-cancer Space bundles exactly one DatasetXXX folder, so this returns
137
+ a single label and the UI locks to it. A full checkout with all four datasets
138
+ returns every label and the UI shows the selector.
139
+ """
140
+ weights_dir = Path(weights_dir)
141
+ found = [
142
+ label
143
+ for label, key in CANCER_TYPE_CHOICES.items()
144
+ if (weights_dir / CANCER_CONFIGS[key]["dataset_name"]).exists()
145
+ ]
146
+ return found or list(CANCER_TYPE_CHOICES.keys())
147
+
148
+
149
+ # ── Inference ──────────────────────────────────────────────────────────────────
150
+
151
+ def run_inference(
152
+ input_file,
153
+ cancer_type_label,
154
+ fps,
155
+ progress=gr.Progress(track_tqdm=True),
156
+ ):
157
+ if input_file is None:
158
+ raise gr.Error("Please upload a .nii.gz CT image first.")
159
+
160
+ input_path = Path(input_file)
161
+ if not input_path.name.endswith(".nii.gz"):
162
+ raise gr.Error(f"File must be .nii.gz format. Got: {input_path.name}")
163
+
164
+ progress(0.02, desc="Resolving model weights...")
165
+ try:
166
+ model_dir_path = resolve_weights_dir()
167
+ except Exception as e:
168
+ raise gr.Error(f"Failed to obtain model weights from '{MODEL_REPO_ID}': {e}")
169
+
170
+ cancer_key = CANCER_TYPE_CHOICES[cancer_type_label]
171
+ config = CANCER_CONFIGS[cancer_key]
172
+ case_id = resolve_case_id(input_path)
173
+
174
+ progress(0.10, desc="Loading model weights...")
175
+ model_folder = resolve_model_folder(model_dir_path, config["dataset_name"])
176
+
177
+ output_dir = Path(tempfile.mkdtemp(prefix="pancancerseg_out_"))
178
+
179
+ try:
180
+ with tempfile.TemporaryDirectory(prefix="pancancerseg_in_") as tmp:
181
+ tmp_path = Path(tmp)
182
+ tmp_input_dir = tmp_path / "input"
183
+ tmp_output_dir = tmp_path / "prediction"
184
+ tmp_input_dir.mkdir()
185
+ tmp_output_dir.mkdir()
186
+
187
+ nnunet_input = tmp_input_dir / f"{case_id}_0000.nii.gz"
188
+ try:
189
+ nnunet_input.symlink_to(input_path.resolve())
190
+ except (OSError, NotImplementedError):
191
+ shutil.copy2(input_path, nnunet_input)
192
+
193
+ raw_seg = tmp_output_dir / f"{case_id}.nii.gz"
194
+
195
+ progress(0.20, desc="Running nnUNet inference on GPU (this may take a few minutes)...")
196
+ run_gpu_segmentation(
197
+ str(model_folder),
198
+ str(nnunet_input),
199
+ str(raw_seg),
200
+ )
201
+
202
+ if not raw_seg.exists():
203
+ produced = [p.name for p in tmp_output_dir.glob("*.nii.gz")]
204
+ raise RuntimeError(
205
+ f"nnUNet did not produce the expected segmentation. Found: {produced}"
206
+ )
207
+
208
+ seg_path = output_dir / f"{case_id}_seg.nii.gz"
209
+ shutil.copy2(raw_seg, seg_path)
210
+
211
+ progress(0.80, desc="Generating slice images and overlay video...")
212
+ viz = generate_outputs(
213
+ image_path=input_path,
214
+ mask_path=seg_path,
215
+ output_dir=output_dir,
216
+ case_name=case_id,
217
+ cancer_type=config["display_name"],
218
+ wl=config["wl"],
219
+ ww=config["ww"],
220
+ color=config["color"],
221
+ alpha=0.5,
222
+ fps=int(fps),
223
+ )
224
+
225
+ progress(0.95, desc="Computing tumour volume...")
226
+ positive_voxels, tumor_volume_ml = summarize_segmentation(seg_path)
227
+
228
+ stats = (
229
+ f"Case ID : {case_id}\n"
230
+ f"Cancer type : {config['display_name']}\n"
231
+ f"Positive voxels: {positive_voxels:,}\n"
232
+ f"Tumour volume : {tumor_volume_ml:.3f} mL"
233
+ )
234
+
235
+ slices = viz["slices"]
236
+ video_path = viz["video"]
237
+ video_out = (
238
+ str(video_path)
239
+ if video_path.exists() and video_path.stat().st_size > 0
240
+ else None
241
+ )
242
+
243
+ progress(1.0, desc="Done!")
244
+ return (
245
+ stats,
246
+ str(seg_path),
247
+ str(slices.get("centroid")),
248
+ str(slices.get("max_area")),
249
+ str(slices.get("extent25")),
250
+ str(slices.get("extent75")),
251
+ video_out,
252
+ )
253
+
254
+ except Exception as e:
255
+ shutil.rmtree(output_dir, ignore_errors=True)
256
+ raise gr.Error(str(e))
257
+
258
+
259
+ # ── UI ─────────────────────────────────────────────────────────────────────────
260
+
261
+ def build_ui(available_labels=None):
262
+ labels = available_labels or list(CANCER_TYPE_CHOICES.keys())
263
+ single = len(labels) == 1
264
+ default_label = labels[0]
265
+
266
+ if single:
267
+ title = f"# PanCancerSeg — {default_label} CT Segmentation"
268
+ intro = (
269
+ f"Upload a `.nii.gz` CT image and click **Run Inference** to segment "
270
+ f"**{default_label.lower()}** and obtain a mask plus visualisations."
271
+ )
272
+ else:
273
+ title = "# PanCancerSeg — Specialist CT Tumour Segmentation"
274
+ intro = (
275
+ "Upload a `.nii.gz` CT image, select the cancer type, and click "
276
+ "**Run Inference** to obtain a segmentation mask and visualisations."
277
+ )
278
+
279
+ n_examples = count_examples(default_label) if single else 2
280
+
281
+ with gr.Blocks(title="PanCancerSeg Inference") as demo:
282
+ gr.Markdown(f"{title}\n{intro}")
283
+
284
+ with gr.Row():
285
+ # ── Left panel: inputs ─────────────────────────────────────────────
286
+ with gr.Column(scale=1, min_width=300):
287
+ input_file = gr.File(
288
+ label="CT Image (.nii.gz)",
289
+ file_types=[".gz"],
290
+ )
291
+ cancer_type = gr.Dropdown(
292
+ choices=labels,
293
+ value=default_label,
294
+ label="Cancer Type",
295
+ interactive=not single,
296
+ )
297
+ fps = gr.Slider(
298
+ minimum=1,
299
+ maximum=30,
300
+ value=10,
301
+ step=1,
302
+ label="Video FPS",
303
+ )
304
+ example_buttons = []
305
+ if n_examples > 0:
306
+ with gr.Row():
307
+ for i in range(1, n_examples + 1):
308
+ label = "Load Example" if n_examples == 1 else f"Load Example {i}"
309
+ example_buttons.append(gr.Button(label, size="lg"))
310
+ run_btn = gr.Button("Run Inference", variant="primary", size="lg")
311
+ video_out = gr.Video(label="Overlay Video")
312
+
313
+ # ── Right panel: outputs ───────────────────────────────────────────
314
+ with gr.Column(scale=2):
315
+ with gr.Row():
316
+ stats_box = gr.Textbox(
317
+ label="Inference Summary",
318
+ lines=4,
319
+ interactive=False,
320
+ )
321
+ seg_file = gr.File(label="Download Segmentation Mask (.nii.gz)")
322
+ with gr.Row():
323
+ img_centroid = gr.Image(label="Centroid Slice", type="filepath")
324
+ img_max_area = gr.Image(label="Max Area Slice", type="filepath")
325
+ with gr.Row():
326
+ img_ext25 = gr.Image(label="Extent 25% Slice", type="filepath")
327
+ img_ext75 = gr.Image(label="Extent 75% Slice", type="filepath")
328
+
329
+ for idx, btn in enumerate(example_buttons, start=1):
330
+ btn.click(
331
+ fn=(lambda i: lambda ct: load_example(ct, i))(idx),
332
+ inputs=[cancer_type],
333
+ outputs=[input_file],
334
+ )
335
+
336
+ run_btn.click(
337
+ fn=run_inference,
338
+ inputs=[input_file, cancer_type, fps],
339
+ outputs=[
340
+ stats_box,
341
+ seg_file,
342
+ img_centroid,
343
+ img_max_area,
344
+ img_ext25,
345
+ img_ext75,
346
+ video_out,
347
+ ],
348
+ )
349
+
350
+ return demo
351
+
352
+
353
+ if __name__ == "__main__":
354
+ import os
355
+
356
+ # Warm the weights cache at startup so the very first inference (and every
357
+ # later one) does not trigger a download. Failures are non-fatal: we fall
358
+ # back to lazy download on the first request.
359
+ labels = None
360
+ try:
361
+ weights_dir = resolve_weights_dir()
362
+ labels = available_cancer_labels(weights_dir)
363
+ print(f"[startup] available cancer models: {labels}")
364
+ except Exception as e:
365
+ print(f"[startup] weight pre-fetch skipped: {e}")
366
+
367
+ demo = build_ui(labels)
368
+ # Hugging Face Spaces expect the app on port 7860 (set via GRADIO_SERVER_PORT).
369
+ # Locally this falls back to 7860 unless overridden.
370
+ port = int(os.environ.get("GRADIO_SERVER_PORT", 7860))
371
+ demo.launch(
372
+ server_name="0.0.0.0",
373
+ server_port=port,
374
+ share=False,
375
+ theme=gr.themes.Soft(),
376
+ ssr_mode=False,
377
+ )
predict.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 run_nnunet_prediction_single(model_folder, input_file, output_file, device):
265
+ """Single-case nnUNet inference without any multiprocessing.
266
+
267
+ Uses nnUNet's in-process API so it works inside restricted environments
268
+ (e.g. ZeroGPU daemon workers) where spawning child processes is forbidden.
269
+ """
270
+ from nnunetv2.inference.predict_from_raw_data import nnUNetPredictor
271
+ from nnunetv2.imageio.simpleitk_reader_writer import SimpleITKIO
272
+
273
+ predictor = nnUNetPredictor(
274
+ tile_step_size=0.5,
275
+ use_gaussian=True,
276
+ use_mirroring=False,
277
+ perform_everything_on_device=(device == "cuda"),
278
+ device=torch.device(device),
279
+ verbose=False,
280
+ verbose_preprocessing=False,
281
+ allow_tqdm=True,
282
+ )
283
+ predictor.initialize_from_trained_model_folder(
284
+ str(model_folder),
285
+ use_folds=(0,),
286
+ checkpoint_name=CHECKPOINT_NAME,
287
+ )
288
+
289
+ io = SimpleITKIO()
290
+ images, properties = io.read_images([str(input_file)])
291
+ segmentation = predictor.predict_single_npy_array(
292
+ images, properties, None, None, False
293
+ )
294
+ io.write_seg(segmentation, str(output_file), properties)
295
+
296
+
297
+ def summarize_segmentation(seg_path):
298
+ seg = sitk.ReadImage(str(seg_path))
299
+ seg_arr = sitk.GetArrayFromImage(seg)
300
+ positive_voxels = int(np.count_nonzero(seg_arr))
301
+ spacing_x, spacing_y, spacing_z = seg.GetSpacing()
302
+ tumor_volume_ml = positive_voxels * spacing_x * spacing_y * spacing_z / 1000.0
303
+ return positive_voxels, tumor_volume_ml
304
+
305
+
306
+ def print_summary(seg_path, viz_outputs, positive_voxels, tumor_volume_ml):
307
+ print("\nPanCancerSeg inference complete")
308
+ print(f"Segmentation mask : {seg_path}")
309
+ print("Slice PNGs :")
310
+ for label, path in viz_outputs["slices"].items():
311
+ print(f" {label:9s} : {path}")
312
+ print(f"Overlay video : {viz_outputs['video']}")
313
+ print(f"Positive voxels : {positive_voxels}")
314
+ print(f"Tumor volume : {tumor_volume_ml:.3f} mL")
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
requirements.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ spaces
12
+ gradio>=6.12.0
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
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
+ )