Skip to content

Commit 5a7fdb8

Browse files
committed
tree/export: add parallel tube generation and radius-based sizing
- Introduce generate_tubes_parallel using multiprocessing with per-process temp dirs to avoid MMG temp file collisions. - Add optional radius_based flag to generate_tube/generate_tubes_parallel; build MeshSizingFunction via KDTree k-NN and write in.sol. - When using sizing function, omit -hsiz so MMG uses per-vertex sizes. - Minor: progress bar during parallel build; ensure positive sizing values; normals computed after remesh. - Update build_watertight_solid to use the parallel path and balanced union.
1 parent 988576a commit 5a7fdb8

File tree

1 file changed

+154
-11
lines changed

1 file changed

+154
-11
lines changed

svv/tree/export/export_solid.py

Lines changed: 154 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import numpy
2+
import os
3+
import tempfile
4+
import multiprocessing as mp
25
import pyvista
36
import pymeshfix
47
from tqdm import trange, tqdm
58
from scipy.interpolate import splprep, splev
6-
from svv.utils.remeshing.remesh import remesh_surface
9+
from scipy.spatial import cKDTree
10+
from svv.utils.remeshing.remesh import remesh_surface, write_medit_sol
711
from svv.domain.routines.boolean import boolean
812

913

@@ -455,7 +459,7 @@ def generate_polylines(xyz, r, num=1000):
455459
return polylines
456460

457461

458-
def generate_tube(polyline, hsize=None):
462+
def generate_tube(polyline, hsize=None, radius_based=False):
459463
"""
460464
This function generates a tube from a given polyline representing a single vessel of a
461465
vascular object (tree or forest).
@@ -465,7 +469,12 @@ def generate_tube(polyline, hsize=None):
465469
polyline : pyvista.PolyData
466470
A polyline object representing the centerline of a vessel.
467471
hsize : float
468-
The mesh element size for the surface mesh of the vessel.
472+
The mesh element size for the surface mesh of the vessel. When
473+
radius_based is True, this acts as the target size at the minimum
474+
centerline radius and scales proportionally elsewhere.
475+
radius_based : bool
476+
If True, writes a per-vertex sizing function (in.sol) proportional to
477+
the local centerline radius so MMG adapts edge sizes accordingly.
469478
470479
Returns
471480
-------
@@ -482,7 +491,41 @@ def generate_tube(polyline, hsize=None):
482491
tube = tube.compute_normals(auto_orient_normals=True)
483492
if isinstance(hsize, type(None)):
484493
hsize = (min(polyline['radius'])*2*numpy.pi)/25
485-
tube = remesh_surface(tube, hsiz=hsize)
494+
if radius_based:
495+
# Per-vertex sizing based on local centerline radius using k-NN from KDTree.
496+
# Scale so hsize matches the size at the minimum radius; elsewhere scales proportionally.
497+
poly_pts = numpy.asarray(polyline.points, dtype=float)
498+
poly_rad = numpy.asarray(polyline['radius'], dtype=float).reshape(-1)
499+
rmin = float(poly_rad.min()) if poly_rad.size else 1.0
500+
scale = float(hsize) / rmin if rmin > 0 else float(hsize)
501+
502+
surf_pts = numpy.asarray(tube.points, dtype=float)
503+
n_poly = poly_pts.shape[0]
504+
k_nn = min(4, n_poly) if n_poly > 0 else 1
505+
if n_poly == 0:
506+
sizes = numpy.full(surf_pts.shape[0], float(hsize), dtype=float)
507+
else:
508+
tree = cKDTree(poly_pts)
509+
d, idx = tree.query(surf_pts, k=k_nn)
510+
# Handle k=1 return shape
511+
if k_nn == 1:
512+
r_local = poly_rad[numpy.asarray(idx).reshape(-1)].astype(float)
513+
else:
514+
d = numpy.asarray(d, dtype=float)
515+
idx = numpy.asarray(idx, dtype=int)
516+
# Inverse-distance weights; protect against zero distances
517+
w = 1.0 / (d + 1e-12)
518+
w /= w.sum(axis=1, keepdims=True)
519+
r_neighbors = poly_rad[idx]
520+
r_local = (w * r_neighbors).sum(axis=1)
521+
sizes = scale * r_local
522+
# Ensure strictly positive sizes for MMG
523+
sizes = numpy.maximum(sizes, numpy.finfo(float).eps)
524+
tube.point_data['MeshSizingFunction'] = sizes
525+
# Write MMG sizing file the remesher will pick up in this temp directory
526+
write_medit_sol(tube, 'in.sol', array_name='MeshSizingFunction', scale=1, default_size=hsize)
527+
# If using a sizing function, let MMG drive sizes solely from in.sol (omit -hsiz)
528+
tube = remesh_surface(tube, hsiz=(None if radius_based else hsize), verbosity=0)
486529
tube = tube.compute_normals(auto_orient_normals=True)
487530
fix = pymeshfix.MeshFix(tube)
488531
fix.repair()
@@ -515,6 +558,103 @@ def generate_tubes(polylines, hsize=None):
515558
return tubes
516559

517560

561+
def _tube_worker(args):
562+
"""Worker that builds a single tube in an isolated temp directory.
563+
564+
Args
565+
----
566+
args : tuple
567+
(idx, points, radius, hsize, radius_based)
568+
569+
Returns
570+
-------
571+
tuple
572+
(idx, points, faces) for reconstructed PolyData.
573+
"""
574+
idx, pts, rad, hsize, radius_based = args
575+
old_cwd = os.getcwd()
576+
# Isolate MMG temp files to avoid collisions between processes
577+
with tempfile.TemporaryDirectory(prefix="svv_remesh_") as tmpdir:
578+
try:
579+
os.chdir(tmpdir)
580+
poly = polyline_from_points(numpy.asarray(pts), numpy.asarray(rad))
581+
tube = generate_tube(poly, hsize=hsize, radius_based=radius_based)
582+
# Return minimal geometry to avoid pickling VTK objects
583+
return idx, numpy.asarray(tube.points), numpy.asarray(tube.faces)
584+
finally:
585+
os.chdir(old_cwd)
586+
587+
588+
def generate_tubes_parallel(polylines, hsize=None, processes=None, chunksize=1, start_method=None, show_progress=True, radius_based=False):
589+
"""
590+
Parallel tube generation using multiprocessing. Each tube is built in a
591+
separate process and in a per-process temporary directory to avoid MMG
592+
temp-file collisions.
593+
594+
Parameters
595+
----------
596+
polylines : list[pyvista.PolyData]
597+
Centerline polylines with point-data array 'radius'.
598+
hsize : float, optional
599+
Target surface edge size for remeshing (forwarded to generate_tube).
600+
processes : int, optional
601+
Number of worker processes. Defaults to `os.cpu_count()`.
602+
chunksize : int, optional
603+
Chunk size for Pool.imap. Default 1.
604+
start_method : {"spawn","fork","forkserver"}, optional
605+
Multiprocessing start method. Defaults to 'spawn' when available.
606+
show_progress : bool, optional
607+
If True, display a tqdm progress bar.
608+
radius_based : bool, optional
609+
If True, build a per-vertex MMG sizing function based on the local
610+
centerline radius and pass it via in.sol, yielding radius-proportional
611+
edge sizes. Default False.
612+
613+
Returns
614+
-------
615+
list[pyvista.PolyData]
616+
Reconstructed tube meshes in the same order as input polylines.
617+
"""
618+
n = len(polylines)
619+
if n == 0:
620+
return []
621+
622+
# Serialize inputs (avoid sending VTK objects across processes)
623+
tasks = []
624+
for i, pl in enumerate(polylines):
625+
pts = numpy.asarray(pl.points)
626+
if 'radius' not in pl.point_data:
627+
raise KeyError("Each polyline must have a 'radius' point-data array")
628+
rad = numpy.asarray(pl['radius']).reshape(-1)
629+
tasks.append((i, pts, rad, hsize, radius_based))
630+
631+
# Choose a safe start method to avoid forking VTK state
632+
if start_method is None:
633+
try:
634+
ctx = mp.get_context('spawn')
635+
except ValueError:
636+
ctx = mp.get_context()
637+
else:
638+
ctx = mp.get_context(start_method)
639+
640+
tubes_arrays = [None] * n
641+
with ctx.Pool(processes=processes) as pool:
642+
iterator = pool.imap(_tube_worker, tasks, chunksize)
643+
if show_progress:
644+
iterator = tqdm(iterator, total=n, desc='Generate tubes ', unit='tube', leave=False)
645+
for idx, pts, faces in iterator:
646+
tubes_arrays[idx] = (pts, faces)
647+
648+
# Reconstruct PolyData objects in parent process
649+
tubes = []
650+
for i in range(n):
651+
pts, faces = tubes_arrays[i]
652+
tube = pyvista.PolyData(pts, faces)
653+
# Ensure normals are available
654+
tube = tube.compute_normals(auto_orient_normals=True)
655+
tubes.append(tube)
656+
return tubes
657+
518658
def union_tubes(tubes, lines, cap_resolution=40):
519659
"""
520660
This function performs iterative boolean union operations on a list of Pyvista polydata surface
@@ -564,10 +704,10 @@ def build_watertight_solid(tree, cap_resolution=40):
564704
"""
565705
xyz, r, _, _, branches, _ = get_interpolated_sv_data(tree.data)
566706
lines = generate_polylines(xyz, r)
567-
tubes = generate_tubes(lines)
568-
model = union_tubes(tubes, lines, cap_resolution=cap_resolution)
707+
tubes = generate_tubes_parallel(lines, radius_based=True)
708+
model = union_tubes_balanced(tubes, lines, cap_resolution=cap_resolution)
569709
# Remove poor quality elements and repair the mesh.
570-
# cell_quality = model.compute_cell_quality(quality_measure='scaled_jacobian')
710+
cell_quality = model.compute_cell_quality(quality_measure='scaled_jacobian')
571711
keep = cell_quality.cell_data["CellQuality"] > 0.1
572712
if not numpy.all(keep):
573713
print("Removing poor quality elements from the mesh.")
@@ -586,7 +726,7 @@ def build_watertight_solid(tree, cap_resolution=40):
586726

587727
def union_tubes_balanced(tubes, lines, cap_resolution=40, method='centerline', engine='manifold', fix_mesh=True):
588728
"""
589-
Perform topology-aware, size-balanced boolean unions over a set of vessel tubes.
729+
Perform topology-aware, compute-balanced boolean unions over a set of vessel tubes.
590730
591731
The function:
592732
- Detects unionable parent/child pairs using `find_unionable_pairs` (child's
@@ -707,7 +847,7 @@ def union(a, b):
707847
if comp_count > 0:
708848
total_merges += max(comp_count - 1, 0)
709849

710-
pbar = tqdm(total=total_merges, desc='Union tubes (balanced)', unit='union', leave=False)
850+
pbar = tqdm(total=total_merges, desc='Union tubes ', unit='union', leave=False)
711851

712852
# Iteratively union smallest available pairs until components are reduced
713853
while heap:
@@ -765,9 +905,12 @@ def union(a, b):
765905
# Fallback heuristic if radii missing
766906
hsize = None
767907

768-
if hsize is not None and hsize > 0:
769-
model = remesh_surface(model, hsiz=hsize)
908+
#if hsize is not None and hsize > 0:
909+
# model = remesh_surface(model, hsiz=hsize, verbosity=0)
770910
model = model.compute_normals(auto_orient_normals=True)
911+
#fix = pymeshfix.MeshFix(model)
912+
#fix.repair()
913+
#model = fix.mesh
771914
if hsize is not None:
772915
model.cell_data['hsize'] = 0
773916
model.cell_data['hsize'][0] = hsize

0 commit comments

Comments
 (0)