1111import pyvista as pv
1212import trimesh
1313import point_cloud_utils as pcu
14+ from scipy .spatial import Delaunay
1415
1516from bioio import BioImage
1617
2324#####----------Main Analysis Function----------#####
2425
2526def nuclei_localization (
26- df :pd .DataFrame ,
27+ df :pd .DataFrame ,
2728 data_id :str ,
2829 output_directory :str ,
2930 align_segmentation :bool = True ,
3031 ):
3132 '''
3233 This is the main function to localize nuclei inside a 3D mesh.
33-
34+
3435 Parameters
3536 ----------
3637 manifest_path: str
@@ -49,7 +50,7 @@ def nuclei_localization(
4950
5051 tmp_dir = Path ("./emt_tmp/nuclei_localization/" )
5152 tmp_dir .mkdir (exist_ok = True , parents = True )
52-
53+
5354 # load segmentations and meshes
5455 # First, check for local ZARR file in the reprocessed directory
5556 local_zarr_base = Path ("/allen/aics/emt/all_cells_masks/ZARR_Conversion/August_24_H2B_reprocess_v2/main" )
@@ -69,7 +70,7 @@ def nuclei_localization(
6970 print (f"Using H2B segmentation from quilt manifest: { seg_path } " )
7071 else :
7172 raise ValueError (f"The move { data_id } does not have H2B segmentations" )
72-
73+
7374 # import pdb; pdb.set_trace()
7475 segmentations = BioImage (seg_path )
7576
@@ -117,7 +118,7 @@ def nuclei_localization(
117118
118119 # load meshes
119120 meshes = pv .read (mesh_fn )
120-
121+
121122 # localize nuclei for each timepoint
122123 num_timepoints = int (df ['Image Size T' ].values [0 ])
123124 nuclei = []
@@ -126,7 +127,7 @@ def nuclei_localization(
126127 if f'{ timepoint } ' not in meshes .keys ():
127128 print (f"Mesh for timepoint { timepoint } not found." )
128129 continue
129-
130+
130131 if align_segmentation :
131132 alignment_matrix = alignment .parse_rotation_matrix_from_string (df ['Dual Camera Alignment Matrix Value' ].values [0 ])
132133 else :
@@ -139,11 +140,11 @@ def nuclei_localization(
139140 align_segmentation = align_segmentation ,
140141 alignment_matrix = alignment_matrix
141142 )
142-
143+
143144 nuclei_tp ['Data ID' ] = data_id
144145 nuclei_tp ['Time hr' ] = timepoint / 0.5
145146 nuclei .append (nuclei_tp )
146-
147+
147148 # save nuclei data
148149 nuclei = pd .concat (nuclei )
149150 cols = nuclei .columns .tolist ()
@@ -156,18 +157,100 @@ def nuclei_localization(
156157 rmtree (tmp_dir )
157158
158159
159-
160+
160161#####----------Helper Functions----------#####
161-
162+
163+ def fill_holes_flat_cap (mesh_pv : pv .PolyData ) -> tuple :
164+ """
165+ Fill holes in a mesh by creating a flat cap at the max boundary Z level.
166+ Creates a closed mesh directly without using pcu.make_mesh_watertight()
167+ to avoid double-wall artifacts.
168+
169+ This is an MIT-licensed alternative to PyMeshFix's GPL-licensed repair.
170+
171+ Parameters
172+ ----------
173+ mesh_pv : pv.PolyData
174+ PyVista mesh with holes to fill.
175+
176+ Returns
177+ -------
178+ tuple
179+ (vertices, faces) of the closed mesh.
180+ """
181+ vert = mesh_pv .points .copy ()
182+ faces = mesh_pv .faces .reshape (- 1 , 4 )[:, 1 :].copy ()
183+
184+ # Extract boundary edges (the hole outline)
185+ boundary = mesh_pv .extract_feature_edges (
186+ boundary_edges = True , feature_edges = False ,
187+ manifold_edges = False , non_manifold_edges = False
188+ )
189+ boundary_points = boundary .points
190+
191+ if len (boundary_points ) == 0 :
192+ # No holes found, return as-is
193+ return vert , faces
194+
195+ # Use max Z of boundary as cap level (preserves full biological extent)
196+ cap_z = np .max (boundary_points [:, 2 ])
197+
198+ # Map boundary points to vertex indices in original mesh
199+ boundary_indices = []
200+ for bp in boundary_points :
201+ dists = np .linalg .norm (vert - bp , axis = 1 )
202+ idx = np .argmin (dists )
203+ if dists [idx ] < 0.1 :
204+ boundary_indices .append (idx )
205+ boundary_indices = np .unique (boundary_indices )
206+
207+ # Create cap vertices (same XY as boundary, but at cap_z)
208+ cap_verts = vert [boundary_indices ].copy ()
209+ cap_verts [:, 2 ] = cap_z
210+
211+ # Add cap vertices to mesh
212+ n_orig_verts = len (vert )
213+ new_vert = np .vstack ([vert , cap_verts ])
214+ cap_vert_indices = np .arange (n_orig_verts , n_orig_verts + len (cap_verts ))
215+ boundary_to_cap = dict (zip (boundary_indices , cap_vert_indices ))
216+
217+ # Create side faces connecting boundary to cap
218+ boundary_edges = boundary .lines .reshape (- 1 , 3 )[:, 1 :]
219+ side_faces = []
220+ for edge in boundary_edges :
221+ p1 , p2 = boundary_points [edge [0 ]], boundary_points [edge [1 ]]
222+ d1 = np .linalg .norm (vert - p1 , axis = 1 )
223+ d2 = np .linalg .norm (vert - p2 , axis = 1 )
224+ v1 , v2 = np .argmin (d1 ), np .argmin (d2 )
225+ if v1 in boundary_to_cap and v2 in boundary_to_cap :
226+ c1 , c2 = boundary_to_cap [v1 ], boundary_to_cap [v2 ]
227+ side_faces .append ([v1 , v2 , c2 ])
228+ side_faces .append ([v1 , c2 , c1 ])
229+ side_faces = np .array (side_faces ) if side_faces else np .empty ((0 , 3 ), dtype = int )
230+
231+ # Create cap faces using Delaunay triangulation
232+ cap_xy = cap_verts [:, :2 ]
233+ tri = Delaunay (cap_xy )
234+ cap_faces = cap_vert_indices [tri .simplices ]
235+
236+ # Combine all faces
237+ if len (side_faces ) > 0 :
238+ new_faces = np .vstack ([faces , side_faces , cap_faces ])
239+ else :
240+ new_faces = np .vstack ([faces , cap_faces ])
241+
242+ return new_vert , new_faces
243+
244+
162245def localize_for_timepoint (
163- mesh :pv .PolyData ,
164- seg :np .ndarray ,
246+ mesh :pv .PolyData ,
247+ seg :np .ndarray ,
165248 align_segmentation :bool ,
166249 alignment_matrix :np .ndarray
167250 ):
168251 '''
169252 This function localizes nuclei inside a 3D mesh for a given timepoint.
170-
253+
171254 Parameters
172255 ----------
173256 mesh: pv.PolyData
@@ -179,7 +262,7 @@ def localize_for_timepoint(
179262 barcode: str
180263 Barcode of the movie.
181264 '''
182-
265+
183266 # align segmentation if required
184267 if align_segmentation :
185268 transform = alignment .get_alignment_matrix (alignment_matrix )
@@ -192,31 +275,18 @@ def localize_for_timepoint(
192275 vert = outline_verts [i ]
193276 mesh .extract_feature_edges (boundary_edges = True , feature_edges = False , manifold_edges = False )
194277 new_vert = np .array ([vert [0 ], vert [1 ], max ([vert [2 ], top ])])
195-
278+
196279 v_idx = mesh .find_closest_point (vert )
197280 mesh .points [v_idx ] = new_vert
198281
199- vert , faces = mesh .points , mesh .faces .reshape (mesh .n_faces , 4 )[:,1 :]
200- vert_up = np .zeros_like (vert )
201- np .copyto (vert_up , vert )
202- vert_up [:, 2 ] = max (vert [:,2 ])
203- face_up = np .zeros_like (faces )
204- np .copyto (face_up , faces )
205-
206- mesh = trimesh .Trimesh (vertices = vert , faces = faces )
207- roof = trimesh .Trimesh (vertices = vert_up , faces = face_up )
208- mesh_conc = trimesh .util .concatenate (mesh , roof )
209-
210- vert , faces = mesh_conc .vertices , mesh_conc .faces
211-
212- vw , fw = pcu .make_mesh_watertight (vert , faces , 10000 )
213-
214- mesh = trimesh .Trimesh (vertices = vw , faces = fw )
215-
216282 # transpose segmentation to XYZ coordinates and set z-scale for isotropic resolution
217283 seg = seg .transpose (2 , 1 , 0 )
218284 scale = 2.88 / 0.271
219285
286+ # Fill holes and create watertight mesh using custom flat cap approach
287+ vw , fw = fill_holes_flat_cap (mesh )
288+ mesh = trimesh .Trimesh (vertices = vw , faces = fw )
289+
220290 # initialize ray caster (for checking if a point is inside the mesh)
221291 rayCaster = trimesh .ray .ray_triangle .RayMeshIntersector (mesh )
222292
@@ -227,15 +297,15 @@ def localize_for_timepoint(
227297 nucData ["X" ] = []
228298 nucData ["Y" ] = []
229299 nucData ["Z" ] = []
230-
300+
231301 # localize nuclei
232302 props = regionprops (seg .astype (int ))
233303 for prop in props :
234304 nucData ['Label' ].append (prop .label )
235305 nucData ["X" ].append (int (prop .centroid [0 ]))
236306 nucData ["Y" ].append (int (prop .centroid [1 ]))
237307 nucData ["Z" ].append (int (prop .centroid [2 ]))
238-
308+
239309 # get nuclei centroid (scales to isotropic resolution)
240310 centroid = [
241311 prop .centroid [0 ],
@@ -247,13 +317,13 @@ def localize_for_timepoint(
247317 contains = rayCaster .contains_points ([centroid ])
248318 except :
249319 continue
250-
320+
251321 # check if centroid is inside the mesh
252322 if contains [0 ]:
253323 nucData ['Inside' ].append (True )
254324 else :
255325 nucData ['Inside' ].append (False )
256-
326+
257327 return pd .DataFrame (nucData )
258328
259329
@@ -266,7 +336,7 @@ def run_nuclei_localization(
266336 ):
267337 '''
268338 This is the main function to localize nuclei inside a 3D mesh.
269-
339+
270340 Parameters
271341 ----------
272342 manifest_path: str
@@ -314,4 +384,4 @@ def run_nuclei_localization(
314384 run_nuclei_localization (
315385 df_manifest = manifest ,
316386 output_directory = output_dir
317- )
387+ )
0 commit comments