miblab-ssa 0.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.
- miblab_ssa/__init__.py +14 -0
- miblab_ssa/lb.py +260 -0
- miblab_ssa/metrics.py +280 -0
- miblab_ssa/normalize.py +532 -0
- miblab_ssa/pca.py +98 -0
- miblab_ssa/pdm.py +177 -0
- miblab_ssa/sdf_cheby.py +153 -0
- miblab_ssa/sdf_ft.py +78 -0
- miblab_ssa/sdf_ft_simple.py +47 -0
- miblab_ssa/sdf_mono.py +214 -0
- miblab_ssa/sh.py +444 -0
- miblab_ssa/ssa.py +525 -0
- miblab_ssa/zernike.py +144 -0
- miblab_ssa-0.0.0.dist-info/METADATA +34 -0
- miblab_ssa-0.0.0.dist-info/RECORD +18 -0
- miblab_ssa-0.0.0.dist-info/WHEEL +5 -0
- miblab_ssa-0.0.0.dist-info/licenses/LICENSE +201 -0
- miblab_ssa-0.0.0.dist-info/top_level.txt +1 -0
miblab_ssa/pdm.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import trimesh
|
|
3
|
+
from skimage.measure import marching_cubes
|
|
4
|
+
from scipy.interpolate import griddata
|
|
5
|
+
from scipy.ndimage import gaussian_filter, binary_dilation, binary_fill_holes
|
|
6
|
+
|
|
7
|
+
# ---------------------------------------------------------
|
|
8
|
+
# 1. Template & Mapping Utils
|
|
9
|
+
# ---------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
def get_template_mesh(radius=1.0, subdivisions=3):
|
|
12
|
+
"""
|
|
13
|
+
Returns the fixed topology icosphere.
|
|
14
|
+
"""
|
|
15
|
+
mesh = trimesh.creation.icosphere(subdivisions=subdivisions, radius=radius)
|
|
16
|
+
|
|
17
|
+
# Pre-calculate the spherical coordinates (Theta, Phi) for this template ONCE.
|
|
18
|
+
# We will query these angles on the target kidney.
|
|
19
|
+
verts = mesh.vertices
|
|
20
|
+
# Normalize just in case
|
|
21
|
+
verts /= np.linalg.norm(verts, axis=1)[:, None]
|
|
22
|
+
|
|
23
|
+
# Convert vector -> angles
|
|
24
|
+
# Mapping (x, y, z) -> (x, z, y) so Z is poles is standard for many libs,
|
|
25
|
+
# but let's stick to standard math: Z is up.
|
|
26
|
+
Xc, Yc, Zc = verts[:,0], verts[:,1], verts[:,2]
|
|
27
|
+
|
|
28
|
+
# Clip z to [-1, 1] to avoid NaNs at poles
|
|
29
|
+
Theta = np.arccos(np.clip(Zc, -1, 1))
|
|
30
|
+
Phi = np.arctan2(Yc, Xc) % (2*np.pi)
|
|
31
|
+
|
|
32
|
+
return mesh, Theta, Phi
|
|
33
|
+
|
|
34
|
+
def get_inflated_mapping(verts, faces, iterations=80):
|
|
35
|
+
"""
|
|
36
|
+
Iteratively smoothes the target mesh into a sphere.
|
|
37
|
+
This 'unwraps' the concave kidney so we can map it to the template.
|
|
38
|
+
"""
|
|
39
|
+
smooth_verts = verts.copy()
|
|
40
|
+
n_verts = len(verts)
|
|
41
|
+
|
|
42
|
+
# 1. Build Adjacency (Edges)
|
|
43
|
+
edges = np.concatenate([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]])
|
|
44
|
+
|
|
45
|
+
# 2. Laplacian Smoothing (Inflation)
|
|
46
|
+
for _ in range(iterations):
|
|
47
|
+
# Calculate Face centers
|
|
48
|
+
face_centers = smooth_verts[faces].mean(axis=1)
|
|
49
|
+
|
|
50
|
+
# Accumulate neighbor forces
|
|
51
|
+
v_sums = np.zeros_like(smooth_verts)
|
|
52
|
+
v_counts = np.zeros(n_verts)
|
|
53
|
+
|
|
54
|
+
np.add.at(v_sums, faces, face_centers[:, None, :])
|
|
55
|
+
np.add.at(v_counts, faces, 1)
|
|
56
|
+
|
|
57
|
+
v_counts[v_counts==0] = 1
|
|
58
|
+
v_avgs = v_sums / v_counts[:, None]
|
|
59
|
+
|
|
60
|
+
# Update (Relax towards average)
|
|
61
|
+
smooth_verts = 0.5 * smooth_verts + 0.5 * v_avgs
|
|
62
|
+
|
|
63
|
+
# Re-project to unit sphere (The "Inflation" step)
|
|
64
|
+
smooth_verts -= smooth_verts.mean(axis=0)
|
|
65
|
+
norms = np.linalg.norm(smooth_verts, axis=1)
|
|
66
|
+
smooth_verts /= (norms[:, None] + 1e-9)
|
|
67
|
+
|
|
68
|
+
# 3. Get Angles of the inflated target
|
|
69
|
+
Xc, Yc, Zc = smooth_verts[:,0], smooth_verts[:,1], smooth_verts[:,2]
|
|
70
|
+
Theta = np.arccos(np.clip(Zc, -1, 1))
|
|
71
|
+
Phi = np.arctan2(Yc, Xc) % (2*np.pi)
|
|
72
|
+
|
|
73
|
+
return Theta, Phi
|
|
74
|
+
|
|
75
|
+
# ---------------------------------------------------------
|
|
76
|
+
# 2. Main API
|
|
77
|
+
# ---------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def features_from_mask(mask: np.ndarray, subdivisions=3):
|
|
80
|
+
"""
|
|
81
|
+
Extracts PDM features by mapping the Template Mesh to the Target.
|
|
82
|
+
Preserves Concavities (Hollows).
|
|
83
|
+
"""
|
|
84
|
+
# 1. Extract Target Mesh
|
|
85
|
+
# Blur slightly to get smooth normals
|
|
86
|
+
mask_float = gaussian_filter(mask.astype(float), sigma=1.0)
|
|
87
|
+
verts_target, faces_target, _, _ = marching_cubes(mask_float, level=0.5)
|
|
88
|
+
|
|
89
|
+
# 2. Center Target (Crucial for PCA)
|
|
90
|
+
centroid = verts_target.mean(axis=0)
|
|
91
|
+
verts_centered = verts_target - centroid
|
|
92
|
+
|
|
93
|
+
# 3. Inflate Target (Get its spherical address map)
|
|
94
|
+
# This finds the unique (Theta, Phi) for every vertex on the kidney
|
|
95
|
+
Theta_target, Phi_target = get_inflated_mapping(verts_centered, faces_target)
|
|
96
|
+
|
|
97
|
+
# 4. Get Template Angles
|
|
98
|
+
# We want to move the Template vertices to the corresponding locations
|
|
99
|
+
template_mesh, Theta_template, Phi_template = get_template_mesh(subdivisions=subdivisions)
|
|
100
|
+
|
|
101
|
+
# 5. Interpolate
|
|
102
|
+
# We use the Target's (Theta, Phi) -> (X, Y, Z) mapping
|
|
103
|
+
# to look up the (X, Y, Z) for the Template's (Theta, Phi).
|
|
104
|
+
|
|
105
|
+
# Fix the Seam at 2pi: Augment data
|
|
106
|
+
T_aug = np.concatenate([Theta_target, Theta_target, Theta_target])
|
|
107
|
+
P_aug = np.concatenate([Phi_target, Phi_target - 2*np.pi, Phi_target + 2*np.pi])
|
|
108
|
+
V_aug = np.concatenate([verts_centered, verts_centered, verts_centered])
|
|
109
|
+
|
|
110
|
+
points_in = np.column_stack((T_aug, P_aug))
|
|
111
|
+
|
|
112
|
+
# Look up X, Y, Z coordinates for the template vertices
|
|
113
|
+
feat_x = griddata(points_in, V_aug[:,0], (Theta_template, Phi_template), method='linear')
|
|
114
|
+
feat_y = griddata(points_in, V_aug[:,1], (Theta_template, Phi_template), method='linear')
|
|
115
|
+
feat_z = griddata(points_in, V_aug[:,2], (Theta_template, Phi_template), method='linear')
|
|
116
|
+
|
|
117
|
+
# Fill any interpolation gaps (poles or sparse areas)
|
|
118
|
+
mask_nan = np.isnan(feat_x)
|
|
119
|
+
if np.any(mask_nan):
|
|
120
|
+
feat_x[mask_nan] = griddata(points_in, V_aug[:,0], (Theta_template[mask_nan], Phi_template[mask_nan]), method='nearest')
|
|
121
|
+
feat_y[mask_nan] = griddata(points_in, V_aug[:,1], (Theta_template[mask_nan], Phi_template[mask_nan]), method='nearest')
|
|
122
|
+
feat_z[mask_nan] = griddata(points_in, V_aug[:,2], (Theta_template[mask_nan], Phi_template[mask_nan]), method='nearest')
|
|
123
|
+
|
|
124
|
+
# 6. Flatten to Feature Vector
|
|
125
|
+
# These are the vertices of the TEMPLATE, but moved to the KIDNEY surface
|
|
126
|
+
return np.column_stack((feat_x, feat_y, feat_z)).flatten()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def mask_from_features(features: np.ndarray, shape, subdivisions=3):
|
|
130
|
+
"""
|
|
131
|
+
Reconstructs the mask. Since features are derived from a Template Mesh,
|
|
132
|
+
we can use the Template's faces to create a guaranteed watertight surface.
|
|
133
|
+
"""
|
|
134
|
+
vol = np.zeros(shape, dtype=np.uint8)
|
|
135
|
+
|
|
136
|
+
# 1. Rebuild Mesh Object
|
|
137
|
+
ref_template, _, _ = get_template_mesh(subdivisions=subdivisions)
|
|
138
|
+
n_verts = len(ref_template.vertices)
|
|
139
|
+
|
|
140
|
+
if features.size != n_verts * 3:
|
|
141
|
+
raise ValueError(f"Feature mismatch! Expected {n_verts*3}, got {features.size}. Check subdivision level.")
|
|
142
|
+
|
|
143
|
+
vertices = features.reshape((n_verts, 3))
|
|
144
|
+
|
|
145
|
+
# Center back to volume
|
|
146
|
+
vol_center = np.array(shape) / 2.0
|
|
147
|
+
vertices += vol_center
|
|
148
|
+
|
|
149
|
+
# Create the mesh using Fixed Topology
|
|
150
|
+
mesh = trimesh.Trimesh(vertices=vertices, faces=ref_template.faces)
|
|
151
|
+
|
|
152
|
+
# 2. Robust Voxelization (Sample & Fill)
|
|
153
|
+
# We perform dense sampling on the mesh surface to handle any folding
|
|
154
|
+
# 50,000 points covers a 300^3 volume well without gaps
|
|
155
|
+
samples, _ = trimesh.sample.sample_surface(mesh, count=50000)
|
|
156
|
+
|
|
157
|
+
ix = np.round(samples[:, 0]).astype(int)
|
|
158
|
+
iy = np.round(samples[:, 1]).astype(int)
|
|
159
|
+
iz = np.round(samples[:, 2]).astype(int)
|
|
160
|
+
|
|
161
|
+
# Bounds check
|
|
162
|
+
valid = (ix >= 0) & (ix < shape[0]) & \
|
|
163
|
+
(iy >= 0) & (iy < shape[1]) & \
|
|
164
|
+
(iz >= 0) & (iz < shape[2])
|
|
165
|
+
|
|
166
|
+
# Paint surface
|
|
167
|
+
vol[ix[valid], iy[valid], iz[valid]] = 1
|
|
168
|
+
|
|
169
|
+
# 3. Fill
|
|
170
|
+
vol = binary_dilation(vol, iterations=2)
|
|
171
|
+
vol = binary_fill_holes(vol)
|
|
172
|
+
|
|
173
|
+
return vol.astype(np.uint8)
|
|
174
|
+
|
|
175
|
+
def smooth_mask(mask, subdivisions=3):
|
|
176
|
+
feats = features_from_mask(mask, subdivisions=subdivisions)
|
|
177
|
+
return mask_from_features(feats, mask.shape, subdivisions=subdivisions)
|
miblab_ssa/sdf_cheby.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.ndimage import distance_transform_edt, label, center_of_mass
|
|
3
|
+
from numpy.polynomial.chebyshev import chebvander
|
|
4
|
+
from sklearn.linear_model import Ridge
|
|
5
|
+
from itertools import product
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def features_from_mask(mask, order=15, n_samples=50000, random_seed=42):
|
|
9
|
+
"""
|
|
10
|
+
Extracts PCA-ready shape coefficients using a fixed coordinate system
|
|
11
|
+
based on the mask volume dimensions.
|
|
12
|
+
"""
|
|
13
|
+
# Fix the seed so the random sampling is identical every time
|
|
14
|
+
np.random.seed(random_seed)
|
|
15
|
+
|
|
16
|
+
# 1. Define Fixed Coordinate System (Middle of the Cube)
|
|
17
|
+
# Since your data is registered, we use the volume's geometrical center.
|
|
18
|
+
nz, ny, nx = mask.shape
|
|
19
|
+
center = np.array([nz, ny, nx]) / 2.0
|
|
20
|
+
|
|
21
|
+
# Scale covers the entire volume (radius from center to edge)
|
|
22
|
+
# For a cube of size N, radius is N/2.
|
|
23
|
+
scale = np.max([nz, ny, nx]) / 2.0
|
|
24
|
+
|
|
25
|
+
# 2. Compute SDF
|
|
26
|
+
dist_outside = distance_transform_edt(1 - mask)
|
|
27
|
+
dist_inside = distance_transform_edt(mask)
|
|
28
|
+
sdf = dist_outside - dist_inside
|
|
29
|
+
|
|
30
|
+
# 3. Robust Sampling Strategy
|
|
31
|
+
boundary_mask = np.abs(sdf) < 3.0
|
|
32
|
+
idx_boundary = np.argwhere(boundary_mask)
|
|
33
|
+
|
|
34
|
+
# Generate random indices globally
|
|
35
|
+
idx_random = np.random.randint(0, [nz, ny, nx], size=(n_samples, 3))
|
|
36
|
+
|
|
37
|
+
# Combine (50% boundary, 50% random air)
|
|
38
|
+
n_bound = int(n_samples * 0.5)
|
|
39
|
+
if len(idx_boundary) > 0:
|
|
40
|
+
idx_b_select = idx_boundary[np.random.choice(len(idx_boundary), n_bound, replace=True)]
|
|
41
|
+
else:
|
|
42
|
+
idx_b_select = idx_boundary # Fallback
|
|
43
|
+
|
|
44
|
+
indices = np.vstack([idx_b_select, idx_random])
|
|
45
|
+
|
|
46
|
+
z_idx, y_idx, x_idx = indices[:, 0], indices[:, 1], indices[:, 2]
|
|
47
|
+
Values = sdf[z_idx, y_idx, x_idx]
|
|
48
|
+
|
|
49
|
+
# 4. Normalize Coords to [-1, 1] using Fixed Center/Scale
|
|
50
|
+
Z = (z_idx - center[0]) / scale
|
|
51
|
+
Y = (y_idx - center[1]) / scale
|
|
52
|
+
X = (x_idx - center[2]) / scale
|
|
53
|
+
|
|
54
|
+
# Filter valid cube (points outside the fitting domain must be ignored)
|
|
55
|
+
valid_mask = (np.abs(X) <= 1) & (np.abs(Y) <= 1) & (np.abs(Z) <= 1)
|
|
56
|
+
|
|
57
|
+
X, Y, Z = X[valid_mask], Y[valid_mask], Z[valid_mask]
|
|
58
|
+
Values = Values[valid_mask]
|
|
59
|
+
|
|
60
|
+
# 5. Generate Basis
|
|
61
|
+
Tx = chebvander(X, order)
|
|
62
|
+
Ty = chebvander(Y, order)
|
|
63
|
+
Tz = chebvander(Z, order)
|
|
64
|
+
|
|
65
|
+
basis_cols = []
|
|
66
|
+
# Deterministic loop order for PCA consistency
|
|
67
|
+
for i, j, k in product(range(order + 1), repeat=3):
|
|
68
|
+
if i + j + k <= order:
|
|
69
|
+
col = Tx[:, i] * Ty[:, j] * Tz[:, k]
|
|
70
|
+
basis_cols.append(col)
|
|
71
|
+
|
|
72
|
+
A = np.column_stack(basis_cols)
|
|
73
|
+
|
|
74
|
+
# 6. Solve
|
|
75
|
+
clf = Ridge(alpha=1e-3, fit_intercept=False)
|
|
76
|
+
clf.fit(A, Values)
|
|
77
|
+
|
|
78
|
+
return clf.coef_
|
|
79
|
+
|
|
80
|
+
def mask_from_features(coeffs, shape, order):
|
|
81
|
+
"""
|
|
82
|
+
Reconstructs mask from coefficients using the fixed volume center/scale.
|
|
83
|
+
"""
|
|
84
|
+
vol = np.zeros(shape, dtype=np.uint8)
|
|
85
|
+
|
|
86
|
+
# 1. Define Fixed Coordinate System (Same as features_from_mask)
|
|
87
|
+
nz, ny, nx = shape
|
|
88
|
+
center = np.array([nz, ny, nx]) / 2.0
|
|
89
|
+
scale = np.max([nz, ny, nx]) / 2.0
|
|
90
|
+
|
|
91
|
+
# 2. ROI optimization (We only loop over the area covered by 'scale')
|
|
92
|
+
pad = int(scale * 1.0)
|
|
93
|
+
z_min, z_max = max(0, int(center[0]-pad)), min(shape[0], int(center[0]+pad))
|
|
94
|
+
y_min, y_max = max(0, int(center[1]-pad)), min(shape[1], int(center[1]+pad))
|
|
95
|
+
x_min, x_max = max(0, int(center[2]-pad)), min(shape[2], int(center[2]+pad))
|
|
96
|
+
|
|
97
|
+
roi_nz, roi_ny, roi_nx = z_max - z_min, y_max - y_min, x_max - x_min
|
|
98
|
+
if roi_nz <= 0 or roi_ny <= 0 or roi_nx <= 0: return vol
|
|
99
|
+
|
|
100
|
+
# 3. Coords
|
|
101
|
+
z_coords = (np.arange(z_min, z_max) - center[0]) / scale
|
|
102
|
+
y_coords = (np.arange(y_min, y_max) - center[1]) / scale
|
|
103
|
+
x_coords = (np.arange(x_min, x_max) - center[2]) / scale
|
|
104
|
+
|
|
105
|
+
# 4. Basis
|
|
106
|
+
Tz = chebvander(z_coords, order).T
|
|
107
|
+
Ty = chebvander(y_coords, order).T
|
|
108
|
+
Tx = chebvander(x_coords, order).T
|
|
109
|
+
|
|
110
|
+
# 5. Tensor Assembly
|
|
111
|
+
poly_indices = []
|
|
112
|
+
for i, j, k in product(range(order + 1), repeat=3):
|
|
113
|
+
if i + j + k <= order:
|
|
114
|
+
poly_indices.append((i, j, k))
|
|
115
|
+
|
|
116
|
+
C_tensor = np.zeros((order + 1, order + 1, order + 1))
|
|
117
|
+
for idx, (i, j, k) in enumerate(poly_indices):
|
|
118
|
+
C_tensor[k, j, i] = coeffs[idx]
|
|
119
|
+
|
|
120
|
+
# 6. Contraction
|
|
121
|
+
recon_roi = np.einsum('kji,kz,jy,ix->zyx', C_tensor, Tz, Ty, Tx, optimize='optimal')
|
|
122
|
+
|
|
123
|
+
# 7. Hard Geometric Crop
|
|
124
|
+
zz, yy, xx = np.meshgrid(z_coords, y_coords, x_coords, indexing='ij')
|
|
125
|
+
valid_box = (np.abs(zz) <= 1.0) & (np.abs(yy) <= 1.0) & (np.abs(xx) <= 1.0)
|
|
126
|
+
|
|
127
|
+
mask_roi = (recon_roi < 0) & valid_box
|
|
128
|
+
|
|
129
|
+
# 8. Intelligent Filtering (Center Distance)
|
|
130
|
+
if np.any(mask_roi):
|
|
131
|
+
labeled, n_components = label(mask_roi)
|
|
132
|
+
if n_components > 1:
|
|
133
|
+
# We assume the kidney is near the center of the volume
|
|
134
|
+
roi_center = np.array([roi_nz/2, roi_ny/2, roi_nx/2])
|
|
135
|
+
|
|
136
|
+
centers = center_of_mass(mask_roi, labeled, range(1, n_components+1))
|
|
137
|
+
centers = np.array(centers)
|
|
138
|
+
|
|
139
|
+
if len(centers) > 0:
|
|
140
|
+
dists = np.linalg.norm(centers - roi_center, axis=1)
|
|
141
|
+
winner_idx = np.argmin(dists) + 1
|
|
142
|
+
mask_roi = (labeled == winner_idx)
|
|
143
|
+
|
|
144
|
+
vol[z_min:z_max, y_min:y_max, x_min:x_max] = mask_roi
|
|
145
|
+
|
|
146
|
+
return vol
|
|
147
|
+
|
|
148
|
+
def smooth_mask(mask:np.ndarray, order=20):
|
|
149
|
+
coeffs = features_from_mask(mask, order=order)
|
|
150
|
+
mask_rec = mask_from_features(coeffs, mask.shape, order)
|
|
151
|
+
return mask_rec
|
|
152
|
+
|
|
153
|
+
# N = (L+1) (L+2) (L+3) / 6
|
miblab_ssa/sdf_ft.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.ndimage import distance_transform_edt
|
|
3
|
+
from scipy.fft import dctn, idctn
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_spectral_mask(shape, radius):
|
|
9
|
+
"""
|
|
10
|
+
Generates a boolean mask for spherical truncation.
|
|
11
|
+
shape: The shape of the coefficient block (e.g., (16, 16, 16))
|
|
12
|
+
radius: The spectral radius to keep (e.g., 16.0)
|
|
13
|
+
"""
|
|
14
|
+
# 1. strictly use the 'shape' argument to determine grid dimensions
|
|
15
|
+
ranges = [np.arange(n) for n in shape]
|
|
16
|
+
|
|
17
|
+
# 2. Create the grid of frequencies (i, j, k)
|
|
18
|
+
I, J, K = np.meshgrid(*ranges, indexing='ij')
|
|
19
|
+
|
|
20
|
+
# 3. Calculate distance from DC component (0,0,0)
|
|
21
|
+
# i^2 + j^2 + k^2 <= r^2
|
|
22
|
+
mask = (I**2 + J**2 + K**2) <= radius**2
|
|
23
|
+
|
|
24
|
+
return mask
|
|
25
|
+
|
|
26
|
+
def features_from_mask(mask: np.ndarray, order=16, norm="ortho", saturation_threshold=5.0):
|
|
27
|
+
# 1. Compute SDF & Saturate
|
|
28
|
+
mask = mask.astype(bool)
|
|
29
|
+
# Using float32 saves memory/speed for DCT without losing precision relevant for shape
|
|
30
|
+
sdf = (distance_transform_edt(~mask) - distance_transform_edt(mask)).astype(np.float32)
|
|
31
|
+
sdf_saturated = saturation_threshold * np.tanh(sdf / saturation_threshold)
|
|
32
|
+
|
|
33
|
+
# 2. Compute coefficients
|
|
34
|
+
full_coeffs = dctn(sdf_saturated, norm=norm, workers=-1)
|
|
35
|
+
|
|
36
|
+
# 3. Cubic Truncation first (Efficiency)
|
|
37
|
+
# We slice out the corner cube first to avoid generating a mask for the massive 300^3 volume
|
|
38
|
+
cube_coeffs = full_coeffs[:order, :order, :order]
|
|
39
|
+
|
|
40
|
+
# 4. Spherical Masking
|
|
41
|
+
# Now we generate the mask specifically for this cube shape
|
|
42
|
+
mask = get_spectral_mask(cube_coeffs.shape, radius=order)
|
|
43
|
+
|
|
44
|
+
# Flatten ONLY the valid spherical coefficients
|
|
45
|
+
coeffs_flat = cube_coeffs[mask]
|
|
46
|
+
|
|
47
|
+
return coeffs_flat
|
|
48
|
+
|
|
49
|
+
def mask_from_features(coeffs_flat: np.ndarray, shape, order, norm="ortho"):
|
|
50
|
+
# 1. Recreate the Mask
|
|
51
|
+
# We must generate the exact same mask used during encoding
|
|
52
|
+
# The container is the small corner cube of size (order, order, order)
|
|
53
|
+
cube_shape = (order, order, order)
|
|
54
|
+
mask = get_spectral_mask(cube_shape, radius=order)
|
|
55
|
+
|
|
56
|
+
# 2. Rebuild the Cube
|
|
57
|
+
# Validate that coefficient counts match
|
|
58
|
+
expected_size = np.sum(mask)
|
|
59
|
+
if coeffs_flat.size != expected_size:
|
|
60
|
+
raise ValueError(f"Coefficient mismatch! Expected {expected_size} coeffs for order {order}, got {coeffs_flat.size}.")
|
|
61
|
+
|
|
62
|
+
cube_coeffs = np.zeros(cube_shape, dtype=coeffs_flat.dtype)
|
|
63
|
+
cube_coeffs[mask] = coeffs_flat
|
|
64
|
+
|
|
65
|
+
# 3. Pad to Full Volume
|
|
66
|
+
full_coeffs = np.zeros(shape, dtype=coeffs_flat.dtype)
|
|
67
|
+
full_coeffs[:order, :order, :order] = cube_coeffs
|
|
68
|
+
|
|
69
|
+
# 4. Inverse DCT
|
|
70
|
+
sdf_recon = idctn(full_coeffs, norm=norm, workers=-1)
|
|
71
|
+
|
|
72
|
+
return sdf_recon < 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def smooth_mask(mask:np.ndarray, order=16, norm="ortho"):
|
|
76
|
+
coeffs = features_from_mask(mask, order, norm)
|
|
77
|
+
mask_recon = mask_from_features(coeffs, mask.shape, order, norm)
|
|
78
|
+
return mask_recon
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.ndimage import distance_transform_edt
|
|
3
|
+
from scipy.fftpack import dctn, idctn
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def features_from_mask(mask:np.ndarray, order=16, norm="ortho"):
|
|
7
|
+
|
|
8
|
+
# Compute sdf
|
|
9
|
+
mask = mask.astype(bool)
|
|
10
|
+
dist_out = distance_transform_edt(~mask)
|
|
11
|
+
dist_in = distance_transform_edt(mask)
|
|
12
|
+
sdf = dist_out - dist_in
|
|
13
|
+
|
|
14
|
+
# Compute coefficients, truncate and flatten
|
|
15
|
+
coeffs = dctn(sdf, norm=norm)
|
|
16
|
+
coeffs = coeffs[:order, :order, :order]
|
|
17
|
+
coeffs = coeffs.flatten()
|
|
18
|
+
|
|
19
|
+
return coeffs
|
|
20
|
+
|
|
21
|
+
def mask_from_features(coeffs_trunc:np.ndarray, shape, norm="ortho"):
|
|
22
|
+
|
|
23
|
+
# Rebuild coefficients at required shape
|
|
24
|
+
order = int(np.cbrt(coeffs_trunc.size))
|
|
25
|
+
coeffs_trunc = coeffs_trunc.reshape(3 * [order])
|
|
26
|
+
coeffs = np.zeros(shape, dtype=coeffs_trunc.dtype)
|
|
27
|
+
coeffs[:order, :order, :order] = coeffs_trunc
|
|
28
|
+
|
|
29
|
+
# Compute mask from sdf
|
|
30
|
+
sdf = idctn(coeffs, norm=norm)
|
|
31
|
+
mask = sdf < 0
|
|
32
|
+
|
|
33
|
+
return mask
|
|
34
|
+
|
|
35
|
+
def smooth_mask(mask:np.ndarray, order=16, norm="ortho"):
|
|
36
|
+
coeffs = features_from_mask(mask, order, norm)
|
|
37
|
+
mask_recon = mask_from_features(coeffs, mask.shape, norm)
|
|
38
|
+
return mask_recon
|
|
39
|
+
|
|
40
|
+
def features_from_dataset(masks, order=16, norm="ortho"):
|
|
41
|
+
features = [features_from_mask(mask, order, norm) for mask in masks]
|
|
42
|
+
return np.array(features)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
miblab_ssa/sdf_mono.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from scipy.ndimage import distance_transform_edt
|
|
3
|
+
from itertools import product
|
|
4
|
+
from sklearn.linear_model import Ridge
|
|
5
|
+
|
|
6
|
+
def compute_implicit_coeffs(mask, order=6, n_samples=50000):
|
|
7
|
+
"""
|
|
8
|
+
Fits an implicit surface using narrow-band sampling for high accuracy.
|
|
9
|
+
Lower order (4-8) is often more stable than high order (10+).
|
|
10
|
+
"""
|
|
11
|
+
# 1. Compute SDF
|
|
12
|
+
dist_outside = distance_transform_edt(1 - mask)
|
|
13
|
+
dist_inside = distance_transform_edt(mask)
|
|
14
|
+
sdf = dist_outside - dist_inside
|
|
15
|
+
|
|
16
|
+
# 2. Define Coordinate System (Relative to Object)
|
|
17
|
+
coords = np.argwhere(mask)
|
|
18
|
+
min_c = coords.min(axis=0)
|
|
19
|
+
max_c = coords.max(axis=0)
|
|
20
|
+
center = (min_c + max_c) / 2.0
|
|
21
|
+
# Use max extent for uniform scaling (preserves aspect ratio)
|
|
22
|
+
scale = (max_c - min_c).max() / 2.0 * 1.1 # 10% padding
|
|
23
|
+
|
|
24
|
+
# 3. Intelligent Sampling (The Key Fix)
|
|
25
|
+
# Instead of zooming the grid, we pick specific points to learn from.
|
|
26
|
+
|
|
27
|
+
# Grid of all voxel indices
|
|
28
|
+
nz, ny, nx = mask.shape
|
|
29
|
+
|
|
30
|
+
# Strategy: Pick mostly points near the boundary (narrow band)
|
|
31
|
+
# plus some points inside and outside to define the bulk.
|
|
32
|
+
boundary_mask = np.abs(sdf) < 2.0 # Points within 2 pixels of surface
|
|
33
|
+
inside_mask = (sdf < -2.0) # Deep inside
|
|
34
|
+
outside_mask = (sdf > 2.0) # Far outside
|
|
35
|
+
|
|
36
|
+
# Get indices
|
|
37
|
+
idx_boundary = np.argwhere(boundary_mask)
|
|
38
|
+
idx_inside = np.argwhere(inside_mask)
|
|
39
|
+
idx_outside = np.argwhere(outside_mask)
|
|
40
|
+
|
|
41
|
+
# Sampling counts
|
|
42
|
+
n_bound = int(n_samples * 0.5) # 50% samples on boundary
|
|
43
|
+
n_in = int(n_samples * 0.25) # 25% inside
|
|
44
|
+
n_out = int(n_samples * 0.25) # 25% outside
|
|
45
|
+
|
|
46
|
+
# Helper to safely sample
|
|
47
|
+
def sample_indices(indices, n):
|
|
48
|
+
if len(indices) == 0: return indices
|
|
49
|
+
# If we have fewer points than requested, take them all
|
|
50
|
+
if len(indices) < n: return indices
|
|
51
|
+
choice = np.random.choice(len(indices), n, replace=False)
|
|
52
|
+
return indices[choice]
|
|
53
|
+
|
|
54
|
+
s_bound = sample_indices(idx_boundary, n_bound)
|
|
55
|
+
s_in = sample_indices(idx_inside, n_in)
|
|
56
|
+
s_out = sample_indices(idx_outside, n_out)
|
|
57
|
+
|
|
58
|
+
# Combine samples
|
|
59
|
+
all_indices = np.vstack([s_bound, s_in, s_out])
|
|
60
|
+
|
|
61
|
+
# 4. Extract Coordinates and Values
|
|
62
|
+
# Z, Y, X (indices)
|
|
63
|
+
z_idx, y_idx, x_idx = all_indices[:, 0], all_indices[:, 1], all_indices[:, 2]
|
|
64
|
+
|
|
65
|
+
# Normalize to [-1, 1]
|
|
66
|
+
Z_norm = (z_idx - center[0]) / scale
|
|
67
|
+
Y_norm = (y_idx - center[1]) / scale
|
|
68
|
+
X_norm = (x_idx - center[2]) / scale
|
|
69
|
+
|
|
70
|
+
Values = sdf[z_idx, y_idx, x_idx]
|
|
71
|
+
|
|
72
|
+
# 5. Build Design Matrix
|
|
73
|
+
# Basis: x^i * y^j * z^k
|
|
74
|
+
# Pre-calculating powers is faster than looping
|
|
75
|
+
basis_cols = []
|
|
76
|
+
for i, j, k in product(range(order + 1), repeat=3):
|
|
77
|
+
if i + j + k <= order:
|
|
78
|
+
col = (X_norm**i) * (Y_norm**j) * (Z_norm**k)
|
|
79
|
+
basis_cols.append(col)
|
|
80
|
+
|
|
81
|
+
A = np.column_stack(basis_cols)
|
|
82
|
+
|
|
83
|
+
# 6. Fit
|
|
84
|
+
clf = Ridge(alpha=1e-4) # Small regularization
|
|
85
|
+
clf.fit(A, Values)
|
|
86
|
+
|
|
87
|
+
return clf.coef_, center, scale, order
|
|
88
|
+
|
|
89
|
+
def reconstruct_implicit(coeffs, shape, center, scale, order):
|
|
90
|
+
"""
|
|
91
|
+
Reconstructs the volume.
|
|
92
|
+
Optimization: Only evaluate within the bounding box of the object.
|
|
93
|
+
"""
|
|
94
|
+
vol = np.zeros(shape, dtype=np.uint8)
|
|
95
|
+
|
|
96
|
+
# Determine Bounding Box in indices (where normalized coords are roughly [-1, 1])
|
|
97
|
+
# Evaluating the polynomial over the entire 301^3 volume is wasteful and slow.
|
|
98
|
+
pad = int(scale * 1.1)
|
|
99
|
+
z_min, z_max = max(0, int(center[0]-pad)), min(shape[0], int(center[0]+pad))
|
|
100
|
+
y_min, y_max = max(0, int(center[1]-pad)), min(shape[1], int(center[1]+pad))
|
|
101
|
+
x_min, x_max = max(0, int(center[2]-pad)), min(shape[2], int(center[2]+pad))
|
|
102
|
+
|
|
103
|
+
# Generate grid only for the ROI
|
|
104
|
+
z_idx, y_idx, x_idx = np.meshgrid(
|
|
105
|
+
np.arange(z_min, z_max),
|
|
106
|
+
np.arange(y_min, y_max),
|
|
107
|
+
np.arange(x_min, x_max),
|
|
108
|
+
indexing='ij'
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Normalize
|
|
112
|
+
Z = (z_idx - center[0]) / scale
|
|
113
|
+
Y = (y_idx - center[1]) / scale
|
|
114
|
+
X = (x_idx - center[2]) / scale
|
|
115
|
+
|
|
116
|
+
# Flatten ROI
|
|
117
|
+
Xf, Yf, Zf = X.flatten(), Y.flatten(), Z.flatten()
|
|
118
|
+
|
|
119
|
+
# Evaluate
|
|
120
|
+
recon_sdf = np.zeros_like(Xf)
|
|
121
|
+
col_idx = 0
|
|
122
|
+
for i, j, k in product(range(order + 1), repeat=3):
|
|
123
|
+
if i + j + k <= order:
|
|
124
|
+
weight = coeffs[col_idx]
|
|
125
|
+
recon_sdf += weight * (Xf**i) * (Yf**j) * (Zf**k)
|
|
126
|
+
col_idx += 1
|
|
127
|
+
|
|
128
|
+
# Threshold
|
|
129
|
+
# SDF < 0 is inside
|
|
130
|
+
mask_roi = (recon_sdf < 0)
|
|
131
|
+
|
|
132
|
+
# Place back into full volume
|
|
133
|
+
vol[z_min:z_max, y_min:y_max, x_min:x_max] = mask_roi.reshape(z_idx.shape)
|
|
134
|
+
|
|
135
|
+
return vol
|
|
136
|
+
|
|
137
|
+
import numpy as np
|
|
138
|
+
from itertools import product
|
|
139
|
+
|
|
140
|
+
def reconstruct_implicit_fast(coeffs, shape, center, scale, order):
|
|
141
|
+
"""
|
|
142
|
+
Optimized reconstruction using precomputed powers and broadcasting.
|
|
143
|
+
"""
|
|
144
|
+
vol = np.zeros(shape, dtype=np.uint8)
|
|
145
|
+
|
|
146
|
+
# 1. Determine ROI (Bounding Box)
|
|
147
|
+
pad = int(scale * 1.1)
|
|
148
|
+
z_min, z_max = max(0, int(center[0]-pad)), min(shape[0], int(center[0]+pad))
|
|
149
|
+
y_min, y_max = max(0, int(center[1]-pad)), min(shape[1], int(center[1]+pad))
|
|
150
|
+
x_min, x_max = max(0, int(center[2]-pad)), min(shape[2], int(center[2]+pad))
|
|
151
|
+
|
|
152
|
+
# Dimensions of the ROI
|
|
153
|
+
nz, ny, nx = z_max - z_min, y_max - y_min, x_max - x_min
|
|
154
|
+
if nz <= 0 or ny <= 0 or nx <= 0: return vol
|
|
155
|
+
|
|
156
|
+
# 2. Create Normalized Coordinate Vectors (1D)
|
|
157
|
+
# Instead of making a meshgrid immediately, we keep them 1D first.
|
|
158
|
+
z_coords = (np.arange(z_min, z_max) - center[0]) / scale
|
|
159
|
+
y_coords = (np.arange(y_min, y_max) - center[1]) / scale
|
|
160
|
+
x_coords = (np.arange(x_min, x_max) - center[2]) / scale
|
|
161
|
+
|
|
162
|
+
# 3. Precompute Powers (Vandermonde-ish)
|
|
163
|
+
# Shape: (order+1, N)
|
|
164
|
+
# P_x[p, :] contains x^p for all x in the ROI
|
|
165
|
+
P_z = np.array([z_coords**p for p in range(order + 1)])
|
|
166
|
+
P_y = np.array([y_coords**p for p in range(order + 1)])
|
|
167
|
+
P_x = np.array([x_coords**p for p in range(order + 1)])
|
|
168
|
+
|
|
169
|
+
# 4. Re-assemble the Polynomial via Tensor Contraction
|
|
170
|
+
# The polynomial is: Sum_{i,j,k} ( C_{ijk} * Z^i * Y^j * X^k )
|
|
171
|
+
# This is a tensor dot product.
|
|
172
|
+
|
|
173
|
+
# First, we need to unpack the flat 'coeffs' list back into a 3D tensor C[i,j,k].
|
|
174
|
+
# Since our flat list skipped terms where i+j+k > order, we must be careful.
|
|
175
|
+
C_tensor = np.zeros((order + 1, order + 1, order + 1))
|
|
176
|
+
|
|
177
|
+
col_idx = 0
|
|
178
|
+
# Use the exact same iteration order as the training step
|
|
179
|
+
for i in range(order + 1):
|
|
180
|
+
for j in range(order + 1):
|
|
181
|
+
for k in range(order + 1):
|
|
182
|
+
if i + j + k <= order:
|
|
183
|
+
# Note: Your training loop was (X^i, Y^j, Z^k),
|
|
184
|
+
# so map indices carefully: C[k, j, i] if Z is axis 0, etc.
|
|
185
|
+
# Your generic loop: (X**i) * (Y**j) * (Z**k)
|
|
186
|
+
# Let's align C_tensor indices to (k, j, i) -> (Z, Y, X)
|
|
187
|
+
C_tensor[k, j, i] = coeffs[col_idx]
|
|
188
|
+
col_idx += 1
|
|
189
|
+
|
|
190
|
+
# 5. Perform Contraction (Einstein Summation)
|
|
191
|
+
# We want result[z, y, x] = Sum_{k,j,i} C[k,j,i] * P_z[k,z] * P_y[j,y] * P_x[i,x]
|
|
192
|
+
# 'kji,kz,jy,ix->zyx'
|
|
193
|
+
# k,j,i are power indices
|
|
194
|
+
# z,y,x are spatial indices
|
|
195
|
+
|
|
196
|
+
# einsum is efficient but can be memory hungry for large chunks.
|
|
197
|
+
# If memory fails, we can do it in two steps.
|
|
198
|
+
|
|
199
|
+
# Step A: Contract X axis -> Temp[k, j, y, x]
|
|
200
|
+
# T1 = np.einsum('kji,ix->kjx', C_tensor, P_x)
|
|
201
|
+
# But contracting 3 axes at once is cleanest if it fits in RAM:
|
|
202
|
+
|
|
203
|
+
recon_roi = np.einsum('kji,kz,jy,ix->zyx', C_tensor, P_z, P_y, P_x, optimize='optimal')
|
|
204
|
+
|
|
205
|
+
# 6. Threshold and Place
|
|
206
|
+
mask_roi = (recon_roi < 0)
|
|
207
|
+
vol[z_min:z_max, y_min:y_max, x_min:x_max] = mask_roi
|
|
208
|
+
|
|
209
|
+
return vol
|
|
210
|
+
|
|
211
|
+
def reconstruct_shape(mask, order=8): # Order 8 is a good balance for kidneys
|
|
212
|
+
coeffs, center, scale, order = compute_implicit_coeffs(mask, order=order)
|
|
213
|
+
mask_rec = reconstruct_implicit_fast(coeffs, mask.shape, center, scale, order)
|
|
214
|
+
return mask_rec
|