Spaces:
Running on Zero
Running on Zero
Single-cancer Space: Liver (Dataset103) with bundled weights
Browse files- PanCancerSeg-Specialized-weights/Dataset103_Liver/nnUNetTrainerWandb2000__nnUNetResEncUNetMPlans__3d_fullres/dataset.json +11 -0
- PanCancerSeg-Specialized-weights/Dataset103_Liver/nnUNetTrainerWandb2000__nnUNetResEncUNetMPlans__3d_fullres/dataset_fingerprint.json +0 -0
- PanCancerSeg-Specialized-weights/Dataset103_Liver/nnUNetTrainerWandb2000__nnUNetResEncUNetMPlans__3d_fullres/fold_0/checkpoint_best.pth +3 -0
- PanCancerSeg-Specialized-weights/Dataset103_Liver/nnUNetTrainerWandb2000__nnUNetResEncUNetMPlans__3d_fullres/plans.json +532 -0
- README.md +124 -6
- app.py +377 -0
- predict.py +318 -0
- requirements.txt +12 -0
- sample_input/liver/FLARE_04936_0000.nii.gz +3 -0
- trainers/nnUNetTrainerWandb2000.py +72 -0
- visualize.py +287 -0
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
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: indigo
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.12'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
)
|