syntx 0.1.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- syntx/__init__.py +23 -0
- syntx/features.py +473 -0
- syntx/resnet.py +100 -0
- syntx/syn.py +1727 -0
- syntx/syn_jax.py +1412 -0
- syntx/transform.py +202 -0
- syntx-0.1.7.dist-info/METADATA +160 -0
- syntx-0.1.7.dist-info/RECORD +10 -0
- syntx-0.1.7.dist-info/WHEEL +5 -0
- syntx-0.1.7.dist-info/top_level.txt +1 -0
syntx/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .syn import registration, SyNTo
|
|
2
|
+
from .syn_jax import SyNTo as SyNToJax
|
|
3
|
+
from .transform import SyNToTransform
|
|
4
|
+
from .features import FeatureSpaceLoss, VGG19Extractor, DINOv2Extractor, ResNet10Extractor, SwinUNETRExtractor
|
|
5
|
+
|
|
6
|
+
# Expose both syn and registration to satisfy user requirements
|
|
7
|
+
syn = registration
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.7"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"syn",
|
|
14
|
+
"registration",
|
|
15
|
+
"SyNTo",
|
|
16
|
+
"SyNToJax",
|
|
17
|
+
"SyNToTransform",
|
|
18
|
+
"FeatureSpaceLoss",
|
|
19
|
+
"VGG19Extractor",
|
|
20
|
+
"DINOv2Extractor",
|
|
21
|
+
"ResNet10Extractor",
|
|
22
|
+
"SwinUNETRExtractor",
|
|
23
|
+
]
|
syntx/features.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import torch
|
|
3
|
+
import torch.nn as nn
|
|
4
|
+
import torch.nn.functional as F
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from .resnet import resnet10_2d, resnet10_3d
|
|
8
|
+
|
|
9
|
+
class FeatureExtractor(nn.Module):
|
|
10
|
+
"""Abstract base for all feature extractors."""
|
|
11
|
+
@property
|
|
12
|
+
def is_3d(self) -> bool:
|
|
13
|
+
raise NotImplementedError
|
|
14
|
+
|
|
15
|
+
@property
|
|
16
|
+
def in_channels(self) -> int:
|
|
17
|
+
raise NotImplementedError
|
|
18
|
+
|
|
19
|
+
def normalize(self, x: torch.Tensor) -> torch.Tensor:
|
|
20
|
+
raise NotImplementedError
|
|
21
|
+
|
|
22
|
+
def extract(self, x: torch.Tensor) -> list:
|
|
23
|
+
raise NotImplementedError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class VGG19Extractor(FeatureExtractor):
|
|
27
|
+
"""VGG19 feature extractor with memory truncation."""
|
|
28
|
+
is_3d = False
|
|
29
|
+
in_channels = 3
|
|
30
|
+
|
|
31
|
+
def __init__(self, feature_layers=[8]):
|
|
32
|
+
super().__init__()
|
|
33
|
+
import torchvision.models as models
|
|
34
|
+
vgg = models.vgg19(weights=models.VGG19_Weights.DEFAULT).features
|
|
35
|
+
|
|
36
|
+
# Discard layers beyond the max needed layer to save memory
|
|
37
|
+
max_layer = max(feature_layers)
|
|
38
|
+
self.layers = nn.ModuleList([vgg[i] for i in range(max_layer + 1)])
|
|
39
|
+
self.feature_layers = feature_layers
|
|
40
|
+
|
|
41
|
+
for m in self.layers.modules():
|
|
42
|
+
if isinstance(m, nn.ReLU):
|
|
43
|
+
m.inplace = False
|
|
44
|
+
for p in self.parameters():
|
|
45
|
+
p.requires_grad = False
|
|
46
|
+
self.eval()
|
|
47
|
+
|
|
48
|
+
self.register_buffer("mean", torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1))
|
|
49
|
+
self.register_buffer("std", torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1))
|
|
50
|
+
|
|
51
|
+
def normalize(self, x):
|
|
52
|
+
return (x - self.mean.to(x)) / self.std.to(x)
|
|
53
|
+
|
|
54
|
+
def extract(self, x):
|
|
55
|
+
features = []
|
|
56
|
+
for i, layer in enumerate(self.layers):
|
|
57
|
+
x = layer(x)
|
|
58
|
+
if i in self.feature_layers:
|
|
59
|
+
features.append(x)
|
|
60
|
+
return features
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class DINOv2Extractor(FeatureExtractor):
|
|
64
|
+
"""DINOv2 feature extractor supporting both ViT-S and ViT-B with sub-network pruning."""
|
|
65
|
+
is_3d = False
|
|
66
|
+
in_channels = 3
|
|
67
|
+
|
|
68
|
+
def __init__(self, version='vits14', feature_layers=[11]):
|
|
69
|
+
super().__init__()
|
|
70
|
+
model_name = f'dinov2_{version}'
|
|
71
|
+
# Load from torch hub
|
|
72
|
+
self.model = torch.hub.load('facebookresearch/dinov2', model_name)
|
|
73
|
+
self.patch_size = 14
|
|
74
|
+
self.feature_layers = feature_layers
|
|
75
|
+
|
|
76
|
+
# Extract only the needed transformer blocks to save memory
|
|
77
|
+
max_layer = max(feature_layers)
|
|
78
|
+
self.model.blocks = nn.ModuleList([self.model.blocks[i] for i in range(max_layer + 1)])
|
|
79
|
+
|
|
80
|
+
for p in self.model.parameters():
|
|
81
|
+
p.requires_grad = False
|
|
82
|
+
self.model.eval()
|
|
83
|
+
self.eval()
|
|
84
|
+
|
|
85
|
+
self.register_buffer("mean", torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1))
|
|
86
|
+
self.register_buffer("std", torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1))
|
|
87
|
+
|
|
88
|
+
def normalize(self, x):
|
|
89
|
+
return (x - self.mean.to(x)) / self.std.to(x)
|
|
90
|
+
|
|
91
|
+
def extract(self, x):
|
|
92
|
+
B, C, H, W = x.shape
|
|
93
|
+
# Pad to patch_size-divisible dimensions
|
|
94
|
+
ph = (self.patch_size - H % self.patch_size) % self.patch_size
|
|
95
|
+
pw = (self.patch_size - W % self.patch_size) % self.patch_size
|
|
96
|
+
if ph > 0 or pw > 0:
|
|
97
|
+
x = F.pad(x, (0, pw, 0, ph))
|
|
98
|
+
|
|
99
|
+
# We step through the model blocks to collect intermediate features
|
|
100
|
+
# Matching DINOv2's forward pass but stopping at the max block
|
|
101
|
+
x_tokens = self.model.prepare_tokens_with_masks(x)
|
|
102
|
+
|
|
103
|
+
features = []
|
|
104
|
+
for i, blk in enumerate(self.model.blocks):
|
|
105
|
+
x_tokens = blk(x_tokens)
|
|
106
|
+
if i in self.feature_layers:
|
|
107
|
+
# Patch tokens shape: (B, H_p * W_p, C_feat)
|
|
108
|
+
patch_tokens = x_tokens[:, 1:] # skip class token
|
|
109
|
+
hp = (H + ph) // self.patch_size
|
|
110
|
+
wp = (W + pw) // self.patch_size
|
|
111
|
+
feat_grid = patch_tokens.reshape(B, hp, wp, -1).permute(0, 3, 1, 2)
|
|
112
|
+
features.append(feat_grid)
|
|
113
|
+
return features
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ResNet10Extractor(FeatureExtractor):
|
|
117
|
+
"""Unified ResNet-10 extractor supporting 2D or 3D."""
|
|
118
|
+
def __init__(self, dim=3, feature_layers=[4]):
|
|
119
|
+
super().__init__()
|
|
120
|
+
self._is_3d = (dim == 3)
|
|
121
|
+
self.feature_layers = feature_layers
|
|
122
|
+
|
|
123
|
+
if self._is_3d:
|
|
124
|
+
self.model = resnet10_3d()
|
|
125
|
+
self._in_channels = 1
|
|
126
|
+
# Try to load MedicalNet weights if available
|
|
127
|
+
weights_path = os.path.expanduser("~/.syntx_cache/resnet_10_23iseg.pth")
|
|
128
|
+
if os.path.exists(weights_path):
|
|
129
|
+
state = torch.load(weights_path, map_location='cpu')
|
|
130
|
+
self.model.load_state_dict(state.get('state_dict', state), strict=False)
|
|
131
|
+
else:
|
|
132
|
+
self.model = resnet10_2d()
|
|
133
|
+
self._in_channels = 1
|
|
134
|
+
|
|
135
|
+
for p in self.model.parameters():
|
|
136
|
+
p.requires_grad = False
|
|
137
|
+
self.model.eval()
|
|
138
|
+
self.eval()
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def is_3d(self) -> bool:
|
|
142
|
+
return self._is_3d
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def in_channels(self) -> int:
|
|
146
|
+
return self._in_channels
|
|
147
|
+
|
|
148
|
+
def normalize(self, x):
|
|
149
|
+
# Grayscale volumes are already in [0, 1]
|
|
150
|
+
return x
|
|
151
|
+
|
|
152
|
+
def extract(self, x):
|
|
153
|
+
# Extract features at layers
|
|
154
|
+
out = self.model.relu(self.model.bn1(self.model.conv1(x)))
|
|
155
|
+
out = self.model.maxpool(out)
|
|
156
|
+
|
|
157
|
+
features = []
|
|
158
|
+
# Layer 1
|
|
159
|
+
out = self.model.layer1(out)
|
|
160
|
+
if 1 in self.feature_layers:
|
|
161
|
+
features.append(out)
|
|
162
|
+
# Layer 2
|
|
163
|
+
out = self.model.layer2(out)
|
|
164
|
+
if 2 in self.feature_layers:
|
|
165
|
+
features.append(out)
|
|
166
|
+
# Layer 3
|
|
167
|
+
out = self.model.layer3(out)
|
|
168
|
+
if 3 in self.feature_layers:
|
|
169
|
+
features.append(out)
|
|
170
|
+
# Layer 4
|
|
171
|
+
out = self.model.layer4(out)
|
|
172
|
+
if 4 in self.feature_layers:
|
|
173
|
+
features.append(out)
|
|
174
|
+
|
|
175
|
+
return features
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class SwinUNETRExtractor(FeatureExtractor):
|
|
179
|
+
"""SwinUNETR 3D self-supervised encoder feature extractor with lazy loading and dynamic size resizing."""
|
|
180
|
+
is_3d = True
|
|
181
|
+
in_channels = 1
|
|
182
|
+
|
|
183
|
+
def __init__(self, feature_layers=[4], weights_path=None, img_size=(96, 96, 96)):
|
|
184
|
+
super().__init__()
|
|
185
|
+
# Lazy import to avoid hard dependency on MONAI
|
|
186
|
+
try:
|
|
187
|
+
from monai.networks.nets import SwinUNETR
|
|
188
|
+
except ImportError:
|
|
189
|
+
raise ImportError(
|
|
190
|
+
"MONAI is required to use SwinUNETRExtractor. "
|
|
191
|
+
"Please install it using 'pip install monai'."
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if not feature_layers:
|
|
195
|
+
raise ValueError("feature_layers cannot be empty.")
|
|
196
|
+
for layer in feature_layers:
|
|
197
|
+
if layer not in [1, 2, 3, 4]:
|
|
198
|
+
raise ValueError("Invalid layer index. SwinUNETR layers must be in [1, 2, 3, 4].")
|
|
199
|
+
|
|
200
|
+
self.feature_layers = feature_layers
|
|
201
|
+
if isinstance(img_size, int):
|
|
202
|
+
self.img_size = (img_size, img_size, img_size)
|
|
203
|
+
else:
|
|
204
|
+
self.img_size = tuple(img_size)
|
|
205
|
+
|
|
206
|
+
# Default SwinUNETR configuration for pre-trained weights
|
|
207
|
+
self.model = SwinUNETR(
|
|
208
|
+
in_channels=self.in_channels,
|
|
209
|
+
out_channels=14, # default out channels in SSL pretrained zoo
|
|
210
|
+
feature_size=48,
|
|
211
|
+
spatial_dims=3
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if weights_path != "random":
|
|
215
|
+
if weights_path is None:
|
|
216
|
+
weights_path = os.path.expanduser("~/.syntx_cache/model_swinvit.pt")
|
|
217
|
+
|
|
218
|
+
if not os.path.exists(weights_path):
|
|
219
|
+
url = "https://github.com/Project-MONAI/MONAI-extra-test-data/releases/download/0.8.1/model_swinvit.pt"
|
|
220
|
+
try:
|
|
221
|
+
os.makedirs(os.path.dirname(weights_path), exist_ok=True)
|
|
222
|
+
temp_path = weights_path + ".tmp"
|
|
223
|
+
import urllib.request
|
|
224
|
+
urllib.request.urlretrieve(url, temp_path)
|
|
225
|
+
os.rename(temp_path, weights_path)
|
|
226
|
+
except Exception as e:
|
|
227
|
+
import warnings
|
|
228
|
+
warnings.warn(
|
|
229
|
+
f"Failed to download Swin ViT weights from MONAI zoo: {e}. "
|
|
230
|
+
f"If you are in an offline or restricted network environment, "
|
|
231
|
+
f"please manually download the weights from {url} and place them at '{weights_path}'."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
if os.path.exists(weights_path):
|
|
235
|
+
# Load checkpoint, strip nested keys, and load into backbone
|
|
236
|
+
state = torch.load(weights_path, map_location='cpu')
|
|
237
|
+
state_dict = state.get('state_dict', state)
|
|
238
|
+
|
|
239
|
+
swinvit_state_dict = {}
|
|
240
|
+
for k, v in state_dict.items():
|
|
241
|
+
if k.startswith("module."):
|
|
242
|
+
k = k[7:]
|
|
243
|
+
if k.startswith("swinViT."):
|
|
244
|
+
k = k[8:]
|
|
245
|
+
swinvit_state_dict[k] = v
|
|
246
|
+
|
|
247
|
+
self.model.swinViT.load_state_dict(swinvit_state_dict, strict=False)
|
|
248
|
+
|
|
249
|
+
# Freeze model parameters for extraction
|
|
250
|
+
for p in self.model.parameters():
|
|
251
|
+
p.requires_grad = False
|
|
252
|
+
self.model.eval()
|
|
253
|
+
|
|
254
|
+
def normalize(self, x: torch.Tensor) -> torch.Tensor:
|
|
255
|
+
# Grayscale volumes are already scaled.
|
|
256
|
+
return x
|
|
257
|
+
|
|
258
|
+
def extract(self, x: torch.Tensor) -> list:
|
|
259
|
+
if x.shape[0] == 0:
|
|
260
|
+
raise ValueError("Batch size cannot be 0")
|
|
261
|
+
if len(x.shape) != 5:
|
|
262
|
+
raise ValueError("Input must be a 5D tensor (B, C, D, H, W)")
|
|
263
|
+
|
|
264
|
+
import math
|
|
265
|
+
spatial_shape = x.shape[2:]
|
|
266
|
+
|
|
267
|
+
# Compute padding size (multiple of 32, at least 32)
|
|
268
|
+
pad_size = [int(math.ceil(s / 32.0) * 32) for s in spatial_shape]
|
|
269
|
+
|
|
270
|
+
pad_d = pad_size[0] - spatial_shape[0]
|
|
271
|
+
pad_h = pad_size[1] - spatial_shape[1]
|
|
272
|
+
pad_w = pad_size[2] - spatial_shape[2]
|
|
273
|
+
|
|
274
|
+
# Pad input volume to this size (constant zero padding at the end of dimensions)
|
|
275
|
+
x_input = F.pad(x, (0, pad_w, 0, pad_h, 0, pad_d), mode='constant', value=0.0)
|
|
276
|
+
|
|
277
|
+
hidden_states = self.model.swinViT(x_input)
|
|
278
|
+
features = []
|
|
279
|
+
for layer in self.feature_layers:
|
|
280
|
+
if len(hidden_states) == 5:
|
|
281
|
+
feat = hidden_states[layer]
|
|
282
|
+
else:
|
|
283
|
+
feat = hidden_states[layer - 1]
|
|
284
|
+
|
|
285
|
+
# Crop the padded feature map back to expected_shape
|
|
286
|
+
downsample_factor = 2 ** (layer + 1)
|
|
287
|
+
expected_shape = [max(1, s // downsample_factor) for s in spatial_shape]
|
|
288
|
+
feat = feat[:, :, :expected_shape[0], :expected_shape[1], :expected_shape[2]]
|
|
289
|
+
|
|
290
|
+
features.append(feat)
|
|
291
|
+
|
|
292
|
+
return features
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class FeatureSpaceLoss(nn.Module):
|
|
296
|
+
"""Dimension-agnostic loss using modular feature extractors."""
|
|
297
|
+
def __init__(self, extractor: FeatureExtractor, mode='lncc_3d', num_slices=4, lncc_window=9):
|
|
298
|
+
super().__init__()
|
|
299
|
+
self.extractor = extractor
|
|
300
|
+
self.mode = mode
|
|
301
|
+
self.num_slices = num_slices
|
|
302
|
+
self.lncc_window = lncc_window
|
|
303
|
+
|
|
304
|
+
def forward(self, input_nd, target_nd):
|
|
305
|
+
dim = len(input_nd.shape) - 2
|
|
306
|
+
|
|
307
|
+
if self.extractor.is_3d:
|
|
308
|
+
if dim == 2:
|
|
309
|
+
raise ValueError("Cannot run 3D feature extractor on 2D input.")
|
|
310
|
+
return self._forward_3d(input_nd, target_nd)
|
|
311
|
+
else:
|
|
312
|
+
if dim == 2:
|
|
313
|
+
return self._forward_2d_direct(input_nd, target_nd)
|
|
314
|
+
else:
|
|
315
|
+
if self.mode == 'lncc_3d':
|
|
316
|
+
return self._forward_2d_reconstruct_3d(input_nd, target_nd)
|
|
317
|
+
else:
|
|
318
|
+
return self._forward_2d_triplanar(input_nd, target_nd)
|
|
319
|
+
|
|
320
|
+
def _forward_3d(self, input_nd, target_nd):
|
|
321
|
+
# Native 3D pass
|
|
322
|
+
feats_in = self.extractor.extract(self.extractor.normalize(input_nd))
|
|
323
|
+
feats_tg = self.extractor.extract(self.extractor.normalize(target_nd))
|
|
324
|
+
|
|
325
|
+
loss = 0.0
|
|
326
|
+
from .syn import local_ncc_loss_nd
|
|
327
|
+
for f_in, f_tg in zip(feats_in, feats_tg):
|
|
328
|
+
# Compute 3D LNCC
|
|
329
|
+
loss += local_ncc_loss_nd(f_in, f_tg, window_size=self.lncc_window)
|
|
330
|
+
return loss
|
|
331
|
+
|
|
332
|
+
def _forward_2d_direct(self, input_nd, target_nd):
|
|
333
|
+
# Direct 2D pass for 2D registration using 2D networks
|
|
334
|
+
# Ensure 3-channel input if extractor expects RGB
|
|
335
|
+
if self.extractor.in_channels == 3 and input_nd.shape[1] == 1:
|
|
336
|
+
input_nd = input_nd.repeat(1, 3, 1, 1)
|
|
337
|
+
target_nd = target_nd.repeat(1, 3, 1, 1)
|
|
338
|
+
|
|
339
|
+
feats_in = self.extractor.extract(self.extractor.normalize(input_nd))
|
|
340
|
+
feats_tg = self.extractor.extract(self.extractor.normalize(target_nd))
|
|
341
|
+
|
|
342
|
+
loss = 0.0
|
|
343
|
+
from .syn import local_ncc_loss_nd
|
|
344
|
+
for f_in, f_tg in zip(feats_in, feats_tg):
|
|
345
|
+
loss += local_ncc_loss_nd(f_in, f_tg, window_size=self.lncc_window)
|
|
346
|
+
return loss
|
|
347
|
+
|
|
348
|
+
def _forward_2d_triplanar(self, input_nd, target_nd):
|
|
349
|
+
# Extract orthogonal 2D slices and run 2D LNCC on 2D feature maps
|
|
350
|
+
D, H, W = input_nd.shape[2:]
|
|
351
|
+
device = input_nd.device
|
|
352
|
+
|
|
353
|
+
z_indices = torch.linspace(D // 4, 3 * D // 4, self.num_slices, dtype=torch.long, device=device)
|
|
354
|
+
y_indices = torch.linspace(H // 4, 3 * H // 4, self.num_slices, dtype=torch.long, device=device)
|
|
355
|
+
x_indices = torch.linspace(W // 4, 3 * W // 4, self.num_slices, dtype=torch.long, device=device)
|
|
356
|
+
|
|
357
|
+
target_size = max(D, H, W)
|
|
358
|
+
slices_in = []
|
|
359
|
+
slices_tg = []
|
|
360
|
+
|
|
361
|
+
# Axial
|
|
362
|
+
for z in z_indices:
|
|
363
|
+
# Handle RGB channel stacking if needed
|
|
364
|
+
if self.extractor.in_channels == 3:
|
|
365
|
+
slice_in = input_nd[:, 0, z-1:z+2]
|
|
366
|
+
slice_tg = target_nd[:, 0, z-1:z+2]
|
|
367
|
+
else:
|
|
368
|
+
slice_in = input_nd[:, 0:1, z:z+1]
|
|
369
|
+
slice_tg = target_nd[:, 0:1, z:z+1]
|
|
370
|
+
|
|
371
|
+
if H != target_size or W != target_size:
|
|
372
|
+
slice_in = F.interpolate(slice_in, size=(target_size, target_size), mode='bilinear', align_corners=True)
|
|
373
|
+
slice_tg = F.interpolate(slice_tg, size=(target_size, target_size), mode='bilinear', align_corners=True)
|
|
374
|
+
slices_in.append(slice_in)
|
|
375
|
+
slices_tg.append(slice_tg)
|
|
376
|
+
|
|
377
|
+
# Coronal
|
|
378
|
+
for y in y_indices:
|
|
379
|
+
if self.extractor.in_channels == 3:
|
|
380
|
+
slice_in = input_nd[:, 0, :, y-1:y+2, :].movedim(2, 1)
|
|
381
|
+
slice_tg = target_nd[:, 0, :, y-1:y+2, :].movedim(2, 1)
|
|
382
|
+
else:
|
|
383
|
+
slice_in = input_nd[:, 0:1, :, y:y+1, :].movedim(2, 1)
|
|
384
|
+
slice_tg = target_nd[:, 0:1, :, y:y+1, :].movedim(2, 1)
|
|
385
|
+
|
|
386
|
+
if D != target_size or W != target_size:
|
|
387
|
+
slice_in = F.interpolate(slice_in, size=(target_size, target_size), mode='bilinear', align_corners=True)
|
|
388
|
+
slice_tg = F.interpolate(slice_tg, size=(target_size, target_size), mode='bilinear', align_corners=True)
|
|
389
|
+
slices_in.append(slice_in)
|
|
390
|
+
slices_tg.append(slice_tg)
|
|
391
|
+
|
|
392
|
+
# Sagittal
|
|
393
|
+
for xi in x_indices:
|
|
394
|
+
if self.extractor.in_channels == 3:
|
|
395
|
+
slice_in = input_nd[:, 0, :, :, xi-1:xi+2].movedim(3, 1)
|
|
396
|
+
slice_tg = target_nd[:, 0, :, :, xi-1:xi+2].movedim(3, 1)
|
|
397
|
+
else:
|
|
398
|
+
slice_in = input_nd[:, 0:1, :, :, xi:xi+1].movedim(3, 1)
|
|
399
|
+
slice_tg = target_nd[:, 0:1, :, :, xi:xi+1].movedim(3, 1)
|
|
400
|
+
|
|
401
|
+
if D != target_size or H != target_size:
|
|
402
|
+
slice_in = F.interpolate(slice_in, size=(target_size, target_size), mode='bilinear', align_corners=True)
|
|
403
|
+
slice_tg = F.interpolate(slice_tg, size=(target_size, target_size), mode='bilinear', align_corners=True)
|
|
404
|
+
slices_in.append(slice_in)
|
|
405
|
+
slices_tg.append(slice_tg)
|
|
406
|
+
|
|
407
|
+
input_batch = torch.cat(slices_in, dim=0)
|
|
408
|
+
target_batch = torch.cat(slices_tg, dim=0)
|
|
409
|
+
|
|
410
|
+
feats_in = self.extractor.extract(self.extractor.normalize(input_batch))
|
|
411
|
+
feats_tg = self.extractor.extract(self.extractor.normalize(target_batch))
|
|
412
|
+
|
|
413
|
+
loss = 0.0
|
|
414
|
+
from .syn import local_ncc_loss_nd
|
|
415
|
+
for f_in, f_tg in zip(feats_in, feats_tg):
|
|
416
|
+
loss += local_ncc_loss_nd(f_in, f_tg, window_size=self.lncc_window)
|
|
417
|
+
return loss
|
|
418
|
+
|
|
419
|
+
def _forward_2d_reconstruct_3d(self, input_nd, target_nd):
|
|
420
|
+
# 3D Feature LNCC on reconstructed feature volume
|
|
421
|
+
D, H, W = input_nd.shape[2:]
|
|
422
|
+
B = input_nd.shape[0]
|
|
423
|
+
|
|
424
|
+
def reconstruct_3d_features(x):
|
|
425
|
+
# 1. Axial
|
|
426
|
+
slices_ax = []
|
|
427
|
+
for z in range(1, D - 1):
|
|
428
|
+
if self.extractor.in_channels == 3:
|
|
429
|
+
slices_ax.append(x[:, 0, z-1:z+2])
|
|
430
|
+
else:
|
|
431
|
+
slices_ax.append(x[:, 0:1, z:z+1])
|
|
432
|
+
batch_ax = self.extractor.normalize(torch.cat(slices_ax, dim=0))
|
|
433
|
+
|
|
434
|
+
# 2. Coronal
|
|
435
|
+
slices_co = []
|
|
436
|
+
for y in range(1, H - 1):
|
|
437
|
+
if self.extractor.in_channels == 3:
|
|
438
|
+
slices_co.append(x[:, 0, :, y-1:y+2, :].movedim(2, 1))
|
|
439
|
+
else:
|
|
440
|
+
slices_co.append(x[:, 0:1, :, y:y+1, :].movedim(2, 1))
|
|
441
|
+
batch_co = self.extractor.normalize(torch.cat(slices_co, dim=0))
|
|
442
|
+
|
|
443
|
+
# 3. Sagittal
|
|
444
|
+
slices_sa = []
|
|
445
|
+
for xi in range(1, W - 1):
|
|
446
|
+
if self.extractor.in_channels == 3:
|
|
447
|
+
slices_sa.append(x[:, 0, :, :, xi-1:xi+2].movedim(3, 1))
|
|
448
|
+
else:
|
|
449
|
+
slices_sa.append(x[:, 0:1, :, :, xi:xi+1].movedim(3, 1))
|
|
450
|
+
batch_sa = self.extractor.normalize(torch.cat(slices_sa, dim=0))
|
|
451
|
+
|
|
452
|
+
# Run through extractor
|
|
453
|
+
# We assume extract returns a single feature map for simplicity or we use the last one
|
|
454
|
+
feat_ax = self.extractor.extract(batch_ax)[-1]
|
|
455
|
+
feat_co = self.extractor.extract(batch_co)[-1]
|
|
456
|
+
feat_sa = self.extractor.extract(batch_sa)[-1]
|
|
457
|
+
|
|
458
|
+
# Reconstruct volumes
|
|
459
|
+
vol_ax = feat_ax.view(D-2, B, -1, feat_ax.shape[2], feat_ax.shape[3]).permute(1, 2, 0, 3, 4)
|
|
460
|
+
vol_co = feat_co.view(H-2, B, -1, feat_co.shape[2], feat_co.shape[3]).permute(1, 2, 3, 0, 4)
|
|
461
|
+
vol_sa = feat_sa.view(W-2, B, -1, feat_sa.shape[2], feat_sa.shape[3]).permute(1, 2, 3, 4, 0)
|
|
462
|
+
|
|
463
|
+
return vol_ax, vol_co, vol_sa
|
|
464
|
+
|
|
465
|
+
vol_in_ax, vol_in_co, vol_in_sa = reconstruct_3d_features(input_nd)
|
|
466
|
+
vol_tg_ax, vol_tg_co, vol_tg_sa = reconstruct_3d_features(target_nd)
|
|
467
|
+
|
|
468
|
+
from .syn import local_ncc_loss_nd
|
|
469
|
+
loss_ax = local_ncc_loss_nd(vol_in_ax, vol_tg_ax, window_size=5)
|
|
470
|
+
loss_co = local_ncc_loss_nd(vol_in_co, vol_tg_co, window_size=5)
|
|
471
|
+
loss_sa = local_ncc_loss_nd(vol_in_sa, vol_tg_sa, window_size=5)
|
|
472
|
+
|
|
473
|
+
return loss_ax + loss_co + loss_sa
|
syntx/resnet.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import torch
|
|
2
|
+
import torch.nn as nn
|
|
3
|
+
|
|
4
|
+
class BasicBlock2D(nn.Module):
|
|
5
|
+
expansion = 1
|
|
6
|
+
|
|
7
|
+
def __init__(self, in_planes, planes, stride=1):
|
|
8
|
+
super().__init__()
|
|
9
|
+
self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False)
|
|
10
|
+
self.bn1 = nn.BatchNorm2d(planes)
|
|
11
|
+
self.relu = nn.ReLU(inplace=False)
|
|
12
|
+
self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False)
|
|
13
|
+
self.bn2 = nn.BatchNorm2d(planes)
|
|
14
|
+
|
|
15
|
+
self.shortcut = nn.Sequential()
|
|
16
|
+
if stride != 1 or in_planes != self.expansion * planes:
|
|
17
|
+
self.shortcut = nn.Sequential(
|
|
18
|
+
nn.Conv2d(in_planes, self.expansion * planes, kernel_size=1, stride=stride, bias=False),
|
|
19
|
+
nn.BatchNorm2d(self.expansion * planes)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def forward(self, x):
|
|
23
|
+
out = self.relu(self.bn1(self.conv1(x)))
|
|
24
|
+
out = self.bn2(self.conv2(out))
|
|
25
|
+
out += self.shortcut(x)
|
|
26
|
+
out = self.relu(out)
|
|
27
|
+
return out
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class BasicBlock3D(nn.Module):
|
|
31
|
+
expansion = 1
|
|
32
|
+
|
|
33
|
+
def __init__(self, in_planes, planes, stride=1):
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.conv1 = nn.Conv3d(in_planes, planes, kernel_size=3, stride=stride, padding=1, bias=False)
|
|
36
|
+
self.bn1 = nn.BatchNorm3d(planes)
|
|
37
|
+
self.relu = nn.ReLU(inplace=False)
|
|
38
|
+
self.conv2 = nn.Conv3d(planes, planes, kernel_size=3, stride=1, padding=1, bias=False)
|
|
39
|
+
self.bn2 = nn.BatchNorm3d(planes)
|
|
40
|
+
|
|
41
|
+
self.shortcut = nn.Sequential()
|
|
42
|
+
if stride != 1 or in_planes != self.expansion * planes:
|
|
43
|
+
self.shortcut = nn.Sequential(
|
|
44
|
+
nn.Conv3d(in_planes, self.expansion * planes, kernel_size=1, stride=stride, bias=False),
|
|
45
|
+
nn.BatchNorm3d(self.expansion * planes)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def forward(self, x):
|
|
49
|
+
out = self.relu(self.bn1(self.conv1(x)))
|
|
50
|
+
out = self.bn2(self.conv2(out))
|
|
51
|
+
out += self.shortcut(x)
|
|
52
|
+
out = self.relu(out)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ResNet10(nn.Module):
|
|
57
|
+
def __init__(self, block, num_blocks, dim=3, num_classes=1):
|
|
58
|
+
super().__init__()
|
|
59
|
+
self.in_planes = 64
|
|
60
|
+
self.dim = dim
|
|
61
|
+
|
|
62
|
+
if dim == 2:
|
|
63
|
+
self.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)
|
|
64
|
+
self.bn1 = nn.BatchNorm2d(64)
|
|
65
|
+
self.relu = nn.ReLU(inplace=False)
|
|
66
|
+
self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
|
|
67
|
+
else:
|
|
68
|
+
self.conv1 = nn.Conv3d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)
|
|
69
|
+
self.bn1 = nn.BatchNorm3d(64)
|
|
70
|
+
self.relu = nn.ReLU(inplace=False)
|
|
71
|
+
self.maxpool = nn.MaxPool3d(kernel_size=3, stride=2, padding=1)
|
|
72
|
+
|
|
73
|
+
self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)
|
|
74
|
+
self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)
|
|
75
|
+
self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)
|
|
76
|
+
self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)
|
|
77
|
+
|
|
78
|
+
def _make_layer(self, block, planes, num_blocks, stride):
|
|
79
|
+
strides = [stride] + [1]*(num_blocks-1)
|
|
80
|
+
layers = []
|
|
81
|
+
for s in strides:
|
|
82
|
+
layers.append(block(self.in_planes, planes, s))
|
|
83
|
+
self.in_planes = planes * block.expansion
|
|
84
|
+
return nn.Sequential(*layers)
|
|
85
|
+
|
|
86
|
+
def forward(self, x):
|
|
87
|
+
out = self.relu(self.bn1(self.conv1(x)))
|
|
88
|
+
out = self.maxpool(out)
|
|
89
|
+
out1 = self.layer1(out)
|
|
90
|
+
out2 = self.layer2(out1)
|
|
91
|
+
out3 = self.layer3(out2)
|
|
92
|
+
out4 = self.layer4(out3)
|
|
93
|
+
return out4
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def resnet10_2d():
|
|
97
|
+
return ResNet10(BasicBlock2D, [1, 1, 1, 1], dim=2)
|
|
98
|
+
|
|
99
|
+
def resnet10_3d():
|
|
100
|
+
return ResNet10(BasicBlock3D, [1, 1, 1, 1], dim=3)
|