Source code for physiotwin4d.contour_tools
"""
Tools for creating and manipulating contours.
"""
from __future__ import annotations
import logging
import os
from typing import Optional, Sequence, cast
import itk
import numpy as np
import pyacvd
import pyvista as pv
import trimesh
from .image_tools import ImageTools
from .physiotwin4d_base import PhysioTwin4DBase
from .transform_tools import TransformTools
from .usd_anatomy_tools import USDAnatomyTools
# VTK_VOXEL lists its eight corners in (i, j, k) raster order; VTK_HEXAHEDRON
# wants the bottom quad wound consistently, then the matching top quad.
_VOXEL_TO_HEX = [0, 1, 3, 2, 4, 5, 7, 6]
# When the image direction matrix is left-handed (negative determinant), the
# above winding yields inverted (negative-Jacobian) hexahedra, so swap the
# bottom and top quads to restore a positive volume.
_VOXEL_TO_HEX_FLIPPED = [4, 5, 7, 6, 0, 1, 3, 2]
# trim_tetrahedra_to_surface halves a point's move until its cells clear the
# quality bound; below this fraction of the original move the point is put back
# where it started instead, which both ends the search and keeps the mesh free
# of moves too small to matter.
_MIN_TRIM_DAMPING = 2.0**-10
# Background voxels kept around the labels in extract_label_surfaces, enough
# for the isotropic grid to hold a full voxel of background on every side.
_LABEL_SURFACE_PAD = 3
# extract_contours resamples a labelmap whose coarsest spacing is more than
# this multiple of its finest one, because a contour built on the coarse axis
# terraces at its pitch. Below it the resample costs more than it buys.
_CONTOUR_ANISOTROPY_LIMIT = 1.5
[docs]
class ContourTools(PhysioTwin4DBase):
"""
Tools for creating and manipulating contours.
"""
[docs]
def __init__(self, log_level: int | str = logging.INFO):
"""Initialize ContourTools.
Args:
log_level: Logging level (default: logging.INFO)
"""
super().__init__(class_name=self.__class__.__name__, log_level=log_level)
# USDAnatomyTools builds its color tables in __init__ without touching
# the stage, so stage=None is safe for these lookup-only uses.
self._anatomy_tools = USDAnatomyTools(stage=None, log_level=log_level)
[docs]
def apply_anatomy_color(
self, mesh: pv.DataSet, anatomy_names: Sequence[str]
) -> None:
"""Attach a structure's :class:`USDAnatomyTools` color **in-place**.
Sets, as :meth:`WorkflowConvertImageToVTK._annotate` does, so geometry
from here colors the same way in Paraview, PyVista, and the USD
exporter:
- ``field_data['AnatomyColor']`` — RGB float32 color.
- ``cell_data['Color']`` — RGBA uint8 solid color (n_cells × 4).
Args:
mesh: Surface or volume mesh to annotate.
anatomy_names: Names tried in order, most specific first, e.g. an
organ name followed by its anatomy group. ``USDAnatomyTools``
carries overrides for some organs (``myocardium``) but not
others (``left_ventricle``), so a group name is the usual
second entry. Falls back to ``'other'`` when none resolves.
"""
name = next(
(
candidate
for candidate in anatomy_names
if self._anatomy_tools.resolve_anatomy_type(candidate) is not None
),
None,
)
if name is None:
self.log_warning(
"No anatomy color matches %s; using 'other'",
" or ".join(anatomy_names),
)
name = "other"
color_rgb = self._anatomy_tools.get_anatomy_diffuse_color(name)
mesh.field_data["AnatomyColor"] = np.array(color_rgb, dtype=np.float32)
rgba = np.array(
[int(channel * 255) for channel in color_rgb] + [255], dtype=np.uint8
)
if mesh.n_cells > 0:
mesh.cell_data["Color"] = np.tile(rgba, (mesh.n_cells, 1))
[docs]
def extract_contours(
self,
labelmap_image: itk.image,
smoothing_iterations: int = 10,
smoothing_scale: float = 1.0,
surface_reduction_rate: float = 0.0,
taubin_iterations: int = 20,
) -> pv.PolyData:
"""
Make contours from a labelmap image.
Every label boundary is emitted, including the internal boundaries
between adjacent labels, so the result is a multi-material surface and
is not watertight: an edge where three labels meet is shared by three
faces. Use :meth:`extract_watertight_surface` when a single label's
closed surface is needed.
Two passes keep the result off the voxel block edges the labelmap is
drawn on. An anisotropic labelmap is first resampled onto an isotropic
grid of its finest pitch, the way :meth:`extract_label_surfaces` does,
so that a boundary between two thick slices lands between them instead
of terracing at one of them. The contour is then Taubin-smoothed,
which the surface net's own constrained smoothing cannot substitute
for: that one may not move a point more than about a voxel, so it
rounds the blocks without removing them. Taubin does not shrink the
surface, and the surface net shares its points between neighboring
labels, so smoothing the mesh as one moves a shared point once and the
labels stay in contact.
Args:
labelmap_image (itk.image): The labelmap image to create contours from
smoothing_iterations: Surface-net smoothing iterations.
smoothing_scale: Surface-net smoothing scale.
surface_reduction_rate: Fraction of triangles to remove afterwards
(0.0 disables).
taubin_iterations: Taubin smoothing iterations applied after
reduction, so they act on evenly sized triangles (0 disables).
Returns:
pv.PolyData: The contours as a PyVista PolyData object
"""
spacing = np.asarray(labelmap_image.GetSpacing(), dtype=np.float64)
if float(np.max(spacing) / np.min(spacing)) > _CONTOUR_ANISOTROPY_LIMIT:
self.log_info(
"Contouring on a %.3g mm isotropic grid rather than the "
"labelmap's %s mm one",
float(np.min(spacing)),
" x ".join(f"{value:.3g}" for value in spacing),
)
labelmap_image = self._resample_labelmap_isotropic(
labelmap_image, float(np.min(spacing))
)
labels = pv.wrap(itk.vtk_image_from_image(labelmap_image))
contours = cast(
pv.PolyData,
labels.contour_labels(
boundary_style="all",
pad_background=False,
smoothing=True,
smoothing_iterations=smoothing_iterations,
smoothing_scale=smoothing_scale,
output_mesh_type="triangles",
),
)
return self.remesh_and_smooth_surface(
contours, surface_reduction_rate, taubin_iterations
)
[docs]
def extract_watertight_surface(
self,
mask_image: itk.image,
smoothing_iterations: int = 10,
gaussian_sigma_mm: float = 0.5,
surface_reduction_rate: float = 0.0,
anatomy_names: Optional[Sequence[str]] = None,
) -> pv.PolyData:
"""Extract one binary mask's closed, outward-oriented surface.
:meth:`extract_contours` cannot produce a watertight surface: its
surface nets pinch where a mask self-touches across a voxel diagonal,
leaving edges shared by four faces, and they leave the mask open where
it reaches the image border. Isocontouring a continuous field cannot
pinch, so this pads the mask with one voxel of background, blurs it, and
runs marching cubes at the half-way isovalue instead.
Reduction goes through :meth:`remesh_and_smooth_surface`, whose ACVD
remeshing keeps a watertight input watertight where the VTK decimators
do not. That is a property of the remesher rather than a guarantee, so
a reduced result is still checked and a warning logged if it degrades.
Args:
mask_image: Binary mask holding the single structure to extract.
smoothing_iterations: Taubin smoothing iterations (0 disables).
gaussian_sigma_mm: Blur applied before isocontouring, in
millimeters, so it is independent of the voxel pitch.
surface_reduction_rate: Fraction of triangles to remove (0.0
disables).
anatomy_names: Names passed to :meth:`apply_anatomy_color`, most
specific first. ``None`` leaves the surface uncolored.
Returns:
The structure's surface, with outward normals.
"""
spacing = np.asarray(mask_image.GetSpacing(), dtype=np.float64)
direction = itk.array_from_matrix(mask_image.GetDirection())
# One voxel of background all around, so a structure reaching the image
# border still closes; the origin steps back one voxel to match.
padded_arr = np.pad(itk.GetArrayViewFromImage(mask_image).astype(np.float32), 1)
padded = itk.GetImageFromArray(np.ascontiguousarray(padded_arr))
padded.SetSpacing(mask_image.GetSpacing())
padded.SetDirection(mask_image.GetDirection())
padded.SetOrigin(
np.asarray(mask_image.GetOrigin(), dtype=np.float64) + direction @ -spacing
)
blurred = itk.smoothing_recursive_gaussian_image_filter(
padded, sigma=gaussian_sigma_mm
)
surface = cast(
pv.PolyData,
pv.wrap(itk.vtk_image_from_image(blurred)).contour(
[0.5], method="flying_edges"
),
)
if smoothing_iterations > 0:
surface = surface.smooth_taubin(n_iter=smoothing_iterations)
# VTK winds faces for a right-handed direction matrix, so an LPS image
# with a negative-determinant direction comes out with inward normals.
surface = surface.compute_normals(
auto_orient_normals=True, consistent_normals=True
)
if surface_reduction_rate > 0.0:
surface = self.remesh_and_smooth_surface(surface, surface_reduction_rate, 0)
if not self.is_watertight(surface):
self.log_warning(
"Remeshing by %.2f made the surface non-watertight",
surface_reduction_rate,
)
if anatomy_names is not None:
self.apply_anatomy_color(surface, anatomy_names)
return surface
[docs]
def extract_label_surfaces(
self,
labelmap_image: itk.image,
isotropic_spacing_mm: Optional[float] = None,
distance_sigma_mm: Optional[float] = None,
smoothing_iterations: int = 30,
) -> dict[int, pv.PolyData]:
"""Extract every label's surface, smooth and conforming with its neighbors.
:meth:`extract_watertight_surface`, run per label, traces each label's
own voxel block edges: on anisotropic data the result terraces at the
slice pitch, and neighboring labels are contoured independently, so
their shared wall is meshed twice and the two copies do not match.
This extracts all labels together instead:
1. The labelmap is resampled onto an isotropic grid with ITK's
label-aware Gaussian interpolator, which votes over the labels in a
physical-space kernel rather than picking a nearest voxel. That is
what removes the terracing: a boundary between two slices lands
between them instead of on one of them.
2. Every label, plus the background, gets a signed distance map on that
grid, and each voxel is assigned to the label whose map is smallest.
The assignment is a partition, so no gap or overlap can arise.
3. Label ``L``'s surface is the zero level of ``D_L`` minus the smallest
of the other maps. On a wall between ``L`` and ``M`` that field is
the negation of ``M``'s, so marching cubes puts identical vertices on
both surfaces and the two meet exactly.
4. The surfaces are merged, which welds those coincident vertices, then
Taubin-smoothed as one mesh. Smoothing therefore moves a shared
vertex once and the surfaces stay in contact.
Structures thinner than the interpolation kernel lose volume, coronary
arteries most of all; the fraction of each label's voxel volume that
the surface encloses is logged.
Args:
labelmap_image: Multi-label image; every non-zero label present is
extracted. A binary mask yields a single surface.
isotropic_spacing_mm: Edge length of the isotropic grid the
surfaces are contoured on, which sets both their smoothness and
their triangle count. ``None`` uses the labelmap's finest
spacing.
distance_sigma_mm: Blur applied to the distance maps, which is what
takes the voxel facets out of the contoured surface. ``None``
uses the isotropic spacing; raising it smooths further and
thins the smallest structures.
smoothing_iterations: Taubin smoothing iterations (0 disables).
Returns:
Label id → that label's closed, outward-oriented surface. A label
too small to survive the isotropic grid is left out, so the mapping
is empty when the labelmap holds no non-zero label and may be
missing labels that it does hold.
"""
labels = itk.GetArrayViewFromImage(labelmap_image)
label_ids = [int(value) for value in np.unique(labels) if value != 0]
if not label_ids:
self.log_warning("Labelmap holds no non-zero label")
return {}
spacing = np.asarray(labelmap_image.GetSpacing(), dtype=np.float64)
iso = (
float(np.min(spacing))
if isotropic_spacing_mm is None
else isotropic_spacing_mm
)
sigma = iso if distance_sigma_mm is None else distance_sigma_mm
cropped = self._crop_to_labels(labelmap_image, labels)
fine = self._resample_labelmap_isotropic(cropped, iso)
fine_labels = itk.GetArrayViewFromImage(fine)
# Pass one: the closest and second closest label at every voxel. Two
# values are needed because a label's own map is the closest one inside
# it, and its surface is measured against the next closest.
closest = np.full(fine_labels.shape, np.inf, dtype=np.float32)
runner_up = np.full(fine_labels.shape, np.inf, dtype=np.float32)
closest_index = np.zeros(fine_labels.shape, dtype=np.int16)
for index, label_id in enumerate(label_ids + [0]):
distance = self._signed_distance_mm(fine, fine_labels, label_id, sigma)
is_closer = distance < closest
runner_up = np.where(is_closer, closest, np.minimum(runner_up, distance))
closest = np.where(is_closer, distance, closest)
closest_index[is_closer] = index
# Pass two: one surface per label, tagged so the merged mesh can be
# split again after smoothing. The distance maps are recomputed rather
# than kept, which costs one more pass but not one array per label.
parts: list[pv.PolyData] = []
for index, label_id in enumerate(label_ids):
distance = self._signed_distance_mm(fine, fine_labels, label_id, sigma)
others = np.where(closest_index == index, runner_up, closest)
field = itk.GetImageFromArray(np.ascontiguousarray(distance - others))
field.CopyInformation(fine)
part = cast(
pv.PolyData,
pv.wrap(itk.vtk_image_from_image(field)).contour(
[0.0], method="flying_edges"
),
)
# Voxels where two labels tie exactly contour to zero-area
# triangles. clean turns those into line cells rather than
# dropping them, so the polygons are then taken on their own.
part = part.clean().triangulate()
part = pv.PolyData(part.points, faces=part.faces)
part.cell_data["LabelId"] = np.full(part.n_cells, label_id, dtype=np.int32)
parts.append(part)
merged = cast(pv.PolyData, pv.merge(parts, merge_points=True))
if smoothing_iterations > 0:
# Every wall between two labels is meshed twice, so its edges are
# non-manifold; without non_manifold_smoothing VTK pins them and
# nothing moves.
merged = merged.smooth_taubin(
n_iter=smoothing_iterations, non_manifold_smoothing=True
)
voxel_volume = float(np.prod(spacing))
merged_ids = np.asarray(merged.cell_data["LabelId"])
surfaces: dict[int, pv.PolyData] = {}
for label_id in label_ids:
# Kept as an array rather than a list: a label with no cells gives an
# empty selection, and an empty list has no integer dtype for
# extract_cells to recognize it by. Empty here means no surface,
# which is what the next branch reports.
cell_ids = cast(Sequence[int], np.flatnonzero(merged_ids == label_id))
surface = self.extract_surface(merged.extract_cells(cell_ids)).triangulate()
if surface.n_cells == 0:
# A label smaller than the isotropic grid loses its vote to its
# neighbors, so no voxel is assigned to it and its field never
# crosses zero. It has no surface to return.
self.log_warning(
"Label %d is too small for a %.3g mm grid; it has no surface",
label_id,
iso,
)
continue
# The bookkeeping arrays of the merge and the split; the label is
# the key of the returned mapping, so it is not data on the mesh.
for array_name in ("LabelId", "vtkOriginalCellIds", "vtkOriginalPointIds"):
surface.cell_data.pop(array_name, None)
surface.point_data.pop(array_name, None)
# VTK winds faces for a right-handed direction matrix, so an LPS
# image with a negative-determinant direction comes out inward.
surfaces[label_id] = surface.compute_normals(
auto_orient_normals=True, consistent_normals=True
)
self.log_debug(
"Label %d: %d triangles, %.3f of its voxel volume",
label_id,
surfaces[label_id].n_cells,
float(surfaces[label_id].volume)
/ (int(np.count_nonzero(labels == label_id)) * voxel_volume),
)
return surfaces
@staticmethod
def _crop_to_labels(labelmap_image: itk.image, labels: np.ndarray) -> itk.image:
"""Return *labelmap_image* cropped to its labels and padded with background.
The pad closes structures that reach the image border, which would
otherwise contour to an open surface, and gives the distance maps room
to fall away from the outermost label.
"""
spacing = np.asarray(labelmap_image.GetSpacing(), dtype=np.float64)
direction = itk.array_from_matrix(labelmap_image.GetDirection())
# labels' axes are reversed relative to the ITK image, so the extents
# come back as (z, y, x) and are flipped to (x, y, z) for the origin.
extents = np.nonzero(labels)
start = np.array([int(axis.min()) for axis in extents])
stop = np.array([int(axis.max()) + 1 for axis in extents])
cropped_arr = np.pad(
labels[start[0] : stop[0], start[1] : stop[1], start[2] : stop[2]],
_LABEL_SURFACE_PAD,
)
cropped = itk.GetImageFromArray(
np.ascontiguousarray(cropped_arr.astype(np.uint16))
)
cropped.SetSpacing(labelmap_image.GetSpacing())
cropped.SetDirection(labelmap_image.GetDirection())
cropped.SetOrigin(
np.asarray(labelmap_image.GetOrigin(), dtype=np.float64)
+ direction @ (spacing * (start[::-1] - _LABEL_SURFACE_PAD))
)
return cropped
@staticmethod
def _resample_labelmap_isotropic(
labelmap_image: itk.image,
isotropic_spacing_mm: float,
sigma_mm: Optional[np.ndarray] = None,
) -> itk.image:
"""Resample a labelmap onto an isotropic grid, label boundaries intact.
``LabelImageGaussianInterpolateImageFunction`` gives each output voxel
the label with the largest Gaussian-weighted vote among its neighbors,
so labels stay whole numbers, keep sharing their walls, and their
boundaries move to where the vote turns over rather than snapping to an
input voxel.
Args:
labelmap_image: Labelmap to resample.
isotropic_spacing_mm: Edge length of the output voxels.
sigma_mm: Per-axis width of the voting kernel. ``None`` uses one
input voxel along each axis, which is what lets a coarse axis
interpolate between its slices.
"""
spacing = np.asarray(labelmap_image.GetSpacing(), dtype=np.float64)
size = itk.size(labelmap_image)
interpolator = itk.LabelImageGaussianInterpolateImageFunction.New(
labelmap_image
)
sigma = spacing if sigma_mm is None else sigma_mm
interpolator.SetSigma([float(value) for value in sigma])
interpolator.SetAlpha(3.0)
return itk.resample_image_filter(
labelmap_image,
size=[
int((size[axis] - 1) * spacing[axis] / isotropic_spacing_mm) + 1
for axis in range(3)
],
output_spacing=[isotropic_spacing_mm] * 3,
output_origin=list(labelmap_image.GetOrigin()),
output_direction=labelmap_image.GetDirection(),
interpolator=interpolator,
default_pixel_value=0,
)
@staticmethod
def _signed_distance_mm(
reference: itk.image, labels: np.ndarray, label_id: int, sigma_mm: float
) -> np.ndarray:
"""Return the signed distance, in mm, to *label_id*, negative inside.
The map measures to voxel centers, so its zero level is faceted at the
voxel pitch; *sigma_mm* of blur takes those facets out, at the cost of
pulling the level set in by roughly ``sigma_mm ** 2`` times the surface
curvature. ``label_id`` of ``0`` measures to the background, whose
distance is the negated distance to the union of every label.
"""
inside = labels != 0 if label_id == 0 else labels == label_id
mask = itk.GetImageFromArray(np.ascontiguousarray(inside.astype(np.uint8)))
mask.CopyInformation(reference)
distance = itk.signed_maurer_distance_map_image_filter(
mask,
use_image_spacing=True,
squared_distance=False,
inside_is_positive=False,
)
if sigma_mm > 0.0:
distance = itk.smoothing_recursive_gaussian_image_filter(
distance, sigma=sigma_mm
)
distance_arr = np.asarray(itk.GetArrayFromImage(distance), dtype=np.float32)
return -distance_arr if label_id == 0 else distance_arr
[docs]
@staticmethod
def is_watertight(surface: pv.PolyData) -> bool:
"""Report whether every edge of *surface* is shared by exactly two faces.
A surface with no faces has no edge that fails the test, so it is
reported as not watertight rather than vacuously watertight.
"""
faces = surface.triangulate().faces.reshape(-1, 4)[:, 1:]
if len(faces) == 0:
return False
edges = np.sort(
np.vstack([faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]]), axis=1
)
_, counts = np.unique(edges, axis=0, return_counts=True)
return bool(np.all(counts == 2))
[docs]
def extract_tetrahedra(
self,
mask_image: itk.image,
element_size_mm: Optional[float] = None,
anatomy_names: Optional[Sequence[str]] = None,
) -> pv.UnstructuredGrid:
"""Build a tetrahedral mesh filling one binary mask.
Every retained voxel becomes a hexahedron, which VTK then splits into
six tetrahedra sharing the hexahedra's points, so the result is a
conforming mesh whose boundary is the voxel staircase rather than the
smooth surface :meth:`extract_label_surfaces` returns. Pass the result
through :meth:`trim_tetrahedra_to_surface` to relax that staircase onto
the surface.
Args:
mask_image: Binary mask holding the single structure to fill.
element_size_mm: Edge length of the isotropic voxels the mask is
resampled to before meshing, which is the resulting element
size. ``None`` meshes the mask's own voxels, so on anisotropic
data the elements inherit that anisotropy. A size above the
thinnest part of the structure drops that part.
anatomy_names: Names passed to :meth:`apply_anatomy_color`, most
specific first. ``None`` leaves the mesh uncolored.
Returns:
The structure's tetrahedral mesh, empty if the mask is empty or
*element_size_mm* is too coarse to keep any of it.
"""
mask_arr = itk.GetArrayViewFromImage(mask_image) != 0
if not mask_arr.any():
self.log_warning("Mask holds no voxel to mesh; its mesh is empty")
return pv.UnstructuredGrid()
# mask_arr axes are reversed relative to the ITK image, so the per-axis
# extents come back as (z, y, x) and are flipped to (x, y, z).
starts, stops = [], []
for axis_extent in np.nonzero(mask_arr):
starts.append(int(axis_extent.min()))
stops.append(int(axis_extent.max()) + 1)
start_zyx, stop_zyx = np.array(starts), np.array(stops)
cropped_arr = mask_arr[
start_zyx[0] : stop_zyx[0],
start_zyx[1] : stop_zyx[1],
start_zyx[2] : stop_zyx[2],
]
spacing = np.asarray(mask_image.GetSpacing(), dtype=np.float64)
direction = itk.array_from_matrix(mask_image.GetDirection())
# Cropping only translates the image, so the direction is unchanged and
# the winding correction below still applies after any resampling.
cropped = itk.GetImageFromArray(
np.ascontiguousarray(cropped_arr.astype(np.uint16))
)
cropped.SetSpacing(mask_image.GetSpacing())
cropped.SetDirection(mask_image.GetDirection())
cropped.SetOrigin(
np.asarray(mask_image.GetOrigin(), dtype=np.float64)
+ direction @ (spacing * start_zyx[::-1])
)
if element_size_mm is not None:
# A vote over the mask rather than a nearest neighbor, and one
# taken over at least an output voxel, so that coarsening keeps
# thin walls instead of sampling through them.
cropped = self._resample_labelmap_isotropic(
cropped,
element_size_mm,
np.maximum(spacing, element_size_mm),
)
cropped_arr = itk.GetArrayViewFromImage(cropped) != 0
spacing = np.full(3, element_size_mm, dtype=np.float64)
if not cropped_arr.any():
self.log_warning(
"Elements of %.3g mm are too coarse for this structure; "
"its mesh is empty",
element_size_mm,
)
return pv.UnstructuredGrid()
# Corner-point grid: one more point than voxels along each axis, with
# the origin backed off half a voxel to reach the first voxel's corner.
grid = pv.ImageData(
dimensions=tuple(int(n) + 1 for n in cropped_arr.shape[::-1]),
spacing=tuple(spacing),
origin=tuple(
np.asarray(cropped.GetOrigin(), dtype=np.float64)
+ direction @ (spacing * -0.5)
),
)
grid.direction_matrix = direction
grid.cell_data["label"] = cropped_arr.ravel().astype(np.uint8)
voxels = grid.threshold(0.5, scalars="label")
order = (
_VOXEL_TO_HEX if np.linalg.det(direction) > 0.0 else _VOXEL_TO_HEX_FLIPPED
)
connectivity = voxels.cells.reshape(-1, 9)[:, 1:][:, order]
cells = np.hstack(
[np.full((len(connectivity), 1), 8, dtype=connectivity.dtype), connectivity]
)
hexahedra = pv.UnstructuredGrid(
cells.ravel(),
np.full(len(connectivity), pv.CellType.HEXAHEDRON, dtype=np.uint8),
voxels.points,
)
tetrahedra = cast(pv.UnstructuredGrid, hexahedra.triangulate())
if anatomy_names is not None:
self.apply_anatomy_color(tetrahedra, anatomy_names)
return tetrahedra
[docs]
def trim_tetrahedra_to_surface(
self,
tetrahedra: pv.UnstructuredGrid,
surface: pv.PolyData,
iterations: int = 5,
relaxation: float = 0.6,
min_scaled_jacobian: float = 0.1,
) -> pv.UnstructuredGrid:
"""Relax a tetrahedral mesh onto *surface*, keeping every cell whole.
:meth:`extract_tetrahedra` meshes voxels, so its boundary is a
staircase that both protrudes through the smooth surface
:meth:`extract_label_surfaces` builds from the same mask and falls short
of it elsewhere. Cutting the mesh at the surface with ``clip_surface``
would follow it exactly but shatters the boundary tetrahedra into
slivers (about a tenth of the cells drop below a scaled Jacobian of
0.1), and neither VTK nor any current dependency can repair those.
So nothing is cut. A crinkle clip drops the cells that lie entirely
outside while leaving every surviving cell intact, then the mesh is
relaxed: each sweep moves every point part of the way toward the
average of its neighbors, and every boundary point instead toward its
closest point on *surface*. The interior smoothing is what makes room
for the boundary to reach the surface -- projecting the boundary alone
flattens the cells behind it, and the quality bound below then undoes
the move, which is why one projection pass leaves the staircase in
place.
A move that would still wreck a cell is backed off: every point of a
cell below *min_scaled_jacobian* has that sweep's step halved,
repeatedly, until the whole mesh clears the bound.
Args:
tetrahedra: Volume mesh to relax; its cell and field data survive.
surface: Closed surface to relax onto, in the same frame.
iterations: Relaxation sweeps. The boundary reaches the surface in
the first few and then stops moving: on the Duke heart labels,
sweeps beyond the default leave the mean boundary-to-surface
distance and the worst cell quality where they already were,
and only cost time.
relaxation: Fraction of the way to its target a point moves per
sweep. ``1.0`` moves the whole way and oscillates.
min_scaled_jacobian: Cell-quality bound every tetrahedron must meet
after each sweep. ``0.0`` only rules out flattened and
inverted cells; the default also rules out slivers.
Returns:
The relaxed mesh.
"""
# crinkle keeps whole cells, so this only discards, never subdivides.
relaxed = cast(
pv.UnstructuredGrid,
tetrahedra.clip_surface(surface, invert=True, crinkle=True),
)
if relaxed.n_cells == 0:
self.log_warning("Trimming against the surface removed every cell")
return relaxed
connectivity = relaxed.cells_dict[np.uint8(pv.CellType.TETRA)]
# Both directions of every tetrahedron edge, so a point's neighbors are
# the second column of the rows its id occupies in the first.
edges = np.vstack(
[
connectivity[:, pair]
for pair in ((0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3))
]
)
starts = np.concatenate([edges[:, 0], edges[:, 1]])
ends = np.concatenate([edges[:, 1], edges[:, 0]])
point_count = relaxed.n_points
neighbor_counts = np.bincount(starts, minlength=point_count).clip(1)
boundary_ids = np.asarray(
relaxed.extract_surface(algorithm="dataset_surface").point_data[
"vtkOriginalPointIds"
]
)
points = np.array(relaxed.points)
for _ in range(iterations):
target = (
np.column_stack(
[
np.bincount(
starts, weights=points[ends, axis], minlength=point_count
)
for axis in range(3)
]
)
/ neighbor_counts[:, np.newaxis]
)
_, closest = cast(
"tuple[np.ndarray, np.ndarray]",
surface.find_closest_cell(
target[boundary_ids], return_closest_point=True
),
)
target[boundary_ids] = closest
step = relaxation * (target - points)
# Backing one point off can push a neighboring cell below the bound
# that the full move had cleared, so this repeats until the whole
# mesh passes. The step bottoms out at zero, so the loop
# terminates even when the mesh holds cells below the bound that no
# amount of backing off can rescue.
damping = np.ones(point_count)
while True:
relaxed.points = points + damping[:, np.newaxis] * step
quality = np.asarray(
relaxed.cell_quality(["scaled_jacobian"]).cell_data[
"scaled_jacobian"
]
)
below_bound = quality < min_scaled_jacobian
if not np.any(below_bound):
break
damped_ids = np.unique(connectivity[below_bound])
damped_ids = damped_ids[damping[damped_ids] > 0.0]
if damped_ids.size == 0:
break
halved = damping[damped_ids] * 0.5
damping[damped_ids] = np.where(halved < _MIN_TRIM_DAMPING, 0.0, halved)
points = np.array(relaxed.points)
return relaxed
[docs]
def remesh_and_smooth_surface(
self,
surface: pv.PolyData,
surface_reduction_rate: float = 0.0,
smoothing_iterations: int = 0,
) -> pv.PolyData:
"""Optionally remesh then smooth a surface (no-op when disabled).
Reduction is isotropic remeshing (ACVD, via ``pyacvd``) rather than
decimation: the surface is re-tiled with uniform, well-shaped triangles
at the requested resolution. ``decimate_pro`` reaches the same triangle
count but leaves a watertight input non-watertight; ACVD does not.
Remeshing rebuilds the topology and so discards cell data, exactly as
``decimate_pro`` did: per-cell ``boundary_labels`` (needed for anatomy
splitting downstream) and ``SegmentationLabelIds`` (which structure each
triangle belongs to) are transferred back onto the new cells from their
nearest original cell so anatomy materials and structure ids still apply. Uniform
triangles cannot represent a label patch smaller than one of them,
though, so such a patch is absorbed by its neighbours and its label pair
disappears -- a warning names the pairs lost. ``decimate_pro`` kept
those patches by being non-uniform, which is the trade being made here.
Smoothing uses non-shrinking Taubin smoothing, which only moves points
and therefore preserves cells and their labels. It is told to move
non-manifold points too, since on a multi-material surface every edge
where three labels meet is non-manifold and VTK pins those points
otherwise; on a manifold surface the setting has nothing to act on.
Args:
surface: Input surface.
surface_reduction_rate: Fraction of triangles to remove (0.0 disables).
smoothing_iterations: Taubin smoothing iterations (0 disables).
Returns:
The remeshed and/or smoothed surface.
"""
conditioned = surface
if surface_reduction_rate > 0.0:
original = conditioned
clustering = pyacvd.Clustering(conditioned.triangulate())
# One cluster per retained point. A closed surface carries about
# twice as many triangles as points, so scaling the point count by
# (1 - rate) scales the triangle count by the same fraction; four
# is the fewest clusters that can still close a surface.
clustering.cluster(
max(4, round(original.n_points * (1.0 - surface_reduction_rate)))
)
conditioned = clustering.create_mesh()
carried = [
name
for name in ("boundary_labels", "SegmentationLabelIds")
if name in original.cell_data
]
if carried:
nearest = original.find_closest_cell(conditioned.cell_centers().points)
for name in carried:
conditioned.cell_data[name] = np.asarray(original.cell_data[name])[
nearest
]
if "boundary_labels" in original.cell_data:
labels = np.asarray(original.cell_data["boundary_labels"])
pairs = labels.reshape(len(labels), -1)
before = {tuple(row) for row in np.unique(pairs, axis=0).tolist()}
after = {
tuple(row) for row in np.unique(pairs[nearest], axis=0).tolist()
}
if before - after:
self.log_warning(
"Remeshing by %.2f dropped %d of %d boundary label pairs, "
"each covering less than one output triangle: %s",
surface_reduction_rate,
len(before - after),
len(before),
sorted(before - after),
)
if smoothing_iterations > 0:
conditioned = conditioned.smooth_taubin(
n_iter=smoothing_iterations, non_manifold_smoothing=True
)
return conditioned
[docs]
@staticmethod
def extract_surface(mesh: pv.DataSet) -> pv.PolyData:
"""Extract the surface of a mesh.
Args:
mesh: Input mesh (PolyData is returned unchanged; any other DataSet
is passed through ``extract_surface``).
Returns:
pv.PolyData: The surface of the mesh.
"""
if isinstance(mesh, pv.PolyData):
return mesh
return mesh.extract_surface(algorithm="dataset_surface")
[docs]
@staticmethod
def transform_contours(
contours: pv.PolyData,
tfm: itk.Transform,
with_deformation_magnitude: bool = False,
) -> pv.PolyData:
"""
Transform contours using a given transform.
Args:
tfm (itk.Transform): The transform to use
Returns:
pv.PolyData: The transformed contours with deformation magnitude
"""
new_contours = TransformTools().transform_pvcontour(
contours, tfm, with_deformation_magnitude=with_deformation_magnitude
)
return new_contours
[docs]
def merge_meshes(
self, meshes: list[pv.PolyData]
) -> tuple[pv.PolyData, list[pv.PolyData]]:
"""
Merge multiple fixed meshes into a single mesh.
Returns
-------
pv.PolyData
Merged mesh
"""
self.log_info("Merging meshes...")
trimesh_meshes: list[trimesh.Trimesh] = []
if hasattr(meshes[0], "n_faces_strict"):
trimesh_meshes = [
trimesh.Trimesh(
vertices=mesh.points,
faces=mesh.faces.reshape((mesh.n_faces_strict, 4))[:, 1:],
)
for mesh in meshes
]
else:
trimesh_meshes = [
trimesh.Trimesh(
vertices=mesh.points, faces=mesh.faces.reshape(-1, 4)[:, 1:4]
)
for mesh in meshes
]
# Merge meshes
merged_trimesh = trimesh.util.concatenate(trimesh_meshes)
flip_matrix = np.array(
[[-1, 0, 0, 0], [0, -1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]]
)
merged_trimesh.apply_transform(flip_matrix) # Apply flip transformation
for mesh in trimesh_meshes:
mesh.apply_transform(flip_matrix)
merged_mesh = pv.wrap(merged_trimesh)
pv_meshes = [pv.wrap(mesh) for mesh in trimesh_meshes]
return merged_mesh, pv_meshes
[docs]
@staticmethod
def create_reference_image(
mesh: pv.DataSet,
spatial_resolution: float = 0.5,
buffer_factor: float = 0.25,
ptype: type = itk.F,
) -> itk.Image:
"""
Create a reference image from a mesh.
"""
points = np.array(mesh.points)
min_bounds = points.min(axis=0)
max_bounds = points.max(axis=0)
min_bounds = min_bounds - buffer_factor * (max_bounds - min_bounds)
max_bounds = max_bounds + buffer_factor * (max_bounds - min_bounds)
region = (
((max_bounds - min_bounds) / spatial_resolution + 1)
.astype(np.int32)
.tolist()
)
itk_region = itk.ImageRegion[3]()
itk_region.SetSize(region)
reference_image = itk.Image[ptype, 3].New()
reference_image.SetRegions(itk_region)
reference_image.SetSpacing([spatial_resolution] * 3)
reference_image.SetOrigin(min_bounds.tolist())
reference_image.Allocate()
return reference_image
[docs]
@staticmethod
def create_mask_from_mesh(
mesh: pv.DataSet | pv.UnstructuredGrid,
reference_image: itk.Image,
) -> itk.Image:
ref_spacing = np.array(reference_image.GetSpacing())
# Create trimesh object with LPS coordinates
if isinstance(mesh, pv.UnstructuredGrid):
mesh = mesh.extract_surface(algorithm="dataset_surface")
if hasattr(mesh, "n_faces_strict"):
# PyVista PolyData
num_points_per_face = len(mesh.faces) // mesh.n_faces_strict
faces = mesh.faces.reshape((mesh.n_faces_strict, num_points_per_face))[
:, 1:
]
else:
# Handle other mesh types
faces = mesh.faces.reshape((-1, 4))[:, 1:]
trimesh_mesh = trimesh.Trimesh(vertices=mesh.points, faces=faces)
# Determine voxel spacing (use minimum spacing from reference)
voxel_pitch = float(np.min(ref_spacing))
# Voxelize the mesh
# trimesh.voxelized() creates a grid aligned with the mesh's bounding box
# The voxel grid origin is at the minimum corner of the bounding box
vox = trimesh_mesh.voxelized(pitch=voxel_pitch)
binary_array = vox.matrix.astype(np.uint8)
# Get the physical origin of the voxel grid in LPS space
# trimesh voxel grids use a transformation matrix, and the voxel grid starts
# at the mesh's minimum bounds. The physical origin is where voxel [0,0,0]
# center is located.
# Get mesh bounds in LPS coordinates
mesh_bounds_lps = (
trimesh_mesh.bounds
) # shape (2, 3): [[x_min, y_min, z_min], [x_max, y_max, z_max]]
# The voxel grid origin is at the minimum corner, but ITK origin is the CENTER
# of voxel (0,0,0)
# So we need to add half a voxel pitch to each dimension
voxel_grid_origin_lps = mesh_bounds_lps[0] + voxel_pitch / 2.0
voxel_grid_origin_lps[2] = (
voxel_grid_origin_lps[2] + voxel_pitch * binary_array.shape[2]
)
# transpose to match trimesh XYZ convention
binary_array_zyx = np.transpose(binary_array, (2, 1, 0))
binary_array_flip = np.flip(binary_array_zyx, axis=0)
binary_image = itk.GetImageFromArray(binary_array_flip)
# Set ITK image metadata in LPS coordinates
# Origin: where the center of voxel (0,0,0) is located in physical space
binary_image.SetOrigin(voxel_grid_origin_lps)
# Spacing: uniform voxel pitch in all directions
binary_image.SetSpacing([voxel_pitch] * 3)
# Direction: use identity for now (axis-aligned), will be handled by resampling
# Flip Z axis to match ITK convention
ref_dir = itk.array_from_matrix(binary_image.GetDirection())
ref_dir[2, 2] = -ref_dir[2, 2]
binary_image.SetDirection(ref_dir)
# Fill holes to create solid mask
ImageType = type(binary_image)
fill_filter = itk.BinaryFillholeImageFilter[ImageType].New()
fill_filter.SetInput(binary_image)
fill_filter.SetForegroundValue(1)
fill_filter.Update()
mask_image = fill_filter.GetOutput()
resampler = itk.ResampleImageFilter.New(Input=mask_image)
resampler.SetReferenceImage(reference_image)
resampler.SetUseReferenceImage(True)
resampler.SetInterpolator(
itk.NearestNeighborInterpolateImageFunction.New(mask_image)
)
resampler.SetDefaultPixelValue(0)
resampler.Update()
mask_image = resampler.GetOutput()
return mask_image
[docs]
def create_labelmap_from_meshes(
self,
meshes: list[pv.DataSet | pv.UnstructuredGrid],
reference_image: itk.Image,
) -> itk.Image:
"""
Create a labelmap from a list of meshes.
"""
labelmap_arr = np.zeros(
(
reference_image.GetLargestPossibleRegion().GetSize()[2],
reference_image.GetLargestPossibleRegion().GetSize()[1],
reference_image.GetLargestPossibleRegion().GetSize()[0],
),
dtype=np.uint16,
)
for i, mesh in enumerate(meshes):
mask_image = self.create_mask_from_mesh(mesh, reference_image)
mask_arr = itk.GetArrayFromImage(mask_image)
labelmap_arr[mask_arr > 0] = i + 1
labelmap_image = itk.GetImageFromArray(labelmap_arr)
labelmap_image.CopyInformation(reference_image)
return labelmap_image
[docs]
@staticmethod
def sample_mesh_faces(mesh: pv.DataSet, max_spacing: float) -> np.ndarray:
"""Return mesh points supplemented by samples across the triangle faces.
Rasterizing vertices alone leaves gaps between them on meshes that are
coarse relative to the voxel size, which makes a distance map built from
them ripple. Adding barycentric samples dense enough that consecutive
samples are closer than ``max_spacing`` closes those gaps.
Args:
mesh: Source mesh; its surface is triangulated if needed.
max_spacing: Target spacing between samples, in mm.
Returns:
(n, 3) array of sample points, starting with the mesh's own points.
"""
points = np.asarray(mesh.points, dtype=np.float64)
surface = mesh.extract_surface() if not isinstance(mesh, pv.PolyData) else mesh
surface = surface.triangulate()
if surface.faces.size == 0:
return points
faces = surface.faces.reshape(-1, 4)[:, 1:]
vertices = np.asarray(surface.points, dtype=np.float64)
corners = vertices[faces] # (n_faces, 3, 3)
edge_lengths = np.linalg.norm(
corners - np.roll(corners, 1, axis=1), axis=2
).max(axis=1)
# Group faces by how finely they need to be subdivided so each division
# level is generated as one vectorized batch.
divisions = np.maximum(1, np.ceil(edge_lengths / max(max_spacing, 1e-6)))
divisions = np.minimum(divisions, 64).astype(np.int64)
samples = [points]
for level in np.unique(divisions):
if level < 2:
continue
selected = corners[divisions == level]
# Barycentric lattice with `level` divisions per edge.
steps = np.arange(level + 1, dtype=np.float64) / level
u, v = np.meshgrid(steps, steps, indexing="ij")
mask = (u + v) <= 1.0
weights = np.column_stack([1.0 - u[mask] - v[mask], u[mask], v[mask]])
samples.append(np.einsum("fca,kc->fka", selected, weights).reshape(-1, 3))
return np.concatenate(samples, axis=0)
[docs]
def create_distance_map(
self,
mesh: pv.DataSet | pv.UnstructuredGrid,
reference_image: itk.Image,
squared_distance: bool = False,
negative_inside: bool = True,
zero_inside: bool = False,
norm_to_max_distance: float = 0.0,
sample_faces: bool = True,
) -> itk.Image:
"""Compute a distance map of a mesh on the reference image's grid.
Args:
mesh: Mesh whose surface the distances are measured to.
reference_image: Image defining the output grid.
squared_distance: Sign-preserving square of the result. Default: False
negative_inside: Keep the signed output. Default: True
zero_inside: Clip negative values to zero before anything else.
Default: False
norm_to_max_distance: If non-zero, divide by this value and clip to
[-1, 1]. Default: 0.0 (distances stay in mm)
sample_faces: Rasterize samples across the triangle faces as well as
the vertices, so that coarse meshes do not leave gaps in the
rasterized surface. Default: True
Returns:
ITK image of distances on the reference grid.
"""
self.log_info("Computing signed distance map...")
size = reference_image.GetLargestPossibleRegion().GetSize()
if sample_faces:
points = self.sample_mesh_faces(
mesh, 0.5 * float(min(reference_image.GetSpacing()))
)
self.log_debug(
"Distance map: %d face samples from %d mesh points",
len(points),
mesh.n_points,
)
else:
points = np.asarray(mesh.points, dtype=np.float64)
# NumPy convention is (z, y, x); ITK GetSize() returns (x, y, z)
tmp_arr = np.zeros((size[2], size[1], size[0]), dtype=np.uint8)
# Bulk equivalent of TransformPhysicalPointToIndex, which rounds half up.
index_to_world = itk.array_from_matrix(
reference_image.GetDirection()
) @ np.diag(np.asarray(reference_image.GetSpacing()))
origin = np.asarray(reference_image.GetOrigin(), dtype=np.float64)
indices = np.floor(
(points - origin) @ np.linalg.inv(index_to_world).T + 0.5
).astype(np.int64)
size_arr = np.array([size[0], size[1], size[2]], dtype=np.int64)
inside = np.all((indices >= 0) & (indices < size_arr), axis=1)
indices = indices[inside]
point_count = len(indices)
if point_count:
tmp_arr[indices[:, 2], indices[:, 1], indices[:, 0]] = 1
self.log_info(
"Distance map: %d/%d surface samples within reference image",
point_count,
len(points),
)
if point_count == 0:
self.log_warning(
"No surface points fall within the reference image! "
"Distance map will be constant. "
"Mesh bounds: %s Image origin: %s Image size: %s Image spacing: %s",
str(mesh.bounds),
str(reference_image.GetOrigin()),
str(size),
str(reference_image.GetSpacing()),
)
elif not inside.all():
# Distances near the dropped region are measured to whatever samples
# remain in the grid, so they are larger than the true distance.
self.log_warning(
"%d of %d surface samples fall outside the reference image; "
"distances near that boundary are overestimated.",
len(points) - point_count,
len(points),
)
tmp_binary_image = itk.GetImageFromArray(tmp_arr)
tmp_binary_image.CopyInformation(reference_image)
assert (
tmp_binary_image.GetLargestPossibleRegion().GetSize()
== reference_image.GetLargestPossibleRegion().GetSize()
)
distance_filter = itk.SignedMaurerDistanceMapImageFilter.New(
Input=tmp_binary_image
)
distance_filter.SetSquaredDistance(False)
distance_filter.SetUseImageSpacing(True)
distance_filter.Update()
distance_image = distance_filter.GetOutput()
distance_arr = itk.GetArrayFromImage(distance_image).astype(np.float32)
if zero_inside:
distance_arr = np.clip(distance_arr, 0.0, None)
if not negative_inside:
distance_arr = np.abs(distance_arr)
if squared_distance:
distance_arr = np.sign(distance_arr) * distance_arr**2
if norm_to_max_distance != 0.0:
distance_arr = distance_arr / norm_to_max_distance
distance_arr = np.clip(distance_arr, -1.0, 1.0)
distance_image = itk.GetImageFromArray(distance_arr)
distance_image.CopyInformation(reference_image)
return distance_image
[docs]
@staticmethod
def create_deformation_field(
points: np.ndarray,
point_displacements: np.ndarray,
reference_image: itk.Image,
blur_sigma: float = 2.5,
ptype: type = itk.D,
) -> itk.Image:
"""
Create a displacement map from model points and displacements.
"""
size = reference_image.GetLargestPossibleRegion().GetSize()
norm_map = np.zeros((size[2], size[1], size[0])).astype(np.float32)
displacement_map_x = np.zeros((size[2], size[1], size[0])).astype(np.float32)
displacement_map_y = np.zeros((size[2], size[1], size[0])).astype(np.float32)
displacement_map_z = np.zeros((size[2], size[1], size[0])).astype(np.float32)
itk_point = itk.Point[itk.D, 3]()
for i, point in enumerate(points):
itk_point[0] = float(point[0])
itk_point[1] = float(point[1])
itk_point[2] = float(point[2])
indx = reference_image.TransformPhysicalPointToIndex(itk_point)
if (
indx[0] < 0
or indx[1] < 0
or indx[2] < 0
or indx[0] >= size[0]
or indx[1] >= size[1]
or indx[2] >= size[2]
):
continue
displacement_map_x[int(indx[2]), int(indx[1]), int(indx[0])] = (
point_displacements[i, 0]
)
displacement_map_y[int(indx[2]), int(indx[1]), int(indx[0])] = (
point_displacements[i, 1]
)
displacement_map_z[int(indx[2]), int(indx[1]), int(indx[0])] = (
point_displacements[i, 2]
)
norm_map[int(indx[2]), int(indx[1]), int(indx[0])] = 1
norm_img = itk.GetImageFromArray(norm_map)
norm_img.CopyInformation(reference_image)
assert (
norm_img.GetLargestPossibleRegion().GetSize()
== reference_image.GetLargestPossibleRegion().GetSize()
)
blurred_norm = itk.smoothing_recursive_gaussian_image_filter(
Input=norm_img, Sigma=blur_sigma
)
blurred_norm_arr = itk.GetArrayFromImage(blurred_norm)
blurred_norm_arr = np.where(blurred_norm_arr < 1.0e-4, 1.0e-4, blurred_norm_arr)
deformation_field_x_img = itk.GetImageFromArray(displacement_map_x)
deformation_field_x_img.CopyInformation(reference_image)
deformation_field_x_img = itk.smoothing_recursive_gaussian_image_filter(
Input=deformation_field_x_img, Sigma=blur_sigma
)
deformation_field_y_img = itk.GetImageFromArray(displacement_map_y)
deformation_field_y_img.CopyInformation(reference_image)
deformation_field_y_img = itk.smoothing_recursive_gaussian_image_filter(
Input=deformation_field_y_img, Sigma=blur_sigma
)
deformation_field_z_img = itk.GetImageFromArray(displacement_map_z)
deformation_field_z_img.CopyInformation(reference_image)
deformation_field_z_img = itk.smoothing_recursive_gaussian_image_filter(
Input=deformation_field_z_img, Sigma=blur_sigma
)
deformation_field_x = (
itk.GetArrayFromImage(deformation_field_x_img) / blurred_norm_arr
)
deformation_field_y = (
itk.GetArrayFromImage(deformation_field_y_img) / blurred_norm_arr
)
deformation_field_z = (
itk.GetArrayFromImage(deformation_field_z_img) / blurred_norm_arr
)
deformation_field_x = np.where(
blurred_norm_arr > 1.0e-3, deformation_field_x, 0.0
)
deformation_field_y = np.where(
blurred_norm_arr > 1.0e-3, deformation_field_y, 0.0
)
deformation_field_z = np.where(
blurred_norm_arr > 1.0e-3, deformation_field_z, 0.0
)
deformation_field = np.stack(
[deformation_field_x, deformation_field_y, deformation_field_z], axis=-1
)
image_tools = ImageTools()
deformation_field_img = image_tools.convert_array_to_image_of_vectors(
deformation_field, reference_image, ptype=ptype
)
return deformation_field_img
# ─────────────────────────── I/O helpers ───────────────────────────────
[docs]
@staticmethod
def save_surfaces(
surfaces: dict[str, pv.PolyData],
output_dir: str,
prefix: str = "",
) -> dict[str, str]:
"""Save each named surface to its own VTP file.
Args:
surfaces: Mapping of name → surface (e.g. the ``'surfaces'``
value from :meth:`WorkflowConvertImageToVTK.process`).
output_dir: Directory to write files into (created if absent).
prefix: Optional filename prefix. Each file is named
``{prefix}_{name}.vtp`` (or ``{name}.vtp`` when *prefix* is empty).
Returns:
Mapping of name → absolute path of the saved file.
"""
os.makedirs(output_dir, exist_ok=True)
saved: dict[str, str] = {}
for name, surface in surfaces.items():
stem = f"{prefix}_{name}" if prefix else name
path = os.path.join(output_dir, f"{stem}.vtp")
surface.save(path)
saved[name] = path
return saved
[docs]
@staticmethod
def save_combined_surfaces(
surfaces: dict[str, pv.PolyData],
output_filename: str,
) -> str:
"""Merge all named surfaces into a single VTP file.
The merged mesh retains per-cell ``Color`` (RGBA uint8) from each
surface's annotation, enabling colour-by-anatomy rendering in
Paraview, PyVista, etc.
It also gains a per-cell ``SegmentationLabelIds`` (int32) array, which
carries each cell's originating label ID so structure identity survives
the merge. Downstream, :class:`ConvertVTKToUSD` splits on this array
when given ``mask_ids``, giving one prim (and one anatomy material) per
structure. A surface whose ``field_data['SegmentationLabelIds']`` does
not hold exactly one ID has no per-cell attribution — that is the case
for the per-group surfaces of :class:`WorkflowConvertImageToVTK`, which
are contoured from a merged binary mask — so its cells are tagged ``0``.
Pass the per-label surfaces (``'label_surfaces'``) to get real IDs.
Per-object ``field_data`` is *not* preserved: it is per-object, so a
single merged mesh cannot carry one value per input surface. The
remaining keys set by :meth:`WorkflowConvertImageToVTK._annotate` are
therefore lost:
- ``AnatomyGroup`` — group name, e.g. ``'heart'``.
- ``SegmentationLabelNames`` — structure names within the group.
- ``AnatomyColor`` — RGB float color (survives indirectly as the
per-cell ``Color`` array).
Use :meth:`save_surfaces` instead when structure *names* must be
recoverable from the saved files.
Args:
surfaces: Mapping of name → surface.
output_filename: Path of the VTP file to write, including its
directory. Any missing parent directories are created.
Returns:
Path to the saved VTP file.
Raises:
ValueError: If *surfaces* is empty.
"""
if not surfaces:
raise ValueError("No surfaces to save.")
output_dir = os.path.dirname(output_filename)
if output_dir:
os.makedirs(output_dir, exist_ok=True)
# Shallow copies so tagging does not add an array to the caller's
# surfaces; the point/cell arrays themselves stay shared.
tagged: list[pv.PolyData] = []
for surface in surfaces.values():
label_ids = surface.field_data.get("SegmentationLabelIds")
if label_ids is not None and len(label_ids) == 1:
label_id = int(label_ids[0])
else:
label_id = 0
tagged_surface = surface.copy(deep=False)
tagged_surface.cell_data["SegmentationLabelIds"] = np.full(
tagged_surface.n_cells, label_id, dtype=np.int32
)
tagged.append(tagged_surface)
merged = cast(pv.PolyData, pv.merge(tagged, merge_points=False))
merged.save(output_filename)
return output_filename