miblab-plot 0.0.2__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,5 +1,6 @@
1
- from miblab_plot.image_3d import (
1
+ from .image_3d import (
2
2
  volume_to_mosaic,
3
3
  mosaic_overlay,
4
4
  mosaic_checkerboard,
5
- )
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()
miblab_plot/movie.py ADDED
@@ -0,0 +1,79 @@
1
+ import os
2
+ import shutil
3
+
4
+ import imageio.v2 as imageio # Use v2 interface for compatibility
5
+ from moviepy import VideoFileClip
6
+ import matplotlib
7
+ import numpy as np
8
+ import matplotlib.pyplot as plt
9
+ from tqdm import tqdm
10
+
11
+
12
+
13
+ def get_distinct_colors(rois, colormap='jet'):
14
+ if len(rois)==1:
15
+ colors = [[255, 0, 0, 0.6]]
16
+ elif len(rois)==2:
17
+ colors = [[255, 0, 0, 0.6], [0, 255, 0, 0.6]]
18
+ elif len(rois)==3:
19
+ colors = [[255, 0, 0, 0.6], [0, 255, 0, 0.6], [0, 0, 255, 0.6]]
20
+ else:
21
+ n = len(rois)
22
+ #cmap = cm.get_cmap(colormap, n)
23
+ cmap = matplotlib.colormaps[colormap]
24
+ colors = [cmap(i)[:3] + (0.6,) for i in np.linspace(0, 1, n)] # Set alpha to 0.6 for transparency
25
+
26
+ return colors
27
+
28
+
29
+ def movie_overlay(img, rois, file):
30
+
31
+ # Define RGBA colors (R, G, B, Alpha) — alpha controls transparency
32
+ colors = get_distinct_colors(rois, colormap='tab20')
33
+
34
+ # Directory to store temporary frames
35
+ tmp = os.path.join(os.getcwd(), 'tmp')
36
+ os.makedirs(tmp, exist_ok=True)
37
+ filenames = []
38
+
39
+ # Generate and save a sequence of plots
40
+ for i in tqdm(range(img.shape[2]), desc='Building animation..'):
41
+
42
+ # Set up figure
43
+ fig, ax = plt.subplots(
44
+ figsize=(5, 5),
45
+ dpi=300,
46
+ )
47
+
48
+ # Display the background image
49
+ ax.imshow(img[:,:,i].T, cmap='gray', interpolation='none', vmin=0, vmax=np.mean(img) + 2 * np.std(img))
50
+
51
+ # Overlay each mask
52
+ for mask, color in zip([m.astype(bool) for m in rois.values()], colors):
53
+ rgba = np.zeros((img.shape[0], img.shape[1], 4), dtype=float)
54
+ for c in range(4): # RGBA
55
+ rgba[..., c] = mask[:,:,i] * color[c]
56
+ ax.imshow(rgba.transpose((1,0,2)), interpolation='none')
57
+
58
+ # Save eachg image to a tmp file
59
+ fname = os.path.join(tmp, f'frame_{i}.png')
60
+ fig.savefig(fname)
61
+ filenames.append(fname)
62
+ plt.close(fig)
63
+
64
+ # Create GIF
65
+ print('Creating movie')
66
+ gif = os.path.join(tmp, 'movie.gif')
67
+ with imageio.get_writer(gif, mode="I", duration=0.2) as writer:
68
+ for fname in filenames:
69
+ image = imageio.imread(fname)
70
+ writer.append_data(image)
71
+
72
+ # Load gif
73
+ clip = VideoFileClip(gif)
74
+
75
+ # Save as MP4
76
+ clip.write_videofile(file, codec='libx264')
77
+
78
+ # Clean up temporary files
79
+ shutil.rmtree(tmp)
miblab_plot/mp4.py ADDED
@@ -0,0 +1,83 @@
1
+ import os
2
+
3
+ from moviepy.video.io.ImageSequenceClip import ImageSequenceClip # NEW (v2.x)
4
+
5
+ def images_to_video(image_folder, output_file, fps=30):
6
+ # 1. Get and sort images
7
+ images = [os.path.join(image_folder, img)
8
+ for img in os.listdir(image_folder)
9
+ if img.endswith(".png")]
10
+ images.sort()
11
+
12
+ if not images:
13
+ print("No images found!")
14
+ return
15
+
16
+ # 2. Create the clip
17
+ clip = ImageSequenceClip(images, fps=fps)
18
+
19
+ # 3. Write to MP4 (Windows compatible)
20
+ # The 'logger=None' argument suppresses the progress bar if you want cleaner output
21
+ clip.write_videofile(output_file, codec='libx264', bitrate="50000k")
22
+
23
+ # Usage
24
+ # save_high_quality_mp4('your/image/folder', 'video.mp4')
25
+
26
+ # Usage
27
+ # create_lossless_video('your_folder', 'high_quality.mp4')
28
+
29
+ # Usage
30
+ # save_high_quality_mp4('my_folder', 'final_video.mp4')
31
+
32
+ # def _images_to_video(image_folder, output_video_file, fps=30):
33
+ # """
34
+ # Converts a folder of PNG images into a video file.
35
+
36
+ # Args:
37
+ # image_folder (str): Path to the folder containing images.
38
+ # output_video_file (str): Output filename (e.g., 'output.mp4').
39
+ # fps (int): Frames per second.
40
+ # """
41
+
42
+ # # 1. Get the list of files
43
+ # images = [img for img in os.listdir(image_folder) if img.endswith(".png")]
44
+
45
+ # # 2. Sort the images to ensure they are in the correct order
46
+ # # Note: This uses standard string sorting. If your files are named 1.png, 10.png, 2.png,
47
+ # # you might need "natural sorting" logic.
48
+ # images.sort()
49
+
50
+ # if not images:
51
+ # print("No PNG images found in the directory.")
52
+ # return
53
+
54
+ # # 3. Read the first image to determine width and height
55
+ # frame = cv2.imread(os.path.join(image_folder, images[0]))
56
+ # height, width, layers = frame.shape
57
+ # size = (width, height)
58
+
59
+ # # 4. Define the codec and create VideoWriter object
60
+ # # 'mp4v' is a standard codec for MP4 containers
61
+ # # fourcc = cv2.VideoWriter_fourcc(*'mp4v')
62
+ # fourcc = cv2.VideoWriter_fourcc(*'MJPG') # Motion JPEG codec
63
+ # out = cv2.VideoWriter(output_video_file, fourcc, fps, size)
64
+
65
+
66
+ # print(f"Processing {len(images)} images...")
67
+
68
+ # # 5. Write images to video
69
+ # for image in images:
70
+ # img_path = os.path.join(image_folder, image)
71
+ # frame = cv2.imread(img_path)
72
+
73
+ # # specific check: resizing might be needed if images vary in size
74
+ # # frame = cv2.resize(frame, size)
75
+
76
+ # out.write(frame)
77
+
78
+ # # 6. Release everything
79
+ # out.release()
80
+ # print(f"Video saved as {output_video_file}")
81
+
82
+ # # --- Usage Example ---
83
+ # # images_to_video('path/to/your/images', 'my_animation.mp4', fps=24)
miblab_plot/pvplot.py ADDED
@@ -0,0 +1,364 @@
1
+ import os
2
+ from pathlib import Path
3
+ from typing import Union
4
+
5
+ import numpy as np
6
+ from tqdm import tqdm
7
+ import pyvista as pv
8
+ import dbdicom as db
9
+ import zarr
10
+
11
+ import miblab_ssa as ssa
12
+
13
+
14
+ def mosaic_masks_dcm(masks, imagefile, labels=None, view_vector=(1, 0, 0)):
15
+
16
+ # Plot settings
17
+ aspect_ratio = 16/9
18
+ width = 150
19
+ height = 150
20
+
21
+ # Count nr of mosaics
22
+ n_mosaics = len(masks)
23
+ nrows = int(np.ceil(np.sqrt((width*n_mosaics)/(aspect_ratio*height))))
24
+ ncols = int(np.ceil(n_mosaics/nrows))
25
+
26
+ plotter = pv.Plotter(window_size=(ncols*width, nrows*height), shape=(nrows, ncols), border=False, off_screen=True)
27
+ plotter.background_color = 'white'
28
+
29
+ row = 0
30
+ col = 0
31
+ for i, mask_series in tqdm(enumerate(masks), desc=f'Building mosaic'):
32
+
33
+ # Set up plotter
34
+ plotter.subplot(row,col)
35
+ if labels is not None:
36
+ plotter.add_text(labels[i], font_size=6)
37
+ if col == ncols-1:
38
+ col = 0
39
+ row += 1
40
+ else:
41
+ col += 1
42
+
43
+ # Load data
44
+ vol = db.volume(mask_series, verbose=0)
45
+ mask_norm = ssa.sdf_ft.smooth_mask(vol.values, order=32)
46
+
47
+ # Plot tile
48
+ orig_vol = pv.wrap(mask_norm.astype(float))
49
+ orig_vol.spacing = vol.spacing
50
+ orig_surface = orig_vol.contour(isosurfaces=[0.5])
51
+ plotter.add_mesh(orig_surface, color='lightblue', opacity=1.0, style='surface')
52
+ plotter.camera_position = 'iso'
53
+ plotter.view_vector(view_vector) # rotate 180° around vertical axis
54
+
55
+ plotter.screenshot(imagefile)
56
+ plotter.close()
57
+
58
+
59
+ def rotating_masks_grid(
60
+ dir_output:str,
61
+ masks:Union[zarr.Array, np.ndarray],
62
+ labels:np.ndarray=None,
63
+ nviews=25,
64
+ ):
65
+ # masks: (cols, rows) + 3d shape
66
+ # labels: (cols, rows)
67
+ # Plot settings
68
+ width = 150
69
+ height = 150
70
+
71
+ # Define view points
72
+ angles = np.linspace(0, 2*np.pi, nviews)
73
+ dirs = [(np.cos(a), np.sin(a), 0.0) for a in angles] # rotate around z
74
+ dirs += [(np.cos(a), 0.0, np.sin(a)) for a in angles] # rotate around y
75
+
76
+ # Count nr of mosaics
77
+ ncols = masks.shape[0]
78
+ nrows = masks.shape[1]
79
+
80
+ plotters = {}
81
+ for i, vec in enumerate(dirs):
82
+ plotters[i] = pv.Plotter(
83
+ window_size=(ncols*width, nrows*height),
84
+ shape=(nrows, ncols),
85
+ border=False,
86
+ off_screen=True,
87
+ )
88
+ plotters[i].background_color = 'white'
89
+
90
+ for row in tqdm(range(nrows), desc=f'Building mosaic'):
91
+ for col in range(ncols):
92
+
93
+ # Load data once
94
+ mask_norm = masks[col, row, ...]
95
+
96
+ orig_vol = pv.wrap(mask_norm.astype(float))
97
+ orig_vol.spacing = [1.0, 1.0, 1.0]
98
+ orig_surface = orig_vol.contour(isosurfaces=[0.5])
99
+
100
+ prev_up = None
101
+ for i, vec in enumerate(dirs):
102
+ # Camera position
103
+ distance = orig_surface.length * 2.0 # controls zoom
104
+ center = list(orig_surface.center)
105
+ pos = center + distance * np.array(vec) # vec = direction
106
+ up = _camera_up_from_direction(vec, prev_up)
107
+ prev_up = up
108
+
109
+ # Set up plotter
110
+ plotters[i].subplot(row, col)
111
+ if labels is not None:
112
+ plotters[i].add_text(labels[col, row], font_size=6)
113
+ plotters[i].add_mesh(orig_surface, color='lightblue', opacity=1.0, style='surface')
114
+ plotters[i].camera_position = [pos, center, up]
115
+
116
+ for i, vec in tqdm(enumerate(dirs), desc='Saving mosaics..'):
117
+ file = os.path.join(dir_output, f"mosaic_{i:03d}.png")
118
+ os.makedirs(Path(file).parent, exist_ok=True)
119
+ plotters[i].screenshot(file)
120
+ plotters[i].close()
121
+
122
+
123
+
124
+
125
+ def rotating_mosaics_npz(dir_output, masks, labels=None, chunksize=None, nviews=25, columns=None, rows=None):
126
+
127
+ if labels is None:
128
+ labels = [str(i) for i in range(len(masks))]
129
+ if chunksize is None:
130
+ chunksize = len(masks)
131
+
132
+ # Split into numbered chunks
133
+ def chunk_list(lst, size):
134
+ chunks = [lst[i:i+size] for i in range(0, len(lst), size)]
135
+ return list(enumerate(chunks))
136
+
137
+ mask_chunks = chunk_list(masks, chunksize)
138
+ label_chunks = chunk_list(labels, chunksize)
139
+
140
+ # Define view points
141
+ angles = np.linspace(0, 2*np.pi, nviews)
142
+ dirs = [(np.cos(a), np.sin(a), 0.0) for a in angles] # rotate around z
143
+ dirs += [(np.cos(a), 0.0, np.sin(a)) for a in angles] # rotate around y
144
+
145
+ # Save mosaics for each chunk and view
146
+ for mask_chunk, label_chunk in zip(mask_chunks, label_chunks):
147
+ chunk_idx = mask_chunk[0]
148
+ names = [f"group_{str(chunk_idx).zfill(2)}_{i:02d}.png" for i in range(len(dirs))]
149
+ directions = {vec: os.path.join(dir_output, name) for name, vec in zip(names, dirs)}
150
+ multiple_mosaic_masks_npz(mask_chunk[1], directions, label_chunk[1], columns=columns, rows=rows)
151
+
152
+
153
+ def multiple_mosaic_masks_npz(masks, directions:dict, labels, columns=None, rows=None):
154
+ # Plot settings
155
+ aspect_ratio = 16/9
156
+ width = 150
157
+ height = 150
158
+
159
+ # Count nr of mosaics
160
+ n_mosaics = len(masks)
161
+ if columns is None:
162
+ ncols = int(np.ceil(np.sqrt((height*n_mosaics)/(aspect_ratio*width))))
163
+ else:
164
+ ncols = columns
165
+ if rows is None:
166
+ nrows = int(np.ceil(n_mosaics/ncols))
167
+ else:
168
+ nrows = rows
169
+ # nrows = int(np.ceil(np.sqrt((width*n_mosaics)/(aspect_ratio*height))))
170
+ # ncols = int(np.ceil(n_mosaics/nrows))
171
+
172
+ plotters = {}
173
+ for vec in directions.keys():
174
+ plotters[vec] = pv.Plotter(
175
+ window_size=(ncols*width, nrows*height),
176
+ shape=(nrows, ncols),
177
+ border=False,
178
+ off_screen=True,
179
+ )
180
+ plotters[vec].background_color = 'white'
181
+
182
+ row = 0
183
+ col = 0
184
+ for mask_label, mask_series in tqdm(zip(labels, masks), desc=f'Building mosaic'):
185
+
186
+ # Load data once
187
+ vol = db.npz.volume(mask_series)
188
+ mask_norm = ssa.sdf_ft.smooth_mask(vol.values.astype(bool), order=32)
189
+
190
+ orig_vol = pv.wrap(mask_norm.astype(float))
191
+ orig_vol.spacing = [1.0, 1.0, 1.0]
192
+ orig_surface = orig_vol.contour(isosurfaces=[0.5])
193
+
194
+ prev_up = None
195
+ for vec in directions.keys():
196
+ # Camera position
197
+ distance = orig_surface.length * 2.0 # controls zoom
198
+ center = list(orig_surface.center)
199
+ pos = center + distance * np.array(vec) # vec = direction
200
+ up = _camera_up_from_direction(vec, prev_up)
201
+ prev_up = up
202
+
203
+ # Set up plotter
204
+ plotter = plotters[vec]
205
+ plotter.subplot(row,col)
206
+ if labels is not None:
207
+ plotter.add_text(mask_label, font_size=6)
208
+ plotter.add_mesh(orig_surface, color='lightblue', opacity=1.0, style='surface')
209
+ plotter.camera_position = [pos, center, up]
210
+
211
+ # plotter.camera_position = 'iso'
212
+ # plotter.view_vector(vec) # rotate 180° around vertical axis
213
+
214
+ if col == ncols-1:
215
+ col = 0
216
+ row += 1
217
+ else:
218
+ col += 1
219
+
220
+ for vec, file in directions.items():
221
+ # plotters[vec].render()
222
+ plotters[vec].screenshot(file)
223
+ plotters[vec].close()
224
+
225
+
226
+
227
+ def _camera_up_from_direction(d, prev_up=None):
228
+ d = np.asarray(d, float)
229
+ d /= np.linalg.norm(d)
230
+
231
+ # 1. First Frame: Use your original logic to establish an initial Up vector
232
+ if prev_up is None:
233
+ ref = np.array([0, 0, 1])
234
+ # If looking straight down Z, switch ref to Y to avoid singularity
235
+ if abs(np.dot(d, ref)) > 0.99:
236
+ ref = np.array([0, 1, 0])
237
+
238
+ right = np.cross(ref, d)
239
+ right /= np.linalg.norm(right)
240
+ up = np.cross(d, right)
241
+
242
+ # 2. Subsequent Frames: Parallel Transport
243
+ else:
244
+ # Project the previous Up vector onto the plane perpendicular to the new direction.
245
+ # This removes the component of prev_up that is parallel to d.
246
+ # Formula: v_perp = v - (v . d) * d
247
+ up = prev_up - np.dot(prev_up, d) * d
248
+
249
+ # Normalize the result
250
+ norm = np.linalg.norm(up)
251
+
252
+ # Handle rare edge case where d aligns perfectly with prev_up (norm is 0)
253
+ if norm < 1e-6:
254
+ # Fallback to initial logic
255
+ ref = np.array([0, 0, 1])
256
+ if abs(np.dot(d, ref)) > 0.99:
257
+ ref = np.array([0, 1, 0])
258
+ right = np.cross(ref, d)
259
+ up = np.cross(d, right)
260
+ up /= np.linalg.norm(up)
261
+ else:
262
+ up /= norm
263
+
264
+ return up
265
+
266
+
267
+ def mosaic_masks_npz(masks, imagefile, labels=None, view_vector=(1, 0, 0)):
268
+
269
+ # Plot settings
270
+ aspect_ratio = 16/9
271
+ width = 150
272
+ height = 150
273
+
274
+ # Count nr of mosaics
275
+ n_mosaics = len(masks)
276
+ nrows = int(np.ceil(np.sqrt((width*n_mosaics)/(aspect_ratio*height))))
277
+ ncols = int(np.ceil(n_mosaics/nrows))
278
+
279
+ plotter = pv.Plotter(
280
+ window_size=(ncols*width, nrows*height),
281
+ shape=(nrows, ncols),
282
+ border=False,
283
+ off_screen=True,
284
+ )
285
+ plotter.background_color = 'white'
286
+
287
+ row = 0
288
+ col = 0
289
+ for i, mask_series in tqdm(enumerate(masks), desc=f'Building mosaic'):
290
+
291
+ # Set up plotter
292
+ plotter.subplot(row,col)
293
+ if labels is not None:
294
+ plotter.add_text(labels[i], font_size=6)
295
+ if col == ncols-1:
296
+ col = 0
297
+ row += 1
298
+ else:
299
+ col += 1
300
+
301
+ # Load data
302
+ vol = db.npz.volume(mask_series)
303
+ mask_norm = ssa.sdf_ft.smooth_mask(vol.values.astype(bool), order=32)
304
+
305
+ # Plot tile
306
+ orig_vol = pv.wrap(mask_norm.astype(float))
307
+ orig_vol.spacing = [1.0, 1.0, 1.0]
308
+ orig_surface = orig_vol.contour(isosurfaces=[0.5])
309
+ plotter.add_mesh(orig_surface, color='lightblue', opacity=1.0, style='surface')
310
+ plotter.camera_position = 'iso'
311
+ plotter.view_vector(view_vector) # rotate 180° around vertical axis
312
+
313
+ plotter.screenshot(imagefile)
314
+ plotter.close()
315
+
316
+
317
+ def mosaic_features_npz(features, imagefile, labels=None, view_vector=(1, 0, 0)):
318
+
319
+ # Plot settings
320
+ aspect_ratio = 16/9
321
+ width = 150
322
+ height = 150
323
+
324
+ # Count nr of mosaics
325
+ n_mosaics = len(features)
326
+ nrows = int(np.ceil(np.sqrt((width*n_mosaics)/(aspect_ratio*height))))
327
+ ncols = int(np.ceil(n_mosaics/nrows))
328
+
329
+ plotter = pv.Plotter(
330
+ window_size=(ncols*width, nrows*height),
331
+ shape=(nrows, ncols),
332
+ border=False,
333
+ off_screen=True,
334
+ )
335
+ plotter.background_color = 'white'
336
+
337
+ row = 0
338
+ col = 0
339
+ for i, feat in tqdm(enumerate(features), desc=f'Building mosaic'):
340
+
341
+ # Set up plotter
342
+ plotter.subplot(row,col)
343
+ if labels is not None:
344
+ plotter.add_text(labels[i], font_size=6)
345
+ if col == ncols-1:
346
+ col = 0
347
+ row += 1
348
+ else:
349
+ col += 1
350
+
351
+ # Load data
352
+ ft = np.load(feat)
353
+ mask_norm = ssa.sdf_ft.mask_from_features(ft['features'], ft['shape'], ft['order'])
354
+
355
+ # Plot tile
356
+ orig_vol = pv.wrap(mask_norm.astype(float))
357
+ orig_vol.spacing = [1.0, 1.0, 1.0]
358
+ orig_surface = orig_vol.contour(isosurfaces=[0.5])
359
+ plotter.add_mesh(orig_surface, color='lightblue', opacity=1.0, style='surface')
360
+ plotter.camera_position = 'iso'
361
+ plotter.view_vector(view_vector) # rotate 180° around vertical axis
362
+
363
+ plotter.screenshot(imagefile)
364
+ plotter.close()
@@ -1,11 +1,11 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: miblab-plot
3
- Version: 0.0.2
3
+ Version: 0.0.3
4
4
  Summary: Plotting utilities for medical images
5
5
  Author-email: Steven Sourbron <s.sourbron@sheffield.ac.uk>
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://miblab.org/
8
- Project-URL: Source Code, https://github.com/openmiblab/miblab-plot
8
+ Project-URL: Source Code, https://github.com/openmiblab/pckg-miblab-plot
9
9
  Keywords: python,medical imaging,MRI
10
10
  Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Intended Audience :: Developers
@@ -20,6 +20,11 @@ Requires-Dist: pillow
20
20
  Requires-Dist: numpy
21
21
  Requires-Dist: matplotlib
22
22
  Requires-Dist: tqdm
23
+ Requires-Dist: pyvista
24
+ Requires-Dist: scikit-image
25
+ Requires-Dist: moviepy
26
+ Requires-Dist: dbdicom
27
+ Requires-Dist: zarr
23
28
  Dynamic: license-file
24
29
 
25
30
  # miblab-plot
@@ -0,0 +1,11 @@
1
+ miblab_plot/__init__.py,sha256=1v5zoONP5hCwbc1ncF4dvt2EUssrmcgYoKm4jEpQkuM,130
2
+ miblab_plot/gui.py,sha256=d-0Ks7vdKHAzm9PczXUrevPqBEIJ_mxak81xvXZVb6Y,25758
3
+ miblab_plot/image_3d.py,sha256=nwP_cF--H_rUSrL-Ozp6qzbO-4Y8159TkWOsg27s5b0,10779
4
+ miblab_plot/movie.py,sha256=eXNkas10U7USIlOoKGcl59GdZy6HEZCXXi1XKvIiQiY,2468
5
+ miblab_plot/mp4.py,sha256=QvJa8RPeTXzoOf0zGoLRY2v6iLqgzz-3XKbi5yYbI7w,2825
6
+ miblab_plot/pvplot.py,sha256=mEW1fOArcl3UDShjfXk8kdRaP9kZ7AONvR1QAAC98Ww,11945
7
+ miblab_plot-0.0.3.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
8
+ miblab_plot-0.0.3.dist-info/METADATA,sha256=rZi3f9O3FhD1dh87uTgXmRw9pJ4ACF_9KNmddxCGSh8,1064
9
+ miblab_plot-0.0.3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
10
+ miblab_plot-0.0.3.dist-info/top_level.txt,sha256=w6glSbOeiZvvL3Tywi8XTJxYAatD7VMkOifD5iKnTSU,12
11
+ miblab_plot-0.0.3.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,7 +0,0 @@
1
- miblab_plot/__init__.py,sha256=7k0ANRoyjRqb-6xxqpbR657tOMBf5UtLmtBif_PC93s,109
2
- miblab_plot/image_3d.py,sha256=nwP_cF--H_rUSrL-Ozp6qzbO-4Y8159TkWOsg27s5b0,10779
3
- miblab_plot-0.0.2.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
4
- miblab_plot-0.0.2.dist-info/METADATA,sha256=vAnLpEphCq9CTiEX5JkUONETakTH2ElVgr88HAXBHyg,937
5
- miblab_plot-0.0.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
6
- miblab_plot-0.0.2.dist-info/top_level.txt,sha256=w6glSbOeiZvvL3Tywi8XTJxYAatD7VMkOifD5iKnTSU,12
7
- miblab_plot-0.0.2.dist-info/RECORD,,