import math from vmtk import vtkvmtk from vtk.util import numpy_support import numpy as np import SimpleITK as sitk import vtk # Coordinate conversion helpers (Left-Posterior-Superior ↔ Right-Anterior-Superior) by flipping X & Y # LPS: +X is patient's left, +Y is Posterior (back), +Z is Superior (head), RPS: +X is Patient's Right, +Y is Anterior (front) def lps_ras_conversion(polydata): transform = vtk.vtkTransform() transform.Scale(-1.0, -1.0, 1.0) tf = vtk.vtkTransformPolyDataFilter() tf.SetInputData(polydata) tf.SetTransform(transform) tf.Update() return tf.GetOutput() def lps_endpoints_to_ras(endpoints): return [(-x, -y, z, is_start) for x, y, z, is_start in endpoints] # Reads the NRRD segmentation using SimpleITK instead of vmtkImageReader as it correctly preserves full 3x3 direction cosine matrix def load_nrrd_as_vtk_image(path): sitk_img = sitk.ReadImage(path) arr = sitk.GetArrayFromImage(sitk_img) # Auto-binarizes multi-label segmentations (any non-zero voxel → 1) arr_max = arr.max() if arr_max > 1 or (arr_max == 1 and arr.min() < 0): arr = (arr > 0).astype(np.uint8) elif arr.dtype != np.uint8: arr = arr.astype(np.uint8) spacing = sitk_img.GetSpacing() # (sx, sy, sz) in mm, indicates distance between the center of two adjacent voxels origin = sitk_img.GetOrigin() # (ox, oy, oz) in mm direction = sitk_img.GetDirection() # 3×3 direction cosine matrix # Build vtkImageData matching the SimpleITK geometry (VTK functions only accept vtkImageData) vtk_img = vtk.vtkImageData() vtk_img.SetDimensions(arr.shape[::-1]) # SimpleITK is (z,y,x), VTK needs (x,y,z) vtk_img.SetSpacing(spacing) vtk_img.SetOrigin(origin) # Flattens the 3D numpy array (shape [Z, Y, X]) into a 1D array in row-major (C) order, then converts it to vtkDataArray using deep copy to allow garbage collection vtk_arr = numpy_support.numpy_to_vtk(num_array=arr.ravel(order="C"), deep=True) vtk_arr.SetName("Image") # Names the scalar array so VTK knows it's the primary scalar dataset vtk_img.GetPointData().SetScalars(vtk_arr) return vtk_img, origin, direction # Main centerline network computation pipeline, converts VTK image (representing segmentation file) into complete centerline network def compute_centerlines_network( image, origin, direction, label_value=1, surface_smoothing=0.3, surface_decimation=0.0, keep_all_components=True, start_point=None, curve_sampling_distance=1.0 ): # Build closed surface mesh from segmentation, generating both LPS surface (for skeletonization) and RAS surface (for centerline extraction) surface_polydata_lps = create_closed_surface_polydata(image, origin, direction, label_value, surface_smoothing, surface_decimation, keep_all_components, to_ras=False) surface_polydata_ras = lps_ras_conversion(surface_polydata_lps) # Skeletonize the surface mesh to get the network topolgy (vessel endpoints and radius at each point) print("\nExtracting skeleton centerline network...") network = skeletonize( image, origin, direction, surface_polydata_lps, label_value, surface_smoothing, surface_decimation, keep_all_components, target_number_of_points=5000, subdivide=False, start_point=start_point, compute_geometry=False, ) # Detect the aorta root, iliac endpoints, branch endpoints from the skeleton network print("\nDetecting aorta and iliac endpoints from network...") aorta_iliac_endpoints, branch_endpoints = detect_centerline_endpoints(network) if not aorta_iliac_endpoints: print("Failed to detect aorta-iliac endpoints from network") return None, [] # Convert detected endpoints from LPS to RAS (centerline extraction works in RAS) aorta_iliac_endpoints_ras = lps_endpoints_to_ras(aorta_iliac_endpoints) # Decimate the surface by reducing surface mesh points before voronoi centerline extraction as it scales badly with large surfaces #surface_for_centerline = decimate_surface_for_voronoi(surface_polydata_ras, target_points=50000) # Extract main aorta→iliacs centerline via Voronoi method try: print("Extracting main centerline...") centerline_polydata = extract_centerline_from_surface(surface_polydata_ras, aorta_iliac_endpoints_ras, curve_sampling_distance, smooth_iterations=20) print(f"Main centerline: {centerline_polydata.GetNumberOfCells()} cells, {centerline_polydata.GetNumberOfPoints()} points") except Exception as e: print(f"Main centerline extraction failed: {type(e).__name__}: {e}") return None, [] # Extract branching arteries centerlines branch_centerlines = [] branch_bifurcations = [] if centerline_polydata and branch_endpoints: try: branch_centerlines, branch_bifurcations = process_all_branches(branch_endpoints, centerline_polydata, surface_polydata_ras, curve_sampling_distance) except Exception as e: print(f"Branch extraction failed: {type(e).__name__}: {e}") # Merge main + branch centerlines if branch_centerlines: try: merged = merge_main_and_branch_centerlines(centerline_polydata, branch_centerlines) except Exception as e: print(f"Merging centerlines failed: {e}") merged = centerline_polydata else: merged = centerline_polydata # Convert final result from RAS back to LPS (to match expected coordinate system) complete_centerline = lps_ras_conversion(merged) branch_junctions_lps = [(-c[0], -c[1], c[2]) for c in branch_bifurcations] return complete_centerline, branch_junctions_lps # Converts a binary labelmap into a closed 3D surface mesh following the same algorithm as 3D Slicer's vtkBinaryLabelmapToClosedSurfaceConversionRule def create_closed_surface_polydata(image, origin, direction, label_value, smoothing, decimation, keep_all_components, to_ras=True, conversion_method="flying_edges"): smoothing = max(0.0, min(1.0, float(smoothing))) decimation = max(0.0, min(1.0, float(decimation))) label_value = int(label_value) # Cast segmentation VTK image to unsigned short (discrete surface filters require integer types) cast = vtk.vtkImageCast() cast.SetInputData(image) cast.SetOutputScalarTypeToUnsignedShort() cast.Update() image_data = cast.GetOutput() # Pad by 1 voxel if border voxels are non-background (prevents open edges) if is_labelmap_padding_necessary(image_data): padder = vtk.vtkImageConstantPad() padder.SetInputData(image_data) extent = list(image_data.GetExtent()) padder.SetOutputWholeExtent(extent[0] - 1, extent[1] + 1, extent[2] - 1, extent[3] + 1, extent[4] - 1, extent[5] + 1) padder.Update() image_data = padder.GetOutput() # Work in IJK space (identity geometry) for cleaner isosurface extraction which generate triangle vertices at positions inside voxel grid # If the image has non-uniform spacing or a non-zero origin the triangles get stretched/shifted mid-computation producing degenerate results image_identity = vtk.vtkImageData() image_identity.ShallowCopy(image_data) image_identity.SetOrigin(0.0, 0.0, 0.0) image_identity.SetSpacing(1.0, 1.0, 1.0) # Surface extraction if conversion_method == "surface_nets": try: # generates a smoother, watertight mesh by placing vertices at the center of edges crossing the boundary surface_filter = vtk.vtkSurfaceNets3D() surface_filter.SetInputData(image_identity) surface_filter.SmoothingOff() surface_filter.SetValue(0, label_value) surface_filter.Update() except AttributeError: raise RuntimeError("vtkSurfaceNets3D not available in this VTK build") else: try: # Parallelized, cache-optimized reimplementation of Marching Cubes for labeled/discrete data surface_filter = vtk.vtkDiscreteFlyingEdges3D() except AttributeError: # Each cube defined by 8 neighboring voxels is classified by which corners are "inside" vs "outside", then a lookup table selects triangle patterns surface_filter = vtk.vtkDiscreteMarchingCubes() surface_filter.SetInputData(image_identity) surface_filter.ComputeGradientsOff() # Don't compute gradient vectors — not needed, saves time surface_filter.ComputeNormalsOff() # Skip per-vertex normals here — we compute consistent ones later surface_filter.SetValue(0, label_value) # Extract isosurface at label_value (usually 1) surface_filter.Update() surface = surface_filter.GetOutput() # Optional decimation for faster computation if decimation > 0.0: decimator = vtk.vtkDecimatePro() decimator.SetInputData(surface) decimator.SetFeatureAngle(60) # Edges sharper than 60° are "features" and won't be collapsed decimator.SplittingOff() # Don't split the mesh at feature edges (preserves topology) decimator.PreserveTopologyOn() # Avoids creating holes during decimation decimator.SetMaximumError(1) # Max displacement of any vertex during decimation = 1 unit decimator.SetTargetReduction(decimation) # Fraction of triangles to remove (0.0–1.0) decimator.Update() surface = decimator.GetOutput() # Windowed Sinc Smoothing with 3D Slicer's passband & iteration mapping if smoothing > 0.0 and conversion_method != "surface_nets": pass_band = pow(10.0, -4.0 * smoothing) # Lower passband = more smoothing iterations = int(20 + smoothing * 40) # More smoothing = more iterations smoother = vtk.vtkWindowedSincPolyDataFilter() smoother.SetInputData(surface) smoother.SetNumberOfIterations(iterations) smoother.SetPassBand(pass_band) smoother.BoundarySmoothingOff() # Keep open boundary edges fixed smoother.FeatureEdgeSmoothingOff() # Keep sharp feature edges in place smoother.NonManifoldSmoothingOn() # Allow smoothing at non-manifold intersections smoother.NormalizeCoordinatesOn() # Scale coords to unit cube before smoothing for numerical stability smoother.Update() surface = smoother.GetOutput() # Transform from IJK back to physical space via 4×4 affine matrix (combines direction cosine, spacing, origin into one transform) d = (1, 0, 0, 0, 1, 0, 0, 0, 1) if direction and len(direction) == 9: d = direction spacing = (1.0, 1.0, 1.0) if hasattr(image, "GetSpacing"): spacing = image.GetSpacing() ox, oy, oz = origin mat = vtk.vtkMatrix4x4() mat.Identity() mat.SetElement(0, 0, d[0] * spacing[0]); mat.SetElement(0, 1, d[1] * spacing[1]); mat.SetElement(0, 2, d[2] * spacing[2]); mat.SetElement(0, 3, ox) mat.SetElement(1, 0, d[3] * spacing[0]); mat.SetElement(1, 1, d[4] * spacing[1]); mat.SetElement(1, 2, d[5] * spacing[2]); mat.SetElement(1, 3, oy) mat.SetElement(2, 0, d[6] * spacing[0]); mat.SetElement(2, 1, d[7] * spacing[1]); mat.SetElement(2, 2, d[8] * spacing[2]); mat.SetElement(2, 3, oz) transform = vtk.vtkTransform() transform.SetMatrix(mat) tf = vtk.vtkTransformPolyDataFilter() tf.SetInputData(surface) tf.SetTransform(transform) tf.Update() transformed = tf.GetOutput() # Compute consistent normals (all pointing outward or inward) that matter for voronoi methods that depend on normals directions if smoothing > 0.0 or conversion_method == "flying_edges": normals_filter = vtk.vtkPolyDataNormals() normals_filter.SetInputData(transformed) normals_filter.ConsistencyOn() normals_filter.SplittingOff() normals_filter.Update() transformed = normals_filter.GetOutput() # Keep only largest connected component (removes noisy islands) if not keep_all_components: conn = vtk.vtkPolyDataConnectivityFilter() conn.SetInputData(transformed) conn.SetExtractionModeToLargestRegion() conn.Update() transformed = conn.GetOutput() # Convert LPS → RAS if requested if to_ras: transformed = lps_ras_conversion(transformed) return transformed # Helper that checks if any non-background voxels touch the image border (would create open edges) def is_labelmap_padding_necessary(image_data): extent = image_data.GetExtent() # (xmin, xmax, ymin, ymax, zmin, zmax) in voxels if extent[0] > extent[1] or extent[2] > extent[3] or extent[4] > extent[5]: # Empty image guard return False dims = image_data.GetDimensions() scalars = image_data.GetPointData().GetScalars() # Flat array of voxel values if scalars is None: return False arr = numpy_support.vtk_to_numpy(scalars).reshape((dims[2], dims[1], dims[0])) # returns True if any voxel on that face is non-zero (the segmented vessel touches the image boundary) return ( arr[0, :, :].any() or arr[-1, :, :].any() or arr[:, 0, :].any() or arr[:, -1, :].any() or arr[:, :, 0].any() or arr[:, :, -1].any() ) # Generates a centerline skeleton from a segmentaion using the same approach as 3D Slicer's ExtractCenterline module # The output has Radius, Topology, and Marks arrays on each point, Endpoints (degree-1) are vessel tips while degree-3+ points are bifurcations def skeletonize( image, origin, direction, surface_polydata, label_value, smoothing, decimation, keep_all_components, target_number_of_points, subdivide, start_point, compute_geometry, ): # Build closed surface from segmentation if not provided if surface_polydata is None: if image is None: raise ValueError("image is required when surface_polydata is not provided") if origin is None: origin = (0.0, 0.0, 0.0) if hasattr(image, "GetOrigin"): origin = image.GetOrigin() if direction is None: direction = (1, 0, 0, 0, 1, 0, 0, 0, 1) surface_polydata = create_closed_surface_polydata(image, origin, direction, label_value, smoothing, decimation, keep_all_components, to_ras=True) # Decimate, smooth, clean, triangulate the surface processed_surface = preprocess_surface_for_network_extraction(surface_polydata, target_number_of_points, subdivide) # Choose where to open the surface if start_point is not None: start_position = start_point else: bounds = processed_surface.GetBounds() start_position = (bounds[0], bounds[2], bounds[4]) # x_min, y_min, z_min (bottom left corner) # Open a small hole as skeletonization algorithm requires opening points inside the vessel open_surface_at_point(processed_surface, start_position) # Run VMTK's network extraction which traces the medial axis using maximal inscribed sphere approach network_extraction = vtkvmtk.vtkvmtkPolyDataNetworkExtraction() network_extraction.SetInputData(processed_surface) network_extraction.SetAdvancementRatio(1.05) # Advancing sphere can shrink by no more than ~5% between successive step network_extraction.SetRadiusArrayName("Radius") network_extraction.SetTopologyArrayName("Topology") # Integer array where each point stores connectivity degree (0: interior, 1: endpoint, ..) network_extraction.SetMarksArrayName("Marks") # Used internally by the network extraction algorithm to track which points have been "visited" network_extraction.Update() network_output = network_extraction.GetOutput() if not compute_geometry: return network_output # Compute geometric properties: Length, Curvature, Torsion, Tortuosity, Frenet frame centerline_geometry = vtkvmtk.vtkvmtkCenterlineGeometry() centerline_geometry.SetInputData(network_output) centerline_geometry.SetLengthArrayName("Length") centerline_geometry.SetCurvatureArrayName("Curvature") centerline_geometry.SetTorsionArrayName("Torsion") centerline_geometry.SetTortuosityArrayName("Tortuosity") centerline_geometry.SetFrenetTangentArrayName("FrenetTangent") centerline_geometry.SetFrenetNormalArrayName("FrenetNormal") centerline_geometry.SetFrenetBinormalArrayName("FrenetBinormal") centerline_geometry.Update() return centerline_geometry.GetOutput() # Prepares the surface mesh for VMTK's skeleton (network) extraction def preprocess_surface_for_network_extraction(surface_polydata, target_number_of_points, subdivide): num_points = surface_polydata.GetNumberOfPoints() if num_points == 0: raise ValueError("Input surface is empty") # Optionally decimate surface by reducing point count to target via quadric decimation, decreasing computation time decimated = surface_polydata reduction_factor = (num_points - target_number_of_points) / num_points if reduction_factor > 0.0: decim = vtk.vtkQuadricDecimation() decim.SetInputData(surface_polydata) decim.SetTargetReduction(min(max(reduction_factor, 0.0), 0.99)) decim.AttributeErrorMetricOn() decim.VolumePreservationOn() decim.Update() decimated = decim.GetOutput() # Smooth to improve mesh quality for network extraction smoother = vtk.vtkWindowedSincPolyDataFilter() smoother.SetInputData(decimated) smoother.SetNumberOfIterations(10) smoother.SetPassBand(0.1) smoother.NonManifoldSmoothingOn() smoother.NormalizeCoordinatesOn() smoother.Update() # Cleaning the mesh which merges coincident points (removing degenerate cells, converting any polygons to triangle) surface_cleaner = vtk.vtkCleanPolyData() surface_cleaner.SetInputData(smoother.GetOutput()) surface_cleaner.Update() # Triangulate as VMTK requires pure triangle meshes surface_triangulator = vtk.vtkTriangleFilter() surface_triangulator.SetInputData(surface_cleaner.GetOutput()) surface_triangulator.PassLinesOff() surface_triangulator.PassVertsOff() surface_triangulator.Update() # Optional linear subdivision for extra detail (splits each triangle into 4 smaller triangles) subdivided = surface_triangulator.GetOutput() if subdivide: subdiv = vtk.vtkLinearSubdivisionFilter() subdiv.SetInputData(surface_triangulator.GetOutput()) subdiv.SetNumberOfSubdivisions(1) subdiv.Update() if subdiv.GetOutput().GetNumberOfPoints() > 0: subdivided = subdiv.GetOutput() # Orient normals consistently (ensures all face normals point outward from the vessel surface) normals_filter = vtk.vtkPolyDataNormals() normals_filter.SetInputData(subdivided) normals_filter.SetAutoOrientNormals(1) normals_filter.SetFlipNormals(0) normals_filter.SetConsistency(1) normals_filter.SplittingOff() normals_filter.Update() return normals_filter.GetOutput() # Deletes one cell at the specified point to create a small opening in the surface mesh which VMTK's network extraction requires to enter the vessel def open_surface_at_point(polydata, hole_position, hole_point_index=None): if hole_point_index is None: locator = vtk.vtkPointLocator() locator.SetDataSet(polydata) locator.BuildLocator() hole_point_index = locator.FindClosestPoint(hole_position) if hole_point_index < 0: raise ValueError("open_surface_at_point: invalid hole point") polydata.BuildLinks() cell_ids = vtk.vtkIdList() polydata.GetPointCells(hole_point_index, cell_ids) if cell_ids.GetNumberOfIds() > 0: polydata.DeleteCell(cell_ids.GetId(0)) # Only remove the first cell polydata.RemoveDeletedCells() # Analyzes the network skeleton to detect: Aortic root (endpoint with the largest inscribed sphere radius), Iliac endpoints, Branch endpoints def detect_centerline_endpoints(network_polydata): # Clean and prepare the network (remove duplicate points or zero length cells) cleaner = vtk.vtkCleanPolyData() cleaner.SetInputData(network_polydata) cleaner.Update() network = cleaner.GetOutput() network.BuildCells() network.BuildLinks(0) points = network.GetPoints() if points is None or network.GetNumberOfCells() == 0: return [], [] radius_array = network.GetPointData().GetArray("Radius") if radius_array is None: radius_array = network.GetPointData().GetArray("MaximumInscribedSphereRadius") # Find all endpoints (points connected to exactly 1 cell) endpoint_ids = vtk.vtkIdList() tmp_point_cells = vtk.vtkIdList() for cell_id in range(network.GetNumberOfCells()): cell = network.GetCell(cell_id) num_pts = cell.GetNumberOfPoints() if num_pts < 2: continue for pid in (cell.GetPointId(0), cell.GetPointId(num_pts - 1)): network.GetPointCells(pid, tmp_point_cells) if tmp_point_cells.GetNumberOfIds() == 1: endpoint_ids.InsertUniqueId(pid) if endpoint_ids.GetNumberOfIds() < 3: print(f"Warning: only {endpoint_ids.GetNumberOfIds()} endpoints found (need >= 3)") return [], [] # Collect information about each endpoint position and radius endpoints_info = [] for i in range(endpoint_ids.GetNumberOfIds()): pid = endpoint_ids.GetId(i) pos = points.GetPoint(pid) radius = 0.0 if radius_array: radius = radius_array.GetValue(pid) endpoints_info.append({'id': pid, 'pos': pos, 'x': pos[0], 'y': pos[1], 'z': pos[2], 'radius': radius}) # Aortic root = endpoint with the largest inscribed sphere radius aortic_root = max(endpoints_info, key=lambda ep: ep['radius']) # Iliac candidates = endpoints below the aortic root iliac_candidates = [ep for ep in endpoints_info if ep['z'] < aortic_root['z']] # Fallback if iliac candidates weren't found, we take the two furthest endpoints from aortic root as iliac endpoints if len(iliac_candidates) < 2: iliac_candidates = sorted(endpoints_info, key=lambda ep: vtk.vtkMath.Distance2BetweenPoints(aortic_root['pos'], ep['pos']), reverse=True)[:2] # Keep only the most inferior endpoints first (iliac tips are the lowest), use up to 8 points so we still capture external/internal iliacs on both sides iliac_candidates = sorted(iliac_candidates, key=lambda endpoint: endpoint['z'])[: min(8, len(iliac_candidates))] # Split left and right candidates by sorted X into two balanced sets iliac_candidates.sort(key=lambda endpoint: endpoint['x']) n_iliac = len(iliac_candidates) half = n_iliac // 2 if n_iliac <= 1: left_iliacs = iliac_candidates[:] right_iliacs = [] # Even count: exact half/half split elif n_iliac % 2 == 0: left_iliacs = iliac_candidates[:half] right_iliacs = iliac_candidates[half:] # Odd count: assign middle point to the side whose boundary X is closer. else: middle = iliac_candidates[half] left_boundary = iliac_candidates[half - 1] right_boundary = iliac_candidates[half + 1] d_left = abs(middle['x'] - left_boundary['x']) d_right = abs(right_boundary['x'] - middle['x']) if d_left <= d_right: left_iliacs = iliac_candidates[: half + 1] right_iliacs = iliac_candidates[half + 1 :] else: left_iliacs = iliac_candidates[:half] right_iliacs = iliac_candidates[half:] # Fallback if one side is empty: split by mean X pivot if not left_iliacs or not right_iliacs: x_mean = sum(endpoint['x'] for endpoint in iliac_candidates) / max(1, len(iliac_candidates)) left_iliacs = [endpoint for endpoint in iliac_candidates if endpoint['x'] <= x_mean] right_iliacs = [endpoint for endpoint in iliac_candidates if endpoint['x'] > x_mean] def sort_by_z_and_radius(iliacs): return sorted(iliacs, key=lambda endpoint: (endpoint['z'], -endpoint['radius'])) left_sorted = sort_by_z_and_radius(left_iliacs) right_sorted = sort_by_z_and_radius(right_iliacs) left_external = left_sorted[0] if len(left_sorted) > 0 else None left_internal = left_sorted[1] if len(left_sorted) > 1 else None right_external = right_sorted[0] if len(right_sorted) > 0 else None right_internal = right_sorted[1] if len(right_sorted) > 1 else None # Build aorta + iliac endpoint list where aortic root is the source (is_start=True) while iliac endpoints are the targets (is_start=False) aorta_iliac_endpoints = [(aortic_root['x'], aortic_root['y'], aortic_root['z'], True)] iliac_ids = set() for endpoint in (right_external, right_internal, left_external, left_internal): if endpoint: aorta_iliac_endpoints.append((endpoint['x'], endpoint['y'], endpoint['z'], False)) iliac_ids.add(endpoint['id']) # Other endpoints not detected as aortic root or iliac endpoints are branching arteries endpoints branch_endpoints = [] for endpoint in endpoints_info: if endpoint['id'] != aortic_root['id'] and endpoint['id'] not in iliac_ids: bif_pt = find_junction_point(network, endpoint['id']) # Trace endpoint to its skeleton bifurcation branch_endpoints.append({'endpoint': (endpoint['x'], endpoint['y'], endpoint['z']), 'bifurcation': bif_pt, 'radius': endpoint['radius']}) # Diagnostic text output print(f"Aorta (source): {aortic_root['x']:.2f}, {aortic_root['y']:.2f}, {aortic_root['z']:.2f} (R={aortic_root['radius']:.2f})") if right_external: print(f"Right ext iliac: {right_external['x']:.2f}, {right_external['y']:.2f}, {right_external['z']:.2f}") if left_external: print(f"Left ext iliac: {left_external['x']:.2f}, {left_external['y']:.2f}, {left_external['z']:.2f}") if branch_endpoints: print(f"{len(branch_endpoints)} additional branch endpoint(s)") return aorta_iliac_endpoints, branch_endpoints # Traces back from an endpoint along the centerline skeleton to find its bifurcation point (point where artery branches from main aorta) def find_junction_point(network_polydata, endpoint_id): network_polydata.BuildLinks(0) points = network_polydata.GetPoints() current_id = endpoint_id visited = set([current_id]) while True: cell_ids = vtk.vtkIdList() network_polydata.GetPointCells(current_id, cell_ids) num_cells = cell_ids.GetNumberOfIds() # Degree >= 3 means we reached a bifurcation if num_cells >= 3: return points.GetPoint(current_id) # Find the next unvisited neighbour along the skeleton next_id = None for c in range(num_cells): cell = network_polydata.GetCell(cell_ids.GetId(c)) for j in range(cell.GetNumberOfPoints()): pid = cell.GetPointId(j) if pid != current_id and pid not in visited: next_id = pid break if next_id is not None: break # Reached dead-end, no bifurcation found if next_id is None: return None visited.add(next_id) current_id = next_id # Decimates the surface mesh to speed up Voronoi centerline extraction. Voronoi computation scales roughly O(n^2) with surface point count def decimate_surface_for_voronoi(surface_polydata, target_points, max_reduction=0.7): num_points = surface_polydata.GetNumberOfPoints() if num_points <= target_points: return surface_polydata reduction = (num_points - target_points) / num_points reduction = min(reduction, max_reduction) decim = vtk.vtkQuadricDecimation() decim.SetInputData(surface_polydata) decim.SetTargetReduction(reduction) decim.AttributeErrorMetricOn() decim.VolumePreservationOn() decim.Update() # Clean after decimation to remove degenerate cells and ensure triangle mesh cleaner = vtk.vtkCleanPolyData() cleaner.SetInputData(decim.GetOutput()) cleaner.Update() tri = vtk.vtkTriangleFilter() tri.SetInputData(cleaner.GetOutput()) tri.PassLinesOff() tri.PassVertsOff() tri.Update() result = tri.GetOutput() return result # Voronoi-based centerline extraction from aorta to iliacs to compute accurate centerline paths, transforms closed surface to centerline network def extract_centerline_from_surface(surface_polydata, endpoints, curve_sampling_distance, smooth_iterations=40, smooth_relaxation=0.1): if not endpoints or len(endpoints) < 2: raise ValueError("At least two endpoints are needed for centerline extraction") # Cap open mesh ends (required for Voronoi computation inside the volume) capper = vtkvmtk.vtkvmtkCapPolyData() capper.SetInputData(surface_polydata) # Cap is exactly a flat polygon in the plane of the opening. The Voronoi algorithm doesn't care whether caps are flat or domed capper.SetDisplacement(0.0) capper.SetInPlaneDisplacement(0.0) capper.Update() tube_polydata = capper.GetOutput() # Classify seeds: is_start=True → source, is_start=False → target source_ids = vtk.vtkIdList() target_ids = vtk.vtkIdList() locator = vtk.vtkPointLocator() locator.SetDataSet(tube_polydata) locator.BuildLocator() has_explicit_start = any(p[3] for p in endpoints) # returns True if at least one endpoint has is_start=True for idx, (x, y, z, is_start) in enumerate(endpoints): is_target = not is_start if not has_explicit_start and idx == 0: is_target = False pid = locator.FindClosestPoint((x, y, z)) if is_target: target_ids.InsertNextId(pid) else: source_ids.InsertNextId(pid) # Run VMTK's Voronoi-based centerline filter which finds minimum-cost paths between source and target seeds (const is inversely proportional to inscribed sphere radius) cl_filter = vtkvmtk.vtkvmtkPolyDataCenterlines() cl_filter.SetInputData(tube_polydata) cl_filter.SetSourceSeedIds(source_ids) cl_filter.SetTargetSeedIds(target_ids) cl_filter.SetRadiusArrayName("Radius") cl_filter.SetCostFunction("1/R") cl_filter.SetFlipNormals(False) # Controls whether the Voronoi diagram is built from the inward normals (flipped) or outward normals cl_filter.SetAppendEndPointsToCenterlines(0) # Path starts/ends at the nearest Voronoi node, which may be a fraction of a millimeter away from exact seed point cl_filter.SetSimplifyVoronoi(False) # Voronoi smoothing disabled (slows up path finding but maintains narrow vessel sections) cl_filter.SetCenterlineResampling(1) # Enable resampling every 1mm, without this the raw Voronoi path is returned which contains thousands of points per segment cl_filter.SetResamplingStepLength(curve_sampling_distance) # Turns on resampler itself cl_filter.Update() if not cl_filter.GetOutput(): raise ValueError("Centerline extraction produced no output") centerline_polydata = vtk.vtkPolyData() centerline_polydata.DeepCopy(cl_filter.GetOutput()) """voronoi_polydata = None if cl_filter.GetVoronoiDiagram(): voronoi_polydata = vtk.vtkPolyData() voronoi_polydata.DeepCopy(cl_filter.GetVoronoiDiagram())""" # Optional Laplacian smoothing to remove zigzag artifacts if smooth_iterations > 0: centerline_polydata = smooth_centerline_polydata(centerline_polydata, smooth_iterations, smooth_relaxation) return centerline_polydata # Applies low pass smoothing filter to a centerline polydata while keeping endpoints fixed, it removes high-frequency zigzag noise while preserving vessel curvature def smooth_centerline_polydata(centerline_polydata, iterations, relaxation_factor): smoother = vtk.vtkWindowedSincPolyDataFilter() smoother.SetInputData(centerline_polydata) smoother.SetNumberOfIterations(iterations) smoother.SetPassBand(relaxation_factor) smoother.BoundarySmoothingOff() # Keep both endpoints exactly fixed smoother.FeatureEdgeSmoothingOff() smoother.NonManifoldSmoothingOn() # Required for polyline topology (not just surfaces) smoother.NormalizeCoordinatesOn() # Numerical stability on long centerlines smoother.Update() smoothed = vtk.vtkPolyData() smoothed.DeepCopy(smoother.GetOutput()) return smoothed # Process all branches, generating their centerlines, computing their new bifurcation points on actual voronoi centerline (not skeleton) def process_all_branches(branch_information, main_centerline_polydata, surface_polydata, curve_sampling_distance): if not branch_information: return [], [] # Convert branch info from LPS to RAS branch_info_ras = [] for info in branch_information: x, y, z = info['endpoint'] ep_ras = (-x, -y, z) bif_ras = None if info['bifurcation'] is not None: bx, by, bz = info['bifurcation'] bif_ras = (-bx, -by, bz) branch_info_ras.append({'endpoint': ep_ras, 'bifurcation': bif_ras}) branch_centerlines = [] branch_bifurcations = [] for _, info in enumerate(branch_info_ras, 1): centerline, contact_point = extract_branch_centerline(info, main_centerline_polydata, surface_polydata, curve_sampling_distance) if centerline: branch_centerlines.append(centerline) if contact_point: branch_bifurcations.append(contact_point) print(f"\nExtracted {len(branch_centerlines)}/{len(branch_info_ras)} branch centerlines") return branch_centerlines, branch_bifurcations # A separate Voronoi centerline is extracted between the branch tip and its contact point on the aorta def extract_branch_centerline(branch_info, main_centerline_polydata, surface_polydata, curve_sampling_distance): branch_endpoint = branch_info['endpoint'] bifurcation_point = branch_info['bifurcation'] # Find the contact point on the main centerline contact_point = find_branch_contact_point(branch_endpoint, bifurcation_point, main_centerline_polydata) # Branch tip = source, contact point = target branch_endpoints = [(branch_endpoint[0], branch_endpoint[1], branch_endpoint[2], True), (contact_point[0], contact_point[1], contact_point[2], False),] # Clip full segmentation surface to a local region covering only the artery for faster processing during voronoi centerline extraction of that branch clipped_surface = clip_surface_around_points(surface_polydata, branch_endpoint, contact_point, radius_multiplier=1.25) #clipped_surface = decimate_surface_for_voronoi(clipped_surface, target_points=15000) # Extract the branch centerline try: branch_centerline = extract_centerline_from_surface(clipped_surface, branch_endpoints, curve_sampling_distance, smooth_iterations=20) return branch_centerline, contact_point except Exception as e: print(f"\nWarning: branch centerline extraction failed: {e}") return None, contact_point # Finds where a branch meets the main centerline using the skeleton bifurcation point def find_branch_contact_point(branch_endpoint, bifurcation_point, main_centerline_polydata): locator = vtk.vtkPointLocator() locator.SetDataSet(main_centerline_polydata) locator.BuildLocator() search_point = None if bifurcation_point is None: # Fallback: closest point on main centerline to the branch tip search_point = branch_endpoint else: # Project the skeleton bifurcation onto the main centerline search_point = bifurcation_point closest_id = locator.FindClosestPoint(search_point) return main_centerline_polydata.GetPoints().GetPoint(closest_id) # Clips the surface mesh to a spherical region between two points (Used to reduce input mesh size from full vessel tree to local branch for faster extraction) def clip_surface_around_points(surface_polydata, point1, point2, radius_multiplier): center = [(point1[0] + point2[0]) / 2.0, (point1[1] + point2[1]) / 2.0, (point1[2] + point2[2]) / 2.0] distance = math.sqrt(vtk.vtkMath.Distance2BetweenPoints(point1, point2)) radius = max(distance * radius_multiplier, 20.0) # 20 mm minimum sphere = vtk.vtkSphere() sphere.SetCenter(center) sphere.SetRadius(radius) clipper = vtk.vtkClipPolyData() clipper.SetInputData(surface_polydata) clipper.SetClipFunction(sphere) clipper.InsideOutOn() # Keep points inside the sphere clipper.Update() cleaner = vtk.vtkCleanPolyData() cleaner.SetInputData(clipper.GetOutput()) cleaner.Update() return cleaner.GetOutput() # Merges the main aorta→iliacs centerline with all branch centerlines into one polydata. def merge_main_and_branch_centerlines(main_centerline_polydata, branch_centerlines): if not branch_centerlines: return main_centerline_polydata append_filter = vtk.vtkAppendPolyData() append_filter.AddInputData(main_centerline_polydata) for cl in branch_centerlines: append_filter.AddInputData(cl) append_filter.Update() # Applies localized smoothing near junction points so that branch-to-trunk connections look smooth cleaner = vtk.vtkCleanPolyData() cleaner.SetInputData(append_filter.GetOutput()) cleaner.SetToleranceIsAbsolute(True) cleaner.SetAbsoluteTolerance(1.0) # 1mm tolerance to merge junction points from separately-extracted branch centerlines cleaner.Update() merged = cleaner.GetOutput() return merged