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/normalize.py
ADDED
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import vreg
|
|
3
|
+
from scipy.ndimage import rotate
|
|
4
|
+
from tqdm import tqdm
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from miblab_ssa.metrics import dice_coefficient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def rotate_to_ref(
|
|
11
|
+
mask_ref: np.ndarray,
|
|
12
|
+
mask_to_rotate: np.ndarray,
|
|
13
|
+
axis: int = 2, #rotation in Z axis, (X,Y,Z)
|
|
14
|
+
angle_range=(0.0, 360.0),
|
|
15
|
+
angle_step: float = 1.0,
|
|
16
|
+
verbose: int = 0,
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
Rotate a 3D mask around a selected axis and compute the Dice score
|
|
20
|
+
across all angles. By default returns only the maximum Dice score.
|
|
21
|
+
|
|
22
|
+
Parameters
|
|
23
|
+
----------
|
|
24
|
+
mask_ref : np.ndarray
|
|
25
|
+
Reference 3D binary mask (fixed).
|
|
26
|
+
mask_to_rotate : np.ndarray
|
|
27
|
+
Moving 3D binary mask to be rotated.
|
|
28
|
+
axis : int, default=2
|
|
29
|
+
Axis of rotation (0, 1, or 2).
|
|
30
|
+
angle_range : tuple, default=(0.0, 360.0)
|
|
31
|
+
Range of rotation angles.
|
|
32
|
+
angle_step : float, default=1.0
|
|
33
|
+
Step size in degrees.
|
|
34
|
+
verbose : int, default=0
|
|
35
|
+
If 0, no progress bar is shown. Any other value shows a progress bar.
|
|
36
|
+
|
|
37
|
+
Returns
|
|
38
|
+
-------
|
|
39
|
+
best_rotate : np.ndarray
|
|
40
|
+
Rotated mask
|
|
41
|
+
best_angle : float
|
|
42
|
+
Rotation angle
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
if mask_ref.shape != mask_to_rotate.shape:
|
|
46
|
+
raise ValueError("mask_ref and mask_to_rotate must have the same shape")
|
|
47
|
+
|
|
48
|
+
# Select rotation plane
|
|
49
|
+
if axis == 0:
|
|
50
|
+
rot_axes = (1, 2)
|
|
51
|
+
elif axis == 1:
|
|
52
|
+
rot_axes = (0, 2)
|
|
53
|
+
elif axis == 2:
|
|
54
|
+
rot_axes = (0, 1)
|
|
55
|
+
else:
|
|
56
|
+
raise ValueError("axis must be 0, 1, or 2")
|
|
57
|
+
|
|
58
|
+
start, end = angle_range
|
|
59
|
+
angles = np.arange(start, end, angle_step)
|
|
60
|
+
|
|
61
|
+
best_dice = -1.0
|
|
62
|
+
best_angle = None
|
|
63
|
+
best_rotated = None
|
|
64
|
+
|
|
65
|
+
for angle in tqdm(angles, desc='Finding rotation..', disable=verbose==0):
|
|
66
|
+
rotated = rotate(
|
|
67
|
+
mask_to_rotate,
|
|
68
|
+
angle=angle,
|
|
69
|
+
axes=rot_axes,
|
|
70
|
+
reshape=False,
|
|
71
|
+
order=0, # nearest-neighbor for binary masks
|
|
72
|
+
mode='constant',
|
|
73
|
+
cval=0.0
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
d = dice_coefficient(mask_ref, rotated)
|
|
77
|
+
|
|
78
|
+
if d > best_dice:
|
|
79
|
+
best_dice = d
|
|
80
|
+
best_angle = angle
|
|
81
|
+
best_rotated = rotated
|
|
82
|
+
|
|
83
|
+
# --- return logic ---
|
|
84
|
+
return best_rotated, best_angle
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def pca_affine(original_affine, centroid, eigvecs):
|
|
92
|
+
"""
|
|
93
|
+
Build a new affine aligned to PCA axes.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
original_affine (4x4): Input affine (voxel -> world).
|
|
97
|
+
centroid (3,): PCA centroid in world coords.
|
|
98
|
+
eigvecs (3x3): PCA eigenvectors, columns = axes in world.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
new_affine (4x4): Voxel -> PCA-aligned coords.
|
|
102
|
+
transform_world_to_pca (4x4): Extra transform applied in world space.
|
|
103
|
+
"""
|
|
104
|
+
# World -> PCA coords
|
|
105
|
+
R = eigvecs.T # rotation
|
|
106
|
+
T = -centroid # translation
|
|
107
|
+
|
|
108
|
+
# Build 4x4 homogeneous transform world->PCA
|
|
109
|
+
W2P = np.eye(4)
|
|
110
|
+
W2P[:3,:3] = R
|
|
111
|
+
W2P[:3,3] = R @ T
|
|
112
|
+
|
|
113
|
+
return W2P @ original_affine
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def second_vector_cone_sweep(mask, centroid, eigvec1, eigvec2, voxel_size, cone_angle=30, angle_step=1):
|
|
123
|
+
"""
|
|
124
|
+
Find a direction lying in the plane of eigvec1 & eigvec2 that points to
|
|
125
|
+
the half-cone with the least mass.
|
|
126
|
+
|
|
127
|
+
Robustness Fix: Uses vector averaging for the best candidates to avoid
|
|
128
|
+
cyclic discontinuity issues at 0/180 degrees.
|
|
129
|
+
"""
|
|
130
|
+
# 1. Normalize eigenvectors
|
|
131
|
+
eigvec1 = eigvec1 / np.linalg.norm(eigvec1)
|
|
132
|
+
eigvec2 = eigvec2 / np.linalg.norm(eigvec2)
|
|
133
|
+
|
|
134
|
+
# 2. Voxel coordinates in physical units, shifted to centroid
|
|
135
|
+
# Optimization: Pre-calculate norms once if memory allows, or keep as is.
|
|
136
|
+
coords = np.argwhere(mask > 0).astype(float) * voxel_size
|
|
137
|
+
coords -= centroid
|
|
138
|
+
|
|
139
|
+
if coords.shape[0] == 0:
|
|
140
|
+
return eigvec1
|
|
141
|
+
|
|
142
|
+
cos_cone = np.cos(np.deg2rad(cone_angle / 2))
|
|
143
|
+
|
|
144
|
+
candidates = [] # Store (mass, direction_vector)
|
|
145
|
+
|
|
146
|
+
# 3. Sweep candidate directions
|
|
147
|
+
# We sweep 0 to 180. The logic handles the orientation (d vs -d).
|
|
148
|
+
for angle in np.arange(0, 180, angle_step):
|
|
149
|
+
rad = np.deg2rad(angle)
|
|
150
|
+
d = np.cos(rad) * eigvec1 + np.sin(rad) * eigvec2
|
|
151
|
+
d /= np.linalg.norm(d)
|
|
152
|
+
|
|
153
|
+
# Optimization: fast dot product
|
|
154
|
+
# Ideally, pre-normalized unit_coords would be faster if memory permits
|
|
155
|
+
dots = coords @ d
|
|
156
|
+
# We need normalized dot product for the angle check
|
|
157
|
+
# But for 'left/right' mass, we just need the sign of the projection
|
|
158
|
+
|
|
159
|
+
# Calculate angle check efficiently
|
|
160
|
+
coord_norms = np.linalg.norm(coords, axis=1)
|
|
161
|
+
valid = coord_norms > 0
|
|
162
|
+
|
|
163
|
+
# Cosine similarity
|
|
164
|
+
cos_sim = np.zeros_like(dots)
|
|
165
|
+
cos_sim[valid] = np.abs(dots[valid]) / coord_norms[valid] # abs for double cone
|
|
166
|
+
|
|
167
|
+
# Identify voxels in the double-cone
|
|
168
|
+
mask_in_cone = cos_sim >= cos_cone
|
|
169
|
+
|
|
170
|
+
if not np.any(mask_in_cone):
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
# Project selected voxels onto the axis 'd' to check side (Left vs Right)
|
|
174
|
+
proj = dots[mask_in_cone]
|
|
175
|
+
|
|
176
|
+
left_mass = np.sum(proj < 0)
|
|
177
|
+
right_mass = np.sum(proj >= 0)
|
|
178
|
+
|
|
179
|
+
current_min_mass = min(left_mass, right_mass)
|
|
180
|
+
|
|
181
|
+
# Determine correct orientation for this specific axis
|
|
182
|
+
# If left (negative proj) is lighter, we want to point NEGATIVE.
|
|
183
|
+
d_oriented = -d if left_mass < right_mass else d
|
|
184
|
+
|
|
185
|
+
candidates.append((current_min_mass, d_oriented))
|
|
186
|
+
|
|
187
|
+
if not candidates:
|
|
188
|
+
return eigvec1
|
|
189
|
+
|
|
190
|
+
# 4. Find global minimum mass
|
|
191
|
+
min_mass = min(c[0] for c in candidates)
|
|
192
|
+
|
|
193
|
+
# 5. Collect ALL vectors that achieved this minimum mass
|
|
194
|
+
# This handles the "plateau" of equal values
|
|
195
|
+
best_vectors = [c[1] for c in candidates if c[0] == min_mass]
|
|
196
|
+
|
|
197
|
+
# 6. Compute the Mean Vector
|
|
198
|
+
# Instead of sorting angles, we average the 3D vectors.
|
|
199
|
+
# This correctly interpolates across the 0/180 degree wrap-around.
|
|
200
|
+
mean_vector = np.mean(best_vectors, axis=0)
|
|
201
|
+
|
|
202
|
+
# Handle rare cancellation case (e.g., vectors pointing exactly opposite)
|
|
203
|
+
if np.linalg.norm(mean_vector) < 1e-6:
|
|
204
|
+
return best_vectors[0] # Fallback to first candidate
|
|
205
|
+
|
|
206
|
+
best_vec = mean_vector / np.linalg.norm(mean_vector)
|
|
207
|
+
|
|
208
|
+
return best_vec
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def orient_vector_with_cone(mask, centroid, vector, voxel_size, cone_angle=30):
|
|
213
|
+
"""
|
|
214
|
+
Orients the given vector so that it points toward the 'lighter' end of the object,
|
|
215
|
+
considering only the mass contained within a cone aligned with the vector.
|
|
216
|
+
|
|
217
|
+
Parameters:
|
|
218
|
+
-----------
|
|
219
|
+
mask : np.ndarray
|
|
220
|
+
3D binary mask.
|
|
221
|
+
centroid : np.ndarray
|
|
222
|
+
(3,) physical centroid.
|
|
223
|
+
vector : np.ndarray
|
|
224
|
+
(3,) unit vector (e.g., the principal axis) to orient.
|
|
225
|
+
voxel_size : tuple/list
|
|
226
|
+
(3,) physical voxel dimensions.
|
|
227
|
+
cone_angle : float
|
|
228
|
+
The aperture of the cone in degrees.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
--------
|
|
232
|
+
np.ndarray
|
|
233
|
+
The oriented unit vector pointing to the lighter side.
|
|
234
|
+
float
|
|
235
|
+
The mass the vector is pointing to
|
|
236
|
+
"""
|
|
237
|
+
# 1. Normalize the input vector
|
|
238
|
+
vec = vector / np.linalg.norm(vector)
|
|
239
|
+
|
|
240
|
+
# 2. Get centered physical coordinates
|
|
241
|
+
indices = np.argwhere(mask)
|
|
242
|
+
if indices.shape[0] == 0:
|
|
243
|
+
return vec
|
|
244
|
+
|
|
245
|
+
points = indices * np.array(voxel_size)
|
|
246
|
+
points_centered = points - centroid
|
|
247
|
+
|
|
248
|
+
# 3. Calculate norms (distances from centroid)
|
|
249
|
+
points_norm = np.linalg.norm(points_centered, axis=1)
|
|
250
|
+
|
|
251
|
+
# Avoid division by zero for the voxel exactly at the centroid
|
|
252
|
+
valid_points_mask = points_norm > 1e-9
|
|
253
|
+
|
|
254
|
+
if not np.any(valid_points_mask):
|
|
255
|
+
return vec
|
|
256
|
+
|
|
257
|
+
# Filter arrays to exclude the center point
|
|
258
|
+
p_centered = points_centered[valid_points_mask]
|
|
259
|
+
p_norm = points_norm[valid_points_mask]
|
|
260
|
+
|
|
261
|
+
# 4. Calculate Dot Products and Angles
|
|
262
|
+
# Dot product of points with the axis vector
|
|
263
|
+
dots = p_centered @ vec
|
|
264
|
+
|
|
265
|
+
# Cosine of the angle between point and vector: (A . B) / (|A| |B|)
|
|
266
|
+
# Since B is unit vector: (A . B) / |A|
|
|
267
|
+
cos_angles = dots / p_norm
|
|
268
|
+
|
|
269
|
+
# 5. Define the Cone Threshold
|
|
270
|
+
# We use abs(cos_angles) to catch points in BOTH the forward cone
|
|
271
|
+
# and the backward cone (the "double cone").
|
|
272
|
+
threshold = np.cos(np.deg2rad(cone_angle / 2))
|
|
273
|
+
in_cone_mask = np.abs(cos_angles) >= threshold
|
|
274
|
+
|
|
275
|
+
# If the cone is too narrow and catches nothing, return original
|
|
276
|
+
if not np.any(in_cone_mask):
|
|
277
|
+
return vec
|
|
278
|
+
|
|
279
|
+
# 6. Count Mass in Forward vs Backward Cones
|
|
280
|
+
# We look at the sign of the dot product for points inside the cone.
|
|
281
|
+
# dot > 0 : Forward Cone (Direction of 'vec')
|
|
282
|
+
# dot < 0 : Backward Cone (Direction of '-vec')
|
|
283
|
+
valid_dots = dots[in_cone_mask]
|
|
284
|
+
|
|
285
|
+
mass_forward = np.sum(valid_dots > 0)
|
|
286
|
+
mass_backward = np.sum(valid_dots < 0)
|
|
287
|
+
skew = np.abs(mass_forward - mass_backward) / (mass_forward + mass_backward)
|
|
288
|
+
|
|
289
|
+
# 7. Orientation Logic
|
|
290
|
+
# We want to point to the LEAST mass.
|
|
291
|
+
if mass_forward > mass_backward:
|
|
292
|
+
# The direction we are currently pointing to is heavier. Flip it.
|
|
293
|
+
return -vec, skew
|
|
294
|
+
|
|
295
|
+
# Otherwise, current direction is lighter (or equal). Keep it.
|
|
296
|
+
return vec, skew
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def kidney_canonical_axes(mask, centroid, eigvecs, voxel_size):
|
|
300
|
+
"""
|
|
301
|
+
Returns orthogonal vectors in a canonical reference frame optimized
|
|
302
|
+
for normalization of kidney shapes.
|
|
303
|
+
|
|
304
|
+
- The z-axis (kidney longitudinal) is the principal eigenvector oriented fro head to foot
|
|
305
|
+
- The x-axis (kidney sagittal) is perpendicular to the longitudinal axis, through the
|
|
306
|
+
centroid and pointing in the direction with least mass in a small cone around the axis
|
|
307
|
+
- The y-axis (kidney transverse) is derived from the first two so as to make a right-handed
|
|
308
|
+
X,Y,Z Cartesian reference frame
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
# Find eigenvector along FH
|
|
312
|
+
foot_to_head = [0,-1,0]
|
|
313
|
+
longitudinal_axis = eigvecs[:, 0].copy()
|
|
314
|
+
if np.dot(longitudinal_axis, foot_to_head) < 0:
|
|
315
|
+
longitudinal_axis *= -1
|
|
316
|
+
|
|
317
|
+
# Decide direction of the secondary axes
|
|
318
|
+
# sagittal_axis = second_vector_cone_sweep(mask, centroid, eigvecs[:,1], eigvecs[:,2], voxel_size)
|
|
319
|
+
axis1, skew1 = orient_vector_with_cone(mask, centroid, eigvecs[:,1], voxel_size, cone_angle=90)
|
|
320
|
+
axis2, skew2 = orient_vector_with_cone(mask, centroid, eigvecs[:,2], voxel_size, cone_angle=90)
|
|
321
|
+
if skew1 > skew2:
|
|
322
|
+
sagittal_axis = axis1
|
|
323
|
+
else:
|
|
324
|
+
sagittal_axis = axis2
|
|
325
|
+
|
|
326
|
+
# Compute transversal axis
|
|
327
|
+
transversal_axis = np.cross(longitudinal_axis, sagittal_axis)
|
|
328
|
+
|
|
329
|
+
# Build new eigenvectors
|
|
330
|
+
canonical_axes = np.zeros((3,3))
|
|
331
|
+
canonical_axes[:,0] = sagittal_axis
|
|
332
|
+
canonical_axes[:,1] = transversal_axis
|
|
333
|
+
canonical_axes[:,2] = longitudinal_axis
|
|
334
|
+
|
|
335
|
+
return canonical_axes
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def inertia_principal_axes(mask, voxel_size=(1.0, 1.0, 1.0)):
|
|
339
|
+
"""
|
|
340
|
+
Calculates the physical centroid and normalized, right-handed principal axes
|
|
341
|
+
of a 3D binary mask.
|
|
342
|
+
|
|
343
|
+
Parameters:
|
|
344
|
+
-----------
|
|
345
|
+
mask : np.ndarray
|
|
346
|
+
A 3D binary array (boolean or 0/1) where True indicates the object.
|
|
347
|
+
voxel_size : tuple or list of 3 floats
|
|
348
|
+
The voxel size in physical units (e.g., [dz, dy, dx] or [dx, dy, dz]).
|
|
349
|
+
Must match the order of dimensions in the mask array.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
--------
|
|
353
|
+
centroid : np.ndarray
|
|
354
|
+
A (3,) array containing the center of mass in physical coordinates.
|
|
355
|
+
axes : np.ndarray
|
|
356
|
+
A (3, 3) matrix where each column represents a principal axis vector.
|
|
357
|
+
- Column 0: Major axis (largest variance)
|
|
358
|
+
- Column 1: Intermediate axis
|
|
359
|
+
- Column 2: Minor axis (smallest variance)
|
|
360
|
+
The system is guaranteed to be right-handed and normalized.
|
|
361
|
+
"""
|
|
362
|
+
# 1. Get indices of the non-zero voxels (N, 3)
|
|
363
|
+
indices = np.argwhere(mask)
|
|
364
|
+
|
|
365
|
+
if indices.shape[0] < 3:
|
|
366
|
+
raise ValueError("Mask must contain at least 3 points to define 3D axes.")
|
|
367
|
+
|
|
368
|
+
# 2. Convert to physical coordinates
|
|
369
|
+
# We multiply the indices by the voxel_size to handle anisotropic voxels correctly
|
|
370
|
+
points = indices * np.array(voxel_size)
|
|
371
|
+
|
|
372
|
+
# 3. Calculate Centroid
|
|
373
|
+
centroid = np.mean(points, axis=0)
|
|
374
|
+
|
|
375
|
+
# 4. Center the points (subtract centroid)
|
|
376
|
+
points_centered = points - centroid
|
|
377
|
+
|
|
378
|
+
# 5. Compute Covariance Matrix
|
|
379
|
+
# rowvar=False means each column is a variable (x, y, z), each row is an observation
|
|
380
|
+
cov_matrix = np.cov(points_centered, rowvar=False)
|
|
381
|
+
|
|
382
|
+
# 6. Eigendecomposition
|
|
383
|
+
# eigh is optimized for symmetric matrices (like covariance matrices)
|
|
384
|
+
eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
|
|
385
|
+
|
|
386
|
+
# 7. Sort axes by Eigenvalues (descending order)
|
|
387
|
+
# eigh returns eigenvalues in ascending order, so we reverse them
|
|
388
|
+
# We want: Index 0 = Major Axis (Largest eigenvalue)
|
|
389
|
+
sort_indices = np.argsort(eigenvalues)[::-1]
|
|
390
|
+
|
|
391
|
+
sorted_eigenvalues = eigenvalues[sort_indices]
|
|
392
|
+
sorted_vectors = eigenvectors[:, sort_indices]
|
|
393
|
+
|
|
394
|
+
# 8. Enforce Right-Handed Coordinate System
|
|
395
|
+
# Calculate the determinant. If -1, the system is left-handed (reflection).
|
|
396
|
+
# We flip the minor axis (last column) to fix this.
|
|
397
|
+
if np.linalg.det(sorted_vectors) < 0:
|
|
398
|
+
sorted_vectors[:, -1] *= -1
|
|
399
|
+
|
|
400
|
+
return centroid, sorted_vectors, sorted_eigenvalues
|
|
401
|
+
|
|
402
|
+
# This works for any image but needed for masks
|
|
403
|
+
def _inertia_principal_axes(volume, voxel_size=(1.0,1.0,1.0), eps=1e-12):
|
|
404
|
+
"""
|
|
405
|
+
Compute intensity-weighted center of mass and inertia (second-moment) tensor,
|
|
406
|
+
and return principal axes (eigenvectors) and eigenvalues.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
volume (ndarray): 3D numpy array (x,y,z) of intensities (non-negative typically).
|
|
410
|
+
voxel_size (tuple[float] or np.ndarray): physical voxel spacing (dx, dy, dz).
|
|
411
|
+
eps (float): small value to avoid division by zero.
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
centroid_phys (ndarray shape (3,)): intensity-weighted centroid in physical coordinates (x,y,z).
|
|
415
|
+
eigvals (ndarray shape (3,)): eigenvalues (descending).
|
|
416
|
+
eigvecs (ndarray shape (3,3)): eigenvectors as columns; eigvecs[:,i] is eigenvector for eigvals[i].
|
|
417
|
+
inertia (ndarray shape (3,3)): the computed inertia / second-moment matrix used.
|
|
418
|
+
"""
|
|
419
|
+
voxel_size = np.asarray(voxel_size, dtype=float)
|
|
420
|
+
if voxel_size.size != 3:
|
|
421
|
+
raise ValueError("voxel_size must be length-3 (dx,dy,dz)")
|
|
422
|
+
|
|
423
|
+
# Get indices and intensities for non-zero (or all) voxels
|
|
424
|
+
# Using all voxels is fine; we can optionally ignore zeros if desired by the user upstream.
|
|
425
|
+
coords_idx = np.argwhere(volume != 0) # indices in (x,y,z) order
|
|
426
|
+
if coords_idx.size == 0:
|
|
427
|
+
raise ValueError("Volume contains no nonzero voxels.")
|
|
428
|
+
|
|
429
|
+
intensities = volume[coords_idx[:,0], coords_idx[:,1], coords_idx[:,2]].astype(float)
|
|
430
|
+
total_mass = intensities.sum()
|
|
431
|
+
if total_mass <= eps:
|
|
432
|
+
raise ValueError("Total intensity (mass) is zero or too small.")
|
|
433
|
+
|
|
434
|
+
coords_phys = coords_idx.astype(float) * voxel_size # shape (N,3) in (x,y,z)
|
|
435
|
+
|
|
436
|
+
# Intensity-weighted centroid (center of mass)
|
|
437
|
+
centroid_phys = (coords_phys * intensities[:,None]).sum(axis=0) / total_mass
|
|
438
|
+
|
|
439
|
+
# Centralized coordinates
|
|
440
|
+
delta = coords_phys - centroid_phys # (N,3)
|
|
441
|
+
|
|
442
|
+
# Compute the 3x3 inertia / second moment matrix (covariance-like, but with mass)
|
|
443
|
+
# We compute the second central moments: M = sum_i w_i * (delta_i ⊗ delta_i)
|
|
444
|
+
# This is essentially the (unnormalized) weighted covariance multiplied by total_mass.
|
|
445
|
+
# Optionally normalize by total_mass to get weighted covariance; eigenvectors same either way.
|
|
446
|
+
M = np.einsum('ni,nj->ij', delta * intensities[:,None], delta) # shape (3,3)
|
|
447
|
+
|
|
448
|
+
# If you prefer the covariance form: M_cov = M / total_mass
|
|
449
|
+
# For axis extraction eigenvectors are identical up to eigenvalue scaling.
|
|
450
|
+
# We'll compute eigen-decomposition of M (symmetric)
|
|
451
|
+
# Use np.linalg.eigh (for symmetric matrices)
|
|
452
|
+
eigvals_raw, eigvecs_raw = np.linalg.eigh(M) # ascending order
|
|
453
|
+
|
|
454
|
+
# Sort descending
|
|
455
|
+
idx = np.argsort(eigvals_raw)[::-1]
|
|
456
|
+
eigvals = eigvals_raw[idx]
|
|
457
|
+
eigvecs = eigvecs_raw[:, idx]
|
|
458
|
+
|
|
459
|
+
# Return inertia matrix as used (unnormalized). If user wants covariance: M / total_mass
|
|
460
|
+
return centroid_phys, eigvecs, eigvals
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def normalize_kidney_mask(mask, voxel_size, side, ref=None, verbose=1):
|
|
464
|
+
"""
|
|
465
|
+
Normalize a 3D binary mask using mesh-based PCA alignment and scaling.
|
|
466
|
+
Centers the mesh in the middle of the volume grid.
|
|
467
|
+
"""
|
|
468
|
+
# TODO: return rotation angles versus patient reference frame (obliqueness).
|
|
469
|
+
|
|
470
|
+
if side not in ['left', 'right']:
|
|
471
|
+
raise ValueError(
|
|
472
|
+
f"The side argument must be either 'left' or 'right'. "
|
|
473
|
+
f"You have entered {side}. "
|
|
474
|
+
)
|
|
475
|
+
if np.size(voxel_size) != 3:
|
|
476
|
+
raise ValueError("Voxel size must have 3 elements.")
|
|
477
|
+
if np.ndim(mask) != 3:
|
|
478
|
+
raise ValueError("mask must be 3D. ")
|
|
479
|
+
|
|
480
|
+
# voxel size in mm
|
|
481
|
+
# target_volume in mm3
|
|
482
|
+
target_spacing = 1.0
|
|
483
|
+
target_volume = 1e6
|
|
484
|
+
|
|
485
|
+
# Optional mirroring
|
|
486
|
+
if side == 'left':
|
|
487
|
+
mask = np.flip(mask, 0)
|
|
488
|
+
|
|
489
|
+
# Build volume with identity affine and a corner on the origina
|
|
490
|
+
volume = vreg.volume(mask.astype(float), spacing=voxel_size)
|
|
491
|
+
|
|
492
|
+
# Align principal axes to reference frame
|
|
493
|
+
centroid, eigvecs, eigvals = inertia_principal_axes(mask, voxel_size)
|
|
494
|
+
canonical_axes = kidney_canonical_axes(mask, centroid, eigvecs, voxel_size)
|
|
495
|
+
canonical_affine = pca_affine(volume.affine, centroid, canonical_axes)
|
|
496
|
+
volume.set_affine(canonical_affine)
|
|
497
|
+
|
|
498
|
+
# Scale to target volume
|
|
499
|
+
voxel_volume = np.prod(voxel_size)
|
|
500
|
+
current_volume = mask.sum() * voxel_volume
|
|
501
|
+
scale = (target_volume / current_volume) ** (1/3)
|
|
502
|
+
volume = volume.stretch(scale)
|
|
503
|
+
|
|
504
|
+
# Resample on standard isotropic volume
|
|
505
|
+
target_length = 3 * (target_volume ** (1/3)) # length in mm
|
|
506
|
+
target_dim = np.ceil(1 + target_length / target_spacing).astype(int) # length in mm
|
|
507
|
+
target_shape = 3 * [target_dim]
|
|
508
|
+
target_affine = np.diag(3 * [target_spacing] + [1.0])
|
|
509
|
+
target_affine[:3,3] = 3 * [- target_spacing * (target_dim - 1) / 2]
|
|
510
|
+
volume = volume.slice_like((target_shape, target_affine))
|
|
511
|
+
|
|
512
|
+
# Convert back to mask
|
|
513
|
+
mask_norm = volume.values > 0.5
|
|
514
|
+
|
|
515
|
+
params = { # Derive biomarkers on angulation vs ref axes
|
|
516
|
+
"centroid": centroid,
|
|
517
|
+
"eigvecs": eigvecs,
|
|
518
|
+
"eigvals": eigvals,
|
|
519
|
+
"scale": scale,
|
|
520
|
+
"canonical_axes": canonical_axes,
|
|
521
|
+
"canonical_affine": canonical_affine,
|
|
522
|
+
"scale": scale,
|
|
523
|
+
"output_affine": target_affine,
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
# If a reference is provided, rotate to match
|
|
527
|
+
if ref is not None:
|
|
528
|
+
mask_norm, ref_rotation_angle = rotate_to_ref(ref, mask_norm, angle_step=5, verbose=verbose)
|
|
529
|
+
params["ref_rotation"] = ref_rotation_angle
|
|
530
|
+
|
|
531
|
+
return mask_norm, params
|
|
532
|
+
|
miblab_ssa/pca.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from sklearn.decomposition import PCA
|
|
2
|
+
from sklearn.cluster import KMeans
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run_pca(features, n_components=10):
|
|
7
|
+
"""
|
|
8
|
+
Run PCA on feature matrix.
|
|
9
|
+
Returns reduced features and fitted PCA object.
|
|
10
|
+
"""
|
|
11
|
+
pca = PCA(n_components=n_components)
|
|
12
|
+
reduced = pca.fit_transform(features)
|
|
13
|
+
return reduced, pca
|
|
14
|
+
|
|
15
|
+
def save_pca_model(feature_file, pca_file, n_components=None):
|
|
16
|
+
"""
|
|
17
|
+
Saves the essential math attributes of a fitted PCA object.
|
|
18
|
+
"""
|
|
19
|
+
features = np.load(feature_file)['features']
|
|
20
|
+
pca = PCA(n_components=n_components)
|
|
21
|
+
pca.fit(features)
|
|
22
|
+
np.savez(
|
|
23
|
+
pca_file,
|
|
24
|
+
mean=pca.mean_,
|
|
25
|
+
components=pca.components_,
|
|
26
|
+
variance=pca.explained_variance_,
|
|
27
|
+
variance_ratio=pca.explained_variance_ratio_,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def decompose_new_point(new_feature_vector, model_path="pca_kidney_model.npz"):
|
|
31
|
+
# 1. Load the matrices
|
|
32
|
+
data = np.load(model_path)
|
|
33
|
+
mean_vec = data['mean'] # Shape: (n_features,)
|
|
34
|
+
components = data['components'] # Shape: (n_modes, n_features)
|
|
35
|
+
variance = data['variance'] # Shape: (n_modes,)
|
|
36
|
+
|
|
37
|
+
# 2. Pre-processing: Center the data
|
|
38
|
+
# We subtract the "Average Kidney" from our new sample
|
|
39
|
+
centered_vector = new_feature_vector - mean_vec
|
|
40
|
+
|
|
41
|
+
# 3. Projection (The "Transform" step)
|
|
42
|
+
# Dot product with the transposed components matrix
|
|
43
|
+
# Shape: (n_features,) dot (n_features, n_modes) -> (n_modes,)
|
|
44
|
+
scores = np.dot(centered_vector, components.T)
|
|
45
|
+
|
|
46
|
+
# 4. Calculate Sigma (Z-Score)
|
|
47
|
+
# How many standard deviations is this from the mean?
|
|
48
|
+
sigmas = scores / np.sqrt(variance)
|
|
49
|
+
|
|
50
|
+
return scores, sigmas
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def reconstruct_from_scores(scores, model_path="pca_kidney_model.npz"):
|
|
54
|
+
# 1. Load Model
|
|
55
|
+
data = np.load(model_path)
|
|
56
|
+
mean_vec = data['mean']
|
|
57
|
+
components = data['components']
|
|
58
|
+
|
|
59
|
+
# 2. Reverse Projection
|
|
60
|
+
# Scores (1, n_modes) dot Components (n_modes, n_features) -> (1, n_features)
|
|
61
|
+
# This rebuilds the shape variation from the origin
|
|
62
|
+
shape_variation = np.dot(scores, components)
|
|
63
|
+
|
|
64
|
+
# 3. Add Mean
|
|
65
|
+
# Move the shape from the origin back to the "Average Kidney" location
|
|
66
|
+
reconstructed_vector = shape_variation + mean_vec
|
|
67
|
+
|
|
68
|
+
return reconstructed_vector
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def classify_shapes(features_reduced, n_clusters=2, random_state=0):
|
|
72
|
+
"""
|
|
73
|
+
Cluster shapes.
|
|
74
|
+
"""
|
|
75
|
+
kmeans = KMeans(n_clusters=n_clusters, random_state=random_state, n_init=10)
|
|
76
|
+
labels = kmeans.fit_predict(features_reduced)
|
|
77
|
+
return labels, kmeans
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- Usage Example ---
|
|
81
|
+
# 1. Decompose
|
|
82
|
+
# scores, _ = decompose_new_point(original_vector)
|
|
83
|
+
|
|
84
|
+
# 2. Reconstruct
|
|
85
|
+
# recon_vector = reconstruct_from_scores(scores)
|
|
86
|
+
|
|
87
|
+
# 3. Visualize (using the mesh function from before)
|
|
88
|
+
# mesh = mesh_from_features(recon_vector, original_shape=(300,300,300), grid_size=48)
|
|
89
|
+
# mesh.show()
|
|
90
|
+
|
|
91
|
+
# --- Example Usage ---
|
|
92
|
+
# new_vector = features_from_mask(new_mask, grid_size=64)
|
|
93
|
+
# scores, sigmas = decompose_new_point(new_vector)
|
|
94
|
+
|
|
95
|
+
# print(f"PC1 Score: {scores[0]:.4f} ({sigmas[0]:.2f} Sigmas)")
|
|
96
|
+
|
|
97
|
+
# Usage:
|
|
98
|
+
# save_pca_model(pca)
|