Initial release: 12 checkpoints, inference and transfer code
Browse files- .gitattributes +0 -34
- .gitignore +7 -0
- LICENSE +51 -0
- README.md +383 -0
- configs/c3d_han.yaml +35 -0
- configs/c3d_pancreas.yaml +35 -0
- configs/mednext_han.yaml +35 -0
- configs/swinunetr_han.yaml +35 -0
- meta_files_template/README.md +68 -0
- meta_files_template/han/meta_data.csv +4 -0
- meta_files_template/han/oar_dict.json +14 -0
- meta_files_template/han/ptv_dict.json +16 -0
- meta_files_template/pancreas/meta_data.csv +4 -0
- meta_files_template/pancreas/oar_dict.json +9 -0
- meta_files_template/pancreas/ptv_dict.json +8 -0
- requirements.txt +22 -0
- src/build_model.py +84 -0
- src/channels.py +129 -0
- src/checkpoint.py +71 -0
- src/data_loader_han.py +259 -0
- src/data_loader_pancreas.py +243 -0
- src/inference.py +162 -0
- src/model_c3d.py +176 -0
- src/toolkit_han.py +117 -0
- src/toolkit_pancreas.py +225 -0
- src/transfer.py +94 -0
- tools/convert_checkpoints.py +195 -0
- weights/MANIFEST.json +206 -0
- weights/c3d_finetuned.pt +3 -0
- weights/c3d_fromscratch.pt +3 -0
- weights/c3d_pretrained.pt +3 -0
- weights/mednext_finetuned.pt +3 -0
- weights/mednext_fromscratch.pt +3 -0
- weights/mednext_pretrained.pt +3 -0
- weights/pancreas_c3d_finetuned.pt +3 -0
- weights/pancreas_c3d_fromscratch.pt +3 -0
- weights/pancreas_c3d_pretrained.pt +3 -0
- weights/swinunetr_finetuned.pt +3 -0
- weights/swinunetr_fromscratch.pt +3 -0
- weights/swinunetr_pretrained.pt +3 -0
.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) · **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
|