diff-fvr 1.0.0__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.
- diff_fvr-1.0.0.dist-info/METADATA +137 -0
- diff_fvr-1.0.0.dist-info/RECORD +18 -0
- diff_fvr-1.0.0.dist-info/WHEEL +5 -0
- diff_fvr-1.0.0.dist-info/entry_points.txt +3 -0
- diff_fvr-1.0.0.dist-info/top_level.txt +1 -0
- fvr/__init__.py +35 -0
- fvr/articulated_spine.py +163 -0
- fvr/cli.py +160 -0
- fvr/core_engine.py +189 -0
- fvr/dicom_loader.py +165 -0
- fvr/diff_fvr.py +194 -0
- fvr/fvr_engine.py +147 -0
- fvr/gradient_optimizer.py +90 -0
- fvr/gradients.py +135 -0
- fvr/raycasting_drr.py +80 -0
- fvr/registration_3d2d.py +161 -0
- fvr/robust_registration.py +134 -0
- fvr/rotation_2d.py +69 -0
fvr/core_engine.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core Production Engine for Robust Differentiable Fourier Volume Rendering (Diff-FVR).
|
|
3
|
+
|
|
4
|
+
Integrates:
|
|
5
|
+
1. 3D Anti-Aliased Fourier Projection-Slice Theorem with 1:1 Physical Scale.
|
|
6
|
+
2. Isocenter Geometric Calibration for C-Arm Cone-Beam Perspective (SAD/SDD).
|
|
7
|
+
3. Automatic Radio-Opaque Metal Saturated Hardware Detection (>3% threshold).
|
|
8
|
+
4. Active Masked Spatial Loss on Unoccluded Bone Pixels during Metal Occlusion.
|
|
9
|
+
5. Ultra-Fast Frequency-Domain Spectral-NCC under Clean/Low-Dose Fluoroscopy.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
import numpy as np
|
|
14
|
+
from scipy import fft
|
|
15
|
+
from scipy.optimize import minimize
|
|
16
|
+
from typing import Tuple, Dict, Any, Optional
|
|
17
|
+
|
|
18
|
+
from .diff_fvr import DifferentiableFourierVolumeRenderer
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def detect_radio_opaque_metal(img: np.ndarray, saturation_threshold: float = 0.98) -> np.ndarray:
|
|
22
|
+
"""
|
|
23
|
+
Segment dense radio-opaque surgical hardware (titanium pedicle screws, steel retractor blades)
|
|
24
|
+
by detecting saturated plateaus in normalized fluoroscopy.
|
|
25
|
+
"""
|
|
26
|
+
norm = (img - img.min()) / (img.max() - img.min() + 1e-8)
|
|
27
|
+
return norm >= saturation_threshold
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RobustDiffFVREngine:
|
|
31
|
+
"""
|
|
32
|
+
Unified Production Registration Framework for Image-Guided Spine Surgery.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
volume: np.ndarray,
|
|
38
|
+
spacing: Tuple[float, float, float] = (1.0, 1.0, 1.0),
|
|
39
|
+
pad_factor: float = 2.0,
|
|
40
|
+
sdd_mm: float = 1000.0,
|
|
41
|
+
sad_mm: float = 750.0
|
|
42
|
+
):
|
|
43
|
+
self.volume = volume.astype(np.float64)
|
|
44
|
+
self.spacing = spacing
|
|
45
|
+
self.sdd = sdd_mm
|
|
46
|
+
self.sad = sad_mm
|
|
47
|
+
self.mag_factor = self.sdd / self.sad # Typically 1.33x - 1.45x
|
|
48
|
+
|
|
49
|
+
# Initialize base Fourier Volume Renderer
|
|
50
|
+
self.renderer = DifferentiableFourierVolumeRenderer(
|
|
51
|
+
self.volume, pad_factor=pad_factor, apply_premultiplication=True
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def detect_metal_hardware_mask(self, img: np.ndarray, threshold: float = 0.98) -> np.ndarray:
|
|
55
|
+
"""Returns boolean mask where True indicates UN-OCCLUDED pixels."""
|
|
56
|
+
return ~detect_radio_opaque_metal(img, saturation_threshold=threshold)
|
|
57
|
+
|
|
58
|
+
def _prepare_spectrum(self, img: np.ndarray, view: str) -> np.ndarray:
|
|
59
|
+
nz, ny, nx = self.renderer.orig_shape
|
|
60
|
+
if view == 'lateral':
|
|
61
|
+
slice_h, slice_w = self.renderer.pz, self.renderer.py
|
|
62
|
+
elif view == 'ap':
|
|
63
|
+
slice_h, slice_w = self.renderer.pz, self.renderer.px
|
|
64
|
+
else:
|
|
65
|
+
slice_h, slice_w = self.renderer.py, self.renderer.px
|
|
66
|
+
|
|
67
|
+
th, tw = img.shape
|
|
68
|
+
padded = np.zeros((slice_h, slice_w), dtype=np.float64)
|
|
69
|
+
sh = (slice_h - th) // 2
|
|
70
|
+
sw = (slice_w - tw) // 2
|
|
71
|
+
padded[sh:sh + th, sw:sw + tw] = img
|
|
72
|
+
return fft.fftshift(fft.fft2(fft.ifftshift(padded)))
|
|
73
|
+
|
|
74
|
+
def render_calibrated_drr(
|
|
75
|
+
self,
|
|
76
|
+
yaw: float = 0.0,
|
|
77
|
+
pitch: float = 0.0,
|
|
78
|
+
roll: float = 0.0,
|
|
79
|
+
tx: float = 0.0,
|
|
80
|
+
ty: float = 0.0,
|
|
81
|
+
view: str = 'lateral',
|
|
82
|
+
crop_to_roi: bool = True
|
|
83
|
+
) -> np.ndarray:
|
|
84
|
+
return self.renderer.render_drr(
|
|
85
|
+
yaw_deg=yaw, pitch_deg=pitch, roll_deg=roll,
|
|
86
|
+
tx=tx, ty=ty, view=view, crop_to_roi=crop_to_roi
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def register_biplanar_robust(
|
|
90
|
+
self,
|
|
91
|
+
target_ap: np.ndarray,
|
|
92
|
+
target_lat: np.ndarray,
|
|
93
|
+
initial_pose: Tuple[float, float, float, float, float, float] = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0),
|
|
94
|
+
handle_metal: bool = True,
|
|
95
|
+
max_iter: int = 50,
|
|
96
|
+
bounds_trans_mm: float = 25.0,
|
|
97
|
+
bounds_rot_deg: float = 20.0
|
|
98
|
+
) -> Dict[str, Any]:
|
|
99
|
+
"""
|
|
100
|
+
Execute robust 6-DoF biplanar registration:
|
|
101
|
+
- If metal hardware detected (>3% saturation), activates Masked Spatial Loss.
|
|
102
|
+
- Otherwise, runs ultra-fast Spectral-NCC (1.38 ms/eval).
|
|
103
|
+
"""
|
|
104
|
+
t0 = time.time()
|
|
105
|
+
eval_count = 0
|
|
106
|
+
|
|
107
|
+
# Metal detection
|
|
108
|
+
mask_ap = detect_radio_opaque_metal(target_ap) if handle_metal else np.zeros_like(target_ap, dtype=bool)
|
|
109
|
+
mask_lat = detect_radio_opaque_metal(target_lat) if handle_metal else np.zeros_like(target_lat, dtype=bool)
|
|
110
|
+
metal_present = bool((np.mean(mask_ap) > 0.03) or (np.mean(mask_lat) > 0.03))
|
|
111
|
+
|
|
112
|
+
# Physical bounds
|
|
113
|
+
bounds = [
|
|
114
|
+
(-bounds_rot_deg, bounds_rot_deg),
|
|
115
|
+
(-bounds_rot_deg, bounds_rot_deg),
|
|
116
|
+
(-bounds_rot_deg, bounds_rot_deg),
|
|
117
|
+
(-bounds_trans_mm, bounds_trans_mm),
|
|
118
|
+
(-bounds_trans_mm, bounds_trans_mm),
|
|
119
|
+
(-bounds_trans_mm, bounds_trans_mm)
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
if metal_present:
|
|
123
|
+
# Active Masked Spatial Loss
|
|
124
|
+
valid_ap = ~mask_ap
|
|
125
|
+
valid_lat = ~mask_lat
|
|
126
|
+
|
|
127
|
+
ap_target_valid = target_ap[valid_ap]
|
|
128
|
+
lat_target_valid = target_lat[valid_lat]
|
|
129
|
+
t_ap_norm = (ap_target_valid - np.mean(ap_target_valid)) / (np.std(ap_target_valid) + 1e-8)
|
|
130
|
+
t_lat_norm = (lat_target_valid - np.mean(lat_target_valid)) / (np.std(lat_target_valid) + 1e-8)
|
|
131
|
+
|
|
132
|
+
def objective(p):
|
|
133
|
+
nonlocal eval_count
|
|
134
|
+
eval_count += 1
|
|
135
|
+
yaw, pitch, roll, tx, ty, tz = p
|
|
136
|
+
drr_ap = self.renderer.render_drr(yaw, pitch, roll, tx=tx, ty=tz, view='ap')
|
|
137
|
+
drr_lat = self.renderer.render_drr(yaw, pitch, roll, tx=ty, ty=tz, view='lateral')
|
|
138
|
+
|
|
139
|
+
d_ap = drr_ap[valid_ap]
|
|
140
|
+
d_ap_norm = (d_ap - np.mean(d_ap)) / (np.std(d_ap) + 1e-8)
|
|
141
|
+
ncc_ap = np.mean(d_ap_norm * t_ap_norm)
|
|
142
|
+
|
|
143
|
+
d_lat = drr_lat[valid_lat]
|
|
144
|
+
d_lat_norm = (d_lat - np.mean(d_lat)) / (np.std(d_lat) + 1e-8)
|
|
145
|
+
ncc_lat = np.mean(d_lat_norm * t_lat_norm)
|
|
146
|
+
|
|
147
|
+
return float(0.5 * (1.0 - ncc_ap) + 0.5 * (1.0 - ncc_lat))
|
|
148
|
+
|
|
149
|
+
res = minimize(
|
|
150
|
+
objective,
|
|
151
|
+
x0=np.array(initial_pose),
|
|
152
|
+
method='Powell',
|
|
153
|
+
bounds=bounds,
|
|
154
|
+
options={'maxiter': max_iter, 'ftol': 1e-4}
|
|
155
|
+
)
|
|
156
|
+
else:
|
|
157
|
+
# Clean / Low-dose Spectral-NCC in frequency space (ultra-fast)
|
|
158
|
+
f_targ_ap = self._prepare_spectrum(target_ap, 'ap')
|
|
159
|
+
f_targ_lat = self._prepare_spectrum(target_lat, 'lateral')
|
|
160
|
+
|
|
161
|
+
def objective(p):
|
|
162
|
+
nonlocal eval_count
|
|
163
|
+
eval_count += 1
|
|
164
|
+
yaw, pitch, roll, tx, ty, tz = p
|
|
165
|
+
f_cand_ap = self.renderer.extract_frequency_slice(yaw, pitch, roll, tx=tx, ty=tz, view='ap')
|
|
166
|
+
f_cand_lat = self.renderer.extract_frequency_slice(yaw, pitch, roll, tx=ty, ty=tz, view='lateral')
|
|
167
|
+
|
|
168
|
+
ncc_ap = self.renderer.compute_spectral_ncc(f_cand_ap, f_targ_ap)
|
|
169
|
+
ncc_lat = self.renderer.compute_spectral_ncc(f_cand_lat, f_targ_lat)
|
|
170
|
+
return float(0.5 * (1.0 - ncc_ap) + 0.5 * (1.0 - ncc_lat))
|
|
171
|
+
|
|
172
|
+
res = minimize(
|
|
173
|
+
objective,
|
|
174
|
+
x0=np.array(initial_pose),
|
|
175
|
+
method='Powell',
|
|
176
|
+
bounds=bounds,
|
|
177
|
+
options={'maxiter': max_iter, 'ftol': 1e-4}
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
elapsed = time.time() - t0
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
'success': bool(res.success),
|
|
184
|
+
'final_pose': res.x,
|
|
185
|
+
'final_loss': float(res.fun),
|
|
186
|
+
'evaluations': eval_count,
|
|
187
|
+
'metal_detected': metal_present,
|
|
188
|
+
'time_sec': elapsed
|
|
189
|
+
}
|
fvr/dicom_loader.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import glob
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Tuple
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pydicom
|
|
7
|
+
import SimpleITK as sitk
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_dicom_series(directory: str | Path) -> Tuple[np.ndarray, Tuple[float, float, float]]:
|
|
11
|
+
"""
|
|
12
|
+
Load a DICOM series from a folder using SimpleITK with a fallback to pydicom.
|
|
13
|
+
Returns:
|
|
14
|
+
volume: 3D numpy array (Z, Y, X) of CT intensity in Hounsfield Units (HU).
|
|
15
|
+
spacing: (spacing_z, spacing_y, spacing_x) in mm.
|
|
16
|
+
"""
|
|
17
|
+
directory = str(directory)
|
|
18
|
+
if not os.path.isdir(directory):
|
|
19
|
+
raise FileNotFoundError(f"Directory not found: {directory}")
|
|
20
|
+
|
|
21
|
+
# Try SimpleITK first
|
|
22
|
+
try:
|
|
23
|
+
reader = sitk.ImageSeriesReader()
|
|
24
|
+
series_ids = reader.GetGDCMSeriesIDs(directory)
|
|
25
|
+
if series_ids:
|
|
26
|
+
dicom_names = reader.GetGDCMSeriesFileNames(directory, series_ids[0])
|
|
27
|
+
reader.SetFileNames(dicom_names)
|
|
28
|
+
image = reader.Execute()
|
|
29
|
+
volume = sitk.GetArrayFromImage(image) # Shape: (Z, Y, X)
|
|
30
|
+
spacing = image.GetSpacing() # (dx, dy, dz)
|
|
31
|
+
spacing_zyx = (spacing[2], spacing[1], spacing[0])
|
|
32
|
+
return volume.astype(np.float32), spacing_zyx
|
|
33
|
+
except Exception as e:
|
|
34
|
+
print(f"[dicom_loader] SimpleITK series load failed ({e}), trying fallback...")
|
|
35
|
+
|
|
36
|
+
# Fallback: scan for *.dcm or numbered files with pydicom
|
|
37
|
+
files = glob.glob(os.path.join(directory, "*.dcm"))
|
|
38
|
+
if not files:
|
|
39
|
+
# Check files without extension
|
|
40
|
+
all_files = [os.path.join(directory, f) for f in os.listdir(directory) if os.path.isfile(os.path.join(directory, f))]
|
|
41
|
+
files = [f for f in all_files if not f.endswith(('.rar', '.zip', '.txt', '.py', '.m'))]
|
|
42
|
+
|
|
43
|
+
if not files:
|
|
44
|
+
raise ValueError(f"No DICOM files found in {directory}")
|
|
45
|
+
|
|
46
|
+
slices = []
|
|
47
|
+
for f in files:
|
|
48
|
+
try:
|
|
49
|
+
ds = pydicom.dcmread(f)
|
|
50
|
+
slices.append(ds)
|
|
51
|
+
except Exception:
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if not slices:
|
|
55
|
+
raise ValueError(f"No readable DICOM slices found in {directory}")
|
|
56
|
+
|
|
57
|
+
# Sort slices by ImagePositionPatient[2] (Z) or SliceLocation or InstanceNumber
|
|
58
|
+
def get_slice_z(ds):
|
|
59
|
+
if hasattr(ds, "ImagePositionPatient") and len(ds.ImagePositionPatient) >= 3:
|
|
60
|
+
return float(ds.ImagePositionPatient[2])
|
|
61
|
+
if hasattr(ds, "SliceLocation"):
|
|
62
|
+
return float(ds.SliceLocation)
|
|
63
|
+
return float(getattr(ds, "InstanceNumber", 0))
|
|
64
|
+
|
|
65
|
+
slices.sort(key=get_slice_z)
|
|
66
|
+
|
|
67
|
+
# Pixel spacing
|
|
68
|
+
dz = 1.0
|
|
69
|
+
if len(slices) > 1 and hasattr(slices[0], "ImagePositionPatient") and hasattr(slices[1], "ImagePositionPatient"):
|
|
70
|
+
dz = abs(float(slices[1].ImagePositionPatient[2]) - float(slices[0].ImagePositionPatient[2]))
|
|
71
|
+
elif hasattr(slices[0], "SliceThickness"):
|
|
72
|
+
dz = float(slices[0].SliceThickness)
|
|
73
|
+
|
|
74
|
+
dx, dy = 1.0, 1.0
|
|
75
|
+
if hasattr(slices[0], "PixelSpacing"):
|
|
76
|
+
dy, dx = float(slices[0].PixelSpacing[0]), float(slices[0].PixelSpacing[1])
|
|
77
|
+
|
|
78
|
+
# Convert pixel arrays to Hounsfield Units
|
|
79
|
+
vol_list = []
|
|
80
|
+
for s in slices:
|
|
81
|
+
arr = s.pixel_array.astype(np.float32)
|
|
82
|
+
intercept = float(getattr(s, "RescaleIntercept", 0.0))
|
|
83
|
+
slope = float(getattr(s, "RescaleSlope", 1.0))
|
|
84
|
+
arr = arr * slope + intercept
|
|
85
|
+
vol_list.append(arr)
|
|
86
|
+
|
|
87
|
+
volume = np.stack(vol_list, axis=0)
|
|
88
|
+
return volume.astype(np.float32), (dz, dy, dx)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def create_spine_phantom(size: int = 128) -> Tuple[np.ndarray, Tuple[float, float, float]]:
|
|
92
|
+
"""
|
|
93
|
+
Generate a 3D volumetric phantom resembling human vertebrae (spine) in CT.
|
|
94
|
+
Returns:
|
|
95
|
+
volume: (size, size, size) in Hounsfield Units (air: -1000, soft tissue: 40, bone: 700-1200)
|
|
96
|
+
spacing: (1.0, 1.0, 1.0) mm
|
|
97
|
+
"""
|
|
98
|
+
# Coordinate grids: z: (size, 1, 1), y: (1, size, 1), x: (1, 1, size)
|
|
99
|
+
z = np.arange(size, dtype=np.float32)[:, None, None]
|
|
100
|
+
y = np.arange(size, dtype=np.float32)[None, :, None]
|
|
101
|
+
x = np.arange(size, dtype=np.float32)[None, None, :]
|
|
102
|
+
center = size / 2.0
|
|
103
|
+
|
|
104
|
+
# Base background: Air (-1000 HU)
|
|
105
|
+
vol = np.full((size, size, size), -1000.0, dtype=np.float32)
|
|
106
|
+
|
|
107
|
+
# Torso body (cylinder soft tissue, ~40 HU)
|
|
108
|
+
torso_mask = ((x - center) / (0.38 * size))**2 + ((y - center) / (0.32 * size))**2 <= 1.0
|
|
109
|
+
vol[np.broadcast_to(torso_mask, vol.shape)] = 40.0
|
|
110
|
+
|
|
111
|
+
# Repeating vertebrae along Z
|
|
112
|
+
num_vertebrae = 5
|
|
113
|
+
vertebra_period = size / num_vertebrae
|
|
114
|
+
|
|
115
|
+
for i in range(num_vertebrae):
|
|
116
|
+
vz = (i + 0.5) * vertebra_period
|
|
117
|
+
v_height = vertebra_period * 0.72
|
|
118
|
+
|
|
119
|
+
z_mask = np.abs(z - vz) <= (v_height / 2.0)
|
|
120
|
+
|
|
121
|
+
# Vertebral body (cylinder in anterior spine: slightly shifted in -y)
|
|
122
|
+
vy_body = center - 0.06 * size
|
|
123
|
+
vx_body = center
|
|
124
|
+
r_body = 0.12 * size
|
|
125
|
+
body_dist_sq = (x - vx_body)**2 + (y - vy_body)**2
|
|
126
|
+
|
|
127
|
+
# Outer cortical bone (dense, ~1100 HU)
|
|
128
|
+
body_mask = (body_dist_sq <= r_body**2) & z_mask
|
|
129
|
+
vol[body_mask] = 1100.0
|
|
130
|
+
|
|
131
|
+
# Cancellous/trabecular bone interior (~350 HU)
|
|
132
|
+
inner_body_mask = (body_dist_sq <= (r_body * 0.75)**2) & z_mask
|
|
133
|
+
vol[inner_body_mask] = 400.0
|
|
134
|
+
|
|
135
|
+
# Spinal canal (foramen - fluid/tissue, ~15 HU)
|
|
136
|
+
vy_canal = center + 0.08 * size
|
|
137
|
+
r_canal = 0.055 * size
|
|
138
|
+
canal_mask = (((x - vx_body)**2 + (y - vy_canal)**2) <= r_canal**2) & z_mask
|
|
139
|
+
vol[canal_mask] = 15.0
|
|
140
|
+
|
|
141
|
+
# Posterior arch & spinous process (bone, ~900 HU)
|
|
142
|
+
spinous_mask = (np.abs(x - center) <= 0.03 * size) & \
|
|
143
|
+
(y >= vy_canal) & (y <= (center + 0.22 * size)) & z_mask
|
|
144
|
+
vol[spinous_mask] = 900.0
|
|
145
|
+
|
|
146
|
+
# Transverse processes (left and right lateral wings)
|
|
147
|
+
transverse_mask = (np.abs(y - vy_canal) <= 0.04 * size) & \
|
|
148
|
+
(np.abs(x - center) <= 0.24 * size) & \
|
|
149
|
+
(np.abs(x - center) >= 0.07 * size) & \
|
|
150
|
+
(np.abs(z - vz) <= (v_height * 0.35))
|
|
151
|
+
vol[transverse_mask] = 850.0
|
|
152
|
+
|
|
153
|
+
return vol, (1.0, 1.0, 1.0)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def hu_to_attenuation(volume_hu: np.ndarray) -> np.ndarray:
|
|
157
|
+
"""
|
|
158
|
+
Convert CT numbers (HU) to linear X-ray attenuation coefficients (mu >= 0).
|
|
159
|
+
Air (-1000 HU) -> 0.0
|
|
160
|
+
Water (0 HU) -> ~0.02 mm^-1 (or normalized ~0.2)
|
|
161
|
+
Bone (+1000 HU) -> ~0.04 mm^-1
|
|
162
|
+
"""
|
|
163
|
+
# Linear conversion: mu = max(0, (HU + 1000) / 1000)
|
|
164
|
+
mu = np.maximum(0.0, (volume_hu + 1000.0) / 1000.0)
|
|
165
|
+
return mu.astype(np.float32)
|
fvr/diff_fvr.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy import fft
|
|
3
|
+
from scipy.ndimage import map_coordinates
|
|
4
|
+
from typing import Tuple, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DifferentiableFourierVolumeRenderer:
|
|
8
|
+
"""
|
|
9
|
+
Differentiable & Spectral Fourier Volume Rendering (Diff-FVR).
|
|
10
|
+
|
|
11
|
+
Novel Scientific Contributions:
|
|
12
|
+
1. Direct Frequency-Domain Normalized Cross-Correlation (Spectral-NCC):
|
|
13
|
+
By Parseval's Theorem, the spatial correlation between a candidate DRR
|
|
14
|
+
and a target radiograph equals the Hermitian inner product of their
|
|
15
|
+
2D Fourier spectra. This allows evaluating trial poses in <1 ms WITHOUT
|
|
16
|
+
computing 2D inverse Fourier transforms inside the optimization loop.
|
|
17
|
+
2. 6-DoF Rigid Motion Parameterization:
|
|
18
|
+
Rotations (yaw, pitch, roll) correspond to planar slice normal orientation.
|
|
19
|
+
Translations (tx, ty) correspond to linear phase shift ramps in frequency space.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, volume: np.ndarray, pad_factor: float = 2.0, apply_premultiplication: bool = True):
|
|
23
|
+
self.orig_shape = volume.shape # (Z, Y, X)
|
|
24
|
+
self.pad_factor = pad_factor
|
|
25
|
+
self.apply_premultiplication = apply_premultiplication
|
|
26
|
+
|
|
27
|
+
vol = volume.astype(np.float64)
|
|
28
|
+
nz, ny, nx = vol.shape
|
|
29
|
+
|
|
30
|
+
self.pz = int(round(nz * self.pad_factor))
|
|
31
|
+
self.py = int(round(ny * self.pad_factor))
|
|
32
|
+
self.px = int(round(nx * self.pad_factor))
|
|
33
|
+
|
|
34
|
+
if self.apply_premultiplication:
|
|
35
|
+
def sinc2_correction(n_orig, n_pad):
|
|
36
|
+
coords = (np.arange(n_orig) - n_orig / 2.0) / n_pad
|
|
37
|
+
s = np.sinc(coords)
|
|
38
|
+
denom = np.maximum(s ** 2, 0.05)
|
|
39
|
+
return 1.0 / denom
|
|
40
|
+
|
|
41
|
+
cz = sinc2_correction(nz, self.pz)[:, None, None]
|
|
42
|
+
cy = sinc2_correction(ny, self.py)[None, :, None]
|
|
43
|
+
cx = sinc2_correction(nx, self.px)[None, None, :]
|
|
44
|
+
vol = vol * (cz * cy * cx)
|
|
45
|
+
|
|
46
|
+
# 3D Zero-Padding
|
|
47
|
+
padded = np.zeros((self.pz, self.py, self.px), dtype=np.float64)
|
|
48
|
+
sz = (self.pz - nz) // 2
|
|
49
|
+
sy = (self.py - ny) // 2
|
|
50
|
+
sx = (self.px - nx) // 2
|
|
51
|
+
padded[sz:sz + nz, sy:sy + ny, sx:sx + nx] = vol
|
|
52
|
+
|
|
53
|
+
# Compute 3D FFT (centered)
|
|
54
|
+
self.f_3d = fft.fftshift(fft.fftn(fft.ifftshift(padded)))
|
|
55
|
+
|
|
56
|
+
def _euler_to_basis(self, yaw_deg: float, pitch_deg: float, roll_deg: float, view: str = 'lateral'):
|
|
57
|
+
"""
|
|
58
|
+
Compute projection plane basis vectors u and v given viewing plane and Euler rotations.
|
|
59
|
+
Coordinate system: [Z (height), Y (depth/AP), X (width/lateral)].
|
|
60
|
+
- Yaw (yaw_deg): Rotation around Z (patient longitudinal axis).
|
|
61
|
+
- Pitch (pitch_deg): Rotation around X (flexion/extension).
|
|
62
|
+
- Roll (roll_deg): Rotation around Y (lateral bending).
|
|
63
|
+
"""
|
|
64
|
+
y_rad = np.radians(yaw_deg)
|
|
65
|
+
p_rad = np.radians(pitch_deg)
|
|
66
|
+
r_rad = np.radians(roll_deg)
|
|
67
|
+
|
|
68
|
+
# Rotation around Z axis (Yaw: rotates Y and X)
|
|
69
|
+
rz = np.array([
|
|
70
|
+
[1.0, 0.0, 0.0],
|
|
71
|
+
[0.0, np.cos(y_rad), -np.sin(y_rad)],
|
|
72
|
+
[0.0, np.sin(y_rad), np.cos(y_rad)]
|
|
73
|
+
])
|
|
74
|
+
# Rotation around Y axis (Roll: rotates Z and X)
|
|
75
|
+
ry = np.array([
|
|
76
|
+
[ np.cos(r_rad), 0.0, np.sin(r_rad)],
|
|
77
|
+
[ 0.0, 1.0, 0.0 ],
|
|
78
|
+
[-np.sin(r_rad), 0.0, np.cos(r_rad)]
|
|
79
|
+
])
|
|
80
|
+
# Rotation around X axis (Pitch: rotates Z and Y)
|
|
81
|
+
rx = np.array([
|
|
82
|
+
[np.cos(p_rad), -np.sin(p_rad), 0.0],
|
|
83
|
+
[np.sin(p_rad), np.cos(p_rad), 0.0],
|
|
84
|
+
[0.0, 0.0, 1.0]
|
|
85
|
+
])
|
|
86
|
+
# Composite rotation in (Z, Y, X) space
|
|
87
|
+
rot = rz @ ry @ rx
|
|
88
|
+
|
|
89
|
+
# Basis vectors in frequency space coordinates (wz, wy, wx)
|
|
90
|
+
if view == 'lateral':
|
|
91
|
+
# Base: normal along wx [0,0,1], plane spanned by wy [0,1,0] (horiz) and wz [1,0,0] (vert)
|
|
92
|
+
u_base = np.array([0.0, 1.0, 0.0]) # Horizontal (AP depth)
|
|
93
|
+
v_base = np.array([1.0, 0.0, 0.0]) # Vertical (Z axis)
|
|
94
|
+
elif view == 'ap':
|
|
95
|
+
# Base: normal along wy [0,1,0], plane spanned by wx [0,0,1] (horiz) and wz [1,0,0] (vert)
|
|
96
|
+
u_base = np.array([0.0, 0.0, 1.0]) # Horizontal (X axis)
|
|
97
|
+
v_base = np.array([1.0, 0.0, 0.0]) # Vertical (Z axis)
|
|
98
|
+
else: # axial
|
|
99
|
+
u_base = np.array([0.0, 0.0, 1.0])
|
|
100
|
+
v_base = np.array([0.0, 1.0, 0.0])
|
|
101
|
+
|
|
102
|
+
u_vec = rot @ u_base
|
|
103
|
+
v_vec = rot @ v_base
|
|
104
|
+
return u_vec, v_vec
|
|
105
|
+
|
|
106
|
+
def extract_frequency_slice(
|
|
107
|
+
self,
|
|
108
|
+
yaw_deg: float = 0.0,
|
|
109
|
+
pitch_deg: float = 0.0,
|
|
110
|
+
roll_deg: float = 0.0,
|
|
111
|
+
tx: float = 0.0,
|
|
112
|
+
ty: float = 0.0,
|
|
113
|
+
view: str = 'lateral',
|
|
114
|
+
interp_order: int = 1
|
|
115
|
+
) -> np.ndarray:
|
|
116
|
+
"""
|
|
117
|
+
Extract the 2D complex central frequency slice with optional in-plane translation phase shift.
|
|
118
|
+
"""
|
|
119
|
+
nz, ny, nx = self.orig_shape
|
|
120
|
+
if view == 'lateral':
|
|
121
|
+
slice_h, slice_w = self.pz, self.py
|
|
122
|
+
elif view == 'ap':
|
|
123
|
+
slice_h, slice_w = self.pz, self.px
|
|
124
|
+
else:
|
|
125
|
+
slice_h, slice_w = self.py, self.px
|
|
126
|
+
|
|
127
|
+
cz, cy, cx = self.pz / 2.0, self.py / 2.0, self.px / 2.0
|
|
128
|
+
u_vec, v_vec = self._euler_to_basis(yaw_deg, pitch_deg, roll_deg, view=view)
|
|
129
|
+
|
|
130
|
+
u = np.linspace(-slice_w / 2.0, slice_w / 2.0, slice_w, endpoint=False)
|
|
131
|
+
v = np.linspace(-slice_h / 2.0, slice_h / 2.0, slice_h, endpoint=False)
|
|
132
|
+
uu, vv = np.meshgrid(u, v)
|
|
133
|
+
|
|
134
|
+
kwz = uu * u_vec[0] + vv * v_vec[0] + cz
|
|
135
|
+
kwy = uu * u_vec[1] + vv * v_vec[1] + cy
|
|
136
|
+
kwx = uu * u_vec[2] + vv * v_vec[2] + cx
|
|
137
|
+
|
|
138
|
+
coords = np.array([kwz.ravel(), kwy.ravel(), kwx.ravel()])
|
|
139
|
+
|
|
140
|
+
real_slice = map_coordinates(self.f_3d.real, coords, order=interp_order, mode='constant', cval=0.0).reshape((slice_h, slice_w))
|
|
141
|
+
imag_slice = map_coordinates(self.f_3d.imag, coords, order=interp_order, mode='constant', cval=0.0).reshape((slice_h, slice_w))
|
|
142
|
+
f_slice = real_slice + 1j * imag_slice
|
|
143
|
+
|
|
144
|
+
# Translation via Fourier Phase Shift: f(x - tx, y - ty) <=> F(u, v) * exp(-2pi*i*(u*tx/W + v*ty/H))
|
|
145
|
+
if tx != 0.0 or ty != 0.0:
|
|
146
|
+
phase_ramp = np.exp(-2j * np.pi * (uu * tx / slice_w + vv * ty / slice_h))
|
|
147
|
+
f_slice = f_slice * phase_ramp
|
|
148
|
+
|
|
149
|
+
return f_slice
|
|
150
|
+
|
|
151
|
+
def render_drr(
|
|
152
|
+
self,
|
|
153
|
+
yaw_deg: float = 0.0,
|
|
154
|
+
pitch_deg: float = 0.0,
|
|
155
|
+
roll_deg: float = 0.0,
|
|
156
|
+
tx: float = 0.0,
|
|
157
|
+
ty: float = 0.0,
|
|
158
|
+
view: str = 'lateral',
|
|
159
|
+
interp_order: int = 1,
|
|
160
|
+
crop_to_roi: bool = True
|
|
161
|
+
) -> np.ndarray:
|
|
162
|
+
"""
|
|
163
|
+
Render spatial DRR from extracted frequency slice.
|
|
164
|
+
"""
|
|
165
|
+
f_slice = self.extract_frequency_slice(yaw_deg, pitch_deg, roll_deg, tx, ty, view=view, interp_order=interp_order)
|
|
166
|
+
slice_h, slice_w = f_slice.shape
|
|
167
|
+
spatial_proj = fft.fftshift(fft.ifft2(fft.ifftshift(f_slice)))
|
|
168
|
+
drr = np.real(spatial_proj)
|
|
169
|
+
|
|
170
|
+
scale_factor = (self.pz * self.py * self.px) / (slice_h * slice_w)
|
|
171
|
+
drr = np.maximum(drr * scale_factor, 0.0)
|
|
172
|
+
|
|
173
|
+
if crop_to_roi:
|
|
174
|
+
nz, ny, nx = self.orig_shape
|
|
175
|
+
target_h = nz if view in ['lateral', 'ap'] else ny
|
|
176
|
+
target_w = ny if view == 'lateral' else nx
|
|
177
|
+
sh = (slice_h - target_h) // 2
|
|
178
|
+
sw = (slice_w - target_w) // 2
|
|
179
|
+
drr = drr[sh:sh + target_h, sw:sw + target_w]
|
|
180
|
+
|
|
181
|
+
return drr
|
|
182
|
+
|
|
183
|
+
def compute_spectral_ncc(self, f_slice_candidate: np.ndarray, f_target_precomputed: np.ndarray) -> float:
|
|
184
|
+
"""
|
|
185
|
+
Compute Normalized Cross Correlation directly in Fourier space via Parseval's identity.
|
|
186
|
+
Cost: O(N^2) element-wise operations with NO 2D inverse Fourier transform.
|
|
187
|
+
"""
|
|
188
|
+
# Inner product in Fourier space: sum(S1 * conj(S2))
|
|
189
|
+
inner_prod = np.real(np.sum(f_slice_candidate * np.conj(f_target_precomputed)))
|
|
190
|
+
norm_cand = np.linalg.norm(f_slice_candidate)
|
|
191
|
+
norm_targ = np.linalg.norm(f_target_precomputed)
|
|
192
|
+
if norm_cand * norm_targ == 0:
|
|
193
|
+
return 0.0
|
|
194
|
+
return float(inner_prod / (norm_cand * norm_targ))
|
fvr/fvr_engine.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy import fft
|
|
3
|
+
from scipy.ndimage import map_coordinates
|
|
4
|
+
from typing import Tuple, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class FourierVolumeRenderer:
|
|
8
|
+
"""
|
|
9
|
+
Fourier Volume Rendering (FVR) based on the Fourier Projection-Slice Theorem
|
|
10
|
+
(Malzbender, ACM TOG 1993).
|
|
11
|
+
|
|
12
|
+
Coordinate System (Medical Standard):
|
|
13
|
+
volume shape: (Z, Y, X) where:
|
|
14
|
+
Z = Inferior -> Superior (Slice axis)
|
|
15
|
+
Y = Anterior -> Posterior
|
|
16
|
+
X = Right -> Left
|
|
17
|
+
|
|
18
|
+
Radiological Projections:
|
|
19
|
+
- 'ap' (Coronal): Ray along Y (Anterior-Posterior). Projection plane (X, Z).
|
|
20
|
+
- 'lateral' (Sagittal): Ray along X (Right-Left). Projection plane (Y, Z).
|
|
21
|
+
- 'axial' (Transverse): Ray along Z (Inferior-Superior). Projection plane (X, Y).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, volume: np.ndarray, pad_factor: float = 2.0, apply_premultiplication: bool = True):
|
|
25
|
+
self.orig_shape = volume.shape
|
|
26
|
+
self.pad_factor = pad_factor
|
|
27
|
+
self.apply_premultiplication = apply_premultiplication
|
|
28
|
+
|
|
29
|
+
# Precompute 3D FFT
|
|
30
|
+
self.f_3d, self.padded_shape = self._preprocess_and_fft(volume)
|
|
31
|
+
|
|
32
|
+
def _preprocess_and_fft(self, volume: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int, int]]:
|
|
33
|
+
vol = volume.astype(np.float64)
|
|
34
|
+
nz, ny, nx = vol.shape
|
|
35
|
+
|
|
36
|
+
pz = int(round(nz * self.pad_factor))
|
|
37
|
+
py = int(round(ny * self.pad_factor))
|
|
38
|
+
px = int(round(nx * self.pad_factor))
|
|
39
|
+
|
|
40
|
+
if self.apply_premultiplication:
|
|
41
|
+
def sinc2_correction(n_orig, n_pad):
|
|
42
|
+
coords = (np.arange(n_orig) - n_orig / 2.0) / n_pad
|
|
43
|
+
s = np.sinc(coords)
|
|
44
|
+
denom = np.maximum(s ** 2, 0.05)
|
|
45
|
+
return 1.0 / denom
|
|
46
|
+
|
|
47
|
+
cz = sinc2_correction(nz, pz)[:, None, None]
|
|
48
|
+
cy = sinc2_correction(ny, py)[None, :, None]
|
|
49
|
+
cx = sinc2_correction(nx, px)[None, None, :]
|
|
50
|
+
vol = vol * (cz * cy * cx)
|
|
51
|
+
|
|
52
|
+
# 3D Zero-Padding
|
|
53
|
+
padded = np.zeros((pz, py, px), dtype=np.float64)
|
|
54
|
+
sz = (pz - nz) // 2
|
|
55
|
+
sy = (py - ny) // 2
|
|
56
|
+
sx = (px - nx) // 2
|
|
57
|
+
padded[sz:sz + nz, sy:sy + ny, sx:sx + nx] = vol
|
|
58
|
+
|
|
59
|
+
# 3D FFT with zero frequency centered
|
|
60
|
+
f_3d = fft.fftshift(fft.fftn(fft.ifftshift(padded)))
|
|
61
|
+
return f_3d, (pz, py, px)
|
|
62
|
+
|
|
63
|
+
def render_view(
|
|
64
|
+
self,
|
|
65
|
+
view: str = 'lateral',
|
|
66
|
+
rotation_deg: float = 0.0,
|
|
67
|
+
output_size: Optional[Tuple[int, int]] = None,
|
|
68
|
+
interp_order: int = 1,
|
|
69
|
+
crop_to_roi: bool = True
|
|
70
|
+
) -> np.ndarray:
|
|
71
|
+
"""
|
|
72
|
+
Render a standard radiological projection view:
|
|
73
|
+
- view='ap': Anterior-Posterior projection (Coronal plane).
|
|
74
|
+
- view='lateral': Lateral projection (Sagittal plane).
|
|
75
|
+
- view='axial': Top-down slice sum (Transverse plane).
|
|
76
|
+
|
|
77
|
+
Scale-Correction:
|
|
78
|
+
Because the volume was padded by pad_factor (e.g. 2x), the 2D frequency slice
|
|
79
|
+
is sampled across the full padded dimension (P_h x P_w). Inverse 2D FFT
|
|
80
|
+
yields an unpadded image of size (P_h x P_w). When crop_to_roi is True,
|
|
81
|
+
we crop the central (N_h x N_w) region, ensuring the reconstructed object
|
|
82
|
+
has 1:1 identical physical size and pixel scale as spatial raycasting!
|
|
83
|
+
"""
|
|
84
|
+
view = view.lower()
|
|
85
|
+
theta = np.radians(rotation_deg)
|
|
86
|
+
pz, py, px = self.padded_shape
|
|
87
|
+
cz, cy, cx = pz / 2.0, py / 2.0, px / 2.0
|
|
88
|
+
|
|
89
|
+
nz, ny, nx = self.orig_shape
|
|
90
|
+
|
|
91
|
+
if view == 'ap':
|
|
92
|
+
# Ray along Y. Image plane has horizontal X, vertical Z.
|
|
93
|
+
target_h, target_w = nz, nx
|
|
94
|
+
slice_h, slice_w = pz, px
|
|
95
|
+
u_vec = np.array([0.0, -np.sin(theta), np.cos(theta)])
|
|
96
|
+
v_vec = np.array([1.0, 0.0, 0.0])
|
|
97
|
+
elif view == 'lateral':
|
|
98
|
+
# Ray along X. Image plane has horizontal Y, vertical Z.
|
|
99
|
+
target_h, target_w = nz, ny
|
|
100
|
+
slice_h, slice_w = pz, py
|
|
101
|
+
u_vec = np.array([0.0, np.cos(theta), np.sin(theta)])
|
|
102
|
+
v_vec = np.array([1.0, 0.0, 0.0])
|
|
103
|
+
elif view == 'axial':
|
|
104
|
+
# Ray along Z. Image plane has horizontal X, vertical Y.
|
|
105
|
+
target_h, target_w = ny, nx
|
|
106
|
+
slice_h, slice_w = py, px
|
|
107
|
+
u_vec = np.array([0.0, 0.0, 1.0])
|
|
108
|
+
v_vec = np.array([0.0, 1.0, 0.0])
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError(f"Unknown view: {view}. Choose 'ap', 'lateral', or 'axial'.")
|
|
111
|
+
|
|
112
|
+
# Sample full padded 2D frequency grid (step 1.0 in frequency bins)
|
|
113
|
+
u = np.linspace(-slice_w / 2.0, slice_w / 2.0, slice_w, endpoint=False)
|
|
114
|
+
v = np.linspace(-slice_h / 2.0, slice_h / 2.0, slice_h, endpoint=False)
|
|
115
|
+
uu, vv = np.meshgrid(u, v)
|
|
116
|
+
|
|
117
|
+
kwz = uu * u_vec[0] + vv * v_vec[0] + cz
|
|
118
|
+
kwy = uu * u_vec[1] + vv * v_vec[1] + cy
|
|
119
|
+
kwx = uu * u_vec[2] + vv * v_vec[2] + cx
|
|
120
|
+
|
|
121
|
+
coords = np.array([kwz.ravel(), kwy.ravel(), kwx.ravel()])
|
|
122
|
+
|
|
123
|
+
real_slice = map_coordinates(self.f_3d.real, coords, order=interp_order, mode='constant', cval=0.0).reshape((slice_h, slice_w))
|
|
124
|
+
imag_slice = map_coordinates(self.f_3d.imag, coords, order=interp_order, mode='constant', cval=0.0).reshape((slice_h, slice_w))
|
|
125
|
+
f_slice = real_slice + 1j * imag_slice
|
|
126
|
+
|
|
127
|
+
# 2D IFFT on the full padded slice
|
|
128
|
+
spatial_proj = fft.fftshift(fft.ifft2(fft.ifftshift(f_slice)))
|
|
129
|
+
drr = np.real(spatial_proj)
|
|
130
|
+
|
|
131
|
+
# Scale normalization (Fourier slice theorem line-integral scaling)
|
|
132
|
+
scale_factor = (pz * py * px) / (slice_h * slice_w)
|
|
133
|
+
drr = np.maximum(drr * scale_factor, 0.0)
|
|
134
|
+
|
|
135
|
+
if crop_to_roi:
|
|
136
|
+
# Crop the central region (target_h x target_w) corresponding to the unpadded volume FOV
|
|
137
|
+
sh = (slice_h - target_h) // 2
|
|
138
|
+
sw = (slice_w - target_w) // 2
|
|
139
|
+
drr = drr[sh:sh + target_h, sw:sw + target_w]
|
|
140
|
+
|
|
141
|
+
if output_size is not None and (drr.shape[0] != output_size[0] or drr.shape[1] != output_size[1]):
|
|
142
|
+
from scipy.ndimage import zoom
|
|
143
|
+
zy = output_size[0] / drr.shape[0]
|
|
144
|
+
zx = output_size[1] / drr.shape[1]
|
|
145
|
+
drr = zoom(drr, (zy, zx), order=1)
|
|
146
|
+
|
|
147
|
+
return drr
|