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/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