1212
1313import numpy as np
1414import matplotlib .pyplot as plt
15+ from iso2mesh .modify import qmeshcut
1516
1617##====================================================================================
1718## implementations
@@ -33,70 +34,100 @@ def m2v(*args):
3334
3435def mesh2vol (node , elem , xi , yi = None , zi = None ):
3536 """
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.
3740
3841 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
4451
4552 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>
4859 """
4960
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 ])
6385 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 ))]
7290
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" )
7593
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
7897
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 :
83108 continue
84109
85- maskz , weightz = mesh2mask (cutpos , facedata , xi , yi , fig )
86- idx = np .where (~ np .isnan (maskz ))
87- mask [:, :, i ] = maskz
88-
89110 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 = (
92120 cutvalue [eid [:, 0 ]] * weightz [0 , idx ]
93121 + cutvalue [eid [:, 1 ]] * weightz [1 , idx ]
94122 + cutvalue [eid [:, 2 ]] * weightz [2 , idx ]
95123 + cutvalue [eid [:, 3 ]] * weightz [3 , idx ]
96124 )
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
98130
99- plt .close (fig )
100131 return mask , weight
101132
102133
@@ -115,6 +146,9 @@ def mesh2mask(node, face, xi, yi=None, hf=None):
115146 mask: 2D image where pixel values correspond to the triangle index
116147 weight: (Optional) Barycentric weights for each triangle
117148 """
149+ from matplotlib .collections import PatchCollection
150+ from matplotlib .patches import Polygon
151+ import matplotlib .cm as cm
118152
119153 # Determine grid size from inputs
120154 if isinstance (xi , (int , float )) and yi is None :
@@ -138,78 +172,112 @@ def mesh2mask(node, face, xi, yi=None, hf=None):
138172 "node must have 2 or 3 columns; face must have at least 3 columns"
139173 )
140174
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 )
146205
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 )
150212
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
153216
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 )
159219
160- # Rendering or saving to image
161- mask = np .zeros (output_size , dtype = np .int32 )
162220 if hf is None :
163221 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-
170222 return mask , weight
171223
172224
173225def barycentricgrid (node , face , xi , yi , mask ):
174226 """
175- Compute barycentric weights for a grid.
227+ Compute barycentric weights for a 2D triangle mesh over a pixel grid.
176228
177229 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.
183238
184239 Returns:
185- weight: Barycentric weights for each triangle
240+ weight : ndarray (3, H, W)
241+ Barycentric coordinate weights for each pixel inside a triangle.
186242 """
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 )
190246
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
194249
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)
196256 tt = (t2 [:, 1 ] - t3 [:, 1 ]) * (t1 [:, 0 ] - t3 [:, 0 ]) + (t3 [:, 0 ] - t2 [:, 0 ]) * (
197257 t1 [:, 1 ] - t3 [:, 1 ]
198258 )
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 ]) + (
201262 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 ]) + (
204265 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
214282
215283 return weight
0 commit comments