namijiang98 commited on
Commit
9759eef
·
verified ·
1 Parent(s): 7a158a4

Initial release: 12 checkpoints, inference and transfer code

Browse files
.gitattributes CHANGED
@@ -1,35 +1 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  *.pt filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ # Your filled-in metadata describes real patients -- keep it out of the repo.
2
+ meta_files/
3
+ predictions/
4
+
5
+ __pycache__/
6
+ *.py[cod]
7
+ .DS_Store
LICENSE ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Attribution-NonCommercial 4.0 International (CC BY-NC 4.0)
2
+
3
+ Copyright (c) 2026 Lu Jiang, Emily Hirata, Evan Porter, Di Xu, Jiayi Du,
4
+ Wensha Yang, Qihui Lyu, Minsong Cao, Ke Sheng
5
+ Department of Radiation Oncology,
6
+ University of California, San Francisco
7
+
8
+ The model weights, source code and documentation in this repository are licensed
9
+ under the Creative Commons Attribution-NonCommercial 4.0 International License.
10
+
11
+ You are free to:
12
+
13
+ Share — copy and redistribute the material in any medium or format
14
+ Adapt — remix, transform, and build upon the material
15
+
16
+ Under the following terms:
17
+
18
+ Attribution — You must give appropriate credit, provide a link to the
19
+ license, and indicate if changes were made. You may do so in
20
+ any reasonable manner, but not in any way that suggests the
21
+ licensor endorses you or your use.
22
+
23
+ NonCommercial — You may not use the material for commercial purposes.
24
+
25
+ No additional restrictions — You may not apply legal terms or technological
26
+ measures that legally restrict others from doing anything the
27
+ license permits.
28
+
29
+ Full license text: https://creativecommons.org/licenses/by-nc/4.0/legalcode
30
+ Human-readable summary: https://creativecommons.org/licenses/by-nc/4.0/
31
+
32
+ --------------------------------------------------------------------------------
33
+ NO CLINICAL USE
34
+
35
+ These models are research artefacts. They are NOT a medical device, have not
36
+ been reviewed or cleared by any regulatory body, and must not be used to plan,
37
+ guide, verify or inform the treatment of any patient. Predicted dose
38
+ distributions carry no guarantee of accuracy or deliverability.
39
+
40
+ THE MATERIAL IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
41
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
42
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
43
+
44
+ --------------------------------------------------------------------------------
45
+ THIRD-PARTY COMPONENTS
46
+
47
+ Portions of the data pipeline are derived from the GDP-HMM / Flexible-CM GAN
48
+ codebase (Gao et al., CVPR 2023). The C3D architecture follows Liu et al.,
49
+ Medical Physics 2021. SwinUNETR is provided by MONAI (Apache 2.0) and MedNeXt by
50
+ MIC-DKFZ (Apache 2.0); neither is redistributed here. Their respective licenses
51
+ apply to those components.
README.md ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-nc-4.0
3
+ tags:
4
+ - radiotherapy
5
+ - dose-prediction
6
+ - medical-imaging
7
+ - transfer-learning
8
+ - 3d-segmentation
9
+ - pytorch
10
+ library_name: pytorch
11
+ pipeline_tag: image-to-image
12
+ ---
13
+
14
+ # Transfer Learning for 3D Radiotherapy Dose Prediction
15
+
16
+ Model weights and inference code for **"End-to-end Automated Radiotherapy Planning
17
+ Using Transfer Learning to Overcome Data Scarcity"** (Jiang et al., Department of
18
+ Radiation Oncology, UCSF).
19
+
20
+ Pretrained and transfer-learned 3D dose-prediction models for head-and-neck and
21
+ pancreas radiotherapy, across three architectures (**C3D**, **MedNeXt-B**,
22
+ **SwinUNETR-L**).
23
+
24
+ Each architecture ships three checkpoints so the effect of transfer learning is
25
+ directly measurable:
26
+
27
+ | checkpoint | what it is |
28
+ |---|---|
29
+ | `*_pretrained` | trained on a large public dose dataset (the **source domain**) |
30
+ | `*_finetuned` | that model fine-tuned on the private target cohort |
31
+ | `*_fromscratch` | same architecture and schedule, random init — the **no-transfer baseline** |
32
+
33
+ > **Research use only.** Not a medical device, not cleared by any regulatory
34
+ > body, and not to be used for treating patients. See [LICENSE](LICENSE).
35
+
36
+ ---
37
+
38
+ ## ⚠️ Read this first: the models do not all take the same input
39
+
40
+ There are **three different input encodings** in this release. All of them are
41
+ 5-channel or 8-channel volumes of shape `(Z, Y, X) = (128, 192, 192)`, so
42
+ feeding a model the wrong one **runs without error and silently produces wrong
43
+ dose**. Every checkpoint records which encoding it expects in
44
+ `ckpt["channel_spec"]`, and `src/inference.py` picks the matching data loader
45
+ automatically.
46
+
47
+ | encoding | channels | used by |
48
+ |---|---|---|
49
+ | `source_8ch` | 8 | all three `*_pretrained` checkpoints |
50
+ | `han_5ch` | 5 | `c3d/mednext/swinunetr_finetuned` and `_fromscratch` |
51
+ | `pancreas_5ch` | 5 | `pancreas_c3d_finetuned` and `_fromscratch` |
52
+
53
+ `src/channels.py` is the authoritative spec. Print any of them with:
54
+
55
+ ```bash
56
+ python src/channels.py
57
+ ```
58
+
59
+ ### The 8 source-domain channels (`*_pretrained`)
60
+
61
+ The pretrained models come from a public challenge dataset that carried beam
62
+ geometry and plan metadata:
63
+
64
+ | ch | name | content |
65
+ |---|---|---|
66
+ | 0 | `comb_optptv` | optimisation PTVs (`OPTName` structures) × prescription/10, merged by voxel-wise max |
67
+ | 1 | `comb_ptv` | planning PTVs (`StructName` structures) × prescription/10, merged by max |
68
+ | 2 | `comb_oar` | all OARs in one label-encoded channel, each mask scaled by `1 + 4·i/30` |
69
+ | 3 | `body` | binary external / BODY mask |
70
+ | 4 | `img` | CT clipped to `[-1000, 1000]` HU, divided by 500 → `[-2, 2]` |
71
+ | 5 | `beam_plate` | ray-traced beam paths from source through the PTV surface, inverse-square weighted, summed over gantry angles |
72
+ | 6 | `angle_plate` | 2D gantry-angle map replicated over the ±5 slices around the isocentre in Z, zero elsewhere |
73
+ | 7 | `prompt_extend` | `[isVMAT, n_PTVs, site, cohort]` broadcast into four equal blocks along Z |
74
+
75
+ ### **Which channels are unused, and why they were removed rather than zeroed**
76
+
77
+ **The paper's models use 5 input channels.** The private target cohorts have no
78
+ beam-geometry or plan-prompt information, so **channels 5, 6 and 7 carry no
79
+ signal at all** for this data.
80
+
81
+ They were **dropped, not zero-filled**. The released `*_finetuned` and
82
+ `*_fromscratch` checkpoints physically have a 5-channel input stem — you cannot
83
+ feed them 8 channels, and you should not pad your 5 channels to 8 for them.
84
+ Channels 0–4 keep the same positions and meanings, which is what makes transfer
85
+ possible; only the input stem changed shape. See
86
+ [Transfer mechanics](#transfer-mechanics) below.
87
+
88
+ The consequence for the `*_pretrained` checkpoints: they still expect 8
89
+ channels, and they were trained with *real* beam and prompt channels, so they
90
+ are published as a **fine-tuning starting point, not as a runnable predictor**
91
+ on data that lacks those channels. `src/inference.py` refuses to run them by
92
+ default. `channels.pad_to_source_8ch` exists for shape debugging only.
93
+
94
+ ### How the two 5-channel encodings differ
95
+
96
+ Channels 3 (`body`) and 4 (`img`) are identical between HaN and pancreas.
97
+ **Channels 0, 1 and 2 are not** — this is the trap:
98
+
99
+ | ch | `han_5ch` | `pancreas_5ch` |
100
+ |---|---|---|
101
+ | 0 `comb_optptv` | PTV × Rx/10 | **PTV with every overlapping OAR subtracted** (`ptv · (1 − any_oar)`), × Rx/10 |
102
+ | 1 `comb_ptv` | PTV × Rx/10 | PTV × **`min(Rx/10, 3.0)`** — the value is **capped at 30 Gy** |
103
+ | 2 `comb_oar` | 23 HaN OARs, each scaled `1 + 4·i/30` → `[1.13, 4.07]` | 14 PancVMAT OARs in **two tiers**: dose-limiting OARs (Stomach, Bowel_Small, Bowel_Large + PRVs) → `[2.0, 2.5]`; all others → `[0.5, 0.8]` |
104
+
105
+ Two consequences worth knowing:
106
+
107
+ * **In `han_5ch`, channel 0 and channel 1 are byte-identical.** The HaN loader
108
+ builds the optimisation and planning PTV sets from the same `StructName`
109
+ masks, so the channel is duplicated. It is kept as a separate channel only to
110
+ preserve the pretrained model's layout. (Verified on real cases, not inferred
111
+ from the code.)
112
+ * **In `pancreas_5ch`, channel 1 is capped at 3.0.** A 40 Gy PTV appears as
113
+ `3.0`, not `4.0`, while channel 0 for the same PTV is uncapped at `4.0`. If
114
+ you reimplement the pipeline and skip the cap, the model sees inputs it was
115
+ never trained on.
116
+
117
+ The dose *target* differs too: HaN normalises the planned dose to the PTV_High
118
+ D97 before dividing by `dose_div_factor`; pancreas does not renormalise
119
+ (`norm_scale = 1.0`).
120
+
121
+ ---
122
+
123
+ ## Available weights
124
+
125
+ All files are plain `state_dict` + metadata, safe under
126
+ `torch.load(..., weights_only=True)`. SHA-256 sums are in
127
+ [`weights/MANIFEST.json`](weights/MANIFEST.json).
128
+
129
+ | file | arch | input encoding | params | size |
130
+ |---|---|---|---|---|
131
+ | `c3d_pretrained.pt` | C3D | `source_8ch` | 32.4 M | 129 MB |
132
+ | `c3d_finetuned.pt` | C3D | `han_5ch` | 32.3 M | 129 MB |
133
+ | `c3d_fromscratch.pt` | C3D | `han_5ch` | 32.3 M | 129 MB |
134
+ | `mednext_pretrained.pt` | MedNeXt-B | `source_8ch` | 10.5 M | 42 MB |
135
+ | `mednext_finetuned.pt` | MedNeXt-B | `han_5ch` | 10.5 M | 42 MB |
136
+ | `mednext_fromscratch.pt` | MedNeXt-B | `han_5ch` | 10.5 M | 42 MB |
137
+ | `swinunetr_pretrained.pt` | SwinUNETR-L | `source_8ch` | 291.4 M | 1169 MB |
138
+ | `swinunetr_finetuned.pt` | SwinUNETR-L | `han_5ch` | 291.3 M | 1169 MB |
139
+ | `swinunetr_fromscratch.pt` | SwinUNETR-L | `han_5ch` | 291.3 M | 1169 MB |
140
+ | `pancreas_c3d_pretrained.pt` | C3D | `source_8ch` | 32.4 M | 129 MB |
141
+ | `pancreas_c3d_finetuned.pt` | C3D | `pancreas_5ch` | 32.3 M | 129 MB |
142
+ | `pancreas_c3d_fromscratch.pt` | C3D | `pancreas_5ch` | 32.3 M | 129 MB |
143
+
144
+ Notes:
145
+
146
+ * `pancreas_c3d_pretrained.pt` has **byte-identical weights** to
147
+ `c3d_pretrained.pt` — the pancreas model was fine-tuned **directly from the
148
+ source-domain model, not from the head-and-neck model**. It is duplicated so
149
+ the pancreas set stands alone; only its `scale_out` metadata differs.
150
+ * The `*_pretrained` files have marginally more parameters than their 5-channel
151
+ descendants — that difference is entirely the 8- vs 5-channel input stem.
152
+
153
+ ## Output convention
154
+
155
+ Every model emits a raw tensor that must be rescaled to physical dose:
156
+
157
+ ```
158
+ dose_Gy = sigmoid(output) × scale_out × dose_div_factor
159
+ ```
160
+
161
+ with `scale_out = 7.5` for head-and-neck, `5.5` for pancreas, and
162
+ `dose_div_factor = 10` everywhere. `checkpoint.to_dose()` does this for you
163
+ using each checkpoint's own constants.
164
+
165
+ **C3D returns a list `[output_A, output_B]`** — `output_A` is the coarse first
166
+ U-Net, **`output_B` is the refined prediction you want**. MedNeXt and SwinUNETR
167
+ return a single tensor.
168
+
169
+ ---
170
+
171
+ ## Quick start
172
+
173
+ ```bash
174
+ pip install -r requirements.txt
175
+ # MedNeXt checkpoints only:
176
+ pip install git+https://github.com/MIC-DKFZ/MedNeXt.git
177
+ ```
178
+
179
+ Minimal load-and-predict:
180
+
181
+ ```python
182
+ import sys, torch
183
+ sys.path.insert(0, "src")
184
+ from checkpoint import load_model, to_dose
185
+
186
+ model, ckpt = load_model("weights/c3d_finetuned.pt", device="cuda")
187
+ print(ckpt["channel_spec"], ckpt["channel_names"])
188
+ # han_5ch ['comb_optptv', 'comb_ptv', 'comb_oar', 'body', 'img']
189
+
190
+ x = torch.randn(1, 5, 128, 192, 192, device="cuda") # your prepared input
191
+ with torch.no_grad():
192
+ dose_gy = to_dose(model(x), ckpt) # [1, 1, 128, 192, 192] in Gy
193
+ ```
194
+
195
+ Full inference over a dataset, writing NIfTI volumes:
196
+
197
+ ```bash
198
+ cp -r meta_files_template/han meta_files # then fill in your own cases
199
+ # edit configs/c3d_han.yaml -> loader_params.data_root
200
+
201
+ python src/inference.py \
202
+ --ckpt weights/c3d_finetuned.pt \
203
+ --config configs/c3d_han.yaml \
204
+ --out predictions/c3d_finetuned
205
+ ```
206
+
207
+ The script derives the architecture, channel encoding, data loader and dose
208
+ rescaling from the checkpoint, then per case: converts to Gy, clips to
209
+ `clip_factor × PTV_High prescription`, zeroes everything outside the BODY mask,
210
+ copies the CT's spacing/origin/direction, and writes `<case_id>_pred.nii.gz`.
211
+
212
+ Inspect a checkpoint's expected channels without running anything:
213
+
214
+ ```bash
215
+ python src/inference.py --ckpt weights/pancreas_c3d_finetuned.pt \
216
+ --config configs/c3d_pancreas.yaml --out /tmp/x --describe
217
+ ```
218
+
219
+ ---
220
+
221
+ ## Transfer mechanics
222
+
223
+ How the `*_finetuned` checkpoints were actually produced, so you can reproduce
224
+ it on your own data.
225
+
226
+ Every tensor whose shape matched was copied from the pretrained model; the rest
227
+ kept their fresh initialisation. Because only the input channel count changed,
228
+ that means **only the input stem was re-initialised**:
229
+
230
+ | architecture | transferred | re-initialised |
231
+ |---|---|---|
232
+ | C3D | 170 / 172 | `net_A.encoder.encoder_1.0.single_conv.0.weight` (16,8,3,3,3 → 16,5,3,3,3), `net_B...` (32,24 → 32,21) |
233
+ | MedNeXt-B | 228 / 229 | `stem.weight` (32,8,1,1,1 → 32,5,1,1,1) |
234
+ | SwinUNETR-L | 164 / 167 | `swinViT.patch_embed.proj.weight`, `encoder1.layer.conv1.conv.weight`, `encoder1.layer.conv3.conv.weight` |
235
+
236
+ **No channel slicing, averaging or repetition was applied to the input stem** —
237
+ it was simply retrained from scratch alongside the rest of the network.
238
+
239
+ `src/transfer.py` reproduces this exactly:
240
+
241
+ ```python
242
+ import sys; sys.path.insert(0, "src")
243
+ from build_model import build_model
244
+ from transfer import load_pretrained_into
245
+
246
+ model = build_model("c3d", in_channels=5) # your own channel count
247
+ report = load_pretrained_into(model, "weights/c3d_pretrained.pt")
248
+ print(report) # names every tensor that was NOT transferred -- read it
249
+ ```
250
+
251
+ Training hyperparameters used for the released head-and-neck C3D run: Adam,
252
+ `lr = 3e-5`, `weight_decay = 1e-4`, cosine schedule to `eta_min = 1e-7`,
253
+ 275 epochs. The `_fromscratch` baseline used the same schedule at `lr = 1e-4`.
254
+
255
+ ---
256
+
257
+ ## Repository layout
258
+
259
+ ```
260
+ ├── README.md
261
+ ├── LICENSE CC BY-NC 4.0 + no-clinical-use notice
262
+ ├── requirements.txt
263
+ ├── configs/ one YAML per model/anatomy
264
+ │ ├── c3d_han.yaml
265
+ │ ├── mednext_han.yaml
266
+ │ ├── swinunetr_han.yaml
267
+ │ └── c3d_pancreas.yaml
268
+ ├── src/
269
+ │ ├── channels.py THE channel spec -- read this first
270
+ │ ├── model_c3d.py C3D architecture
271
+ │ ├── build_model.py arch name + channel count -> model
272
+ │ ├── checkpoint.py loading weights, rescaling output to Gy
273
+ │ ├── transfer.py warm-starting your own model
274
+ │ ├── inference.py dataset -> NIfTI dose volumes
275
+ │ ├── data_loader_han.py produces han_5ch
276
+ │ ├── toolkit_han.py
277
+ │ ├── data_loader_pancreas.py produces pancreas_5ch
278
+ │ └── toolkit_pancreas.py
279
+ ├── meta_files_template/ de-identified metadata schema + data layout
280
+ │ ├── han/ pancreas/ README.md
281
+ ├── weights/ 12 checkpoints + MANIFEST.json
282
+ └── tools/
283
+ └── convert_checkpoints.py provenance: raw training ckpts -> weights/*.pt
284
+ ```
285
+
286
+ Training code (loss functions, trainer loop, W&B logging, evaluation) is **not**
287
+ included — this release is scoped to using the weights. Everything needed for
288
+ that is here: architectures, both data loaders, inference, and transfer.
289
+
290
+ ### What is not published
291
+
292
+ * **The training data.** Both target cohorts are private patient data.
293
+ * **The real metadata files.** `meta_files_template/` gives the schema instead.
294
+ * **Few-shot and low-LR checkpoints.** The study also produced 5/10/20/50 %
295
+ few-shot runs (two seeds each) and a low-learning-rate variant. They are not
296
+ in this release; open an issue if you need them.
297
+
298
+ ---
299
+
300
+ ## Citation
301
+
302
+ If you use these weights or code, please cite:
303
+
304
+ ```bibtex
305
+ @article{jiang_endtoend_rtplanning,
306
+ title = {End-to-end Automated Radiotherapy Planning Using Transfer Learning
307
+ to Overcome Data Scarcity},
308
+ author = {Jiang, Lu and Hirata, Emily and Porter, Evan and Xu, Di and Du, Jiayi
309
+ and Yang, Wensha and Lyu, Qihui and Cao, Minsong and Sheng, Ke},
310
+ journal = {Medical Physics},
311
+ year = {2026},
312
+ note = {In press; DOI to be assigned}
313
+ }
314
+ ```
315
+
316
+ > This paper has been provisionally accepted at *Medical Physics*. The BibTeX
317
+ > entry above will be updated with the final volume, pages and DOI once they are
318
+ > assigned.
319
+
320
+ **Authors:** Lu Jiang, Emily Hirata, Evan Porter, Di Xu, Jiayi Du, Wensha Yang,
321
+ Qihui Lyu, Minsong Cao, Ke Sheng — Department of Radiation Oncology, University
322
+ of California, San Francisco.
323
+
324
+ **Corresponding author:** Ke Sheng, PhD — Professor and Vice Chair of Medical
325
+ Physics, Department of Radiation Oncology, UCSF —
326
+ [ke.sheng@ucsf.edu](mailto:ke.sheng@ucsf.edu)
327
+
328
+ **Paper:** *Medical Physics* (in press) &nbsp;·&nbsp; **Model repository:**
329
+ `https://huggingface.co/<your-username>/radiotherapy-dose-prediction-transfer-learning`
330
+
331
+ ## Please also cite the work this builds on
332
+
333
+ ```bibtex
334
+ @inproceedings{gao2023flexible,
335
+ title = {Flexible-CM GAN: Towards Precise 3D Dose Prediction in Radiotherapy},
336
+ author = {Gao, Riqiang and Lou, Bin and Xu, Zhoubing and Comaniciu, Dorin and Kamen, Ali},
337
+ booktitle = {CVPR},
338
+ year = {2023}
339
+ }
340
+
341
+ @article{liu2021cascade,
342
+ title = {A cascade 3D U-Net for dose prediction in radiotherapy},
343
+ author = {Liu, Shuolin and Zhang, Jingjing and Li, Teng and Yan, Hui and Liu, Jianfei},
344
+ journal = {Medical Physics},
345
+ year = {2021}
346
+ }
347
+
348
+ @inproceedings{roy2023mednext,
349
+ title = {MedNeXt: Transformer-driven Scaling of ConvNets for Medical Image Segmentation},
350
+ author = {Roy, Saikat and Koehler, Gregor and Ulrich, Constantin and Baumgartner, Michael
351
+ and Petersen, Jens and Isensee, Fabian and Jaeger, Paul F and Maier-Hein, Klaus},
352
+ booktitle = {MICCAI},
353
+ year = {2023}
354
+ }
355
+
356
+ @inproceedings{hatamizadeh2022swinunetr,
357
+ title = {Swin UNETR: Swin Transformers for Semantic Segmentation of Brain Tumors in MRI Images},
358
+ author = {Hatamizadeh, Ali and Nath, Vishwesh and Tang, Yucheng and Yang, Dong
359
+ and Roth, Holger R and Xu, Daguang},
360
+ booktitle = {MICCAI Brainlesion Workshop},
361
+ year = {2022}
362
+ }
363
+ ```
364
+
365
+ ## Ethics and funding
366
+
367
+ This retrospective study was approved by the Institutional Review Board of the
368
+ University of California, San Francisco (**IRB #24-42071**); the requirement for
369
+ informed consent was waived. The private UCSF head-and-neck and pancreas
370
+ cohorts used for fine-tuning and evaluation are not redistributed here; only
371
+ model weights and code are.
372
+
373
+ Supported by **NIH R01CA255432**, **NIH R44CA183390**, and **NIH R01CA259008**.
374
+
375
+ **Disclosures.** Dr. Ke Sheng reports grant funding from the National Institutes
376
+ of Health (NIH). Dr. Minsong Cao reports consulting fees and honoraria from
377
+ Varian Medical Systems, Siemens Healthineers, and the Medical Dosimetrist
378
+ Certification Board. The other authors declare no conflicts of interest.
379
+
380
+ ## License
381
+
382
+ [CC BY-NC 4.0](LICENSE) — attribution required, non-commercial use only.
383
+ **Research use only; not for clinical use.**
configs/c3d_han.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # C3D (cascade 3D U-Net) on head-and-neck data
2
+ #
3
+ # Paths are resolved relative to the repository root -- run inference from there.
4
+ # Copy meta_files_template/ to meta_files/ and fill it with your own cases first.
5
+
6
+ loader_params:
7
+
8
+ train_bs: 4
9
+ val_bs: 4
10
+ test_bs: 4
11
+ num_workers: 8
12
+
13
+ csv_root: 'meta_files/meta_data.csv'
14
+ scale_dose_dict: 'meta_files/ptv_dict.json'
15
+ pat_obj_dict: 'meta_files/oar_dict.json'
16
+ data_root: '/path/to/DATA/HaN' # <-- point this at your data
17
+
18
+ # CT intensity normalisation: clip to [down_HU, up_HU], then divide by denom_norm_HU
19
+ down_HU: -1000
20
+ up_HU: 1000
21
+ denom_norm_HU: 500
22
+
23
+ in_size: [128, 192, 192] # Z, Y, X -- crop/pad size around the isocentre
24
+ out_size: [128, 192, 192] # Z, Y, X -- resampled network input size
25
+
26
+ norm_oar: True # scale each OAR label rather than using raw indices
27
+ CatStructures: False # False -> merge OARs into 1 channel and PTVs into 1 channel
28
+ # (must stay False: the released weights take 5 channels)
29
+
30
+ dose_div_factor: 10 # dose targets are divided by this
31
+ with_aug: True # only affects the 'train' split; inference always uses tt_augmentation
32
+
33
+ # Output rescaling. dose_Gy = sigmoid(out) * scale_out * dose_div_factor
34
+ act_sig: True
35
+ scale_out: 7.5
configs/c3d_pancreas.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # C3D (cascade 3D U-Net) on pancreas VMAT data
2
+ #
3
+ # Paths are resolved relative to the repository root -- run inference from there.
4
+ # Copy meta_files_template/ to meta_files/ and fill it with your own cases first.
5
+
6
+ loader_params:
7
+
8
+ train_bs: 4
9
+ val_bs: 4
10
+ test_bs: 4
11
+ num_workers: 8
12
+
13
+ csv_root: 'meta_files/meta_data.csv'
14
+ scale_dose_dict: 'meta_files/ptv_dict.json'
15
+ pat_obj_dict: 'meta_files/oar_dict.json'
16
+ data_root: '/path/to/DATA/PancVMAT' # <-- point this at your data
17
+
18
+ # CT intensity normalisation: clip to [down_HU, up_HU], then divide by denom_norm_HU
19
+ down_HU: -1000
20
+ up_HU: 1000
21
+ denom_norm_HU: 500
22
+
23
+ in_size: [128, 192, 192] # Z, Y, X -- crop/pad size around the isocentre
24
+ out_size: [128, 192, 192] # Z, Y, X -- resampled network input size
25
+
26
+ norm_oar: True # scale each OAR label rather than using raw indices
27
+ CatStructures: False # False -> merge OARs into 1 channel and PTVs into 1 channel
28
+ # (must stay False: the released weights take 5 channels)
29
+
30
+ dose_div_factor: 10 # dose targets are divided by this
31
+ with_aug: True # only affects the 'train' split; inference always uses tt_augmentation
32
+
33
+ # Output rescaling. dose_Gy = sigmoid(out) * scale_out * dose_div_factor
34
+ act_sig: True
35
+ scale_out: 5.5
configs/mednext_han.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MedNeXt-B (kernel 3) on head-and-neck data
2
+ #
3
+ # Paths are resolved relative to the repository root -- run inference from there.
4
+ # Copy meta_files_template/ to meta_files/ and fill it with your own cases first.
5
+
6
+ loader_params:
7
+
8
+ train_bs: 2
9
+ val_bs: 2
10
+ test_bs: 2
11
+ num_workers: 8
12
+
13
+ csv_root: 'meta_files/meta_data.csv'
14
+ scale_dose_dict: 'meta_files/ptv_dict.json'
15
+ pat_obj_dict: 'meta_files/oar_dict.json'
16
+ data_root: '/path/to/DATA/HaN' # <-- point this at your data
17
+
18
+ # CT intensity normalisation: clip to [down_HU, up_HU], then divide by denom_norm_HU
19
+ down_HU: -1000
20
+ up_HU: 1000
21
+ denom_norm_HU: 500
22
+
23
+ in_size: [128, 192, 192] # Z, Y, X -- crop/pad size around the isocentre
24
+ out_size: [128, 192, 192] # Z, Y, X -- resampled network input size
25
+
26
+ norm_oar: True # scale each OAR label rather than using raw indices
27
+ CatStructures: False # False -> merge OARs into 1 channel and PTVs into 1 channel
28
+ # (must stay False: the released weights take 5 channels)
29
+
30
+ dose_div_factor: 10 # dose targets are divided by this
31
+ with_aug: True # only affects the 'train' split; inference always uses tt_augmentation
32
+
33
+ # Output rescaling. dose_Gy = sigmoid(out) * scale_out * dose_div_factor
34
+ act_sig: True
35
+ scale_out: 7.5
configs/swinunetr_han.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SwinUNETR-L (feature_size 96, v2) on head-and-neck data
2
+ #
3
+ # Paths are resolved relative to the repository root -- run inference from there.
4
+ # Copy meta_files_template/ to meta_files/ and fill it with your own cases first.
5
+
6
+ loader_params:
7
+
8
+ train_bs: 2
9
+ val_bs: 2
10
+ test_bs: 2
11
+ num_workers: 8
12
+
13
+ csv_root: 'meta_files/meta_data.csv'
14
+ scale_dose_dict: 'meta_files/ptv_dict.json'
15
+ pat_obj_dict: 'meta_files/oar_dict.json'
16
+ data_root: '/path/to/DATA/HaN' # <-- point this at your data
17
+
18
+ # CT intensity normalisation: clip to [down_HU, up_HU], then divide by denom_norm_HU
19
+ down_HU: -1000
20
+ up_HU: 1000
21
+ denom_norm_HU: 500
22
+
23
+ in_size: [128, 192, 192] # Z, Y, X -- crop/pad size around the isocentre
24
+ out_size: [128, 192, 192] # Z, Y, X -- resampled network input size
25
+
26
+ norm_oar: True # scale each OAR label rather than using raw indices
27
+ CatStructures: False # False -> merge OARs into 1 channel and PTVs into 1 channel
28
+ # (must stay False: the released weights take 5 channels)
29
+
30
+ dose_div_factor: 10 # dose targets are divided by this
31
+ with_aug: True # only affects the 'train' split; inference always uses tt_augmentation
32
+
33
+ # Output rescaling. dose_Gy = sigmoid(out) * scale_out * dose_div_factor
34
+ act_sig: True
35
+ scale_out: 7.5
meta_files_template/README.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Metadata templates
2
+
3
+ The released data loaders read three metadata files. The real ones describe a
4
+ private patient cohort and are not published, so these are **de-identified
5
+ templates showing the exact schema** — copy the folder for your anatomy to
6
+ `meta_files/` at the repository root and fill it in with your own cases.
7
+
8
+ ```bash
9
+ cp -r meta_files_template/han meta_files # head and neck
10
+ # or
11
+ cp -r meta_files_template/pancreas meta_files # pancreas
12
+ ```
13
+
14
+ The `PatientID` values here (`case_0001`, …) are placeholders. Whatever you use
15
+ must match the case directory names under `data_root`.
16
+
17
+ ## `meta_data.csv`
18
+
19
+ One row per case. Read by `pandas.read_csv`.
20
+
21
+ | column | meaning |
22
+ |---|---|
23
+ | `PatientID` | case id; must equal the directory name under `data_root/<split>/` |
24
+ | `dev_split` | `train`, `valid` or `test` — this is what selects the split |
25
+ | `phase` | free-text bookkeeping column; not used by the loader |
26
+ | `site` | anatomical site code. **Must be `< 1.5` (use `1`) for the HaN loader**, which raises `NotImplementedError` otherwise. Any value works for the pancreas loader. |
27
+ | `cohort` | cohort code; forwarded into `data_dict['prompt']` only |
28
+ | `num_OAR`, `num_PTV` | bookkeeping columns; not used by the loader |
29
+
30
+ ## `ptv_dict.json`
31
+
32
+ `PatientID -> {PTV_High|PTV_Mid|PTV_Low: {PDose, StructName}}`.
33
+
34
+ `PDose` is the prescription in Gy. `StructName` is the filename stem under
35
+ `Masks/PTV/`, so `"StructName": "PTV_70"` means `Masks/PTV/PTV_70.nii.gz`.
36
+
37
+ `PTV_High` is required — inference clips predictions at a multiple of its
38
+ `PDose`, and the HaN loader normalises the dose target to its D97.
39
+
40
+ ## `oar_dict.json`
41
+
42
+ `PatientID -> [OAR names]`, listing which OARs to load for that case. Names are
43
+ filename stems under `Masks/OAR/`. Names not in the loader's site OAR list
44
+ (`HaN_OAR_LIST` / `Panc_OAR_LIST`) are ignored; listed-but-missing files are
45
+ silently treated as empty masks.
46
+
47
+ Order does not matter — the channel encoding uses each OAR's index in the
48
+ loader's own list, not its position here. See `src/channels.py`.
49
+
50
+ ## Expected data layout
51
+
52
+ ```
53
+ data_root/
54
+ ├── train_valid/ <- cases with dev_split = train or valid
55
+ │ └── <PatientID>/
56
+ │ ├── CT.nii.gz
57
+ │ ├── dose.nii.gz (not needed for inference)
58
+ │ └── Masks/
59
+ │ ├── BODY.nii.gz
60
+ │ ├── PTV/<StructName>.nii.gz
61
+ │ └── OAR/<OARName>.nii.gz
62
+ └── test/ <- cases with dev_split = test
63
+ └── <PatientID>/ ...
64
+ ```
65
+
66
+ All volumes for a case must share a grid; they are read with `nibabel` and
67
+ transposed to `(Z, Y, X)`, then cropped/padded to `in_size` around the volume
68
+ centre and resampled to `out_size`.
meta_files_template/han/meta_data.csv ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ PatientID,dev_split,phase,site,cohort,num_OAR,num_PTV
2
+ case_0001,train,train,1,2,21,3
3
+ case_0002,valid,train,1,2,19,2
4
+ case_0003,test,test,1,2,22,3
meta_files_template/han/oar_dict.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "case_0001": ["Bone_Mandible", "Brainstem", "Cavity_Oral", "Esophagus", "Glnd_Thyroid",
3
+ "Larynx", "Parotid_L", "Parotid_R", "SpinalCord", "Submandibular_L",
4
+ "Submandibular_R", "Brain", "Eye_L", "Eye_R", "OpticNrv_L", "OpticNrv_R",
5
+ "OpticChiasm", "Lens_L", "Lens_R", "Lips", "Trachea"],
6
+ "case_0002": ["Bone_Mandible", "Brainstem", "Cavity_Oral", "Esophagus", "Glnd_Thyroid",
7
+ "Larynx", "Parotid_L", "Parotid_R", "SpinalCord", "Submandibular_L",
8
+ "Submandibular_R", "Brain", "Eye_L", "Eye_R", "OpticNrv_L", "OpticNrv_R",
9
+ "OpticChiasm", "Lips", "Trachea"],
10
+ "case_0003": ["Bone_Mandible", "Brainstem", "Cavity_Oral", "Esophagus", "Glnd_Thyroid",
11
+ "Larynx", "Parotid_L", "Parotid_R", "SpinalCord", "Submandibular_L",
12
+ "Submandibular_R", "Brain", "Eye_L", "Eye_R", "OpticNrv_L", "OpticNrv_R",
13
+ "OpticChiasm", "Lens_L", "Lens_R", "Lips", "Trachea", "Lung_L"]
14
+ }
meta_files_template/han/ptv_dict.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "case_0001": {
3
+ "PTV_High": {"PDose": 70, "StructName": "PTV_70"},
4
+ "PTV_Mid": {"PDose": 59, "StructName": "PTV_59"},
5
+ "PTV_Low": {"PDose": 54, "StructName": "PTV_54"}
6
+ },
7
+ "case_0002": {
8
+ "PTV_High": {"PDose": 66, "StructName": "PTV_66"},
9
+ "PTV_Low": {"PDose": 54, "StructName": "PTV_54"}
10
+ },
11
+ "case_0003": {
12
+ "PTV_High": {"PDose": 70, "StructName": "PTV_70"},
13
+ "PTV_Mid": {"PDose": 63, "StructName": "PTV_63"},
14
+ "PTV_Low": {"PDose": 56, "StructName": "PTV_56"}
15
+ }
16
+ }
meta_files_template/pancreas/meta_data.csv ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ PatientID,dev_split,phase,site,cohort,num_OAR,num_PTV
2
+ case_0001,train,train,2,1,7,2
3
+ case_0002,valid,train,2,1,10,1
4
+ case_0003,test,test,2,1,13,1
meta_files_template/pancreas/oar_dict.json ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "case_0001": ["Kidney_L", "Kidney_R", "Liver", "SpinalCord", "Stomach",
3
+ "Bowel_Small", "Bowel_Large"],
4
+ "case_0002": ["Kidney_L", "Kidney_R", "Kidneys", "Liver", "SpinalCord", "SpinalCanal",
5
+ "Esophagus", "Stomach", "Bowel_Small", "Bowel_Large"],
6
+ "case_0003": ["Kidney_L", "Kidney_R", "Kidneys", "Liver", "SpinalCord", "SpinalCanal",
7
+ "Esophagus", "Heart", "Stomach", "Stomach_PRV", "Bowel_Small",
8
+ "Bowel_Small_PRV", "Bowel_Large"]
9
+ }
meta_files_template/pancreas/ptv_dict.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "case_0001": {
3
+ "PTV_High": {"PDose": 55, "StructName": "PTV_55"},
4
+ "PTV_Mid": {"PDose": 50, "StructName": "PTV_50"}
5
+ },
6
+ "case_0002": {"PTV_High": {"PDose": 40, "StructName": "PTV_40"}},
7
+ "case_0003": {"PTV_High": {"PDose": 45, "StructName": "PTV_45"}}
8
+ }
requirements.txt ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Core (needed for every model)
2
+ torch>=2.0
3
+ numpy>=1.24
4
+ nibabel>=5.0
5
+ SimpleITK>=2.2
6
+ pandas>=2.0
7
+ PyYAML>=6.0
8
+ scipy>=1.10
9
+ tqdm>=4.65
10
+
11
+ # MONAI provides the data-augmentation transforms used by both data loaders,
12
+ # and the SwinUNETR architecture.
13
+ #
14
+ # These models were trained with MONAI < 1.3, whose SwinUNETR took an `img_size`
15
+ # argument that was removed in 1.5. src/build_model.py passes it only when the
16
+ # installed version accepts it, so 1.2 through 1.5+ all work and load the same
17
+ # weights -- the argument never affected the parameters.
18
+ monai>=1.2
19
+
20
+ # MedNeXt checkpoints only. Not on PyPI; install from source:
21
+ # pip install git+https://github.com/MIC-DKFZ/MedNeXt.git
22
+ # nnunet_mednext
src/build_model.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ One place that turns an architecture name + input-channel count into a model.
3
+
4
+ The three architectures are built exactly as they were during training, so the
5
+ released state_dicts load with `strict=True`. If you change any argument here
6
+ (feature_size, model_id, list_ch_*, ...) the checkpoints will no longer fit.
7
+
8
+ MedNeXt and SwinUNETR are imported lazily so that, e.g., running the C3D model
9
+ does not require the MedNeXt package to be installed.
10
+ """
11
+
12
+ import torch.nn as nn
13
+
14
+ # --- architecture arguments, frozen to match the released checkpoints -------
15
+ C3D_KWARGS = dict(
16
+ list_ch_A=[-1, 16, 32, 64, 128, 256],
17
+ list_ch_B=[-1, 32, 64, 128, 256, 512],
18
+ )
19
+
20
+ MEDNEXT_KWARGS = dict(
21
+ model_id="B",
22
+ kernel_size=3,
23
+ deep_supervision=False,
24
+ )
25
+
26
+ SWINUNETR_KWARGS = dict(
27
+ spatial_dims=3,
28
+ feature_size=96, # "L" size
29
+ dropout_path_rate=0.1,
30
+ use_v2=True,
31
+ use_checkpoint=True,
32
+ )
33
+
34
+ # These models were trained with MONAI < 1.3, whose SwinUNETR took an `img_size`
35
+ # argument. It was only used to sanity-check the window size and never affected
36
+ # the parameters, and MONAI removed it in 1.5. We pass it only if the installed
37
+ # version still accepts it, so one checkpoint works across MONAI versions.
38
+ SWINUNETR_IMG_SIZE = (128, 192, 192) # Z, Y, X
39
+
40
+ ARCHITECTURES = ("c3d", "mednext", "swinunetr")
41
+
42
+
43
+ def build_model(arch: str, in_channels: int = 5, out_channels: int = 1) -> nn.Module:
44
+ """
45
+ Build an untrained model.
46
+
47
+ arch : one of ARCHITECTURES
48
+ in_channels : 5 for every fine-tuned / from-scratch checkpoint,
49
+ 8 for the source-domain *pretrained* checkpoints
50
+ out_channels : 1 (dose)
51
+
52
+ Note the C3D forward returns a list ``[output_A, output_B]``; ``output_B``
53
+ is the refined prediction and the one you want. MedNeXt and SwinUNETR
54
+ return a single tensor.
55
+ """
56
+ arch = arch.lower()
57
+
58
+ if arch == "c3d":
59
+ from model_c3d import Model
60
+ return Model(in_ch=in_channels, out_ch=out_channels, **C3D_KWARGS)
61
+
62
+ if arch == "mednext":
63
+ # pip install git+https://github.com/MIC-DKFZ/MedNeXt.git
64
+ from nnunet_mednext import create_mednext_v1
65
+ return create_mednext_v1(
66
+ num_input_channels=in_channels,
67
+ num_classes=out_channels,
68
+ **MEDNEXT_KWARGS,
69
+ )
70
+
71
+ if arch == "swinunetr":
72
+ import inspect
73
+ from monai.networks.nets import SwinUNETR
74
+
75
+ kwargs = dict(SWINUNETR_KWARGS)
76
+ if "img_size" in inspect.signature(SwinUNETR.__init__).parameters:
77
+ kwargs["img_size"] = SWINUNETR_IMG_SIZE
78
+ return SwinUNETR(
79
+ in_channels=in_channels,
80
+ out_channels=out_channels,
81
+ **kwargs,
82
+ )
83
+
84
+ raise ValueError(f"unknown arch {arch!r}; expected one of {ARCHITECTURES}")
src/channels.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ The single source of truth for what every input channel means.
3
+
4
+ There are three different input encodings in this release. Getting them mixed up
5
+ is the easiest way to get silently wrong dose predictions, so they are all
6
+ spelled out here and every checkpoint records which one it expects
7
+ (``ckpt["channel_spec"]``).
8
+
9
+ SOURCE_8CH the 8-channel encoding the *pretrained* checkpoints expect
10
+ HAN_5CH the 5-channel encoding the head-and-neck models expect
11
+ PANCREAS_5CH the 5-channel encoding the pancreas model expects
12
+
13
+ The first five channels line up positionally across all three, which is why
14
+ transfer learning works at all. They are NOT numerically interchangeable --
15
+ see the per-spec notes below.
16
+
17
+ Dose-valued channels are always divided by ``dose_div_factor`` (10), so a 70 Gy
18
+ prescription appears as 7.0.
19
+ """
20
+
21
+ # --------------------------------------------------------------------------
22
+ # 8-channel source-domain encoding (the *_pretrained.pt checkpoints)
23
+ # --------------------------------------------------------------------------
24
+ # Reproduced from the pretraining data loader (GDP-HMM / RT dose challenge
25
+ # codebase), whose pipeline follows Gao et al., "Flexible-CM GAN", CVPR 2023.
26
+ SOURCE_8CH = [
27
+ ("comb_optptv", "Optimisation PTVs (the plan's OPTName structures), each "
28
+ "multiplied by its prescription/10, merged by voxel-wise max."),
29
+ ("comb_ptv", "Planning PTVs (the StructName structures), each multiplied "
30
+ "by its prescription/10, merged by voxel-wise max."),
31
+ ("comb_oar", "All OARs merged into one label-encoded channel: each mask "
32
+ "scaled by 1 + 4*i/30 (i = index in the site's OAR list), "
33
+ "merged by max."),
34
+ ("body", "Binary external / BODY mask."),
35
+ ("img", "CT, clipped to [-1000, 1000] HU and divided by 500 -> [-2, 2]."),
36
+ ("beam_plate", "Beam geometry map: for each gantry angle, rays are traced "
37
+ "from the source through the PTV surface, dilated/eroded and "
38
+ "weighted by inverse-square distance; summed over all angles."),
39
+ ("angle_plate", "2D gantry-angle map replicated over the +/-5 slices around "
40
+ "the isocentre in Z; zero on every other slice."),
41
+ ("prompt_extend", "Plan metadata [isVMAT, n_PTVs, site, cohort] broadcast to a "
42
+ "full volume as four equal blocks stacked along Z."),
43
+ ]
44
+
45
+ # --------------------------------------------------------------------------
46
+ # 5-channel head-and-neck encoding (c3d/mednext/swinunetr _finetuned/_fromscratch)
47
+ # --------------------------------------------------------------------------
48
+ # Channels 5-7 of the source encoding are DROPPED, not zero-filled: the released
49
+ # HaN models physically have a 5-channel input stem. The private HaN dataset has
50
+ # no beam geometry or plan-prompt metadata, so those three channels carried no
51
+ # signal and were removed rather than fed as zeros.
52
+ HAN_5CH = [
53
+ ("comb_optptv", "PTVs multiplied by prescription/10, merged by max. "
54
+ "NOTE: byte-identical to channel 1 -- the HaN loader builds "
55
+ "the optimisation and planning PTV sets from the same "
56
+ "StructName masks. Kept as a separate channel only to match "
57
+ "the pretrained model's layout."),
58
+ ("comb_ptv", "Same as channel 0. See the note above."),
59
+ ("comb_oar", "23 HaN OARs merged into one channel, each mask scaled by "
60
+ "1 + 4*i/30 for i = 1..23, merged by max -> values in "
61
+ "[1.13, 4.07]."),
62
+ ("body", "Binary external / BODY mask."),
63
+ ("img", "CT, clipped to [-1000, 1000] HU and divided by 500 -> [-2, 2]."),
64
+ ]
65
+
66
+ # --------------------------------------------------------------------------
67
+ # 5-channel pancreas encoding (pancreas_c3d_finetuned / _fromscratch)
68
+ # --------------------------------------------------------------------------
69
+ # Same five slots as HAN_5CH, but channels 0, 1 and 2 are computed differently.
70
+ # A HaN-encoded volume fed to the pancreas model (or vice versa) will run
71
+ # without error and give wrong answers.
72
+ PANCREAS_5CH = [
73
+ ("comb_optptv", "PTVs with every overlapping OAR subtracted "
74
+ "(ptv * (1 - any_oar)), then multiplied by prescription/10. "
75
+ "Models the sub-volume that can realistically reach the "
76
+ "prescription. NOT equal to channel 1."),
77
+ ("comb_ptv", "PTVs multiplied by min(prescription/10, 3.0) -- the value is "
78
+ "CAPPED at 30 Gy. A 40 Gy PTV appears as 3.0, not 4.0."),
79
+ ("comb_oar", "14 PancVMAT OARs merged into one channel with two tiers: "
80
+ "dose-limiting OARs (Stomach, Bowel_Small, Bowel_Large and "
81
+ "their PRVs) scale to 2.0 + 0.5*i/14 -> [2.0, 2.5]; all other "
82
+ "OARs scale to 0.5 + 0.3*i/14 -> [0.5, 0.8]."),
83
+ ("body", "Binary external / BODY mask."),
84
+ ("img", "CT, clipped to [-1000, 1000] HU and divided by 500 -> [-2, 2]."),
85
+ ]
86
+
87
+ CHANNEL_SPECS = {
88
+ "source_8ch": SOURCE_8CH,
89
+ "han_5ch": HAN_5CH,
90
+ "pancreas_5ch": PANCREAS_5CH,
91
+ }
92
+
93
+
94
+ def names(spec):
95
+ """Channel names for a spec key, in order."""
96
+ return [n for n, _ in CHANNEL_SPECS[spec]]
97
+
98
+
99
+ def describe(spec):
100
+ """Human-readable description of a channel spec."""
101
+ lines = [f"{spec} ({len(CHANNEL_SPECS[spec])} channels)"]
102
+ for i, (name, doc) in enumerate(CHANNEL_SPECS[spec]):
103
+ lines.append(f" ch{i} {name}")
104
+ lines.append(f" {doc}")
105
+ return "\n".join(lines)
106
+
107
+
108
+ def pad_to_source_8ch(x):
109
+ """
110
+ Zero-pad a 5-channel batch [B, 5, Z, Y, X] to the 8-channel source layout.
111
+
112
+ This lets a *_pretrained.pt checkpoint run on data prepared by the released
113
+ 5-channel loaders, but the result is not a meaningful dose prediction: the
114
+ pretrained model was trained with real beam_plate / angle_plate / prompt
115
+ channels and relies on them. Use it for shape debugging and for warm-starting
116
+ your own fine-tuning, not for evaluation.
117
+ """
118
+ import torch
119
+
120
+ if x.shape[1] != 5:
121
+ raise ValueError(f"expected 5 input channels, got {x.shape[1]}")
122
+ pad = torch.zeros(x.shape[0], 3, *x.shape[2:], dtype=x.dtype, device=x.device)
123
+ return torch.cat((x, pad), dim=1)
124
+
125
+
126
+ if __name__ == "__main__":
127
+ for key in CHANNEL_SPECS:
128
+ print(describe(key))
129
+ print()
src/checkpoint.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Loading the released weights.
3
+
4
+ Every file under weights/ is a plain dict of tensors + primitives, so it loads
5
+ under `torch.load(..., weights_only=True)` -- no custom classes, no pickled
6
+ training objects, no need to have this repo importable to read it.
7
+
8
+ Layout of a checkpoint file::
9
+
10
+ {
11
+ "state_dict": OrderedDict[str, Tensor], # no "module." prefix
12
+ "arch": "c3d" | "mednext" | "swinunetr",
13
+ "in_channels": 8 (pretrained) or 5 (finetuned / fromscratch),
14
+ "out_channels": 1,
15
+ "anatomy": "han" | "pancreas" | "source-domain",
16
+ "regime": "pretrained" | "finetuned" | "fromscratch",
17
+ "act_sig": True, # apply sigmoid to the raw output
18
+ "scale_out": 7.5 | 5.5, # then multiply by this
19
+ "dose_div_factor": 10, # then multiply by this -> dose in Gy
20
+ "source": "...", "note": "...",
21
+ }
22
+
23
+ So the raw network output becomes physical dose as::
24
+
25
+ dose_Gy = sigmoid(out) * scale_out * dose_div_factor
26
+ """
27
+
28
+ import torch
29
+
30
+ from build_model import build_model
31
+
32
+
33
+ def load_checkpoint(path, map_location="cpu"):
34
+ """Read a released checkpoint file. Returns the full dict."""
35
+ return torch.load(path, map_location=map_location, weights_only=True)
36
+
37
+
38
+ def load_model(path, device="cpu", strict=True):
39
+ """
40
+ Build the right architecture for a checkpoint and load its weights.
41
+
42
+ Returns ``(model_in_eval_mode, checkpoint_dict)``. The checkpoint dict
43
+ carries the post-processing constants (``act_sig``, ``scale_out``,
44
+ ``dose_div_factor``) you need to turn the output into Gy.
45
+ """
46
+ ckpt = load_checkpoint(path, map_location="cpu")
47
+
48
+ model = build_model(
49
+ ckpt["arch"],
50
+ in_channels=ckpt["in_channels"],
51
+ out_channels=ckpt.get("out_channels", 1),
52
+ )
53
+ model.load_state_dict(ckpt["state_dict"], strict=strict)
54
+ model.to(device).eval()
55
+ return model, ckpt
56
+
57
+
58
+ def to_dose(output, ckpt):
59
+ """
60
+ Convert a raw network output to dose in Gy using the checkpoint's own
61
+ constants. Handles C3D's ``[output_A, output_B]`` and MedNeXt's optional
62
+ deep-supervision list by taking the refined / main head.
63
+ """
64
+ if isinstance(output, (list, tuple)):
65
+ # C3D: [coarse A, refined B] -> take B. MedNeXt DS: [main, aux...] -> take main.
66
+ output = output[-1] if ckpt["arch"] == "c3d" else output[0]
67
+
68
+ if ckpt.get("act_sig", True):
69
+ output = torch.sigmoid(output)
70
+
71
+ return output * ckpt["scale_out"] * ckpt["dose_div_factor"]
src/data_loader_han.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data loader for the head-and-neck (HaN) dose-prediction models.
3
+
4
+ Produces the 5-channel input the released HaN checkpoints expect:
5
+
6
+ data_dict['data'] : [B, 5, 128, 192, 192] (Z, Y, X)
7
+ ch0 comb_optptv - PTVs weighted by prescription dose (optimisation set)
8
+ ch1 comb_ptv - PTVs weighted by prescription dose
9
+ ch2 comb_oar - all OARs merged into one label-encoded channel
10
+ ch3 body - external/BODY mask
11
+ ch4 img - CT, clipped to [down_HU, up_HU] and divided by denom_norm_HU
12
+ data_dict['label'] : [B, 1, 128, 192, 192] dose, D97-normalised and
13
+ divided by `dose_div_factor`, masked by BODY
14
+
15
+ The *pretrained* (source-domain) checkpoints instead take 8 channels: the five
16
+ above plus beam_plate, angle_plate and prompt_extend. Those three are all-zero
17
+ for this dataset, which is why the released transfer-learned models drop them.
18
+ See README.md for how the input stem was re-initialised.
19
+
20
+ Data pipeline derived from:
21
+ Riqiang Gao, Bin Lou, Zhoubing Xu, Dorin Comaniciu, and Ali Kamen.
22
+ "Flexible-CM GAN: Towards Precise 3D Dose Prediction in Radiotherapy."
23
+ CVPR 2023.
24
+ """
25
+
26
+
27
+
28
+ from torch.utils.data import Dataset, DataLoader
29
+ import pandas as pd
30
+ import torch
31
+ import numpy as np
32
+ import json
33
+ import yaml
34
+
35
+ from scipy import ndimage
36
+ from toolkit_han import *
37
+
38
+ HaN_OAR_LIST = ["Bone_Mandible",
39
+ "Brainstem",
40
+ "Cavity_Oral",
41
+ "Esophagus",
42
+ "Glnd_Thyroid",
43
+ "Larynx",
44
+ "Parotid_L",
45
+ "Parotid_R",
46
+ "SpinalCord",
47
+ "Submandibular_L",
48
+ "Submandibular_R",
49
+
50
+ "Brain",
51
+ "Eye_L",
52
+ "Eye_R",
53
+ "OpticNrv_L",
54
+ "OpticNrv_R",
55
+ "OpticChiasm",
56
+ "Lens_L",
57
+ "Lens_R",
58
+ "Lips",
59
+ "Trachea",
60
+ "Lung_L",
61
+ "SpinalCord_PRV05",
62
+ ] # 23 OARs in HaN
63
+
64
+ HaN_OAR_DICT = {HaN_OAR_LIST[i]: (i+1) for i in range(len(HaN_OAR_LIST))}
65
+
66
+
67
+ class MyDataset(Dataset):
68
+
69
+ def __init__(self, cfig, phase):
70
+ '''
71
+ phase: train, validation, or testing
72
+
73
+ cfig: the configuration dictionary
74
+
75
+ train_bs: training batch size
76
+ val_bs: validation batch size
77
+ num_workers: the number of workers when call the DataLoader of PyTorch
78
+
79
+ csv_root: the meta data file, include patient id and some conditions of the plan.
80
+ scale_dose_dict: path of a dictionary. The dictionary includes the prescribed doses of the PTVs.
81
+ pat_obj_dict: path of a dictionary. The dictionary includes the OARs names used in optimization.
82
+
83
+ down_HU: bottom clip of the CT HU value.
84
+ up_HU: upper clip of the CT HU value.
85
+ denom_norm_HU: the denominator when normalizing the CT.
86
+
87
+ in_size & out_size: the size parameters used in data transformation.
88
+
89
+ norm_oar: True or False. Normalize the OAR channel or not.
90
+ CatStructures: True or False. Concat the PTVs and OARs in multiple channels, or merge them in one channel, respectively.
91
+
92
+ dose_div_factor: the value used to normalize dose.
93
+
94
+ '''
95
+
96
+ self.cfig = cfig
97
+
98
+ df = pd.read_csv(cfig['csv_root'])
99
+ df = df.loc[df['dev_split'] == phase] # !!! Actual train + valid + test split.
100
+
101
+ self.phase = phase
102
+ self.data_list = df['PatientID'].tolist()
103
+ self.site_list = df['site'].tolist()
104
+ self.cohort_list = df['cohort'].tolist()
105
+
106
+ self.scale_dose_Dict = json.load(open(cfig['scale_dose_dict'], 'r'))
107
+ self.pat_obj_dict = json.load(open(cfig['pat_obj_dict'], 'r'))
108
+
109
+ def __len__(self):
110
+ return len(self.data_list)
111
+
112
+ def __getitem__(self, index):
113
+ ID = self.data_list[index]
114
+ PatientID = ID
115
+ if self.phase == 'test':
116
+ root_dir = os.path.join(self.cfig['data_root'], 'test')
117
+ else:
118
+ root_dir = os.path.join(self.cfig['data_root'], 'train_valid')
119
+
120
+ patient_dir = os.path.join(root_dir, ID)
121
+
122
+ ct = load_nifti(os.path.join(patient_dir, 'CT.nii.gz'))
123
+ dose = load_nifti(os.path.join(patient_dir, 'dose.nii.gz'))
124
+ body = load_nifti(os.path.join(patient_dir, 'Masks', 'BODY.nii.gz'))
125
+ # print(f"Loading patient {ID} with CT shape {ct.shape} and dose shape {dose.shape}")
126
+
127
+ # Normalize CT img.
128
+ ct = np.clip(ct, self.cfig['down_HU'], self.cfig['up_HU']) / self.cfig['denom_norm_HU']
129
+ ori_img_size = ct.shape
130
+
131
+ # Normalize dose by D97
132
+ if 'PTV_High' in self.scale_dose_Dict[PatientID]:
133
+ ptv_high = self.scale_dose_Dict[PatientID]['PTV_High']
134
+ pdose = ptv_high['PDose']
135
+ struct_name = ptv_high['StructName']
136
+ ptv_mask = load_nifti(os.path.join(patient_dir, 'Masks', 'PTV', f"{struct_name}.nii.gz"))
137
+
138
+ d97 = np.percentile(dose[ptv_mask > 0], 3)
139
+ norm_scale = pdose / (d97 + 1e-5)
140
+ dose = dose * norm_scale / self.cfig['dose_div_factor']
141
+ dose = np.clip(dose, 0, pdose * 1.2)
142
+
143
+ isocenter = np.array(ct.shape) // 2
144
+
145
+ # Load masks
146
+ if self.site_list[index] < 1.5:
147
+ OAR_LIST = HaN_OAR_LIST
148
+ OAR_DICT = HaN_OAR_DICT
149
+ else:
150
+ raise NotImplementedError("Only HaN site supported in this loader.")
151
+
152
+ try:
153
+ need_list = self.pat_obj_dict[ID.split('+')[0]]
154
+ except:
155
+ need_list = OAR_LIST
156
+
157
+ In_dict = {
158
+ 'img': ct,
159
+ 'body': body,
160
+ 'dose': dose,
161
+ 'isocenter': isocenter,
162
+ }
163
+
164
+
165
+ for name in need_list:
166
+ path = os.path.join(patient_dir, 'Masks', 'OAR', f"{name}.nii.gz")
167
+ if os.path.exists(path):
168
+ In_dict[name] = load_nifti(path)
169
+
170
+ ptv_dict = self.scale_dose_Dict[PatientID]
171
+ opt_dose_dict = {}
172
+ dose_dict = {}
173
+
174
+ for k in ['PTV_High', 'PTV_Mid', 'PTV_Low']:
175
+ if k in ptv_dict:
176
+ pdose = ptv_dict[k]['PDose'] / self.cfig['dose_div_factor']
177
+ struct = ptv_dict[k]['StructName']
178
+ path = os.path.join(patient_dir, 'Masks', 'PTV', f"{struct}.nii.gz")
179
+ if os.path.exists(path):
180
+ In_dict[struct] = load_nifti(path)
181
+ opt_dose_dict[struct] = pdose
182
+ dose_dict[struct] = pdose
183
+
184
+ # Convert to torch tensor
185
+ KEYS = list(In_dict.keys())
186
+ for key in list(KEYS):
187
+ if isinstance(In_dict[key], np.ndarray) and len(In_dict[key].shape) == 3:
188
+ In_dict[key] = torch.from_numpy(In_dict[key].astype('float'))[None]
189
+ else:
190
+ KEYS.remove(key)
191
+
192
+ use_aug = self.cfig.get('with_aug', True)
193
+ if self.phase == 'train':
194
+ if use_aug:
195
+ self.aug = tr_augmentation(KEYS, self.cfig['in_size'], self.cfig['out_size'], isocenter)
196
+ else:
197
+ self.aug = tt_augmentation(KEYS, self.cfig['in_size'], self.cfig['out_size'], isocenter)
198
+ else:
199
+ self.aug = tt_augmentation(KEYS, self.cfig['in_size'], self.cfig['out_size'], isocenter)
200
+
201
+ In_dict = self.aug(In_dict)
202
+
203
+ comb_oar, cat_oar = combine_oar(In_dict, need_list, self.cfig['norm_oar'], OAR_DICT)
204
+ comb_optptv, prs_opt, cat_optptv = combine_ptv(In_dict, opt_dose_dict)
205
+ comb_ptv, _, cat_ptv = combine_ptv(In_dict, dose_dict)
206
+
207
+ # Plan-level metadata. The source-domain (8-channel) models consumed this
208
+ # broadcast into a `prompt_extend` channel; the released 5-channel models
209
+ # do not. It is still returned in data_dict for reference. See channels.py.
210
+ prompt = torch.tensor([1.0, len(prs_opt), self.site_list[index], self.cohort_list[index]]).float()
211
+
212
+ # 5-channel input: comb_optptv, comb_ptv, comb_oar, body, img.
213
+ # The source-domain models additionally took beam_plate, angle_plate and
214
+ # prompt_extend; this dataset has no beam geometry, so those three
215
+ # channels were dropped rather than fed as zeros, and the input stem was
216
+ # re-initialised. See README.md and channels.py.
217
+ if self.cfig['CatStructures']:
218
+ input_data = torch.cat((cat_optptv, cat_ptv, cat_oar, In_dict['body'], In_dict['img']), dim=0)
219
+ else:
220
+ input_data = torch.cat((comb_optptv, comb_ptv, comb_oar, In_dict['body'], In_dict['img']), dim=0)
221
+
222
+
223
+ data_dict = {
224
+ 'data': input_data,
225
+ 'label': In_dict['dose'] * In_dict['body'],
226
+ 'oar': cat_oar,
227
+ 'ptv': cat_ptv,
228
+ 'optptv': cat_optptv,
229
+ 'id': ID,
230
+ 'ori_img_size': torch.tensor(ori_img_size),
231
+ 'ori_isocenter': torch.tensor(isocenter),
232
+ 'prompt': prompt
233
+ }
234
+
235
+ prescribed_dose = [ptv_dict['PTV_High']['PDose'] if 'PTV_High' in ptv_dict else 0]
236
+ prescribed_dose.append(ptv_dict['PTV_Mid']['PDose'] if 'PTV_Mid' in ptv_dict else 0)
237
+ prescribed_dose.append(ptv_dict['PTV_Low']['PDose'] if 'PTV_Low' in ptv_dict else 0)
238
+ data_dict['prescrbed_dose'] = torch.tensor(prescribed_dose).float()
239
+
240
+ return data_dict
241
+
242
+
243
+
244
+ class GetLoader(object):
245
+ def __init__(self, cfig):
246
+ super().__init__()
247
+ self.cfig = cfig
248
+
249
+ def train_dataloader(self):
250
+ dataset = MyDataset(self.cfig, phase='train')
251
+ return DataLoader(dataset, batch_size=self.cfig['train_bs'], shuffle=True, num_workers=self.cfig['num_workers'])
252
+
253
+ def val_dataloader(self):
254
+ dataset = MyDataset(self.cfig, phase='valid')
255
+ return DataLoader(dataset, batch_size=self.cfig['val_bs'], shuffle=False, num_workers=self.cfig['num_workers'])
256
+
257
+ def test_dataloader(self):
258
+ dataset = MyDataset(self.cfig, phase='test')
259
+ return DataLoader(dataset, batch_size=self.cfig['val_bs'], shuffle=False, num_workers=self.cfig['num_workers'])
src/data_loader_pancreas.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data loader for the pancreas (PancVMAT) dose-prediction model.
3
+
4
+ Same 5-channel layout as data_loader_han.py, with two deliberate differences:
5
+
6
+ * `combine_optptv` (see toolkit_pancreas.py) subtracts every overlapping OAR
7
+ from the PTV before building channel 0.
8
+ * The dose target is NOT D97-renormalised (`norm_scale = 1.0`); the planned
9
+ dose is used as-is, only divided by `dose_div_factor`.
10
+
11
+ The OAR list is the 14 PancVMAT structures below, not the 23 HaN ones.
12
+
13
+ Data pipeline derived from:
14
+ Riqiang Gao, Bin Lou, Zhoubing Xu, Dorin Comaniciu, and Ali Kamen.
15
+ "Flexible-CM GAN: Towards Precise 3D Dose Prediction in Radiotherapy."
16
+ CVPR 2023.
17
+ """
18
+
19
+
20
+
21
+ from torch.utils.data import Dataset, DataLoader
22
+ import pandas as pd
23
+ import torch
24
+ import numpy as np
25
+ import json
26
+ import yaml
27
+
28
+ from scipy import ndimage
29
+ from toolkit_pancreas import *
30
+
31
+ Panc_OAR_LIST = ["Kidney_L.nii.gz",
32
+ "Kidney_R.nii.gz",
33
+ "Kidneys.nii.gz",
34
+ "Liver.nii.gz",
35
+ "SpinalCord",
36
+ "SpinalCanal.nii.gz",
37
+ "Esophagus.nii.gz",
38
+ "Heart.nii.gz",
39
+
40
+ "Stomach.nii.gz",
41
+ "Stomach_PRV.nii.gz",
42
+ "Bowel_Small.nii.gz",
43
+ "Bowel_Small_PRV.nii.gz",
44
+ "Bowel_Large.nii.gz",
45
+ "Bowel_Large_PRV.nii.gz",
46
+ ] # 14 OARs in PancVMAT
47
+
48
+ Panc_OAR_DICT = {Panc_OAR_LIST[i]: (i+1) for i in range(len(Panc_OAR_LIST))}
49
+
50
+
51
+ class MyDataset(Dataset):
52
+
53
+ def __init__(self, cfig, phase):
54
+ '''
55
+ phase: train, validation, or testing
56
+
57
+ cfig: the configuration dictionary
58
+
59
+ train_bs: training batch size
60
+ val_bs: validation batch size
61
+ num_workers: the number of workers when call the DataLoader of PyTorch
62
+
63
+ csv_root: the meta data file, include patient id and some conditions of the plan.
64
+ scale_dose_dict: path of a dictionary. The dictionary includes the prescribed doses of the PTVs.
65
+ pat_obj_dict: path of a dictionary. The dictionary includes the OARs names used in optimization.
66
+
67
+ down_HU: bottom clip of the CT HU value.
68
+ up_HU: upper clip of the CT HU value.
69
+ denom_norm_HU: the denominator when normalizing the CT.
70
+
71
+ in_size & out_size: the size parameters used in data transformation.
72
+
73
+ norm_oar: True or False. Normalize the OAR channel or not.
74
+ CatStructures: True or False. Concat the PTVs and OARs in multiple channels, or merge them in one channel, respectively.
75
+
76
+ dose_div_factor: the value used to normalize dose.
77
+
78
+ '''
79
+
80
+ self.cfig = cfig
81
+
82
+ df = pd.read_csv(cfig['csv_root'])
83
+ df = df.loc[df['dev_split'] == phase] # !!! Actual train + valid + test split.
84
+
85
+ self.phase = phase
86
+ self.data_list = df['PatientID'].tolist()
87
+ self.site_list = df['site'].tolist()
88
+ self.cohort_list = df['cohort'].tolist()
89
+
90
+ self.scale_dose_Dict = json.load(open(cfig['scale_dose_dict'], 'r'))
91
+ self.pat_obj_dict = json.load(open(cfig['pat_obj_dict'], 'r'))
92
+ self.pat_obj_dict = json.load(open(cfig['pat_obj_dict'], 'r'))
93
+
94
+ def __len__(self):
95
+ return len(self.data_list)
96
+
97
+ def __getitem__(self, index):
98
+ ID = self.data_list[index]
99
+ PatientID = ID
100
+ if self.phase == 'test':
101
+ root_dir = os.path.join(self.cfig['data_root'], 'test')
102
+ else:
103
+ root_dir = os.path.join(self.cfig['data_root'], 'train_valid')
104
+
105
+ patient_dir = os.path.join(root_dir, ID)
106
+
107
+ ct = load_nifti(os.path.join(patient_dir, 'CT.nii.gz'))
108
+ dose = load_nifti(os.path.join(patient_dir, 'dose.nii.gz'))
109
+ body = load_nifti(os.path.join(patient_dir, 'Masks', 'BODY.nii.gz'))
110
+ # print(f"Loading patient {ID} with CT shape {ct.shape} and dose shape {dose.shape}")
111
+
112
+ # Normalize CT img.
113
+ ct = np.clip(ct, self.cfig['down_HU'], self.cfig['up_HU']) / self.cfig['denom_norm_HU']
114
+ ori_img_size = ct.shape
115
+
116
+ # NO Normalize dose by D95!!!
117
+ if 'PTV_High' in self.scale_dose_Dict[PatientID]:
118
+ ptv_high = self.scale_dose_Dict[PatientID]['PTV_High']
119
+ pdose = ptv_high['PDose']
120
+ struct_name = ptv_high['StructName']
121
+ ptv_mask = load_nifti(os.path.join(patient_dir, 'Masks', 'PTV', f"{struct_name}.nii.gz"))
122
+
123
+ # d95 = np.percentile(dose[ptv_mask > 0], 5)
124
+ # norm_scale = pdose / (d95 + 1e-5)
125
+ norm_scale = 1.0
126
+ dose = dose * norm_scale / self.cfig['dose_div_factor']
127
+ dose = np.clip(dose, 0, pdose * 1.2)
128
+
129
+ isocenter = np.array(ct.shape) // 2
130
+
131
+ # Load masks
132
+ if self.site_list[index] == 2:
133
+ OAR_LIST = Panc_OAR_LIST
134
+ OAR_DICT = Panc_OAR_DICT
135
+ else:
136
+ raise NotImplementedError("Only PancVMAT site supported in this loader.")
137
+
138
+ try:
139
+ need_list = self.pat_obj_dict[ID.split('+')[0]]
140
+ except:
141
+ need_list = OAR_LIST
142
+
143
+ In_dict = {
144
+ 'img': ct,
145
+ 'body': body,
146
+ 'dose': dose,
147
+ 'isocenter': isocenter,
148
+ }
149
+
150
+
151
+ for name in need_list:
152
+ path = os.path.join(patient_dir, 'Masks', 'OAR', f"{name}.nii.gz")
153
+ if os.path.exists(path):
154
+ In_dict[name] = load_nifti(path)
155
+
156
+ ptv_dict = self.scale_dose_Dict[PatientID]
157
+ opt_dose_dict = {}
158
+ dose_dict = {}
159
+
160
+ for k in ['PTV_High', 'PTV_Mid', 'PTV_Low']:
161
+ if k in ptv_dict:
162
+ pdose = ptv_dict[k]['PDose'] / self.cfig['dose_div_factor']
163
+ struct = ptv_dict[k]['StructName']
164
+ path = os.path.join(patient_dir, 'Masks', 'PTV', f"{struct}.nii.gz")
165
+ if os.path.exists(path):
166
+ In_dict[struct] = load_nifti(path)
167
+ opt_dose_dict[struct] = pdose
168
+ dose_dict[struct] = pdose
169
+
170
+ # Convert to torch tensor
171
+ KEYS = list(In_dict.keys())
172
+ for key in list(KEYS):
173
+ if isinstance(In_dict[key], np.ndarray) and len(In_dict[key].shape) == 3:
174
+ In_dict[key] = torch.from_numpy(In_dict[key].astype('float'))[None]
175
+ else:
176
+ KEYS.remove(key)
177
+
178
+ use_aug = self.cfig.get('with_aug', True)
179
+ if self.phase == 'train':
180
+ if use_aug:
181
+ self.aug = tr_augmentation(KEYS, self.cfig['in_size'], self.cfig['out_size'], isocenter)
182
+ else:
183
+ self.aug = tt_augmentation(KEYS, self.cfig['in_size'], self.cfig['out_size'], isocenter)
184
+ else:
185
+ self.aug = tt_augmentation(KEYS, self.cfig['in_size'], self.cfig['out_size'], isocenter)
186
+
187
+ In_dict = self.aug(In_dict)
188
+
189
+ comb_oar, cat_oar = combine_oar(In_dict, need_list, self.cfig['norm_oar'], OAR_DICT)
190
+ comb_optptv, prs_opt, cat_optptv = combine_optptv(In_dict, opt_dose_dict, OAR_DICT)
191
+ comb_ptv, _, cat_ptv = combine_ptv(In_dict, dose_dict)
192
+
193
+ # Plan-level metadata. The source-domain (8-channel) models consumed this
194
+ # broadcast into a `prompt_extend` channel; the released 5-channel models
195
+ # do not. It is still returned in data_dict for reference. See channels.py.
196
+ prompt = torch.tensor([1.0, len(prs_opt), self.site_list[index], self.cohort_list[index]]).float()
197
+
198
+ # 5-channel input: comb_optptv, comb_ptv, comb_oar, body, img.
199
+ # Channels 0-2 are computed differently from the HaN loader -- see
200
+ # channels.py (PANCREAS_5CH). Do not mix the two encodings.
201
+ if self.cfig['CatStructures']:
202
+ input_data = torch.cat((cat_optptv, cat_ptv, cat_oar, In_dict['body'], In_dict['img']), dim=0)
203
+ else:
204
+ input_data = torch.cat((comb_optptv, comb_ptv, comb_oar, In_dict['body'], In_dict['img']), dim=0)
205
+
206
+
207
+ data_dict = {
208
+ 'data': input_data,
209
+ 'label': In_dict['dose'] * In_dict['body'],
210
+ 'oar': cat_oar,
211
+ 'ptv': cat_ptv,
212
+ 'optptv': cat_optptv,
213
+ 'id': ID,
214
+ 'ori_img_size': torch.tensor(ori_img_size),
215
+ 'ori_isocenter': torch.tensor(isocenter),
216
+ 'prompt': prompt
217
+ }
218
+
219
+ prescribed_dose = [ptv_dict['PTV_High']['PDose'] if 'PTV_High' in ptv_dict else 0]
220
+ prescribed_dose.append(ptv_dict['PTV_Mid']['PDose'] if 'PTV_Mid' in ptv_dict else 0)
221
+ prescribed_dose.append(ptv_dict['PTV_Low']['PDose'] if 'PTV_Low' in ptv_dict else 0)
222
+ data_dict['prescrbed_dose'] = torch.tensor(prescribed_dose).float()
223
+
224
+ return data_dict
225
+
226
+
227
+
228
+ class GetLoader(object):
229
+ def __init__(self, cfig):
230
+ super().__init__()
231
+ self.cfig = cfig
232
+
233
+ def train_dataloader(self):
234
+ dataset = MyDataset(self.cfig, phase='train')
235
+ return DataLoader(dataset, batch_size=self.cfig['train_bs'], shuffle=True, num_workers=self.cfig['num_workers'])
236
+
237
+ def val_dataloader(self):
238
+ dataset = MyDataset(self.cfig, phase='valid')
239
+ return DataLoader(dataset, batch_size=self.cfig['val_bs'], shuffle=False, num_workers=self.cfig['num_workers'])
240
+
241
+ def test_dataloader(self):
242
+ dataset = MyDataset(self.cfig, phase='test')
243
+ return DataLoader(dataset, batch_size=self.cfig['val_bs'], shuffle=False, num_workers=self.cfig['num_workers'])
src/inference.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Run a released checkpoint over a test set and write predicted dose as NIfTI.
3
+
4
+ python src/inference.py \
5
+ --ckpt weights/c3d_finetuned.pt \
6
+ --config configs/c3d_han.yaml \
7
+ --out predictions/c3d_finetuned
8
+
9
+ The architecture, input-channel count and dose rescaling all come from the
10
+ checkpoint itself, so the same command works for any file under weights/.
11
+
12
+ Post-processing applied to every case (same as during evaluation):
13
+ 1. raw output -> Gy (sigmoid * scale_out * dose_div_factor)
14
+ 2. clip to [0, 1.2 x prescription of PTV_High] (1.1x for C3D-HaN, see --clip_factor)
15
+ 3. zero everything outside the BODY mask
16
+ 4. copy spacing/origin/direction from the case's CT and write <case_id>_pred.nii.gz
17
+ """
18
+
19
+ import argparse
20
+ import json
21
+ import os
22
+ import sys
23
+ import time
24
+
25
+ import numpy as np
26
+ import SimpleITK as sitk
27
+ import torch
28
+ import yaml
29
+ from tqdm.auto import tqdm
30
+
31
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
32
+
33
+ from channels import describe, pad_to_source_8ch # noqa: E402
34
+ from checkpoint import load_model, to_dose # noqa: E402
35
+
36
+
37
+ def copy_sitk_imageinfo(reference, image):
38
+ image.SetSpacing(reference.GetSpacing())
39
+ image.SetDirection(reference.GetDirection())
40
+ image.SetOrigin(reference.GetOrigin())
41
+ return image
42
+
43
+
44
+ def get_loader(channel_spec, loader_cfig):
45
+ """
46
+ Pick the data loader that produces the channel encoding this checkpoint wants.
47
+
48
+ The two 5-channel encodings are NOT interchangeable -- channels 0, 1 and 2
49
+ are computed differently. Feeding one model the other's inputs runs cleanly
50
+ and silently produces wrong dose. See channels.py.
51
+ """
52
+ if channel_spec == "pancreas_5ch":
53
+ import data_loader_pancreas as dl
54
+ else:
55
+ import data_loader_han as dl
56
+ return dl.GetLoader(cfig=loader_cfig)
57
+
58
+
59
+ def main():
60
+ p = argparse.ArgumentParser(description=__doc__,
61
+ formatter_class=argparse.RawDescriptionHelpFormatter)
62
+ p.add_argument("--ckpt", required=True, help="a file under weights/")
63
+ p.add_argument("--config", required=True, help="a file under configs/")
64
+ p.add_argument("--out", required=True, help="directory for the predicted NIfTIs")
65
+ p.add_argument("--phase", default="test", choices=["train", "valid", "test"],
66
+ help="which dev_split of meta_data.csv to run on")
67
+ p.add_argument("--data_root", default=None,
68
+ help="overrides loader_params.data_root in the config")
69
+ p.add_argument("--clip_factor", type=float, default=None,
70
+ help="clip dose at this multiple of the PTV_High prescription; "
71
+ "defaults to the value the checkpoint was evaluated with")
72
+ p.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
73
+ p.add_argument("--pad_to_8ch", action="store_true",
74
+ help="zero-pad 5-channel inputs to the 8-channel source layout so a "
75
+ "*_pretrained checkpoint will run. Shape-debugging only -- the "
76
+ "padded channels carry real signal during pretraining, so the "
77
+ "output is not a valid dose prediction.")
78
+ p.add_argument("--describe", action="store_true",
79
+ help="print the checkpoint's channel spec and exit")
80
+ args = p.parse_args()
81
+
82
+ cfig = yaml.safe_load(open(args.config))
83
+ loader_cfig = cfig["loader_params"]
84
+ if args.data_root:
85
+ loader_cfig["data_root"] = args.data_root
86
+
87
+ device = torch.device(args.device)
88
+ model, ckpt = load_model(args.ckpt, device=device)
89
+ print(f"Loaded {ckpt['arch']} / {ckpt['regime']} / {ckpt['anatomy']} "
90
+ f"({ckpt['in_channels']}-channel input) from {args.ckpt}")
91
+
92
+ print(describe(ckpt["channel_spec"]))
93
+ if args.describe:
94
+ return
95
+
96
+ if ckpt["in_channels"] == 8 and not args.pad_to_8ch:
97
+ p.error(
98
+ "This is a source-domain *pretrained* checkpoint: it takes 8 input channels, "
99
+ "but the released data loaders emit 5 (no beam_plate / angle_plate / "
100
+ "prompt_extend for this data). It is published as a fine-tuning starting "
101
+ "point, not as a runnable predictor. Use a *_finetuned or *_fromscratch "
102
+ "checkpoint, or pass --pad_to_8ch if you really want a zero-padded forward "
103
+ "pass. See README.md.")
104
+
105
+ clip_factor = args.clip_factor
106
+ if clip_factor is None:
107
+ clip_factor = ckpt.get("eval_clip_factor", 1.2)
108
+
109
+ loaders = get_loader(ckpt["channel_spec"], loader_cfig)
110
+ test_loader = {"train": loaders.train_dataloader,
111
+ "valid": loaders.val_dataloader,
112
+ "test": loaders.test_dataloader}[args.phase]()
113
+
114
+ ptv_dict = json.load(open(loader_cfig["scale_dose_dict"]))
115
+ os.makedirs(args.out, exist_ok=True)
116
+ split_dir = "test" if args.phase == "test" else "train_valid"
117
+
118
+ t0 = time.time()
119
+ n_written = 0
120
+ with torch.no_grad():
121
+ for data_dict in tqdm(test_loader, desc="Running inference"):
122
+ inputs = data_dict["data"].to(device)
123
+ if args.pad_to_8ch:
124
+ inputs = pad_to_source_8ch(inputs)
125
+ dose = to_dose(model(inputs), ckpt)
126
+
127
+ for i in range(len(dose)):
128
+ pred = dose[i, 0].cpu().numpy()
129
+ case_id = data_dict["id"][i]
130
+ key = case_id.split("+")[0]
131
+
132
+ entry = ptv_dict.get(key) or ptv_dict.get(key.zfill(3))
133
+ if entry and "PTV_High" in entry:
134
+ pred = np.clip(pred, 0, entry["PTV_High"]["PDose"] * clip_factor)
135
+ else:
136
+ print(f"[Warning] no PTV_High prescription for {case_id}; clipping at 80 Gy")
137
+ pred = np.clip(pred, 0, 80.0)
138
+
139
+ case_dir = os.path.join(loader_cfig["data_root"], split_dir, case_id)
140
+ ct_path = os.path.join(case_dir, "CT.nii.gz")
141
+ body_path = os.path.join(case_dir, "Masks", "BODY.nii.gz")
142
+ if not (os.path.exists(ct_path) and os.path.exists(body_path)):
143
+ print(f"[Skip] missing CT or BODY for {case_id}")
144
+ continue
145
+
146
+ pred = pred * (sitk.GetArrayFromImage(sitk.ReadImage(body_path)) > 0)
147
+
148
+ pred_img = sitk.GetImageFromArray(pred.astype(np.float32))
149
+ pred_img = copy_sitk_imageinfo(sitk.ReadImage(ct_path), pred_img)
150
+ sitk.WriteImage(pred_img, os.path.join(args.out, f"{case_id}_pred.nii.gz"))
151
+ n_written += 1
152
+
153
+ elapsed = time.time() - t0
154
+ print(f"Wrote {n_written} predictions to {args.out}")
155
+ if n_written:
156
+ print(f"Avg time/scan: {elapsed / n_written:.2f} s")
157
+ if device.type == "cuda":
158
+ print(f"Peak GPU memory: {torch.cuda.max_memory_allocated(device) / 1024 ** 3:.2f} GB")
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
src/model_c3d.py ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ C3D -- the cascade 3D U-Net used for the C3D and pancreas checkpoints.
3
+
4
+ Two U-Nets in series: net_A makes a coarse prediction, net_B refines it from
5
+ the concatenation of net_A's features and the original input. `forward` returns
6
+ ``[output_A, output_B]``; **output_B is the prediction you want**.
7
+
8
+ Architecture as published in:
9
+ Shuolin Liu, Jingjing Zhang, Teng Li, Hui Yan, Jianfei Liu.
10
+ "A Cascade 3D U-Net for Dose Prediction in Radiotherapy." Med. Phys. 2021.
11
+ """
12
+
13
+ import torch
14
+ import torch.nn as nn
15
+ import torch.nn.functional as F
16
+
17
+
18
+ class SingleConv(nn.Module):
19
+ def __init__(self, in_ch, out_ch, kernel_size, stride, padding):
20
+ super(SingleConv, self).__init__()
21
+
22
+ self.single_conv = nn.Sequential(
23
+ nn.Conv3d(in_ch, out_ch, kernel_size=kernel_size, padding=padding, stride=stride, bias=True),
24
+ nn.InstanceNorm3d(out_ch, affine=True),
25
+ nn.ReLU(inplace=True)
26
+ )
27
+
28
+ def forward(self, x):
29
+ return self.single_conv(x)
30
+
31
+
32
+ class UpConv(nn.Module):
33
+ def __init__(self, in_ch, out_ch):
34
+ super(UpConv, self).__init__()
35
+
36
+ self.conv = nn.Sequential(
37
+ nn.Conv3d(in_ch, out_ch, kernel_size=3, padding=1, stride=1, bias=True),
38
+ nn.InstanceNorm3d(out_ch, affine=True),
39
+ nn.ReLU(inplace=True)
40
+ )
41
+
42
+ def forward(self, x):
43
+ x = F.interpolate(x, scale_factor=2, mode='trilinear', align_corners=True)
44
+ x = self.conv(x)
45
+ return x
46
+
47
+
48
+ class Encoder(nn.Module):
49
+ def __init__(self, in_ch, list_ch):
50
+ super(Encoder, self).__init__()
51
+ self.encoder_1 = nn.Sequential(
52
+ SingleConv(in_ch, list_ch[1], kernel_size=3, stride=1, padding=1),
53
+ SingleConv(list_ch[1], list_ch[1], kernel_size=3, stride=1, padding=1)
54
+ )
55
+ self.encoder_2 = nn.Sequential(
56
+ SingleConv(list_ch[1], list_ch[2], kernel_size=3, stride=2, padding=1),
57
+ SingleConv(list_ch[2], list_ch[2], kernel_size=3, stride=1, padding=1)
58
+ )
59
+ self.encoder_3 = nn.Sequential(
60
+ SingleConv(list_ch[2], list_ch[3], kernel_size=3, stride=2, padding=1),
61
+ SingleConv(list_ch[3], list_ch[3], kernel_size=3, stride=1, padding=1)
62
+ )
63
+ self.encoder_4 = nn.Sequential(
64
+ SingleConv(list_ch[3], list_ch[4], kernel_size=3, stride=2, padding=1),
65
+ SingleConv(list_ch[4], list_ch[4], kernel_size=3, stride=1, padding=1)
66
+ )
67
+ self.encoder_5 = nn.Sequential(
68
+ SingleConv(list_ch[4], list_ch[5], kernel_size=3, stride=2, padding=1),
69
+ SingleConv(list_ch[5], list_ch[5], kernel_size=3, stride=1, padding=1)
70
+ )
71
+
72
+ def forward(self, x):
73
+ out_encoder_1 = self.encoder_1(x)
74
+ out_encoder_2 = self.encoder_2(out_encoder_1)
75
+ out_encoder_3 = self.encoder_3(out_encoder_2)
76
+ out_encoder_4 = self.encoder_4(out_encoder_3)
77
+ out_encoder_5 = self.encoder_5(out_encoder_4)
78
+
79
+ return [out_encoder_1, out_encoder_2, out_encoder_3, out_encoder_4, out_encoder_5]
80
+
81
+
82
+ class Decoder(nn.Module):
83
+ def __init__(self, list_ch):
84
+ super(Decoder, self).__init__()
85
+
86
+ self.upconv_4 = UpConv(list_ch[5], list_ch[4])
87
+ self.decoder_conv_4 = nn.Sequential(
88
+ SingleConv(2 * list_ch[4], list_ch[4], kernel_size=3, stride=1, padding=1),
89
+ SingleConv(list_ch[4], list_ch[4], kernel_size=3, stride=1, padding=1)
90
+ )
91
+ self.upconv_3 = UpConv(list_ch[4], list_ch[3])
92
+ self.decoder_conv_3 = nn.Sequential(
93
+ SingleConv(2 * list_ch[3], list_ch[3], kernel_size=3, stride=1, padding=1),
94
+ SingleConv(list_ch[3], list_ch[3], kernel_size=3, stride=1, padding=1)
95
+ )
96
+ self.upconv_2 = UpConv(list_ch[3], list_ch[2])
97
+ self.decoder_conv_2 = nn.Sequential(
98
+ SingleConv(2 * list_ch[2], list_ch[2], kernel_size=3, stride=1, padding=1),
99
+ SingleConv(list_ch[2], list_ch[2], kernel_size=3, stride=1, padding=1)
100
+ )
101
+ self.upconv_1 = UpConv(list_ch[2], list_ch[1])
102
+ self.decoder_conv_1 = nn.Sequential(
103
+ SingleConv(2 * list_ch[1], list_ch[1], kernel_size=3, stride=1, padding=1)
104
+ )
105
+
106
+ def forward(self, out_encoder):
107
+ out_encoder_1, out_encoder_2, out_encoder_3, out_encoder_4, out_encoder_5 = out_encoder
108
+
109
+ out_decoder_4 = self.decoder_conv_4(
110
+ torch.cat((self.upconv_4(out_encoder_5), out_encoder_4), dim=1)
111
+ )
112
+ out_decoder_3 = self.decoder_conv_3(
113
+ torch.cat((self.upconv_3(out_decoder_4), out_encoder_3), dim=1)
114
+ )
115
+ out_decoder_2 = self.decoder_conv_2(
116
+ torch.cat((self.upconv_2(out_decoder_3), out_encoder_2), dim=1)
117
+ )
118
+ out_decoder_1 = self.decoder_conv_1(
119
+ torch.cat((self.upconv_1(out_decoder_2), out_encoder_1), dim=1)
120
+ )
121
+
122
+ return out_decoder_1
123
+
124
+
125
+ class BaseUNet(nn.Module):
126
+ def __init__(self, in_ch, list_ch):
127
+ super(BaseUNet, self).__init__()
128
+ self.encoder = Encoder(in_ch, list_ch)
129
+ self.decoder = Decoder(list_ch)
130
+
131
+ # init
132
+ self.initialize()
133
+
134
+ @staticmethod
135
+ def init_conv_IN(modules):
136
+ for m in modules():
137
+ if isinstance(m, nn.Conv3d):
138
+ nn.init.kaiming_uniform_(m.weight, mode='fan_in', nonlinearity='relu')
139
+ if m.bias is not None:
140
+ nn.init.constant_(m.bias, 0.)
141
+ elif isinstance(m, nn.InstanceNorm3d):
142
+ nn.init.constant_(m.weight, 1.)
143
+ nn.init.constant_(m.bias, 0.)
144
+
145
+ def initialize(self):
146
+ # Kaiming-uniform init. Irrelevant when you immediately load released
147
+ # weights over it; it matters if you build the model to train from scratch.
148
+ self.init_conv_IN(self.decoder.modules)
149
+ self.init_conv_IN(self.encoder.modules)
150
+
151
+ def forward(self, x):
152
+ out_encoder = self.encoder(x)
153
+ out_decoder = self.decoder(out_encoder)
154
+
155
+ # Output is a list: [Output]
156
+ return out_decoder
157
+
158
+
159
+ class Model(nn.Module):
160
+ def __init__(self, in_ch, out_ch, list_ch_A, list_ch_B):
161
+ super(Model, self).__init__()
162
+
163
+ # list_ch records the number of channels in each stage, eg. [-1, 32, 64, 128, 256, 512]
164
+ self.net_A = BaseUNet(in_ch, list_ch_A)
165
+ self.net_B = BaseUNet(in_ch + list_ch_A[1], list_ch_B)
166
+
167
+ self.conv_out_A = nn.Conv3d(list_ch_A[1], out_ch, kernel_size=1, padding=0, bias=True)
168
+ self.conv_out_B = nn.Conv3d(list_ch_B[1], out_ch, kernel_size=1, padding=0, bias=True)
169
+
170
+ def forward(self, x):
171
+ out_net_A = self.net_A(x)
172
+ out_net_B = self.net_B(torch.cat((out_net_A, x), dim=1))
173
+
174
+ output_A = self.conv_out_A(out_net_A)
175
+ output_B = self.conv_out_B(out_net_B)
176
+ return [output_A, output_B]
src/toolkit_han.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data-loading helpers for the HaN dose-prediction models.
3
+
4
+ Trimmed from the original research toolkit: only the functions the released
5
+ data loader and inference script actually call are kept, which drops the
6
+ cv2 / matplotlib / SimpleITK plotting dependencies.
7
+
8
+ Derived from the data pipeline of:
9
+ Riqiang Gao, Bin Lou, Zhoubing Xu, Dorin Comaniciu, Ali Kamen.
10
+ "Flexible-CM GAN: Towards Precise 3D Dose Prediction in Radiotherapy." CVPR 2023.
11
+ """
12
+
13
+ import os
14
+
15
+ import numpy as np
16
+ import torch
17
+ import nibabel as nib
18
+ from monai.transforms import (
19
+ Compose,
20
+ Resized,
21
+ RandFlipd,
22
+ RandRotated,
23
+ SpatialPadd,
24
+ SpatialCropd,
25
+ RandSpatialCropd,
26
+ )
27
+
28
+
29
+ def combine_oar(tmp_dict, need_list,norm_oar = True, OAR_DICT = None):
30
+
31
+ '''
32
+ this function is used to support the data loader.
33
+
34
+ tmp_dict: the dictionary of the data loaded from the npz file
35
+ need_list: the list of the OARs needed to be combined
36
+ norm_oar: if True, the OARs will be normalized to the same scale
37
+ OAR_DICT: the dictionary of the OARs, the key is the name of the OAR, the value is the index of the OAR in the combined
38
+ '''
39
+
40
+ comb_oar = torch.zeros(tmp_dict['img'].shape)
41
+ cat_oar = torch.zeros([len(OAR_DICT)] + list(tmp_dict['img'].shape)[1:])
42
+ for key in OAR_DICT.keys():
43
+
44
+ if key not in need_list:
45
+ continue
46
+
47
+ if key in tmp_dict.keys():
48
+ single_oar = tmp_dict[key]
49
+ else:
50
+ single_oar = torch.zeros(tmp_dict['img'].shape)
51
+
52
+ cat_oar[OAR_DICT[key]-1: OAR_DICT[key]] = single_oar
53
+
54
+ if norm_oar:
55
+ comb_oar = torch.maximum(comb_oar, single_oar.round() * (1.0 + 4.0 * OAR_DICT[key] / 30))
56
+ else:
57
+ comb_oar = torch.maximum(comb_oar, single_oar.round() * OAR_DICT[key]) # changed in this version
58
+
59
+ return comb_oar, cat_oar
60
+
61
+
62
+ def combine_ptv(tmp_dict, scaled_dose_dict):
63
+
64
+ prescribed_dose = []
65
+
66
+ cat_ptv = torch.zeros([3] + list(tmp_dict['img'].shape)[1:])
67
+ prescribed_dose = [0] * 3
68
+
69
+ comb_ptv = torch.zeros(tmp_dict['img'].shape)
70
+
71
+ cnt = 0
72
+ for key in scaled_dose_dict.keys():
73
+
74
+ tmp_ptv = tmp_dict[key] * scaled_dose_dict[key]
75
+
76
+ prescribed_dose[cnt] = scaled_dose_dict[key]
77
+
78
+ cat_ptv[cnt] = tmp_ptv
79
+ comb_ptv = torch.maximum(comb_ptv, tmp_ptv)
80
+
81
+ cnt += 1
82
+
83
+ # sort the cat_ptv according to the prescribed dose
84
+ paired = [(cat_ptv[i], prescribed_dose[i]) for i in range(len(prescribed_dose))]
85
+ paired_sorted = sorted(paired, key=lambda x: x[1], reverse=True)
86
+ cat_ptv = torch.stack([x[0] for x in paired_sorted])
87
+ prescribed_dose = [x[1] for x in paired_sorted]
88
+
89
+ return comb_ptv, prescribed_dose, cat_ptv
90
+
91
+
92
+ def tr_augmentation(KEYS, in_size, out_size, crop_center):
93
+ return Compose([
94
+ SpatialCropd(keys = KEYS, roi_center = crop_center, roi_size = [int(in_size[0] * 1.2), int(in_size[1] * 1.2), int(in_size[2] * 1.2)], allow_missing_keys = True),
95
+ SpatialPadd(keys = KEYS, spatial_size = [int(in_size[0] * 1.2), int(in_size[1] * 1.2), int(in_size[2] * 1.2)], mode = 'constant', allow_missing_keys = True),
96
+ RandSpatialCropd(keys = KEYS, roi_size = [int(in_size[0] * 0.85), int(in_size[1] * 0.85), int(in_size[2] * 0.85)], max_roi_size = [int(in_size[0] * 1.2), int(in_size[1] * 1.2), int(in_size[2] * 1.2)], random_center = True, random_size = True, allow_missing_keys = True),
97
+ RandRotated(keys = KEYS, prob=0.8, range_x= 1, range_y = 0.2, range_z = 0.2, allow_missing_keys = True),
98
+ RandFlipd(keys = KEYS, prob = 0.4, spatial_axis = 0, allow_missing_keys = True),
99
+ RandFlipd(keys = KEYS, prob = 0.4, spatial_axis = 1, allow_missing_keys = True),
100
+ RandFlipd(keys = KEYS, prob = 0.4, spatial_axis = 2, allow_missing_keys = True),
101
+ Resized(keys = KEYS, spatial_size = out_size, allow_missing_keys = True),
102
+ ])
103
+
104
+ def tt_augmentation(KEYS, in_size, out_size, crop_center):
105
+ return Compose([
106
+ SpatialCropd(keys = KEYS, roi_center = crop_center, roi_size = in_size, allow_missing_keys = True),
107
+ SpatialPadd(keys = KEYS, spatial_size = in_size, mode = 'constant', allow_missing_keys = True),
108
+ Resized(keys = KEYS, spatial_size = out_size, allow_missing_keys = True),
109
+ ])
110
+
111
+ def load_nifti(path):
112
+ """
113
+ Load a .nii or .nii.gz file and return a numpy array.
114
+ torch shape: [B, C, Z, Y, X] where B=1, C=1, Z=depth, Y=height, X=width
115
+ """
116
+ nii = nib.load(path)
117
+ return np.transpose(nii.get_fdata(), (2, 1, 0)).astype(np.float32)
src/toolkit_pancreas.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data-loading helpers for the pancreas (PancVMAT) dose-prediction model.
3
+
4
+ Trimmed from the original research toolkit: only the functions the released
5
+ data loader and inference script actually call are kept.
6
+
7
+ Differs from toolkit_han.py in `combine_optptv`, which subtracts every
8
+ overlapping OAR from the PTV before building the "optimisation PTV" channel.
9
+ Keep the two files separate -- swapping them changes model inputs.
10
+ """
11
+
12
+ import os
13
+
14
+ import numpy as np
15
+ import torch
16
+ import nibabel as nib
17
+ from monai.transforms import (
18
+ Compose,
19
+ Resized,
20
+ RandFlipd,
21
+ RandRotated,
22
+ SpatialPadd,
23
+ SpatialCropd,
24
+ RandSpatialCropd,
25
+ )
26
+
27
+
28
+ def combine_optptv(tmp_dict, opt_dose_dict, OAR_DICT):
29
+ '''
30
+ Combine the optimal PTV and the dose of the optimal PTV
31
+ 从PTV mask中扣除与所有OAR重叠的部分
32
+
33
+ 这个修改的目的是解决放疗中的关键问题:
34
+ - 当PTV与OAR重叠时,重叠区域可能无法达到处方剂量
35
+ - 通过从PTV中扣除所有OAR重叠区域,可以更准确地表示实际可达到处方剂量的区域
36
+ - 使用OAR_DICT中的所有OAR
37
+
38
+ Args:
39
+ tmp_dict: 包含所有mask的字典
40
+ opt_dose_dict: 包含处方剂量的字典
41
+ OAR_DICT: OAR索引字典
42
+
43
+ Returns:
44
+ comb_optptv: 组合的优化PTV (扣除OAR重叠后)
45
+ prs_opt: 处方剂量列表
46
+ cat_optptv: 分别的优化PTV通道
47
+ '''
48
+ comb_optptv = torch.zeros(tmp_dict['img'].shape)
49
+ cat_optptv = torch.zeros([3] + list(tmp_dict['img'].shape)[1:]) # 假设最多3个PTV
50
+ prs_opt = []
51
+
52
+ # 创建组合的所有OAR mask
53
+ # 使用OAR_DICT中的所有OAR
54
+ # 注意:OAR_DICT的key带.nii.gz后缀,但tmp_dict中的key可能不带后缀
55
+ combined_oar_mask = torch.zeros(tmp_dict['img'].shape)
56
+ for oar_name in OAR_DICT.keys():
57
+ # 尝试多种可能的key格式
58
+ oar_key = None
59
+ for possible_key in [oar_name, oar_name.replace('.nii.gz', ''), f"{oar_name}.nii.gz" if not oar_name.endswith('.nii.gz') else oar_name]:
60
+ if possible_key in tmp_dict:
61
+ oar_key = possible_key
62
+ break
63
+
64
+ if oar_key is not None:
65
+ combined_oar_mask = torch.maximum(combined_oar_mask, tmp_dict[oar_key])
66
+
67
+ cnt = 0
68
+ for key in opt_dose_dict.keys():
69
+ if key in tmp_dict:
70
+ original_ptv_mask = tmp_dict[key]
71
+ prescribed_dose = opt_dose_dict[key]
72
+
73
+ # 从PTV mask中扣除与所有OAR重叠的部分
74
+ # 只保留不与任何OAR重叠的PTV区域
75
+ # 使用 > 0 来创建binary mask,确保OAR区域被完全排除
76
+ oar_binary_mask = (combined_oar_mask > 0).float()
77
+ modified_ptv_mask = original_ptv_mask * (1 - oar_binary_mask)
78
+
79
+ # 创建优化的PTV(应用处方剂量)
80
+ tmp_optptv = modified_ptv_mask * prescribed_dose
81
+
82
+ # 存储到cat_optptv中(如果有足够的通道)
83
+ if cnt < cat_optptv.shape[0]:
84
+ cat_optptv[cnt] = tmp_optptv
85
+
86
+ # 更新组合的optptv
87
+ comb_optptv = torch.maximum(comb_optptv, tmp_optptv)
88
+
89
+ # 记录处方剂量
90
+ prs_opt.append(prescribed_dose)
91
+
92
+ cnt += 1
93
+
94
+ return comb_optptv, prs_opt, cat_optptv
95
+
96
+
97
+
98
+ def combine_oar(tmp_dict, need_list, norm_oar=True, OAR_DICT=None):
99
+ '''
100
+ Revised to emphasize Limiting OARs (Stomach, Bowel) over General OARs
101
+ '''
102
+ comb_oar = torch.zeros(tmp_dict['img'].shape)
103
+ cat_oar = torch.zeros([len(OAR_DICT)] + list(tmp_dict['img'].shape)[1:])
104
+
105
+ # Define Limiting OARs (need_list不带.nii.gz后缀,所以这里也不带后缀)
106
+ LIMITING_OARS = [
107
+ "Stomach", "Stomach_PRV",
108
+ "Bowel_Small", "Bowel_Small_PRV",
109
+ "Bowel_Large", "Bowel_Large_PRV"
110
+ ]
111
+
112
+
113
+ for key in OAR_DICT.keys():
114
+ # need_list来自oar_dict.json,不带.nii.gz后缀
115
+ # OAR_DICT的key带.nii.gz后缀(除了SpinalCord)
116
+ # 需要去掉.nii.gz后缀来匹配need_list
117
+ key_without_suffix = key.replace('.nii.gz', '')
118
+ key_in_need_list = (key_without_suffix in need_list)
119
+
120
+ if not key_in_need_list:
121
+ continue
122
+
123
+ # 尝试在tmp_dict中找到数据
124
+ # tmp_dict中的key可能是带或不带.nii.gz的格式
125
+ single_oar = None
126
+
127
+ # 尝试多种可能的key格式:原始key、去掉后缀、添加后缀
128
+ for possible_key in [key, key_without_suffix, f"{key_without_suffix}.nii.gz"]:
129
+ if possible_key in tmp_dict:
130
+ single_oar = tmp_dict[possible_key]
131
+ break
132
+
133
+ if single_oar is None:
134
+ single_oar = torch.zeros(tmp_dict['img'].shape)
135
+
136
+ cat_oar[OAR_DICT[key]-1: OAR_DICT[key]] = single_oar
137
+
138
+ # --- Special Handling for Limiting OARs Start ---
139
+ if norm_oar:
140
+ # Check if current OAR is a Limiting OAR
141
+ # need_list不带.nii.gz,所以用key_without_suffix来检查
142
+ is_limiting = (key_without_suffix in LIMITING_OARS)
143
+
144
+ if is_limiting:
145
+ # Limiting OARs: base value set to 2.0, with a small gradient to distinguish different bowel segments.
146
+ # Value range: 2.0 ~ 2.5 (high enough, and significantly different from normal organs)
147
+ # The index weight is very small, only to distinguish slightly, not to significantly increase the value
148
+ base_val = 2.0
149
+ scale_val = base_val + 0.5 * (OAR_DICT[key] / len(OAR_DICT))
150
+ else:
151
+ # Normal organs: value range 0.5 ~ 0.8
152
+ base_val = 0.5
153
+ scale_val = base_val + 0.3 * (OAR_DICT[key] / len(OAR_DICT))
154
+
155
+ # Assign value
156
+ comb_oar = torch.maximum(comb_oar, single_oar.round() * scale_val)
157
+
158
+ else:
159
+ # If not normalize, keep the original logic or modify according to needs
160
+ comb_oar = torch.maximum(comb_oar, single_oar.round() * OAR_DICT[key])
161
+ # --- Special Handling for Limiting OARs End ---
162
+
163
+ return comb_oar, cat_oar
164
+
165
+
166
+
167
+ def combine_ptv(tmp_dict, scaled_dose_dict):
168
+
169
+ prescribed_dose = []
170
+
171
+ cat_ptv = torch.zeros([3] + list(tmp_dict['img'].shape)[1:])
172
+ prescribed_dose = [0] * 3
173
+
174
+ comb_ptv = torch.zeros(tmp_dict['img'].shape)
175
+
176
+ cnt = 0
177
+ for key in scaled_dose_dict.keys():
178
+ original_rx = scaled_dose_dict[key]
179
+ target_val = min(original_rx, 3.0)
180
+ # tmp_ptv = tmp_dict[key] * scaled_dose_dict[key]
181
+ tmp_ptv = tmp_dict[key] * target_val # v4: set the prescribed dose to 30 Gy
182
+
183
+ prescribed_dose[cnt] = scaled_dose_dict[key]
184
+
185
+ cat_ptv[cnt] = tmp_ptv
186
+ comb_ptv = torch.maximum(comb_ptv, tmp_ptv)
187
+
188
+ cnt += 1
189
+
190
+ # sort the cat_ptv according to the prescribed dose
191
+ paired = [(cat_ptv[i], prescribed_dose[i]) for i in range(len(prescribed_dose))]
192
+ paired_sorted = sorted(paired, key=lambda x: x[1], reverse=True)
193
+ cat_ptv = torch.stack([x[0] for x in paired_sorted])
194
+ prescribed_dose = [x[1] for x in paired_sorted]
195
+
196
+ return comb_ptv, prescribed_dose, cat_ptv
197
+
198
+
199
+
200
+ def tr_augmentation(KEYS, in_size, out_size, crop_center):
201
+ return Compose([
202
+ SpatialCropd(keys = KEYS, roi_center = crop_center, roi_size = [int(in_size[0] * 1.2), int(in_size[1] * 1.2), int(in_size[2] * 1.2)], allow_missing_keys = True),
203
+ SpatialPadd(keys = KEYS, spatial_size = [int(in_size[0] * 1.2), int(in_size[1] * 1.2), int(in_size[2] * 1.2)], mode = 'constant', allow_missing_keys = True),
204
+ RandSpatialCropd(keys = KEYS, roi_size = [int(in_size[0] * 0.85), int(in_size[1] * 0.85), int(in_size[2] * 0.85)], max_roi_size = [int(in_size[0] * 1.2), int(in_size[1] * 1.2), int(in_size[2] * 1.2)], random_center = True, random_size = True, allow_missing_keys = True),
205
+ RandRotated(keys = KEYS, prob=0.8, range_x= 1, range_y = 0.2, range_z = 0.2, allow_missing_keys = True),
206
+ RandFlipd(keys = KEYS, prob = 0.4, spatial_axis = 0, allow_missing_keys = True),
207
+ RandFlipd(keys = KEYS, prob = 0.4, spatial_axis = 1, allow_missing_keys = True),
208
+ RandFlipd(keys = KEYS, prob = 0.4, spatial_axis = 2, allow_missing_keys = True),
209
+ Resized(keys = KEYS, spatial_size = out_size, allow_missing_keys = True),
210
+ ])
211
+
212
+ def tt_augmentation(KEYS, in_size, out_size, crop_center):
213
+ return Compose([
214
+ SpatialCropd(keys = KEYS, roi_center = crop_center, roi_size = in_size, allow_missing_keys = True),
215
+ SpatialPadd(keys = KEYS, spatial_size = in_size, mode = 'constant', allow_missing_keys = True),
216
+ Resized(keys = KEYS, spatial_size = out_size, allow_missing_keys = True),
217
+ ])
218
+
219
+ def load_nifti(path):
220
+ """
221
+ Load a .nii or .nii.gz file and return a numpy array.
222
+ torch shape: [B, C, Z, Y, X] where B=1, C=1, Z=depth, Y=height, X=width
223
+ """
224
+ nii = nib.load(path)
225
+ return np.transpose(nii.get_fdata(), (2, 1, 0)).astype(np.float32)
src/transfer.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Warm-starting your own model from a released checkpoint.
3
+
4
+ This is the piece most people actually want: the pretrained checkpoints take
5
+ 8 input channels, and almost certainly your data does not have the beam-geometry
6
+ and plan-prompt channels the source domain had. `load_pretrained_into` copies
7
+ every tensor whose shape matches and leaves the rest at their fresh
8
+ initialisation, which for these three architectures means only the input stem is
9
+ re-initialised:
10
+
11
+ C3D 170 / 172 tensors transferred (net_A + net_B first conv skipped)
12
+ MedNeXt 228 / 229 tensors transferred (stem.weight skipped)
13
+ SwinUNETR 164 / 167 tensors transferred (patch_embed.proj, encoder1 conv1/conv3)
14
+
15
+ This is exactly how the released *_finetuned checkpoints were produced. No
16
+ channel slicing, averaging or repetition was applied to the input stem -- it was
17
+ simply retrained from scratch.
18
+
19
+ Example
20
+ -------
21
+ from build_model import build_model
22
+ from transfer import load_pretrained_into
23
+
24
+ model = build_model("c3d", in_channels=5) # your channel count
25
+ report = load_pretrained_into(model, "weights/c3d_pretrained.pt")
26
+ print(report)
27
+ # ... then train as usual; a low LR (3e-5, cosine) worked best for us.
28
+ """
29
+
30
+ from dataclasses import dataclass, field
31
+ from typing import List
32
+
33
+ import torch
34
+
35
+
36
+ @dataclass
37
+ class TransferReport:
38
+ transferred: List[str] = field(default_factory=list)
39
+ shape_mismatch: List[str] = field(default_factory=list)
40
+ missing_in_checkpoint: List[str] = field(default_factory=list)
41
+ unused_in_checkpoint: List[str] = field(default_factory=list)
42
+
43
+ def __str__(self):
44
+ n_ok = len(self.transferred)
45
+ n_total = n_ok + len(self.shape_mismatch) + len(self.missing_in_checkpoint)
46
+ lines = [f"transferred {n_ok}/{n_total} tensors"]
47
+ for label, items in (
48
+ ("re-initialised (shape mismatch)", self.shape_mismatch),
49
+ ("re-initialised (absent from checkpoint)", self.missing_in_checkpoint),
50
+ ("ignored (not in your model)", self.unused_in_checkpoint),
51
+ ):
52
+ if items:
53
+ lines.append(f" {label}: {len(items)}")
54
+ lines.extend(f" - {k}" for k in items)
55
+ return "\n".join(lines)
56
+
57
+
58
+ def load_pretrained_into(model, ckpt_path, verbose=True):
59
+ """
60
+ Copy every shape-compatible tensor from a released checkpoint into `model`.
61
+
62
+ Tensors that do not match (different input-channel count, different output
63
+ head, ...) keep whatever `model` initialised them to. Returns a
64
+ TransferReport naming exactly what was and was not transferred -- read it,
65
+ do not assume.
66
+ """
67
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=True)
68
+ src = ckpt["state_dict"] if "state_dict" in ckpt else ckpt
69
+ src = {k[len("module."):] if k.startswith("module.") else k: v
70
+ for k, v in src.items()}
71
+
72
+ own = model.state_dict()
73
+ report = TransferReport()
74
+ staged = {}
75
+
76
+ for name, tensor in own.items():
77
+ if name not in src:
78
+ report.missing_in_checkpoint.append(name)
79
+ elif src[name].shape != tensor.shape:
80
+ report.shape_mismatch.append(
81
+ f"{name}: checkpoint {tuple(src[name].shape)} vs model {tuple(tensor.shape)}")
82
+ else:
83
+ staged[name] = src[name]
84
+ report.transferred.append(name)
85
+
86
+ report.unused_in_checkpoint = [k for k in src if k not in own]
87
+
88
+ own.update(staged)
89
+ model.load_state_dict(own)
90
+
91
+ if verbose:
92
+ print(f"[transfer] {ckpt_path} -> {type(model).__name__}")
93
+ print(report)
94
+ return report
tools/convert_checkpoints.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Convert the raw training checkpoints into the released weights/*.pt files.
3
+
4
+ The originals are of two kinds:
5
+
6
+ * ``Pretrained/*/best_val.pth`` -- already a bare state_dict.
7
+ * ``Output/*/best_val_evaluation_index.pkl`` -- a dict with the network
8
+ state_dict *plus* optimizer state, LR-scheduler state and a pickled
9
+ ``network_trainer.TrainerLog`` object. Those are 4-30x larger than the
10
+ weights and cannot be read without this repo's training module on the path.
11
+
12
+ Both are normalised here into a small, self-describing, ``weights_only=True``-safe
13
+ file. See src/checkpoint.py for the resulting layout.
14
+
15
+ This script is kept for provenance -- you do not need to run it to use the
16
+ released weights.
17
+ """
18
+
19
+ import argparse
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import sys
24
+ import types
25
+ from collections import OrderedDict
26
+
27
+ import torch
28
+
29
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src"))
30
+ from channels import CHANNEL_SPECS, names # noqa: E402
31
+
32
+ # The .pkl files pickle a TrainerLog instance. Stub the module out so we can
33
+ # unpickle them without dragging in the training code (which we are not
34
+ # releasing). We only ever read the 'network_state_dict' key.
35
+ _stub = types.ModuleType("network_trainer")
36
+
37
+
38
+ class _Placeholder:
39
+ def __setstate__(self, state):
40
+ self.__dict__.update(state if isinstance(state, dict) else {})
41
+
42
+
43
+ for _name in ("TrainerLog", "TrainerSetting", "TrainerTime", "NetworkTrainer"):
44
+ setattr(_stub, _name, type(_name, (_Placeholder,), {}))
45
+ sys.modules.setdefault("network_trainer", _stub)
46
+
47
+
48
+ # (output name, source path, arch, in_channels, anatomy, regime, scale_out,
49
+ # eval_clip_factor, note)
50
+ SPECS = [
51
+ # ---------------- head and neck: C3D ----------------
52
+ ("c3d_pretrained.pt", "Pretrained/C3D_128x128x160/best_val.pth",
53
+ "c3d", 8, "source-domain", "pretrained", 7.5, 1.1,
54
+ "Source-domain model trained on the public 8-channel dose dataset. "
55
+ "Starting point for c3d_finetuned.pt and pancreas_c3d_finetuned.pt."),
56
+ ("c3d_finetuned.pt", "Output/C3D/Finetune_v2/best_val_evaluation_index.pkl",
57
+ "c3d", 5, "han", "finetuned", 7.5, 1.1,
58
+ "Fine-tuned from c3d_pretrained.pt on head-and-neck data; Adam lr 3e-5, "
59
+ "cosine schedule, 275 epochs."),
60
+ ("c3d_fromscratch.pt", "Output/C3D/FromScratch/best_val_evaluation_index.pkl",
61
+ "c3d", 5, "han", "fromscratch", 7.5, 1.1,
62
+ "Same architecture and schedule trained from random init; Adam lr 1e-4. "
63
+ "The no-transfer baseline."),
64
+
65
+ # ---------------- head and neck: MedNeXt ----------------
66
+ ("mednext_pretrained.pt", "Pretrained/MedNeXt/best_val.pth",
67
+ "mednext", 8, "source-domain", "pretrained", 7.5, 1.2,
68
+ "Source-domain MedNeXt-B trained on the public 8-channel dose dataset."),
69
+ ("mednext_finetuned.pt", "Output/MedNeXt/Finetune/best_val_evaluation_index.pkl",
70
+ "mednext", 5, "han", "finetuned", 7.5, 1.2,
71
+ "Fine-tuned from mednext_pretrained.pt on head-and-neck data."),
72
+ ("mednext_fromscratch.pt", "Output/MedNeXt/FromScratch/best_val_evaluation_index.pkl",
73
+ "mednext", 5, "han", "fromscratch", 7.5, 1.2,
74
+ "MedNeXt-B trained from random init. The no-transfer baseline."),
75
+
76
+ # ---------------- head and neck: SwinUNETR ----------------
77
+ ("swinunetr_pretrained.pt", "Pretrained/SwinUNETR_L_128x128x160/best_val.pth",
78
+ "swinunetr", 8, "source-domain", "pretrained", 7.5, 1.2,
79
+ "Source-domain SwinUNETR-L trained on the public 8-channel dose dataset."),
80
+ ("swinunetr_finetuned.pt", "Output/SwinUNETR_L/Finetune/best_val_evaluation_index.pkl",
81
+ "swinunetr", 5, "han", "finetuned", 7.5, 1.2,
82
+ "Fine-tuned from swinunetr_pretrained.pt on head-and-neck data."),
83
+ ("swinunetr_fromscratch.pt", "Output/SwinUNETR_L/FromScratch/best_val_evaluation_index.pkl",
84
+ "swinunetr", 5, "han", "fromscratch", 7.5, 1.2,
85
+ "SwinUNETR-L trained from random init. The no-transfer baseline."),
86
+
87
+ # ---------------- pancreas: C3D ----------------
88
+ ("pancreas_c3d_pretrained.pt", "Pretrained/C3D_128x128x160/best_val.pth",
89
+ "c3d", 8, "source-domain", "pretrained", 5.5, 1.2,
90
+ "Byte-identical weights to c3d_pretrained.pt; duplicated so the pancreas "
91
+ "set is self-contained. Only scale_out differs (5.5 for pancreas)."),
92
+ ("pancreas_c3d_finetuned.pt",
93
+ "Output/Pancreas/PancC3D/FineTune_v5/best_val_evaluation_index.pkl",
94
+ "c3d", 5, "pancreas", "finetuned", 5.5, 1.2,
95
+ "Fine-tuned directly from the source-domain pretrained C3D onto pancreas "
96
+ "VMAT data -- NOT from the head-and-neck model."),
97
+ ("pancreas_c3d_fromscratch.pt",
98
+ "Output/Pancreas/PancC3D/FromScratch/best_val_evaluation_index.pkl",
99
+ "c3d", 5, "pancreas", "fromscratch", 5.5, 1.2,
100
+ "C3D trained from random init on pancreas VMAT data. The no-transfer baseline."),
101
+ ]
102
+
103
+
104
+ def extract_state_dict(path):
105
+ raw = torch.load(path, map_location="cpu", weights_only=False)
106
+ if isinstance(raw, dict) and "network_state_dict" in raw:
107
+ raw = raw["network_state_dict"]
108
+ clean = OrderedDict()
109
+ for k, v in raw.items():
110
+ clean[k[len("module."):] if k.startswith("module.") else k] = v.cpu()
111
+ return clean
112
+
113
+
114
+ def sha256(path, chunk=1 << 20):
115
+ h = hashlib.sha256()
116
+ with open(path, "rb") as f:
117
+ for block in iter(lambda: f.read(chunk), b""):
118
+ h.update(block)
119
+ return h.hexdigest()
120
+
121
+
122
+ def main():
123
+ p = argparse.ArgumentParser()
124
+ p.add_argument("--src_root", default="..",
125
+ help="directory containing Pretrained/ and Output/")
126
+ p.add_argument("--out_dir", default="weights")
127
+ args = p.parse_args()
128
+
129
+ os.makedirs(args.out_dir, exist_ok=True)
130
+ manifest = []
131
+
132
+ for (name, rel, arch, in_ch, anatomy, regime,
133
+ scale_out, clip, note) in SPECS:
134
+ src = os.path.join(args.src_root, rel)
135
+ state_dict = extract_state_dict(src)
136
+
137
+ if in_ch == 8:
138
+ spec = "source_8ch"
139
+ elif anatomy == "pancreas":
140
+ spec = "pancreas_5ch"
141
+ else:
142
+ spec = "han_5ch"
143
+ assert len(CHANNEL_SPECS[spec]) == in_ch, (spec, in_ch)
144
+
145
+ ckpt = {
146
+ "state_dict": state_dict,
147
+ "arch": arch,
148
+ "in_channels": in_ch,
149
+ "channel_spec": spec,
150
+ "channel_names": names(spec),
151
+ "out_channels": 1,
152
+ "anatomy": anatomy,
153
+ "regime": regime,
154
+ "act_sig": True,
155
+ "scale_out": scale_out,
156
+ "dose_div_factor": 10,
157
+ "eval_clip_factor": clip,
158
+ "input_size_zyx": [128, 192, 192],
159
+ "source": rel,
160
+ "note": note,
161
+ }
162
+
163
+ dst = os.path.join(args.out_dir, name)
164
+ torch.save(ckpt, dst)
165
+
166
+ n_params = sum(v.numel() for v in state_dict.values())
167
+ entry = {
168
+ "file": name,
169
+ "arch": arch,
170
+ "anatomy": anatomy,
171
+ "regime": regime,
172
+ "in_channels": in_ch,
173
+ "channel_spec": spec,
174
+ "scale_out": scale_out,
175
+ "eval_clip_factor": clip,
176
+ "num_parameters": n_params,
177
+ "num_tensors": len(state_dict),
178
+ "size_bytes": os.path.getsize(dst),
179
+ "source": rel,
180
+ "source_size_bytes": os.path.getsize(src),
181
+ "sha256": sha256(dst),
182
+ "note": note,
183
+ }
184
+ manifest.append(entry)
185
+ print(f"{name:32s} {n_params/1e6:8.2f} M params "
186
+ f"{entry['size_bytes']/1e6:8.1f} MB "
187
+ f"(from {entry['source_size_bytes']/1e6:.1f} MB)")
188
+
189
+ with open(os.path.join(args.out_dir, "MANIFEST.json"), "w") as f:
190
+ json.dump(manifest, f, indent=2)
191
+ print(f"\nWrote {os.path.join(args.out_dir, 'MANIFEST.json')}")
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()
weights/MANIFEST.json ADDED
@@ -0,0 +1,206 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "file": "c3d_pretrained.pt",
4
+ "arch": "c3d",
5
+ "anatomy": "source-domain",
6
+ "regime": "pretrained",
7
+ "in_channels": 8,
8
+ "channel_spec": "source_8ch",
9
+ "scale_out": 7.5,
10
+ "eval_clip_factor": 1.1,
11
+ "num_parameters": 32353106,
12
+ "num_tensors": 172,
13
+ "size_bytes": 129469738,
14
+ "source": "Pretrained/C3D_128x128x160/best_val.pth",
15
+ "source_size_bytes": 129481303,
16
+ "sha256": "b215969e57733cc6699d17a9d23f20dde8411971f2662fd114c66ee0c25befef",
17
+ "note": "Source-domain model trained on the public 8-channel dose dataset. Starting point for c3d_finetuned.pt and pancreas_c3d_finetuned.pt."
18
+ },
19
+ {
20
+ "file": "c3d_finetuned.pt",
21
+ "arch": "c3d",
22
+ "anatomy": "han",
23
+ "regime": "finetuned",
24
+ "in_channels": 5,
25
+ "channel_spec": "han_5ch",
26
+ "scale_out": 7.5,
27
+ "eval_clip_factor": 1.1,
28
+ "num_parameters": 32349218,
29
+ "num_tensors": 172,
30
+ "size_bytes": 129453946,
31
+ "source": "Output/C3D/Finetune_v2/best_val_evaluation_index.pkl",
32
+ "source_size_bytes": 517875237,
33
+ "sha256": "73679f45b10104d96b8a28f90024f3f92be9a40e8bc733fa02e18d31825f43be",
34
+ "note": "Fine-tuned from c3d_pretrained.pt on head-and-neck data; Adam lr 3e-5, cosine schedule, 275 epochs."
35
+ },
36
+ {
37
+ "file": "c3d_fromscratch.pt",
38
+ "arch": "c3d",
39
+ "anatomy": "han",
40
+ "regime": "fromscratch",
41
+ "in_channels": 5,
42
+ "channel_spec": "han_5ch",
43
+ "scale_out": 7.5,
44
+ "eval_clip_factor": 1.1,
45
+ "num_parameters": 32349218,
46
+ "num_tensors": 172,
47
+ "size_bytes": 129454298,
48
+ "source": "Output/C3D/FromScratch/best_val_evaluation_index.pkl",
49
+ "source_size_bytes": 517881381,
50
+ "sha256": "7e7df38af255dcb9b8621b058f1265a1c6946c32c51edf501e4c0b628e94208e",
51
+ "note": "Same architecture and schedule trained from random init; Adam lr 1e-4. The no-transfer baseline."
52
+ },
53
+ {
54
+ "file": "mednext_pretrained.pt",
55
+ "arch": "mednext",
56
+ "anatomy": "source-domain",
57
+ "regime": "pretrained",
58
+ "in_channels": 8,
59
+ "channel_spec": "source_8ch",
60
+ "scale_out": 7.5,
61
+ "eval_clip_factor": 1.2,
62
+ "num_parameters": 10526498,
63
+ "num_tensors": 229,
64
+ "size_bytes": 42178475,
65
+ "source": "Pretrained/MedNeXt/best_val.pth",
66
+ "source_size_bytes": 42184734,
67
+ "sha256": "75c971ae3a35e14fa6e1667b829f367f07b5d8c57404871d62610c38c86f1b70",
68
+ "note": "Source-domain MedNeXt-B trained on the public 8-channel dose dataset."
69
+ },
70
+ {
71
+ "file": "mednext_finetuned.pt",
72
+ "arch": "mednext",
73
+ "anatomy": "han",
74
+ "regime": "finetuned",
75
+ "in_channels": 5,
76
+ "channel_spec": "han_5ch",
77
+ "scale_out": 7.5,
78
+ "eval_clip_factor": 1.2,
79
+ "num_parameters": 10526402,
80
+ "num_tensors": 229,
81
+ "size_bytes": 42177794,
82
+ "source": "Output/MedNeXt/Finetune/best_val_evaluation_index.pkl",
83
+ "source_size_bytes": 168792326,
84
+ "sha256": "f397b9bc4ec257f07b798f55adf8143085fc3f236dccc2a043de084c9260694e",
85
+ "note": "Fine-tuned from mednext_pretrained.pt on head-and-neck data."
86
+ },
87
+ {
88
+ "file": "mednext_fromscratch.pt",
89
+ "arch": "mednext",
90
+ "anatomy": "han",
91
+ "regime": "fromscratch",
92
+ "in_channels": 5,
93
+ "channel_spec": "han_5ch",
94
+ "scale_out": 7.5,
95
+ "eval_clip_factor": 1.2,
96
+ "num_parameters": 10526402,
97
+ "num_tensors": 229,
98
+ "size_bytes": 42178260,
99
+ "source": "Output/MedNeXt/FromScratch/best_val_evaluation_index.pkl",
100
+ "source_size_bytes": 168793926,
101
+ "sha256": "e1e041424d496e1241b56f9b73b79fde0bb5c473e4299db5bf34609904ab463c",
102
+ "note": "MedNeXt-B trained from random init. The no-transfer baseline."
103
+ },
104
+ {
105
+ "file": "swinunetr_pretrained.pt",
106
+ "arch": "swinunetr",
107
+ "anatomy": "source-domain",
108
+ "regime": "pretrained",
109
+ "in_channels": 8,
110
+ "channel_spec": "source_8ch",
111
+ "scale_out": 7.5,
112
+ "eval_clip_factor": 1.2,
113
+ "num_parameters": 291356139,
114
+ "num_tensors": 167,
115
+ "size_bytes": 1169245779,
116
+ "source": "Pretrained/SwinUNETR_L_128x128x160/best_val.pth",
117
+ "source_size_bytes": 1169258908,
118
+ "sha256": "edca3e20d8a6f1a00b4d3c6dfbf19e67c4f89b3fbd769c2ed9bae7ce51520160",
119
+ "note": "Source-domain SwinUNETR-L trained on the public 8-channel dose dataset."
120
+ },
121
+ {
122
+ "file": "swinunetr_finetuned.pt",
123
+ "arch": "swinunetr",
124
+ "anatomy": "han",
125
+ "regime": "finetuned",
126
+ "in_channels": 5,
127
+ "channel_spec": "han_5ch",
128
+ "scale_out": 7.5,
129
+ "eval_clip_factor": 1.2,
130
+ "num_parameters": 291345771,
131
+ "num_tensors": 167,
132
+ "size_bytes": 1169204072,
133
+ "source": "Output/SwinUNETR_L/Finetune/best_val_evaluation_index.pkl",
134
+ "source_size_bytes": 4654279265,
135
+ "sha256": "e92c795b3792094fa881a8f7a7ef4a03a411454f78026351ea38fe5b0632204e",
136
+ "note": "Fine-tuned from swinunetr_pretrained.pt on head-and-neck data."
137
+ },
138
+ {
139
+ "file": "swinunetr_fromscratch.pt",
140
+ "arch": "swinunetr",
141
+ "anatomy": "han",
142
+ "regime": "fromscratch",
143
+ "in_channels": 5,
144
+ "channel_spec": "han_5ch",
145
+ "scale_out": 7.5,
146
+ "eval_clip_factor": 1.2,
147
+ "num_parameters": 291345771,
148
+ "num_tensors": 167,
149
+ "size_bytes": 1169204414,
150
+ "source": "Output/SwinUNETR_L/FromScratch/best_val_evaluation_index.pkl",
151
+ "source_size_bytes": 4654274081,
152
+ "sha256": "6981fa28668ab6efc1f5450eea8921b2c6477803eff8582911c6e4ff8ec4881f",
153
+ "note": "SwinUNETR-L trained from random init. The no-transfer baseline."
154
+ },
155
+ {
156
+ "file": "pancreas_c3d_pretrained.pt",
157
+ "arch": "c3d",
158
+ "anatomy": "source-domain",
159
+ "regime": "pretrained",
160
+ "in_channels": 8,
161
+ "channel_spec": "source_8ch",
162
+ "scale_out": 5.5,
163
+ "eval_clip_factor": 1.2,
164
+ "num_parameters": 32353106,
165
+ "num_tensors": 172,
166
+ "size_bytes": 129471450,
167
+ "source": "Pretrained/C3D_128x128x160/best_val.pth",
168
+ "source_size_bytes": 129481303,
169
+ "sha256": "aa51c8851a2eb0f74b0e59c8a202e57ceee1013abb2274bf7b52e1a623b926f4",
170
+ "note": "Byte-identical weights to c3d_pretrained.pt; duplicated so the pancreas set is self-contained. Only scale_out differs (5.5 for pancreas)."
171
+ },
172
+ {
173
+ "file": "pancreas_c3d_finetuned.pt",
174
+ "arch": "c3d",
175
+ "anatomy": "pancreas",
176
+ "regime": "finetuned",
177
+ "in_channels": 5,
178
+ "channel_spec": "pancreas_5ch",
179
+ "scale_out": 5.5,
180
+ "eval_clip_factor": 1.2,
181
+ "num_parameters": 32349218,
182
+ "num_tensors": 172,
183
+ "size_bytes": 129455658,
184
+ "source": "Output/Pancreas/PancC3D/FineTune_v5/best_val_evaluation_index.pkl",
185
+ "source_size_bytes": 517882405,
186
+ "sha256": "36d4b7369e4221cf86e3c06947c98fa8e96d1d50381d362933a13ec0fdca777f",
187
+ "note": "Fine-tuned directly from the source-domain pretrained C3D onto pancreas VMAT data -- NOT from the head-and-neck model."
188
+ },
189
+ {
190
+ "file": "pancreas_c3d_fromscratch.pt",
191
+ "arch": "c3d",
192
+ "anatomy": "pancreas",
193
+ "regime": "fromscratch",
194
+ "in_channels": 5,
195
+ "channel_spec": "pancreas_5ch",
196
+ "scale_out": 5.5,
197
+ "eval_clip_factor": 1.2,
198
+ "num_parameters": 32349218,
199
+ "num_tensors": 172,
200
+ "size_bytes": 129455946,
201
+ "source": "Output/Pancreas/PancC3D/FromScratch/best_val_evaluation_index.pkl",
202
+ "source_size_bytes": 517899237,
203
+ "sha256": "e80eb22116f022fb170094f5eae29529275838098fe2d0490f92d721f3a98436",
204
+ "note": "C3D trained from random init on pancreas VMAT data. The no-transfer baseline."
205
+ }
206
+ ]
weights/c3d_finetuned.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:73679f45b10104d96b8a28f90024f3f92be9a40e8bc733fa02e18d31825f43be
3
+ size 129453946
weights/c3d_fromscratch.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7e7df38af255dcb9b8621b058f1265a1c6946c32c51edf501e4c0b628e94208e
3
+ size 129454298
weights/c3d_pretrained.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b215969e57733cc6699d17a9d23f20dde8411971f2662fd114c66ee0c25befef
3
+ size 129469738
weights/mednext_finetuned.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f397b9bc4ec257f07b798f55adf8143085fc3f236dccc2a043de084c9260694e
3
+ size 42177794
weights/mednext_fromscratch.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e1e041424d496e1241b56f9b73b79fde0bb5c473e4299db5bf34609904ab463c
3
+ size 42178260
weights/mednext_pretrained.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:75c971ae3a35e14fa6e1667b829f367f07b5d8c57404871d62610c38c86f1b70
3
+ size 42178475
weights/pancreas_c3d_finetuned.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:36d4b7369e4221cf86e3c06947c98fa8e96d1d50381d362933a13ec0fdca777f
3
+ size 129455658
weights/pancreas_c3d_fromscratch.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e80eb22116f022fb170094f5eae29529275838098fe2d0490f92d721f3a98436
3
+ size 129455946
weights/pancreas_c3d_pretrained.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aa51c8851a2eb0f74b0e59c8a202e57ceee1013abb2274bf7b52e1a623b926f4
3
+ size 129471450
weights/swinunetr_finetuned.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e92c795b3792094fa881a8f7a7ef4a03a411454f78026351ea38fe5b0632204e
3
+ size 1169204072
weights/swinunetr_fromscratch.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6981fa28668ab6efc1f5450eea8921b2c6477803eff8582911c6e4ff8ec4881f
3
+ size 1169204414
weights/swinunetr_pretrained.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:edca3e20d8a6f1a00b4d3c6dfbf19e67c4f89b3fbd769c2ed9bae7ce51520160
3
+ size 1169245779