zimmeryWo commited on
Commit
71e1d70
·
1 Parent(s): 951bbeb

upload data

Browse files
Files changed (4) hide show
  1. README.md +96 -3
  2. checkpoints/metadata.json +39 -0
  3. checkpoints/model.pt +3 -0
  4. sct_generator.py +358 -0
README.md CHANGED
@@ -1,3 +1,96 @@
1
- ---
2
- license: apache-2.0
3
- ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ tags:
4
+ - medical-imaging
5
+ - ct
6
+ - cbct
7
+ - synthetic-ct
8
+ - image-synthesis
9
+ - nnunet
10
+ - regression
11
+ - torchscript
12
+ language: []
13
+ ---
14
+
15
+ # SimCBCT Generator — Synthetic CT, Pelvis
16
+
17
+ A 3D regression model that generates synthetic CT (sCT) images from Cone-Beam CT (CBCT) scans of the PELVIS region. Part of the [SimCBCTGenerator](https://github.com/openvoxelmed/simcbctgenerator) framework, which was used to generate the training data for this model (aligned input/output pairs by simulating CBCT data).
18
+
19
+ The model is trained with an nnUNet regression pipeline (`nnUNetTrainerRegression_mae_deep`, 3D full-resolution).
20
+
21
+ ## Intended Use
22
+
23
+ Convert acquired CBCT images to synthetic CT for pelvis anatomy. Typical applications include adaptive radiotherapy, where sCT is needed for dose recalculation or replanning directly from CBCT without a new planning CT acquisition. It was extensively tested for Elekta machines but initial inspection also results in good performance for Varian datasets!
24
+
25
+ ## Model Files
26
+
27
+ | File | Description |
28
+ |---|---|
29
+ | `checkpoints/model.pt` | TorchScript compiled model (~390 MB) |
30
+ | `checkpoints/metadata.json` | Patch size, normalization stats, inference config |
31
+ | `sct_generator.py` | Self-contained inference class — no nnUNet install required |
32
+
33
+ The `model.pt` is a **TorchScript** module exported with `torch.jit.save()` and must be loaded with `torch.jit.load()`.
34
+
35
+ ## Requirements
36
+
37
+ ```
38
+ torch
39
+ scipy
40
+ numpy
41
+ ```
42
+
43
+
44
+ ## Quick Start
45
+
46
+ ```python
47
+ import numpy as np
48
+ from sct_generator import StandaloneRegressionInference
49
+
50
+ # Load model (pass the directory containing model.pt and metadata.json)
51
+ model = StandaloneRegressionInference(
52
+ model_path="checkpoints/",
53
+ device="cuda" # or "cpu"
54
+ )
55
+
56
+ # cbct_volume: 3D numpy array of HU values, shape (D, H, W)
57
+ cbct_volume = np.load("your_cbct_volume.npy")
58
+
59
+ # Run inference — returns sCT volume in HU, same spatial shape as input
60
+ sct_output = model.predict(cbct_volume)
61
+ ```
62
+
63
+ `predict()` handles internally:
64
+ - Z-score normalization of the CBCT input
65
+ - Sliding-window tiled inference with Gaussian patch blending
66
+ - Denormalization of the sCT output back to HU values with the precomputed statistics
67
+
68
+
69
+ ## Training Details
70
+
71
+ | Field | Value |
72
+ |---|---|
73
+ | Trainer | `nnUNetTrainerRegression_mae_deep` |
74
+ | Configuration | `3d_fullres` |
75
+ | Fold | all |
76
+ | Loss | MAE |
77
+ | Input | Physics-based simulated CBCT |
78
+ | Output | CT |
79
+ | Anatomy | Pelvis |
80
+
81
+ ## Citation
82
+
83
+ If you use this model or the SimCBCTGenerator framework, please cite:
84
+
85
+ ```bibtex
86
+ @article{zimmermann2026simcbct,
87
+ title = {Eliminating Registration Bias in Synthetic CT Generation:
88
+ A Physics-Based Simulation Framework},
89
+ author = {Zimmermann, Lukas and Rauter, Michael and Schmid, Maximilian
90
+ and Georg, Dietmar and Kn\"{a}usl, Barbara},
91
+ journal = {arXiv preprint arXiv:2602.02130},
92
+ year = {2026}
93
+ }
94
+ ```
95
+
96
+ **Framework repository:** [github.com/openvoxelmed/simcbctgenerator](https://github.com/openvoxelmed/simcbctgenerator)
checkpoints/metadata.json ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_info": {
3
+ "trainer_name": "nnUNetTrainerRegression_mae_deep",
4
+ "dataset_name": "Unknown",
5
+ "configuration": "3d_fullres",
6
+ "fold": "all"
7
+ },
8
+ "inference_config": {
9
+ "patch_size": [
10
+ 32,
11
+ 224,
12
+ 192
13
+ ],
14
+ "tile_step_size": 0.5,
15
+ "use_gaussian": true
16
+ },
17
+ "normalization": {
18
+ "input": {
19
+ "scheme": "ZScoreNormalization",
20
+ "mean": -505.56842041015625,
21
+ "std": 369.4824523925781,
22
+ "percentile_00_5": -1000.0,
23
+ "percentile_99_5": 156.0,
24
+ "median": -309.0,
25
+ "min": -1000.0,
26
+ "max": 2574.0
27
+ },
28
+ "output": {
29
+ "scheme": "GlobalNormalization",
30
+ "mean": -335.02899169921875,
31
+ "std": 475.0570373535156,
32
+ "percentile_00_5": -1011.0,
33
+ "percentile_99_5": 702.0,
34
+ "median": -93.0,
35
+ "min": -1459.0,
36
+ "max": 3000.0
37
+ }
38
+ }
39
+ }
checkpoints/model.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d93cf35c194fb02c28a0acabe3bcd4842b9fefb4b7828b1277551eab46cd8ef2
3
+ size 407933751
sct_generator.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone inference utilities for nnUNet-style regression models."""
2
+
3
+ import json
4
+ from functools import lru_cache
5
+ from pathlib import Path
6
+ from typing import List, Optional, Tuple, Union
7
+
8
+ import numpy as np
9
+ import torch
10
+ from scipy.ndimage import gaussian_filter
11
+
12
+
13
+ # ============================================================================
14
+ # Helper Functions
15
+ # ============================================================================
16
+
17
+ def compute_steps_for_sliding_window(
18
+ image_size: Tuple[int, ...],
19
+ tile_size: Tuple[int, ...],
20
+ tile_step_size: float
21
+ ) -> List[List[int]]:
22
+ """Return sliding-window start indices for each spatial dimension."""
23
+ assert all(i >= j for i, j in zip(image_size, tile_size)), \
24
+ "Image size must be >= tile size in all dimensions"
25
+ assert 0 < tile_step_size <= 1, "tile_step_size must be in range (0, 1]"
26
+
27
+ # Calculate target step size in voxels
28
+ target_step_sizes_in_voxels = [int(i * tile_step_size) for i in tile_size]
29
+
30
+ # Calculate number of steps needed in each dimension
31
+ num_steps = [
32
+ int(np.ceil((i - k) / j)) + 1
33
+ for i, j, k in zip(image_size, target_step_sizes_in_voxels, tile_size)
34
+ ]
35
+
36
+ # Compute actual step positions
37
+ steps = []
38
+ for dim in range(len(tile_size)):
39
+ max_step_value = image_size[dim] - tile_size[dim]
40
+
41
+ if num_steps[dim] > 1:
42
+ # Evenly distribute steps
43
+ actual_step_size = max_step_value / (num_steps[dim] - 1)
44
+ else:
45
+ actual_step_size = 99999999999 # Doesn't matter, only one step at 0
46
+
47
+ steps_here = [int(np.round(actual_step_size * i)) for i in range(num_steps[dim])]
48
+ steps.append(steps_here)
49
+
50
+ return steps
51
+
52
+
53
+ @lru_cache(maxsize=4)
54
+ def compute_gaussian_weight(
55
+ tile_size: Tuple[int, ...],
56
+ sigma_scale: float = 1.0 / 8,
57
+ value_scaling_factor: float = 1.0,
58
+ dtype: torch.dtype = torch.float32,
59
+ device: Union[str, torch.device] = "cuda"
60
+ ) -> torch.Tensor:
61
+ """Build a cached Gaussian weight map for patch blending."""
62
+ # Create zero array and set center to 1
63
+ tmp = np.zeros(tile_size)
64
+ center_coords = [i // 2 for i in tile_size]
65
+ sigmas = [i * sigma_scale for i in tile_size]
66
+ tmp[tuple(center_coords)] = 1
67
+
68
+ # Apply Gaussian filter
69
+ gaussian_importance_map = gaussian_filter(tmp, sigmas, 0, mode='constant', cval=0)
70
+
71
+ # Convert to PyTorch tensor
72
+ gaussian_importance_map = torch.from_numpy(gaussian_importance_map)
73
+
74
+ # Normalize by max value
75
+ gaussian_importance_map /= (torch.max(gaussian_importance_map) / value_scaling_factor)
76
+ gaussian_importance_map = gaussian_importance_map.to(device=device, dtype=dtype)
77
+
78
+ # Ensure no zeros to prevent NaN when dividing
79
+ mask = gaussian_importance_map == 0
80
+ if mask.any():
81
+ gaussian_importance_map[mask] = torch.min(gaussian_importance_map[~mask])
82
+
83
+ return gaussian_importance_map
84
+
85
+
86
+ # ============================================================================
87
+ # Main Inference Class
88
+ # ============================================================================
89
+
90
+ class StandaloneRegressionInference:
91
+ """Run TorchScript regression inference with optional tiled prediction."""
92
+
93
+ def __init__(
94
+ self,
95
+ model_path: Union[str, Path],
96
+ device: Union[str, torch.device] = "cuda"
97
+ ):
98
+ """Load model bundle (`model.pt`, `metadata.json`) on the given device."""
99
+ self.model_path = Path(model_path)
100
+ self.device = torch.device(device) if isinstance(device, str) else device
101
+
102
+ # Load metadata
103
+ metadata_path = self.model_path / "metadata.json"
104
+ if not metadata_path.exists():
105
+ raise FileNotFoundError(f"Metadata file not found: {metadata_path}")
106
+
107
+ with open(metadata_path, 'r') as f:
108
+ self.metadata = json.load(f)
109
+
110
+ # Load TorchScript model
111
+ model_file = self.model_path / "model.pt"
112
+ if not model_file.exists():
113
+ raise FileNotFoundError(f"Model file not found: {model_file}")
114
+
115
+ print(f"Loading TorchScript model from {model_file}...")
116
+ self.model = torch.jit.load(str(model_file), map_location=self.device)
117
+ self.model.eval()
118
+
119
+ # Extract inference configuration
120
+ self.patch_size = tuple(self.metadata['inference_config']['patch_size'])
121
+ self.tile_step_size = self.metadata['inference_config']['tile_step_size']
122
+ self.use_gaussian = self.metadata['inference_config']['use_gaussian']
123
+
124
+ # Precompute Gaussian weights for efficiency
125
+ if self.use_gaussian:
126
+ self._gaussian_cache = compute_gaussian_weight(
127
+ self.patch_size,
128
+ sigma_scale=1.0 / 8,
129
+ value_scaling_factor=10.0, # nnUNet uses 10 for better blending
130
+ dtype=torch.float32,
131
+ device=self.device
132
+ )
133
+ else:
134
+ self._gaussian_cache = None
135
+
136
+ print(f"Loaded model: {self.metadata['model_info']['trainer_name']}")
137
+ print(f"Patch size: {self.patch_size}, Tile step size: {self.tile_step_size}")
138
+ print(f"Gaussian blending: {self.use_gaussian}")
139
+
140
+ def _normalize(self, data: np.ndarray, channel: str = 'input') -> np.ndarray:
141
+ """Apply metadata-based normalization to input/output arrays."""
142
+ norm_config = self.metadata['normalization'][channel]
143
+ scheme = norm_config['scheme']
144
+
145
+ # Convert to float32 for normalization
146
+ data = data.astype(np.float32, copy=False)
147
+
148
+ if scheme == 'ZScoreNormalization':
149
+ mean = norm_config['mean']
150
+ std = norm_config['std']
151
+ data = (data - mean) / max(std, 1e-8)
152
+
153
+ elif scheme == 'CTNormalization':
154
+ mean = norm_config['mean']
155
+ std = norm_config['std']
156
+ lower = norm_config['percentile_00_5']
157
+ upper = norm_config['percentile_99_5']
158
+ # Clip then normalize
159
+ np.clip(data, lower, upper, out=data)
160
+ data = (data - mean) / max(std, 1e-8)
161
+
162
+ elif scheme == 'GlobalNormalization':
163
+ mean = norm_config['mean']
164
+ std = norm_config['std']
165
+ data = (data - mean) / max(std, 1e-8)
166
+
167
+ elif scheme == 'NoNormalization':
168
+ # No normalization
169
+ pass
170
+
171
+ elif scheme == 'RescaleTo01Normalization':
172
+ data = data - data.min()
173
+ data = data / np.clip(data.max(), a_min=1e-8, a_max=None)
174
+
175
+ else:
176
+ raise ValueError(f"Unknown normalization scheme: {scheme}")
177
+
178
+ return data
179
+
180
+ def _denormalize(self, data: np.ndarray, channel: str = 'output') -> np.ndarray:
181
+ """Undo normalization for channels that store reversible stats."""
182
+ norm_config = self.metadata['normalization'][channel]
183
+ scheme = norm_config['scheme']
184
+
185
+ if scheme in ['ZScoreNormalization', 'CTNormalization', 'GlobalNormalization']:
186
+ mean = norm_config['mean']
187
+ std = norm_config['std']
188
+ # Reverse: x_orig = (x_norm * std) + mean
189
+ data = data * std + mean
190
+
191
+ elif scheme == 'NoNormalization':
192
+ # No denormalization needed
193
+ pass
194
+
195
+ elif scheme == 'RescaleTo01Normalization':
196
+ # This would need original min/max, which aren't stored
197
+ # Return as-is
198
+ pass
199
+
200
+ else:
201
+ raise ValueError(f"Unknown normalization scheme: {scheme}")
202
+
203
+ return data
204
+
205
+ def _sliding_window_inference(
206
+ self,
207
+ data: torch.Tensor,
208
+ tile_step_size: Optional[float] = None
209
+ ) -> torch.Tensor:
210
+ """Run tiled inference and blend overlaps with Gaussian weights."""
211
+ # Use provided tile_step_size or default
212
+ effective_step_size = tile_step_size if tile_step_size is not None else self.tile_step_size
213
+
214
+ # Get spatial dimensions (excluding batch and channel)
215
+ data_shape = data.shape[2:]
216
+ num_dimensions = len(data_shape)
217
+
218
+ # Check if image is smaller than patch size
219
+ if any(i < j for i, j in zip(data_shape, self.patch_size)):
220
+ # For small images, just run inference directly
221
+ with torch.no_grad():
222
+ prediction = self.model(data)
223
+ return prediction
224
+
225
+ # Compute sliding window steps
226
+ steps = compute_steps_for_sliding_window(data_shape, self.patch_size, effective_step_size)
227
+
228
+ # Initialize output accumulators
229
+ predicted_logits = torch.zeros(
230
+ (1, 1) + data_shape,
231
+ dtype=torch.float32,
232
+ device=self.device
233
+ )
234
+ n_predictions = torch.zeros(
235
+ data_shape,
236
+ dtype=torch.float32,
237
+ device=self.device
238
+ )
239
+
240
+ # Get Gaussian weights if enabled
241
+ if self.use_gaussian and self._gaussian_cache is not None:
242
+ gaussian = self._gaussian_cache
243
+ else:
244
+ gaussian = torch.ones(self.patch_size, dtype=torch.float32, device=self.device)
245
+
246
+ # Iterate over all patch positions
247
+ if num_dimensions == 3:
248
+ # 3D case
249
+ for x in steps[0]:
250
+ for y in steps[1]:
251
+ for z in steps[2]:
252
+ # Extract patch
253
+ patch = data[
254
+ :, :,
255
+ x:x+self.patch_size[0],
256
+ y:y+self.patch_size[1],
257
+ z:z+self.patch_size[2]
258
+ ]
259
+
260
+ # Run inference
261
+ with torch.no_grad():
262
+ prediction = self.model(patch)
263
+
264
+ # Apply Gaussian weighting
265
+ if self.use_gaussian:
266
+ prediction = prediction * gaussian
267
+
268
+ # Accumulate
269
+ predicted_logits[
270
+ :, :,
271
+ x:x+self.patch_size[0],
272
+ y:y+self.patch_size[1],
273
+ z:z+self.patch_size[2]
274
+ ] += prediction
275
+
276
+ n_predictions[
277
+ x:x+self.patch_size[0],
278
+ y:y+self.patch_size[1],
279
+ z:z+self.patch_size[2]
280
+ ] += gaussian
281
+
282
+ elif num_dimensions == 2:
283
+ # 2D case
284
+ for x in steps[0]:
285
+ for y in steps[1]:
286
+ # Extract patch
287
+ patch = data[
288
+ :, :,
289
+ x:x+self.patch_size[0],
290
+ y:y+self.patch_size[1]
291
+ ]
292
+
293
+ # Run inference
294
+ with torch.no_grad():
295
+ prediction = self.model(patch)
296
+
297
+ # Apply Gaussian weighting
298
+ if self.use_gaussian:
299
+ prediction = prediction * gaussian
300
+
301
+ # Accumulate
302
+ predicted_logits[
303
+ :, :,
304
+ x:x+self.patch_size[0],
305
+ y:y+self.patch_size[1]
306
+ ] += prediction
307
+
308
+ n_predictions[
309
+ x:x+self.patch_size[0],
310
+ y:y+self.patch_size[1]
311
+ ] += gaussian
312
+ else:
313
+ raise ValueError(f"Unsupported number of dimensions: {num_dimensions}")
314
+
315
+ # Normalize by cumulative weights
316
+ predicted_logits = predicted_logits / n_predictions
317
+
318
+ return predicted_logits
319
+
320
+ def predict(
321
+ self,
322
+ input_array: np.ndarray,
323
+ apply_normalization: bool = True,
324
+ apply_denormalization: bool = True,
325
+ tile_step_size: Optional[float] = None
326
+ ) -> np.ndarray:
327
+ """Predict on 2D/3D numpy input with optional (de)normalization."""
328
+ # Ensure input has channel dimension
329
+ if input_array.ndim == 3:
330
+ # (H, W, D) -> (1, H, W, D)
331
+ input_array = input_array[np.newaxis, ...]
332
+ elif input_array.ndim == 2:
333
+ # (H, W) -> (1, H, W)
334
+ input_array = input_array[np.newaxis, ...]
335
+
336
+ # Normalize if requested
337
+ if apply_normalization:
338
+ input_array = self._normalize(input_array, channel='input')
339
+
340
+ # Convert to PyTorch tensor: (C, H, W, D) -> (1, C, H, W, D)
341
+ input_tensor = torch.from_numpy(input_array).float()
342
+ input_tensor = input_tensor.unsqueeze(0).to(self.device)
343
+
344
+ # Run sliding window inference
345
+ prediction = self._sliding_window_inference(input_tensor, tile_step_size)
346
+
347
+ # Convert back to NumPy
348
+ prediction_np = prediction.squeeze(0).cpu().numpy() # (1, H, W, D) -> (C, H, W, D)
349
+
350
+ # Denormalize if requested
351
+ if apply_denormalization:
352
+ prediction_np = self._denormalize(prediction_np, channel='output')
353
+
354
+ # Remove channel dimension to match input: (1, H, W, D) -> (H, W, D)
355
+ if prediction_np.shape[0] == 1:
356
+ prediction_np = prediction_np[0]
357
+
358
+ return prediction_np