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.
@@ -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)