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 +3 -2
- miblab_plot/gui.py +652 -0
- miblab_plot/movie.py +79 -0
- miblab_plot/mp4.py +83 -0
- miblab_plot/pvplot.py +364 -0
- {miblab_plot-0.0.2.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.2.dist-info → miblab_plot-0.0.3.dist-info}/WHEEL +1 -1
- miblab_plot-0.0.2.dist-info/RECORD +0 -7
- {miblab_plot-0.0.2.dist-info → miblab_plot-0.0.3.dist-info}/licenses/LICENSE +0 -0
- {miblab_plot-0.0.2.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()
|
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.
|
|
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,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,,
|
|
File without changes
|
|
File without changes
|