Feature Extraction
Transformers
Safetensors
English
spectre
medical-imaging
ct-scan
3d
vision-transformer
self-supervised-learning
foundation-model
radiology
custom_code
Instructions to use cclaess/SPECTRE-Large with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use cclaess/SPECTRE-Large with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("feature-extraction", model="cclaess/SPECTRE-Large", trust_remote_code=True)# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("cclaess/SPECTRE-Large", trust_remote_code=True, device_map="auto") - Notebooks
- Google Colab
- Kaggle
Improved overall functionality and added functions for batch inference
Browse files- README.md +69 -33
- config.json +14 -1
- configuration_spectre.py +55 -18
- modeling_spectre.py +26 -2
- spectre/__init__.py +12 -15
- spectre/model.py +433 -117
- spectre/models/__init__.py +3 -31
- spectre/models/vision_transformer.py +8 -49
- spectre/models/vision_transformer_features.py +14 -46
- spectre/presets.py +130 -0
- spectre/utils/__init__.py +3 -65
- spectre/utils/modeling.py +118 -1
- spectre/windowing.py +245 -0
README.md
CHANGED
|
@@ -14,6 +14,8 @@ library_name: transformers
|
|
| 14 |
pipeline_tag: feature-extraction
|
| 15 |
---
|
| 16 |
|
|
|
|
|
|
|
| 17 |
📢 [2026-05-20] The pretrained SPECTRE model can now be loaded directly through the `transformers` library, no separate SPECTRE package installation required. Check below for details and usage examples.
|
| 18 |
|
| 19 |
📢 [2026-04-10] SPECTRE is now an official baseline for the [**CVPR 2026 Workshop Competition: Foundation Models for General CT Image Diagnosis**](https://www.codabench.org/competitions/12650/)! See `experiments/cvpr26_fm_for_ct_diag_task_1` for scripts and additional details.
|
|
@@ -22,7 +24,6 @@ pipeline_tag: feature-extraction
|
|
| 22 |
|
| 23 |
📢 [2026-01-20] [Semantic segmentation](https://github.com/cviviers/nnUNet) code and configurations using the nnUNet framework are now released!
|
| 24 |
|
| 25 |
-
|
| 26 |
# SPECTRE 👻👻👻
|
| 27 |
|
| 28 |
<p align="center">
|
|
@@ -45,6 +46,7 @@ SPECTRE has been trained on a large cohort of **open-source CT scans** of the **
|
|
| 45 |
This repository provides pretrained SPECTRE models together with tools for fine-tuning and evaluation.
|
| 46 |
|
| 47 |
## 🧠 Pretrained Models
|
|
|
|
| 48 |
The pretrained SPECTRE model can easily be imported using the `transformers` library
|
| 49 |
|
| 50 |
```python
|
|
@@ -55,56 +57,75 @@ model = AutoModel.from_pretrained('cclaess/SPECTRE-Large', trust_remote_code=Tru
|
|
| 55 |
or by using the `spectre-fm` package as follows:
|
| 56 |
|
| 57 |
```python
|
| 58 |
-
from spectre import SpectreImageFeatureExtractor
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
```
|
| 62 |
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
```python
|
| 65 |
import torch
|
| 66 |
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
B, C, H, W, D = x.shape
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
| 76 |
|
| 77 |
-
|
| 78 |
-
B, C,
|
| 79 |
-
H // pH, pH,
|
| 80 |
-
W // pW, pW,
|
| 81 |
-
D // pD, pD,
|
| 82 |
-
).permute(0, 2, 4, 6, 1, 3, 5, 7).reshape(B, -1, C, pH, pW, pD)
|
| 83 |
|
| 84 |
with torch.no_grad():
|
| 85 |
-
features = model(
|
| 86 |
-
x,
|
| 87 |
-
grid_size=(
|
| 88 |
-
H // pH,
|
| 89 |
-
W // pW,
|
| 90 |
-
D // pD,
|
| 91 |
-
),
|
| 92 |
-
)
|
| 93 |
-
print("Features shape:", features.shape)
|
| 94 |
```
|
| 95 |
|
|
|
|
|
|
|
| 96 |
Alternatively, you can download the weights of the separate components through HuggingFace using the following links:
|
| 97 |
|
| 98 |
| Architecture | Input Modality | Pretraining Objective | Model Weights |
|
| 99 |
|---------------------------|--------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------|
|
| 100 |
-
| SPECTRE-ViT-Local | CT crops | SSL | [
|
| 101 |
-
| SPECTRE-ViT-Local | CT crops | SSL + VLA | [
|
| 102 |
-
| SPECTRE-ViT-Global | Embedded CT crops | VLA | [
|
| 103 |
-
| Qwen3-Embedding-0.6B LoRA | Text (radiology) | VLA | [
|
| 104 |
|
| 105 |
## 🩻 Segmentation (nnUNet)
|
| 106 |
|
| 107 |
-
If you're looking for a nnUNet-based segmentation pipeline that uses SPECTRE as the backbone, see
|
| 108 |
|
| 109 |
## 📂 Repository Contents
|
| 110 |
|
|
@@ -131,6 +152,12 @@ pip install spectre-fm
|
|
| 131 |
|
| 132 |
This installs only the runtime dependencies needed to load and run the pretrained models.
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
If you want to fine-tune or pretrain SPECTRE, install the matching extra:
|
| 135 |
|
| 136 |
```bash
|
|
@@ -168,7 +195,9 @@ pip install git+https://github.com/cclaess/SPECTRE.git
|
|
| 168 |
To facilitate deployment and reproducibility, SPECTRE can be run using **Docker**. This allows you to set up a fully functional environment without manually installing dependencies using your own local copy of spectre.
|
| 169 |
|
| 170 |
### **Building the Docker Image**
|
|
|
|
| 171 |
First, ensure you have **Docker** installed. Then, clone and navigate to the repository to build the image:
|
|
|
|
| 172 |
```bash
|
| 173 |
git clone https://github.com/cclaess/SPECTRE
|
| 174 |
cd SPECTRE
|
|
@@ -176,22 +205,28 @@ docker build -t spectre-fm .
|
|
| 176 |
```
|
| 177 |
|
| 178 |
### **Running Experiments Inside Docker**
|
|
|
|
| 179 |
Once the image is built, you can start a container and execute scripts inside it. For example, to run a DINO pretraining experiment:
|
|
|
|
| 180 |
```bash
|
| 181 |
docker run --gpus all --rm -v "$(pwd):/mnt" spectre-fm python3 experiments/pretraining/pretrain_dino.py --config_file spectre/configs/dino_default.yaml --output_dir /mnt/outputs/pretraining/dino/
|
| 182 |
```
|
|
|
|
| 183 |
- `--gpus all` enables GPU acceleration if available.
|
| 184 |
- `--rm` removes the container after execution.
|
| 185 |
- `-v $(pwd):/mnt` mounts the current directory inside the container.
|
| 186 |
|
| 187 |
## ⚖️ License
|
|
|
|
| 188 |
- **Code: MIT** — see `LICENSE` (permissive; commercial use permitted).
|
| 189 |
- **Pretrained model weights: CC-BY-NC-SA** — non-commercial share-alike. The weights and any derivative models that include these weights are NOT cleared for commercial use. See `LICENSE_MODELS` for details and the precise license text.
|
| 190 |
|
| 191 |
> Note: the pretrained weights are subject to the original dataset licenses. Users intending to use SPECTRE in commercial settings should verify dataset and model licensing and obtain any required permissions.
|
| 192 |
|
| 193 |
## 📜 Citation
|
|
|
|
| 194 |
If you use SPECTRE in your research or wish to cite it, please use the following BibTeX entry of our [preprint](https://arxiv.org/abs/2511.17209):
|
|
|
|
| 195 |
```
|
| 196 |
@misc{claessens_scaling_2025,
|
| 197 |
title = {Scaling {Self}-{Supervised} and {Cross}-{Modal} {Pretraining} for {Volumetric} {CT} {Transformers}},
|
|
@@ -203,6 +238,7 @@ If you use SPECTRE in your research or wish to cite it, please use the following
|
|
| 203 |
```
|
| 204 |
|
| 205 |
## 🤝 Acknowledgements
|
|
|
|
| 206 |
This project builds upon prior work in self-supervised learning, medical imaging, and transformer-based representation learning. We especially acknowledge [**MONAI**](https://project-monai.github.io/) for their awesome framework and the [**timm**](https://timm.fast.ai/) & [**lightly**](https://docs.lightly.ai/self-supervised-learning/) Python libraries for providing 2D PyTorch models (timm) and object-oriented self-supervised learning methods (lightly), from which we adapted parts of the code for 3D.
|
| 207 |
|
| 208 |
-
[](https://star-history.com/#cclaess/SPECTRE&Date)
|
|
|
|
| 14 |
pipeline_tag: feature-extraction
|
| 15 |
---
|
| 16 |
|
| 17 |
+
📢 [2026-07-17] SPECTRE now ships a command-line tool: point `spectre embed` at a `.nii`/`.nii.gz` file or a folder of them to get embeddings without writing any Python. Check below for details and usage examples.
|
| 18 |
+
|
| 19 |
📢 [2026-05-20] The pretrained SPECTRE model can now be loaded directly through the `transformers` library, no separate SPECTRE package installation required. Check below for details and usage examples.
|
| 20 |
|
| 21 |
📢 [2026-04-10] SPECTRE is now an official baseline for the [**CVPR 2026 Workshop Competition: Foundation Models for General CT Image Diagnosis**](https://www.codabench.org/competitions/12650/)! See `experiments/cvpr26_fm_for_ct_diag_task_1` for scripts and additional details.
|
|
|
|
| 24 |
|
| 25 |
📢 [2026-01-20] [Semantic segmentation](https://github.com/cviviers/nnUNet) code and configurations using the nnUNet framework are now released!
|
| 26 |
|
|
|
|
| 27 |
# SPECTRE 👻👻👻
|
| 28 |
|
| 29 |
<p align="center">
|
|
|
|
| 46 |
This repository provides pretrained SPECTRE models together with tools for fine-tuning and evaluation.
|
| 47 |
|
| 48 |
## 🧠 Pretrained Models
|
| 49 |
+
|
| 50 |
The pretrained SPECTRE model can easily be imported using the `transformers` library
|
| 51 |
|
| 52 |
```python
|
|
|
|
| 57 |
or by using the `spectre-fm` package as follows:
|
| 58 |
|
| 59 |
```python
|
| 60 |
+
from spectre import SpectreImageFeatureExtractor
|
| 61 |
+
model = SpectreImageFeatureExtractor.from_pretrained('spectre-large')
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
Run `spectre list-models` to see what is available. Pass `include_feature_combiner=False` for per-crop backbone features instead of one embedding per scan.
|
| 65 |
+
|
| 66 |
+
### 🖥️ From the command line
|
| 67 |
+
|
| 68 |
+
If you just want embeddings out of a CT scan and would rather not write Python:
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
pip install "spectre-fm[inference]"
|
| 72 |
+
|
| 73 |
+
spectre embed scan.nii.gz -o embeddings/ # one scan
|
| 74 |
+
spectre embed /data/scans/ -o embeddings/ # a whole folder
|
| 75 |
```
|
| 76 |
|
| 77 |
+
This handles everything internally with the defaults SPECTRE was pretrained on: HU windowing to [-1000, 1000], RAS orientation, and 128×128×64 crops at the scan's native voxel spacing. Each scan produces `<name>.npz` containing `cls` (one vector for the scan) and `patch_tokens` (one vector per crop, shaped to the crop grid), plus a `manifest.csv`. Useful flags: `--backbone-only`, `--device cuda`, `--spacing 0.5 0.5 1.0` to resample, and `--max-crops-per-forward` if you run out of memory. See `spectre embed --help`.
|
| 78 |
+
|
| 79 |
+
### 🐍 From Python
|
| 80 |
+
|
| 81 |
+
Hand the model a scan in Hounsfield Units and it does the windowing for you:
|
| 82 |
+
|
| 83 |
```python
|
| 84 |
import torch
|
| 85 |
|
| 86 |
+
# One scan: (C, H, W, D) or (H, W, D), in raw HU.
|
| 87 |
+
scan = torch.randn(1, 384, 384, 256) * 500 - 500
|
| 88 |
+
|
| 89 |
+
with torch.no_grad():
|
| 90 |
+
features = model(scan)
|
| 91 |
+
|
| 92 |
+
print("Features shape:", features.shape) # (T', F') -> a CLS token plus one token per crop
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
Scans of different sizes can be embedded together by passing a list. All crops from all scans go through the backbone in a single pass, and only the feature combiner is split back out per scan:
|
| 96 |
+
|
| 97 |
+
```python
|
| 98 |
+
scans = [scan_a, scan_b, scan_c] # any sizes, all in HU
|
| 99 |
|
| 100 |
+
with torch.no_grad():
|
| 101 |
+
features = model.extract(scans) # -> list of (T', F') tensors
|
| 102 |
+
```
|
|
|
|
| 103 |
|
| 104 |
+
If you have already windowed the scans yourself, pass the crops and their grid instead, and they are used untouched:
|
| 105 |
+
|
| 106 |
+
```python
|
| 107 |
+
from spectre import window_scan
|
| 108 |
|
| 109 |
+
crops, grid_size = window_scan(scan) # (N, C, 128, 128, 64), (n_h, n_w, n_d)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
with torch.no_grad():
|
| 112 |
+
features = model(crops, grid_size=grid_size)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
```
|
| 114 |
|
| 115 |
+
> **Reading files:** `spectre.load_ct` / `spectre.load_and_window` read `.nii`/`.nii.gz` and need the `[inference]` extra. Everything above works with just `pip install spectre-fm`.
|
| 116 |
+
|
| 117 |
Alternatively, you can download the weights of the separate components through HuggingFace using the following links:
|
| 118 |
|
| 119 |
| Architecture | Input Modality | Pretraining Objective | Model Weights |
|
| 120 |
|---------------------------|--------------------|-------------------------|-----------------------------------------------------------------------------------------------------------------------------|
|
| 121 |
+
| SPECTRE-ViT-Local | CT crops | SSL | [Download](https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_backbone_vit_large_patch16_128_no_vla.pt?download=true) |
|
| 122 |
+
| SPECTRE-ViT-Local | CT crops | SSL + VLA | [Download](https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_backbone_vit_large_patch16_128.pt?download=true) |
|
| 123 |
+
| SPECTRE-ViT-Global | Embedded CT crops | VLA | [Download](https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_combiner_feature_vit_large.pt?download=true) |
|
| 124 |
+
| Qwen3-Embedding-0.6B LoRA | Text (radiology) | VLA | [Download](https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_qwen3_embedding_0.6B_lora.pt?download=true) |
|
| 125 |
|
| 126 |
## 🩻 Segmentation (nnUNet)
|
| 127 |
|
| 128 |
+
If you're looking for a nnUNet-based segmentation pipeline that uses SPECTRE as the backbone, see this [GitHub](https://github.com/cviviers/nnUNet).
|
| 129 |
|
| 130 |
## 📂 Repository Contents
|
| 131 |
|
|
|
|
| 152 |
|
| 153 |
This installs only the runtime dependencies needed to load and run the pretrained models.
|
| 154 |
|
| 155 |
+
To read CT scans from `.nii`/`.nii.gz` files or use the `spectre` command-line tool, add the inference extra:
|
| 156 |
+
|
| 157 |
+
```bash
|
| 158 |
+
pip install "spectre-fm[inference]"
|
| 159 |
+
```
|
| 160 |
+
|
| 161 |
If you want to fine-tune or pretrain SPECTRE, install the matching extra:
|
| 162 |
|
| 163 |
```bash
|
|
|
|
| 195 |
To facilitate deployment and reproducibility, SPECTRE can be run using **Docker**. This allows you to set up a fully functional environment without manually installing dependencies using your own local copy of spectre.
|
| 196 |
|
| 197 |
### **Building the Docker Image**
|
| 198 |
+
|
| 199 |
First, ensure you have **Docker** installed. Then, clone and navigate to the repository to build the image:
|
| 200 |
+
|
| 201 |
```bash
|
| 202 |
git clone https://github.com/cclaess/SPECTRE
|
| 203 |
cd SPECTRE
|
|
|
|
| 205 |
```
|
| 206 |
|
| 207 |
### **Running Experiments Inside Docker**
|
| 208 |
+
|
| 209 |
Once the image is built, you can start a container and execute scripts inside it. For example, to run a DINO pretraining experiment:
|
| 210 |
+
|
| 211 |
```bash
|
| 212 |
docker run --gpus all --rm -v "$(pwd):/mnt" spectre-fm python3 experiments/pretraining/pretrain_dino.py --config_file spectre/configs/dino_default.yaml --output_dir /mnt/outputs/pretraining/dino/
|
| 213 |
```
|
| 214 |
+
|
| 215 |
- `--gpus all` enables GPU acceleration if available.
|
| 216 |
- `--rm` removes the container after execution.
|
| 217 |
- `-v $(pwd):/mnt` mounts the current directory inside the container.
|
| 218 |
|
| 219 |
## ⚖️ License
|
| 220 |
+
|
| 221 |
- **Code: MIT** — see `LICENSE` (permissive; commercial use permitted).
|
| 222 |
- **Pretrained model weights: CC-BY-NC-SA** — non-commercial share-alike. The weights and any derivative models that include these weights are NOT cleared for commercial use. See `LICENSE_MODELS` for details and the precise license text.
|
| 223 |
|
| 224 |
> Note: the pretrained weights are subject to the original dataset licenses. Users intending to use SPECTRE in commercial settings should verify dataset and model licensing and obtain any required permissions.
|
| 225 |
|
| 226 |
## 📜 Citation
|
| 227 |
+
|
| 228 |
If you use SPECTRE in your research or wish to cite it, please use the following BibTeX entry of our [preprint](https://arxiv.org/abs/2511.17209):
|
| 229 |
+
|
| 230 |
```
|
| 231 |
@misc{claessens_scaling_2025,
|
| 232 |
title = {Scaling {Self}-{Supervised} and {Cross}-{Modal} {Pretraining} for {Volumetric} {CT} {Transformers}},
|
|
|
|
| 238 |
```
|
| 239 |
|
| 240 |
## 🤝 Acknowledgements
|
| 241 |
+
|
| 242 |
This project builds upon prior work in self-supervised learning, medical imaging, and transformer-based representation learning. We especially acknowledge [**MONAI**](https://project-monai.github.io/) for their awesome framework and the [**timm**](https://timm.fast.ai/) & [**lightly**](https://docs.lightly.ai/self-supervised-learning/) Python libraries for providing 2D PyTorch models (timm) and object-oriented self-supervised learning methods (lightly), from which we adapted parts of the code for 3D.
|
| 243 |
|
| 244 |
+
[](https://star-history.com/#cclaess/SPECTRE&Date)
|
config.json
CHANGED
|
@@ -16,6 +16,11 @@
|
|
| 16 |
}
|
| 17 |
},
|
| 18 |
"backbone_name": "vit_large_patch16_128",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
"dtype": "float32",
|
| 20 |
"feature_combiner_kwargs": {
|
| 21 |
"global_pool": "",
|
|
@@ -27,6 +32,14 @@
|
|
| 27 |
}
|
| 28 |
},
|
| 29 |
"feature_combiner_name": "feat_vit_large",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
"model_type": "spectre",
|
| 31 |
-
"
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
|
|
|
| 16 |
}
|
| 17 |
},
|
| 18 |
"backbone_name": "vit_large_patch16_128",
|
| 19 |
+
"crop_size": [
|
| 20 |
+
128,
|
| 21 |
+
128,
|
| 22 |
+
64
|
| 23 |
+
],
|
| 24 |
"dtype": "float32",
|
| 25 |
"feature_combiner_kwargs": {
|
| 26 |
"global_pool": "",
|
|
|
|
| 32 |
}
|
| 33 |
},
|
| 34 |
"feature_combiner_name": "feat_vit_large",
|
| 35 |
+
"hu_range": [
|
| 36 |
+
-1000.0,
|
| 37 |
+
1000.0
|
| 38 |
+
],
|
| 39 |
+
"include_feature_combiner": true,
|
| 40 |
"model_type": "spectre",
|
| 41 |
+
"orientation": "RAS",
|
| 42 |
+
"preset": "spectre-large",
|
| 43 |
+
"transformers_version": "5.3.0",
|
| 44 |
+
"voxel_spacing": null
|
| 45 |
}
|
configuration_spectre.py
CHANGED
|
@@ -1,32 +1,69 @@
|
|
| 1 |
from transformers import PretrainedConfig
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
class SpectreConfig(PretrainedConfig):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
model_type = "spectre"
|
| 6 |
|
| 7 |
def __init__(
|
| 8 |
self,
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
"rope_kwargs": {"base": 100.0},
|
| 23 |
-
"init_values": 1.0,
|
| 24 |
-
},
|
| 25 |
**kwargs,
|
| 26 |
):
|
| 27 |
super().__init__(**kwargs)
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
self.backbone_name = backbone_name
|
| 30 |
self.backbone_kwargs = backbone_kwargs or {}
|
| 31 |
-
self.feature_combiner_name = feature_combiner_name
|
| 32 |
-
self.feature_combiner_kwargs = feature_combiner_kwargs or {}
|
|
|
|
| 1 |
from transformers import PretrainedConfig
|
| 2 |
|
| 3 |
+
from spectre.presets import get_preset
|
| 4 |
+
from spectre.windowing import DEFAULT_CROP_SIZE, DEFAULT_HU_RANGE
|
| 5 |
+
|
| 6 |
|
| 7 |
class SpectreConfig(PretrainedConfig):
|
| 8 |
+
"""Configuration of a pretrained SPECTRE model.
|
| 9 |
+
|
| 10 |
+
Nothing about the model is written down twice. The architecture is resolved from a named
|
| 11 |
+
preset in `spectre.presets`, and the preprocessing defaults come from `spectre.windowing` -
|
| 12 |
+
the same values the package itself uses. The resolved architecture is still serialised into
|
| 13 |
+
`config.json`, so the published config stays self-describing.
|
| 14 |
+
|
| 15 |
+
The fields above the architecture are the input contract: SPECTRE consumes a grid of
|
| 16 |
+
`crop_size` crops cut from a CT scan oriented to `orientation` and windowed from `hu_range`
|
| 17 |
+
onto [0, 1] at `voxel_spacing` (None meaning the scan's native spacing).
|
| 18 |
+
`SpectreImageFeatureExtractor` applies all of that for you when handed a raw scan in
|
| 19 |
+
Hounsfield Units.
|
| 20 |
+
|
| 21 |
+
Compatibility runs both ways:
|
| 22 |
+
* A `config.json` written before the `preset` field existed carries an explicit
|
| 23 |
+
`backbone_name` and `backbone_kwargs`; those take precedence, so it keeps loading its own
|
| 24 |
+
architecture instead of silently inheriting this class's defaults.
|
| 25 |
+
* Older code reading a newer `config.json` ignores the fields it does not know and reads the
|
| 26 |
+
resolved architecture, which is still written out in full.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
model_type = "spectre"
|
| 30 |
|
| 31 |
def __init__(
|
| 32 |
self,
|
| 33 |
+
preset="spectre-large",
|
| 34 |
+
# What the model expects to be fed.
|
| 35 |
+
crop_size=DEFAULT_CROP_SIZE,
|
| 36 |
+
hu_range=DEFAULT_HU_RANGE,
|
| 37 |
+
voxel_spacing=None,
|
| 38 |
+
orientation="RAS",
|
| 39 |
+
# Architecture. Left as None these are resolved from `preset`; passing them explicitly
|
| 40 |
+
# (as every pre-0.2 config.json does) takes precedence.
|
| 41 |
+
backbone_name=None,
|
| 42 |
+
backbone_kwargs=None,
|
| 43 |
+
feature_combiner_name=None,
|
| 44 |
+
feature_combiner_kwargs=None,
|
| 45 |
+
include_feature_combiner=True,
|
|
|
|
|
|
|
|
|
|
| 46 |
**kwargs,
|
| 47 |
):
|
| 48 |
super().__init__(**kwargs)
|
| 49 |
|
| 50 |
+
self.preset = preset
|
| 51 |
+
self.crop_size = tuple(crop_size)
|
| 52 |
+
self.hu_range = tuple(hu_range)
|
| 53 |
+
self.voxel_spacing = tuple(voxel_spacing) if voxel_spacing is not None else None
|
| 54 |
+
self.orientation = orientation
|
| 55 |
+
self.include_feature_combiner = include_feature_combiner
|
| 56 |
+
|
| 57 |
+
if backbone_name is None:
|
| 58 |
+
resolved = get_preset(preset)
|
| 59 |
+
backbone_name = resolved.backbone
|
| 60 |
+
if backbone_kwargs is None:
|
| 61 |
+
backbone_kwargs = resolved.backbone_kwargs
|
| 62 |
+
feature_combiner_name = feature_combiner_name or resolved.feature_combiner
|
| 63 |
+
if feature_combiner_kwargs is None:
|
| 64 |
+
feature_combiner_kwargs = resolved.feature_combiner_kwargs
|
| 65 |
+
|
| 66 |
self.backbone_name = backbone_name
|
| 67 |
self.backbone_kwargs = backbone_kwargs or {}
|
| 68 |
+
self.feature_combiner_name = feature_combiner_name if include_feature_combiner else None
|
| 69 |
+
self.feature_combiner_kwargs = feature_combiner_kwargs or {}
|
modeling_spectre.py
CHANGED
|
@@ -3,6 +3,7 @@ from transformers import PreTrainedModel
|
|
| 3 |
from transformers.modeling_outputs import BaseModelOutput
|
| 4 |
|
| 5 |
from spectre.model import SpectreImageFeatureExtractor
|
|
|
|
| 6 |
try:
|
| 7 |
from .configuration_spectre import SpectreConfig
|
| 8 |
except ImportError:
|
|
@@ -10,6 +11,21 @@ except ImportError:
|
|
| 10 |
|
| 11 |
|
| 12 |
class SpectreModel(PreTrainedModel):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
config_class = SpectreConfig
|
| 14 |
base_model_prefix = "spectre"
|
| 15 |
main_input_name = "pixel_values"
|
|
@@ -26,6 +42,11 @@ class SpectreModel(PreTrainedModel):
|
|
| 26 |
|
| 27 |
self.post_init()
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
def forward(
|
| 30 |
self,
|
| 31 |
pixel_values: torch.Tensor,
|
|
@@ -33,9 +54,12 @@ class SpectreModel(PreTrainedModel):
|
|
| 33 |
return_dict=False,
|
| 34 |
**kwargs,
|
| 35 |
):
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
if not return_dict:
|
| 39 |
return outputs
|
| 40 |
|
| 41 |
-
return BaseModelOutput(last_hidden_state=outputs)
|
|
|
|
| 3 |
from transformers.modeling_outputs import BaseModelOutput
|
| 4 |
|
| 5 |
from spectre.model import SpectreImageFeatureExtractor
|
| 6 |
+
|
| 7 |
try:
|
| 8 |
from .configuration_spectre import SpectreConfig
|
| 9 |
except ImportError:
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
class SpectreModel(PreTrainedModel):
|
| 14 |
+
"""`transformers` wrapper around `SpectreImageFeatureExtractor`.
|
| 15 |
+
|
| 16 |
+
from transformers import AutoModel
|
| 17 |
+
model = AutoModel.from_pretrained("cclaess/SPECTRE-Large", trust_remote_code=True)
|
| 18 |
+
|
| 19 |
+
# A raw CT scan (C, H, W, D) in Hounsfield Units - windowed internally.
|
| 20 |
+
features = model(scan).last_hidden_state
|
| 21 |
+
|
| 22 |
+
This wrapper takes a single tensor, because `BaseModelOutput` holds a tensor. For batches of
|
| 23 |
+
differently-sized scans - where all crops share one backbone pass - use the underlying
|
| 24 |
+
extractor directly, which accepts a list:
|
| 25 |
+
|
| 26 |
+
features = model.model.extract([scan_a, scan_b]) # -> list of (T', F')
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
config_class = SpectreConfig
|
| 30 |
base_model_prefix = "spectre"
|
| 31 |
main_input_name = "pixel_values"
|
|
|
|
| 42 |
|
| 43 |
self.post_init()
|
| 44 |
|
| 45 |
+
@property
|
| 46 |
+
def crop_size(self):
|
| 47 |
+
"""Spatial size (H, W, D) of one CT crop the backbone consumes."""
|
| 48 |
+
return self.model.crop_size
|
| 49 |
+
|
| 50 |
def forward(
|
| 51 |
self,
|
| 52 |
pixel_values: torch.Tensor,
|
|
|
|
| 54 |
return_dict=False,
|
| 55 |
**kwargs,
|
| 56 |
):
|
| 57 |
+
"""Embed one or more CT scans. See `SpectreImageFeatureExtractor.forward` for the shapes
|
| 58 |
+
accepted; `grid_size` is required only for input that is already windowed into crops.
|
| 59 |
+
"""
|
| 60 |
+
outputs = self.model(pixel_values, grid_size=grid_size, **kwargs)
|
| 61 |
|
| 62 |
if not return_dict:
|
| 63 |
return outputs
|
| 64 |
|
| 65 |
+
return BaseModelOutput(last_hidden_state=outputs)
|
spectre/__init__.py
CHANGED
|
@@ -2,25 +2,22 @@
|
|
| 2 |
|
| 3 |
Expose a small, stable public API here so users can do:
|
| 4 |
|
| 5 |
-
from spectre import SpectreImageFeatureExtractor
|
|
|
|
| 6 |
|
| 7 |
Keep implementations in subpackages; this file only re-exports the most
|
| 8 |
important symbols and subpackages for convenience.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
from .model import SpectreImageFeatureExtractor, MODEL_CONFIGS
|
|
|
|
|
|
|
| 11 |
from . import models
|
| 12 |
from . import utils
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
__all__ = [
|
| 19 |
-
"SpectreImageFeatureExtractor",
|
| 20 |
-
"MODEL_CONFIGS",
|
| 21 |
-
"models",
|
| 22 |
-
"utils",
|
| 23 |
-
"__version__",
|
| 24 |
-
"__author__",
|
| 25 |
-
"__email__",
|
| 26 |
-
]
|
|
|
|
| 2 |
|
| 3 |
Expose a small, stable public API here so users can do:
|
| 4 |
|
| 5 |
+
from spectre import SpectreImageFeatureExtractor
|
| 6 |
+
model = SpectreImageFeatureExtractor.from_pretrained("spectre-large")
|
| 7 |
|
| 8 |
Keep implementations in subpackages; this file only re-exports the most
|
| 9 |
important symbols and subpackages for convenience.
|
| 10 |
+
|
| 11 |
+
Note for maintainers: `scripts/export_hf.py` rewrites this file when vendoring the package into
|
| 12 |
+
the Hugging Face Hub repo, dropping the imports it does not ship. Never import `.cli` here, or
|
| 13 |
+
anything that needs an optional dependency at module level.
|
| 14 |
"""
|
| 15 |
from .model import SpectreImageFeatureExtractor, MODEL_CONFIGS
|
| 16 |
+
from .presets import SpectrePreset, PRESETS, get_preset, list_presets, list_pretrained
|
| 17 |
+
from .windowing import window_scan, largest_multiple_center_crop, grid_patch, scale_intensity_range
|
| 18 |
from . import models
|
| 19 |
from . import utils
|
| 20 |
+
__version__ = '0.2.1'
|
| 21 |
+
__author__ = 'Cris Claessens'
|
| 22 |
+
__email__ = 'c.h.b.claessens@tue.nl'
|
| 23 |
+
__all__ = ['SpectreImageFeatureExtractor', 'MODEL_CONFIGS', 'SpectrePreset', 'PRESETS', 'get_preset', 'list_presets', 'list_pretrained', 'window_scan', 'largest_multiple_center_crop', 'grid_patch', 'scale_intensity_range', 'models', 'utils', '__version__', '__author__', '__email__']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
spectre/model.py
CHANGED
|
@@ -1,141 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import torch
|
| 2 |
import torch.nn as nn
|
| 3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
"spectre-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
"
|
| 14 |
-
"
|
| 15 |
-
"
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
"
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
"
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
"num_classes": 0,
|
| 43 |
-
"global_pool": '',
|
| 44 |
-
"pos_embed": "rope",
|
| 45 |
-
"rope_kwargs": {"base": 100.0},
|
| 46 |
-
"init_values": 1.0,
|
| 47 |
-
},
|
| 48 |
-
"description": "SPECTRE model with ViT-Base backbone and feature combiner.",
|
| 49 |
-
}, # Pretrained/Distilled checkpoints will be added later
|
| 50 |
-
"spectre-large": {
|
| 51 |
-
"name": "spectre-large",
|
| 52 |
-
"backbone": "vit_large_patch16_128",
|
| 53 |
-
"backbone_checkpoint_path_or_url": None,
|
| 54 |
-
"backbone_kwargs": {
|
| 55 |
-
"num_classes": 0,
|
| 56 |
-
"global_pool": '',
|
| 57 |
-
"pos_embed": "rope",
|
| 58 |
-
"rope_kwargs": {"base": 1000.0},
|
| 59 |
-
"init_values": 1.0,
|
| 60 |
-
},
|
| 61 |
-
"feature_combiner": "feat_vit_large",
|
| 62 |
-
"feature_combiner_checkpoint_path_or_url": None,
|
| 63 |
-
"feature_combiner_kwargs": {
|
| 64 |
-
"num_classes": 0,
|
| 65 |
-
"global_pool": '',
|
| 66 |
-
"pos_embed": "rope",
|
| 67 |
-
"rope_kwargs": {"base": 100.0},
|
| 68 |
-
"init_values": 1.0,
|
| 69 |
-
},
|
| 70 |
-
"description": "SPECTRE model with ViT-Large backbone and feature combiner.",
|
| 71 |
-
},
|
| 72 |
-
"spectre-large-pretrained": {
|
| 73 |
-
"name": "spectre-large-pretrained",
|
| 74 |
-
"backbone": "vit_large_patch16_128",
|
| 75 |
-
"backbone_checkpoint_path_or_url": "https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_backbone_vit_large_patch16_128.pt?download=true",
|
| 76 |
-
"backbone_kwargs": {
|
| 77 |
-
"num_classes": 0,
|
| 78 |
-
"global_pool": '',
|
| 79 |
-
"pos_embed": "rope",
|
| 80 |
-
"rope_kwargs": {"base": 1000.0},
|
| 81 |
-
"init_values": 1.0,
|
| 82 |
-
},
|
| 83 |
-
"feature_combiner": "feat_vit_large",
|
| 84 |
-
"feature_combiner_checkpoint_path_or_url": "https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_combiner_feature_vit_large.pt?download=true",
|
| 85 |
-
"feature_combiner_kwargs": {
|
| 86 |
-
"num_classes": 0,
|
| 87 |
-
"global_pool": '',
|
| 88 |
-
"pos_embed": "rope",
|
| 89 |
-
"rope_kwargs": {"base": 100.0},
|
| 90 |
-
"init_values": 1.0,
|
| 91 |
-
},
|
| 92 |
-
"description": "Pretrained SPECTRE model with ViT-Large backbone and feature combiner.",
|
| 93 |
}
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
|
| 97 |
class SpectreImageFeatureExtractor(nn.Module):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
def __init__(
|
| 99 |
-
self,
|
| 100 |
-
backbone_name: str,
|
| 101 |
-
backbone_kwargs: dict =
|
| 102 |
backbone_checkpoint_path_or_url: str | None = None,
|
| 103 |
-
feature_combiner_name: str | None = None,
|
| 104 |
-
feature_combiner_kwargs: dict =
|
| 105 |
feature_combiner_checkpoint_path_or_url: str | None = None,
|
| 106 |
**kwargs,
|
| 107 |
-
):
|
|
|
|
|
|
|
| 108 |
super().__init__()
|
| 109 |
self.backbone = None
|
| 110 |
self.feature_combiner = None
|
| 111 |
self._init_backbone(
|
| 112 |
backbone_name,
|
| 113 |
checkpoint_path_or_url=backbone_checkpoint_path_or_url,
|
| 114 |
-
**backbone_kwargs,
|
| 115 |
**kwargs,
|
| 116 |
)
|
| 117 |
if feature_combiner_name is not None:
|
| 118 |
self._init_feature_combiner(
|
| 119 |
feature_combiner_name,
|
| 120 |
checkpoint_path_or_url=feature_combiner_checkpoint_path_or_url,
|
| 121 |
-
**feature_combiner_kwargs,
|
| 122 |
**kwargs,
|
| 123 |
)
|
| 124 |
|
| 125 |
def _init_backbone(
|
| 126 |
-
self,
|
| 127 |
model_name: str,
|
| 128 |
checkpoint_path_or_url: str | None = None,
|
| 129 |
**kwargs
|
| 130 |
):
|
| 131 |
backbone_cls = getattr(__import__('spectre.models', fromlist=[model_name]), model_name)
|
| 132 |
self.backbone = backbone_cls(
|
| 133 |
-
checkpoint_path_or_url=checkpoint_path_or_url,
|
| 134 |
**kwargs,
|
| 135 |
)
|
| 136 |
-
|
| 137 |
def _init_feature_combiner(
|
| 138 |
-
self,
|
| 139 |
model_name: str,
|
| 140 |
checkpoint_path_or_url: str | None = None,
|
| 141 |
**kwargs,
|
|
@@ -147,20 +155,105 @@ class SpectreImageFeatureExtractor(nn.Module):
|
|
| 147 |
|
| 148 |
feature_combiner_cls = getattr(__import__('spectre.models', fromlist=[model_name]), model_name)
|
| 149 |
self.feature_combiner = feature_combiner_cls(
|
| 150 |
-
patch_dim=patch_dim,
|
| 151 |
-
checkpoint_path_or_url=checkpoint_path_or_url,
|
| 152 |
**kwargs,
|
| 153 |
)
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
def extract_backbone_features(
|
| 156 |
-
self,
|
| 157 |
x: torch.Tensor,
|
| 158 |
):
|
| 159 |
"""
|
| 160 |
-
Extract features from the backbone for a batch of image sets. Input is expected to be of
|
| 161 |
-
shape (B, N, C, H, W, D), where B is the batch size, N is the number of image patches per
|
| 162 |
image, C is the number of channels, H is height, W is width, and D is depth.
|
| 163 |
-
The output will be a tensor of extracted features (B, N, T, F) where T is the number of
|
| 164 |
tokens and F is the feature dimension.
|
| 165 |
|
| 166 |
Args:
|
|
@@ -178,7 +271,7 @@ class SpectreImageFeatureExtractor(nn.Module):
|
|
| 178 |
return features
|
| 179 |
|
| 180 |
def combine_features(
|
| 181 |
-
self,
|
| 182 |
features: torch.Tensor,
|
| 183 |
grid_size: tuple[int, int, int],
|
| 184 |
):
|
|
@@ -207,21 +300,244 @@ class SpectreImageFeatureExtractor(nn.Module):
|
|
| 207 |
features = self.feature_combiner(features, grid_size) # (B, T', F')
|
| 208 |
return features
|
| 209 |
|
| 210 |
-
def
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
@classmethod
|
| 219 |
def from_config(
|
| 220 |
-
cls,
|
| 221 |
config: dict,
|
| 222 |
**kwargs,
|
| 223 |
) -> 'SpectreImageFeatureExtractor':
|
| 224 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
model = cls(
|
| 226 |
backbone_name=config["backbone"],
|
| 227 |
backbone_checkpoint_path_or_url=config.get("backbone_checkpoint_path_or_url", None),
|
|
|
|
| 1 |
+
import copy
|
| 2 |
+
import warnings
|
| 3 |
+
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
| 4 |
+
|
| 5 |
import torch
|
| 6 |
import torch.nn as nn
|
| 7 |
|
| 8 |
+
from spectre.presets import (
|
| 9 |
+
BACKBONE_KWARGS,
|
| 10 |
+
COMBINER_KWARGS,
|
| 11 |
+
get_preset,
|
| 12 |
+
list_presets,
|
| 13 |
+
list_pretrained,
|
| 14 |
+
)
|
| 15 |
+
from spectre.windowing import window_scan
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _legacy_model_configs() -> Dict[str, Dict[str, Any]]:
|
| 19 |
+
"""The pre-0.2 `MODEL_CONFIGS` payload.
|
| 20 |
|
| 21 |
+
The architecture kwargs come from `spectre.presets`, but the *layout* is reproduced by hand
|
| 22 |
+
because the two registries disagree about what `"spectre-large"` means: here it is a
|
| 23 |
+
weightless architecture and `"spectre-large-pretrained"` carries the weights, whereas
|
| 24 |
+
`from_pretrained("spectre-large")` loads weights. Deriving the mapping would silently change
|
| 25 |
+
which checkpoint existing callers get. `tests/test_legacy_api.py` pins the result.
|
| 26 |
+
"""
|
| 27 |
+
def entry(name, backbone, combiner, backbone_ckpt, combiner_ckpt, description):
|
| 28 |
+
return {
|
| 29 |
+
"name": name,
|
| 30 |
+
"backbone": backbone,
|
| 31 |
+
"backbone_checkpoint_path_or_url": backbone_ckpt,
|
| 32 |
+
"backbone_kwargs": copy.deepcopy(BACKBONE_KWARGS),
|
| 33 |
+
"feature_combiner": combiner,
|
| 34 |
+
"feature_combiner_checkpoint_path_or_url": combiner_ckpt,
|
| 35 |
+
"feature_combiner_kwargs": copy.deepcopy(COMBINER_KWARGS),
|
| 36 |
+
"description": description,
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
return {
|
| 40 |
+
"spectre-small": entry(
|
| 41 |
+
"spectre-small", "vit_small_patch16_128", "feat_vit_small", None, None,
|
| 42 |
+
"SPECTRE model with ViT-Small backbone and feature combiner.",
|
| 43 |
+
),
|
| 44 |
+
"spectre-base": entry(
|
| 45 |
+
"spectre-base", "vit_base_patch16_128", "feat_vit_base", None, None,
|
| 46 |
+
"SPECTRE model with ViT-Base backbone and feature combiner.",
|
| 47 |
+
),
|
| 48 |
+
"spectre-large": entry(
|
| 49 |
+
"spectre-large", "vit_large_patch16_128", "feat_vit_large", None, None,
|
| 50 |
+
"SPECTRE model with ViT-Large backbone and feature combiner.",
|
| 51 |
+
),
|
| 52 |
+
"spectre-large-pretrained": entry(
|
| 53 |
+
"spectre-large-pretrained", "vit_large_patch16_128", "feat_vit_large",
|
| 54 |
+
"https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_backbone_vit_large_patch16_128.pt?download=true",
|
| 55 |
+
"https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_combiner_feature_vit_large.pt?download=true",
|
| 56 |
+
"Pretrained SPECTRE model with ViT-Large backbone and feature combiner.",
|
| 57 |
+
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
_MODEL_CONFIGS_DEPRECATION = (
|
| 62 |
+
"spectre.MODEL_CONFIGS is deprecated since spectre-fm 0.2.1 and will be removed in 0.3.0. "
|
| 63 |
+
"Use SpectreImageFeatureExtractor.from_pretrained('spectre-large') for the pretrained model, "
|
| 64 |
+
"or spectre.list_presets() to see the available architectures."
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class _DeprecatedModelConfigs(dict):
|
| 69 |
+
"""`MODEL_CONFIGS` that warns on access.
|
| 70 |
+
|
| 71 |
+
A dict subclass rather than a module-level `__getattr__`, because `spectre/__init__.py`
|
| 72 |
+
imports the name eagerly - a module hook would fire on every `import spectre`, including
|
| 73 |
+
inside the Hub's trust_remote_code import.
|
| 74 |
+
"""
|
| 75 |
+
|
| 76 |
+
def __getitem__(self, key):
|
| 77 |
+
warnings.warn(_MODEL_CONFIGS_DEPRECATION, FutureWarning, stacklevel=2)
|
| 78 |
+
return super().__getitem__(key)
|
| 79 |
+
|
| 80 |
+
def get(self, key, default=None):
|
| 81 |
+
# dict.get does not route through __getitem__ in CPython, so it needs its own warning.
|
| 82 |
+
warnings.warn(_MODEL_CONFIGS_DEPRECATION, FutureWarning, stacklevel=2)
|
| 83 |
+
return super().get(key, default)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
MODEL_CONFIGS = _DeprecatedModelConfigs(_legacy_model_configs())
|
| 87 |
|
| 88 |
|
| 89 |
class SpectreImageFeatureExtractor(nn.Module):
|
| 90 |
+
"""SPECTRE image encoder: a ViT backbone over CT crops, plus an optional feature combiner.
|
| 91 |
+
|
| 92 |
+
The backbone embeds each `crop_size` crop independently; the feature combiner then attends
|
| 93 |
+
over the grid of crop embeddings to produce one embedding for the whole scan.
|
| 94 |
+
|
| 95 |
+
The usual way in is `from_pretrained`:
|
| 96 |
+
|
| 97 |
+
model = SpectreImageFeatureExtractor.from_pretrained("spectre-large")
|
| 98 |
+
features = model(scan) # scan: (C, H, W, D) in Hounsfield Units
|
| 99 |
+
features = model([scan_a, scan_b]) # differing sizes, one backbone pass
|
| 100 |
+
|
| 101 |
+
Pass `include_feature_combiner=False` for per-crop backbone features only.
|
| 102 |
+
"""
|
| 103 |
+
|
| 104 |
def __init__(
|
| 105 |
+
self,
|
| 106 |
+
backbone_name: str,
|
| 107 |
+
backbone_kwargs: Optional[dict] = None,
|
| 108 |
backbone_checkpoint_path_or_url: str | None = None,
|
| 109 |
+
feature_combiner_name: str | None = None,
|
| 110 |
+
feature_combiner_kwargs: Optional[dict] = None,
|
| 111 |
feature_combiner_checkpoint_path_or_url: str | None = None,
|
| 112 |
**kwargs,
|
| 113 |
+
):
|
| 114 |
+
# This signature is a published contract: the Hugging Face wrapper
|
| 115 |
+
# (hf_export/modeling_spectre.py) calls it with exactly these keywords.
|
| 116 |
super().__init__()
|
| 117 |
self.backbone = None
|
| 118 |
self.feature_combiner = None
|
| 119 |
self._init_backbone(
|
| 120 |
backbone_name,
|
| 121 |
checkpoint_path_or_url=backbone_checkpoint_path_or_url,
|
| 122 |
+
**(backbone_kwargs or {}),
|
| 123 |
**kwargs,
|
| 124 |
)
|
| 125 |
if feature_combiner_name is not None:
|
| 126 |
self._init_feature_combiner(
|
| 127 |
feature_combiner_name,
|
| 128 |
checkpoint_path_or_url=feature_combiner_checkpoint_path_or_url,
|
| 129 |
+
**(feature_combiner_kwargs or {}),
|
| 130 |
**kwargs,
|
| 131 |
)
|
| 132 |
|
| 133 |
def _init_backbone(
|
| 134 |
+
self,
|
| 135 |
model_name: str,
|
| 136 |
checkpoint_path_or_url: str | None = None,
|
| 137 |
**kwargs
|
| 138 |
):
|
| 139 |
backbone_cls = getattr(__import__('spectre.models', fromlist=[model_name]), model_name)
|
| 140 |
self.backbone = backbone_cls(
|
| 141 |
+
checkpoint_path_or_url=checkpoint_path_or_url,
|
| 142 |
**kwargs,
|
| 143 |
)
|
| 144 |
+
|
| 145 |
def _init_feature_combiner(
|
| 146 |
+
self,
|
| 147 |
model_name: str,
|
| 148 |
checkpoint_path_or_url: str | None = None,
|
| 149 |
**kwargs,
|
|
|
|
| 155 |
|
| 156 |
feature_combiner_cls = getattr(__import__('spectre.models', fromlist=[model_name]), model_name)
|
| 157 |
self.feature_combiner = feature_combiner_cls(
|
| 158 |
+
patch_dim=patch_dim,
|
| 159 |
+
checkpoint_path_or_url=checkpoint_path_or_url,
|
| 160 |
**kwargs,
|
| 161 |
)
|
| 162 |
|
| 163 |
+
@classmethod
|
| 164 |
+
def from_pretrained(
|
| 165 |
+
cls,
|
| 166 |
+
name: str = "spectre-large",
|
| 167 |
+
*,
|
| 168 |
+
include_feature_combiner: bool = True,
|
| 169 |
+
pretrained: bool = True,
|
| 170 |
+
strict: bool = True,
|
| 171 |
+
backbone_kwargs: Optional[dict] = None,
|
| 172 |
+
feature_combiner_kwargs: Optional[dict] = None,
|
| 173 |
+
device: Union[str, torch.device, None] = None,
|
| 174 |
+
dtype: Optional[torch.dtype] = None,
|
| 175 |
+
verbose: bool = False,
|
| 176 |
+
) -> 'SpectreImageFeatureExtractor':
|
| 177 |
+
"""Build a named SPECTRE model, by default with its published weights.
|
| 178 |
+
|
| 179 |
+
Args:
|
| 180 |
+
name: A preset name, e.g. "spectre-large". See `spectre.list_presets()`.
|
| 181 |
+
include_feature_combiner: Include the feature combiner (one embedding per scan). When
|
| 182 |
+
False you get per-crop backbone features and no combiner checkpoint is fetched.
|
| 183 |
+
pretrained: Load published weights. Raises if the preset has none.
|
| 184 |
+
strict: Require the checkpoint to match the architecture exactly.
|
| 185 |
+
backbone_kwargs: Overrides merged over the preset's backbone kwargs.
|
| 186 |
+
feature_combiner_kwargs: Overrides merged over the preset's combiner kwargs.
|
| 187 |
+
device: Device to move the model to.
|
| 188 |
+
dtype: Dtype to cast the model to.
|
| 189 |
+
verbose: Print checkpoint download/load progress.
|
| 190 |
+
|
| 191 |
+
Returns:
|
| 192 |
+
The model in eval mode.
|
| 193 |
+
"""
|
| 194 |
+
if any(token in str(name) for token in ('/', '\\', '://')) or str(name).endswith('.pt'):
|
| 195 |
+
raise ValueError(
|
| 196 |
+
f"from_pretrained() takes a preset name, not a path or URL, but got {name!r}. "
|
| 197 |
+
f"Available presets: {list_presets()}. To load your own checkpoint, construct "
|
| 198 |
+
f"the model directly:\n"
|
| 199 |
+
f" SpectreImageFeatureExtractor(\n"
|
| 200 |
+
f" backbone_name='vit_large_patch16_128',\n"
|
| 201 |
+
f" backbone_checkpoint_path_or_url={name!r},\n"
|
| 202 |
+
f" )"
|
| 203 |
+
)
|
| 204 |
+
|
| 205 |
+
preset = get_preset(name)
|
| 206 |
+
|
| 207 |
+
if pretrained and not preset.has_pretrained_weights:
|
| 208 |
+
raise ValueError(
|
| 209 |
+
f"Preset {name!r} has no published weights. Presets with weights: "
|
| 210 |
+
f"{list_pretrained()}. Pass pretrained=False for a randomly initialised "
|
| 211 |
+
f"{name!r} model."
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
merged_backbone_kwargs = dict(preset.backbone_kwargs)
|
| 215 |
+
merged_backbone_kwargs.update(backbone_kwargs or {})
|
| 216 |
+
merged_combiner_kwargs = dict(preset.feature_combiner_kwargs)
|
| 217 |
+
merged_combiner_kwargs.update(feature_combiner_kwargs or {})
|
| 218 |
+
|
| 219 |
+
# __init__ forwards **kwargs into both sub-model factories, which only accept verbose and
|
| 220 |
+
# strict when they are actually loading a checkpoint.
|
| 221 |
+
load_kwargs = {"verbose": verbose, "strict": strict} if pretrained else {}
|
| 222 |
+
|
| 223 |
+
model = cls(
|
| 224 |
+
backbone_name=preset.backbone,
|
| 225 |
+
backbone_kwargs=merged_backbone_kwargs,
|
| 226 |
+
backbone_checkpoint_path_or_url=preset.backbone_weights if pretrained else None,
|
| 227 |
+
feature_combiner_name=preset.feature_combiner if include_feature_combiner else None,
|
| 228 |
+
feature_combiner_kwargs=merged_combiner_kwargs,
|
| 229 |
+
feature_combiner_checkpoint_path_or_url=(
|
| 230 |
+
preset.feature_combiner_weights if pretrained and include_feature_combiner else None
|
| 231 |
+
),
|
| 232 |
+
**load_kwargs,
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
if device is not None or dtype is not None:
|
| 236 |
+
model = model.to(device=device, dtype=dtype)
|
| 237 |
+
return model.eval()
|
| 238 |
+
|
| 239 |
+
@property
|
| 240 |
+
def crop_size(self) -> Tuple[int, int, int]:
|
| 241 |
+
"""Spatial size (H, W, D) of one CT crop the backbone consumes."""
|
| 242 |
+
return tuple(self.backbone.patch_embed.img_size)
|
| 243 |
+
|
| 244 |
+
@property
|
| 245 |
+
def has_feature_combiner(self) -> bool:
|
| 246 |
+
return self.feature_combiner is not None
|
| 247 |
+
|
| 248 |
def extract_backbone_features(
|
| 249 |
+
self,
|
| 250 |
x: torch.Tensor,
|
| 251 |
):
|
| 252 |
"""
|
| 253 |
+
Extract features from the backbone for a batch of image sets. Input is expected to be of
|
| 254 |
+
shape (B, N, C, H, W, D), where B is the batch size, N is the number of image patches per
|
| 255 |
image, C is the number of channels, H is height, W is width, and D is depth.
|
| 256 |
+
The output will be a tensor of extracted features (B, N, T, F) where T is the number of
|
| 257 |
tokens and F is the feature dimension.
|
| 258 |
|
| 259 |
Args:
|
|
|
|
| 271 |
return features
|
| 272 |
|
| 273 |
def combine_features(
|
| 274 |
+
self,
|
| 275 |
features: torch.Tensor,
|
| 276 |
grid_size: tuple[int, int, int],
|
| 277 |
):
|
|
|
|
| 300 |
features = self.feature_combiner(features, grid_size) # (B, T', F')
|
| 301 |
return features
|
| 302 |
|
| 303 |
+
def _pool_crop_tokens(self, features: torch.Tensor) -> torch.Tensor:
|
| 304 |
+
"""Reduce per-crop backbone tokens (M, T, F) to the combiner's input (M, 2F).
|
| 305 |
+
|
| 306 |
+
Must stay bit-identical to `combine_features`: the same CLS token concatenated with the
|
| 307 |
+
same mean over the same axis. The prefix index 0 assumes num_prefix_tokens == 1, which
|
| 308 |
+
holds for every preset; changing it would move published embeddings.
|
| 309 |
+
"""
|
| 310 |
+
if features.ndim == 2: # backbone already pooled globally
|
| 311 |
+
return features
|
| 312 |
+
if features.shape[1] == 1: # CLS only
|
| 313 |
+
return features[:, 0]
|
| 314 |
+
return torch.cat([features[:, 0], features[:, 1:].mean(dim=1)], dim=-1)
|
| 315 |
+
|
| 316 |
+
def _encode(
|
| 317 |
+
self,
|
| 318 |
+
items: List[Tuple[torch.Tensor, Optional[Tuple[int, int, int]]]],
|
| 319 |
+
max_crops_per_forward: Optional[int] = None,
|
| 320 |
+
) -> List[torch.Tensor]:
|
| 321 |
+
"""Run crops from any number of scans through the backbone in one pass.
|
| 322 |
+
|
| 323 |
+
`items` pairs each scan's crops (N_i, C, cH, cW, cD) with its grid. Crops from every scan
|
| 324 |
+
go through the backbone together; only the feature combiner, whose positional encoding
|
| 325 |
+
depends on the grid, is split back out per scan.
|
| 326 |
+
"""
|
| 327 |
+
if not items:
|
| 328 |
+
return []
|
| 329 |
+
|
| 330 |
+
crops_per_scan = [crops for crops, _ in items]
|
| 331 |
+
grids = [grid for _, grid in items]
|
| 332 |
+
|
| 333 |
+
reference = crops_per_scan[0].shape[1:]
|
| 334 |
+
for i, crops in enumerate(crops_per_scan):
|
| 335 |
+
if crops.shape[1:] != reference:
|
| 336 |
+
raise ValueError(
|
| 337 |
+
f"All scans must have the same crop shape, but scan 0 has crops of "
|
| 338 |
+
f"{tuple(reference)} and scan {i} has {tuple(crops.shape[1:])}."
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
param = next(self.parameters())
|
| 342 |
+
counts = [int(crops.shape[0]) for crops in crops_per_scan]
|
| 343 |
+
all_crops = torch.cat(
|
| 344 |
+
[crops.to(device=param.device, dtype=param.dtype) for crops in crops_per_scan], dim=0
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
chunk = max_crops_per_forward if max_crops_per_forward else all_crops.shape[0]
|
| 348 |
+
chunk_features = []
|
| 349 |
+
for crops in torch.split(all_crops, chunk, dim=0):
|
| 350 |
+
features = self.backbone(crops)
|
| 351 |
+
if features.ndim == 2: # only CLS token
|
| 352 |
+
features = features.unsqueeze(1)
|
| 353 |
+
if self.feature_combiner is not None:
|
| 354 |
+
# Pool inside the loop: unpooled tokens for a whole batch of scans are orders of
|
| 355 |
+
# magnitude larger than the pooled features the combiner actually needs.
|
| 356 |
+
features = self._pool_crop_tokens(features)
|
| 357 |
+
chunk_features.append(features)
|
| 358 |
+
|
| 359 |
+
features = torch.cat(chunk_features, dim=0)
|
| 360 |
+
per_scan = torch.split(features, counts, dim=0)
|
| 361 |
+
|
| 362 |
+
if self.feature_combiner is None:
|
| 363 |
+
return list(per_scan)
|
| 364 |
+
|
| 365 |
+
# The combiner's rotary embedding depends on the grid and there is no attention mask, so
|
| 366 |
+
# scans can only share a forward pass when their grids match exactly.
|
| 367 |
+
groups: Dict[Tuple[int, int, int], List[int]] = {}
|
| 368 |
+
for index, grid in enumerate(grids):
|
| 369 |
+
groups.setdefault(tuple(grid), []).append(index)
|
| 370 |
+
|
| 371 |
+
results: List[torch.Tensor] = [torch.empty(0)] * len(items)
|
| 372 |
+
for grid, indices in groups.items():
|
| 373 |
+
batch = torch.stack([per_scan[i] for i in indices], dim=0) # (G, N, 2F)
|
| 374 |
+
combined = self.feature_combiner(batch, grid) # (G, T', F')
|
| 375 |
+
for position, index in enumerate(indices):
|
| 376 |
+
results[index] = combined[position]
|
| 377 |
+
|
| 378 |
+
return results
|
| 379 |
+
|
| 380 |
+
def _prepare_scan(
|
| 381 |
+
self,
|
| 382 |
+
x: torch.Tensor,
|
| 383 |
+
grid_size: Optional[Sequence[int]],
|
| 384 |
+
index: Optional[int] = None,
|
| 385 |
+
) -> Tuple[torch.Tensor, Tuple[int, int, int]]:
|
| 386 |
+
"""Normalise one scan to (crops, grid), windowing it first if it is still a whole volume."""
|
| 387 |
+
where = "" if index is None else f" (scan {index})"
|
| 388 |
+
|
| 389 |
+
if hasattr(x, 'as_tensor'): # MONAI MetaTensor, without importing monai
|
| 390 |
+
x = x.as_tensor()
|
| 391 |
+
if not isinstance(x, torch.Tensor):
|
| 392 |
+
raise TypeError(f"Expected a torch.Tensor{where}, got {type(x).__name__}.")
|
| 393 |
+
|
| 394 |
+
crop_size = self.crop_size
|
| 395 |
+
|
| 396 |
+
if x.ndim in (3, 4):
|
| 397 |
+
if grid_size is not None:
|
| 398 |
+
raise ValueError(
|
| 399 |
+
f"grid_size must not be passed for a raw {x.ndim}D scan{where}: it is derived "
|
| 400 |
+
f"from the scan's shape while windowing. Pass grid_size only for "
|
| 401 |
+
f"pre-windowed (N, C, H, W, D) input."
|
| 402 |
+
)
|
| 403 |
+
expected_channels = self.backbone.patch_embed.proj.in_channels
|
| 404 |
+
if x.ndim == 4 and x.shape[0] != expected_channels:
|
| 405 |
+
raise ValueError(
|
| 406 |
+
f"A 4D input{where} is read as one raw scan (C, H, W, D) with C="
|
| 407 |
+
f"{expected_channels}, but got shape {tuple(x.shape)}. For a batch of scans, "
|
| 408 |
+
f"pass a list of tensors instead."
|
| 409 |
+
)
|
| 410 |
+
return window_scan(x, crop_size)
|
| 411 |
+
|
| 412 |
+
if x.ndim == 5:
|
| 413 |
+
# (N, C, H, W, D) and (B, C, H, W, D) are indistinguishable by rank, so 5D always
|
| 414 |
+
# means one pre-windowed scan; raw batches go through a list.
|
| 415 |
+
if tuple(x.shape[2:]) != crop_size:
|
| 416 |
+
raise ValueError(
|
| 417 |
+
f"A 5D input{where} is read as one pre-windowed scan (N, C, H, W, D) whose "
|
| 418 |
+
f"crops must be {crop_size}, but got crops of {tuple(x.shape[2:])}. If this "
|
| 419 |
+
f"is a batch of raw scans, pass a list of (C, H, W, D) tensors instead."
|
| 420 |
+
)
|
| 421 |
+
if grid_size is None:
|
| 422 |
+
raise ValueError(
|
| 423 |
+
f"grid_size is required for pre-windowed (N, C, H, W, D) input{where}. Pass "
|
| 424 |
+
f"the raw scan as (C, H, W, D) to have it windowed for you."
|
| 425 |
+
)
|
| 426 |
+
grid = tuple(int(g) for g in grid_size)
|
| 427 |
+
if len(grid) != 3:
|
| 428 |
+
raise ValueError(f"grid_size must have 3 elements (H, W, D), got {grid}.")
|
| 429 |
+
expected = grid[0] * grid[1] * grid[2]
|
| 430 |
+
if x.shape[0] != expected:
|
| 431 |
+
raise ValueError(
|
| 432 |
+
f"grid_size {grid} implies {expected} crops but got {x.shape[0]}{where}."
|
| 433 |
+
)
|
| 434 |
+
return x, grid
|
| 435 |
+
|
| 436 |
+
raise ValueError(
|
| 437 |
+
f"Unsupported input with {x.ndim} dimensions{where}. Expected one of: (H, W, D) or "
|
| 438 |
+
f"(C, H, W, D) for a raw scan, (N, C, H, W, D) for a pre-windowed scan, "
|
| 439 |
+
f"(B, N, C, H, W, D) for a pre-windowed batch, or a list of scans."
|
| 440 |
+
)
|
| 441 |
+
|
| 442 |
+
def forward(
|
| 443 |
+
self,
|
| 444 |
+
x: Union[torch.Tensor, Sequence[torch.Tensor]],
|
| 445 |
+
grid_size: Optional[Union[Sequence[int], Sequence[Sequence[int]]]] = None,
|
| 446 |
+
*,
|
| 447 |
+
max_crops_per_forward: Optional[int] = None,
|
| 448 |
+
) -> Union[torch.Tensor, List[torch.Tensor]]:
|
| 449 |
+
"""Embed one or more CT scans.
|
| 450 |
+
|
| 451 |
+
Accepts, dispatching on rank:
|
| 452 |
+
|
| 453 |
+
| input | meaning | grid_size | returns |
|
| 454 |
+
|----------------------|--------------------------------|-----------|------------------|
|
| 455 |
+
| `(H, W, D)` | raw scan, in HU | derived | `(T', F')` |
|
| 456 |
+
| `(C, H, W, D)` | raw scan, in HU | derived | `(T', F')` |
|
| 457 |
+
| `(N, C, cH, cW, cD)` | pre-windowed scan | required | `(T', F')` |
|
| 458 |
+
| `(B, N, C, ...)` | pre-windowed batch, one grid | required | `(B, T', F')` |
|
| 459 |
+
| `list[...]` | batch of scans, may differ | per scan | `list[(T', F')]` |
|
| 460 |
+
|
| 461 |
+
Raw input is HU-windowed and cut into crops internally; pre-windowed input is passed
|
| 462 |
+
through untouched, so it must already be preprocessed the same way.
|
| 463 |
+
|
| 464 |
+
Without a feature combiner the trailing shape is the backbone's per-crop tokens
|
| 465 |
+
`(N, T, F)` instead of `(T', F')`.
|
| 466 |
+
|
| 467 |
+
Args:
|
| 468 |
+
x: Scan(s), as above.
|
| 469 |
+
grid_size: Crop grid for pre-windowed input. For a list, either one grid shared by
|
| 470 |
+
every scan or one per scan.
|
| 471 |
+
max_crops_per_forward: Cap the crops in a single backbone pass to bound memory.
|
| 472 |
+
None runs them all at once.
|
| 473 |
+
"""
|
| 474 |
+
is_batch = isinstance(x, (list, tuple))
|
| 475 |
+
|
| 476 |
+
if is_batch:
|
| 477 |
+
grids = self._grids_for_batch(grid_size, len(x))
|
| 478 |
+
items = [self._prepare_scan(scan, g, i) for i, (scan, g) in enumerate(zip(x, grids))]
|
| 479 |
+
return self._encode(items, max_crops_per_forward)
|
| 480 |
+
|
| 481 |
+
if hasattr(x, 'as_tensor'):
|
| 482 |
+
x = x.as_tensor()
|
| 483 |
+
|
| 484 |
+
if isinstance(x, torch.Tensor) and x.ndim == 6:
|
| 485 |
+
if grid_size is None:
|
| 486 |
+
raise ValueError(
|
| 487 |
+
"grid_size is required for pre-windowed (B, N, C, H, W, D) input. Pass a list "
|
| 488 |
+
"of raw (C, H, W, D) scans to have them windowed for you."
|
| 489 |
+
)
|
| 490 |
+
grid = tuple(int(g) for g in grid_size)
|
| 491 |
+
items = [self._prepare_scan(scan, grid, i) for i, scan in enumerate(x)]
|
| 492 |
+
return torch.stack(self._encode(items, max_crops_per_forward), dim=0)
|
| 493 |
+
|
| 494 |
+
items = [self._prepare_scan(x, grid_size)]
|
| 495 |
+
return self._encode(items, max_crops_per_forward)[0]
|
| 496 |
+
|
| 497 |
+
@staticmethod
|
| 498 |
+
def _grids_for_batch(
|
| 499 |
+
grid_size: Optional[Union[Sequence[int], Sequence[Sequence[int]]]],
|
| 500 |
+
count: int,
|
| 501 |
+
) -> List[Optional[Tuple[int, ...]]]:
|
| 502 |
+
"""Resolve `grid_size` for a list input: one shared grid, one per scan, or none."""
|
| 503 |
+
if grid_size is None:
|
| 504 |
+
return [None] * count
|
| 505 |
+
# A grid is always three ints, so all-ints means one shared grid.
|
| 506 |
+
if all(isinstance(g, int) for g in grid_size):
|
| 507 |
+
return [tuple(int(g) for g in grid_size)] * count
|
| 508 |
+
grids = [tuple(int(v) for v in g) for g in grid_size]
|
| 509 |
+
if len(grids) != count:
|
| 510 |
+
raise ValueError(
|
| 511 |
+
f"Got {len(grids)} grid sizes for {count} scans. Pass one grid_size per scan, or "
|
| 512 |
+
f"a single (H, W, D) grid shared by all of them."
|
| 513 |
+
)
|
| 514 |
+
return grids
|
| 515 |
+
|
| 516 |
+
@torch.inference_mode()
|
| 517 |
+
def extract(
|
| 518 |
+
self,
|
| 519 |
+
scans: Union[torch.Tensor, Sequence[torch.Tensor]],
|
| 520 |
+
grid_size: Optional[Union[Sequence[int], Sequence[Sequence[int]]]] = None,
|
| 521 |
+
*,
|
| 522 |
+
max_crops_per_forward: Optional[int] = 16,
|
| 523 |
+
) -> Union[torch.Tensor, List[torch.Tensor]]:
|
| 524 |
+
"""`forward` without gradients, with memory capped by default. See `forward`."""
|
| 525 |
+
return self.forward(scans, grid_size, max_crops_per_forward=max_crops_per_forward)
|
| 526 |
|
| 527 |
@classmethod
|
| 528 |
def from_config(
|
| 529 |
+
cls,
|
| 530 |
config: dict,
|
| 531 |
**kwargs,
|
| 532 |
) -> 'SpectreImageFeatureExtractor':
|
| 533 |
+
warnings.warn(
|
| 534 |
+
"SpectreImageFeatureExtractor.from_config() is deprecated since spectre-fm 0.2.1 and "
|
| 535 |
+
"will be removed in 0.3.0. Use "
|
| 536 |
+
"SpectreImageFeatureExtractor.from_pretrained('spectre-large') instead, or call "
|
| 537 |
+
"SpectreImageFeatureExtractor(...) directly for a custom architecture.",
|
| 538 |
+
FutureWarning,
|
| 539 |
+
stacklevel=2,
|
| 540 |
+
)
|
| 541 |
model = cls(
|
| 542 |
backbone_name=config["backbone"],
|
| 543 |
backbone_checkpoint_path_or_url=config.get("backbone_checkpoint_path_or_url", None),
|
spectre/models/__init__.py
CHANGED
|
@@ -1,31 +1,3 @@
|
|
| 1 |
-
from .vision_transformer import
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
vit_small_patch16_128,
|
| 5 |
-
vit_base_patch16_128,
|
| 6 |
-
vit_base_patch32_128,
|
| 7 |
-
vit_large_patch16_128,
|
| 8 |
-
vit_large_patch32_128,
|
| 9 |
-
)
|
| 10 |
-
from .vision_transformer_features import (
|
| 11 |
-
FeatureVisionTransformer,
|
| 12 |
-
feat_vit_tiny,
|
| 13 |
-
feat_vit_small,
|
| 14 |
-
feat_vit_base,
|
| 15 |
-
feat_vit_large,
|
| 16 |
-
)
|
| 17 |
-
|
| 18 |
-
__all__ = [
|
| 19 |
-
'VisionTransformer',
|
| 20 |
-
'vit_tiny_patch16_128',
|
| 21 |
-
'vit_small_patch16_128',
|
| 22 |
-
'vit_base_patch16_128',
|
| 23 |
-
'vit_base_patch32_128',
|
| 24 |
-
'vit_large_patch16_128',
|
| 25 |
-
'vit_large_patch32_128',
|
| 26 |
-
'FeatureVisionTransformer',
|
| 27 |
-
'feat_vit_tiny',
|
| 28 |
-
'feat_vit_small',
|
| 29 |
-
'feat_vit_base',
|
| 30 |
-
'feat_vit_large',
|
| 31 |
-
]
|
|
|
|
| 1 |
+
from .vision_transformer import VisionTransformer, vit_tiny_patch16_128, vit_small_patch16_128, vit_base_patch16_128, vit_base_patch32_128, vit_large_patch16_128, vit_large_patch32_128
|
| 2 |
+
from .vision_transformer_features import FeatureVisionTransformer, feat_vit_tiny, feat_vit_small, feat_vit_base, feat_vit_large
|
| 3 |
+
__all__ = ['VisionTransformer', 'vit_tiny_patch16_128', 'vit_small_patch16_128', 'vit_base_patch16_128', 'vit_base_patch32_128', 'vit_large_patch16_128', 'vit_large_patch32_128', 'FeatureVisionTransformer', 'feat_vit_tiny', 'feat_vit_small', 'feat_vit_base', 'feat_vit_large']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
spectre/models/vision_transformer.py
CHANGED
|
@@ -1,6 +1,5 @@
|
|
| 1 |
import os
|
| 2 |
from functools import partial
|
| 3 |
-
from urllib.parse import urlparse
|
| 4 |
from typing import (
|
| 5 |
Tuple, Union, Callable, Literal,
|
| 6 |
Optional, Type, Set, List, Dict, Any,
|
|
@@ -10,17 +9,17 @@ import torch
|
|
| 10 |
import torch.nn as nn
|
| 11 |
from timm.layers import PatchDropout, AttentionPoolLatent
|
| 12 |
from timm.models.vision_transformer import LayerScale, DropPath, Mlp
|
| 13 |
-
from huggingface_hub import hf_hub_download, load_state_dict_from_file
|
| 14 |
|
| 15 |
from spectre.models.layers import (
|
| 16 |
-
PatchEmbed,
|
| 17 |
-
Attention,
|
| 18 |
RotaryPositionEmbedding,
|
| 19 |
)
|
| 20 |
from spectre.utils import (
|
| 21 |
-
resample_abs_pos_embed,
|
| 22 |
-
feature_take_indices,
|
| 23 |
global_pool_nlc,
|
|
|
|
| 24 |
)
|
| 25 |
|
| 26 |
|
|
@@ -591,53 +590,13 @@ class VisionTransformer(nn.Module):
|
|
| 591 |
cls,
|
| 592 |
checkpoint_path_or_url: Union[str, os.PathLike],
|
| 593 |
verbose: bool = True,
|
|
|
|
| 594 |
**kwargs
|
| 595 |
) -> 'VisionTransformer':
|
| 596 |
"""Load pretrained model weights from a local path or a URL."""
|
| 597 |
model = cls(**kwargs)
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
try:
|
| 601 |
-
parsed = urlparse(str(path))
|
| 602 |
-
return parsed.scheme in ('http', 'https')
|
| 603 |
-
except Exception:
|
| 604 |
-
return False
|
| 605 |
-
|
| 606 |
-
def _is_hf_url(path: str) -> bool:
|
| 607 |
-
try:
|
| 608 |
-
parsed = urlparse(str(path))
|
| 609 |
-
return 'huggingface.co' in parsed.netloc
|
| 610 |
-
except Exception:
|
| 611 |
-
return False
|
| 612 |
-
|
| 613 |
-
if _is_hf_url(checkpoint_path_or_url):
|
| 614 |
-
if verbose:
|
| 615 |
-
print(f"Downloading pretrained weights from Hugging Face URL: {checkpoint_path_or_url}")
|
| 616 |
-
# Extract repo_id and filename from the URL
|
| 617 |
-
parsed = urlparse(checkpoint_path_or_url)
|
| 618 |
-
parts = parsed.path.strip('/').split('/')
|
| 619 |
-
repo_id = '/'.join(parts[:2]) # e.g., 'cclaess/SPECTRE'
|
| 620 |
-
filename = parts[-1] # e.g., 'spectre_backbone_vit_large_patch16_128.pt'
|
| 621 |
-
|
| 622 |
-
local_path = hf_hub_download(repo_id=repo_id, filename=filename)
|
| 623 |
-
state_dict = load_state_dict_from_file(local_path, map_location='cpu')
|
| 624 |
-
elif _is_url(checkpoint_path_or_url):
|
| 625 |
-
if verbose:
|
| 626 |
-
print(f"Downloading pretrained weights from URL: {checkpoint_path_or_url}")
|
| 627 |
-
state_dict = torch.hub.load_state_dict_from_url(
|
| 628 |
-
checkpoint_path_or_url, map_location='cpu', weights_only=False, progress=verbose)
|
| 629 |
-
else:
|
| 630 |
-
local_path = os.fspath(checkpoint_path_or_url)
|
| 631 |
-
if not os.path.exists(local_path):
|
| 632 |
-
raise FileNotFoundError(f"Checkpoint file not found: {local_path}")
|
| 633 |
-
if verbose:
|
| 634 |
-
print(f"Loading checkpoint from local path: {local_path}")
|
| 635 |
-
state_dict = torch.load(local_path, map_location='cpu', weights_only=False)
|
| 636 |
-
|
| 637 |
-
msg = model.load_state_dict(state_dict, strict=False)
|
| 638 |
-
if verbose:
|
| 639 |
-
print(f"Loaded pretrained weights with msg: {msg}")
|
| 640 |
-
return model
|
| 641 |
|
| 642 |
|
| 643 |
def vit_tiny_patch16_128(
|
|
|
|
| 1 |
import os
|
| 2 |
from functools import partial
|
|
|
|
| 3 |
from typing import (
|
| 4 |
Tuple, Union, Callable, Literal,
|
| 5 |
Optional, Type, Set, List, Dict, Any,
|
|
|
|
| 9 |
import torch.nn as nn
|
| 10 |
from timm.layers import PatchDropout, AttentionPoolLatent
|
| 11 |
from timm.models.vision_transformer import LayerScale, DropPath, Mlp
|
|
|
|
| 12 |
|
| 13 |
from spectre.models.layers import (
|
| 14 |
+
PatchEmbed,
|
| 15 |
+
Attention,
|
| 16 |
RotaryPositionEmbedding,
|
| 17 |
)
|
| 18 |
from spectre.utils import (
|
| 19 |
+
resample_abs_pos_embed,
|
| 20 |
+
feature_take_indices,
|
| 21 |
global_pool_nlc,
|
| 22 |
+
load_pretrained_into,
|
| 23 |
)
|
| 24 |
|
| 25 |
|
|
|
|
| 590 |
cls,
|
| 591 |
checkpoint_path_or_url: Union[str, os.PathLike],
|
| 592 |
verbose: bool = True,
|
| 593 |
+
strict: bool = True,
|
| 594 |
**kwargs
|
| 595 |
) -> 'VisionTransformer':
|
| 596 |
"""Load pretrained model weights from a local path or a URL."""
|
| 597 |
model = cls(**kwargs)
|
| 598 |
+
return load_pretrained_into(
|
| 599 |
+
model, checkpoint_path_or_url, strict=strict, verbose=verbose)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
|
| 601 |
|
| 602 |
def vit_tiny_patch16_128(
|
spectre/models/vision_transformer_features.py
CHANGED
|
@@ -1,16 +1,19 @@
|
|
| 1 |
import os
|
| 2 |
import math
|
| 3 |
from functools import partial
|
| 4 |
-
from urllib.parse import urlparse
|
| 5 |
from typing import Union, Callable, Literal, Optional, Type, Set, Tuple
|
| 6 |
|
| 7 |
import torch
|
| 8 |
import torch.nn as nn
|
| 9 |
from timm.models.vision_transformer import Mlp
|
| 10 |
from timm.layers import PatchDropout, AttentionPoolLatent
|
| 11 |
-
from huggingface_hub import hf_hub_download, load_state_dict_from_file
|
| 12 |
|
| 13 |
-
from spectre.utils import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from spectre.models.vision_transformer import Block
|
| 15 |
from spectre.models.layers import RotaryPositionEmbedding
|
| 16 |
|
|
@@ -124,6 +127,11 @@ class FeatureVisionTransformer(nn.Module):
|
|
| 124 |
num_heads=num_heads,
|
| 125 |
**rope_kwargs,
|
| 126 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
self.pos_drop = nn.Dropout(p=pos_drop_rate)
|
| 128 |
if patch_drop_rate > 0:
|
| 129 |
self.patch_drop = PatchDropout(
|
|
@@ -318,53 +326,13 @@ class FeatureVisionTransformer(nn.Module):
|
|
| 318 |
cls,
|
| 319 |
checkpoint_path_or_url: Union[str, os.PathLike],
|
| 320 |
verbose: bool = True,
|
|
|
|
| 321 |
**kwargs
|
| 322 |
) -> 'FeatureVisionTransformer':
|
| 323 |
"""Load pretrained model weights from a local path or a URL."""
|
| 324 |
model = cls(**kwargs)
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
try:
|
| 328 |
-
parsed = urlparse(str(path))
|
| 329 |
-
return parsed.scheme in ('http', 'https')
|
| 330 |
-
except Exception:
|
| 331 |
-
return False
|
| 332 |
-
|
| 333 |
-
def _is_hf_url(path: str) -> bool:
|
| 334 |
-
try:
|
| 335 |
-
parsed = urlparse(str(path))
|
| 336 |
-
return 'huggingface.co' in parsed.netloc
|
| 337 |
-
except Exception:
|
| 338 |
-
return False
|
| 339 |
-
|
| 340 |
-
if _is_hf_url(checkpoint_path_or_url):
|
| 341 |
-
if verbose:
|
| 342 |
-
print(f"Downloading pretrained weights from Hugging Face URL: {checkpoint_path_or_url}")
|
| 343 |
-
# Extract repo_id and filename from the URL
|
| 344 |
-
parsed = urlparse(checkpoint_path_or_url)
|
| 345 |
-
parts = parsed.path.strip('/').split('/')
|
| 346 |
-
repo_id = '/'.join(parts[:2]) # e.g., 'cclaess/SPECTRE'
|
| 347 |
-
filename = parts[-1] # e.g., 'spectre_backbone_vit_large_patch16_128.pt'
|
| 348 |
-
|
| 349 |
-
local_path = hf_hub_download(repo_id=repo_id, filename=filename)
|
| 350 |
-
state_dict = load_state_dict_from_file(local_path, map_location='cpu')
|
| 351 |
-
elif _is_url(checkpoint_path_or_url):
|
| 352 |
-
if verbose:
|
| 353 |
-
print(f"Downloading pretrained weights from URL: {checkpoint_path_or_url}")
|
| 354 |
-
state_dict = torch.hub.load_state_dict_from_url(
|
| 355 |
-
checkpoint_path_or_url, map_location='cpu', weights_only=False, progress=verbose)
|
| 356 |
-
else:
|
| 357 |
-
local_path = os.fspath(checkpoint_path_or_url)
|
| 358 |
-
if not os.path.exists(local_path):
|
| 359 |
-
raise FileNotFoundError(f"Checkpoint file not found: {local_path}")
|
| 360 |
-
if verbose:
|
| 361 |
-
print(f"Loading checkpoint from local path: {local_path}")
|
| 362 |
-
state_dict = torch.load(local_path, map_location='cpu', weights_only=False)
|
| 363 |
-
|
| 364 |
-
msg = model.load_state_dict(state_dict, strict=False)
|
| 365 |
-
if verbose:
|
| 366 |
-
print(f"Loaded pretrained weights with msg: {msg}")
|
| 367 |
-
return model
|
| 368 |
|
| 369 |
|
| 370 |
def feat_vit_tiny(
|
|
|
|
| 1 |
import os
|
| 2 |
import math
|
| 3 |
from functools import partial
|
|
|
|
| 4 |
from typing import Union, Callable, Literal, Optional, Type, Set, Tuple
|
| 5 |
|
| 6 |
import torch
|
| 7 |
import torch.nn as nn
|
| 8 |
from timm.models.vision_transformer import Mlp
|
| 9 |
from timm.layers import PatchDropout, AttentionPoolLatent
|
|
|
|
| 10 |
|
| 11 |
+
from spectre.utils import (
|
| 12 |
+
global_pool_nlc,
|
| 13 |
+
to_3tuple,
|
| 14 |
+
resample_abs_pos_embed,
|
| 15 |
+
load_pretrained_into,
|
| 16 |
+
)
|
| 17 |
from spectre.models.vision_transformer import Block
|
| 18 |
from spectre.models.layers import RotaryPositionEmbedding
|
| 19 |
|
|
|
|
| 127 |
num_heads=num_heads,
|
| 128 |
**rope_kwargs,
|
| 129 |
)
|
| 130 |
+
self.requires_per_sample_rope = any([
|
| 131 |
+
self.rope.shift_coords is not None,
|
| 132 |
+
self.rope.jitter_coords is not None,
|
| 133 |
+
self.rope.rescale_coords is not None,
|
| 134 |
+
])
|
| 135 |
self.pos_drop = nn.Dropout(p=pos_drop_rate)
|
| 136 |
if patch_drop_rate > 0:
|
| 137 |
self.patch_drop = PatchDropout(
|
|
|
|
| 326 |
cls,
|
| 327 |
checkpoint_path_or_url: Union[str, os.PathLike],
|
| 328 |
verbose: bool = True,
|
| 329 |
+
strict: bool = True,
|
| 330 |
**kwargs
|
| 331 |
) -> 'FeatureVisionTransformer':
|
| 332 |
"""Load pretrained model weights from a local path or a URL."""
|
| 333 |
model = cls(**kwargs)
|
| 334 |
+
return load_pretrained_into(
|
| 335 |
+
model, checkpoint_path_or_url, strict=strict, verbose=verbose)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
|
| 337 |
|
| 338 |
def feat_vit_tiny(
|
spectre/presets.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Named SPECTRE architectures and their published weights.
|
| 2 |
+
|
| 3 |
+
This module is vendored into the Hugging Face Hub repository by `scripts/export_hf.py`, so it
|
| 4 |
+
must import nothing beyond the standard library.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import copy
|
| 9 |
+
import difflib
|
| 10 |
+
from dataclasses import dataclass, field
|
| 11 |
+
from typing import Any, Dict, List, Optional
|
| 12 |
+
|
| 13 |
+
__all__ = [
|
| 14 |
+
'SpectrePreset',
|
| 15 |
+
'PRESETS',
|
| 16 |
+
'BACKBONE_KWARGS',
|
| 17 |
+
'COMBINER_KWARGS',
|
| 18 |
+
'get_preset',
|
| 19 |
+
'list_presets',
|
| 20 |
+
'list_pretrained',
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
_HF_WEIGHTS = "https://huggingface.co/cclaess/SPECTRE/resolve/main/{}?download=true"
|
| 24 |
+
|
| 25 |
+
# The only place the SPECTRE architecture kwargs are written down. `spectre.model`, the Hugging
|
| 26 |
+
# Face SpectreConfig and scripts/export_hf.py all resolve back to here rather than restating them.
|
| 27 |
+
# The backbone sees raw CT crops; the feature combiner sees the backbone's per-crop embeddings,
|
| 28 |
+
# hence the different RoPE bases.
|
| 29 |
+
BACKBONE_KWARGS: Dict[str, Any] = {
|
| 30 |
+
"num_classes": 0,
|
| 31 |
+
"global_pool": '',
|
| 32 |
+
"pos_embed": "rope",
|
| 33 |
+
"rope_kwargs": {"base": 1000.0},
|
| 34 |
+
"init_values": 1.0,
|
| 35 |
+
}
|
| 36 |
+
COMBINER_KWARGS: Dict[str, Any] = {
|
| 37 |
+
"num_classes": 0,
|
| 38 |
+
"global_pool": '',
|
| 39 |
+
"pos_embed": "rope",
|
| 40 |
+
"rope_kwargs": {"base": 100.0},
|
| 41 |
+
"init_values": 1.0,
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass(frozen=True)
|
| 46 |
+
class SpectrePreset:
|
| 47 |
+
"""One named SPECTRE architecture, optionally with published weights.
|
| 48 |
+
|
| 49 |
+
There is deliberately no crop size here: it is `backbone.patch_embed.img_size`, so the
|
| 50 |
+
architecture stays the single source of truth.
|
| 51 |
+
"""
|
| 52 |
+
name: str
|
| 53 |
+
backbone: str
|
| 54 |
+
feature_combiner: str
|
| 55 |
+
backbone_kwargs: Dict[str, Any] = field(default_factory=dict)
|
| 56 |
+
feature_combiner_kwargs: Dict[str, Any] = field(default_factory=dict)
|
| 57 |
+
backbone_weights: Optional[str] = None
|
| 58 |
+
feature_combiner_weights: Optional[str] = None
|
| 59 |
+
description: str = ""
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def has_pretrained_weights(self) -> bool:
|
| 63 |
+
return self.backbone_weights is not None
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
PRESETS: Dict[str, SpectrePreset] = {
|
| 67 |
+
"spectre-small": SpectrePreset(
|
| 68 |
+
name="spectre-small",
|
| 69 |
+
backbone="vit_small_patch16_128",
|
| 70 |
+
feature_combiner="feat_vit_small",
|
| 71 |
+
backbone_kwargs=copy.deepcopy(BACKBONE_KWARGS),
|
| 72 |
+
feature_combiner_kwargs=copy.deepcopy(COMBINER_KWARGS),
|
| 73 |
+
description="SPECTRE with a ViT-Small backbone. No published weights yet.",
|
| 74 |
+
),
|
| 75 |
+
"spectre-base": SpectrePreset(
|
| 76 |
+
name="spectre-base",
|
| 77 |
+
backbone="vit_base_patch16_128",
|
| 78 |
+
feature_combiner="feat_vit_base",
|
| 79 |
+
backbone_kwargs=copy.deepcopy(BACKBONE_KWARGS),
|
| 80 |
+
feature_combiner_kwargs=copy.deepcopy(COMBINER_KWARGS),
|
| 81 |
+
description="SPECTRE with a ViT-Base backbone. No published weights yet.",
|
| 82 |
+
),
|
| 83 |
+
"spectre-large": SpectrePreset(
|
| 84 |
+
name="spectre-large",
|
| 85 |
+
backbone="vit_large_patch16_128",
|
| 86 |
+
feature_combiner="feat_vit_large",
|
| 87 |
+
backbone_kwargs=copy.deepcopy(BACKBONE_KWARGS),
|
| 88 |
+
feature_combiner_kwargs=copy.deepcopy(COMBINER_KWARGS),
|
| 89 |
+
backbone_weights=_HF_WEIGHTS.format("spectre_backbone_vit_large_patch16_128.pt"),
|
| 90 |
+
feature_combiner_weights=_HF_WEIGHTS.format("spectre_combiner_feature_vit_large.pt"),
|
| 91 |
+
description="SPECTRE with a ViT-Large backbone, pretrained with SSL + vision-language alignment.",
|
| 92 |
+
),
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def list_presets() -> List[str]:
|
| 97 |
+
"""Every known architecture name."""
|
| 98 |
+
return list(PRESETS)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def list_pretrained() -> List[str]:
|
| 102 |
+
"""The architecture names that have published weights."""
|
| 103 |
+
return [name for name, preset in PRESETS.items() if preset.has_pretrained_weights]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def get_preset(name: str) -> SpectrePreset:
|
| 107 |
+
"""Look up a preset by name.
|
| 108 |
+
|
| 109 |
+
The returned preset owns deep copies of its kwargs, so callers can mutate them without
|
| 110 |
+
corrupting the registry for the rest of the process.
|
| 111 |
+
"""
|
| 112 |
+
try:
|
| 113 |
+
preset = PRESETS[name]
|
| 114 |
+
except KeyError:
|
| 115 |
+
suggestion = difflib.get_close_matches(str(name), list(PRESETS), n=1)
|
| 116 |
+
hint = f" Did you mean {suggestion[0]!r}?" if suggestion else ""
|
| 117 |
+
raise ValueError(
|
| 118 |
+
f"Unknown SPECTRE preset {name!r}. Available: {list_presets()}.{hint}"
|
| 119 |
+
) from None
|
| 120 |
+
|
| 121 |
+
return SpectrePreset(
|
| 122 |
+
name=preset.name,
|
| 123 |
+
backbone=preset.backbone,
|
| 124 |
+
feature_combiner=preset.feature_combiner,
|
| 125 |
+
backbone_kwargs=copy.deepcopy(preset.backbone_kwargs),
|
| 126 |
+
feature_combiner_kwargs=copy.deepcopy(preset.feature_combiner_kwargs),
|
| 127 |
+
backbone_weights=preset.backbone_weights,
|
| 128 |
+
feature_combiner_weights=preset.feature_combiner_weights,
|
| 129 |
+
description=preset.description,
|
| 130 |
+
)
|
spectre/utils/__init__.py
CHANGED
|
@@ -1,65 +1,3 @@
|
|
| 1 |
-
from ._utils import
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
to_1tuple,
|
| 5 |
-
to_2tuple,
|
| 6 |
-
to_3tuple,
|
| 7 |
-
to_4tuple,
|
| 8 |
-
)
|
| 9 |
-
from .modeling import (
|
| 10 |
-
deactivate_requires_grad_and_to_eval,
|
| 11 |
-
activate_requires_grad_and_to_train,
|
| 12 |
-
update_momentum,
|
| 13 |
-
update_drop_path_rate,
|
| 14 |
-
repeat_token,
|
| 15 |
-
expand_index_like,
|
| 16 |
-
get_at_index,
|
| 17 |
-
set_at_index,
|
| 18 |
-
mask_at_index,
|
| 19 |
-
mask_bool,
|
| 20 |
-
patchify,
|
| 21 |
-
random_token_mask,
|
| 22 |
-
resample_abs_pos_embed,
|
| 23 |
-
resample_abs_pos_embed_nhwdc,
|
| 24 |
-
resample_patch_embed,
|
| 25 |
-
feature_take_indices,
|
| 26 |
-
global_pool_nlc,
|
| 27 |
-
cat_keep_shapes,
|
| 28 |
-
uncat_with_shapes,
|
| 29 |
-
last_token_pool,
|
| 30 |
-
Format,
|
| 31 |
-
nchwd_to,
|
| 32 |
-
nhwdc_to,
|
| 33 |
-
)
|
| 34 |
-
|
| 35 |
-
__all__ = [
|
| 36 |
-
"fix_random_seeds",
|
| 37 |
-
"to_ntuple",
|
| 38 |
-
"to_1tuple",
|
| 39 |
-
"to_2tuple",
|
| 40 |
-
"to_3tuple",
|
| 41 |
-
"to_4tuple",
|
| 42 |
-
"deactivate_requires_grad_and_to_eval",
|
| 43 |
-
"activate_requires_grad_and_to_train",
|
| 44 |
-
"update_momentum",
|
| 45 |
-
"update_drop_path_rate",
|
| 46 |
-
"repeat_token",
|
| 47 |
-
"expand_index_like",
|
| 48 |
-
"get_at_index",
|
| 49 |
-
"set_at_index",
|
| 50 |
-
"mask_at_index",
|
| 51 |
-
"mask_bool",
|
| 52 |
-
"patchify",
|
| 53 |
-
"random_token_mask",
|
| 54 |
-
"resample_abs_pos_embed",
|
| 55 |
-
"resample_abs_pos_embed_nhwdc",
|
| 56 |
-
"resample_patch_embed",
|
| 57 |
-
"feature_take_indices",
|
| 58 |
-
"global_pool_nlc",
|
| 59 |
-
"cat_keep_shapes",
|
| 60 |
-
"uncat_with_shapes",
|
| 61 |
-
"last_token_pool",
|
| 62 |
-
"Format",
|
| 63 |
-
"nchwd_to",
|
| 64 |
-
"nhwdc_to",
|
| 65 |
-
]
|
|
|
|
| 1 |
+
from ._utils import fix_random_seeds, to_ntuple, to_1tuple, to_2tuple, to_3tuple, to_4tuple
|
| 2 |
+
from .modeling import load_checkpoint, load_pretrained_into, deactivate_requires_grad_and_to_eval, activate_requires_grad_and_to_train, update_momentum, update_drop_path_rate, repeat_token, expand_index_like, get_at_index, set_at_index, mask_at_index, mask_bool, patchify, random_token_mask, resample_abs_pos_embed, resample_abs_pos_embed_nhwdc, resample_patch_embed, feature_take_indices, global_pool_nlc, cat_keep_shapes, uncat_with_shapes, last_token_pool, Format, nchwd_to, nhwdc_to
|
| 3 |
+
__all__ = ['fix_random_seeds', 'to_ntuple', 'to_1tuple', 'to_2tuple', 'to_3tuple', 'to_4tuple', 'load_checkpoint', 'load_pretrained_into', 'deactivate_requires_grad_and_to_eval', 'activate_requires_grad_and_to_train', 'update_momentum', 'update_drop_path_rate', 'repeat_token', 'expand_index_like', 'get_at_index', 'set_at_index', 'mask_at_index', 'mask_bool', 'patchify', 'random_token_mask', 'resample_abs_pos_embed', 'resample_abs_pos_embed_nhwdc', 'resample_patch_embed', 'feature_take_indices', 'global_pool_nlc', 'cat_keep_shapes', 'uncat_with_shapes', 'last_token_pool', 'Format', 'nchwd_to', 'nhwdc_to']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
spectre/utils/modeling.py
CHANGED
|
@@ -1,13 +1,130 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
import math
|
|
|
|
| 4 |
from enum import Enum
|
| 5 |
-
from typing import List, Tuple, Optional, Union
|
|
|
|
| 6 |
|
| 7 |
import torch
|
| 8 |
import torch.nn as nn
|
| 9 |
import torch.nn.functional as F
|
| 10 |
import numpy as np
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def deactivate_requires_grad_and_to_eval(model: nn.Module):
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import os
|
| 4 |
import math
|
| 5 |
+
import warnings
|
| 6 |
from enum import Enum
|
| 7 |
+
from typing import Dict, List, Tuple, Optional, Union
|
| 8 |
+
from urllib.parse import urlparse
|
| 9 |
|
| 10 |
import torch
|
| 11 |
import torch.nn as nn
|
| 12 |
import torch.nn.functional as F
|
| 13 |
import numpy as np
|
| 14 |
+
from huggingface_hub import hf_hub_download, load_state_dict_from_file
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _is_url(path: Union[str, os.PathLike]) -> bool:
|
| 18 |
+
try:
|
| 19 |
+
return urlparse(str(path)).scheme in ('http', 'https')
|
| 20 |
+
except Exception:
|
| 21 |
+
return False
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _is_hf_url(path: Union[str, os.PathLike]) -> bool:
|
| 25 |
+
try:
|
| 26 |
+
return 'huggingface.co' in urlparse(str(path)).netloc
|
| 27 |
+
except Exception:
|
| 28 |
+
return False
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _torch_load(path: Union[str, os.PathLike], map_location) -> Dict[str, torch.Tensor]:
|
| 32 |
+
"""Load a checkpoint, preferring the safe unpickler and falling back if the file needs it."""
|
| 33 |
+
try:
|
| 34 |
+
return torch.load(path, map_location=map_location, weights_only=True)
|
| 35 |
+
except Exception:
|
| 36 |
+
warnings.warn(
|
| 37 |
+
f"Could not load {path} with weights_only=True, retrying with weights_only=False. "
|
| 38 |
+
f"This executes arbitrary code from the checkpoint - only do this for files you trust.",
|
| 39 |
+
RuntimeWarning,
|
| 40 |
+
stacklevel=2,
|
| 41 |
+
)
|
| 42 |
+
return torch.load(path, map_location=map_location, weights_only=False)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def load_checkpoint(
|
| 46 |
+
checkpoint_path_or_url: Union[str, os.PathLike],
|
| 47 |
+
map_location: Union[str, torch.device] = 'cpu',
|
| 48 |
+
verbose: bool = False,
|
| 49 |
+
) -> Dict[str, torch.Tensor]:
|
| 50 |
+
"""Load a state dict from a local path, a Hugging Face URL, or a plain URL.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
checkpoint_path_or_url: Local file, a `https://huggingface.co/<repo>/resolve/<rev>/<file>`
|
| 54 |
+
URL, or any other http(s) URL.
|
| 55 |
+
map_location: Passed through to the underlying loader.
|
| 56 |
+
verbose: Print where the checkpoint is coming from.
|
| 57 |
+
Returns:
|
| 58 |
+
The loaded state dict.
|
| 59 |
+
"""
|
| 60 |
+
if _is_hf_url(checkpoint_path_or_url):
|
| 61 |
+
if verbose:
|
| 62 |
+
print(f"Downloading pretrained weights from Hugging Face URL: {checkpoint_path_or_url}")
|
| 63 |
+
parsed = urlparse(str(checkpoint_path_or_url))
|
| 64 |
+
parts = parsed.path.strip('/').split('/')
|
| 65 |
+
if len(parts) < 2:
|
| 66 |
+
raise ValueError(
|
| 67 |
+
f"Cannot parse a Hugging Face repo id out of {checkpoint_path_or_url!r}. "
|
| 68 |
+
f"Expected https://huggingface.co/<owner>/<repo>/resolve/<revision>/<filename>."
|
| 69 |
+
)
|
| 70 |
+
repo_id = '/'.join(parts[:2])
|
| 71 |
+
filename = parts[-1]
|
| 72 |
+
local_path = hf_hub_download(repo_id=repo_id, filename=filename)
|
| 73 |
+
return load_state_dict_from_file(local_path, map_location=map_location)
|
| 74 |
+
|
| 75 |
+
if _is_url(checkpoint_path_or_url):
|
| 76 |
+
if verbose:
|
| 77 |
+
print(f"Downloading pretrained weights from URL: {checkpoint_path_or_url}")
|
| 78 |
+
try:
|
| 79 |
+
return torch.hub.load_state_dict_from_url(
|
| 80 |
+
str(checkpoint_path_or_url), map_location=map_location,
|
| 81 |
+
weights_only=True, progress=verbose,
|
| 82 |
+
)
|
| 83 |
+
except Exception:
|
| 84 |
+
warnings.warn(
|
| 85 |
+
f"Could not load {checkpoint_path_or_url} with weights_only=True, retrying with "
|
| 86 |
+
f"weights_only=False. This executes arbitrary code from the checkpoint - only do "
|
| 87 |
+
f"this for URLs you trust.",
|
| 88 |
+
RuntimeWarning,
|
| 89 |
+
stacklevel=2,
|
| 90 |
+
)
|
| 91 |
+
return torch.hub.load_state_dict_from_url(
|
| 92 |
+
str(checkpoint_path_or_url), map_location=map_location,
|
| 93 |
+
weights_only=False, progress=verbose,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
local_path = os.fspath(checkpoint_path_or_url)
|
| 97 |
+
if not os.path.exists(local_path):
|
| 98 |
+
raise FileNotFoundError(f"Checkpoint file not found: {local_path}")
|
| 99 |
+
if verbose:
|
| 100 |
+
print(f"Loading checkpoint from local path: {local_path}")
|
| 101 |
+
return _torch_load(local_path, map_location)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def load_pretrained_into(
|
| 105 |
+
model: nn.Module,
|
| 106 |
+
checkpoint_path_or_url: Union[str, os.PathLike],
|
| 107 |
+
strict: bool = True,
|
| 108 |
+
verbose: bool = False,
|
| 109 |
+
) -> nn.Module:
|
| 110 |
+
"""Load `checkpoint_path_or_url` into `model` in place, with an actionable error on mismatch.
|
| 111 |
+
|
| 112 |
+
With `strict=False` a mismatched architecture silently leaves layers randomly initialised,
|
| 113 |
+
so the default here is `strict=True`.
|
| 114 |
+
"""
|
| 115 |
+
state_dict = load_checkpoint(checkpoint_path_or_url, map_location='cpu', verbose=verbose)
|
| 116 |
+
try:
|
| 117 |
+
msg = model.load_state_dict(state_dict, strict=strict)
|
| 118 |
+
except RuntimeError as e:
|
| 119 |
+
raise RuntimeError(
|
| 120 |
+
f"Checkpoint {checkpoint_path_or_url} does not match {type(model).__name__}. This "
|
| 121 |
+
f"usually means the architecture kwargs differ from the ones the checkpoint was "
|
| 122 |
+
f"trained with. Pass strict=False to load the matching subset anyway (the rest stays "
|
| 123 |
+
f"randomly initialised).\n\n{e}"
|
| 124 |
+
) from e
|
| 125 |
+
if verbose:
|
| 126 |
+
print(f"Loaded pretrained weights with msg: {msg}")
|
| 127 |
+
return model
|
| 128 |
|
| 129 |
|
| 130 |
def deactivate_requires_grad_and_to_eval(model: nn.Module):
|
spectre/windowing.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pure-torch CT preprocessing: HU scaling, centre cropping and grid patching.
|
| 2 |
+
|
| 3 |
+
This module is vendored into the Hugging Face Hub repository by `scripts/export_hf.py`,
|
| 4 |
+
so it must import nothing beyond `torch` and the standard library.
|
| 5 |
+
|
| 6 |
+
The functions here reproduce the MONAI pipeline used for pretraining and evaluation
|
| 7 |
+
(`ScaleIntensityRanged` -> `LargestMultipleCenterCropd` -> `GridPatchd`) exactly. Two details
|
| 8 |
+
are load-bearing and are covered by `tests/test_windowing_parity.py`:
|
| 9 |
+
|
| 10 |
+
* Patch order is C-order with depth varying fastest, i.e. crop `n` is at grid position
|
| 11 |
+
`n = (h * n_w + w) * n_d + d`. This matches both MONAI's `GridPatch` and the coordinates
|
| 12 |
+
`RotaryPositionEmbedding` builds, so getting it wrong shifts every embedding silently.
|
| 13 |
+
* The centre-crop offset is `start = (S // 2) - (R // 2)`, which is not the same as
|
| 14 |
+
`(S - R) // 2` when the crop is odd and the volume even.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import warnings
|
| 19 |
+
from typing import Optional, Sequence, Tuple
|
| 20 |
+
|
| 21 |
+
import torch
|
| 22 |
+
import torch.nn.functional as F
|
| 23 |
+
|
| 24 |
+
DEFAULT_CROP_SIZE: Tuple[int, int, int] = (128, 128, 64) # (H, W, D)
|
| 25 |
+
DEFAULT_HU_RANGE: Tuple[float, float] = (-1000.0, 1000.0)
|
| 26 |
+
|
| 27 |
+
__all__ = [
|
| 28 |
+
'DEFAULT_CROP_SIZE',
|
| 29 |
+
'DEFAULT_HU_RANGE',
|
| 30 |
+
'scale_intensity_range',
|
| 31 |
+
'largest_multiple_crop_size',
|
| 32 |
+
'largest_multiple_center_crop',
|
| 33 |
+
'grid_patch',
|
| 34 |
+
'window_scan',
|
| 35 |
+
]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def scale_intensity_range(
|
| 39 |
+
x: torch.Tensor,
|
| 40 |
+
a_min: float = DEFAULT_HU_RANGE[0],
|
| 41 |
+
a_max: float = DEFAULT_HU_RANGE[1],
|
| 42 |
+
b_min: float = 0.0,
|
| 43 |
+
b_max: float = 1.0,
|
| 44 |
+
clip: bool = True,
|
| 45 |
+
) -> torch.Tensor:
|
| 46 |
+
"""Map intensities from `[a_min, a_max]` onto `[b_min, b_max]`.
|
| 47 |
+
|
| 48 |
+
Mirrors `monai.transforms.ScaleIntensityRange`. SPECTRE was pretrained with the default
|
| 49 |
+
HU window of [-1000, 1000] -> [0, 1] with clipping.
|
| 50 |
+
"""
|
| 51 |
+
if a_max == a_min:
|
| 52 |
+
raise ValueError(f"a_min and a_max must differ, got a_min == a_max == {a_min}.")
|
| 53 |
+
x = (x - a_min) / (a_max - a_min)
|
| 54 |
+
x = x * (b_max - b_min) + b_min
|
| 55 |
+
if clip:
|
| 56 |
+
x = torch.clamp(x, min(b_min, b_max), max(b_min, b_max))
|
| 57 |
+
return x
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def largest_multiple_crop_size(
|
| 61 |
+
spatial_shape: Sequence[int],
|
| 62 |
+
crop_size: Sequence[int] = DEFAULT_CROP_SIZE,
|
| 63 |
+
) -> Tuple[int, ...]:
|
| 64 |
+
"""Largest per-axis size that is a whole multiple of `crop_size`.
|
| 65 |
+
|
| 66 |
+
An axis shorter than its crop keeps its original size (there is no non-zero multiple to
|
| 67 |
+
take), matching `spectre.transforms.LargestMultipleCenterCropd`.
|
| 68 |
+
"""
|
| 69 |
+
if len(spatial_shape) != len(crop_size):
|
| 70 |
+
raise ValueError(
|
| 71 |
+
f"spatial_shape {tuple(spatial_shape)} and crop_size {tuple(crop_size)} must have "
|
| 72 |
+
f"the same number of dimensions."
|
| 73 |
+
)
|
| 74 |
+
return tuple(
|
| 75 |
+
(s // c) * c if s >= c else s
|
| 76 |
+
for s, c in zip(spatial_shape, crop_size)
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def largest_multiple_center_crop(
|
| 81 |
+
x: torch.Tensor,
|
| 82 |
+
crop_size: Sequence[int] = DEFAULT_CROP_SIZE,
|
| 83 |
+
) -> torch.Tensor:
|
| 84 |
+
"""Centre-crop a channel-first `(C, H, W, D)` volume to a whole multiple of `crop_size`."""
|
| 85 |
+
if x.ndim != 4:
|
| 86 |
+
raise ValueError(f"Expected a channel-first (C, H, W, D) volume, got shape {tuple(x.shape)}.")
|
| 87 |
+
|
| 88 |
+
spatial_shape = tuple(x.shape[1:])
|
| 89 |
+
roi_size = largest_multiple_crop_size(spatial_shape, crop_size)
|
| 90 |
+
|
| 91 |
+
# MONAI's CenterSpatialCrop offset: roi_center - (roi_size // 2), clamped at 0. This is not
|
| 92 |
+
# the same as (S - R) // 2 for odd R and even S, so keep the formula verbatim.
|
| 93 |
+
slices = []
|
| 94 |
+
for size, roi in zip(spatial_shape, roi_size):
|
| 95 |
+
start = max(size // 2 - roi // 2, 0)
|
| 96 |
+
slices.append(slice(start, start + roi))
|
| 97 |
+
return x[(slice(None), *slices)]
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def grid_patch(
|
| 101 |
+
x: torch.Tensor,
|
| 102 |
+
crop_size: Sequence[int] = DEFAULT_CROP_SIZE,
|
| 103 |
+
) -> Tuple[torch.Tensor, Tuple[int, int, int]]:
|
| 104 |
+
"""Tile a `(C, H, W, D)` volume into non-overlapping crops.
|
| 105 |
+
|
| 106 |
+
Every spatial axis must already be a whole multiple of `crop_size` (see
|
| 107 |
+
`largest_multiple_center_crop`).
|
| 108 |
+
|
| 109 |
+
Returns:
|
| 110 |
+
The crops `(N, C, cH, cW, cD)` and the grid `(n_h, n_w, n_d)` with `N == n_h*n_w*n_d`.
|
| 111 |
+
Crop `n` sits at grid position `n = (h * n_w + w) * n_d + d`.
|
| 112 |
+
"""
|
| 113 |
+
if x.ndim != 4:
|
| 114 |
+
raise ValueError(f"Expected a channel-first (C, H, W, D) volume, got shape {tuple(x.shape)}.")
|
| 115 |
+
|
| 116 |
+
channels = x.shape[0]
|
| 117 |
+
spatial_shape = tuple(x.shape[1:])
|
| 118 |
+
c_h, c_w, c_d = (int(c) for c in crop_size)
|
| 119 |
+
|
| 120 |
+
for axis, (size, crop) in enumerate(zip(spatial_shape, (c_h, c_w, c_d))):
|
| 121 |
+
if crop <= 0:
|
| 122 |
+
raise ValueError(f"crop_size must be positive, got {tuple(crop_size)}.")
|
| 123 |
+
if size % crop != 0:
|
| 124 |
+
raise ValueError(
|
| 125 |
+
f"Axis {'HWD'[axis]} has size {size}, which is not a whole multiple of the crop "
|
| 126 |
+
f"size {crop}. Call largest_multiple_center_crop() first, or use window_scan() "
|
| 127 |
+
f"which does this for you."
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
n_h, n_w, n_d = (s // c for s, c in zip(spatial_shape, (c_h, c_w, c_d)))
|
| 131 |
+
|
| 132 |
+
# Depth varies fastest, matching MONAI's GridPatch and the RoPE coordinate order.
|
| 133 |
+
crops = x.contiguous().view(channels, n_h, c_h, n_w, c_w, n_d, c_d)
|
| 134 |
+
crops = crops.permute(1, 3, 5, 0, 2, 4, 6)
|
| 135 |
+
crops = crops.reshape(n_h * n_w * n_d, channels, c_h, c_w, c_d)
|
| 136 |
+
return crops, (n_h, n_w, n_d)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _pad_short_axes(
|
| 140 |
+
x: torch.Tensor,
|
| 141 |
+
crop_size: Sequence[int],
|
| 142 |
+
pad_value: float,
|
| 143 |
+
) -> torch.Tensor:
|
| 144 |
+
"""Symmetrically pad any spatial axis shorter than its crop up to exactly one crop."""
|
| 145 |
+
spatial_shape = tuple(x.shape[1:])
|
| 146 |
+
short = [
|
| 147 |
+
(axis, size, int(crop))
|
| 148 |
+
for axis, (size, crop) in enumerate(zip(spatial_shape, crop_size))
|
| 149 |
+
if size < crop
|
| 150 |
+
]
|
| 151 |
+
if not short:
|
| 152 |
+
return x
|
| 153 |
+
|
| 154 |
+
# F.pad takes the last dimension first, so build the spec back to front: D, W, H.
|
| 155 |
+
pad_spec: list = []
|
| 156 |
+
for axis in reversed(range(3)):
|
| 157 |
+
size, crop = spatial_shape[axis], int(crop_size[axis])
|
| 158 |
+
if size < crop:
|
| 159 |
+
total = crop - size
|
| 160 |
+
pad_spec.extend([total // 2, total - total // 2])
|
| 161 |
+
else:
|
| 162 |
+
pad_spec.extend([0, 0])
|
| 163 |
+
|
| 164 |
+
detail = ', '.join(
|
| 165 |
+
f"{'HWD'[axis]}={size} < {crop}" for axis, size, crop in short
|
| 166 |
+
)
|
| 167 |
+
warnings.warn(
|
| 168 |
+
f"Scan is smaller than one crop along {detail}. Padding with {pad_value} to reach the "
|
| 169 |
+
f"crop size {tuple(int(c) for c in crop_size)}. SPECTRE was not pretrained on padded "
|
| 170 |
+
f"volumes, so the resulting embedding is out of distribution. Pass pad_short_axes=False "
|
| 171 |
+
f"to raise instead.",
|
| 172 |
+
RuntimeWarning,
|
| 173 |
+
stacklevel=3,
|
| 174 |
+
)
|
| 175 |
+
return F.pad(x, pad_spec, mode='constant', value=pad_value)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def window_scan(
|
| 179 |
+
x: torch.Tensor,
|
| 180 |
+
crop_size: Sequence[int] = DEFAULT_CROP_SIZE,
|
| 181 |
+
*,
|
| 182 |
+
scale_intensity: bool = True,
|
| 183 |
+
hu_range: Tuple[float, float] = DEFAULT_HU_RANGE,
|
| 184 |
+
pad_short_axes: bool = True,
|
| 185 |
+
pad_value: Optional[float] = None,
|
| 186 |
+
) -> Tuple[torch.Tensor, Tuple[int, int, int]]:
|
| 187 |
+
"""Turn a whole CT volume into the crops SPECTRE's backbone consumes.
|
| 188 |
+
|
| 189 |
+
Applies, in the order used for pretraining: HU scaling -> centre crop to a whole multiple of
|
| 190 |
+
`crop_size` -> grid patching.
|
| 191 |
+
|
| 192 |
+
Args:
|
| 193 |
+
x: A `(H, W, D)` or `(C, H, W, D)` volume. In raw Hounsfield Units unless
|
| 194 |
+
`scale_intensity=False`.
|
| 195 |
+
crop_size: Spatial size `(H, W, D)` of one crop.
|
| 196 |
+
scale_intensity: Map `hu_range` onto [0, 1] with clipping. Turn off if `x` is already
|
| 197 |
+
normalised.
|
| 198 |
+
hu_range: The `(a_min, a_max)` HU window. SPECTRE was pretrained with (-1000, 1000).
|
| 199 |
+
pad_short_axes: Pad axes shorter than one crop instead of raising.
|
| 200 |
+
pad_value: Value to pad with. Defaults to the scaled air value (0.0) when
|
| 201 |
+
`scale_intensity` is on, and to `x.min()` otherwise.
|
| 202 |
+
|
| 203 |
+
Returns:
|
| 204 |
+
The crops `(N, C, cH, cW, cD)` and the grid `(n_h, n_w, n_d)`.
|
| 205 |
+
"""
|
| 206 |
+
if x.ndim == 3:
|
| 207 |
+
x = x.unsqueeze(0)
|
| 208 |
+
elif x.ndim != 4:
|
| 209 |
+
raise ValueError(
|
| 210 |
+
f"Expected a (H, W, D) or (C, H, W, D) volume, got shape {tuple(x.shape)}."
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
if len(crop_size) != 3:
|
| 214 |
+
raise ValueError(f"crop_size must have 3 elements (H, W, D), got {tuple(crop_size)}.")
|
| 215 |
+
|
| 216 |
+
if scale_intensity:
|
| 217 |
+
if float(x.min()) > -100.0:
|
| 218 |
+
warnings.warn(
|
| 219 |
+
f"Input does not look like Hounsfield Units (min={float(x.min()):.1f}). It will "
|
| 220 |
+
f"be rescaled from {hu_range} to [0, 1], which double-scales already-normalised "
|
| 221 |
+
f"data. Pass scale_intensity=False if the volume is already normalised.",
|
| 222 |
+
RuntimeWarning,
|
| 223 |
+
stacklevel=2,
|
| 224 |
+
)
|
| 225 |
+
x = scale_intensity_range(x, a_min=hu_range[0], a_max=hu_range[1])
|
| 226 |
+
|
| 227 |
+
if pad_value is None:
|
| 228 |
+
pad_value = 0.0 if scale_intensity else float(x.min())
|
| 229 |
+
|
| 230 |
+
too_short = [
|
| 231 |
+
f"{'HWD'[axis]}={int(size)} < {int(crop)}"
|
| 232 |
+
for axis, (size, crop) in enumerate(zip(x.shape[1:], crop_size))
|
| 233 |
+
if size < crop
|
| 234 |
+
]
|
| 235 |
+
if too_short:
|
| 236 |
+
if not pad_short_axes:
|
| 237 |
+
raise ValueError(
|
| 238 |
+
f"Scan is smaller than one crop along {', '.join(too_short)}. Pass "
|
| 239 |
+
f"pad_short_axes=True to pad with air, or supply a scan covering at least "
|
| 240 |
+
f"{tuple(int(c) for c in crop_size)} voxels."
|
| 241 |
+
)
|
| 242 |
+
x = _pad_short_axes(x, crop_size, pad_value)
|
| 243 |
+
|
| 244 |
+
x = largest_multiple_center_crop(x, crop_size)
|
| 245 |
+
return grid_patch(x, crop_size)
|