Source code for multimodars._converters

from __future__ import annotations

from typing import cast

import numpy as np
import trimesh
from .multimodars import (
    PyContour,
    PyCenterline,
    PyGeometry,
    PyFrame,
    PyInputData,
    PyContourPoint,
    PyRecord,
    PyContourType,
)


[docs] def to_array(generic) -> np.ndarray | dict | tuple[dict, dict]: """ Convert various multimodars Py* objects into numpy array(s) or dictionaries of arrays. Parameters ---------- generic : PyContour, PyCenterline, PyGeometry, PyGeometryPair, PyFrame, or PyInputData The object to be converted to numpy representation. Returns ------- np.ndarray For PyContour or PyCenterline: A 2D array of shape (N, 4), where each row is (frame_index, x, y, z). dict[str, np.ndarray] For PyGeometry: A dictionary with keys for each contour type and "reference", each containing a 2D array of shape (M, 4), where M is the number of points in that layer. tuple[dict[str, np.ndarray], dict[str, np.ndarray]] For PyGeometryPair: A tuple of two dictionaries (one for geom_a, one for geom_b), each in the same format as returned for a single PyGeometry. dict[str, np.ndarray | list[str] | bool] For PyInputData: A dictionary containing arrays for each contour type and metadata. Raises ------ TypeError If the input type is not one of the supported multimodars types. """ from . import ( PyContour, PyCenterline, PyGeometry, PyGeometryPair, PyFrame, PyInputData, ) if isinstance(generic, PyContour): pts = [(p.frame_index, p.x, p.y, p.z) for p in generic.points] return np.array(pts, dtype=float) if isinstance(generic, PyCenterline): pts = [ ( p.contour_point.frame_index, p.contour_point.x, p.contour_point.y, p.contour_point.z, ) for p in generic.points ] return np.array(pts, dtype=float) if isinstance(generic, PyFrame): return _frame_to_numpy(generic) if isinstance(generic, PyGeometry): return _geometry_to_numpy(generic) if isinstance(generic, PyGeometryPair): geom_a_dict = _geometry_to_numpy(generic.geom_a) geom_b_dict = _geometry_to_numpy(generic.geom_b) return geom_a_dict, geom_b_dict if isinstance(generic, PyInputData): return _input_data_to_numpy(generic) raise TypeError(f"Unsupported type for to_array: {type(generic)}")
def _frame_to_numpy(frame) -> dict[str, np.ndarray]: """Convert PyFrame to dictionary of numpy arrays.""" result = {} # Add lumen lumen_pts = [(p.frame_index, p.x, p.y, p.z) for p in frame.lumen.points] result["lumen"] = ( np.array(lumen_pts, dtype=float) if lumen_pts else np.zeros((0, 4), dtype=float) ) # Add extras for contour_type, contour in frame.extras.items(): pts = [(p.frame_index, p.x, p.y, p.z) for p in contour.points] result[contour_type.lower()] = ( np.array(pts, dtype=float) if pts else np.zeros((0, 4), dtype=float) ) # Add reference point if frame.reference_point: ref = frame.reference_point result["reference"] = np.array( [[ref.frame_index, ref.x, ref.y, ref.z]], dtype=float ) else: result["reference"] = np.zeros((0, 4), dtype=float) return result def _geometry_to_numpy(geom) -> dict[str, np.ndarray]: """Convert PyGeometry to dictionary of numpy arrays.""" result = { "lumen": np.zeros((0, 4), dtype=float), "eem": np.zeros((0, 4), dtype=float), "calcification": np.zeros((0, 4), dtype=float), "sidebranch": np.zeros((0, 4), dtype=float), "catheter": np.zeros((0, 4), dtype=float), "wall": np.zeros((0, 4), dtype=float), "reference": np.zeros((0, 4), dtype=float), } # Collect all points from all frames for frame in geom.frames: frame_data = _frame_to_numpy(frame) for key in result: if key in frame_data and len(frame_data[key]) > 0: if len(result[key]) == 0: result[key] = frame_data[key] else: result[key] = cast( np.ndarray, np.vstack([result[key], frame_data[key]]) ) return result def _input_data_to_numpy(input_data) -> dict[str, np.ndarray | list[str] | bool]: """Convert PyInputData to dictionary of numpy arrays and metadata.""" result = { "lumen": np.zeros((0, 4), dtype=float), "eem": np.zeros((0, 4), dtype=float), "calcification": np.zeros((0, 4), dtype=float), "sidebranch": np.zeros((0, 4), dtype=float), "reference": np.zeros((0, 4), dtype=float), "diastole": input_data.diastole, "label": input_data.label, } # Process lumen (required) if input_data.lumen: lumen_pts = [] for contour in input_data.lumen: lumen_pts.extend([(p.frame_index, p.x, p.y, p.z) for p in contour.points]) if lumen_pts: result["lumen"] = np.array(lumen_pts, dtype=float) # Process optional contour types for contour_type in ["eem", "calcification", "sidebranch"]: contours = getattr(input_data, contour_type) if contours: pts = [] for contour in contours: pts.extend([(p.frame_index, p.x, p.y, p.z) for p in contour.points]) if pts: result[contour_type] = np.array(pts, dtype=float) # Process reference point ref = input_data.ref_point result["reference"] = np.array( [[ref.frame_index, ref.x, ref.y, ref.z]], dtype=float ) # Process records if available if input_data.record: record_data = [] for record in input_data.record: row = [record.frame, record.phase] row.append( record.measurement_1 if record.measurement_1 is not None else np.nan ) row.append( record.measurement_2 if record.measurement_2 is not None else np.nan ) record_data.append(row) result["records"] = np.array(record_data, dtype=object) return result
[docs] def numpy_to_inputdata( lumen_arr: np.ndarray, ref_point: np.ndarray, diastole: bool, record: np.ndarray | None = None, eem_arr: np.ndarray | None = None, calcification: np.ndarray | None = None, sidebranch: np.ndarray | None = None, label: str = "", ) -> PyInputData: """ Build a ``PyInputData`` from numpy arrays, grouping by frame index into frames. Each row in the array arguments must be ``[frame_index, x, y, z]``. Parameters ---------- lumen_arr : np.ndarray ``(N, 4)`` array of lumen points ``[frame_index, x, y, z]``. record : np.ndarray, optional ``(M, 4)`` array of records ``[frame, phase, measurement_1, measurement_2]``. ref_point : np.ndarray ``(1, 4)`` or ``(4,)`` array ``[frame_index, x, y, z]`` for the reference point. diastole : bool ``True`` if the data corresponds to the diastolic phase. eem_arr : np.ndarray, optional ``(N, 4)`` array of EEM points ``[frame_index, x, y, z]``. Default is ``None``. calcification : np.ndarray, optional ``(N, 4)`` array of calcification points. Default is ``None``. sidebranch : np.ndarray, optional ``(N, 4)`` array of side-branch points. Default is ``None``. label : str, optional Label string for the input data. Default is ``""``. Returns ------- input_data : PyInputData Input data object with contours grouped by frame index. Raises ------ ValueError If ``lumen_arr`` is empty. """ def _to_numeric_array(arr: np.ndarray | None, name: str) -> np.ndarray: if arr is None: return np.zeros((0, 4), dtype=float) # Handle structured arrays with named fields if arr.ndim == 1 and arr.dtype.names: try: arr = np.vstack([arr[n] for n in arr.dtype.names]).T except Exception: raise ValueError(f"Could not convert structured array for {name}") arr = np.asarray(arr, dtype=float) if arr.ndim == 1: # Single row -> make it 2D arr = arr.reshape(1, -1) return arr def build_contour_from_array(arr: np.ndarray, frame_id: int, contour_type: str): """Return PyContour for given frame_id or None if not present.""" if arr.size == 0: return None mask = arr[:, 0].astype(int) == int(frame_id) pts_arr = arr[mask] if pts_arr.shape[0] == 0: return None pts = [ PyContourPoint( frame_index=int(fr), point_index=i, x=float(x), y=float(y), z=float(z), aortic=False, ) for i, (fr, x, y, z) in enumerate(pts_arr) ] centroid = ( float(np.mean(pts_arr[:, 1])), float(np.mean(pts_arr[:, 2])), float(np.mean(pts_arr[:, 3])), ) return PyContour( id=int(frame_id), original_frame=int(frame_id), points=pts, centroid=centroid, aortic_thickness=None, pulmonary_thickness=None, kind=contour_type, ) def _records_from_array(arr: np.ndarray | None): if arr is None: return None # If structured with fields, try to coerce to (N,4) or (N,3) if arr.ndim == 1 and arr.dtype.names: # Try to create a 2D array with numeric fields where appropriate try: arr = np.vstack([arr[n] for n in arr.dtype.names]).T except Exception: # fallback to treating each element as a row-like object arr = np.asarray(arr) arr = np.asarray(arr) if arr.size == 0: return None # If 1D single-row, reshape if arr.ndim == 1: arr = arr.reshape(1, -1) recs = [] for row in arr: # Attempt to extract fields robustly: # Expecting [frame, phase, measurement_1, measurement_2] or at least [frame, phase] frame = int(row[0]) phase_val = row[1] if row.shape[0] > 1 else "" # Normalize phase to string if isinstance(phase_val, (bytes, bytearray)): try: phase = phase_val.decode("utf-8") except Exception: phase = str(phase_val) else: # numeric -> map 0 -> "D", 1 -> "S", otherwise string-ify if np.issubdtype(type(phase_val), np.number): phase = "D" if int(phase_val) == 0 else "S" else: phase = str(phase_val) def _to_optional_float(v): try: fv = float(v) if np.isnan(fv): return None return fv except Exception: return None m1 = _to_optional_float(row[2]) if row.shape[0] > 2 else None m2 = _to_optional_float(row[3]) if row.shape[0] > 3 else None recs.append( PyRecord(frame=frame, phase=phase, measurement_1=m1, measurement_2=m2) ) return recs if len(recs) > 0 else None # Convert arrays lumen_arr = _to_numeric_array(lumen_arr, "lumen_arr") eem_arr = _to_numeric_array(eem_arr, "eem_arr") calcification_arr = _to_numeric_array(calcification, "calcification") sidebranch_arr = _to_numeric_array(sidebranch, "sidebranch") # Reference point: prefer provided ref_point, otherwise a default global_ref = None if ref_point is not None: try: ref_arr = np.asarray(ref_point) if ref_arr.ndim == 1: fr, x, y, z = ref_arr[:4] else: fr, x, y, z = ref_arr[0, :4] global_ref = PyContourPoint( frame_index=int(fr), point_index=0, x=float(x), y=float(y), z=float(z), aortic=False, ) except Exception: global_ref = None if global_ref is None: # default fallback (required by PyInputData ctor) global_ref = PyContourPoint( frame_index=0, point_index=0, x=0.0, y=0.0, z=0.0, aortic=False ) # Build lists of contours if lumen_arr.size == 0: raise ValueError("lumen_arr cannot be empty") all_lumen_frames = sorted(set(lumen_arr[:, 0].astype(int))) lumen_list = [] eem_list = [] calc_list = [] sidebranch_list = [] for frame_id in all_lumen_frames: lumen_contour = build_contour_from_array(lumen_arr, frame_id, "Lumen") if lumen_contour is None: # If no lumen for this frame, skip (shouldn't usually happen since frames come from lumen) continue lumen_list.append(lumen_contour) eem_contour = build_contour_from_array(eem_arr, frame_id, "Eem") if eem_contour is not None: eem_list.append(eem_contour) calc_contour = build_contour_from_array( calcification_arr, frame_id, "Calcification" ) if calc_contour is not None: calc_list.append(calc_contour) sb_contour = build_contour_from_array(sidebranch_arr, frame_id, "Sidebranch") if sb_contour is not None: sidebranch_list.append(sb_contour) # Convert records (if any) record_list = _records_from_array(record) # Convert empty lists to None for optional fields like eem/calcification/sidebranch/record eem_final = eem_list if len(eem_list) > 0 else None calc_final = calc_list if len(calc_list) > 0 else None sidebranch_final = sidebranch_list if len(sidebranch_list) > 0 else None return PyInputData( lumen=lumen_list, eem=eem_final, calcification=calc_final, sidebranch=sidebranch_final, record=record_list, ref_point=global_ref, diastole=bool(diastole), label=label or "", )
[docs] def numpy_to_geometry( lumen_arr: np.ndarray, eem_arr: np.ndarray | None = None, catheter_arr: np.ndarray | None = None, wall_arr: np.ndarray | None = None, reference_arr: np.ndarray | None = None, label: str = "", ) -> PyGeometry: """ Build a ``PyGeometry`` from numpy arrays, grouping by frame index into frames. Each row in the array arguments must be ``[frame_index, x, y, z]``. Parameters ---------- lumen_arr : np.ndarray ``(N, 4)`` array of lumen points ``[frame_index, x, y, z]``. eem_arr : np.ndarray, optional ``(M, 4)`` array of EEM points ``[frame_index, x, y, z]``. Default is ``None``. catheter_arr : np.ndarray, optional ``(K, 4)`` array of catheter points ``[frame_index, x, y, z]``. Default is ``None``. wall_arr : np.ndarray, optional ``(L, 4)`` array of wall points ``[frame_index, x, y, z]``. Default is ``None``. reference_arr : np.ndarray, optional ``(1, 4)`` or ``(4,)`` array ``[frame_index, x, y, z]`` for the reference point. Default is ``None``. label : str, optional Label string for the geometry. Default is ``""``. Returns ------- geometry : PyGeometry Geometry object with frames constructed from the provided arrays. Raises ------ ValueError If ``lumen_arr`` is empty. """ def _to_numeric_array(arr: np.ndarray | None, layer_name: str) -> np.ndarray: if arr is None: return np.zeros((0, 4), dtype=float) # Handle structured arrays if arr.ndim == 1 and arr.dtype.names: try: arr = np.vstack([arr[name] for name in arr.dtype.names]).T except Exception: raise ValueError(f"Could not convert structured array for {layer_name}") arr = np.asarray(arr, dtype=float) return arr def build_contour_from_array( arr: np.ndarray, frame_id: int, contour_type: str ) -> PyContour | None: """Build a PyContour from array points for a specific frame.""" if arr.size == 0: return None mask = arr[:, 0].astype(int) == frame_id pts_arr = arr[mask] if len(pts_arr) == 0: return None pts = [ PyContourPoint( frame_index=int(fr), point_index=i, x=float(x), y=float(y), z=float(z), aortic=False, ) for i, (fr, x, y, z) in enumerate(pts_arr) ] # Compute centroid centroid = ( np.mean(pts_arr[:, 1]), np.mean(pts_arr[:, 2]), np.mean(pts_arr[:, 3]), ) return PyContour( id=frame_id, original_frame=frame_id, points=pts, centroid=centroid, aortic_thickness=None, pulmonary_thickness=None, kind=contour_type, ) # Convert arrays to numeric format lumen_arr = _to_numeric_array(lumen_arr, "lumen_arr") eem_arr = _to_numeric_array(eem_arr, "eem_arr") catheter_arr = _to_numeric_array(catheter_arr, "catheter_arr") wall_arr = _to_numeric_array(wall_arr, "wall_arr") reference_arr = _to_numeric_array(reference_arr, "reference_arr") if lumen_arr.size == 0: raise ValueError("lumen_arr cannot be empty") # Handle reference array - always take the first valid reference point global_reference = None if reference_arr.size > 0: # If 1D array, use it directly if reference_arr.ndim == 1: fr, x, y, z = reference_arr[:4] else: # If 2D array, take the first row fr, x, y, z = reference_arr[0, :4] global_reference = PyContourPoint( frame_index=int(fr), point_index=0, x=float(x), y=float(y), z=float(z), aortic=False, ) # Get all unique frame indices from all arrays all_frames: set[int] = set() for arr in [lumen_arr, eem_arr, catheter_arr, wall_arr]: if arr.size > 0: all_frames.update(arr[:, 0].astype(int)) frames = [] for frame_id in sorted(all_frames): # Build lumen contour (required) lumen_contour = build_contour_from_array(lumen_arr, frame_id, "Lumen") if not lumen_contour: continue # Build extras extras = {} eem_contour = build_contour_from_array(eem_arr, frame_id, "Eem") if eem_contour: extras["Eem"] = eem_contour catheter_contour = build_contour_from_array(catheter_arr, frame_id, "Catheter") if catheter_contour: extras["Catheter"] = catheter_contour wall_contour = build_contour_from_array(wall_arr, frame_id, "Wall") if wall_contour: extras["Wall"] = wall_contour # Use the global reference point for all frames frame_reference = global_reference frame = PyFrame( id=frame_id, centroid=lumen_contour.centroid, lumen=lumen_contour, extras=extras, reference_point=frame_reference, ) frames.append(frame) return PyGeometry(frames=frames, label=label)
[docs] def numpy_to_centerline( arr: np.ndarray, aortic: bool = False, ) -> PyCenterline: """ Build a ``PyCenterline`` from a numpy array. Linearly interpolates NaN values along each coordinate axis. Raises ``ValueError`` if an entire coordinate column is NaN or fewer than two points remain after processing. Parameters ---------- arr : np.ndarray ``(N, 3)`` array where each row is ``(x, y, z)``. aortic : bool, optional Whether to mark each point as aortic. Default is ``False``. Returns ------- centerline : PyCenterline Centerline object built from the provided points. Raises ------ ValueError If ``arr`` is not ``(N, 3)``, is empty, all values in a coordinate column are NaN, or fewer than two points remain after interpolation. """ arr = np.asarray(arr, dtype=float) if arr.ndim != 2 or arr.shape[1] != 3: raise ValueError("Input must be a (N,3) array") n = arr.shape[0] if n == 0: raise ValueError("Input array must contain at least one point") # If there are NaNs, try linear interpolation along the index axis. if np.isnan(arr).any(): idx = np.arange(n) arr_interp = arr.copy() for col in range(3): col_vals = arr[:, col] valid_mask = ~np.isnan(col_vals) if valid_mask.sum() == 0: # Can't interpolate if whole column is missing raise ValueError( f"All values are NaN for coordinate column {col}; cannot build centerline." ) if valid_mask.sum() < n: # np.interp will fill leading and trailing NaNs by extrapolating the first/last valid values arr_interp[:, col] = np.interp( idx, idx[valid_mask], col_vals[valid_mask] ) arr = arr_interp # After interpolation, ensure we have at least two distinct points to form a centerline if arr.shape[0] < 2: raise ValueError( "Centerline must contain at least two points after cleaning/interpolation." ) pts = [] for i, (x, y, z) in enumerate(arr.tolist()): pts.append( PyContourPoint( frame_index=i, point_index=i, # point_index can be meaningful; set to i instead of 0 x=float(x), y=float(y), z=float(z), aortic=aortic, ) ) # Optionally validate that no NaNs remain for p in pts: if any(np.isnan((p.x, p.y, p.z))): raise ValueError("NaN coordinate found after interpolation — aborting.") return PyCenterline.from_contour_points(pts)
[docs] def array_to_pyinputdata( lumen=None, eem=None, calcification=None, sidebranch=None, records=None, reference=None, diastole: bool = True, label: str = "", ) -> PyInputData: """ Create a ``PyInputData`` from either ``Py*`` objects or NumPy arrays. Accepts existing ``PyContour`` / ``PyRecord`` instances or raw arrays. For layer arrays each row must be ``(frame_index, x, y, z)``. Records accept a structured array or a list/array of rows ``(frame, phase, measurement_1, measurement_2)``. Parameters ---------- lumen : list of PyContour or np.ndarray, optional Lumen contours or ``(N, 4)`` array. Default is ``None``. eem : list of PyContour or np.ndarray, optional EEM contours or ``(N, 4)`` array. Default is ``None``. calcification : list of PyContour or np.ndarray, optional Calcification contours or ``(N, 4)`` array. Default is ``None``. sidebranch : list of PyContour or np.ndarray, optional Side-branch contours or ``(N, 4)`` array. Default is ``None``. records : list of PyRecord or np.ndarray, optional Records or ``(M, 4)`` array of ``(frame, phase, m1, m2)`` rows. Default is ``None``. reference : np.ndarray, optional ``(1, 4)`` or ``(4,)`` array ``[frame_index, x, y, z]`` for the reference point. Default is ``None``. diastole : bool, optional ``True`` if the data corresponds to the diastolic phase. Default is ``True``. label : str, optional Label string for the input data. Default is ``""``. Returns ------- input_data : PyInputData Input data object populated from the provided arguments. Raises ------ ValueError If a layer array has an incompatible shape or unsupported format. """ def _to_numeric_array(arr, layer_name: str): if arr is None: return np.zeros((0, 4), dtype=float) if isinstance(arr, (list, tuple)): arr = np.asarray(arr, dtype=object) if isinstance(arr, np.ndarray) and arr.dtype.names: try: arr = np.vstack([arr[name] for name in arr.dtype.names]).T except Exception as e: raise ValueError( f"Could not convert structured array for {layer_name}: {e}" ) arr = np.asarray(arr) if arr.size == 0: return np.zeros((0, 4), dtype=float) if arr.ndim == 1: if arr.shape[0] == 4: arr = arr[np.newaxis, :] else: raise ValueError( f"{layer_name} 1D array must have length 4, got {arr.shape}" ) return arr.astype(object) def build_layer_from_array(arr, layer_name: str, kind: str): """Return list[PyContour] from a numeric array (frame,x,y,z) grouped by frame.""" arr = _to_numeric_array(arr, layer_name) if arr.size == 0: return [] if arr.ndim != 2 or arr.shape[1] < 4: raise ValueError(f"{layer_name} must be (N,4)-like, got shape {arr.shape}") frames = np.unique(arr[:, 0].astype(int)) contours = [] for frame in frames: mask = arr[:, 0].astype(int) == frame pts_arr = arr[mask] pts = [] for i, row in enumerate(pts_arr): fr = int(row[0]) x = float(row[1]) y = float(row[2]) z = float(row[3]) pts.append( PyContourPoint( frame_index=fr, point_index=i, x=x, y=y, z=z, aortic=False ) ) # Compute centroid if len(pts_arr) > 0: centroid = ( np.mean(pts_arr[:, 1]), np.mean(pts_arr[:, 2]), np.mean(pts_arr[:, 3]), ) else: centroid = (0.0, 0.0, 0.0) contour = PyContour( id=frame, original_frame=frame, points=pts, centroid=centroid, aortic_thickness=None, pulmonary_thickness=None, kind=kind, ) contours.append(contour) return contours def ensure_contours(maybe, kind: str): """Accept a list of PyContour already, or numpy arrays, or None.""" if maybe is None: return [] if ( isinstance(maybe, list) and maybe and hasattr(maybe[0], "points") and hasattr(maybe[0], "id") ): return maybe return build_layer_from_array(maybe, "layer", kind) lumen_contours = ensure_contours(lumen, "Lumen") eem_contours = ensure_contours(eem, "Eem") calc_contours = ensure_contours(calcification, "Calcification") sidebranch_contours = ensure_contours(sidebranch, "Sidebranch") def parse_records(recs): if recs is None: return None if ( isinstance(recs, (list, tuple)) and recs and hasattr(recs[0], "frame") and hasattr(recs[0], "phase") ): return list(recs) if isinstance(recs, np.ndarray): if recs.dtype.names: names = recs.dtype.names def get_field(name, default=None): for cand in [name, name.lower(), name.upper()]: if cand in names: return recs[cand] return None frames = get_field("frame") phases = get_field("phase") m1 = get_field("measurement_1") or get_field("m1") m2 = get_field("measurement_2") or get_field("m2") if frames is None or phases is None: raise ValueError( "Structured records must contain 'frame' and 'phase'" ) out = [] for fr, ph, mm1, mm2 in zip( frames, phases, m1 if m1 is not None else [None] * len(frames), m2 if m2 is not None else [None] * len(frames), ): out.append( PyRecord( int(fr), str(ph), None if mm1 is None else float(mm1), None if mm2 is None else float(mm2), ) ) return out else: arr = np.asarray(recs) if arr.ndim == 1: arr = arr[np.newaxis, :] out = [] for row in arr: fr = int(row[0]) ph = str(row[1]) m1 = ( None if ( len(row) < 3 or row[2] is None or (isinstance(row[2], float) and np.isnan(row[2])) ) else float(row[2]) ) m2 = ( None if ( len(row) < 4 or row[3] is None or (isinstance(row[3], float) and np.isnan(row[3])) ) else float(row[3]) ) out.append(PyRecord(fr, ph, m1, m2)) return out if isinstance(recs, (list, tuple)): out = [] for item in recs: if hasattr(item, "frame") and hasattr(item, "phase"): out.append(item) else: fr = int(item[0]) ph = str(item[1]) m1 = None if len(item) < 3 or item[2] is None else float(item[2]) m2 = None if len(item) < 4 or item[3] is None else float(item[3]) out.append(PyRecord(fr, ph, m1, m2)) return out raise ValueError("Unsupported records format") parsed_records = parse_records(records) def parse_reference(ref): if ref is None: return PyContourPoint( frame_index=0, point_index=0, x=0.0, y=0.0, z=0.0, aortic=False ) arr = np.asarray(ref) if arr.ndim == 1: if arr.shape[0] >= 4: fr, x, y, z = arr[:4] else: raise ValueError("reference must be length 4 or shape (1,4)") else: if arr.shape[1] < 4: raise ValueError("reference must be (N,4)-like") nonzero = np.any(arr != 0, axis=1) if np.any(nonzero): row = arr[nonzero][0] else: row = arr[0] fr, x, y, z = row[:4] return PyContourPoint( frame_index=int(fr), point_index=0, x=float(x), y=float(y), z=float(z), aortic=False, ) ref_point = parse_reference(reference) def none_if_empty(lst): return None if not lst else lst pyinput = PyInputData( lumen=lumen_contours, eem=none_if_empty(eem_contours), calcification=none_if_empty(calc_contours), sidebranch=none_if_empty(sidebranch_contours), record=parsed_records, ref_point=ref_point, diastole=bool(diastole), label=str(label), ) return pyinput
[docs] def geometry_to_frames_array(geometry: PyGeometry) -> dict[str, dict[str, np.ndarray]]: """ Convert a ``PyGeometry`` to a nested dictionary of numpy arrays by frame. Parameters ---------- geometry : PyGeometry Geometry object to convert. Returns ------- result : dict of str to dict of str to np.ndarray Mapping from frame ID strings to dictionaries of contour-type arrays. Each inner dictionary has keys such as ``"lumen"``, ``"eem"``, ``"catheter"``, ``"wall"``, and ``"reference"``, each containing a ``(N, 4)`` array ``[frame_index, x, y, z]``. """ result = {} for frame in geometry.frames: frame_data = {} # Add lumen lumen_pts = [(p.frame_index, p.x, p.y, p.z) for p in frame.lumen.points] frame_data["lumen"] = ( np.array(lumen_pts, dtype=float) if lumen_pts else np.zeros((0, 4), dtype=float) ) # Add extras for contour_type, contour in frame.extras.items(): pts = [(p.frame_index, p.x, p.y, p.z) for p in contour.points] frame_data[contour_type.lower()] = ( np.array(pts, dtype=float) if pts else np.zeros((0, 4), dtype=float) ) # Add reference point if frame.reference_point: ref = frame.reference_point frame_data["reference"] = np.array( [[ref.frame_index, ref.x, ref.y, ref.z]], dtype=float ) else: frame_data["reference"] = np.zeros((0, 4), dtype=float) result[str(frame.id)] = frame_data return result
def geometry_to_trimesh( geometry: PyGeometry, contour_type: PyContourType | None = None, ) -> trimesh.Trimesh: """Build a trimesh surface from one contour type across all frames. Parameters ---------- geometry: The geometry whose frames supply the contours. contour_type: Which contour type to use. Defaults to ``PyContourType.Lumen``. Returns ------- trimesh.Trimesh Closed tube mesh with one quad-strip per adjacent contour pair. Each quad is split into two triangles. """ if contour_type is None: contour_type = PyContourType.Lumen if contour_type == PyContourType.Lumen: contours = geometry.get_lumen_contours() else: contours = geometry.get_contours_by_type(contour_type.name) if len(contours) < 2: raise ValueError("Need at least two contours to build a mesh.") n = len(contours[0].points) # points per contour (same for all) # --- vertices ----------------------------------------------------------- vertices = np.array( [pt for c in contours for pt in c.points_as_tuples()], dtype=np.float64, ) # shape: (n_contours * n, 3) # --- faces -------------------------------------------------------------- faces = [] n_contours = len(contours) for i in range(n_contours - 1): base_i = i * n base_j = (i + 1) * n for j in range(n): j1 = (j + 1) % n # quad corners: a-b (contour i), c-d (contour i+1) a = base_i + j b = base_i + j1 c = base_j + j1 d = base_j + j faces.append([a, b, d]) faces.append([b, c, d]) mesh = trimesh.Trimesh( vertices=vertices, faces=np.array(faces, dtype=np.int64), process=False, ) # Ensure normals point away from the vessel lumen (outward). # Use the first ring's centroid as the "inside" reference and the first # face's centre as the sample point — if the face normal points toward the # centroid the whole mesh is wound inward and all faces are flipped. first_centroid = np.array(contours[0].centroid, dtype=np.float64) first_face_center = mesh.triangles_center[0] first_normal = mesh.face_normals[0] if np.dot(first_normal, first_face_center - first_centroid) < 0: mesh.faces = mesh.faces[:, ::-1] return mesh