12
12
13
13
import numpy as np
14
14
import matplotlib .pyplot as plt
15
+ from iso2mesh .modify import qmeshcut
15
16
16
17
##====================================================================================
17
18
## implementations
@@ -33,70 +34,100 @@ def m2v(*args):
33
34
34
35
def mesh2vol (node , elem , xi , yi = None , zi = None ):
35
36
"""
36
- Fast rasterization of a 3D mesh to a volume with tetrahedron index labels.
37
+ mesh2vol(node, elem, xi, yi=None, zi=None)
38
+
39
+ Fast rasterization of a 3D tetrahedral mesh into a volumetric label image.
37
40
38
41
Parameters:
39
- node: Node coordinates (Nx3 array)
40
- elem: Tetrahedron element list (Nx4 array)
41
- xi: Grid or number of divisions along x-axis
42
- yi: (Optional) Grid along y-axis
43
- zi: (Optional) Grid along z-axis
42
+ node : ndarray
43
+ Node coordinates (N x 3) or (N x 4, with values in 4th column)
44
+ elem : ndarray
45
+ Tetrahedral elements (M x 4 or M x >4)
46
+ xi, yi, zi : array-like or scalar
47
+ Grid definitions. Supports:
48
+ - scalar: voxel resolution
49
+ - [Nx, Ny, Nz]: volume size
50
+ - xi, yi, zi: actual grid vectors
44
51
45
52
Returns:
46
- mask: 3D volume where voxel values correspond to the tetrahedron index
47
- weight: (Optional) Barycentric weights for each voxel
53
+ mask : 3D ndarray
54
+ Voxelized volume with element labels
55
+ weight : 4 x Nx x Ny x Nz array (if requested or values present)
56
+
57
+ Author:
58
+ Qianqian Fang <q.fang at neu.edu>
48
59
"""
49
60
50
- # Check if xi is scalar or list of grid divisions
51
- if isinstance (xi , (int , float )) and yi is None and zi is None :
52
- mn = np .min (node , axis = 0 )
53
- mx = np .max (node , axis = 0 )
54
- df = (mx [:3 ] - mn [:3 ]) / xi
55
- elif len (xi ) == 3 and yi is None and zi is None :
56
- mn = np .min (node , axis = 0 )
57
- mx = np .max (node , axis = 0 )
58
- df = (mx [:3 ] - mn [:3 ]) / xi
59
- elif yi is not None and zi is not None :
60
- mx = [np .max (xi ), np .max (yi ), np .max (zi )]
61
- mn = [np .min (xi ), np .min (yi ), np .min (zi )]
62
- df = [np .min (np .diff (xi )), np .min (np .diff (yi )), np .min (np .diff (zi ))]
61
+ node = np .array (node , dtype = np .float64 )
62
+ elem = np .array (elem , dtype = np .int32 )
63
+
64
+ nodeval = None
65
+ if node .shape [1 ] == 4 :
66
+ nodeval = node [:, 3 ].copy ()
67
+ node = node [:, :3 ]
68
+
69
+ if yi is None and zi is None :
70
+ if isinstance (xi , (int , float )):
71
+ mn = np .min (node , axis = 0 )
72
+ mx = np .max (node , axis = 0 )
73
+ df = (mx - mn ) / xi
74
+ elif isinstance (xi , (list , tuple , np .ndarray )) and len (xi ) == 3 :
75
+ mn = np .min (node , axis = 0 )
76
+ mx = np .max (node , axis = 0 )
77
+ df = (mx - mn ) / np .array (xi )
78
+ else :
79
+ raise ValueError (
80
+ "xi must be scalar or 3-element vector if yi and zi are not provided"
81
+ )
82
+ xi = np .arange (mn [0 ], mx [0 ] + df [0 ], df [0 ])
83
+ yi = np .arange (mn [1 ], mx [1 ] + df [1 ], df [1 ])
84
+ zi = np .arange (mn [2 ], mx [2 ] + df [2 ], df [2 ])
63
85
else :
64
- raise ValueError ("At least xi input is required" )
65
-
66
- xi = np .arange (mn [0 ], mx [0 ], df [0 ])
67
- yi = np .arange (mn [1 ], mx [1 ], df [1 ])
68
- zi = np .arange (mn [2 ], mx [2 ], df [2 ])
69
-
70
- if node .shape [1 ] != 3 or elem .shape [1 ] <= 3 :
71
- raise ValueError ("node must have 3 columns; elem must have 4 columns" )
86
+ xi = np .array (xi )
87
+ yi = np .array (yi )
88
+ zi = np .array (zi )
89
+ df = [np .min (np .diff (xi )), np .min (np .diff (yi )), np .min (np .diff (zi ))]
72
90
73
- mask = np . zeros (( len ( xi ) - 1 , len ( yi ) - 1 , len ( zi ) - 1 ), dtype = int )
74
- weight = None
91
+ if node . shape [ 1 ] != 3 or elem . shape [ 1 ] < 4 :
92
+ raise ValueError ( "node must have 3 columns; elem must have 4 or more columns" )
75
93
76
- if len (elem .shape ) > 1 :
77
- weight = np .zeros ((4 , len (xi ) - 1 , len (yi ) - 1 , len (zi ) - 1 ))
94
+ nx , ny , nz = len (xi ), len (yi ), len (zi )
95
+ mask = np .zeros ((nx , ny , nz ))
96
+ weight = np .zeros ((4 , nx , ny , nz )) if nodeval is not None else None
78
97
79
- fig = plt .figure ()
80
- for i in range (len (zi )):
81
- cutpos , cutvalue , facedata , elemid = qmeshcut (elem , node , zi [i ])
82
- if cutpos is None :
98
+ for i , zval in enumerate (zi [:- 1 ]):
99
+ if nodeval is not None :
100
+ cutpos , cutvalue , facedata , elemid , _ = qmeshcut (
101
+ elem , node , nodeval , f"z={ zval } "
102
+ )
103
+ else :
104
+ cutpos , cutvalue , facedata , elemid , _ = qmeshcut (
105
+ elem , node , node [:, 0 ], f"z={ zval } "
106
+ )
107
+ if cutpos is None or len (cutpos ) == 0 :
83
108
continue
84
109
85
- maskz , weightz = mesh2mask (cutpos , facedata , xi , yi , fig )
86
- idx = np .where (~ np .isnan (maskz ))
87
- mask [:, :, i ] = maskz
88
-
89
110
if weight is not None :
90
- eid = facedata [maskz [idx ]]
91
- maskz [idx ] = (
111
+ maskz , weightz = mesh2mask (cutpos , facedata , xi , yi )
112
+ weight [:, :, :, i ] = weightz
113
+ else :
114
+ maskz = mesh2mask (cutpos , facedata , xi , yi )[0 ]
115
+
116
+ idx = ~ np .isnan (maskz )
117
+ if nodeval is not None :
118
+ eid = facedata [maskz [idx ].astype (int ) - 1 ] # 1-based to 0-based
119
+ maskz_flat = (
92
120
cutvalue [eid [:, 0 ]] * weightz [0 , idx ]
93
121
+ cutvalue [eid [:, 1 ]] * weightz [1 , idx ]
94
122
+ cutvalue [eid [:, 2 ]] * weightz [2 , idx ]
95
123
+ cutvalue [eid [:, 3 ]] * weightz [3 , idx ]
96
124
)
97
- weight [:, :, :, i ] = weightz
125
+ maskz [idx ] = maskz_flat
126
+ else :
127
+ maskz [idx ] = elemid [(maskz [idx ] - 1 ).astype (int )] # adjust 1-based index
128
+
129
+ mask [:, :, i ] = maskz
98
130
99
- plt .close (fig )
100
131
return mask , weight
101
132
102
133
@@ -115,6 +146,9 @@ def mesh2mask(node, face, xi, yi=None, hf=None):
115
146
mask: 2D image where pixel values correspond to the triangle index
116
147
weight: (Optional) Barycentric weights for each triangle
117
148
"""
149
+ from matplotlib .collections import PatchCollection
150
+ from matplotlib .patches import Polygon
151
+ import matplotlib .cm as cm
118
152
119
153
# Determine grid size from inputs
120
154
if isinstance (xi , (int , float )) and yi is None :
@@ -138,78 +172,112 @@ def mesh2mask(node, face, xi, yi=None, hf=None):
138
172
"node must have 2 or 3 columns; face must have at least 3 columns"
139
173
)
140
174
141
- # If no figure handle is provided, create one
142
- if hf is None :
143
- fig = plt .figure ()
144
- else :
145
- plt .clf ()
175
+ fig = (
176
+ plt .figure (
177
+ figsize = (xi .size * 0.01 , yi .size * 0.01 ), dpi = 100 , layout = "compressed"
178
+ )
179
+ if hf is None
180
+ else hf
181
+ )
182
+ ax = fig .add_subplot (111 )
183
+ ax .set_position ([0 , 0 , 1 , 1 ])
184
+ ax .set_xlim (mn [0 ], mx [0 ])
185
+ ax .set_ylim (mn [1 ], mx [1 ])
186
+ ax .set_axis_off ()
187
+
188
+ colors = cm .gray (np .linspace (0 , 1 , len (face )))
189
+
190
+ patches = []
191
+ for i , f in enumerate (face [:, :3 ]):
192
+ polygon = Polygon (
193
+ node [f - 1 , :2 ],
194
+ closed = True ,
195
+ edgecolor = "none" ,
196
+ linewidth = 0 ,
197
+ linestyle = "none" ,
198
+ )
199
+ patches .append (polygon )
200
+
201
+ collection = PatchCollection (
202
+ patches , facecolors = colors , linewidths = 0.01 , edgecolors = "none" , edgecolor = "face"
203
+ )
204
+ ax .add_collection (collection )
146
205
147
- # Rasterize the mesh to an image
148
- plt .gca ().patch .set_visible (False )
149
- plt .gca ().set_position ([0 , 0 , 1 , 1 ])
206
+ plt .draw ()
207
+ fig .canvas .draw ()
208
+ img = np .array (fig .canvas .renderer .buffer_rgba ())
209
+ mask_raw = img [:, :, 0 ]
210
+ mask = np .zeros (mask_raw .shape , dtype = np .int32 )
211
+ color_vals = (colors [:, :3 ] * 255 ).astype (np .uint8 )
150
212
151
- cmap = plt .get_cmap ("jet" , len (face ))
152
- plt .pcolormesh (node [:, 0 ], node [:, 1 ], np .arange (len (face )), cmap = cmap )
213
+ for idx , cval in enumerate (color_vals ):
214
+ match = np .all (img [:, :, :3 ] == cval , axis = - 1 )
215
+ mask [match ] = idx + 1
153
216
154
- # Set axis limits
155
- plt .xlim ([mn [0 ], mx [0 ]])
156
- plt .ylim ([mn [1 ], mx [1 ]])
157
- plt .clim ([1 , len (face )])
158
- output_size = np .round ((mx [:2 ] - mn [:2 ]) / df ).astype (int )
217
+ mask = mask [: len (yi ), : len (xi )].T
218
+ weight = barycentricgrid (node , face , xi , yi , mask )
159
219
160
- # Rendering or saving to image
161
- mask = np .zeros (output_size , dtype = np .int32 )
162
220
if hf is None :
163
221
plt .close (fig )
164
-
165
- # Optional weight calculation (if requested)
166
- weight = None
167
- if yi is not None :
168
- weight = barycentricgrid (node , face , xi , yi , mask )
169
-
170
222
return mask , weight
171
223
172
224
173
225
def barycentricgrid (node , face , xi , yi , mask ):
174
226
"""
175
- Compute barycentric weights for a grid.
227
+ Compute barycentric weights for a 2D triangle mesh over a pixel grid.
176
228
177
229
Parameters:
178
- node: Node coordinates
179
- face: Triangle surface
180
- xi: x-axis grid
181
- yi: y-axis grid
182
- mask: Rasterized triangle mask
230
+ node : ndarray (N, 2 or 3)
231
+ Node coordinates.
232
+ face : ndarray (M, 3)
233
+ Triangle face indices (1-based).
234
+ xi, yi : 1D arrays
235
+ Grid coordinate vectors.
236
+ mask : 2D ndarray
237
+ Label image where each pixel contains the triangle index (1-based), NaN if outside.
183
238
184
239
Returns:
185
- weight: Barycentric weights for each triangle
240
+ weight : ndarray (3, H, W)
241
+ Barycentric coordinate weights for each pixel inside a triangle.
186
242
"""
187
- xx , yy = np .meshgrid (xi , yi )
188
- idx = ~ np . isnan ( mask )
189
- eid = mask [ idx ]
243
+ xx , yy = np .meshgrid (xi , yi , indexing = "ij" ) # shape: (H, W )
244
+ mask = mask . astype ( float )
245
+ valid_idx = ~ np . isnan ( mask )
190
246
191
- t1 = node [face [:, 0 ], :]
192
- t2 = node [face [:, 1 ], :]
193
- t3 = node [face [:, 2 ], :]
247
+ # 1-based to 0-based index
248
+ eid = mask [valid_idx ].astype (int ) - 1
194
249
195
- # Calculate barycentric coordinates
250
+ # triangle vertices (all triangles)
251
+ t1 = node [face [:, 0 ] - 1 ]
252
+ t2 = node [face [:, 1 ] - 1 ]
253
+ t3 = node [face [:, 2 ] - 1 ]
254
+
255
+ # denominator (twice the area of each triangle)
196
256
tt = (t2 [:, 1 ] - t3 [:, 1 ]) * (t1 [:, 0 ] - t3 [:, 0 ]) + (t3 [:, 0 ] - t2 [:, 0 ]) * (
197
257
t1 [:, 1 ] - t3 [:, 1 ]
198
258
)
199
- w = np .zeros ((len (idx ), 3 ))
200
- w [:, 0 ] = (t2 [eid , 1 ] - t3 [eid , 1 ]) * (xx [idx ] - t3 [eid , 0 ]) + (
259
+
260
+ # numerator for w1 and w2 (barycentric weights)
261
+ w1 = (t2 [eid , 1 ] - t3 [eid , 1 ]) * (xx [valid_idx ] - t3 [eid , 0 ]) + (
201
262
t3 [eid , 0 ] - t2 [eid , 0 ]
202
- ) * (yy [idx ] - t3 [eid , 1 ])
203
- w [:, 1 ] = (t3 [eid , 1 ] - t1 [eid , 1 ]) * (xx [idx ] - t3 [eid , 0 ]) + (
263
+ ) * (yy [valid_idx ] - t3 [eid , 1 ])
264
+ w2 = (t3 [eid , 1 ] - t1 [eid , 1 ]) * (xx [valid_idx ] - t3 [eid , 0 ]) + (
204
265
t1 [eid , 0 ] - t3 [eid , 0 ]
205
- ) * (yy [idx ] - t3 [eid , 1 ])
206
- w [:, 0 ] /= tt [eid ]
207
- w [:, 1 ] /= tt [eid ]
208
- w [:, 2 ] = 1 - w [:, 0 ] - w [:, 1 ]
209
-
210
- weight = np .zeros ((3 , mask .shape [0 ], mask .shape [1 ]))
211
- weight [0 , idx ] = w [:, 0 ]
212
- weight [1 , idx ] = w [:, 1 ]
213
- weight [2 , idx ] = w [:, 2 ]
266
+ ) * (yy [valid_idx ] - t3 [eid , 1 ])
267
+
268
+ w1 = w1 / tt [eid ]
269
+ w2 = w2 / tt [eid ]
270
+ w3 = 1 - w1 - w2
271
+
272
+ # Assemble the weight volume
273
+ weight = np .zeros ((3 , * mask .shape ), dtype = np .float32 )
274
+ ww = np .zeros_like (mask , dtype = np .float32 )
275
+
276
+ ww [valid_idx ] = w1
277
+ weight [0 , :, :] = ww
278
+ ww [valid_idx ] = w2
279
+ weight [1 , :, :] = ww
280
+ ww [valid_idx ] = w3
281
+ weight [2 , :, :] = ww
214
282
215
283
return weight
0 commit comments