Source code for multimodars.ccta

from __future__ import annotations

from . import manipulating
from . import labeling
from . import debug_plots as debug_plots
from . import fixing_functions

from pathlib import Path
from typing import TYPE_CHECKING
import numpy as np
import trimesh

if TYPE_CHECKING:
    from ..multimodars import PyCenterline, PyFrame, PyGeometry


# -------------------------------
# Convenience Functions
# -------------------------------
[docs] def label( path_ccta_geometry: Path | str | trimesh.Trimesh, path_centerline_aorta: Path | str | PyCenterline, path_centerline_rca: Path | str | PyCenterline, path_centerline_lca: Path | str | PyCenterline, aligned_frames: list[PyFrame], anomalous_rca: bool = False, anomalous_lca: bool = False, n_points_intramural: int = 120, bounding_sphere_radius_mm: float = 3.0, tolerance_float: float = 1e-6, control_plot: bool = True, ) -> tuple[dict, tuple[PyCenterline, PyCenterline, PyCenterline]]: """Label CCTA mesh vertices as aorta, RCA, or LCA using centerline-based region detection. Loads a 3-D surface mesh and three centerlines (aorta, RCA, LCA), then assigns each mesh vertex to one of the anatomical regions. For anomalous vessels an additional occlusion-removal step uses ray-triangle intersection to strip intramural segments, followed by adjacency-map reclassification to clean up isolated mis-labelled vertices. Herfore, a ray is cast from every aorta point to the centerline points of the anomalous section and if 3 faces are intersected by the ray the points from the first face must correspond to the intramural section. Additionally partition a coronary region into proximal, anomalous, and distal sub-regions. Uses the intravascular imaging frames to determine where along the centerline the anomalous (intramural) segment begins and ends, then tags each mesh vertex accordingly. Parameters ---------- path_ccta_geometry : Path or str Path to the CCTA surface mesh file (any format supported by :func:`multimodars.io.read_geometrical.read_mesh`). path_centerline_aorta : Path or str Path to a CSV file containing the aortic centerline (comma-delimited, columns: x, y, z, …). path_centerline_rca : Path or str Path to a CSV file containing the RCA centerline. path_centerline_lca : Path or str Path to a CSV file containing the LCA centerline. aligned_frames : list of PyFrame Ordered list of intravascular imaging frames for the vessel. anomalous_rca : bool, optional When ``True`` applies ray-triangle occlusion removal to the RCA region to handle anomalous (intramural) courses. Default is ``False``. anomalous_lca : bool, optional When ``True`` applies ray-triangle occlusion removal to the LCA region. Default is ``False``. n_points_intramural : int, optional Number of coronary centerline points examined during occlusion removal (the intramural segment length). Default is ``120``. bounding_sphere_radius_mm : float, optional Radius in millimetres of the rolling sphere used to collect candidate mesh vertices around each centerline point. Default is ``3.0``. tolerance_float : float, optional Distance tolerance used when matching mesh vertices to points during face lookup. Default is ``1e-6``. control_plot : bool, optional When ``True`` opens an interactive 3-D scene showing the labelled mesh after processing. Default is ``True``. Returns ------- results : dict Dictionary with keys: * ``"mesh"`` - the original :class:`trimesh.Trimesh` object. * ``"aorta_points"`` - list of ``(x, y, z)`` tuples for aortic vertices. * ``"rca_points"`` - list of ``(x, y, z)`` tuples for RCA vertices. * ``"lca_points"`` - list of ``(x, y, z)`` tuples for LCA vertices. * ``"rca_removed_points"`` - RCA vertices removed by occlusion detection. * ``"lca_removed_points"`` - LCA vertices removed by occlusion detection. centerlines : tuple A 3-tuple ``(cl_rca, cl_lca, cl_aorta)`` of ``PyCenterline`` objects. Raises ------ Exception Re-raises any error that occurs while reading the mesh or centerline files, after printing a descriptive message. """ results, (rca_cl, lca_cl, ao_cl) = labeling.label_geometry( path_ccta_geometry, path_centerline_aorta, path_centerline_rca, path_centerline_lca, anomalous_rca, anomalous_lca, n_points_intramural, bounding_sphere_radius_mm, tolerance_float, control_plot, ) if anomalous_rca or anomalous_lca: if anomalous_rca: key = "rca_points" cl = rca_cl else: key = "lca_points" cl = lca_cl results = labeling.label_anomalous_region( centerline=cl, frames=aligned_frames, results=results, results_key=key, ) return results, (rca_cl, lca_cl, ao_cl)
[docs] def scale( results: dict, cl_vessel: PyCenterline, cl_aorta: PyCenterline, aligned_frames: list[PyFrame], ) -> dict: """Scale the distal, proximal, and aortic regions of the vessel mesh. 1. Computes proximal and distal radial scaling factors via :func:`manipulating.find_distal_and_proximal_scaling`, which matches the anomalous segment endpoints to lumen wall points from the first and last intravascular frames. 2. Computes an aortic radial scaling factor via :func:`manipulating.find_aorta_scaling`, which aligns removed RCA points to reconstructed aortic wall points from the frames. 3. Applies the scaling in sequence — distal, then aortic (``aorta_points`` + ``rca_removed_points``), then proximal — using :func:`manipulating.scale_region_centerline_morphing`, which displaces each vertex radially around the nearest centerline point. 4. After each aortic/proximal scaling step, :func:`manipulating.sync_results_to_mesh` remaps all coordinate lists in *results* to the updated vertex positions. Parameters ---------- results : dict Labelled results dictionary containing at minimum: * ``"mesh"`` - the :class:`trimesh.Trimesh` to scale. * ``"anomalous_points"`` - points in the anomalous segment. * ``"distal_points"`` - vertices of the distal region. * ``"proximal_points"`` - vertices of the proximal region. * ``"aorta_points"`` - vertices of the aortic wall region. * ``"rca_removed_points"`` - RCA vertices removed by occlusion detection. cl_vessel : PyCenterline Centerline of the vessel (used for proximal/distal scaling). cl_aorta : PyCenterline Centerline of the aorta (used for aortic scaling). aligned_frames : list of PyFrame Ordered intravascular imaging frames, used as the reference geometry for computing all three scaling factors. Returns ------- dict Updated *results* with ``"mesh"`` replaced by the fully scaled mesh and all coordinate lists remapped to the new vertex positions. """ prox_scaling, distal_scaling = manipulating.find_distal_and_proximal_scaling( frames=aligned_frames, centerline=cl_vessel, results=results, ) aortic_scaling = manipulating.find_aorta_scaling( frames=aligned_frames, cl_aorta=cl_aorta, results=results, ) scaled_distal = manipulating.scale_region_centerline_morphing( mesh=results["mesh"], region_points=results["distal_points"], centerline=cl_vessel, diameter_adjustment_mm=distal_scaling, ) results = manipulating.sync_results_to_mesh(results, results["mesh"], scaled_distal) scaled_distal_aortic = manipulating.scale_region_centerline_morphing( mesh=results["mesh"], region_points=results["aorta_points"] + results["rca_removed_points"], centerline=cl_aorta, diameter_adjustment_mm=aortic_scaling, ) results = manipulating.sync_results_to_mesh( results, results["mesh"], scaled_distal_aortic ) scaled_proximal = manipulating.scale_region_centerline_morphing( mesh=results["mesh"], region_points=results["proximal_points"], centerline=cl_vessel, diameter_adjustment_mm=prox_scaling, ) results = manipulating.sync_results_to_mesh( results, results["mesh"], scaled_proximal ) return results
[docs] def stitch( results: dict, geometry: PyGeometry, postprocessing: bool = False, region_remove: list[str] | str = ["anomalous_points", "proximal_points"], prox_start_mode: str = "highest_z", dist_start_mode: str = "nearest_iv", **postprocessing_kwargs, ) -> dict: """Stitch a CCTA mesh to the intravascular geometry and optionally remesh. Removes labeled anatomical regions from the CCTA mesh, then stitches the remaining surface to the intravascular geometry reconstructed from *geometry*. *Anomalous vessel*: The recommended approach for anomalies is to remove anomalous points and proximal points and stitch the intravascular vessel directly to aorta with highest_z approach (default). When *postprocessing* is ``True`` **and** pymeshlab is installed, the stitched mesh is repaired, isotropically remeshed, and smoothed with a Taubin filter before being returned. Parameters ---------- results : dict Labelled results dictionary (output of :func:`scale`), containing at minimum ``"mesh"`` and the point-label lists produced by :func:`label`. geometry : PyGeometry Intravascular imaging geometry whose contours define the vessel lumen used as the stitching target. postprocessing : bool, optional When ``True``, run :func:`fixing_functions.fix_and_remesh_stitched_mesh` followed by Taubin smoothing on the stitched mesh. Silently skipped if pymeshlab is not installed. Default is ``False``. prox_start_mode : str, optional How to choose index 0 of the proximal boundary ring before stitching. ``"nearest_iv"`` (default) rotates to the point closest to IV point 0; ``"highest_z"`` rotates to the point with the largest z-coordinate. dist_start_mode : str, optional Same as *prox_start_mode* but for the distal boundary ring. **postprocessing_kwargs Keyword arguments forwarded to :func:`fixing_functions.fix_and_remesh_stitched_mesh`, e.g. ``target_edge_length_mm``, ``remesh_iterations``, ``verbose``. Returns ------- dict Stitched results dictionary with the same structure as *results*, where ``"mesh"`` is the stitched (and optionally postprocessed) surface. """ if postprocessing and fixing_functions.pymeshlab is None: raise ImportError( "postprocessing=True requires pymeshlab. " "Install it with: pip install 'multimodars[meshlab]'" ) updated_results = manipulating.remove_labeled_points_from_mesh( results, region_remove ) stitched = manipulating.stitch_ccta_to_intravascular( geometry, updated_results["mesh"], updated_results, prox_start_mode=prox_start_mode, dist_start_mode=dist_start_mode, ) stitched["mesh"] = fixing_functions.manual_hole_fill(stitched["mesh"]) stitched["mesh"] = fixing_functions.postprocess_stitched_mesh( stitched["mesh"], postprocessing=postprocessing, **postprocessing_kwargs, ) return stitched
def _extract_region_with_border_faces( mesh: trimesh.Trimesh, region_points: list, ) -> trimesh.Trimesh: """Return a sub-mesh containing every face that touches at least one vertex in *region_points*. Unlike :func:`manipulating.keep_labeled_points_from_mesh`, which only keeps faces whose *all* vertices belong to the region, this function uses an **at-least-one-vertex** criterion. The result therefore includes the thin ring of adjacent-region vertices that share a face with the target region, giving seamless overlapping boundaries when meshes of different labels are exported side-by-side. """ coord_to_idx = {tuple(v): i for i, v in enumerate(mesh.vertices)} keep_indices = np.array( [coord_to_idx[tuple(p)] for p in region_points if tuple(p) in coord_to_idx], dtype=np.int64, ) if keep_indices.size == 0: return trimesh.Trimesh() face_mask = np.isin(mesh.faces, keep_indices).any(axis=1) selected_faces = mesh.faces[face_mask] used = np.unique(selected_faces) remap = np.full(len(mesh.vertices), -1, dtype=np.int64) remap[used] = np.arange(len(used), dtype=np.int64) return trimesh.Trimesh( vertices=mesh.vertices[used], faces=remap[selected_faces], process=False, )
[docs] def export_section_stl( results: dict, type: str = "all", output_dir: Path | str | None = None, ) -> None: """Export the mesh (or a labeled sub-region) as an STL file. Parameters ---------- results : dict Labeled results dictionary containing ``"mesh"`` and the point-label lists produced by :func:`label` / :func:`scale`. type : str, optional Which region to export. One of: * ``"all"`` - the full mesh as-is. * ``"aorta"`` - only the aorta region. * ``"rca"`` - only the RCA region (includes adjacent aorta ring). * ``"lca"`` - only the LCA region (includes adjacent aorta ring). Default is ``"all"``. output_dir : Path, str, or None, optional Directory in which to write the STL file. Defaults to the current working directory when ``None``. """ output_dir = Path(output_dir) if output_dir is not None else Path(".") output_dir.mkdir(parents=True, exist_ok=True) mesh: trimesh.Trimesh = results["mesh"] _REGION_KEYS = { "aorta": "aorta_points", "rca": "rca_points", "lca": "lca_points", } if type == "all": mesh.export(str(output_dir / "all.stl")) elif type in _REGION_KEYS: region_points = results.get(_REGION_KEYS[type], []) if type == "aorta": sub_mesh_dict = manipulating.keep_labeled_points_from_mesh( results, ["aorta_points", "rca_removed_points", "lca_removed_points"] ) sub_mesh = sub_mesh_dict["mesh"] else: sub_mesh = _extract_region_with_border_faces(mesh, region_points) sub_mesh.export(str(output_dir / f"{type}.stl")) else: raise ValueError( f"Unknown export type {type!r}. " f"Choose one of: 'all', 'aorta', 'rca', 'lca'." )
[docs] def create_wall_mesh( frames: list[PyFrame] | None, cl_aorta: PyCenterline, cl_rca: PyCenterline, cl_lca: PyCenterline, results: dict, aortic_scaling: float | None = None, coronary_scaling: float = 1.0, ) -> dict: """Create a wall, by assigning a wall thickness for coronaries, and then finding either directly the best scaling for aorta (for coronary artery anomalies), by finding the distance between aortic wall and the reference point on the coronary or by directly providing a scaling factor for coronarie sand aorta. ... """ if frames is None and aortic_scaling is None: raise ValueError("Either provide frames or aortic scaling") scaling_factor = 0.0 if frames is not None: scaling = manipulating.find_aortic_wall_scaling( frames=frames, cl_aorta=cl_aorta, results=results, ) scaling_factor = scaling else: assert aortic_scaling is not None scaling_factor = aortic_scaling # Extract aorta sub-mesh, fill ostia holes, then scale the closed aorta directly sub_mesh_dict = manipulating.keep_labeled_points_from_mesh( results, ["aorta_points", "rca_removed_points", "lca_removed_points"] ) sub_mesh = sub_mesh_dict["mesh"] sub_mesh_filled = fixing_functions.manual_hole_fill(sub_mesh) filled_vertices = [ (float(p[0]), float(p[1]), float(p[2])) for p in sub_mesh_filled.vertices ] scaled_aorta = manipulating.scale_region_centerline_morphing( mesh=sub_mesh_filled, region_points=filled_vertices, centerline=cl_aorta, diameter_adjustment_mm=scaling_factor, ) # Extract and scale each coronary sub-mesh independently rca_sub_dict = manipulating.keep_labeled_points_from_mesh(results, ["rca_points"]) scaled_rca = manipulating.scale_region_centerline_morphing( mesh=rca_sub_dict["mesh"], region_points=rca_sub_dict["rca_points"], centerline=cl_rca, diameter_adjustment_mm=coronary_scaling, ) lca_sub_dict = manipulating.keep_labeled_points_from_mesh(results, ["lca_points"]) scaled_lca = manipulating.scale_region_centerline_morphing( mesh=lca_sub_dict["mesh"], region_points=lca_sub_dict["lca_points"], centerline=cl_lca, diameter_adjustment_mm=coronary_scaling, ) results["mesh"] = trimesh.util.concatenate([scaled_aorta, scaled_rca, scaled_lca]) return results