Skip to content

Commit d5dad80

Browse files
committed
ENH: Reduce computation for inversion reduction
1 parent 54c839b commit d5dad80

4 files changed

Lines changed: 133 additions & 6 deletions

File tree

src/monai_physio/contour_tools.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -780,9 +780,15 @@ def repair_inverted_tetrahedra(
780780
already clears zero volume.
781781
782782
Raises:
783-
ValueError: If elements are still inverted or degenerate after
784-
*max_iterations* passes.
783+
ValueError: If *tetrahedra* has no TETRA cells, or if elements
784+
are still inverted or degenerate after *max_iterations*
785+
passes.
785786
"""
787+
if np.uint8(pv.CellType.TETRA) not in tetrahedra.cells_dict:
788+
raise ValueError(
789+
"tetrahedra has no TETRA cells to repair; got cell types "
790+
f"{sorted(tetrahedra.cells_dict)}."
791+
)
786792
connectivity = tetrahedra.cells_dict[np.uint8(pv.CellType.TETRA)]
787793

788794
def volumes(points: np.ndarray) -> np.ndarray:
@@ -803,13 +809,22 @@ def volumes(points: np.ndarray) -> np.ndarray:
803809
)
804810
starts = np.concatenate([edges[:, 0], edges[:, 1]])
805811
ends = np.concatenate([edges[:, 1], edges[:, 0]])
812+
order = np.argsort(starts, kind="stable")
813+
sorted_starts = starts[order]
814+
sorted_ends = ends[order]
815+
split_points = np.searchsorted(sorted_starts, np.arange(len(points) + 1))
816+
neighbors = {
817+
node: np.unique(sorted_ends[split_points[node] : split_points[node + 1]])
818+
for node in np.unique(connectivity)
819+
}
806820

807821
for _ in range(max_iterations):
808822
bad = volumes(points) <= 0.0
809823
if not np.any(bad):
810824
break
825+
snapshot = points.copy()
811826
for node in np.unique(connectivity[bad]):
812-
neighbor_points = points[ends[starts == node]]
827+
neighbor_points = snapshot[neighbors[node]]
813828
if len(neighbor_points):
814829
points[node] = neighbor_points.mean(axis=0)
815830

src/monai_physio/train_physicsnemo_physics_informed_motion.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -651,9 +651,14 @@ def _bind_reference_meshes(
651651
# A per-subject fit carries no cell-quality constraint, so it can
652652
# flip a handful of elements even though the template did not;
653653
# repair here rather than only at fit time so meshes fitted before
654-
# this check existed still load.
655-
mesh = contour_tools.repair_inverted_tetrahedra(mesh)
656-
points = np.asarray(mesh.points, dtype=np.float64)
654+
# this check existed still load. Repair against self._tets, the
655+
# connectivity tet_volumes() below actually uses, rather than
656+
# whatever cells the file happens to store -- a mismatch there
657+
# would repair the wrong topology and still leave the physics
658+
# elements inverted.
659+
tet_grid = pv.UnstructuredGrid({pv.CellType.TETRA: self._tets}, mesh.points)
660+
repaired = contour_tools.repair_inverted_tetrahedra(tet_grid)
661+
points = np.asarray(repaired.points, dtype=np.float64)
657662
_, nodal = tet_volumes(points, self._tets)
658663
reference = torch.from_numpy(points).to(device=device, dtype=torch.float32)
659664
volumes = torch.from_numpy(nodal).to(device=device, dtype=torch.float32)

tests/test_contour_mesh_extraction.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,5 +407,45 @@ def test_anatomy_color_survives(self, contour_tools: ContourTools) -> None:
407407
)
408408

409409

410+
class TestRepairInvertedTetrahedra:
411+
"""Node relaxation must fix a flippable mesh and give up on one that can't."""
412+
413+
@staticmethod
414+
def _single_tetra(points: np.ndarray) -> pv.UnstructuredGrid:
415+
"""Build a one-cell tetrahedral mesh from four *points*."""
416+
cells = np.array([4, 0, 1, 2, 3])
417+
return pv.UnstructuredGrid(cells, [pv.CellType.TETRA], points)
418+
419+
def test_repairs_an_inverted_tetrahedron(self, contour_tools: ContourTools) -> None:
420+
"""Swapping two corners inverts the cell; relaxation must restore it."""
421+
points = np.array(
422+
[[0.0, 0.0, 0.0], [1.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]]
423+
)
424+
mesh = self._single_tetra(points)
425+
426+
repaired = contour_tools.repair_inverted_tetrahedra(mesh)
427+
428+
corners = repaired.points[repaired.cells_dict[np.uint8(pv.CellType.TETRA)]][0]
429+
edges = corners[1:, :] - corners[0:1, :]
430+
assert np.linalg.det(edges) / 6.0 > 0.0
431+
432+
def test_raises_when_unrecoverable(self, contour_tools: ContourTools) -> None:
433+
"""A degenerate cell with no neighbors to average toward can't be fixed."""
434+
points = np.array(
435+
[[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]]
436+
)
437+
mesh = self._single_tetra(points)
438+
439+
with pytest.raises(ValueError, match="still inverted or degenerate"):
440+
contour_tools.repair_inverted_tetrahedra(mesh, max_iterations=2)
441+
442+
def test_raises_when_no_tetra_cells(self, contour_tools: ContourTools) -> None:
443+
"""A mesh without TETRA cells fails with a clear message, not a KeyError."""
444+
mesh = pv.UnstructuredGrid()
445+
446+
with pytest.raises(ValueError, match="no TETRA cells"):
447+
contour_tools.repair_inverted_tetrahedra(mesh)
448+
449+
410450
if __name__ == "__main__":
411451
pytest.main([__file__, "-v", "-s"])

tests/test_physics_informed_motion.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,3 +498,70 @@ def test_the_epoch_log_separates_the_two_loss_terms() -> None:
498498
assert "physics=0.000000" in messages[-1], (
499499
f"An epoch that accumulated nothing should report zero: {messages[-1]}"
500500
)
501+
502+
503+
def test_bind_reference_meshes_repairs_against_template_elements(tmp_path: Any) -> None:
504+
"""Repair must use ``self._tets``, not whatever cells the file stores.
505+
506+
A fitted reference file's own connectivity is never read back by
507+
``tet_volumes`` -- only ``self._tets`` is -- so repairing against the
508+
file's cells instead would validate the wrong topology. Here the file
509+
stores one degenerate, unrecoverable cell that touches only a single
510+
node; repairing against it would raise, but ``self._tets`` names a
511+
perfectly valid mesh, so binding must succeed unchanged.
512+
"""
513+
import torch
514+
515+
from monai_physio.physicsnemo_tools import DistributedContext
516+
from monai_physio.train_physicsnemo_physics_informed_motion import (
517+
TrainPhysicsNeMoPhysicsInformedMotion,
518+
)
519+
520+
points, tets = _grid_mesh(size=3)
521+
mismatched = pv.UnstructuredGrid(
522+
{pv.CellType.TETRA: np.array([[0, 0, 0, 0]])}, points
523+
)
524+
mesh_path = tmp_path / "reference.vtu"
525+
mismatched.save(mesh_path)
526+
527+
method = TrainPhysicsNeMoPhysicsInformedMotion()
528+
method._tets = tets
529+
method._sample_subjects = ["subj0"]
530+
method._reference_meshes = {"subj0": mesh_path}
531+
context = DistributedContext(
532+
device=torch.device("cpu"), rank=0, local_rank=0, world_size=1
533+
)
534+
535+
method._bind_reference_meshes(context, n_points=len(points))
536+
537+
reference, volumes = method._reference_cache["subj0"]
538+
assert np.allclose(reference.numpy(), points, atol=1e-5)
539+
assert torch.all(volumes > 0)
540+
541+
542+
def test_bind_reference_meshes_tolerates_a_file_with_no_cells(tmp_path: Any) -> None:
543+
"""A reference file need not carry any cells at all; only its points do."""
544+
import torch
545+
546+
from monai_physio.physicsnemo_tools import DistributedContext
547+
from monai_physio.train_physicsnemo_physics_informed_motion import (
548+
TrainPhysicsNeMoPhysicsInformedMotion,
549+
)
550+
551+
points, tets = _grid_mesh(size=3)
552+
mesh_path = tmp_path / "reference.vtp"
553+
pv.PolyData(points).save(mesh_path)
554+
555+
method = TrainPhysicsNeMoPhysicsInformedMotion()
556+
method._tets = tets
557+
method._sample_subjects = ["subj0"]
558+
method._reference_meshes = {"subj0": mesh_path}
559+
context = DistributedContext(
560+
device=torch.device("cpu"), rank=0, local_rank=0, world_size=1
561+
)
562+
563+
method._bind_reference_meshes(context, n_points=len(points))
564+
565+
reference, volumes = method._reference_cache["subj0"]
566+
assert np.allclose(reference.numpy(), points, atol=1e-5)
567+
assert torch.all(volumes > 0)

0 commit comments

Comments
 (0)