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/sh.py
ADDED
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from skimage import measure, morphology
|
|
3
|
+
from scipy.spatial import cKDTree
|
|
4
|
+
from scipy.ndimage import binary_fill_holes
|
|
5
|
+
from skimage import measure
|
|
6
|
+
import trimesh
|
|
7
|
+
import pyshtools as pysh
|
|
8
|
+
from skimage.measure import marching_cubes
|
|
9
|
+
from scipy.interpolate import griddata
|
|
10
|
+
from scipy.ndimage import binary_fill_holes
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# ----------------------
|
|
14
|
+
# Utilities / preprocessing
|
|
15
|
+
# ----------------------
|
|
16
|
+
|
|
17
|
+
def preprocess_volume(vol, min_size=1000, closing_radius=2):
|
|
18
|
+
"""Simple morphological cleanup on binary volume."""
|
|
19
|
+
vol = vol.astype(bool)
|
|
20
|
+
vol = morphology.remove_small_objects(vol, min_size=min_size)
|
|
21
|
+
if closing_radius > 0:
|
|
22
|
+
vol = morphology.binary_closing(vol, morphology.ball(closing_radius))
|
|
23
|
+
vol = binary_fill_holes(vol)
|
|
24
|
+
return vol
|
|
25
|
+
|
|
26
|
+
def extract_mesh_from_volume(vol, spacing=(1.0,1.0,1.0), level=0.5):
|
|
27
|
+
"""
|
|
28
|
+
Extract triangle mesh from binary volume using marching cubes.
|
|
29
|
+
Returns verts (N,3) and faces (M,3).
|
|
30
|
+
spacing: voxel spacing tuple (z,y,x) or (dx,dy,dz) according to skimage usage.
|
|
31
|
+
"""
|
|
32
|
+
verts, faces, normals, values = measure.marching_cubes(vol.astype(np.uint8), level=level, spacing=spacing)
|
|
33
|
+
return verts, faces
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def dice_coefficient(vol_a, vol_b):
|
|
38
|
+
"""
|
|
39
|
+
Compute Dice coefficient between two binary volumes.
|
|
40
|
+
"""
|
|
41
|
+
vol_a = vol_a.astype(bool)
|
|
42
|
+
vol_b = vol_b.astype(bool)
|
|
43
|
+
intersection = np.logical_and(vol_a, vol_b).sum()
|
|
44
|
+
size_a = vol_a.sum()
|
|
45
|
+
size_b = vol_b.sum()
|
|
46
|
+
if size_a + size_b == 0:
|
|
47
|
+
return 1.0
|
|
48
|
+
return 2.0 * intersection / (size_a + size_b)
|
|
49
|
+
|
|
50
|
+
def surface_distances(vol_a, vol_b, spacing=(1.0,1.0,1.0)):
|
|
51
|
+
"""
|
|
52
|
+
Compute surface distances (Hausdorff and mean) between two binary volumes.
|
|
53
|
+
Args:
|
|
54
|
+
vol_a, vol_b: binary 3D arrays
|
|
55
|
+
spacing: voxel spacing (dz,dy,dx)
|
|
56
|
+
Returns:
|
|
57
|
+
hausdorff, mean_dist
|
|
58
|
+
"""
|
|
59
|
+
# extract meshes
|
|
60
|
+
verts_a, faces_a, _, _ = measure.marching_cubes(vol_a.astype(np.uint8), level=0.5, spacing=spacing)
|
|
61
|
+
verts_b, faces_b, _, _ = measure.marching_cubes(vol_b.astype(np.uint8), level=0.5, spacing=spacing)
|
|
62
|
+
|
|
63
|
+
# build kd-trees
|
|
64
|
+
tree_a = cKDTree(verts_a)
|
|
65
|
+
tree_b = cKDTree(verts_b)
|
|
66
|
+
|
|
67
|
+
# distances from A→B and B→A
|
|
68
|
+
d_ab, _ = tree_b.query(verts_a, k=1)
|
|
69
|
+
d_ba, _ = tree_a.query(verts_b, k=1)
|
|
70
|
+
|
|
71
|
+
hausdorff = max(d_ab.max(), d_ba.max())
|
|
72
|
+
mean_dist = 0.5 * (d_ab.mean() + d_ba.mean())
|
|
73
|
+
return hausdorff, mean_dist
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sh_reconstruct_surface_from_coeffs(coeffs, lmax = 8):
|
|
83
|
+
|
|
84
|
+
# Suppose coeffs is your SHCoeffs object (from pysh)
|
|
85
|
+
coeffs_array = coeffs.to_array()[:, :lmax+1, :lmax+1]
|
|
86
|
+
coeffs_trunc = pysh.SHCoeffs.from_array(coeffs_array)
|
|
87
|
+
|
|
88
|
+
# Reconstruct the spherical function
|
|
89
|
+
grid_recon = coeffs_trunc.expand(grid='DH') # gives SHGrid
|
|
90
|
+
radii_recon = grid_recon.to_array()
|
|
91
|
+
|
|
92
|
+
# -----------------------
|
|
93
|
+
# 2. Convert back to 3D surface
|
|
94
|
+
# -----------------------
|
|
95
|
+
# Define the same theta/phi grid
|
|
96
|
+
n_theta, n_phi = radii_recon.shape
|
|
97
|
+
theta = np.linspace(0, np.pi, n_theta) # polar
|
|
98
|
+
phi = np.linspace(0, 2*np.pi, n_phi) # azimuth
|
|
99
|
+
theta_grid, phi_grid = np.meshgrid(theta, phi, indexing="ij")
|
|
100
|
+
|
|
101
|
+
# Convert spherical coords (r, theta, phi) -> Cartesian
|
|
102
|
+
x = radii_recon * np.sin(theta_grid) * np.cos(phi_grid)
|
|
103
|
+
y = radii_recon * np.sin(theta_grid) * np.sin(phi_grid)
|
|
104
|
+
z = radii_recon * np.cos(theta_grid)
|
|
105
|
+
|
|
106
|
+
# Flatten for point cloud
|
|
107
|
+
points = np.vstack([x.ravel(), y.ravel(), z.ravel()]).T
|
|
108
|
+
|
|
109
|
+
# -----------------------
|
|
110
|
+
# 3. Create mesh from point cloud
|
|
111
|
+
# -----------------------
|
|
112
|
+
# Use alpha shape or ball pivoting; simplest: convex hull for now
|
|
113
|
+
recon_mesh = trimesh.convex.convex_hull(points)
|
|
114
|
+
|
|
115
|
+
# Save or show mesh
|
|
116
|
+
recon_mesh.export("kidney_recon_lmax8.ply")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def power_spectrum(coeffs):
|
|
124
|
+
# Real and rotationally invariant
|
|
125
|
+
|
|
126
|
+
# Suppose we already computed coeffs from SH expansion (see previous example)
|
|
127
|
+
# coeffs is an SHCoeffs object from pyshtools
|
|
128
|
+
|
|
129
|
+
# Convert coefficients to array: shape (2, lmax+1, lmax+1)
|
|
130
|
+
# axis 0: [0]=real part, [1]=imag part
|
|
131
|
+
coeffs_array = coeffs.to_array()
|
|
132
|
+
|
|
133
|
+
# -----------------------
|
|
134
|
+
# 1. Compute rotation-invariant power spectrum
|
|
135
|
+
# -----------------------
|
|
136
|
+
lmax = coeffs.lmax
|
|
137
|
+
power_spectrum = []
|
|
138
|
+
|
|
139
|
+
for l in range(lmax + 1):
|
|
140
|
+
# Get coefficients for this degree l across all orders m
|
|
141
|
+
c_l = coeffs_array[:, l, :l+1] # shape (2, l+1)
|
|
142
|
+
# Combine real + imaginary into complex
|
|
143
|
+
c_l_complex = c_l[0] + 1j * c_l[1]
|
|
144
|
+
# Sum of squared magnitudes across m
|
|
145
|
+
P_l = np.sqrt(np.sum(np.abs(c_l_complex)**2))
|
|
146
|
+
power_spectrum.append(P_l)
|
|
147
|
+
|
|
148
|
+
power_spectrum = np.array(power_spectrum)
|
|
149
|
+
|
|
150
|
+
return power_spectrum
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cosine_similarity(a, b):
|
|
154
|
+
# Simularity between two descriptor vectors - distance between shapes
|
|
155
|
+
return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
|
|
156
|
+
|
|
157
|
+
def _sh_reconstruct_shape(vol_orig, spacing, lmax=200):
|
|
158
|
+
|
|
159
|
+
# 1. Fit spherical harmonics
|
|
160
|
+
coeffs, centroid, max_extent = sh_compute_coeffs(vol_orig, lmax=lmax)
|
|
161
|
+
|
|
162
|
+
# coeffs contains complex SH coefficients = Fourier shape descriptors
|
|
163
|
+
# -----------------------
|
|
164
|
+
# 6. Use real descriptors for comparison
|
|
165
|
+
# -----------------------
|
|
166
|
+
descriptor_vector_real = coeffs.to_array().ravel().real
|
|
167
|
+
descriptor_vector_power = power_spectrum(coeffs)
|
|
168
|
+
|
|
169
|
+
vol_rec = sh_reconstruct_volume_from_coeffs(coeffs, vol_orig.shape, centroid, max_extent, lmax=lmax)
|
|
170
|
+
|
|
171
|
+
# 4. Compute evaluation metrics
|
|
172
|
+
dice = dice_coefficient(vol_orig, vol_rec)
|
|
173
|
+
hausdorff, mean_dist = surface_distances(vol_orig, vol_rec, spacing=spacing)
|
|
174
|
+
|
|
175
|
+
print('Dice: ', dice)
|
|
176
|
+
|
|
177
|
+
return vol_rec, {'p1': [1, 'par1', '%', 'float'], 'p2': [1, 'par2', '%', 'float']}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def sh_compute_coeffs(volume, lmax=15):
|
|
182
|
+
# -----------------------
|
|
183
|
+
# 1. Original volume
|
|
184
|
+
# -----------------------
|
|
185
|
+
|
|
186
|
+
# Compute centroid and max extent
|
|
187
|
+
coords = np.argwhere(volume)
|
|
188
|
+
centroid = coords.mean(axis=0)
|
|
189
|
+
max_extent = (coords.max(axis=0) - coords.min(axis=0)).max() / 2.0
|
|
190
|
+
|
|
191
|
+
# -----------------------
|
|
192
|
+
# 2. Normalize coordinates to unit sphere
|
|
193
|
+
# -----------------------
|
|
194
|
+
norm_coords = (coords - centroid) / max_extent
|
|
195
|
+
Xc, Yc, Zc = norm_coords[:,0], norm_coords[:,1], norm_coords[:,2]
|
|
196
|
+
R = np.sqrt(Xc**2 + Yc**2 + Zc**2)
|
|
197
|
+
Theta = np.arccos(np.clip(Zc / R, -1, 1)) # polar angle
|
|
198
|
+
Phi = np.arctan2(Yc, Xc) % (2*np.pi) # azimuth
|
|
199
|
+
|
|
200
|
+
# -----------------------
|
|
201
|
+
# 3. Map points to 2D spherical grid
|
|
202
|
+
# -----------------------
|
|
203
|
+
n_theta, n_phi = 64, 128
|
|
204
|
+
# n_theta, n_phi = 128, 256 # n_phi must be n_theta or 2*n_theta
|
|
205
|
+
radii_grid = np.zeros((n_theta, n_phi))
|
|
206
|
+
|
|
207
|
+
# Fill grid with max radius in each cell
|
|
208
|
+
for i in range(norm_coords.shape[0]):
|
|
209
|
+
t_idx = int(Theta[i] / np.pi * (n_theta-1))
|
|
210
|
+
p_idx = int(Phi[i] / (2*np.pi) * (n_phi-1))
|
|
211
|
+
radii_grid[t_idx, p_idx] = max(radii_grid[t_idx, p_idx], R[i])
|
|
212
|
+
|
|
213
|
+
# -----------------------
|
|
214
|
+
# 4. Create SHGrid and compute coefficients
|
|
215
|
+
# -----------------------
|
|
216
|
+
grid = pysh.SHGrid.from_array(radii_grid)
|
|
217
|
+
coeffs = grid.expand() # SHRealCoeffs
|
|
218
|
+
|
|
219
|
+
coeffs_array = coeffs.to_array()[:, :lmax+1, :lmax+1]
|
|
220
|
+
coeffs_trunc = pysh.SHCoeffs.from_array(coeffs_array)
|
|
221
|
+
|
|
222
|
+
return coeffs_trunc, centroid, max_extent
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def sh_reconstruct_volume_from_coeffs(coeffs, vol_shape, centroid, max_extent, lmax = 15):
|
|
226
|
+
|
|
227
|
+
coeffs_array = coeffs.to_array()[:, :lmax+1, :lmax+1]
|
|
228
|
+
coeffs_trunc = pysh.SHCoeffs.from_array(coeffs_array)
|
|
229
|
+
|
|
230
|
+
# Assume coeffs is SHCoeffs object (from earlier steps)
|
|
231
|
+
grid_recon = coeffs_trunc.expand(grid='DH')
|
|
232
|
+
radii_recon = grid_recon.to_array() # normalized unit-sphere radii
|
|
233
|
+
|
|
234
|
+
# -----------------------
|
|
235
|
+
# 4. Reconstruct 3D volume with original shape
|
|
236
|
+
# -----------------------
|
|
237
|
+
nx, ny, nz = vol_shape
|
|
238
|
+
x = np.arange(nx)
|
|
239
|
+
y = np.arange(ny)
|
|
240
|
+
z = np.arange(nz)
|
|
241
|
+
X, Y, Z = np.meshgrid(x, y, z, indexing='ij')
|
|
242
|
+
|
|
243
|
+
# Coordinates relative to original centroid
|
|
244
|
+
Xc = (X - centroid[0]) / max_extent
|
|
245
|
+
Yc = (Y - centroid[1]) / max_extent
|
|
246
|
+
Zc = (Z - centroid[2]) / max_extent
|
|
247
|
+
|
|
248
|
+
R = np.sqrt(Xc**2 + Yc**2 + Zc**2)
|
|
249
|
+
Theta = np.arccos(np.clip(Zc / (R + 1e-9), -1, 1))
|
|
250
|
+
Phi = np.arctan2(Yc, Xc) % (2*np.pi)
|
|
251
|
+
|
|
252
|
+
# Map spherical coordinates to SHGrid indices
|
|
253
|
+
n_theta, n_phi = radii_recon.shape
|
|
254
|
+
theta_idx = (Theta / np.pi * (n_theta-1)).astype(int)
|
|
255
|
+
phi_idx = (Phi / (2*np.pi) * (n_phi-1)).astype(int)
|
|
256
|
+
r_boundary = radii_recon[theta_idx, phi_idx]
|
|
257
|
+
|
|
258
|
+
# Binary reconstruction in original grid
|
|
259
|
+
volume_recon = (R <= r_boundary).astype(np.uint8)
|
|
260
|
+
|
|
261
|
+
return volume_recon
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def sh_reconstruct_shape(mask_orig, lmax=200):
|
|
265
|
+
|
|
266
|
+
coeffs, centroid, max_extent = sh_compute_coeffs(mask_orig, lmax=lmax)
|
|
267
|
+
mask_rec = sh_reconstruct_volume_from_coeffs(coeffs, mask_orig.shape, centroid, max_extent, lmax=lmax)
|
|
268
|
+
|
|
269
|
+
return mask_rec
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
import numpy as np
|
|
275
|
+
import pyshtools as pysh
|
|
276
|
+
from skimage.measure import marching_cubes
|
|
277
|
+
from scipy.ndimage import gaussian_filter, binary_fill_holes, binary_dilation
|
|
278
|
+
from scipy.special import sph_harm
|
|
279
|
+
|
|
280
|
+
def get_inflated_mapping(verts, faces, iterations=80):
|
|
281
|
+
"""
|
|
282
|
+
Inflates the mesh to a sphere to get unique (theta, phi) for every vertex.
|
|
283
|
+
Preserves topology so we don't get overlapping rays in the hollow.
|
|
284
|
+
"""
|
|
285
|
+
smooth_verts = verts.copy()
|
|
286
|
+
# Simple adjacency graph
|
|
287
|
+
edges = np.concatenate([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]])
|
|
288
|
+
|
|
289
|
+
n_verts = len(verts)
|
|
290
|
+
|
|
291
|
+
# Fast vectorized smoothing
|
|
292
|
+
for _ in range(iterations):
|
|
293
|
+
# Calculate face centers
|
|
294
|
+
face_centers = smooth_verts[faces].mean(axis=1)
|
|
295
|
+
|
|
296
|
+
# Accumulate neighbor pulls
|
|
297
|
+
v_sums = np.zeros_like(smooth_verts)
|
|
298
|
+
v_counts = np.zeros(n_verts)
|
|
299
|
+
|
|
300
|
+
np.add.at(v_sums, faces, face_centers[:, None, :])
|
|
301
|
+
np.add.at(v_counts, faces, 1)
|
|
302
|
+
|
|
303
|
+
v_counts[v_counts==0] = 1
|
|
304
|
+
v_avgs = v_sums / v_counts[:, None]
|
|
305
|
+
|
|
306
|
+
# Move vertices
|
|
307
|
+
smooth_verts = 0.5 * smooth_verts + 0.5 * v_avgs
|
|
308
|
+
|
|
309
|
+
# Re-center and normalize to keep numerics healthy
|
|
310
|
+
smooth_verts -= smooth_verts.mean(axis=0)
|
|
311
|
+
rads = np.linalg.norm(smooth_verts, axis=1)
|
|
312
|
+
smooth_verts /= (rads[:, None] + 1e-9)
|
|
313
|
+
|
|
314
|
+
# Convert to Theta/Phi
|
|
315
|
+
Xc, Yc, Zc = smooth_verts[:,0], smooth_verts[:,1], smooth_verts[:,2]
|
|
316
|
+
Theta = np.arccos(np.clip(Zc, -1, 1))
|
|
317
|
+
Phi = np.arctan2(Yc, Xc) % (2*np.pi)
|
|
318
|
+
|
|
319
|
+
return Theta, Phi
|
|
320
|
+
|
|
321
|
+
def fit_coeffs_least_squares(verts, theta, phi, lmax):
|
|
322
|
+
"""
|
|
323
|
+
Solves Y * C = V for C using Least Squares.
|
|
324
|
+
Y is the matrix of spherical harmonics evaluated at (theta, phi).
|
|
325
|
+
V is the vertex coordinates (x, y, or z).
|
|
326
|
+
"""
|
|
327
|
+
# 1. Construct the Design Matrix Y (N_verts x N_coeffs)
|
|
328
|
+
# Total coefficients = (lmax + 1)^2
|
|
329
|
+
n_coeffs = (lmax + 1) ** 2
|
|
330
|
+
n_verts = len(verts)
|
|
331
|
+
|
|
332
|
+
# We construct Y column by column.
|
|
333
|
+
# Mapping convention: pyshtools usually uses complex or real SH.
|
|
334
|
+
# For simplicity in reconstruction, we use real SH from scipy or manual.
|
|
335
|
+
# BUT, to keep compatible with your pyshtools reconstruction, we should use pysh layouts.
|
|
336
|
+
# However, building the pysh matrix manually is complex.
|
|
337
|
+
# FAST PATH: We simply use standard Real Spherical Harmonics here.
|
|
338
|
+
|
|
339
|
+
Y_matrix = np.zeros((n_verts, n_coeffs))
|
|
340
|
+
|
|
341
|
+
col_idx = 0
|
|
342
|
+
# Loop l from 0 to lmax
|
|
343
|
+
for l in range(lmax + 1):
|
|
344
|
+
# Loop m from -l to l
|
|
345
|
+
for m in range(-l, l + 1):
|
|
346
|
+
# Evaluate Real Spherical Harmonic
|
|
347
|
+
# Scipy returns complex; we convert to Real Ylm
|
|
348
|
+
harm = sph_harm(m, l, phi, theta)
|
|
349
|
+
|
|
350
|
+
if m > 0:
|
|
351
|
+
y_real = np.sqrt(2) * np.real(harm)
|
|
352
|
+
elif m < 0:
|
|
353
|
+
y_real = np.sqrt(2) * np.imag(harm)
|
|
354
|
+
else: # m == 0
|
|
355
|
+
y_real = np.real(harm)
|
|
356
|
+
|
|
357
|
+
Y_matrix[:, col_idx] = y_real
|
|
358
|
+
col_idx += 1
|
|
359
|
+
|
|
360
|
+
# 2. Solve Least Squares: Y * c = x
|
|
361
|
+
# We solve for X, Y, Z simultaneously
|
|
362
|
+
# coeffs shape: (N_coeffs, 3)
|
|
363
|
+
coeffs, residuals, rank, s = np.linalg.lstsq(Y_matrix, verts, rcond=None)
|
|
364
|
+
|
|
365
|
+
return coeffs
|
|
366
|
+
|
|
367
|
+
def reconstruct_from_ls_coeffs(coeffs, lmax, vol_shape, centroid, max_extent, density=4):
|
|
368
|
+
"""
|
|
369
|
+
Reconstructs volume from the Least Squares coefficients.
|
|
370
|
+
"""
|
|
371
|
+
# 1. Create dense reconstruction grid
|
|
372
|
+
recon_lmax = lmax * density
|
|
373
|
+
theta_grid = np.linspace(0, np.pi, recon_lmax)
|
|
374
|
+
phi_grid = np.linspace(0, 2*np.pi, recon_lmax * 2)
|
|
375
|
+
T, P = np.meshgrid(theta_grid, phi_grid, indexing='ij')
|
|
376
|
+
|
|
377
|
+
# Flatten for matrix multiplication
|
|
378
|
+
T_flat = T.flatten()
|
|
379
|
+
P_flat = P.flatten()
|
|
380
|
+
n_points = len(T_flat)
|
|
381
|
+
n_coeffs = (lmax + 1) ** 2
|
|
382
|
+
|
|
383
|
+
# 2. Build Reconstruction Matrix (reuse basis function logic)
|
|
384
|
+
Y_recon = np.zeros((n_points, n_coeffs))
|
|
385
|
+
col_idx = 0
|
|
386
|
+
for l in range(lmax + 1):
|
|
387
|
+
for m in range(-l, l + 1):
|
|
388
|
+
harm = sph_harm(m, l, P_flat, T_flat) # Note: scipy takes (m, l, phi, theta)
|
|
389
|
+
if m > 0: y_r = np.sqrt(2) * np.real(harm)
|
|
390
|
+
elif m < 0: y_r = np.sqrt(2) * np.imag(harm)
|
|
391
|
+
else: y_r = np.real(harm)
|
|
392
|
+
Y_recon[:, col_idx] = y_r
|
|
393
|
+
col_idx += 1
|
|
394
|
+
|
|
395
|
+
# 3. Compute Coordinates: X = Y_recon * c_x
|
|
396
|
+
# coeffs is (N_coeffs, 3) -> X, Y, Z
|
|
397
|
+
coords = Y_recon @ coeffs
|
|
398
|
+
|
|
399
|
+
# Denormalize
|
|
400
|
+
coords = (coords * max_extent) + centroid
|
|
401
|
+
|
|
402
|
+
# 4. Voxelize
|
|
403
|
+
ix = np.round(coords[:, 0]).astype(int)
|
|
404
|
+
iy = np.round(coords[:, 1]).astype(int)
|
|
405
|
+
iz = np.round(coords[:, 2]).astype(int)
|
|
406
|
+
|
|
407
|
+
nx, ny, nz = vol_shape
|
|
408
|
+
valid = (ix >= 0) & (ix < nx) & (iy >= 0) & (iy < ny) & (iz >= 0) & (iz < nz)
|
|
409
|
+
|
|
410
|
+
vol = np.zeros(vol_shape, dtype=np.uint8)
|
|
411
|
+
vol[ix[valid], iy[valid], iz[valid]] = 1
|
|
412
|
+
|
|
413
|
+
# Close gaps and fill
|
|
414
|
+
vol = binary_dilation(vol, iterations=2)
|
|
415
|
+
vol = binary_fill_holes(vol).astype(np.uint8)
|
|
416
|
+
|
|
417
|
+
return vol
|
|
418
|
+
|
|
419
|
+
def reconstruct_shape_vsh(mask_orig, lmax=20):
|
|
420
|
+
"""
|
|
421
|
+
Wrapper using Least Squares fitting to preserve concavities.
|
|
422
|
+
"""
|
|
423
|
+
# 1. Mesh Extraction
|
|
424
|
+
vol_float = gaussian_filter(mask_orig.astype(float), sigma=1.0)
|
|
425
|
+
verts, faces, _, _ = marching_cubes(vol_float, level=0.5)
|
|
426
|
+
|
|
427
|
+
centroid = verts.mean(axis=0)
|
|
428
|
+
verts_centered = verts - centroid
|
|
429
|
+
max_extent = np.max(np.linalg.norm(verts_centered, axis=1))
|
|
430
|
+
verts_norm = verts_centered / max_extent
|
|
431
|
+
|
|
432
|
+
# 2. Parameterization (Inflation)
|
|
433
|
+
# This ensures unique angles for the hollow area
|
|
434
|
+
theta, phi = get_inflated_mapping(verts_norm, faces, iterations=100)
|
|
435
|
+
|
|
436
|
+
# 3. Least Squares Fitting
|
|
437
|
+
# Fits the basis functions directly to the vertex positions.
|
|
438
|
+
# This captures the "dent" much better than interpolation.
|
|
439
|
+
coeffs = fit_coeffs_least_squares(verts_norm, theta, phi, lmax)
|
|
440
|
+
|
|
441
|
+
# 4. Reconstruction
|
|
442
|
+
mask_rec = reconstruct_from_ls_coeffs(coeffs, lmax, mask_orig.shape, centroid, max_extent)
|
|
443
|
+
|
|
444
|
+
return mask_rec
|