cclaess commited on
Commit
80df7e4
·
verified ·
1 Parent(s): c94af45

Improved overall functionality and added functions for batch inference

Browse files
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, MODEL_CONFIGS
59
- config = MODEL_CONFIGS['spectre-large-pretrained']
60
- model = SpectreImageFeatureExtractor.from_config(config)
 
 
 
 
 
 
 
 
 
 
 
 
61
  ```
62
 
63
- A simple forward pass would look like:
 
 
 
 
 
64
  ```python
65
  import torch
66
 
67
- model.eval()
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
- # Dummy input: (batch, crops, channels, height, width, depth)
70
- # For a (3 x 3 x 4) grid of (128 x 128 x 64) CT patches -> Total scan size (384 x 384 x 256)
71
- x = torch.randn(1, 1, 384, 384, 256)
72
- B, C, H, W, D = x.shape
73
 
74
- patch_size = (128, 128, 64)
75
- pH, pW, pD = patch_size
 
 
76
 
77
- x = x.view(
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 | [Link](https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_backbone_vit_large_patch16_128_no_vla.pt?download=true) |
101
- | SPECTRE-ViT-Local | CT crops | SSL + VLA | [Link](https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_backbone_vit_large_patch16_128.pt?download=true) |
102
- | SPECTRE-ViT-Global | Embedded CT crops | VLA | [Link](https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_combiner_feature_vit_large.pt?download=true) |
103
- | Qwen3-Embedding-0.6B LoRA | Text (radiology) | VLA | [Link](https://huggingface.co/cclaess/SPECTRE/resolve/main/spectre_qwen3_embedding_0.6B_lora.pt?download=true) |
104
 
105
  ## 🩻 Segmentation (nnUNet)
106
 
107
- If you're looking for a nnUNet-based segmentation pipeline that uses SPECTRE as the backbone, see: https://github.com/cviviers/nnUNet
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
- [![Star History Chart](https://api.star-history.com/svg?repos=cclaess/SPECTRE&type=Date)](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
+ [![Star History Chart](https://api.star-history.com/svg?repos=cclaess/SPECTRE&type=Date)](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
- "transformers_version": "5.3.0"
 
 
 
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
- backbone_name="vit_large_patch16_128",
10
- backbone_kwargs={
11
- "num_classes": 0,
12
- "global_pool": '',
13
- "pos_embed": "rope",
14
- "rope_kwargs": {"base": 1000.0},
15
- "init_values": 1.0,
16
- },
17
- feature_combiner_name="feat_vit_large",
18
- feature_combiner_kwargs={
19
- "num_classes": 0,
20
- "global_pool": "",
21
- "pos_embed": "rope",
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
- outputs = self.model(pixel_values, grid_size=grid_size)
 
 
 
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, models
 
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
- __version__ = "0.1.0"
15
- __author__ = "Cris Claessens"
16
- __email__ = "c.h.b.claessens@tue.nl"
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
- MODEL_CONFIGS = {
6
- "spectre-small": {
7
- "name": "spectre-small",
8
- "backbone": "vit_small_patch16_128",
9
- "backbone_checkpoint_path_or_url": None,
10
- "backbone_kwargs": {
11
- "num_classes": 0,
12
- "global_pool": '',
13
- "pos_embed": "rope",
14
- "rope_kwargs": {"base": 1000.0},
15
- "init_values": 1.0,
16
- },
17
- "feature_combiner": "feat_vit_small",
18
- "feature_combiner_checkpoint_path_or_url": None,
19
- "feature_combiner_kwargs": {
20
- "num_classes": 0,
21
- "global_pool": '',
22
- "pos_embed": "rope",
23
- "rope_kwargs": {"base": 100.0},
24
- "init_values": 1.0,
25
- },
26
- "description": "SPECTRE model with ViT-Small backbone and feature combiner.",
27
- }, # Pretrained/Distilled checkpoints will be added later
28
- "spectre-base": {
29
- "name": "spectre-base",
30
- "backbone": "vit_base_patch16_128",
31
- "backbone_checkpoint_path_or_url": None,
32
- "backbone_kwargs": {
33
- "num_classes": 0,
34
- "global_pool": '',
35
- "pos_embed": "rope",
36
- "rope_kwargs": {"base": 1000.0},
37
- "init_values": 1.0,
38
- },
39
- "feature_combiner": "feat_vit_base",
40
- "feature_combiner_checkpoint_path_or_url": None,
41
- "feature_combiner_kwargs": {
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 forward(self, x, grid_size: tuple[int, int, int] | None = None):
211
- features = self.extract_backbone_features(x)
212
- if self.feature_combiner is not None:
213
- assert grid_size is not None, \
214
- "`grid_size` must be provided when using feature combiner"
215
- features = self.combine_features(features, grid_size)
216
- return features
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- VisionTransformer,
3
- vit_tiny_patch16_128,
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
- def _is_url(path: str) -> bool:
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 global_pool_nlc, to_3tuple, resample_abs_pos_embed
 
 
 
 
 
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
- def _is_url(path: str) -> bool:
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
- fix_random_seeds,
3
- to_ntuple,
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)