miblab-plot 0.0.1__py3-none-any.whl → 0.0.3__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_plot/__init__.py CHANGED
@@ -1 +1,6 @@
1
- from miblab_plot.image_3d import volume_to_mosaic, mosaic_overlay
1
+ from .image_3d import (
2
+ volume_to_mosaic,
3
+ mosaic_overlay,
4
+ mosaic_checkerboard,
5
+ )
6
+ from . import mp4, pvplot, gui
miblab_plot/gui.py ADDED
@@ -0,0 +1,652 @@
1
+ import numpy as np
2
+ import pyvista as pv
3
+ from skimage import measure
4
+ #from pyvistaqt import BackgroundPlotter
5
+
6
+
7
+ def rotation_vector_to_matrix(rot_vec):
8
+ """Convert a rotation vector (axis-angle) to a 3x3 rotation matrix using Rodrigues' formula."""
9
+ theta = np.linalg.norm(rot_vec)
10
+ if theta < 1e-8:
11
+ return np.eye(3)
12
+ k = rot_vec / theta
13
+ K = np.array([[ 0, -k[2], k[1]],
14
+ [ k[2], 0, -k[0]],
15
+ [-k[1], k[0], 0]])
16
+ R = np.eye(3) + np.sin(theta) * K + (1 - np.cos(theta)) * (K @ K)
17
+ return R
18
+
19
+
20
+ def ellipsoid_mask(shape, voxel_sizes=(1.0,1.0,1.0), center=(0,0,0), radii=(1,1,1), rot_vec=None):
21
+ """
22
+ Generate a 3D mask array with a rotated ellipsoid.
23
+
24
+ Parameters
25
+ ----------
26
+ shape : tuple of int
27
+ Shape of the 3D array (z, y, x).
28
+ voxel_sizes : tuple of float
29
+ Physical voxel sizes (dz, dy, dx).
30
+ center : tuple of float
31
+ Center of the ellipsoid in physical units, with (0,0,0) at the **middle of the volume**.
32
+ radii : tuple of float
33
+ Radii of the ellipsoid in physical units.
34
+ rot_vec : array-like of shape (3,), optional
35
+ Rotation vector. Magnitude = angle in radians, direction = rotation axis.
36
+
37
+ Returns
38
+ -------
39
+ mask : np.ndarray of bool
40
+ Boolean 3D mask with the ellipsoid.
41
+ """
42
+ dz, dy, dx = voxel_sizes
43
+ zdim, ydim, xdim = shape
44
+
45
+ # Rotation matrix
46
+ if rot_vec is None:
47
+ rotation = np.eye(3)
48
+ else:
49
+ rot_vec = np.array(rot_vec, dtype=float)
50
+ rotation = rotation_vector_to_matrix(rot_vec)
51
+
52
+ rz, ry, rx = radii
53
+ D = np.diag([1/rz**2, 1/ry**2, 1/rx**2])
54
+ A = rotation @ D @ rotation.T
55
+
56
+ # Generate coordinate grids centered at 0
57
+ z = (np.arange(zdim) - zdim/2 + 0.5) * dz
58
+ y = (np.arange(ydim) - ydim/2 + 0.5) * dy
59
+ x = (np.arange(xdim) - xdim/2 + 0.5) * dx
60
+ zz, yy, xx = np.meshgrid(z, y, x, indexing='ij')
61
+
62
+ coords = np.stack([zz - center[0], yy - center[1], xx - center[2]], axis=-1)
63
+ vals = np.einsum('...i,ij,...j->...', coords, A, coords)
64
+
65
+ return vals <= 1.0
66
+
67
+
68
+
69
+ def add_axes(p, xlabel="X", ylabel="Y", zlabel="Z", color=("red", "green", "blue")):
70
+
71
+ # Draw your volume/mesh
72
+ # p.add_volume(grid)
73
+
74
+ # Custom axis length
75
+ L = 50
76
+
77
+ # Unit right-handed basis
78
+ origin = np.array([0,0,0])
79
+ X = np.array([1,0,0])
80
+ Y = np.array([0,1,0])
81
+ Z = np.cross(X, Y) # guaranteed right-handed
82
+
83
+ # Arrows
84
+ p.add_mesh(pv.Arrow(start=origin, direction=X, scale=L), color=color[0])
85
+ p.add_mesh(pv.Arrow(start=origin, direction=Y, scale=L), color=color[1])
86
+ p.add_mesh(pv.Arrow(start=origin, direction=Z, scale=L), color=color[2])
87
+
88
+ # Add 3D text at the arrow tips
89
+ p.add_point_labels([origin + X*L], [xlabel], font_size=20, text_color=color[0], point_size=0)
90
+ p.add_point_labels([origin + Y*L], [ylabel], font_size=20, text_color=color[1], point_size=0)
91
+ p.add_point_labels([origin + Z*L], [zlabel], font_size=20, text_color=color[2], point_size=0)
92
+
93
+
94
+
95
+
96
+ def visualize_surface_reconstruction(original_mesh, reconstructed_mesh, opacity=(0.3,0.3)):
97
+ # Convert trimesh to pyvista PolyData
98
+ def trimesh_to_pv(mesh):
99
+ faces = np.hstack([np.full((len(mesh.faces),1), 3), mesh.faces]).astype(np.int64)
100
+ return pv.PolyData(mesh.vertices, faces)
101
+
102
+ original_pv = trimesh_to_pv(original_mesh)
103
+ reconstructed_pv = trimesh_to_pv(reconstructed_mesh)
104
+
105
+ plotter = pv.Plotter(window_size=(800,600))
106
+ plotter.background_color = 'white'
107
+ plotter.add_mesh(original_pv, color='red', opacity=opacity[0], label='Original')
108
+ plotter.add_mesh(reconstructed_pv, color='blue', opacity=opacity[1], label='Reconstructed')
109
+ plotter.add_legend()
110
+ plotter.add_text("Original (Red) vs Reconstructed (Blue)", font_size=14)
111
+ plotter.camera_position = 'iso'
112
+ plotter.show()
113
+
114
+
115
+ def display_both_surfaces(mask, mask_recon):
116
+ # ---------------------------
117
+ # 7. Visualize with PyVista
118
+ # ---------------------------
119
+ # Original mesh
120
+ grid_orig = pv.wrap(mask.astype(np.uint8))
121
+ contour_orig = grid_orig.contour(isosurfaces=[0.5])
122
+
123
+ # Reconstructed mesh
124
+ grid_recon = pv.wrap(mask_recon.astype(np.uint8))
125
+ contour_recon = grid_recon.contour(isosurfaces=[0.5])
126
+
127
+ plotter = pv.Plotter(shape=(1,2))
128
+ plotter.subplot(0,0)
129
+ plotter.add_text("Original", font_size=12)
130
+ plotter.add_mesh(contour_orig, color="lightblue")
131
+
132
+ plotter.subplot(0,1)
133
+ plotter.add_text("Reconstructed", font_size=12)
134
+ plotter.add_mesh(contour_recon, color="salmon")
135
+
136
+ plotter.show()
137
+
138
+
139
+
140
+
141
+ import numpy as np
142
+ import pyvista as pv
143
+
144
+ def display_volume(
145
+ kidney,
146
+ kidney_voxel_size=(1.0, 1.0, 1.0),
147
+ surface_color='lightblue',
148
+ opacity=1.0,
149
+ iso_value=0.5,
150
+ smooth_iters=20,
151
+ ):
152
+ """
153
+ Display a single kidney mask in a clean single-panel view.
154
+
155
+ Parameters
156
+ ----------
157
+ kidney : np.ndarray
158
+ 3D binary mask (bool or 0/1).
159
+ kidney_voxel_size : tuple
160
+ Voxel spacing (z, y, x) in world units.
161
+ surface_color : color-like
162
+ Color used for the kidney surface.
163
+ opacity : float
164
+ Surface opacity.
165
+ iso_value : float
166
+ Contour threshold for extracting the surface.
167
+ smooth_iters : int
168
+ Number of VTK smoothing iterations to apply (0 to disable).
169
+ """
170
+
171
+ # Ensure voxel spacing is an array
172
+ kidney_voxel_size = np.asarray(kidney_voxel_size, dtype=float)
173
+
174
+ # Build plotter
175
+ plotter = pv.Plotter(window_size=(900, 700))
176
+ plotter.background_color = "white"
177
+
178
+ # Wrap volume and set spacing so bounds are in world coordinates
179
+ vol = pv.wrap(kidney.astype(float))
180
+ vol.spacing = kidney_voxel_size
181
+
182
+ # Get isosurface
183
+ surf = vol.contour(isosurfaces=[iso_value])
184
+ if smooth_iters and smooth_iters > 0:
185
+ try:
186
+ surf = surf.smooth(n_iter=smooth_iters, relaxation_factor=0.1)
187
+ except Exception:
188
+ # Some VTK builds may not support smooth; ignore if fails
189
+ pass
190
+
191
+ # Main kidney surface
192
+ plotter.add_mesh(
193
+ surf,
194
+ color=surface_color,
195
+ opacity=opacity,
196
+ smooth_shading=True,
197
+ specular=0.2,
198
+ specular_power=20,
199
+ style='surface',
200
+ name="Kidney Surface"
201
+ )
202
+
203
+ # Bounding box (use the volume's bounds)
204
+ box = pv.Box(bounds=vol.bounds)
205
+ plotter.add_mesh(box, color="black", style="wireframe", line_width=2, name="Bounds")
206
+
207
+ # Camera & display
208
+ plotter.camera_position = "iso"
209
+ plotter.show()
210
+
211
+
212
+
213
+
214
+
215
+
216
+
217
+
218
+ # def _old_compute_principal_axes(mask, voxel_size):
219
+ # """
220
+ # Compute centroid and orthonormal principal axes (right-handed) for a 3D binary mask.
221
+ # Returns centroid (world coords) and axes (3x3 matrix, columns are orthonormal axes).
222
+ # """
223
+ # coords = np.argwhere(mask > 0).astype(float) # (N,3) indices (z,y,x) or (i,j,k)
224
+ # if coords.size == 0:
225
+ # raise ValueError("Empty mask provided to compute_principal_axes.")
226
+ # # Convert to physical/world coordinates by multiplying by voxel spacing
227
+ # voxel_size = np.asarray(voxel_size, float)
228
+ # coords_world = coords * voxel_size # broadcasting (N,3) * (3,) -> (N,3)
229
+
230
+ # centroid = coords_world.mean(axis=0)
231
+ # centered = coords_world - centroid # (N,3)
232
+
233
+ # # SVD on centered coords: Vt rows are principal directions; columns of V are axes
234
+ # U, S, Vt = np.linalg.svd(centered, full_matrices=False)
235
+ # axes = Vt.T # shape (3,3) columns are principal directions (unit length)
236
+
237
+ # # Ensure orthonormal (numerical safety) via QR or re-normalize columns
238
+ # # Re-normalize columns
239
+ # for i in range(3):
240
+ # axes[:, i] = axes[:, i] / (np.linalg.norm(axes[:, i]) + 1e-12)
241
+
242
+ # # Make right-handed: if determinant negative, flip third axis
243
+ # if np.linalg.det(axes) < 0:
244
+ # axes[:, 2] = -axes[:, 2]
245
+
246
+ # return centroid, axes
247
+
248
+ def compute_principal_axes(mask, voxel_size=(1.0, 1.0, 1.0)):
249
+ """
250
+ Calculates the physical centroid and normalized, right-handed principal axes
251
+ of a 3D binary mask.
252
+
253
+ Parameters:
254
+ -----------
255
+ mask : np.ndarray
256
+ A 3D binary array (boolean or 0/1) where True indicates the object.
257
+ voxel_size : tuple or list of 3 floats
258
+ The voxel size in physical units (e.g., [dz, dy, dx] or [dx, dy, dz]).
259
+ Must match the order of dimensions in the mask array.
260
+
261
+ Returns:
262
+ --------
263
+ centroid : np.ndarray
264
+ A (3,) array containing the center of mass in physical coordinates.
265
+ axes : np.ndarray
266
+ A (3, 3) matrix where each column represents a principal axis vector.
267
+ - Column 0: Major axis (largest variance)
268
+ - Column 1: Intermediate axis
269
+ - Column 2: Minor axis (smallest variance)
270
+ The system is guaranteed to be right-handed and normalized.
271
+ """
272
+ # 1. Get indices of the non-zero voxels (N, 3)
273
+ indices = np.argwhere(mask)
274
+
275
+ if indices.shape[0] < 3:
276
+ raise ValueError("Mask must contain at least 3 points to define 3D axes.")
277
+
278
+ # 2. Convert to physical coordinates
279
+ # We multiply the indices by the voxel_size to handle anisotropic voxels correctly
280
+ points = indices * np.array(voxel_size)
281
+
282
+ # 3. Calculate Centroid
283
+ centroid = np.mean(points, axis=0)
284
+
285
+ # 4. Center the points (subtract centroid)
286
+ points_centered = points - centroid
287
+
288
+ # 5. Compute Covariance Matrix
289
+ # rowvar=False means each column is a variable (x, y, z), each row is an observation
290
+ cov_matrix = np.cov(points_centered, rowvar=False)
291
+
292
+ # 6. Eigendecomposition
293
+ # eigh is optimized for symmetric matrices (like covariance matrices)
294
+ eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
295
+
296
+ # 7. Sort axes by Eigenvalues (descending order)
297
+ # eigh returns eigenvalues in ascending order, so we reverse them
298
+ # We want: Index 0 = Major Axis (Largest eigenvalue)
299
+ sort_indices = np.argsort(eigenvalues)[::-1]
300
+
301
+ sorted_eigenvalues = eigenvalues[sort_indices]
302
+ sorted_vectors = eigenvectors[:, sort_indices]
303
+
304
+ # 8. Enforce Right-Handed Coordinate System
305
+ # Calculate the determinant. If -1, the system is left-handed (reflection).
306
+ # We flip the minor axis (last column) to fix this.
307
+ if np.linalg.det(sorted_vectors) < 0:
308
+ sorted_vectors[:, -1] *= -1
309
+
310
+ return centroid, sorted_vectors
311
+
312
+ def add_principal_axes(plotter, centroid_world, axes, bounds, colors=("red","green","blue")):
313
+ """
314
+ Add principal axes as rays (lines through centroid extending to volume bounds).
315
+
316
+ Args:
317
+ plotter (pv.Plotter): pyvista plotter
318
+ centroid_world (np.ndarray): centroid in world coordinates
319
+ axes (np.ndarray): 3x3 matrix, columns are unit direction vectors
320
+ bounds (tuple): (xmin, xmax, ymin, ymax, zmin, zmax)
321
+ colors (tuple): colors for the three axes
322
+ """
323
+ centroid_world = np.asarray(centroid_world, float)
324
+ bounds = np.asarray(bounds).reshape(3,2) # [[xmin,xmax],[ymin,ymax],[zmin,zmax]]
325
+
326
+ for i in range(3):
327
+ dir_vec = axes[:, i]
328
+ dir_vec /= np.linalg.norm(dir_vec) + 1e-12
329
+
330
+ # For each direction ±dir_vec, compute intersection with bounding box planes
331
+ line_pts = []
332
+ for sign in (-1, 1):
333
+ d = dir_vec * sign
334
+ t_vals = []
335
+ for dim in range(3):
336
+ if abs(d[dim]) > 1e-12:
337
+ for plane in bounds[dim]:
338
+ t = (plane - centroid_world[dim]) / d[dim]
339
+ if t > 0: # forward intersection
340
+ t_vals.append(t)
341
+ if len(t_vals) > 0:
342
+ t_min = min(t_vals)
343
+ pt = centroid_world + d * t_min
344
+ line_pts.append(pt)
345
+
346
+ if len(line_pts) == 2:
347
+ line = pv.Line(line_pts[0], line_pts[1])
348
+ plotter.add_mesh(line, color=colors[i], line_width=4, opacity=0.8)
349
+
350
+
351
+ # def add_principal_axes(plotter, centroid_world, axes, scale=100.0, colors=("red","green","blue")):
352
+ # """
353
+ # Add principal axes as rays (lines through centroid in ± directions).
354
+ # """
355
+ # centroid_world = np.asarray(centroid_world, float)
356
+ # for i in range(3):
357
+ # direction = axes[:, i] / (np.linalg.norm(axes[:, i]) + 1e-12)
358
+ # p1 = centroid_world - direction * scale
359
+ # p2 = centroid_world + direction * scale
360
+ # line = pv.Line(p1, p2)
361
+ # plotter.add_mesh(line, color=colors[i], line_width=4, opacity=0.8)
362
+
363
+
364
+ def display_two_normalized_kidneys(kidney1, kidney2,
365
+ kidney1_voxel_size=(1.0,1.0,1.0),
366
+ kidney2_voxel_size=(1.0,1.0,1.0),
367
+ title='Two normalized_kidneys'):
368
+ """
369
+ Visualize two shapes and overlay computed principal axes.
370
+ """
371
+ kidney1_voxel_size = np.asarray(kidney1_voxel_size, float)
372
+ kidney2_voxel_size = np.asarray(kidney2_voxel_size, float)
373
+
374
+ # compute centroids & axes
375
+ centroid_1, axes_1 = compute_principal_axes(kidney1, kidney1_voxel_size)
376
+ centroid_2, axes_2 = compute_principal_axes(kidney2, kidney2_voxel_size)
377
+
378
+ plotter = pv.Plotter(window_size=(1000,600), shape=(1,2))
379
+ plotter.background_color = 'white'
380
+
381
+ # Original
382
+ vol_1 = pv.wrap(kidney1.astype(float))
383
+ vol_1.spacing = kidney1_voxel_size # ensures world coordinates are correct
384
+ vol_1_surf = vol_1.contour(isosurfaces=[0.5])
385
+
386
+ plotter.subplot(0,0)
387
+ plotter.add_text(f"{title} (1)", font_size=12)
388
+ plotter.add_mesh(vol_1_surf, color='lightblue', opacity=1.0, style='surface', ambient=0.1, diffuse=0.9)
389
+ # box
390
+ plotter.add_mesh(pv.Box(bounds=vol_1.bounds), color='black', style='wireframe', line_width=2)
391
+ add_axes(plotter, xlabel='O', ylabel='L', zlabel='T', color=("red", "blue", "green"))
392
+ add_principal_axes(plotter, centroid_1, axes_1, bounds=vol_1.bounds, colors=("black","black","black"))
393
+
394
+ # Normalized
395
+ vol_2 = pv.wrap(kidney2.astype(float))
396
+ vol_2.spacing = kidney2_voxel_size
397
+ vol_2_surf = vol_2.contour(isosurfaces=[0.5])
398
+
399
+ plotter.subplot(0,1)
400
+ plotter.add_text(f"{title} (2)", font_size=12)
401
+ plotter.add_mesh(vol_2_surf, color='lightblue', opacity=1.0, style='surface', ambient=0.1, diffuse=0.9)
402
+ plotter.add_mesh(pv.Box(bounds=vol_2.bounds), color='black', style='wireframe', line_width=2)
403
+ add_axes(plotter, xlabel='O', ylabel='L', zlabel='T', color=("red", "blue", "green"))
404
+ add_principal_axes(plotter, centroid_2, axes_2, bounds=vol_2.bounds, colors=("black","black","black"))
405
+
406
+ plotter.camera_position = 'iso'
407
+ plotter.show()
408
+
409
+ def display_two_kidneys(kidney1, kidney2,
410
+ kidney1_voxel_size=(1.0,1.0,1.0),
411
+ kidney2_voxel_size=(1.0,1.0,1.0),
412
+ title1='Kidney 1', title2='Kidney 2'):
413
+ """
414
+ Visualize two shapes and overlay computed principal axes.
415
+ """
416
+ kidney1_voxel_size = np.asarray(kidney1_voxel_size, float)
417
+ kidney2_voxel_size = np.asarray(kidney2_voxel_size, float)
418
+
419
+ # compute centroids & axes
420
+ centroid_1, axes_1 = compute_principal_axes(kidney1, kidney1_voxel_size)
421
+ centroid_2, axes_2 = compute_principal_axes(kidney2, kidney2_voxel_size)
422
+
423
+ plotter = pv.Plotter(window_size=(1000,600), shape=(1,2))
424
+ plotter.background_color = 'white'
425
+
426
+ # Original
427
+ vol_1 = pv.wrap(kidney1.astype(float))
428
+ vol_1.spacing = kidney1_voxel_size # ensures world coordinates are correct
429
+ vol_1_surf = vol_1.contour(isosurfaces=[0.5])
430
+
431
+ plotter.subplot(0,0)
432
+ plotter.add_text(f"{title1}", font_size=12)
433
+ plotter.add_mesh(vol_1_surf, color='lightblue', opacity=1.0, style='surface', ambient=0.1, diffuse=0.9)
434
+ # box
435
+ plotter.add_mesh(pv.Box(bounds=vol_1.bounds), color='black', style='wireframe', line_width=2)
436
+ add_axes(plotter, xlabel='L', ylabel='F', zlabel='P', color=("red", "green", "blue"))
437
+ add_principal_axes(plotter, centroid_1, axes_1, bounds=vol_1.bounds, colors=("black","black","black"))
438
+
439
+ # Normalized
440
+ vol_2 = pv.wrap(kidney2.astype(float))
441
+ vol_2.spacing = kidney2_voxel_size
442
+ vol_2_surf = vol_2.contour(isosurfaces=[0.5])
443
+
444
+ plotter.subplot(0,1)
445
+ plotter.add_text(f"{title2}", font_size=12)
446
+ plotter.add_mesh(vol_2_surf, color='lightblue', opacity=1.0, style='surface', ambient=0.1, diffuse=0.9)
447
+ plotter.add_mesh(pv.Box(bounds=vol_2.bounds), color='black', style='wireframe', line_width=2)
448
+ add_axes(plotter, xlabel='L', ylabel='F', zlabel='P', color=("red", "green", "blue"))
449
+ add_principal_axes(plotter, centroid_2, axes_2, bounds=vol_2.bounds, colors=("black","black","black"))
450
+
451
+ plotter.camera_position = 'iso'
452
+ plotter.show()
453
+
454
+
455
+ def display_kidney_normalization(kidney, kidney_norm,
456
+ kidney_voxel_size=(1.0,1.0,1.0),
457
+ kidney_norm_voxel_size=None,
458
+ title='Kidney normalization',
459
+ axis_scale=100.0):
460
+ """
461
+ Visualize original and normalized kidneys and overlay computed principal axes.
462
+ """
463
+ kidney_voxel_size = np.asarray(kidney_voxel_size, float)
464
+ if kidney_norm_voxel_size is None:
465
+ kidney_norm_voxel_size = kidney_voxel_size
466
+ kidney_norm_voxel_size = np.asarray(kidney_norm_voxel_size, float)
467
+
468
+ # compute centroids & axes
469
+ centroid_orig, axes_orig = compute_principal_axes(kidney, kidney_voxel_size)
470
+ centroid_norm, axes_norm = compute_principal_axes(kidney_norm, kidney_norm_voxel_size)
471
+
472
+ plotter = pv.Plotter(window_size=(1000,600), shape=(1,2))
473
+ plotter.background_color = 'white'
474
+
475
+ # Original
476
+ orig_vol = pv.wrap(kidney.astype(float))
477
+ orig_vol.spacing = kidney_voxel_size # ensures world coordinates are correct
478
+ orig_surf = orig_vol.contour(isosurfaces=[0.5])
479
+
480
+ plotter.subplot(0,0)
481
+ plotter.add_text(f"{title} (original)", font_size=12)
482
+ plotter.add_mesh(orig_surf, color='lightblue', opacity=1.0, style='surface', ambient=0.1, diffuse=0.9)
483
+ # box
484
+ plotter.add_mesh(pv.Box(bounds=orig_vol.bounds), color='black', style='wireframe', line_width=2)
485
+ add_axes(plotter, xlabel='L', ylabel='F', zlabel='P', color=("red", "green", "blue"))
486
+ add_principal_axes(plotter, centroid_orig, axes_orig, bounds=orig_vol.bounds, colors=("black","black","black"))
487
+
488
+ # Normalized
489
+ recon_vol = pv.wrap(kidney_norm.astype(float))
490
+ recon_vol.spacing = kidney_norm_voxel_size
491
+ recon_surf = recon_vol.contour(isosurfaces=[0.5])
492
+
493
+ plotter.subplot(0,1)
494
+ plotter.add_text(f"{title} (normalized)", font_size=12)
495
+ plotter.add_mesh(recon_surf, color='lightblue', opacity=1.0, style='surface', ambient=0.1, diffuse=0.9)
496
+ plotter.add_mesh(pv.Box(bounds=recon_vol.bounds), color='black', style='wireframe', line_width=2)
497
+ add_axes(plotter, xlabel='O', ylabel='L', zlabel='T', color=("red", "blue", "green"))
498
+ add_principal_axes(plotter, centroid_norm, axes_norm, bounds=recon_vol.bounds, colors=("black","black","black"))
499
+
500
+ plotter.camera_position = 'iso'
501
+ plotter.show()
502
+
503
+
504
+ def compare_processed_kidneys(kidney, kidney_proc, voxel_size=(1.0,1.0,1.0)):
505
+ """
506
+ Visualize original and reconstructed 3D volumes in PyVista with correct proportions.
507
+ """
508
+ voxel_size = np.array(voxel_size, dtype=float)
509
+ plotter = pv.Plotter(window_size=(800,600), shape=(1,2))
510
+ plotter.background_color = 'white'
511
+
512
+ # Wrap original volume
513
+ orig_vol = pv.wrap(kidney.astype(float))
514
+ orig_vol.spacing = voxel_size
515
+ orig_surface = orig_vol.contour(isosurfaces=[0.5])
516
+ plotter.subplot(0,0)
517
+ plotter.add_text(f"Original", font_size=12)
518
+ plotter.add_mesh(
519
+ orig_surface, color='lightblue', opacity=1.0, style='surface',
520
+ ambient=0.1, # ambient term
521
+ diffuse=0.9, # diffuse shading
522
+ )
523
+ # Add wireframe box around original volume
524
+ bounds = orig_vol.bounds # (xmin, xmax, ymin, ymax, zmin, zmax)
525
+ box = pv.Box(bounds=bounds)
526
+ plotter.add_mesh(box, color='black', style='wireframe', line_width=2)
527
+ #add_axes(plotter, xlabel='LO', ylabel='PO', zlabel='HO', color=("red", "blue", "green"))
528
+ add_axes(plotter, xlabel='O', ylabel='L', zlabel='T', color=("red", "blue", "green"))
529
+ # # Force camera to look from the opposite direction
530
+ # plotter.view_vector((-1, 0, 0)) # rotate 180° around vertical axis
531
+
532
+ # Wrap reconstructed volume
533
+ recon_vol = pv.wrap(kidney_proc.astype(float))
534
+ recon_vol.spacing = voxel_size
535
+ recon_surface = recon_vol.contour(isosurfaces=[0.5])
536
+ plotter.subplot(0,1)
537
+ plotter.add_text(f"Processed", font_size=12)
538
+ plotter.add_mesh(recon_surface, color='lightblue', opacity=1.0, style='surface',
539
+ ambient=0.1, # ambient term
540
+ diffuse=0.9, # diffuse shading
541
+ )
542
+ # Add wireframe box around original volume
543
+ bounds = recon_vol.bounds # (xmin, xmax, ymin, ymax, zmin, zmax)
544
+ box = pv.Box(bounds=bounds)
545
+ plotter.add_mesh(box, color='black', style='wireframe', line_width=2)
546
+ # add_axes(plotter, xlabel='LO', ylabel='PO', zlabel='HO', color=("red", "blue", "green"))
547
+ add_axes(plotter, xlabel='O', ylabel='L', zlabel='T', color=("red", "blue", "green"))
548
+
549
+ # Set the camera position and show the plot
550
+ plotter.camera_position = 'iso'
551
+ plotter.show()
552
+
553
+
554
+ def display_volumes_two_panel(original_volume, reconstructed_volume, original_voxel_size=(1.0,1.0,1.0), reconstructed_voxel_size=None):
555
+ """
556
+ Visualize original and reconstructed 3D volumes in PyVista with correct proportions.
557
+ """
558
+ original_voxel_size = np.array(original_voxel_size, dtype=float)
559
+ if reconstructed_voxel_size is None:
560
+ reconstructed_voxel_size = original_voxel_size
561
+ plotter = pv.Plotter(window_size=(800,600), shape=(1,2))
562
+ plotter.background_color = 'white'
563
+
564
+ # Wrap original volume
565
+ orig_vol = pv.wrap(original_volume.astype(float))
566
+ orig_vol.spacing = original_voxel_size
567
+ orig_surface = orig_vol.contour(isosurfaces=[0.5])
568
+ plotter.subplot(0,0)
569
+ plotter.add_text("Original", font_size=12)
570
+ plotter.add_mesh(orig_surface, color='lightblue', opacity=1.0, style='surface')
571
+ # Add wireframe box around original volume
572
+ bounds = orig_vol.bounds # (xmin, xmax, ymin, ymax, zmin, zmax)
573
+ box = pv.Box(bounds=bounds)
574
+ plotter.add_mesh(box, color='black', style='wireframe', line_width=2)
575
+ add_axes(plotter)
576
+
577
+ # Wrap reconstructed volume
578
+ recon_vol = pv.wrap(reconstructed_volume.astype(float))
579
+ recon_vol.spacing = reconstructed_voxel_size
580
+ recon_surface = recon_vol.contour(isosurfaces=[0.5])
581
+ plotter.subplot(0,1)
582
+ plotter.add_text("Reconstructed", font_size=12)
583
+ plotter.add_mesh(recon_surface, color='red', opacity=1.0, style='surface')
584
+ # Add wireframe box around original volume
585
+ bounds = recon_vol.bounds # (xmin, xmax, ymin, ymax, zmin, zmax)
586
+ box = pv.Box(bounds=bounds)
587
+ plotter.add_mesh(box, color='black', style='wireframe', line_width=2)
588
+ add_axes(plotter)
589
+
590
+ # Set the camera position and show the plot
591
+ plotter.camera_position = 'iso'
592
+ plotter.show()
593
+
594
+
595
+ def display_volumes(original_volume, reconstructed_volume, original_voxel_size=(1.0,1.0,1.0), reconstructed_voxel_size=None):
596
+ """
597
+ Visualize original and reconstructed 3D volumes in PyVista with correct proportions.
598
+ """
599
+ original_voxel_size = np.array(original_voxel_size, dtype=float)
600
+ if reconstructed_voxel_size is None:
601
+ reconstructed_voxel_size = original_voxel_size
602
+ plotter = pv.Plotter(window_size=(800,600))
603
+ plotter.background_color = 'white'
604
+
605
+ # Wrap original volume
606
+ orig_vol = pv.wrap(original_volume.astype(float))
607
+ orig_vol.spacing = original_voxel_size
608
+ orig_surface = orig_vol.contour(isosurfaces=[0.5])
609
+ plotter.add_mesh(orig_surface, color='blue', opacity=0.3, style='surface', label='Original Volume')
610
+
611
+ # Wrap reconstructed volume
612
+ recon_vol = pv.wrap(reconstructed_volume.astype(float))
613
+ recon_vol.spacing = reconstructed_voxel_size
614
+ recon_surface = recon_vol.contour(isosurfaces=[0.5])
615
+ plotter.add_mesh(recon_surface, color='red', opacity=0.3, style='surface', label='Reconstructed Volume')
616
+
617
+ # Add wireframe box around original volume
618
+ bounds = orig_vol.bounds # (xmin, xmax, ymin, ymax, zmin, zmax)
619
+ box = pv.Box(bounds=bounds)
620
+ plotter.add_mesh(box, color='black', style='wireframe', line_width=2)
621
+
622
+ plotter.add_legend()
623
+ plotter.add_text('3D Volume Reconstruction', font_size=20)
624
+ plotter.camera_position = 'iso'
625
+ plotter.show()
626
+
627
+
628
+
629
+
630
+
631
+
632
+
633
+
634
+ def display_surface(volume_recon):
635
+
636
+ # -----------------------
637
+ # Extract surface mesh (marching cubes)
638
+ # -----------------------
639
+ verts, faces, normals, _ = measure.marching_cubes(volume_recon, level=0.5)
640
+ faces = np.hstack([np.full((faces.shape[0], 1), 3), faces]).astype(np.int32)
641
+
642
+ mesh = pv.PolyData(verts, faces)
643
+ mesh_smooth = mesh.smooth(n_iter=50, relaxation_factor=0.1)
644
+
645
+ # -----------------------
646
+ # PyVista visualization
647
+ # -----------------------
648
+ plotter = pv.Plotter()
649
+ plotter.add_mesh(mesh_smooth, color="lightblue", opacity=1.0, show_edges=False)
650
+ plotter.add_axes()
651
+ plotter.show_grid()
652
+ plotter.show()