Skip to content

Commit 8054b98

Browse files
committed
Replace Nuclei_localization with flat-cap mesh closing (no watertight)
Replaces Nuclei_localization.py with improved version that uses flat-cap mesh closing instead of pcu.make_mesh_watertight() to avoid double-wall artifacts in the mesh geometry. Changes: - Use fill_holes_flat_cap() to create closed mesh directly with Delaunay triangulation, without calling pcu.make_mesh_watertight() - Add support for local ZARR files (batch1 and batch2_v2 paths) - Add support for local mesh files before falling back to S3 bucket - Handle both mesh naming conventions (DataID-prefixed and generic) - Add error handling for mesh download failures Removed files: - Nuclei_localization_improved.py (used watertight mesh generation) - Nuclei_localization_pymeshfix.py (used pymeshfix GPL library)
1 parent 654a060 commit 8054b98

3 files changed

Lines changed: 108 additions & 734 deletions

File tree

EMT_data_analysis/analysis_scripts/Nuclei_localization.py

Lines changed: 108 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import pyvista as pv
1212
import trimesh
1313
import point_cloud_utils as pcu
14+
from scipy.spatial import Delaunay
1415

1516
from bioio import BioImage
1617

@@ -23,14 +24,14 @@
2324
#####----------Main Analysis Function----------#####
2425

2526
def 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+
162245
def 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

Comments
 (0)