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 +6 -1
- miblab_plot/gui.py +652 -0
- miblab_plot/image_3d.py +138 -10
- miblab_plot/movie.py +79 -0
- miblab_plot/mp4.py +83 -0
- miblab_plot/pvplot.py +364 -0
- {miblab_plot-0.0.1.dist-info → miblab_plot-0.0.3.dist-info}/METADATA +7 -2
- miblab_plot-0.0.3.dist-info/RECORD +11 -0
- {miblab_plot-0.0.1.dist-info → miblab_plot-0.0.3.dist-info}/WHEEL +1 -1
- miblab_plot-0.0.1.dist-info/RECORD +0 -7
- {miblab_plot-0.0.1.dist-info → miblab_plot-0.0.3.dist-info}/licenses/LICENSE +0 -0
- {miblab_plot-0.0.1.dist-info → miblab_plot-0.0.3.dist-info}/top_level.txt +0 -0
miblab_plot/__init__.py
CHANGED
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()
|