diff --git a/.agents/agents/architecture.md b/.agents/agents/architecture.md index ff9eda17..8a6bb6a6 100644 --- a/.agents/agents/architecture.md +++ b/.agents/agents/architecture.md @@ -22,7 +22,9 @@ src/monai_physio/ segment_chest_*.py - TotalSegmentator, VISTA-3D, NIM, Ensemble register_images_*.py - ICON, ANTs, Greedy, time-series wrappers register_models_*.py - ICP, PCA, distance-map registerers - process_contours.py - surface extraction from ITK masks + process_surface_mesh.py - surface extraction from ITK masks + process_volumetric_mesh.py - tetrahedral meshes from ITK masks + process_mesh_kernels.py - GPU primitives for both mesh modules convert_vtk_to_usd.py - high-level VTK→USD (in-memory, PyVista) vtk_to_usd/ - file-based VTK→USD subpackage process_usd.py / process_usd_anatomy.py - USD stage utilities diff --git a/.agents/agents/implementation.md b/.agents/agents/implementation.md index 95308bf5..56c9c053 100644 --- a/.agents/agents/implementation.md +++ b/.agents/agents/implementation.md @@ -19,7 +19,7 @@ tutorials may require a GPU. 4D CT → Segmentation → Registration → Contour Extraction → USD Export Key modules: `monai_physio_base.py`, `segment_chest_*.py`, `register_images_*.py`, -`register_models_*.py`, `process_contours.py`, `convert_vtk_to_usd.py`, `vtk_to_usd/`, +`register_models_*.py`, `process_surface_mesh.py`, `process_volumetric_mesh.py`, `convert_vtk_to_usd.py`, `vtk_to_usd/`, `workflow_*.py`. Use `graphify query ""` to locate classes before searching manually. diff --git a/.agents/agents/testing.md b/.agents/agents/testing.md index eda8c936..7ee25aa9 100644 --- a/.agents/agents/testing.md +++ b/.agents/agents/testing.md @@ -32,7 +32,7 @@ to the project interpreter. If activation is impossible, use ```powershell python -m pytest tests/ -v # fast, recommended (slow/GPU/etc auto-skipped) python -m pytest tests/test_contour_tools.py -v # single file -python -m pytest tests/test_contour_tools.py::TestProcessContours -v # single class +python -m pytest tests/test_process_surface_mesh.py::TestProcessSurfaceMesh -v # single class python -m pytest tests/ -v --run-slow # opt into slow tests # typical local GPU profile; CI adds --run-simpleware --run-tutorials python -m pytest tests/ -v --run-gpu --run-slow diff --git a/.github/workflows/nightly-health.yml b/.github/workflows/nightly-health.yml index ba2a1610..023bff70 100644 --- a/.github/workflows/nightly-health.yml +++ b/.github/workflows/nightly-health.yml @@ -115,11 +115,11 @@ jobs: # installed before ANY monai-physio extra is resolved, since # torch-scatter is now a base dependency and is pulled in immediately. # - # torch, torchvision, and torchaudio are all pinned to the pytorch-cu130 + # torch and torchvision are both pinned to the pytorch-cu130 # index here because the uv pip interface ignores tool.uv.sources. - # Pinning torch alone is not enough: the [cuda13] extra leaves the other - # two unconstrained, so they would resolve from PyPI and be built - # against a different CUDA runtime than torch. Installing all three up + # Pinning torch alone is not enough: the [cuda13] extra leaves + # torchvision unconstrained, so it would resolve from PyPI and be built + # against a different CUDA runtime than torch. Installing both up # front means [cuda13] finds them already satisfied. --index-url (not # --index) replaces PyPI for this command and matches the flag used in # .readthedocs.yaml; the pytorch index also serves the transitive deps. @@ -132,7 +132,7 @@ jobs: if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python -m uv pip install setuptools wheel if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } - python -m uv pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu130 + python -m uv pip install torch torchvision --index-url https://download.pytorch.org/whl/cu130 if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } python -c "import torch; print(f'build-time torch {torch.__version__} (CUDA {torch.version.cuda})')" if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb715b00..e43f6522 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -12,24 +12,26 @@ repos: # Run the formatter - id: ruff-format - # Type checking with mypy - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v2.1.0 + # Local hooks run from the active project venv + - repo: local hooks: + # Type checking with mypy. Runs in the project venv rather than an + # isolated hook env: the GPU modules are typed against the installed + # torch, warp and pyvista, which an isolated env would lack. + # Pass only the staged files pre-commit selects (via the files regex); + # do not scan whole src/ and tutorials/ trees, which would include + # untracked files. - id: mypy - # Pass only the staged files pre-commit selects (via the files regex); - # do not scan whole src/ and tutorials/ trees, which would include - # untracked files. - args: [--config-file=pyproject.toml] - additional_dependencies: - - types-setuptools - - scipy-stubs - - types-requests + name: mypy + entry: mypy + # pre-commit checks files in batches, so per-module config sections for + # files outside a batch would be reported as unused. + args: [--config-file=pyproject.toml, --no-warn-unused-configs] + language: system + types: [python] files: ^(src/|tutorials/) - # Local tests (optional - can be disabled if too slow) - - repo: local - hooks: + # Local tests (optional - can be disabled if too slow) - id: fast-unit-tests name: Run Fast Unit Tests entry: pytest tests/ -m "not slow" -v --maxfail=3 diff --git a/.ruff.toml b/.ruff.toml deleted file mode 100644 index 3b8d8787..00000000 --- a/.ruff.toml +++ /dev/null @@ -1,10 +0,0 @@ -# Ruff configuration file -# This overrides settings in pyproject.toml - -[lint] -# Ignore UP007/UP045: Use Optional[X] instead of X | None for itk.Image compatibility -ignore = ["UP007", "UP045"] - -[lint.per-file-ignores] -# Simpleware loads this script by file name, so the module name is fixed. -"src/monai_physio/simpleware_medical/SimplewareScript_heart_segmentation.py" = ["N999"] diff --git a/AGENTS.md b/AGENTS.md index 5a3c1bab..1175fa2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -82,8 +82,8 @@ python -m pre_commit run --all-files python -m pytest tests/ -v # Single test file or test by name -python -m pytest tests/test_contour_tools.py -v -python -m pytest tests/test_contour_tools.py::test_extract_surface -v +python -m pytest tests/test_process_surface_mesh.py -v +python -m pytest tests/test_process_surface_mesh.py::TestProcessSurfaceMesh::test_extract_contours_from_heart_mask -v # Opt-in test buckets python -m pytest tests/ -v --run-slow diff --git a/README.md b/README.md index 6bd377a4..2bd14d7a 100644 --- a/README.md +++ b/README.md @@ -49,10 +49,11 @@ for users and contributors. Key sections: ### Install ``` -uv pip install "monai-physio[cuda12]" +uv pip install "monai-physio[cuda12]" -f https://data.pyg.org/whl/torch-2.12.0+cu126.html ``` -Installs entirely from prebuilt wheels. On CUDA 13 use `monai-physio[cuda13]`, +Installs entirely from prebuilt wheels; the `-f` option points uv at the PyG +wheel index that hosts `torch-scatter`. On CUDA 13 use `monai-physio[cuda13]`, which additionally needs a CUDA toolkit and C++ toolchain to build `torch-scatter`. See the [installation guide](https://project-monai.github.io/monai-physio/installation.html) diff --git a/data/test/README.md b/data/test/README.md index 8ad8e4de..9cab9055 100644 --- a/data/test/README.md +++ b/data/test/README.md @@ -43,7 +43,7 @@ Every subdirectory is created on demand by `tests/conftest.py` fixtures the first time a test needs them, and is `.gitignore`d - do not commit their contents. The subsets derived from another dataset are only built when that source dataset is present under the input root; otherwise the tests -that need them skip, or fail if `--require-tutorial-data` was passed. +that need them skip, or fail if `--require-tutorial-data` (or `--run-all`) was passed. The tutorials read these directories rather than the full datasets whenever `MONAI_PHYSIO_RUNNING_AS_TEST` is set, and write to the matching `test` subtree of diff --git a/docs/api/utilities/image_conversion.rst b/docs/api/utilities/image_conversion.rst index 6af9880c..0e365abf 100644 --- a/docs/api/utilities/image_conversion.rst +++ b/docs/api/utilities/image_conversion.rst @@ -19,4 +19,4 @@ Module Reference .. rubric:: Navigation -:doc:`process_contours` | :doc:`index` | :doc:`../index` +:doc:`process_mesh_kernels` | :doc:`index` | :doc:`../index` diff --git a/docs/api/utilities/index.rst b/docs/api/utilities/index.rst index 180c131d..fb18b640 100644 --- a/docs/api/utilities/index.rst +++ b/docs/api/utilities/index.rst @@ -28,7 +28,9 @@ Quick Links * :doc:`process_labelmaps` - Labelmap to registration-mask conversion * :doc:`process_transforms` - Transform operations * :doc:`process_landmarks` - Landmark-based registration validation - * :doc:`process_contours` - Contour processing + * :doc:`process_surface_mesh` - Surface mesh extraction and processing + * :doc:`process_volumetric_mesh` - Tetrahedral mesh extraction and relaxation + * :doc:`process_mesh_kernels` - GPU mesh and distance-map primitives * :doc:`image_conversion` - 4D image to 3D time-series utilities * :doc:`process_tests` - Baseline / result comparison helpers * :doc:`download_data` - Optional dataset download helpers @@ -43,7 +45,9 @@ Module Documentation process_labelmaps process_transforms process_landmarks - process_contours + process_surface_mesh + process_volumetric_mesh + process_mesh_kernels image_conversion process_tests download_data diff --git a/docs/api/utilities/process_contours.rst b/docs/api/utilities/process_contours.rst deleted file mode 100644 index 053c1fc6..00000000 --- a/docs/api/utilities/process_contours.rst +++ /dev/null @@ -1,18 +0,0 @@ -==================================== -Contour Tools -==================================== - -.. currentmodule:: monai_physio - -Contour extraction and processing utilities. - -Module Reference -================ - -.. automodule:: monai_physio.process_contours - :members: - :undoc-members: - -.. rubric:: Navigation - -:doc:`process_transforms` | :doc:`index` | :doc:`image_conversion` diff --git a/docs/api/utilities/process_images.rst b/docs/api/utilities/process_images.rst index 80b88096..cb4385d7 100644 --- a/docs/api/utilities/process_images.rst +++ b/docs/api/utilities/process_images.rst @@ -15,4 +15,4 @@ Module Reference .. rubric:: Navigation -:doc:`index` | :doc:`process_transforms` | :doc:`process_contours` +:doc:`index` | :doc:`process_transforms` | :doc:`process_surface_mesh` diff --git a/docs/api/utilities/process_landmarks.rst b/docs/api/utilities/process_landmarks.rst index 1c8f0f1c..d76793cc 100644 --- a/docs/api/utilities/process_landmarks.rst +++ b/docs/api/utilities/process_landmarks.rst @@ -25,4 +25,4 @@ See Also .. rubric:: Navigation -:doc:`process_transforms` | :doc:`index` | :doc:`process_contours` +:doc:`process_transforms` | :doc:`index` | :doc:`process_surface_mesh` diff --git a/docs/api/utilities/process_mesh_kernels.rst b/docs/api/utilities/process_mesh_kernels.rst new file mode 100644 index 00000000..8b36ab9e --- /dev/null +++ b/docs/api/utilities/process_mesh_kernels.rst @@ -0,0 +1,18 @@ +==================================== +Mesh Kernels +==================================== + +.. currentmodule:: monai_physio + +GPU primitives shared by the surface and volumetric mesh tools: closest-point and inside/outside queries, tetrahedron quality, distance maps, blur and label-vote resampling. + +Module Reference +================ + +.. automodule:: monai_physio.process_mesh_kernels + :members: + :undoc-members: + +.. rubric:: Navigation + +:doc:`process_volumetric_mesh` | :doc:`index` | :doc:`image_conversion` \ No newline at end of file diff --git a/docs/api/utilities/process_surface_mesh.rst b/docs/api/utilities/process_surface_mesh.rst new file mode 100644 index 00000000..60325d8b --- /dev/null +++ b/docs/api/utilities/process_surface_mesh.rst @@ -0,0 +1,18 @@ +==================================== +Surface Mesh Tools +==================================== + +.. currentmodule:: monai_physio + +Surfaces from labelmaps and masks, surface remeshing, smoothing and combining, and conversions from surfaces back to images. + +Module Reference +================ + +.. automodule:: monai_physio.process_surface_mesh + :members: + :undoc-members: + +.. rubric:: Navigation + +:doc:`process_transforms` | :doc:`index` | :doc:`process_volumetric_mesh` \ No newline at end of file diff --git a/docs/api/utilities/process_transforms.rst b/docs/api/utilities/process_transforms.rst index 638faaf5..8261ae5d 100644 --- a/docs/api/utilities/process_transforms.rst +++ b/docs/api/utilities/process_transforms.rst @@ -15,4 +15,4 @@ Module Reference .. rubric:: Navigation -:doc:`process_images` | :doc:`index` | :doc:`process_contours` +:doc:`process_images` | :doc:`index` | :doc:`process_surface_mesh` diff --git a/docs/api/utilities/process_volumetric_mesh.rst b/docs/api/utilities/process_volumetric_mesh.rst new file mode 100644 index 00000000..9848e392 --- /dev/null +++ b/docs/api/utilities/process_volumetric_mesh.rst @@ -0,0 +1,18 @@ +==================================== +Volumetric Mesh Tools +==================================== + +.. currentmodule:: monai_physio + +Tetrahedral meshes filling a mask, their relaxation onto a surface, and the repair of inverted elements. + +Module Reference +================ + +.. automodule:: monai_physio.process_volumetric_mesh + :members: + :undoc-members: + +.. rubric:: Navigation + +:doc:`process_surface_mesh` | :doc:`index` | :doc:`process_mesh_kernels` \ No newline at end of file diff --git a/docs/api/workflows.rst b/docs/api/workflows.rst index d300fd10..f1f2379b 100644 --- a/docs/api/workflows.rst +++ b/docs/api/workflows.rst @@ -95,7 +95,6 @@ Image to VTK import itk from monai_physio import ( - ProcessContours, SegmentChestTotalSegmentatorWithContrast, WorkflowConvertImageToVTK, ) @@ -109,7 +108,7 @@ Image to VTK anatomy_groups=["heart", "major_vessels"], ) - ProcessContours.save_combined_surfaces( + WorkflowConvertImageToVTK.save_combined_surfaces( result["surfaces"], "./output/patient01_surfaces.vtp", ) diff --git a/docs/architecture.rst b/docs/architecture.rst index 8a1c2038..65f6bdd2 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -31,7 +31,7 @@ Data Flow SegmentNVSegmentCT (CT only) | v - ProcessContours + ProcessTransforms + ProcessSurfaceMesh + ProcessVolumetricMesh + ProcessTransforms | v WorkflowConvertImageToVTK / ConvertVTKToUSD / WorkflowConvertVTKToUSD diff --git a/docs/cli_scripts/byod_tutorials.rst b/docs/cli_scripts/byod_tutorials.rst index c2d31160..969b7f49 100644 --- a/docs/cli_scripts/byod_tutorials.rst +++ b/docs/cli_scripts/byod_tutorials.rst @@ -18,7 +18,7 @@ or the CPU-only variant: .. code-block:: bash # Recommended - CUDA 12.6, everything installs from prebuilt wheels - uv pip install "monai-physio[cuda12]" + uv pip install "monai-physio[cuda12]" -f https://data.pyg.org/whl/torch-2.12.0+cu126.html # CUDA 13 - same, but torch-scatter compiles from source uv pip install "monai-physio[cuda13]" diff --git a/docs/conf.py b/docs/conf.py index 63b0e953..b1a96f14 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -184,7 +184,6 @@ def __getattr__(cls, name): autodoc_mock_imports = [ "torch", "torchvision", - "torchaudio", "monai", "totalsegmentator", "icon_registration", diff --git a/docs/developer/architecture.rst b/docs/developer/architecture.rst index 5fffc863..4863aaed 100644 --- a/docs/developer/architecture.rst +++ b/docs/developer/architecture.rst @@ -24,7 +24,7 @@ Architecture Diagram +--------------+-------------+-------------+ | v - ProcessContours / ProcessTransforms + ProcessSurfaceMesh / ProcessVolumetricMesh / ProcessTransforms | v ConvertVTKToUSD / vtk_to_usd diff --git a/docs/developer/core.rst b/docs/developer/core.rst index fe8227b4..b42f9acf 100644 --- a/docs/developer/core.rst +++ b/docs/developer/core.rst @@ -35,7 +35,9 @@ Class Boundaries * Registration classes estimate transforms. * Segmentation classes return ITK images or dictionaries of ITK masks. * ``ProcessTransforms`` applies transforms to images and PyVista contours. -* ``ProcessContours`` creates and transforms VTK/PyVista surface data. +* ``ProcessSurfaceMesh`` creates and transforms VTK/PyVista surface data; + ``ProcessVolumetricMesh`` builds and relaxes tetrahedral meshes; both run + their GPU work through ``ProcessMeshKernels``. * ``ProcessUSD`` and ``ProcessUSDAnatomy`` operate on USD stages and files. Public APIs should be documented in ``docs/api``. diff --git a/docs/developer/migration_next.md b/docs/developer/migration_next.md index 5a0b5fdf..3a9bcdb7 100644 --- a/docs/developer/migration_next.md +++ b/docs/developer/migration_next.md @@ -69,3 +69,224 @@ method = InferPhysicsNeMoMGN(model_tag="physics_informed_motion", log_level="DEB **Automated conversion:** `None needed`. Pass `log_level` by keyword and replace class-level `model_tag` overrides with the constructor argument. + +## `ProcessContours` - split into `ProcessSurfaceMesh` and `ProcessVolumetricMesh` + +**Change:** `monai_physio.process_contours.ProcessContours` no longer exists. +- Its tetrahedral methods moved to + `monai_physio.process_volumetric_mesh.ProcessVolumetricMesh`: + - `extract_tetrahedra` + - `trim_tetrahedra_to_surface` + - `repair_inverted_tetrahedra` +- `apply_anatomy_color` moved to `ProcessUSDAnatomy`, which holds the color + tables, next to the new `select_anatomy_type`. +- The savers `save_surfaces` and `save_combined_surfaces` moved to + `WorkflowConvertImageToVTK`, whose results they write. The in-memory merge + behind `save_combined_surfaces` is the new + `ProcessSurfaceMesh.combine_surfaces`. +- Every other method moved to + `monai_physio.process_surface_mesh.ProcessSurfaceMesh`; the surface + extractors changed too (see the next entry). +- The GPU primitives behind both are the new public class + `monai_physio.process_mesh_kernels.ProcessMeshKernels`. It provides closest + points, inside/outside tests, tetrahedron quality, distance maps, the blur + and the label-vote resample. +- All three classes are exported from `monai_physio`. +- Apart from the extractors, method names, signatures and results are + unchanged. + +**Why:** Tutorial 4's GPU work grew `process_contours.py` past 2100 lines, +mixing surface code, tetrahedral code and roughly 500 lines of warp/torch +kernels. Splitting them gives each class one kind of mesh. It also gives the +GPU primitives a documented home that tutorials and tests can call, instead +of importing private module functions. Process classes now work in memory +only, and anatomy colors come from one place, so a surface and a volume mesh +of the same structure always get the same color. + +**Before** + +```python +from monai_physio import ProcessContours + +tools = ProcessContours() +surface = tools.extract_label_surfaces(mask)[1] +tools.apply_anatomy_color(surface, ["myocardium", "heart"]) +mesh = tools.extract_tetrahedra(mask, element_size_mm=1.5) +mesh = tools.trim_tetrahedra_to_surface(mesh, surface) +ProcessContours.save_combined_surfaces(surfaces, "out.vtp") +``` + +**After** + +```python +from monai_physio import ( + ProcessSurfaceMesh, + ProcessUSDAnatomy, + ProcessVolumetricMesh, + WorkflowConvertImageToVTK, +) + +surface_tools = ProcessSurfaceMesh() +volume_tools = ProcessVolumetricMesh() +surface = surface_tools.extract_label_surfaces(mask)[1] +ProcessUSDAnatomy(stage=None).apply_anatomy_color(surface, ["myocardium", "heart"]) +mesh = volume_tools.extract_tetrahedra(mask, element_size_mm=1.5) +mesh = volume_tools.trim_tetrahedra_to_surface(mesh, surface) +WorkflowConvertImageToVTK.save_combined_surfaces(surfaces, "out.vtp") +``` + +**Automated conversion:** +`py utils/migrate_process_contours.py [--dry-run] PATH [PATH ...]` + +It rewrites imports and the class name, and moves calls on a surface-tools +object to `ProcessVolumetricMesh().(...)`, +`ProcessUSDAnatomy(stage=None).apply_anatomy_color(...)` and +`WorkflowConvertImageToVTK.save_*(...)`, adding the imports. It prints every +change and flags any file where an import must be added by hand. Hoist the +per-call `ProcessVolumetricMesh()` and `ProcessUSDAnatomy(stage=None)` into one +instance where they run in a loop. + +## `ProcessSurfaceMesh` extractors - one conforming, watertight extractor + +**Change:** +- `extract_contours` and `extract_watertight_surface` are removed. + `extract_label_surfaces` is the one surface extractor: it returns every + label's closed, outward surface, and neighboring labels share their walls + vertex for vertex, including where three labels meet. +- `extract_label_surfaces` gains two trailing parameters: + - `label_ids` extracts a subset and treats every other label as background; + - `surface_reduction_rate` remeshes each surface with ACVD, after which + neighboring surfaces no longer share their vertices. Lower the triangle + count with a coarser `isotropic_spacing_mm` to keep them matching. +- Each surface it returns carries `field_data["SegmentationLabelIds"]`. +- Its memory no longer grows with the number of labels. +- Nothing produces or reads the per-cell `boundary_labels` array any more: + `ConvertVTKToUSD`, `WorkflowConvertVTKToUSD` and + `ProcessSurfaceMesh.remesh_and_smooth_surface` use per-cell + `SegmentationLabelIds`, which `ProcessSurfaceMesh.combine_surfaces` writes. +- `ProcessMeshKernels.resample_labelmap_isotropic` is removed, as only + `extract_contours` used it; `vote_resample_labelmap` is the GPU equivalent. + +**Why:** the three extractors gave three different surfaces for the same +labelmap: `extract_contours` was not watertight and could pinch, and +`extract_watertight_surface` traced each label on its own, so neighbors +overlapped or left gaps. One extractor gives every workflow, tutorial and USD +export the same geometry. + +**Before** + +```python +contours = tools.extract_contours(labelmap, surface_reduction_rate=0.5) +surface = tools.extract_watertight_surface(mask, anatomy_names=["lung"]) +``` + +**After** + +```python +surfaces = tools.extract_label_surfaces(labelmap, surface_reduction_rate=0.5) +contours = ProcessSurfaceMesh.combine_surfaces(surfaces) + +surface = tools.extract_label_surfaces(mask)[1] +ProcessUSDAnatomy(stage=None).apply_anatomy_color(surface, ["lung"]) +``` + +**Automated conversion:** `py utils/migrate_process_contours.py --dry-run PATH` +flags each line that uses a removed extractor or `boundary_labels`; the +replacement depends on whether the caller wants one multi-label mesh +(`combine_surfaces`) or one mask's surface (`[1]`). + +## `WorkflowConvertImageToVTK.process` - labelmap input, shared walls, ACVD reduction + +**Change:** +- Surfaces come from `ProcessSurfaceMesh.extract_label_surfaces`, so they are + watertight and neighbors share their walls; the group surfaces are extracted + together, and so are the per-label ones. +- `surface_reduction_rate` remeshes with ACVD instead of `decimate_pro`. +- A label surface is colored by its own name before its group's, so + `myocardium` gets its own color rather than the heart's, and a name nothing + matches falls back to the `other` color instead of grey. +- The `segmentation_masks` key is removed from the result. +- New keyword-only options: `labelmap_image` (skip segmentation), + `extract_group_surfaces`, `groups` (custom unions of labels), + `isotropic_spacing_mm`, `smoothing_iterations` and + `tetrahedra_element_size_mm`, which adds `tetrahedra` and + `label_tetrahedra` to the result. + +**Why:** tutorials built labelmap-to-surface and labelmap-to-mesh pipelines by +hand, each slightly differently. One workflow that also accepts a labelmap +gives every tutorial the same surfaces, colors and annotations. + +**Before** + +```python +result = workflow.process(ct, surface_reduction_rate=0.5) +heart_mask = result["segmentation_masks"]["heart"] +``` + +**After** + +```python +result = workflow.process(ct, surface_reduction_rate=0.5) +heart_mask = segmenter.create_anatomy_group_labelmaps(result["labelmap"])["heart"] +``` + +**Automated conversion:** None for the geometry, which changes, so regenerate +saved outputs. `py utils/migrate_process_contours.py --dry-run PATH` flags +uses of `segmentation_masks`. + +## `ProcessUSD` stage methods - take an open stage, never a path + +**Change:** `list_mesh_primvars`, `apply_colormap_from_primvar`, +`set_solid_display_color`, `list_mesh_paths_under` and +`repair_mesh_primvar_element_sizes` take a `Usd.Stage` only, and none of them +saves it; `repair_mesh_primvar_element_sizes` loses its `save` parameter. The +new `ProcessUSD.open_stage(path)` and `ProcessUSD.save_stage(stage)` open and +write a stage. + +**Why:** given a path, each call opened the file and the editing ones saved +it, so a loop over meshes reopened and rewrote the whole USD file once per +mesh. Opening once and saving once is faster and makes the write explicit. + +**Before** + +```python +for mesh_path in mesh_paths: + usd_tools.set_solid_display_color("scene.usd", mesh_path, (1.0, 0.0, 0.0)) +``` + +**After** + +```python +stage = usd_tools.open_stage("scene.usd") +for mesh_path in mesh_paths: + usd_tools.set_solid_display_color(stage, mesh_path, (1.0, 0.0, 0.0)) +usd_tools.save_stage(stage) +``` + +**Automated conversion:** `py utils/migrate_process_contours.py --dry-run PATH` +flags calls that pass a path string or `str(...)`. + +## `EvaluateMovementLung` - scores NVSegmentCTMRI lobes + +**Change:** `EvaluateMovementLung.segmenter_class` is `SegmentNVSegmentCTMRI` +instead of `SegmentChestTotalSegmentator`, and `label_ids` are its lobe ids +`(28, 29, 30, 31, 32)` instead of `(10, 11, 12, 13, 14)`. + +**Why:** the lung tutorials now segment with one segmenter, +`SegmentNVSegmentCTMRI`, so the lobes a shape model is built from and the +lobes its predictions are scored against carry the same ids. + +**Before** + +```python +EvaluateMovementLung().label_ids # (10, 11, 12, 13, 14) +``` + +**After** + +```python +EvaluateMovementLung().label_ids # (28, 29, 30, 31, 32) +``` + +**Automated conversion:** None needed. Delete cached ground-truth labelmaps +made with TotalSegmentator so they are re-segmented. diff --git a/docs/developer/usd_generation.rst b/docs/developer/usd_generation.rst index 9f308458..8b3a05bf 100644 --- a/docs/developer/usd_generation.rst +++ b/docs/developer/usd_generation.rst @@ -63,8 +63,8 @@ In-Memory Meshes Labeled Meshes with Anatomy Grouping ------------------------------------ -When the input has anatomical labels (``mask_ids`` and a ``boundary_labels`` -cell array), pass a :class:`monai_physio.SegmentAnatomyBase` instance +When the input has anatomical labels (``mask_ids`` and a +``SegmentationLabelIds`` cell array), pass a :class:`monai_physio.SegmentAnatomyBase` instance through the ``segmenter`` argument so labeled prims are grouped by anatomy type. The output layout becomes: diff --git a/docs/developer/utilities.rst b/docs/developer/utilities.rst index 0941dfa1..34340ee4 100644 --- a/docs/developer/utilities.rst +++ b/docs/developer/utilities.rst @@ -43,11 +43,11 @@ Contour Tools import itk - from monai_physio import ProcessContours + from monai_physio import ProcessSurfaceMesh mask = itk.imread("heart_mask.nrrd") - contour = ProcessContours().extract_contours(mask) - contour.save("heart_surface.vtp") + surface = ProcessSurfaceMesh().extract_label_surfaces(mask)[1] + surface.save("heart_surface.vtp") USD Tools ========= diff --git a/docs/faq.rst b/docs/faq.rst index 5a7ab2bf..bd01c615 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -50,7 +50,7 @@ default in all standard Python runs): NumPy and run significantly slower. Every workflow still runs. Re-install with uv to get CuPy and CUDA-enabled PyTorch in one step (pip alone will not select the correct CUDA wheel): - uv pip install 'monai-physio[cuda12]' # CUDA 12.6 + uv pip install 'monai-physio[cuda12]' -f https://data.pyg.org/whl/torch-2.12.0+cu126.html # CUDA 12.6 uv pip install 'monai-physio[cuda13]' # CUDA 13 CPU-only mode is suitable for evaluation and small datasets. For production @@ -61,14 +61,15 @@ Which CUDA version is required? Both CUDA 13 and CUDA 12.6 are supported. CUDA 12.6 is the smoothest install, because it is the newest combination with prebuilt ``torch-scatter`` -wheels on every supported platform - nothing compiles: +wheels on every supported platform - nothing compiles when the PyG wheel index +is passed with ``-f``: .. code-block:: bash - uv pip install "monai-physio[cuda12]" # CUDA 12.6 + uv pip install "monai-physio[cuda12]" -f https://data.pyg.org/whl/torch-2.12.0+cu126.html uv pip install "monai-physio[cuda13]" # CUDA 13, builds torch-scatter -Each extra installs CuPy and pins PyTorch, torchvision, and torchaudio to the +Each extra installs CuPy and pins PyTorch and torchvision to the matching wheel index (``https://download.pytorch.org/whl/cu126`` or ``cu130``). On CUDA 13 there is no prebuilt ``torch-scatter`` wheel, so it compiles from source and needs a CUDA toolkit plus a C++ toolchain; see diff --git a/docs/installation.rst b/docs/installation.rst index 4904a590..85840890 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -42,8 +42,21 @@ Use ``uv``. It selects the CUDA wheel and applies this project's .. code-block:: bash - uv pip install "monai-physio[cuda12]" # runtime - uv pip install -e ".[dev_cuda12]" # plus dev/test/docs tooling + uv pip install "monai-physio[cuda12]" \ + -f https://data.pyg.org/whl/torch-2.12.0+cu126.html # runtime + uv pip install -e ".[dev_cuda12]" \ + -f https://data.pyg.org/whl/torch-2.12.0+cu126.html # plus dev tooling + +The ``-f`` (find-links) option points uv at the PyG wheel index, which is +where the prebuilt ``torch-scatter`` lives. Without it uv falls back to a +source build, which fails whenever the CUDA toolkit on ``PATH`` differs from +the one torch was built for (``The detected CUDA version ... mismatches the +version that was used to compile PyTorch``). To install just that package into +an existing environment: + +.. code-block:: bash + + uv pip install torch-scatter -f https://data.pyg.org/whl/torch-2.12.0+cu126.html **CUDA 13 - same, plus a** ``torch-scatter`` **source build:** @@ -52,6 +65,11 @@ Use ``uv``. It selects the CUDA wheel and applies this project's uv pip install "monai-physio[cuda13]" uv pip install -e ".[dev_cuda13]" +PyG publishes no CUDA 13.1 wheel index. Its CUDA 13.0 index +(``https://data.pyg.org/whl/torch-2.12.0+cu130.html``) has Linux wheels only, +so on Linux you can add ``-f`` with that URL to skip the source build. On +Windows, or for any other torch version, the source build below applies. + That build needs a CUDA toolkit with ``nvcc`` on ``PATH`` matching the installed torch, and a C++ toolchain (MSVC Build Tools on Windows, gcc/g++ on Linux). See :ref:`torch-scatter-wheels`. @@ -101,7 +119,7 @@ the compiled CUDA extension behind PhysicsNeMo's MeshGraphNet. Its wheels * - Install - Linux - Windows - * - ``[cuda12]`` (CUDA 12.6, torch < 2.13) + * - ``[cuda12]`` (CUDA 12.6, torch < 2.13), with ``-f`` - Prebuilt wheel - Prebuilt wheel * - ``[cuda13]`` (CUDA 13) @@ -113,7 +131,9 @@ the compiled CUDA extension behind PhysicsNeMo's MeshGraphNet. Its wheels No wheel covers torch 2.13 or newer on any platform, and the CUDA 13.0 wheels are Linux-only. ``[cuda12]`` pins torch below 2.13 to stay inside the -prebuilt-wheel range, which is why it is the recommended install. +prebuilt-wheel range, which is why it is the recommended install. PyPI hosts +only a source distribution of ``torch-scatter``, so the wheels are found only +when ``-f https://data.pyg.org/whl/torch-2.12.0+cu126.html`` is passed. Installing with pip -------------------- @@ -123,8 +143,8 @@ PyTorch first, then the package: .. code-block:: bash - pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu126 - pip install "monai-physio[cuda12]" + pip install torch torchvision --index-url https://download.pytorch.org/whl/cu126 + pip install "monai-physio[cuda12]" -f https://data.pyg.org/whl/torch-2.12.0+cu126.html See https://pytorch.org/get-started/locally/ for the PyTorch selector. Importing ``monai_physio`` from a pip install emits a ``UserWarning`` @@ -136,7 +156,7 @@ the whole install, where ``uv`` scopes it to the one package: .. code-block:: bash - pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu130 + pip install torch torchvision --index-url https://download.pytorch.org/whl/cu130 pip install setuptools pip install "monai-physio[cuda13]" --no-build-isolation @@ -263,8 +283,8 @@ with pip: PyTorch with CUDA ----------------- -The ``[cuda12]`` and ``[cuda13]`` extras pin PyTorch, torchvision, and -torchaudio to the ``cu126`` and ``cu130`` wheel indexes respectively. To +The ``[cuda12]`` and ``[cuda13]`` extras pin PyTorch and torchvision to the +``cu126`` and ``cu130`` wheel indexes respectively. To verify the active build: .. code-block:: python diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 6b9c31f6..6abc6c32 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -9,15 +9,25 @@ Prerequisites Before starting, ensure you have: -* MONAI Physio installed (see :doc:`installation`) * NVIDIA GPU with CUDA 12.6 or CUDA 13 - recommended for production - performance; see :doc:`installation` for the ``[cuda12]`` extra - (recommended, installs entirely from prebuilt wheels), ``[cuda13]``, or - ``--torch-backend=auto``. A CPU-only PyPI install works for evaluation but - is significantly slower. + performance. A CPU-only install works for evaluation but is significantly + slower. * Disk space for the sample datasets (~10-20 GB for the full set; each dataset README lists its own size) +Install +======= + +.. code-block:: bash + + uv pip install "monai-physio[cuda12]" -f https://data.pyg.org/whl/torch-2.12.0+cu126.html + +This installs entirely from prebuilt wheels. The ``-f`` option points uv at +the PyG wheel index that hosts ``torch-scatter``; without it, uv falls back to +a ``torch-scatter`` source build, which needs a matching CUDA toolkit and a +C++ toolchain. See :doc:`installation` for CUDA 13, ``--torch-backend=auto``, +pip, source installs, and CPU-only. + .. _tutorial_scripts: Get the Tutorial Scripts diff --git a/docs/testing.rst b/docs/testing.rst index dee3b4c7..a1afd4d4 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -32,7 +32,8 @@ Flags compose. A typical local GPU profile is: pytest tests/ -v --run-gpu --run-slow ``--run-all`` is a convenience flag that turns on every ``--run-*`` bucket at -once. The self-hosted CI GPU runner uses it (after installing +once and implies ``--require-tutorial-data``, so a missing dataset fails the +run instead of skipping its tests. The self-hosted CI GPU runner uses it (after installing ``.[dev,cuda13]``): .. code-block:: bash diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 0fbbe3f4..6619334b 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -83,7 +83,7 @@ version than the one present on the system. .. code-block:: bash - uv pip install "monai-physio[cuda12]" # CUDA 12.6, nothing compiles + uv pip install "monai-physio[cuda12]" -f https://data.pyg.org/whl/torch-2.12.0+cu126.html # CUDA 12.6, nothing compiles uv pip install "monai-physio[cuda13]" # CUDA 13, builds torch-scatter Each extra installs CuPy and pins PyTorch to the matching wheel index. diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 5587f2bd..bf0afc02 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -252,7 +252,7 @@ Workflow :class:`~monai_physio.WorkflowFitStatisticalModelToPatient` and :class:`~monai_physio.WorkflowInferMovement` (``process_time_series``) driving a pretrained :class:`~monai_physio.WorkflowInferPhysicsNeMo` - MeshGraphNet, with :class:`~monai_physio.SegmentChestTotalSegmentator`. + MeshGraphNet, with :class:`~monai_physio.SegmentNVSegmentCTMRI`. Dataset Chest-CT (auto-download): a single ungated chest CT, unlike the gated @@ -313,7 +313,7 @@ Adapt to your data directory also carries a matching ``pca_model.json`` and ``pca_mean_surface.vtp`` - to demo a different cohort. Demoing another anatomy needs more than swapping ``model_dir``: the segmenter - (``SegmentChestTotalSegmentator``) and ``anatomy_type="lung"`` passed to + (``SegmentNVSegmentCTMRI``) and ``anatomy_type="lung"`` passed to ``process_time_series`` are hardcoded to lung and must change too. This is a standalone shortcut, not step one of the numbered series: start at Tutorial 1 for the full pipeline. @@ -578,9 +578,9 @@ Script Duke-Heart-4DLabelmaps (see `Before You Start`_). Workflow - :class:`~monai_physio.WorkflowConvertImageToVTK` with - :class:`~monai_physio.SegmentChestTotalSegmentatorWithContrast` (heart) or - :class:`~monai_physio.SegmentChestTotalSegmentator` (lung). + :class:`~monai_physio.WorkflowConvertImageToVTK`, with each dataset's + segmenter from its parameters module. The ``duke_heart`` variant passes + its labelmaps through ``labelmap_image`` instead of segmenting a CT. Dataset One frame of Slicer-Heart-CT or TCIA-4DLung - a single static volume is enough. @@ -613,12 +613,15 @@ Inner API usage .. code-block:: python workflow = WorkflowConvertImageToVTK( - segmentation_method=segmentation_method, + segmentation_method=HEART_CT_KCL.segmenter(test_mode), ) result = workflow.process( input_image=ct_image, surface_reduction_rate=HEART_CT_KCL.surface_reduction_rate, - extract_label_surfaces=save_label_surfaces, + extract_label_surfaces=True, + ) + WorkflowConvertImageToVTK.save_combined_surfaces( + result["label_surfaces"], "patient_surfaces.vtp" ) Run @@ -1119,7 +1122,7 @@ Script Workflow :class:`~monai_physio.WorkflowEvaluateMovement`, driving :class:`~monai_physio.WorkflowInferMovement` and, for the lung variant, - :class:`~monai_physio.SegmentChestTotalSegmentator`. + :class:`~monai_physio.SegmentNVSegmentCTMRI`. Dataset The gated sequence itself - TCIA-4DLung for the lung, Duke-Heart-4DLabelmaps @@ -1206,7 +1209,7 @@ Script Workflow :class:`~monai_physio.WorkflowConvertImageToVTK` (lung) or - :class:`~monai_physio.ProcessContours` (heart), + :class:`~monai_physio.ProcessSurfaceMesh` (heart), :class:`~monai_physio.WorkflowFitStatisticalModelToPatient`, then :meth:`~monai_physio.WorkflowInferMovement.process_time_series`. @@ -1534,8 +1537,8 @@ Script Workflow :class:`~monai_physio.WorkflowCreateStatisticalModel` with ``solve_for_surface_pca=False`` on a tetrahedral template built by - :meth:`~monai_physio.ProcessContours.extract_tetrahedra` and - :meth:`~monai_physio.ProcessContours.trim_tetrahedra_to_surface`, then + :meth:`~monai_physio.ProcessVolumetricMesh.extract_tetrahedra` and + :meth:`~monai_physio.ProcessVolumetricMesh.trim_tetrahedra_to_surface`, then :class:`~monai_physio.WorkflowFitStatisticalModelToPatient` per case and gated frame. diff --git a/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py b/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py index 1d2f013b..829a5ecf 100644 --- a/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py +++ b/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py @@ -157,7 +157,7 @@ vessel_path = "/World/AlterraValve/Mesh" # Select primvar for coloring -primvars = usd_tools.list_mesh_primvars(str(output_usd), vessel_path) +primvars = usd_tools.list_mesh_primvars(stage, vessel_path) color_primvar = usd_tools.pick_color_primvar( primvars, keywords=tuple(colormap_primvar_substrs) ) @@ -166,7 +166,7 @@ if color_primvar: print(f"\nApplying colormap to '{color_primvar}' using {colormap_name}") usd_tools.apply_colormap_from_primvar( - str(output_usd), + stage, vessel_path, color_primvar, cmap=colormap_name, @@ -174,3 +174,4 @@ use_sigmoid_scale=True, bind_vertex_color_material=True, ) + usd_tools.save_stage(stage) diff --git a/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py b/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py index 93688585..3bfc990e 100644 --- a/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py +++ b/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py @@ -156,7 +156,7 @@ vessel_path = "/World/TPV25Valve/Mesh" # Select primvar for coloring -primvars = usd_tools.list_mesh_primvars(str(output_usd), vessel_path) +primvars = usd_tools.list_mesh_primvars(stage, vessel_path) color_primvar = usd_tools.pick_color_primvar( primvars, keywords=tuple(colormap_primvar_substrs) ) @@ -165,7 +165,7 @@ if color_primvar: print(f"\nApplying colormap to '{color_primvar}' using {colormap_name}") usd_tools.apply_colormap_from_primvar( - str(output_usd), + stage, vessel_path, color_primvar, cmap=colormap_name, @@ -173,3 +173,4 @@ use_sigmoid_scale=True, bind_vertex_color_material=True, ) + usd_tools.save_stage(stage) diff --git a/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py b/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py index 2e1faa8d..58aa6817 100644 --- a/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py +++ b/experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py @@ -29,7 +29,7 @@ import pyvista as pv from pxr import Usd, UsdGeom, UsdShade -from monai_physio import ConvertVTKToUSD, ProcessContours +from monai_physio import ConvertVTKToUSD, ProcessSurfaceMesh # Configure logging logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") @@ -57,7 +57,7 @@ vtp_file = output_dir / "average_surface.vtp" if not vtp_file.exists(): vtk_mesh = pv.read(vtk_file) - contour_tools = ProcessContours() + contour_tools = ProcessSurfaceMesh() vtp_surface = vtk_mesh.extract_surface(algorithm="dataset_surface") vtp_surface.save(vtp_file) print(f" VTP: {vtp_file.exists()} - {vtp_file}") diff --git a/experiments/Heart-Create_Statistical_Model/2-input_surfaces_to_surfaces_aligned.py b/experiments/Heart-Create_Statistical_Model/2-input_surfaces_to_surfaces_aligned.py index 2bb0c9a1..0b7c3934 100644 --- a/experiments/Heart-Create_Statistical_Model/2-input_surfaces_to_surfaces_aligned.py +++ b/experiments/Heart-Create_Statistical_Model/2-input_surfaces_to_surfaces_aligned.py @@ -20,7 +20,7 @@ import pandas as pd import pyvista as pv -from monai_physio.process_contours import ProcessContours +from monai_physio.process_surface_mesh import ProcessSurfaceMesh from monai_physio.register_models_icp import RegisterModelsICP _HERE = Path(__file__).parent @@ -73,7 +73,7 @@ transforms_point_forward = {} # Moving to Fixed point transforms (moving_to_fixed_transform) transforms_point_inverse = {} # Fixed to Moving point transforms (fixed_to_moving_transform) -contour_tools = ProcessContours() +contour_tools = ProcessSurfaceMesh() # Process each mesh for mesh_file in mesh_files: diff --git a/experiments/Heart-Create_Statistical_Model/3-registration_based_correspondence.py b/experiments/Heart-Create_Statistical_Model/3-registration_based_correspondence.py index afa04001..7239d841 100644 --- a/experiments/Heart-Create_Statistical_Model/3-registration_based_correspondence.py +++ b/experiments/Heart-Create_Statistical_Model/3-registration_based_correspondence.py @@ -25,13 +25,13 @@ import pandas as pd import pyvista as pv -from monai_physio.process_contours import ProcessContours +from monai_physio.process_surface_mesh import ProcessSurfaceMesh from monai_physio.register_models_distance_maps import RegisterModelsDistanceMaps _HERE = Path(__file__).parent -# Initialize ProcessContours -contour_tools = ProcessContours() +# Initialize ProcessSurfaceMesh +contour_tools = ProcessSurfaceMesh() # Setup paths input_dir = _HERE / "kcl-heart-model/surfaces_aligned" diff --git a/experiments/Heart-Create_Statistical_Model/4-surfaces_aligned_correspond_to_pca_inputs.py b/experiments/Heart-Create_Statistical_Model/4-surfaces_aligned_correspond_to_pca_inputs.py index 0d529387..f8201f85 100644 --- a/experiments/Heart-Create_Statistical_Model/4-surfaces_aligned_correspond_to_pca_inputs.py +++ b/experiments/Heart-Create_Statistical_Model/4-surfaces_aligned_correspond_to_pca_inputs.py @@ -5,7 +5,7 @@ import numpy as np import pyvista as pv -from monai_physio.process_contours import ProcessContours +from monai_physio.process_surface_mesh import ProcessSurfaceMesh _HERE = Path(__file__).parent @@ -18,7 +18,7 @@ # Create output directory output_dir.mkdir(exist_ok=True) -contour_tools = ProcessContours() +contour_tools = ProcessSurfaceMesh() template_mesh = pv.read(_HERE / "kcl-heart-model/average_surface.vtp") diff --git a/experiments/Heart-GatedCT_To_USD/2-generate_segmentation.py b/experiments/Heart-GatedCT_To_USD/2-generate_segmentation.py index 0cd6cb6a..9f51beef 100644 --- a/experiments/Heart-GatedCT_To_USD/2-generate_segmentation.py +++ b/experiments/Heart-GatedCT_To_USD/2-generate_segmentation.py @@ -6,7 +6,7 @@ import numpy as np import pyvista as pv -from monai_physio.process_contours import ProcessContours +from monai_physio.process_surface_mesh import ProcessSurfaceMesh from monai_physio.segment_chest_total_segmentator_with_contrast import ( SegmentChestTotalSegmentatorWithContrast, ) @@ -91,8 +91,8 @@ contrast_mask = result["contrast"] # %% - con = ProcessContours() - all_contours = con.extract_contours(labelmap_image) + con = ProcessSurfaceMesh() + all_contours = con.combine_surfaces(con.extract_label_surfaces(labelmap_image)) all_contours.save(os.path.join(output_dir, f"{outname}.all_mask.vtp")) # %% @@ -117,7 +117,7 @@ compression=True, ) - contours = con.extract_contours(dynamic_anatomy_image) + contours = con.combine_surfaces(con.extract_label_surfaces(dynamic_anatomy_image)) contours.save(os.path.join(output_dir, f"{outname}.dynamic_anatomy_mask.vtp")) # %% @@ -131,7 +131,7 @@ compression=True, ) - contours = con.extract_contours(static_anatomy_image) + contours = con.combine_surfaces(con.extract_label_surfaces(static_anatomy_image)) contours.save(os.path.join(output_dir, f"{outname}.static_anatomy_mask.vtp")) # %% diff --git a/experiments/Heart-GatedCT_To_USD/3-transform_dynamic_and_static_contours.py b/experiments/Heart-GatedCT_To_USD/3-transform_dynamic_and_static_contours.py index f4b9ea09..70f760c4 100644 --- a/experiments/Heart-GatedCT_To_USD/3-transform_dynamic_and_static_contours.py +++ b/experiments/Heart-GatedCT_To_USD/3-transform_dynamic_and_static_contours.py @@ -5,7 +5,7 @@ import pyvista as pv from monai_physio import ConvertVTKToUSD -from monai_physio.process_contours import ProcessContours +from monai_physio.process_surface_mesh import ProcessSurfaceMesh from monai_physio.process_usd_anatomy import ProcessUSDAnatomy from monai_physio.segment_chest_total_segmentator import SegmentChestTotalSegmentator @@ -34,7 +34,7 @@ def transform_contours( contours, transform_filenames, frame_indices, base_name, output_dir ): - con = ProcessContours() + con = ProcessSurfaceMesh() for i, transform_filename in zip(frame_indices, transform_filenames): fixed_to_moving_transform = itk.transformread(transform_filename)[0] print(f"Applying transform {transform_filename} to {base_name}") diff --git a/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_icp_itk.py b/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_icp_itk.py index d660148d..595e4892 100644 --- a/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_icp_itk.py +++ b/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_icp_itk.py @@ -24,7 +24,7 @@ # Import from MONAI Physio package from monai_physio import ( - ProcessContours, + ProcessSurfaceMesh, ProcessTransforms, RegisterModelsICPITK, ) @@ -125,8 +125,10 @@ # ## Convert Segmentation Mask to a Surface # %% -contour_tools = ProcessContours() -patient_heart_surface = contour_tools.extract_contours(patient_heart_mask) +contour_tools = ProcessSurfaceMesh() +patient_heart_surface = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(patient_heart_mask) +) # %% [markdown] # ## Perform Initial ICP Rigid Registration diff --git a/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_registration_pca.py b/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_registration_pca.py index 9ae0592e..58bd397f 100644 --- a/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_registration_pca.py +++ b/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_model_registration_pca.py @@ -26,7 +26,7 @@ # Import from MONAI Physio package from monai_physio import ( - ProcessContours, + ProcessSurfaceMesh, ProcessTransforms, RegisterModelsICP, RegisterModelsPCA, @@ -136,8 +136,10 @@ # ## Convert Segmentation Mask to a Surface # %% -contour_tools = ProcessContours() -patient_surface = contour_tools.extract_contours(patient_heart_mask_image) +contour_tools = ProcessSurfaceMesh() +patient_surface = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(patient_heart_mask_image) +) # %% [markdown] # ## Perform Initial ICP Affine Registration diff --git a/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_patient.py b/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_patient.py index a78ad8a9..717152bd 100644 --- a/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_patient.py +++ b/experiments/Heart-Statistical_Model_To_Patient/heart_model_to_patient.py @@ -13,7 +13,7 @@ # Import from MONAI Physio package from monai_physio import ( - ProcessContours, + ProcessSurfaceMesh, SegmentChestTotalSegmentator, WorkflowFitStatisticalModelToPatient, ) @@ -117,7 +117,9 @@ ) # %% - patient_model = ProcessContours().extract_contours(patient_mask) + patient_model = ProcessSurfaceMesh.combine_surfaces( + ProcessSurfaceMesh().extract_label_surfaces(patient_mask) + ) patient_model.save(str(output_dir / "patient_mesh.vtp")) patient_model = pv.read(str(output_dir / "patient_mesh.vtp")) diff --git a/experiments/Heart-VTKSeries_To_USD/1-heart_vtkseries_to_usd.py b/experiments/Heart-VTKSeries_To_USD/1-heart_vtkseries_to_usd.py index 0e4e4d2f..d82541cd 100644 --- a/experiments/Heart-VTKSeries_To_USD/1-heart_vtkseries_to_usd.py +++ b/experiments/Heart-VTKSeries_To_USD/1-heart_vtkseries_to_usd.py @@ -19,20 +19,20 @@ # Segment chest from CT images to generate vtk files import itk - from monai_physio.process_contours import ProcessContours + from monai_physio.process_surface_mesh import ProcessSurfaceMesh from monai_physio.segment_chest_total_segmentator_with_contrast import ( SegmentChestTotalSegmentatorWithContrast, ) input_images = sorted(glob.glob(os.path.join(_DATA_DIR, "slice_*.mha"))) seg = SegmentChestTotalSegmentatorWithContrast() - con = ProcessContours() + con = ProcessSurfaceMesh() for i, img_path in enumerate(input_images): print(f"Segmenting {img_path}...") img = itk.imread(img_path) result = seg.segment(img) labelmap_mask = result["labelmap"] - img_con = con.extract_contours(labelmap_mask) + img_con = con.combine_surfaces(con.extract_label_surfaces(labelmap_mask)) img_con.save(os.path.join(_DATA_DIR, f"slice_{i:03d}.vtp")) # %% diff --git a/experiments/Lung-GatedCT_To_USD/1-make_dirlab_models.py b/experiments/Lung-GatedCT_To_USD/1-make_dirlab_models.py index 442f2e45..9c8d3bda 100644 --- a/experiments/Lung-GatedCT_To_USD/1-make_dirlab_models.py +++ b/experiments/Lung-GatedCT_To_USD/1-make_dirlab_models.py @@ -7,7 +7,7 @@ from data_dirlab_4d_ct import DataDirLab4DCT from monai_physio import ConvertVTKToUSD -from monai_physio.process_contours import ProcessContours +from monai_physio.process_surface_mesh import ProcessSurfaceMesh from monai_physio.segment_chest_total_segmentator import SegmentChestTotalSegmentator # Defensive: today this script only reads `seg.all_mask_ids`, but if anyone @@ -29,7 +29,7 @@ def transform_contours_list( """ Transform a list of contours to a list of transformed contours. """ - con_tools = ProcessContours() + con_tools = ProcessSurfaceMesh() new_contours = [] for i in range(10): moving_to_fixed_transform = itk.transformread( @@ -70,7 +70,9 @@ def make_dirlab_models( label_labelmap_image = itk.image_from_array(label_labelmap_arr) label_labelmap_image.CopyInformation(labelmap_image) - contours = con_tools.extract_contours(label_labelmap_image) + contours = con_tools.combine_surfaces( + con_tools.extract_label_surfaces(label_labelmap_image) + ) contours.save( f"{output_dir}/{case_name}_T{base_timepoint}_{label}_lungGatedBase.vtp", binary=True, @@ -96,7 +98,7 @@ def make_dirlab_models( ) # %% - con_tools = ProcessContours() + con_tools = ProcessSurfaceMesh() seg = SegmentChestTotalSegmentator() for case_name in case_names: diff --git a/experiments/Lung-GatedCT_To_USD_NV/1-make_dirlab_models.py b/experiments/Lung-GatedCT_To_USD_NV/1-make_dirlab_models.py index cbc732aa..b031c93a 100644 --- a/experiments/Lung-GatedCT_To_USD_NV/1-make_dirlab_models.py +++ b/experiments/Lung-GatedCT_To_USD_NV/1-make_dirlab_models.py @@ -7,7 +7,7 @@ from data_dirlab_4d_ct import DataDirLab4DCT from monai_physio import ConvertVTKToUSD -from monai_physio.process_contours import ProcessContours +from monai_physio.process_surface_mesh import ProcessSurfaceMesh from monai_physio.segment_nv_segment_ct_mri import SegmentNVSegmentCTMRI # Defensive: today this script only reads `seg.taxonomy`, but if anyone adds a @@ -29,7 +29,7 @@ def transform_contours_list( """ Transform a list of contours to a list of transformed contours. """ - con_tools = ProcessContours() + con_tools = ProcessSurfaceMesh() new_contours = [] for i in range(10): moving_to_fixed_transform = itk.transformread( @@ -70,7 +70,9 @@ def make_dirlab_models( label_labelmap_image = itk.image_from_array(label_labelmap_arr) label_labelmap_image.CopyInformation(labelmap_image) - contours = con_tools.extract_contours(label_labelmap_image) + contours = con_tools.combine_surfaces( + con_tools.extract_label_surfaces(label_labelmap_image) + ) contours.save( f"{output_dir}/{case_name}_T{base_timepoint}_{label}_lungGatedBase.vtp", binary=True, @@ -96,7 +98,7 @@ def make_dirlab_models( ) # %% - con_tools = ProcessContours() + con_tools = ProcessSurfaceMesh() seg = SegmentNVSegmentCTMRI() for case_name in case_names: diff --git a/experiments/Lung-VesselsAirways/0-GenData.py b/experiments/Lung-VesselsAirways/0-GenData.py index 6271c911..2df90ffa 100644 --- a/experiments/Lung-VesselsAirways/0-GenData.py +++ b/experiments/Lung-VesselsAirways/0-GenData.py @@ -6,10 +6,11 @@ import monai import numpy as np import torch -from monai_physio.notebook_utils import running_as_test from scipy.interpolate import UnivariateSpline from torch.utils.data import DataLoader, Dataset +from monai_physio.notebook_utils import running_as_test + # %% # globals patch_size = 16 diff --git a/pyproject.toml b/pyproject.toml index 534c4f7a..c5a78dd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,130 +6,127 @@ build-backend = "setuptools.build_meta" name = "monai-physio" version = "2026.09.1" description = "Methods, workflows, tutorials, and CLI for creating personalized physiological digital twins from 3D/4D medical images" -authors = [ - {name = "Stephen R. Aylward", email = "saylward@nvidia.com"} -] -maintainers = [ - {name = "Stephen R. Aylward", email = "saylward@nvidia.com"} -] -readme = {file = "README.md", content-type = "text/markdown"} -license = {text = "Apache-2.0"} +authors = [{ name = "Stephen R. Aylward", email = "saylward@nvidia.com" }] +maintainers = [{ name = "Stephen R. Aylward", email = "saylward@nvidia.com" }] +readme = { file = "README.md", content-type = "text/markdown" } +license = { text = "Apache-2.0" } # 3.11 is the floor because the base dependency nvidia-physicsnemo needs it, # supporting >=3.11,<3.14. Declaring 3.10 made that dependency unresolvable # there while claiming otherwise. requires-python = ">=3.11" classifiers = [ - "Development Status :: 4 - Beta", - "Intended Audience :: Healthcare Industry", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: Apache Software License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Programming Language :: Python :: 3.13", - "Topic :: Scientific/Engineering :: Medical Science Apps.", - "Topic :: Scientific/Engineering :: Image Processing", - "Topic :: Scientific/Engineering :: Visualization", - "Topic :: Multimedia :: Graphics :: 3D Modeling", + "Development Status :: 4 - Beta", + "Intended Audience :: Healthcare Industry", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Scientific/Engineering :: Medical Science Apps.", + "Topic :: Scientific/Engineering :: Image Processing", + "Topic :: Scientific/Engineering :: Visualization", + "Topic :: Multimedia :: Graphics :: 3D Modeling", ] keywords = [ - "medical-imaging", - "4d-ct", - "cardiac-imaging", - "lung-imaging", - "omniverse", - "usd", - "monai", - "segmentation", - "registration", - "deformable-registration", - "image-registration", - "model-registration", - "visualization", - "ai", - "deep-learning", - "totalsegmentator", - "ICON", - "ANTS", - "physiological-motion", - "4d-visualization", - "digital-twin", - "statistical-shape-model", - "physicsnemo", - "meshgraphnet" + "medical-imaging", + "4d-ct", + "cardiac-imaging", + "lung-imaging", + "omniverse", + "usd", + "monai", + "segmentation", + "registration", + "deformable-registration", + "image-registration", + "model-registration", + "visualization", + "ai", + "deep-learning", + "totalsegmentator", + "ICON", + "ANTS", + "physiological-motion", + "4d-visualization", + "digital-twin", + "statistical-shape-model", + "physicsnemo", + "meshgraphnet", ] dependencies = [ - # Core medical imaging - "itk>=5.3.0,<6.0.0", - "nibabel>=4.0.0", - "numpy>=1.21.0", - "pydicom>=2.4.0", - "pynrrd>=1.0.0", - "vtk>=9.2.0", - - # AI/ML and segmentation - # monai>=1.4 is required for monai.networks.nets.vista3d132 and - # monai.apps.vista3d, used by the NV-Segment-CTMR bundle. - "monai>=1.4.0", - "torch>=2.0.0", - "transformers>=4.21.0", - "totalsegmentator>=2.0.0", - "huggingface_hub>=0.24.0", - "einops>=0.6.1", - - # AI surrogates (PhysicsNeMo MeshGraphNet workflows, Tutorial 9). All - # three are CPU-capable (physicsnemo falls back to PyTorch/CPU; - # torch-geometric/torch-scatter ship CPU wheel builds), so they are base - # dependencies rather than a CUDA-gated extra -- every install, including - # the CPU-only fallback, can run the AI-surrogate workflows, just slower - # without a GPU. - # - # torch-scatter is the one dependency here that can require a source - # build. It compiles against torch but declares no build-system - # dependency, so its build environment must be the project environment: - # torch and setuptools must already be installed before it resolves. See - # [tool.uv] no-build-isolation-package below (uv) or the installation - # guide's explicit pip sequence. - # - # This is self-imposed by physicsnemo, not by PyG: torch_geometric's own - # ``torch_geometric.utils.scatter`` runs on native torch.scatter_reduce_ - # and treats torch_scatter as an optional accelerator, but physicsnemo - # hard-imports the compiled package (physicsnemo/nn/module/gnn_layers/ - # utils.py: ``importlib.import_module("torch_scatter")``). A one-line - # upstream switch to torch_geometric.utils.scatter would remove this - # dependency entirely; when that lands in physicsnemo, this requirement - # and the no-build-isolation ceremony around it can both go away. - # - # Prebuilt wheel availability (https://data.pyg.org/whl/) is the reason - # the cuda12 extra pins torch below 2.13; see that extra's comment. - "nvidia-physicsnemo>=2.0.0", - "torch-geometric>=2.5.0", - "torch-scatter>=2.1.0", - - # Registration - # 0.6.1 is the first release with a CPython 3.13 Windows wheel. Below it the - # solver is free to pick 0.5.3, whose newest Windows wheel is 3.12, and on - # 3.13 that means building ANTs and ITK from source -- which fails. - "antspyx>=0.6.1", - "icon-registration>=1.0.0", - "picsl-greedy>=0.0.12", - "scipy>=1.10.0", - # Finetuning (``unigradicon.finetuning``, used by - # WorkflowFinetuneICONRegistration and Tutorial 2) only exists on the - # uniGradICON ``feat-add-finetuning`` branch; see [tool.uv.sources] below. - "unigradicon>=1.0.0", - - # Visualization and USD - "pyvista[all]>=0.47.0", - "usd-core>=23.11", - "trimesh>=4.0.0", - "pyacvd>=0.4.0", - - # Utilities - "ipykernel>=6.0.0", - "matplotlib>=3.5.0", - "typing-extensions>=4.0.0", + # Core medical imaging + "itk>=5.3.0,<6.0.0", + "nibabel>=4.0.0", + "numpy>=1.21.0", + "pydicom>=2.4.0", + "pynrrd>=1.0.0", + "vtk>=9.2.0", + + # AI/ML and segmentation + # monai>=1.4 is required for monai.networks.nets.vista3d132 and + # monai.apps.vista3d, used by the NV-Segment-CTMR bundle. + "monai>=1.4.0", + "torch>=2.0.0", + "transformers>=4.21.0", + "totalsegmentator>=2.0.0", + "huggingface_hub>=0.24.0", + "einops>=0.6.1", + + # AI surrogates (PhysicsNeMo MeshGraphNet workflows, Tutorial 9). All + # three are CPU-capable (physicsnemo falls back to PyTorch/CPU; + # torch-geometric/torch-scatter ship CPU wheel builds), so they are base + # dependencies rather than a CUDA-gated extra -- every install, including + # the CPU-only fallback, can run the AI-surrogate workflows, just slower + # without a GPU. + # + # torch-scatter is the one dependency here that can require a source + # build. It compiles against torch but declares no build-system + # dependency, so its build environment must be the project environment: + # torch and setuptools must already be installed before it resolves. See + # [tool.uv] no-build-isolation-package below (uv) or the installation + # guide's explicit pip sequence. + # + # This is self-imposed by physicsnemo, not by PyG: torch_geometric's own + # ``torch_geometric.utils.scatter`` runs on native torch.scatter_reduce_ + # and treats torch_scatter as an optional accelerator, but physicsnemo + # hard-imports the compiled package (physicsnemo/nn/module/gnn_layers/ + # utils.py: ``importlib.import_module("torch_scatter")``). A one-line + # upstream switch to torch_geometric.utils.scatter would remove this + # dependency entirely; when that lands in physicsnemo, this requirement + # and the no-build-isolation ceremony around it can both go away. + # + # Prebuilt wheel availability (https://data.pyg.org/whl/) is the reason + # the cuda12 extra pins torch below 2.13; see that extra's comment. + "nvidia-physicsnemo>=2.0.0", + "torch-geometric>=2.5.0", + "torch-scatter>=2.1.0", + "warp-lang>=1.0.0", + + # Registration + # 0.6.1 is the first release with a CPython 3.13 Windows wheel. Below it the + # solver is free to pick 0.5.3, whose newest Windows wheel is 3.12, and on + # 3.13 that means building ANTs and ITK from source -- which fails. + "antspyx>=0.6.1", + "icon-registration>=1.0.0", + "picsl-greedy>=0.0.12", + "scipy>=1.10.0", + # Finetuning (``unigradicon.finetuning``, used by + # WorkflowFinetuneICONRegistration and Tutorial 2) only exists on the + # uniGradICON ``feat-add-finetuning`` branch; see [tool.uv.sources] below. + "unigradicon>=1.0.0", + + # Visualization and USD + "pyvista[all]>=0.47.0", + "usd-core>=23.11", + "trimesh>=4.0.0", + "pyacvd>=0.4.0", + + # Utilities + "ipykernel>=6.0.0", + "matplotlib>=3.5.0", + "typing-extensions>=4.0.0", ] @@ -146,7 +143,7 @@ no-build-isolation-package = ["torch-scatter"] # fallback to a doomed source build into an immediate resolution error naming # the interpreter that has no wheel. no-build-package = ["antspyx"] -# cuda12 and cuda13 both pin torch/torchvision/torchaudio but to different +# cuda12 and cuda13 both pin torch/torchvision but to different # wheel indexes (see [tool.uv.sources] below) -- installing both together in # one command is not meaningful. conflicts = [[{ extra = "cuda12" }, { extra = "cuda13" }]] @@ -157,7 +154,7 @@ conflicts = [[{ extra = "cuda12" }, { extra = "cuda13" }]] [tool.uv.extra-build-dependencies] torch-scatter = ["torch"] -# torch/torchvision/torchaudio route to the cuda13 or cuda12 PyTorch wheel +# torch/torchvision route to the cuda13 or cuda12 PyTorch wheel # index depending on which extra is requested. Best-effort: the uv pip # interface has been observed to ignore tool.uv.sources for index routing # (see .github/workflows/nightly-health.yml), so this is not relied upon as @@ -167,16 +164,12 @@ torch-scatter = ["torch"] # extras or this source config. [tool.uv.sources] torch = [ - { index = "pytorch-cu126", extra = "cuda12" }, - { index = "pytorch-cu130", extra = "cuda13" }, + { index = "pytorch-cu126", extra = "cuda12" }, + { index = "pytorch-cu130", extra = "cuda13" }, ] torchvision = [ - { index = "pytorch-cu126", extra = "cuda12" }, - { index = "pytorch-cu130", extra = "cuda13" }, -] -torchaudio = [ - { index = "pytorch-cu126", extra = "cuda12" }, - { index = "pytorch-cu130", extra = "cuda13" }, + { index = "pytorch-cu126", extra = "cuda12" }, + { index = "pytorch-cu130", extra = "cuda13" }, ] # The PyPI unigradicon release ships no ``unigradicon.finetuning`` module, so # finetuning (WorkflowFinetuneICONRegistration, Tutorial 2) needs this branch. @@ -197,7 +190,7 @@ url = "https://download.pytorch.org/whl/cu126" explicit = true [project.optional-dependencies] -# CUDA-matched acceleration only (cupy + pinned torch/torchvision/torchaudio). +# CUDA-matched acceleration only (cupy + pinned torch/torchvision). # The AI-surrogate stack (nvidia-physicsnemo, torch-geometric, torch-scatter) # is a base dependency, not part of these extras -- see [project.dependencies] # above. Recommended: `uv pip install --torch-backend=auto monai-physio`, @@ -210,10 +203,9 @@ explicit = true # source here -- requiring nvcc and a C++ toolchain. torch is left unpinned # because pinning it would not buy a wheel. cuda13 = [ - "cupy-cuda13x>=13.6.0", - "torch>=2.0.0", - "torchvision", - "torchaudio", + "cupy-cuda13x[ctk]>=14.0.0", + "torch>=2.0.0", + "torchvision", ] # CUDA 12.6: the newest combination with published prebuilt torch-scatter # wheels on every platform this project supports, including Windows @@ -224,56 +216,55 @@ cuda13 = [ # physicsnemo no longer hard-imports torch_scatter (see the AI-surrogate # comment in [project.dependencies]). cuda12 = [ - "cupy-cuda12x>=13.6.0", - "torch>=2.10,<2.13", - "torchvision", - "torchaudio", + "cupy-cuda12x[ctk]>=14.0.0", + "torch>=2.10,<2.13", + "torchvision", ] # Development, testing, and documentation tooling -- no CUDA/cupy component, # for pairing with `uv pip install --torch-backend=auto -e ".[dev]"`. dev = [ - "bumpver>=2023.0.0", - "jupytext>=1.16.0", - "pre-commit>=3.0.0", - "pytest>=7.0.0", - "pytest-cov>=4.0.0", - "pytest-xdist>=3.0.0", - # conftest.py's pytest_runtest_makereport uses pytest.hookimpl(wrapper=True), - # a new-style hook wrapper that pluggy only grew in 1.2.0. pytest>=7.0 alone - # permits an older pluggy, where that keyword raises at collection time. - "pluggy>=1.2.0", - "pytest-timeout>=2.0.0", - "coverage[toml]>=7.0.0", - "mypy>=2.1.0", - "ruff>=0.1.0", - "scipy-stubs>=1.14.0.0,<1.16.0.0", - "types-requests>=2.32.0.0", - # Sphinx core, theme, and extensions - "sphinx>=8.0.0", - "sphinx-rtd-theme>=3.0.0", - "sphinx-autodoc-typehints>=3.0.0", - "sphinx-copybutton>=0.5.2", - "sphinx-tabs>=3.4.7", - "myst-parser>=4.0.0", - # Docutils 0.22 removed some node attributes (e.g. "backrefs") that - # sphinx-tabs expects, causing a KeyError during builds. - "docutils<0.22", - "linkify-it-py>=2.0.0", - "uc-micro-py>=1.0.1", - "markdown-it-py>=3.0.0", - # Bundled ffmpeg binary used by utils/mp4_to_gif.py - "imageio-ffmpeg>=0.5.0", + "bumpver>=2023.0.0", + "jupytext>=1.16.0", + "pre-commit>=3.0.0", + "pytest>=7.0.0", + "pytest-cov>=4.0.0", + "pytest-xdist>=3.0.0", + # conftest.py's pytest_runtest_makereport uses pytest.hookimpl(wrapper=True), + # a new-style hook wrapper that pluggy only grew in 1.2.0. pytest>=7.0 alone + # permits an older pluggy, where that keyword raises at collection time. + "pluggy>=1.2.0", + "pytest-timeout>=2.0.0", + "coverage[toml]>=7.0.0", + "mypy>=2.1.0", + "ruff>=0.1.0", + "scipy-stubs>=1.14.0.0,<1.16.0.0", + "types-requests>=2.32.0.0", + # Sphinx core, theme, and extensions + "sphinx>=8.0.0", + "sphinx-rtd-theme>=3.0.0", + "sphinx-autodoc-typehints>=3.0.0", + "sphinx-copybutton>=0.5.2", + "sphinx-tabs>=3.4.7", + "myst-parser>=4.0.0", + # Docutils 0.22 removed some node attributes (e.g. "backrefs") that + # sphinx-tabs expects, causing a KeyError during builds. + "docutils<0.22", + "linkify-it-py>=2.0.0", + "uc-micro-py>=1.0.1", + "markdown-it-py>=3.0.0", + # Bundled ffmpeg binary used by utils/mp4_to_gif.py + "imageio-ffmpeg>=0.5.0", ] # Full source/dev installs: CUDA-matched acceleration plus dev/test/docs # tooling. Recommended for anyone building from source, per the assumption # that a source install implies wanting to run tests and build docs too. dev_cuda13 = [ - "monai-physio[cuda13]", - "monai-physio[dev]", + "monai-physio[cuda13]", + "monai-physio[dev]", ] dev_cuda12 = [ - "monai-physio[cuda12]", - "monai-physio[dev]", + "monai-physio[cuda12]", + "monai-physio[dev]", ] [project.urls] @@ -312,13 +303,8 @@ tag = false push = false [tool.bumpver.file_patterns] -"pyproject.toml" = [ - 'current_version = "{version}"', - 'version = "{version}"', -] -"src/monai_physio/__init__.py" = [ - '__version__ = "{version}"', -] +"pyproject.toml" = ['current_version = "{version}"', 'version = "{version}"'] +"src/monai_physio/__init__.py" = ['__version__ = "{version}"'] [tool.mypy] python_version = "3.11" @@ -345,115 +331,106 @@ show_error_codes = true # instead of applying it to first-party modules. [[tool.mypy.overrides]] module = [ - "ants", - "ants.*", - "cupy", - "cupy_backends", - "cupy_backends.*", - "huggingface_hub", - "huggingface_hub.*", - "imageio_ffmpeg", - "icon_registration", - "icon_registration.*", - "itk", - "itk.*", - "matplotlib", - "matplotlib.*", - "nibabel", - "nibabel.*", - "nrrd", - "numpy", - "numpy.*", - "picsl_greedy", - "pydicom", - "pydicom.*", - "physicsnemo", - "physicsnemo.*", - "pxr", - "pxr.*", - "pyacvd", - "pyacvd.*", - "sympy", - "sympy.*", - "pyvista", - "pyvista.*", - "simpleware", - "simpleware.*", - "SimpleITK", - "SimpleITK.*", - "sklearn.*", - "torch", - "torch.*", - "torch_geometric", - "torch_geometric.*", - "totalsegmentator.*", - "trimesh", - "trimesh.*", - "unigradicon", - "unigradicon.*", - "vista3d_config", - "vista3d_model", - "vista3d_pipeline", - "vtk", - "vtk.*", - "yaml", + "ants", + "ants.*", + "cupy", + "cupy_backends", + "cupy_backends.*", + "huggingface_hub", + "huggingface_hub.*", + "itk", + "matplotlib", + "matplotlib.*", + "nibabel", + "nibabel.*", + "nrrd", + "numpy", + "numpy.*", + "picsl_greedy", + "pydicom", + "pydicom.*", + "physicsnemo.*", + "pxr", + "pyacvd", + "sympy", + "sympy.*", + "pyvista", + "pyvista.*", + "simpleware", + "simpleware.*", + "SimpleITK", + "SimpleITK.*", + "sklearn.*", + "torch", + "torch.*", + "torch_geometric", + "torch_geometric.*", + "totalsegmentator.*", + "trimesh", + "trimesh.*", + "vista3d_config", + "vista3d_model", + "vista3d_pipeline", + "vtk", + "vtk.*", + "yaml", ] ignore_missing_imports = true [[tool.mypy.overrides]] module = [ - "monai_physio.cli.create_statistical_model", - "monai_physio.cli.fit_statistical_model_to_patient", - "monai_physio.cli.visualize_pca_modes", - "monai_physio.vtk_to_usd.mesh_utils", - "monai_physio.vtk_to_usd.vtk_reader", - # Tutorial scripts import monai_physio, which is absent from the isolated - # pre-commit mypy environment and ships no py.typed marker when installed. - # mypy per-module patterns match whole components only, so each tutorial is - # listed by name; add a line here when adding or renaming a tutorial. - "parameters_base", - "parameters_duke_heart_labelmaps", - "parameters_duke_heart_physics_informed", - "parameters_heart_ct_kcl", - "parameters_lung_ct_dirlab", - "parameters_tcia_4d_lung", - "tutorial_01_heart_gated_ct_to_usd", - "tutorial_01_lung_gated_ct_to_usd", - "tutorial_01_lung_gated_ct_to_usd_tetmesh", - "tutorial_02_duke_heart_distancemap_finetune_icon", - "tutorial_02_lung_distancemap_finetune_icon", - "tutorial_02_lung_finetune_icon", - "tutorial_03_heart_reconstruct_highres_4d_ct", - "tutorial_03_lung_reconstruct_highres_4d_ct", - "tutorial_04_duke_heart_labelmap_to_vtk", - "tutorial_04_heart_ct_to_vtk", - "tutorial_04_lung_ct_to_vtk", - "tutorial_05_duke_heart_vtk_to_usd", - "tutorial_05_heart_vtk_to_usd", - "tutorial_06_duke_heart_create_statistical_model", - "tutorial_06_heart_create_statistical_model", - "tutorial_06_lung_create_statistical_model", - "tutorial_07_duke_heart_fit_statistical_model_to_patient", - "tutorial_07_heart_fit_statistical_model_to_patient", - "tutorial_07_lung_fit_statistical_model_to_patient", - "tutorial_08_duke_heart_fit_model_to_4d_patients", - "tutorial_08_lung_fit_model_to_4d_patients", - "tutorial_09_duke_heart_train_physicsnemo_mgn", - "tutorial_09_lung_train_physicsnemo_mgn", - "tutorial_10_duke_heart_infer_physicsnemo_mgn", - "tutorial_10_lung_infer_physicsnemo_mgn", - "tutorial_11_duke_heart_evaluate_physicsnemo", - "tutorial_11_lung_evaluate_physicsnemo", - "tutorial_12_duke_heart_end_to_end_inference", - "tutorial_12_lung_end_to_end_inference", - "tutorial_13_heart_and_lung_motion", - "tutorial_14_duke_heart_shape_parameter_sweep", - "tutorial_14_lung_shape_parameter_sweep", - "tutorial_15_duke_heart_leave_one_out", - "tutorial_15_lung_leave_one_out", - "tutorial_16_duke_heart_physics_informed_motion_prep", - "tutorial_17_duke_heart_physics_informed_motion_train", - "tutorial_18_duke_heart_physics_informed_motion_infer", + "monai_physio.cli.create_statistical_model", + "monai_physio.cli.fit_statistical_model_to_patient", + "monai_physio.cli.visualize_pca_modes", + "monai_physio.vtk_to_usd.mesh_utils", + "monai_physio.vtk_to_usd.vtk_reader", + # Tutorial scripts import monai_physio, which ships no py.typed marker when + # installed. + # mypy per-module patterns match whole components only, so each tutorial is + # listed by name; add a line here when adding or renaming a tutorial. + "parameters_base", + "parameters_duke_heart_labelmaps", + "parameters_duke_heart_physics_informed", + "parameters_heart_ct_kcl", + "parameters_lung_ct_dirlab", + "parameters_tcia_4d_lung", + "tutorial_01_heart_gated_ct_to_usd", + "tutorial_01_lung_gated_ct_to_usd", + "tutorial_01_lung_gated_ct_to_usd_tetmesh", + "tutorial_02_duke_heart_distancemap_finetune_icon", + "tutorial_02_lung_distancemap_finetune_icon", + "tutorial_02_lung_finetune_icon", + "tutorial_03_heart_reconstruct_highres_4d_ct", + "tutorial_03_lung_reconstruct_highres_4d_ct", + "tutorial_04_duke_heart_labelmap_to_vtk", + "tutorial_04_heart_ct_to_vtk", + "tutorial_04_lung_ct_to_vtk", + "tutorial_05_duke_heart_vtk_to_usd", + "tutorial_05_heart_vtk_to_usd", + "tutorial_06_duke_heart_create_statistical_model", + "tutorial_06_heart_create_statistical_model", + "tutorial_06_lung_create_statistical_model", + "tutorial_07_duke_heart_fit_statistical_model_to_patient", + "tutorial_07_heart_fit_statistical_model_to_patient", + "tutorial_07_lung_fit_statistical_model_to_patient", + "tutorial_08_duke_heart_fit_model_to_4d_patients", + "tutorial_08_lung_fit_model_to_4d_patients", + "tutorial_09_duke_heart_train_physicsnemo_mgn", + "tutorial_09_lung_train_physicsnemo_mgn", + "tutorial_10_duke_heart_infer_physicsnemo_mgn", + "tutorial_10_lung_infer_physicsnemo_mgn", + "tutorial_11_duke_heart_evaluate_physicsnemo", + "tutorial_11_lung_evaluate_physicsnemo", + "tutorial_12_duke_heart_end_to_end_inference", + "tutorial_12_lung_end_to_end_inference", + "tutorial_13_heart_and_lung_motion", + "tutorial_14_duke_heart_shape_parameter_sweep", + "tutorial_14_lung_shape_parameter_sweep", + "tutorial_15_duke_heart_leave_one_out", + "tutorial_15_lung_leave_one_out", + "tutorial_16_duke_heart_physics_informed_motion_prep", + "tutorial_17_duke_heart_physics_informed_motion_train", + "tutorial_18_duke_heart_physics_informed_motion_infer", ] disable_error_code = ["import-not-found", "import-untyped"] @@ -471,12 +448,12 @@ reportMissingImports = false [tool.pytest.ini_options] minversion = "7.0" addopts = [ - "--strict-markers", - "--strict-config", - "--cov=monai_physio", - "--cov-report=term-missing", - "--cov-report=html", - "--cov-report=xml" + "--strict-markers", + "--strict-config", + "--cov=monai_physio", + "--cov-report=term-missing", + "--cov-report=html", + "--cov-report=xml", ] testpaths = ["tests"] pythonpath = [".", "tutorials"] @@ -487,134 +464,86 @@ pythonpath = [".", "tutorials"] # warnings summary. Order matters: the trailing "ignore" entries are applied # last and so take precedence over "always" for these specific messages. filterwarnings = [ - "always", - "ignore:builtin type Swig", - "ignore:builtin type swigvarlink", - # torch.jit.interface is deprecated in modern torch, but physicsnemo's - # MeshGraphNet import pulls in monai.networks.nets.dints, which still - # uses it at module scope. Third-party, not ours to fix. - "ignore:`torch.jit.interface` is deprecated:FutureWarning", + "always", + "ignore:builtin type Swig", + "ignore:builtin type swigvarlink", + # torch.jit.interface is deprecated in modern torch, but physicsnemo's + # MeshGraphNet import pulls in monai.networks.nets.dints, which still + # uses it at module scope. Third-party, not ours to fix. + "ignore:`torch.jit.interface` is deprecated:FutureWarning", ] markers = [ - "unit: marks tests as unit tests (fast, isolated)", - "integration: marks tests as integration tests (slower, multiple components)", - "slow: marks tests as slow (skipped by default; opt-in via --run-slow)", - "requires_gpu: marks tests that require GPU/CUDA support (opt-in via --run-gpu)", - "requires_simpleware: marks tests that require a local Synopsys Simpleware Medical installation (opt-in via --run-simpleware)", - "requires_physicsnemo: marks tests that require PhysicsNeMo (opt-in via --run-physicsnemo)", - "tutorial: marks tests that run tutorial scripts (data/GPU gated, manual only)", - "xdist_group: marks tests to run in the same pytest-xdist worker (prevents parallel conflicts)" + "unit: marks tests as unit tests (fast, isolated)", + "integration: marks tests as integration tests (slower, multiple components)", + "slow: marks tests as slow (skipped by default; opt-in via --run-slow)", + "requires_gpu: marks tests that require GPU/CUDA support (opt-in via --run-gpu)", + "requires_simpleware: marks tests that require a local Synopsys Simpleware Medical installation (opt-in via --run-simpleware)", + "requires_physicsnemo: marks tests that require PhysicsNeMo (opt-in via --run-physicsnemo)", + "tutorial: marks tests that run tutorial scripts (data/GPU gated, manual only)", + "xdist_group: marks tests to run in the same pytest-xdist worker (prevents parallel conflicts)", ] timeout = 1200 [tool.coverage.run] source = ["src/monai_physio"] omit = [ - "*/tests/*", - "*/test_*", - "*/experiments/*", - "*/__pycache__/*", - "*/venv/*", - "*/build/*", - "*/dist/*" + "*/tests/*", + "*/test_*", + "*/experiments/*", + "*/__pycache__/*", + "*/venv/*", + "*/build/*", + "*/dist/*", ] [tool.coverage.report] exclude_lines = [ - "pragma: no cover", - "def __repr__", - "if self.debug:", - "if settings.DEBUG", - "raise AssertionError", - "raise NotImplementedError", - "if 0:", - "if __name__ == .__main__.:", - "class .*\\bProtocol\\):", - "@(abc\\.)?abstractmethod" + "pragma: no cover", + "def __repr__", + "if self.debug:", + "if settings.DEBUG", + "raise AssertionError", + "raise NotImplementedError", + "if 0:", + "if __name__ == .__main__.:", + "class .*\\bProtocol\\):", + "@(abc\\.)?abstractmethod", ] [tool.ruff] target-version = "py311" line-length = 88 extend-exclude = [ - ".git", - "__pycache__", - "build", - "dist", - ".eggs", - "*.egg-info", - ".venv", - "venv", - ".mypy_cache", - ".pytest_cache" + ".git", + "__pycache__", + "build", + "dist", + ".eggs", + "*.egg-info", + ".venv", + "venv", + ".mypy_cache", + ".pytest_cache", ] [tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # pyflakes - "I", # isort - "B", # flake8-bugbear - "C4", # flake8-comprehensions - "UP", # pyupgrade - "N", # pep8-naming - "YTT", # flake8-2020 - "ASYNC", # flake8-async - "S", # flake8-bandit (security) - "A", # flake8-builtins - "COM", # flake8-commas - "C90", # mccabe complexity - "T10", # flake8-debugger - "ISC", # flake8-implicit-str-concat - "ICN", # flake8-import-conventions - "PIE", # flake8-pie - "PT", # flake8-pytest-style - "Q", # flake8-quotes - "RET", # flake8-return - "SIM", # flake8-simplify - "TCH", # flake8-type-checking - "ARG", # flake8-unused-arguments - "PTH", # flake8-use-pathlib - "PL", # pylint rules -] +# No select: ruff's default rule set applies, pinned by the ruff-pre-commit +# rev in .pre-commit-config.yaml. Review new findings when bumping that rev. ignore = [ - "B008", # do not perform function calls in argument defaults - "C901", # too complex - "PLR0913", # too many arguments - "PLR0912", # too many branches - "PLR0915", # too many statements - "S101", # use of assert (needed for pytest) - "COM812", # trailing comma (conflicts with formatter) - "ISC001", # implicit string concatenation (conflicts with formatter) - "UP007", # use Optional[] instead of | for union with None (itk.Image compatibility) + "UP007", # use Optional[] instead of | for union with None (itk.Image compatibility) + "UP045", # same, for Optional[X] specifically ] [tool.ruff.lint.per-file-ignores] -"__init__.py" = ["F401", "F403"] -"test_*.py" = ["S101", "PLR2004", "ARG001", "ARG002"] -"tests/*.py" = ["S101", "PLR2004", "ARG001", "ARG002"] -"experiments/**/*.py" = ["T201", "S101"] -# torch/icon_registration are lazy-loaded at runtime; forward-reference strings are intentional -"src/monai_physio/register_images_icon.py" = ["F821"] +# Run by Simpleware Medical as a script, by this file name +"src/monai_physio/simpleware_medical/SimplewareScript_heart_segmentation.py" = ["N999"] [tool.ruff.lint.isort] known-first-party = ["monai_physio"] force-single-line = false -lines-after-imports = 2 - -[tool.ruff.lint.mccabe] -max-complexity = 10 - -[tool.ruff.lint.flake8-quotes] -inline-quotes = "double" -multiline-quotes = "double" -docstring-quotes = "double" [tool.ruff.format] quote-style = "double" indent-style = "space" skip-magic-trailing-comma = false line-ending = "auto" -docstring-code-format = true -docstring-code-line-length = 88 diff --git a/src/monai_physio/__init__.py b/src/monai_physio/__init__.py index 3c15f91f..7a9b3c83 100644 --- a/src/monai_physio/__init__.py +++ b/src/monai_physio/__init__.py @@ -66,21 +66,23 @@ # Base classes from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours # Utility classes from .process_images import ProcessImages from .process_labelmaps import ProcessLabelmaps from .process_landmarks import ProcessLandmarks +from .process_mesh_kernels import ProcessMeshKernels from .process_physicsnemo import ( DistributedContext, ProcessPhysicsNemo, distributed_context, ) +from .process_surface_mesh import ProcessSurfaceMesh from .process_tests import ProcessTests from .process_transforms import ProcessTransforms from .process_usd import ProcessUSD from .process_usd_anatomy import ProcessUSDAnatomy +from .process_volumetric_mesh import ProcessVolumetricMesh from .register_images_ants import RegisterImagesANTS # Registration classes @@ -147,15 +149,17 @@ "InferPhysicsNeMoMLP", "MONAIPhysioBase", "MovementGroundTruth", - "ProcessContours", "ProcessImages", "ProcessLabelmaps", "ProcessLandmarks", + "ProcessMeshKernels", "ProcessPhysicsNemo", + "ProcessSurfaceMesh", "ProcessTests", "ProcessTransforms", "ProcessUSD", "ProcessUSDAnatomy", + "ProcessVolumetricMesh", "RegisterImagesANTS", "RegisterImagesBase", "RegisterImagesChain", diff --git a/src/monai_physio/cli/convert_image_to_vtk.py b/src/monai_physio/cli/convert_image_to_vtk.py index d005df3c..2254be7b 100644 --- a/src/monai_physio/cli/convert_image_to_vtk.py +++ b/src/monai_physio/cli/convert_image_to_vtk.py @@ -114,8 +114,8 @@ def main() -> int: type=float, default=0.0, help=( - "Fraction in [0, 1) of surface triangles to remove via " - "decimate_pro (default: 0.0, no decimation)." + "Fraction in [0, 1) of surface triangles to remove by ACVD " + "remeshing (default: 0.0, no reduction)." ), ) @@ -167,7 +167,7 @@ def main() -> int: print("=" * 70) try: - from .. import ProcessContours, WorkflowConvertImageToVTK + from .. import WorkflowConvertImageToVTK workflow = WorkflowConvertImageToVTK( segmentation_method=build_segmentation_method( @@ -202,13 +202,13 @@ def main() -> int: try: if args.output_mode == "combined": stem = f"{prefix}_surfaces" if prefix else "surfaces" - surface_file = ProcessContours.save_combined_surfaces( + surface_file = WorkflowConvertImageToVTK.save_combined_surfaces( surfaces, os.path.join(args.output_dir, f"{stem}.vtp") ) print(f" Combined surface -> {surface_file}") else: # One file per anatomy group or per individual label - saved_surfaces = ProcessContours.save_surfaces( + saved_surfaces = WorkflowConvertImageToVTK.save_surfaces( surfaces, args.output_dir, prefix=prefix ) for name, path in saved_surfaces.items(): diff --git a/src/monai_physio/convert_vtk_to_usd.py b/src/monai_physio/convert_vtk_to_usd.py index 7363e6a9..c00d915e 100644 --- a/src/monai_physio/convert_vtk_to_usd.py +++ b/src/monai_physio/convert_vtk_to_usd.py @@ -986,19 +986,14 @@ def _split_by_labels( if isinstance(vtk_mesh, pv.UnstructuredGrid) and self.convert_to_surface: vtk_mesh = vtk_mesh.extract_surface(algorithm="dataset_surface") - # Get per-cell label IDs. 'SegmentationLabelIds' is written by - # ProcessContours.save_combined_surfaces when merging per-label surfaces; - # 'boundary_labels' comes from contouring a multi-label labelmap. - if "SegmentationLabelIds" in vtk_mesh.cell_data: - label_array = vtk_mesh.cell_data["SegmentationLabelIds"] - elif "boundary_labels" in vtk_mesh.cell_data: - label_array = vtk_mesh.cell_data["boundary_labels"] - else: + # Per-cell label IDs, as ProcessSurfaceMesh.combine_surfaces writes them + # when merging per-label surfaces. + if "SegmentationLabelIds" not in vtk_mesh.cell_data: self.log_warning( - "No 'SegmentationLabelIds' or 'boundary_labels' array found " - "- using unified mesh" + "No 'SegmentationLabelIds' array found - using unified mesh" ) return {"default": self._vtk_to_mesh_data(vtk_mesh, time_idx)} + label_array = vtk_mesh.cell_data["SegmentationLabelIds"] # Create submeshes for each label labeled_meshes = {} diff --git a/src/monai_physio/evaluate_movement_lung.py b/src/monai_physio/evaluate_movement_lung.py index 75376f37..a418f67b 100644 --- a/src/monai_physio/evaluate_movement_lung.py +++ b/src/monai_physio/evaluate_movement_lung.py @@ -16,7 +16,7 @@ import itk from .evaluate_movement_base import EvaluateMovementBase, MovementGroundTruth -from .segment_chest_total_segmentator import SegmentChestTotalSegmentator +from .segment_nv_segment_ct_mri import SegmentNVSegmentCTMRI class EvaluateMovementLung(EvaluateMovementBase): @@ -29,10 +29,10 @@ class EvaluateMovementLung(EvaluateMovementBase): log_level: Logging level. Default: ``logging.INFO``. """ - segmenter_class = SegmentChestTotalSegmentator - # The five lobes of ``SegmentChestTotalSegmentator``. Its "lung" group also carries + segmenter_class = SegmentNVSegmentCTMRI + # The five lobes of ``SegmentNVSegmentCTMRI``. Its "lung" group also carries # whole-lung, tumor and airway labels, which are not lobes. - label_ids = (10, 11, 12, 13, 14) + label_ids = (28, 29, 30, 31, 32) # A lobe barely changes shape over a breath compared to how big it is, so # Dice says more about the lobe than about the motion. Volume difference and # surface RMSE are what resolve it here. diff --git a/src/monai_physio/process_contours.py b/src/monai_physio/process_contours.py deleted file mode 100644 index 2ebb361a..00000000 --- a/src/monai_physio/process_contours.py +++ /dev/null @@ -1,1545 +0,0 @@ -""" -Tools for creating and manipulating contours. -""" - -from __future__ import annotations - -import logging -import os -from collections.abc import Sequence -from typing import Optional, cast - -import itk -import numpy as np -import pyacvd -import pyvista as pv -import trimesh - -from .monai_physio_base import MONAIPhysioBase -from .process_images import ProcessImages -from .process_transforms import ProcessTransforms -from .process_usd_anatomy import ProcessUSDAnatomy - -# 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 - - -class ProcessContours(MONAIPhysioBase): - """ - Tools for creating and manipulating contours. - """ - - def __init__(self, log_level: int | str = logging.INFO): - """Initialize ProcessContours. - - Args: - log_level: Logging level (default: logging.INFO) - """ - super().__init__(class_name=self.__class__.__name__, log_level=log_level) - - # ProcessUSDAnatomy builds its color tables in __init__ without touching - # the stage, so stage=None is safe for these lookup-only uses. - self._anatomy_tools = ProcessUSDAnatomy(stage=None, log_level=log_level) - - def apply_anatomy_color( - self, mesh: pv.DataSet, anatomy_names: Sequence[str] - ) -> None: - """Attach a structure's :class:`ProcessUSDAnatomy` 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 x 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. ``ProcessUSDAnatomy`` - 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)) - - 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 - ) - - 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 - - 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 - - @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)) - - 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 - - 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 = cast(np.ndarray, 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 - - def repair_inverted_tetrahedra( - self, - tetrahedra: pv.UnstructuredGrid, - max_iterations: int = 100, - ) -> pv.UnstructuredGrid: - """Relax the nodes of any inverted or degenerate tetrahedron. - - :meth:`trim_tetrahedra_to_surface` holds every cell of the *template* - above a scaled Jacobian of 0.1, but warping that template onto a - specific subject -- a statistical-model fit, say -- carries no such - constraint, and can flip a handful of elements even though the - template it started from was clean. Only the nodes touching a bad - element are moved, each to the mean of its mesh neighbors, which - leaves geometry the fit got right alone. - - Args: - tetrahedra: Volume mesh to repair; its cell and field data - survive. - max_iterations: Smoothing passes to attempt before giving up. - - Returns: - The repaired mesh, or *tetrahedra* unchanged if every cell - already clears zero volume. - - Raises: - ValueError: If *tetrahedra* has no TETRA cells, or if elements - are still inverted or degenerate after *max_iterations* - passes. - """ - if np.uint8(pv.CellType.TETRA) not in tetrahedra.cells_dict: - raise ValueError( - "tetrahedra has no TETRA cells to repair; got cell types " - f"{sorted(tetrahedra.cells_dict)}." - ) - connectivity = cast( - np.ndarray, tetrahedra.cells_dict[np.uint8(pv.CellType.TETRA)] - ) - - def volumes(points: np.ndarray) -> np.ndarray: - corners = points[connectivity] - edges = corners[:, 1:, :] - corners[:, 0:1, :] - return cast(np.ndarray, np.linalg.det(edges) / 6.0) - - points = np.array(tetrahedra.points) - n_inverted = int(np.sum(volumes(points) <= 0.0)) - if n_inverted == 0: - return tetrahedra - - 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]]) - order = np.argsort(starts, kind="stable") - sorted_starts = starts[order] - sorted_ends = ends[order] - split_points = np.searchsorted(sorted_starts, np.arange(len(points) + 1)) - neighbors = { - node: np.unique(sorted_ends[split_points[node] : split_points[node + 1]]) - for node in np.unique(connectivity) - } - - for _ in range(max_iterations): - bad = volumes(points) <= 0.0 - if not np.any(bad): - break - snapshot = points.copy() - for node in np.unique(connectivity[bad]): - neighbor_points = snapshot[neighbors[node]] - if len(neighbor_points): - points[node] = neighbor_points.mean(axis=0) - - final_volumes = volumes(points) - still_bad_mask = final_volumes <= 0.0 - still_bad = int(np.sum(still_bad_mask)) - if still_bad: - _MAX_LISTED = 20 - bad_cell_ids = np.nonzero(still_bad_mask)[0] - details = "; ".join( - f"cell {cell_id} (nodes {connectivity[cell_id].tolist()}): " - f"volume={final_volumes[cell_id]:.3e}" - for cell_id in bad_cell_ids[:_MAX_LISTED] - ) - if len(bad_cell_ids) > _MAX_LISTED: - details += f"; ... and {len(bad_cell_ids) - _MAX_LISTED} more" - raise ValueError( - f"{still_bad} of {len(connectivity)} tetrahedra are still " - f"inverted or degenerate after {max_iterations} repair " - f"passes ({details}); the fitted mesh needs a real re-fit, " - "not just smoothing." - ) - - self.log_warning( - "%d of %d tetrahedra were inverted or degenerate; repaired by " - "relaxing their nodes onto their neighbors' mean.", - n_inverted, - len(connectivity), - ) - repaired = tetrahedra.copy() - repaired.points = points - return repaired - - 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 - triangulated = conditioned.triangulate() - # ACVD's clustering graph assumes a single connected component; a - # multi-material surface merged from disjoint parts (e.g. left and - # right lung, saved as one file by save_combined_surfaces) crashes - # pyacvd's native clustering step otherwise. Remesh each connected - # component on its own and recombine. - regions = triangulated.connectivity(extraction_mode="all") - remeshed_pieces = [] - for region_id in np.unique(regions.cell_data["RegionId"]): - piece = regions.threshold( - (region_id, region_id), scalars="RegionId", preference="cell" - ).extract_surface(algorithm="geometry") - clustering = pyacvd.Clustering(piece) - # 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(piece.n_points * (1.0 - surface_reduction_rate))) - ) - remeshed_pieces.append(clustering.create_mesh()) - conditioned = pv.merge(remeshed_pieces) - 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 - - @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") - - @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 = ProcessTransforms().transform_pvcontour( - contours, tfm, with_deformation_magnitude=with_deformation_magnitude - ) - - return new_contours - - 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 - - @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 - - @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 - - 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 - - @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) - - 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 - - @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 = ProcessImages() - deformation_field_img = image_tools.convert_array_to_image_of_vectors( - deformation_field, reference_image, ptype=ptype - ) - - return deformation_field_img - - # ─────────────────────────── I/O helpers ─────────────────────────────── - - @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 - - @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 diff --git a/src/monai_physio/process_images.py b/src/monai_physio/process_images.py index c9bb0eae..7e036fa9 100644 --- a/src/monai_physio/process_images.py +++ b/src/monai_physio/process_images.py @@ -515,6 +515,47 @@ def pad_image( result.DisconnectPipeline() return result + def crop_to_nonzero( + self, labelmap_image: itk.Image, pad_voxels: int = 0 + ) -> itk.Image: + """Return *labelmap_image* cropped to its nonzero voxels, then zero-padded. + + The voxels keep their physical positions: the origin moves with the + crop and the pad, while spacing and direction are unchanged. + + Args: + labelmap_image: Labelmap or mask holding at least one nonzero voxel. + pad_voxels: Zero voxels added around the crop on every side. + + Returns: + The cropped labelmap, as uint16. + """ + labels = itk.GetArrayViewFromImage(labelmap_image) + nonzero = labels != 0 + # The array's axes are reversed relative to the image, so the crop's + # start comes out as (z, y, x) and is flipped for the origin. + start, stop = [], [] + for axis in range(labels.ndim): + others = tuple(other for other in range(labels.ndim) if other != axis) + hits = np.flatnonzero(nonzero.any(axis=others)) + start.append(int(hits[0])) + stop.append(int(hits[-1]) + 1) + cropped_arr = np.pad( + labels[tuple(slice(a, b) for a, b in zip(start, stop))], pad_voxels + ) + cropped = itk.GetImageFromArray( + np.ascontiguousarray(cropped_arr.astype(np.uint16)) + ) + spacing = np.asarray(labelmap_image.GetSpacing(), dtype=np.float64) + direction = itk.array_from_matrix(labelmap_image.GetDirection()) + cropped.SetSpacing(labelmap_image.GetSpacing()) + cropped.SetDirection(labelmap_image.GetDirection()) + cropped.SetOrigin( + np.asarray(labelmap_image.GetOrigin(), dtype=np.float64) + + direction @ (spacing * (np.array(start[::-1]) - pad_voxels)) + ) + return cropped + def binary_dilate_image( self, image: itk.Image, diff --git a/src/monai_physio/process_mesh_kernels.py b/src/monai_physio/process_mesh_kernels.py new file mode 100644 index 00000000..5fa178d9 --- /dev/null +++ b/src/monai_physio/process_mesh_kernels.py @@ -0,0 +1,645 @@ +""" +GPU primitives behind the surface and volumetric mesh tools. + +This module provides the :class:`ProcessMeshKernels` class: closest-point and +inside/outside queries against a triangle surface, the tetrahedron scaled +Jacobian, exact Euclidean distance maps, a blur matching ITK's recursive +Gaussian, and label-vote resampling. The warp kernels stay at module scope, +where warp builds them; the class methods prepare their inputs and launch +them. +""" + +from __future__ import annotations + +import functools +import logging +import math +from collections.abc import Sequence +from typing import TYPE_CHECKING, Optional + +import itk +import numpy as np +import pyvista as pv +import vtk +import warp as wp + +from .monai_physio_base import MONAIPhysioBase + +if TYPE_CHECKING: + import torch + + +@wp.kernel +def _closest_point_kernel( + mesh_id: wp.uint64, + queries: wp.array(dtype=wp.vec3), # type: ignore[valid-type] + closest: wp.array(dtype=wp.vec3), # type: ignore[valid-type] +) -> None: + index = wp.tid() + query = queries[index] + hit = wp.mesh_query_point_no_sign(mesh_id, query, 1.0e6) # type: ignore[arg-type, unused-ignore] + if hit.result: # type: ignore[attr-defined] + closest[index] = wp.mesh_eval_position( + mesh_id, + hit.face, # type: ignore[attr-defined] + hit.u, # type: ignore[attr-defined] + hit.v, # type: ignore[attr-defined] + ) + else: + closest[index] = query + + +@wp.kernel +def _inside_surface_kernel( + mesh_id: wp.uint64, + edge_band: wp.float32, + indices: wp.array(dtype=wp.int32), # type: ignore[valid-type] + face_normals: wp.array(dtype=wp.vec3), # type: ignore[valid-type] + face_neighbors: wp.array(dtype=wp.int32), # type: ignore[valid-type] + vertex_starts: wp.array(dtype=wp.int32), # type: ignore[valid-type] + vertex_faces: wp.array(dtype=wp.int32), # type: ignore[valid-type] + queries: wp.array(dtype=wp.vec3), # type: ignore[valid-type] + inside: wp.array(dtype=wp.bool), # type: ignore[valid-type] + undecided: wp.array(dtype=wp.bool), # type: ignore[valid-type] +) -> None: + index = wp.tid() + query = queries[index] + hit = wp.mesh_query_point_no_sign(mesh_id, query, 1.0e6) # type: ignore[arg-type, unused-ignore] + inside[index] = False + undecided[index] = False + if hit.result: # type: ignore[attr-defined] + face = hit.face # type: ignore[attr-defined] + closest = wp.mesh_eval_position( + mesh_id, + face, + hit.u, # type: ignore[attr-defined] + hit.v, # type: ignore[attr-defined] + ) + offset = query - closest + is_inside = wp.dot(offset, face_normals[face]) < 0.0 + inside[index] = is_inside + weights = wp.vec3( + hit.u, # type: ignore[attr-defined] + hit.v, # type: ignore[attr-defined] + 1.0 - hit.u - hit.v, # type: ignore[attr-defined] + ) + zero_count = wp.int32(0) + zero_corner = wp.int32(0) + far_corner = wp.int32(0) + for corner in range(3): + if weights[corner] < edge_band: + zero_count += 1 + zero_corner = corner # type: ignore[assignment] + else: + far_corner = corner # type: ignore[assignment] + if zero_count == 1: + neighbor = face_neighbors[face * 3 + zero_corner] + if neighbor >= 0: # noqa: SIM102 + if (wp.dot(offset, face_normals[neighbor]) < 0.0) != is_inside: + undecided[index] = True + elif zero_count == 2: + vertex = indices[face * 3 + far_corner] + for slot in range(vertex_starts[vertex], vertex_starts[vertex + 1]): + other = vertex_faces[slot] + if (wp.dot(offset, face_normals[other]) < 0.0) != is_inside: + undecided[index] = True + + +@wp.kernel +def _squared_distance_line_kernel( + values: wp.array2d(dtype=wp.float32), # type: ignore[valid-type] + no_feature_limit: wp.float32, + spacing_squared: wp.float32, + position: wp.array2d(dtype=wp.int32), # type: ignore[valid-type] + bound: wp.array2d(dtype=wp.float32), # type: ignore[valid-type] + result: wp.array2d(dtype=wp.float32), # type: ignore[valid-type] +) -> None: + # Lower envelope of the parabolas rooted at every finite entry of one line + # (Felzenszwalb and Huttenlocher), one line per thread; columns of the 2-D + # arrays are lines, so neighboring threads read neighboring addresses. + line = wp.tid() + length = values.shape[0] + top = wp.int32(-1) + for q in range(length): + value = values[q, line] + if value < no_feature_limit: + offset = value + spacing_squared * float(q) * float(q) + crossing = wp.float32(-1.0e30) + placed = wp.int32(0) + while placed == 0: + if top < 0: + placed = 1 # type: ignore[assignment] + else: + p = position[top, line] + other = values[p, line] + spacing_squared * float(p) * float(p) + gap = 2.0 * spacing_squared * float(q - p) + crossing = (offset - other) / gap + if crossing <= bound[top, line]: + top = top - 1 + else: + placed = 1 # type: ignore[assignment] + top = top + 1 + position[top, line] = q + bound[top, line] = crossing + reach = wp.int32(0) + for q in range(length): + if top < 0: + result[q, line] = values[q, line] + else: + while reach < top and bound[reach + 1, line] < float(q): + reach = reach + 1 + p = position[reach, line] + gap = float(q - p) + result[q, line] = values[p, line] + spacing_squared * gap * gap + + +@functools.cache +def _recursive_gaussian_taps(sigma: float) -> np.ndarray: + """Return the impulse response of ITK's recursive Gaussian, in voxels. + + ITK's recursive filter only approximates a Gaussian (its variance falls + short of ``sigma ** 2`` by several percent), and a level set read off a + blurred distance map moves with that difference, so the GPU blur applies + the response ITK's own filter has rather than a true Gaussian. The + response is read off ITK for a unit impulse and cut where it dies away. + """ + # Half-width, in sigmas, of the window over which the response is read, and + # the size under which a tap of it is dropped. + impulse_sigmas = 16.0 + tap_floor = 1.0e-9 + radius = math.ceil(impulse_sigmas * sigma) + impulse = np.zeros((1, 1, 2 * radius + 1), dtype=np.float64) + impulse[0, 0, radius] = 1.0 + image = itk.GetImageFromArray(impulse) + recursive = itk.RecursiveGaussianImageFilter[type(image), type(image)].New() + recursive.SetInput(image) + recursive.SetSigma(sigma) + recursive.SetDirection(0) + recursive.SetNormalizeAcrossScale(False) + recursive.Update() + response = itk.GetArrayFromImage(recursive.GetOutput())[0, 0] + reach = int(np.flatnonzero(np.abs(response) > tap_floor).max()) + half = max(1, reach - radius) + return np.asarray(response[radius - half : radius + half + 1]) + + +@wp.kernel +def _banded_sum_kernel( + source: wp.array2d(dtype=wp.float64), # type: ignore[valid-type] + weights: wp.array2d(dtype=wp.float64), # type: ignore[valid-type] + first: wp.array(dtype=wp.int32), # type: ignore[valid-type] + last: wp.array(dtype=wp.int32), # type: ignore[valid-type] + result: wp.array2d(dtype=wp.float64), # type: ignore[valid-type] +) -> None: + row, column = wp.tid() # type: ignore[misc] + total = wp.float64(0.0) + for index in range(first[row], last[row]): + total += weights[row, index] * source[index, column] + result[row, column] = total + + +class ProcessMeshKernels(MONAIPhysioBase): + """ + GPU primitives shared by :class:`ProcessSurfaceMesh` and + :class:`ProcessVolumetricMesh`. + + Surface queries take a :class:`warp.Mesh` built once by + :meth:`build_surface_mesh` and reused across calls. Distance maps and + blurs take and return torch tensors on :attr:`device`; + :meth:`vote_resample_labelmap` takes and returns ITK images. + + Example: + >>> kernels = ProcessMeshKernels() + >>> mesh = kernels.build_surface_mesh(surface) + >>> closest = kernels.closest_points_on_surface(mesh, points) + """ + + #: Squared distance given to a voxel no feature can reach. + NO_FEATURE = 1.0e30 + + #: Squared distance at or above which a voxel had no feature in reach. + NO_FEATURE_LIMIT = 1.0e20 + + #: ITK's label-vote interpolator weighs a voxel by the Gaussian's mass over + #: it and ignores voxels farther than this many sigmas from the sample. + VOTE_ALPHA = 3.0 + + #: A closest point whose barycentric weight on a triangle corner is below + #: this counts as lying on the opposite edge (on a vertex, for two such + #: weights); float32 positions at a patient's coordinates blur the weights + #: by about 1e-3. + EDGE_BAND = 2.0e-3 + + def __init__(self, log_level: int | str = logging.INFO): + """Initialize the ProcessMeshKernels class. + + Args: + log_level: Logging level (default: logging.INFO) + """ + super().__init__(class_name=self.__class__.__name__, log_level=log_level) + self.device = "cuda:0" + + def build_surface_mesh(self, surface: pv.PolyData) -> wp.Mesh: + """Upload *surface*'s triangles to the GPU for closest-point queries.""" + import torch + + wp.init() + triangles = surface if surface.is_all_triangles else surface.triangulate() + points = torch.as_tensor( + np.asarray(triangles.points, dtype=np.float32), device=self.device + ) + indices = torch.as_tensor( + triangles.faces.reshape(-1, 4)[:, 1:].astype(np.int32).ravel(), + device=self.device, + ) + return wp.Mesh( + points=wp.from_torch(points, dtype=wp.vec3), # type: ignore[arg-type] + indices=wp.from_torch(indices, dtype=wp.int32), # type: ignore[arg-type] + ) + + def closest_points_on_surface( + self, mesh: wp.Mesh, queries: np.ndarray + ) -> np.ndarray: + """Return each row of *queries*' closest point on the surface of *mesh*.""" + import torch + + query_tensor = torch.as_tensor( + np.ascontiguousarray(queries, dtype=np.float32), device=self.device + ) + closest = torch.empty_like(query_tensor) + torch.cuda.synchronize() + wp.launch( + _closest_point_kernel, + dim=len(query_tensor), + inputs=[ + mesh.id, + wp.from_torch(query_tensor, dtype=wp.vec3), + wp.from_torch(closest, dtype=wp.vec3), + ], + device=self.device, + ) + wp.synchronize_device(self.device) + return closest.cpu().numpy().astype(np.float64) + + def points_inside_surface(self, mesh: wp.Mesh, points: np.ndarray) -> np.ndarray: + """Return which rows of *points* lie inside the closed, outward-wound *mesh*. + + The side is the one ``vtkImplicitPolyDataDistance`` reports: that of the + normal of the closest face. Where the closest point lies on an edge or a + vertex, VTK takes whichever of the faces meeting there its search finds + first. If they all put the point on one side the GPU has the answer; on a + thin, tubular surface they sometimes do not, and those few points go to VTK. + """ + import torch + + vertices = wp.to_torch(mesh.points) + corners = wp.to_torch(mesh.indices).long().reshape(-1, 3) + face_normals = torch.nn.functional.normalize( + torch.cross( + vertices[corners[:, 1]] - vertices[corners[:, 0]], + vertices[corners[:, 2]] - vertices[corners[:, 0]], + dim=1, + ), + dim=1, + ) + + # Slot 3 * face + corner holds the face across the edge opposite that corner. + edge_keys = torch.stack( + [ + torch.minimum( + corners[:, (corner + 1) % 3], corners[:, (corner + 2) % 3] + ) + * len(vertices) + + torch.maximum( + corners[:, (corner + 1) % 3], corners[:, (corner + 2) % 3] + ) + for corner in range(3) + ], + dim=1, + ).reshape(-1) + order = torch.argsort(edge_keys) + paired = edge_keys[order][1:] == edge_keys[order][:-1] + first, second = order[:-1][paired], order[1:][paired] + face_neighbors = torch.full_like(edge_keys, -1) + face_neighbors[first] = second // 3 + face_neighbors[second] = first // 3 + + corner_order = torch.argsort(corners.reshape(-1)) + vertex_starts = torch.zeros( + len(vertices) + 1, dtype=torch.int64, device=self.device + ) + vertex_starts[1:] = torch.cumsum( + torch.bincount(corners.reshape(-1), minlength=len(vertices)), dim=0 + ) + + query_tensor = torch.as_tensor( + np.ascontiguousarray(points, dtype=np.float32), device=self.device + ) + inside = torch.empty(len(query_tensor), dtype=torch.bool, device=self.device) + undecided = torch.empty_like(inside) + torch.cuda.synchronize() + wp.launch( + _inside_surface_kernel, + dim=len(query_tensor), + inputs=[ + mesh.id, + self.EDGE_BAND, + mesh.indices, + wp.from_torch(face_normals, dtype=wp.vec3), + wp.from_torch(face_neighbors.int()), + wp.from_torch(vertex_starts.int()), + wp.from_torch((corner_order // 3).int()), + wp.from_torch(query_tensor, dtype=wp.vec3), + wp.from_torch(inside), + wp.from_torch(undecided), + ], + device=self.device, + ) + wp.synchronize_device(self.device) + result = inside.cpu().numpy() + to_vtk = undecided.cpu().numpy() + if to_vtk.any(): + surface = pv.PolyData.from_regular_faces( + vertices.cpu().numpy().astype(np.float64), corners.cpu().numpy() + ) + distance = vtk.vtkImplicitPolyDataDistance() + distance.SetInput(surface) + values = vtk.vtkDoubleArray() + distance.FunctionValue(pv.convert_array(points[to_vtk]), values) + result[to_vtk] = pv.convert_array(values) < 0.0 + return result + + def scaled_jacobian( + self, points: torch.Tensor, connectivity: torch.Tensor + ) -> torch.Tensor: + """Return VTK's tetrahedron scaled Jacobian (Verdict) for every cell.""" + import torch + + p0, p1, p2, p3 = points[connectivity].unbind(dim=1) + side0, side1, side2 = p1 - p0, p2 - p1, p0 - p2 + side3, side4, side5 = p3 - p0, p3 - p1, p3 - p2 + jacobian = (side3 * torch.cross(side2, side0, dim=1)).sum(dim=1) + l0, l1, l2, l3, l4, l5 = ( + torch.linalg.vector_norm(side, dim=1) + for side in (side0, side1, side2, side3, side4, side5) + ) + length_product = torch.maximum( + torch.maximum(l0 * l2 * l3, l0 * l1 * l4), + torch.maximum(l1 * l2 * l5, l3 * l4 * l5), + ) + scaled = jacobian * (2.0**0.5) / length_product + return torch.where( + length_product > torch.finfo(torch.float64).tiny, scaled, 0.0 + ) + + def squared_distance_to_features( + self, features: torch.Tensor, spacing: Sequence[float] + ) -> torch.Tensor: + """Return every voxel's squared distance, in mm squared, to a feature voxel. + + The distance is the exact Euclidean one, taken between voxel centers, with + *spacing* the voxel pitch along each axis of *features*. + """ + import torch + + wp.init() + values = torch.where(features, 0.0, self.NO_FEATURE) + for axis in range(3): + lines = values.movedim(axis, 0).contiguous() + flat = lines.reshape(lines.shape[0], -1) + position = torch.empty(flat.shape, dtype=torch.int32, device=self.device) + bound = torch.empty_like(flat) + result = torch.empty_like(flat) + torch.cuda.synchronize() + wp.launch( + _squared_distance_line_kernel, + dim=flat.shape[1], + inputs=[ + wp.from_torch(flat), + self.NO_FEATURE_LIMIT, + float(spacing[axis]) ** 2, + wp.from_torch(position), + wp.from_torch(bound), + wp.from_torch(result), + ], + device=self.device, + ) + wp.synchronize_device(self.device) + values = result.reshape(lines.shape).movedim(0, axis) + return values.contiguous() + + def _edge_depth_mm( + self, shape: Sequence[int], spacing: Sequence[float] + ) -> torch.Tensor: + """Return how far ITK's signed Maurer map puts each voxel's own edge inside it. + + ITK measures a voxel to itself as the gap between its coordinate taken as a + float product and as a rounded double product, so an edge voxel sits a + rounding error inside its label rather than at zero, and only at some + voxels. An isocontour at zero reads an exact zero as outside, so the + pattern decides which voxels a contour passes between. + """ + import torch + + depth = torch.zeros(shape, device=self.device) + for axis, size in enumerate(shape): + index = torch.arange(size, dtype=torch.float64, device=self.device) + pitch = float(spacing[axis]) + rounded = (index * pitch).to(torch.float32) + product = index.to(torch.float32) * torch.tensor( + pitch, dtype=torch.float32, device=self.device + ) + gap = ((product - rounded) ** 2).reshape( + [-1 if other == axis else 1 for other in range(len(shape))] + ) + depth = depth + gap + return torch.sqrt(depth) + + def gaussian_blur( + self, field: torch.Tensor, spacing: Sequence[float], sigma_mm: float + ) -> torch.Tensor: + """Blur *field* as ITK's recursive Gaussian does, repeating its edge values.""" + import torch + from torch.nn import functional + + blurred = field[None, None] + for axis in range(3): + taps = torch.as_tensor( + _recursive_gaussian_taps(sigma_mm / float(spacing[axis])), + dtype=field.dtype, + device=field.device, + ) + radius = len(taps) // 2 + padding = [0] * 6 + padding[2 * (2 - axis)] = padding[2 * (2 - axis) + 1] = radius + shape = [1, 1, 1, 1, 1] + shape[2 + axis] = -1 + blurred = functional.conv3d( + functional.pad(blurred, padding, mode="replicate"), taps.view(shape) + ) + return blurred[0, 0] + + def _vote_weights( + self, input_size: int, output_size: int, step: float, sigma: float + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """Return how much each input voxel counts toward each output voxel's vote. + + A voxel counts by the mass of a Gaussian of *sigma* (in input voxels) over + it, centered on the output voxel; output voxels are *step* input voxels + apart. Voxels beyond ``VOTE_ALPHA`` sigmas of the center do not count. + Also returns, per output voxel, the first and one-past-last input voxel + that counts, since the weights are banded. + """ + import torch + + centers = torch.arange(output_size, dtype=torch.float64, device=self.device) + voxels = torch.arange(input_size, dtype=torch.float64, device=self.device) + offsets = voxels[None, :] - centers[:, None] * step + scale = 1.0 / (math.sqrt(2.0) * sigma) + mass = torch.erf((offsets + 0.5) * scale) - torch.erf((offsets - 0.5) * scale) + counted = offsets.abs() < self.VOTE_ALPHA * sigma + 0.5 + weights = torch.where(counted, mass, 0.0) + occupied = weights != 0.0 + first = torch.where( + occupied.any(dim=1), occupied.to(torch.int8).argmax(dim=1), 0 + ).to(torch.int32) + last = torch.where( + occupied.any(dim=1), + input_size - occupied.flip(1).to(torch.int8).argmax(dim=1), + 0, + ).to(torch.int32) + return weights, first, last + + def _banded_sum( + self, + field: torch.Tensor, + banded: tuple[torch.Tensor, torch.Tensor, torch.Tensor], + axis: int, + ) -> torch.Tensor: + """Return *field* with its *axis* replaced by the weighted sums over it.""" + import torch + + weights, first, last = banded + wp.init() + lines = field.movedim(axis, 0).contiguous() + flat = lines.reshape(lines.shape[0], -1) + result = torch.empty( + (weights.shape[0], flat.shape[1]), dtype=torch.float64, device=self.device + ) + torch.cuda.synchronize() + wp.launch( + _banded_sum_kernel, + dim=result.shape, + inputs=[ + wp.from_torch(flat), + wp.from_torch(weights), + wp.from_torch(first), + wp.from_torch(last), + wp.from_torch(result), + ], + device=self.device, + ) + wp.synchronize_device(self.device) + return result.reshape(weights.shape[0], *lines.shape[1:]).movedim(0, axis) + + def vote_resample_labelmap( + self, + 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. + + This reproduces ITK's ``LabelImageGaussianInterpolateImageFunction`` + on the GPU, so labels stay whole numbers and keep sharing their walls. + Each output voxel takes the label with the largest Gaussian-weighted + vote, where a voxel's weight is the Gaussian's mass over it. The + weights separate along the axes, so every label's votes come from + three banded sums. Exact ties, which ITK settles by rounding noise, go + to the smaller label. + + 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. + """ + import torch + + spacing = np.asarray(labelmap_image.GetSpacing(), dtype=np.float64) + size = itk.size(labelmap_image) + sigma = spacing if sigma_mm is None else sigma_mm + output_size = [ + int((size[axis] - 1) * spacing[axis] / isotropic_spacing_mm) + 1 + for axis in range(3) + ] + banded_zyx = [ + self._vote_weights( + size[axis], + output_size[axis], + isotropic_spacing_mm / spacing[axis], + sigma[axis] / spacing[axis], + ) + for axis in (2, 1, 0) + ] + input_arr = itk.GetArrayViewFromImage(labelmap_image) + labels = torch.as_tensor(input_arr.astype(np.int32), device=self.device) + + output_shape = (output_size[2], output_size[1], output_size[0]) + best_vote = torch.full( + output_shape, -1.0, dtype=torch.float64, device=self.device + ) + best_label = torch.zeros(output_shape, dtype=torch.int32, device=self.device) + for label_id in torch.unique(labels).tolist(): + votes = (labels == label_id).to(torch.float64) + for axis, banded in enumerate(banded_zyx): + votes = self._banded_sum(votes, banded, axis) + wins = votes > best_vote + best_vote = torch.where(wins, votes, best_vote) + best_label = torch.where(wins, label_id, best_label) + + resampled = itk.GetImageFromArray( + np.ascontiguousarray(best_label.cpu().numpy().astype(input_arr.dtype)) + ) + resampled.SetSpacing([isotropic_spacing_mm] * 3) + resampled.SetOrigin(labelmap_image.GetOrigin()) + resampled.SetDirection(labelmap_image.GetDirection()) + return resampled + + def signed_distance_mm( + self, + labels: torch.Tensor, + spacing: Sequence[float], + label_id: int, + sigma_mm: float, + ) -> torch.Tensor: + """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. + + Like ITK's signed Maurer map, distances run to the voxels of the label + that touch a voxel outside it, so a voxel on the label's edge is at + distance zero. *labels* sits on the GPU and *spacing* is its voxel + pitch along each axis. + """ + import torch + from torch.nn import functional + + inside = labels != 0 if label_id == 0 else labels == label_id + neighborhood_minimum = -functional.max_pool3d( + -inside[None, None].to(torch.float32), 3, 1, 1 + )[0, 0] + edge = inside & (neighborhood_minimum < 0.5) + distance = torch.sqrt(self.squared_distance_to_features(edge, spacing)) + distance = torch.where( + edge, self._edge_depth_mm(labels.shape, spacing), distance + ) + distance = torch.where(inside, -distance, distance) + if sigma_mm > 0.0: + distance = self.gaussian_blur(distance, spacing, sigma_mm) + return -distance if label_id == 0 else distance diff --git a/src/monai_physio/process_physicsnemo.py b/src/monai_physio/process_physicsnemo.py index 8466d05f..91b6a89a 100644 --- a/src/monai_physio/process_physicsnemo.py +++ b/src/monai_physio/process_physicsnemo.py @@ -6,10 +6,11 @@ - :meth:`ProcessPhysicsNemo.import_meshgraphnet` - import PhysicsNeMo's ``MeshGraphNet``, reporting install faults clearly. -- :class:`SubjectManifest` / :meth:`ProcessPhysicsNemo.parse_manifest` - the - per-subject JSON manifest that lists a fitted reference mesh, a PCA - shape-parameter file, the name of the point-data array holding the training - targets, and the phase meshes that carry that array with their stages. +- :class:`SubjectManifest` / :meth:`ProcessPhysicsNemo.parse_manifest` / + :meth:`ProcessPhysicsNemo.write_manifest` - the per-subject JSON manifest + that lists a fitted reference mesh, a PCA shape-parameter file, the name of + the point-data array holding the training targets, and the phase meshes that + carry that array with their stages. - :meth:`ProcessPhysicsNemo.load_target_array` - read one phase's ``(n_points, n_target)`` target values out of a mesh's point data. - :meth:`ProcessPhysicsNemo.build_node_features` - the shared per-vertex @@ -43,6 +44,7 @@ import json import os from collections import OrderedDict +from collections.abc import Sequence from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any, cast @@ -263,6 +265,68 @@ def _resolve(value: str) -> Path: phases=phases, ) + @staticmethod + def write_manifest( + subject_id: str, + fitted_reference_mesh: Path, + pca_coefficients: Path, + phase_meshes: Sequence[Path], + stages: Sequence[float], + output_directory: Path, + target_array: str = "displacement", + ) -> Path: + """Write a subject manifest whose targets are per-point displacements. + + The inverse of :meth:`parse_manifest`, for the displacement model: each + phase mesh is copied into *output_directory* carrying *target_array* = + its points minus *fitted_reference_mesh*'s, which requires the two to + share one point ordering. Any other target is written the same way by + storing a different array on those copies. + + Args: + subject_id: Subject the manifest describes. + fitted_reference_mesh: Fitted reference mesh of the subject. + pca_coefficients: JSON file of its PCA shape parameters. + phase_meshes: One mesh per phase, in the reference mesh's topology. + stages: Each phase's normalized stage in ``[0, 1]``. + output_directory: Where the manifest and target meshes are written. + target_array: Point-data array holding the displacements. + + Returns: + Path of the written ``{subject_id}_manifest.json``. + """ + output_directory.mkdir(parents=True, exist_ok=True) + reference_points = np.asarray( + pv.read(str(fitted_reference_mesh)).points, dtype=np.float32 + ) + phases = [] + for phase_mesh_file, stage in zip(phase_meshes, stages, strict=True): + phase_mesh = pv.read(str(phase_mesh_file)) + phase_mesh.point_data[target_array] = ( + np.asarray(phase_mesh.points, dtype=np.float32) - reference_points + ) + target_file = ( + output_directory + / f"{phase_mesh_file.stem}_target{phase_mesh_file.suffix}" + ) + phase_mesh.save(str(target_file)) + phases.append({"mesh": str(target_file), "stage": float(stage)}) + manifest_file = output_directory / f"{subject_id}_manifest.json" + manifest_file.write_text( + json.dumps( + { + "subject_id": subject_id, + "fitted_reference_mesh": str(fitted_reference_mesh), + "pca_coefficients": str(pca_coefficients), + "target_array": target_array, + "phases": phases, + }, + indent=2, + ), + encoding="utf-8", + ) + return manifest_file + @staticmethod def load_pca_coefficients(path: Path) -> np.ndarray: """Load a PCA shape-parameter vector saved as a JSON list of floats.""" diff --git a/src/monai_physio/process_surface_mesh.py b/src/monai_physio/process_surface_mesh.py new file mode 100644 index 00000000..fd0482c7 --- /dev/null +++ b/src/monai_physio/process_surface_mesh.py @@ -0,0 +1,866 @@ +""" +Tools for creating and manipulating surface meshes. + +This module provides the :class:`ProcessSurfaceMesh` class: surfaces from +labelmaps and masks, surface remeshing, smoothing and combining, and the +conversions from surfaces back to images (masks, labelmaps, distance maps, +deformation fields). Tetrahedral meshes live in +:class:`ProcessVolumetricMesh`; the GPU primitives both use live in +:class:`ProcessMeshKernels`. +""" + +from __future__ import annotations + +import logging +from collections.abc import Mapping, Sequence +from typing import Any, Optional, cast + +import itk +import numpy as np +import pyacvd +import pyvista as pv +import trimesh + +from .monai_physio_base import MONAIPhysioBase +from .process_images import ProcessImages +from .process_mesh_kernels import ProcessMeshKernels +from .process_transforms import ProcessTransforms + + +class ProcessSurfaceMesh(MONAIPhysioBase): + """ + Tools for creating and manipulating surface meshes. + """ + + # 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 + + def __init__(self, log_level: int | str = logging.INFO): + """Initialize ProcessSurfaceMesh. + + Args: + log_level: Logging level (default: logging.INFO) + """ + super().__init__(class_name=self.__class__.__name__, log_level=log_level) + + self._image_tools = ProcessImages(log_level=log_level) + self._kernels = ProcessMeshKernels(log_level=log_level) + + 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, + label_ids: Optional[Sequence[int]] = None, + surface_reduction_rate: float = 0.0, + ) -> dict[int, pv.PolyData]: + """Extract each label's closed surface, conforming with its neighbors. + + Contouring each label on its own traces its voxel block edges: on + anisotropic data the result terraces at the slice pitch, and a wall + between two labels is meshed twice, in copies that do not match. This + extracts the labels together instead: + + 1. The labelmap is resampled onto an isotropic grid with a label-aware + Gaussian vote over a physical-space kernel rather than 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 goes 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 the margin between the + closest and the second closest map, negated where ``L`` is the + closest. 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, including where three + labels meet. + 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 at debug level. + + 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). + label_ids: Labels to extract; every other label is treated as + background. ``None`` extracts every label. To extract a union + of labels as one surface, relabel them to one id first. + surface_reduction_rate: Fraction of each surface's triangles to + remove by :meth:`remesh_and_smooth_surface` (0.0 disables). + Each label is remeshed on its own, so neighboring surfaces no + longer share their vertices; lower the triangle count with a + coarser *isotropic_spacing_mm* instead to keep them matching. + + Returns: + Label id → that label's closed, outward-oriented surface, whose + ``field_data['SegmentationLabelIds']`` holds the id. 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. + """ + import torch + + if label_ids is not None: + all_labels = itk.GetArrayViewFromImage(labelmap_image) + selected = itk.GetImageFromArray( + np.where(np.isin(all_labels, label_ids), all_labels, 0) + ) + selected.CopyInformation(labelmap_image) + labelmap_image = selected + labels = itk.GetArrayViewFromImage(labelmap_image) + present, voxel_counts = torch.unique( + torch.as_tensor(labels.astype(np.int32), device=self._kernels.device), + return_counts=True, + ) + label_voxels = dict(zip(present.tolist(), voxel_counts.tolist())) + present_ids = [value for value in label_voxels if value != 0] + if not present_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 + # 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. + cropped = self._image_tools.crop_to_nonzero( + labelmap_image, self.LABEL_SURFACE_PAD + ) + fine = self._kernels.vote_resample_labelmap(cropped, iso) + fine_labels = torch.as_tensor( + itk.GetArrayViewFromImage(fine).astype(np.int32), + device=self._kernels.device, + ) + fine_spacing = [iso] * 3 + + # Pass one: the closest and second closest label at every voxel, one + # distance map at a time, so memory does not grow with the label count. + closest = torch.full(fine_labels.shape, torch.inf, device=self._kernels.device) + runner_up = torch.full_like(closest, torch.inf) + closest_index = torch.zeros( + fine_labels.shape, dtype=torch.int16, device=self._kernels.device + ) + for index, label_id in enumerate(present_ids + [0]): + distance = self._kernels.signed_distance_mm( + fine_labels, fine_spacing, label_id, sigma + ) + is_closer = distance < closest + runner_up = torch.where( + is_closer, closest, torch.minimum(runner_up, distance) + ) + closest = torch.where(is_closer, distance, closest) + closest_index = torch.where(is_closer, index, closest_index) + + # Pass two: one surface per label, tagged so the merged mesh can be + # split again after smoothing. Label L's field is the margin between + # the closest and second closest maps, negative where L is closest. On + # a wall between L and M that field is the negation of M's, so marching + # cubes puts identical vertices on both surfaces; it does so where three + # labels meet too, since only the two closest labels ever enter it. + margin = runner_up - closest + parts: list[pv.PolyData] = [] + for index, label_id in enumerate(present_ids): + field = itk.GetImageFromArray( + np.ascontiguousarray( + torch.where(closest_index == index, -margin, margin).cpu().numpy() + ) + ) + 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 present_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. + for array_name in ("LabelId", "vtkOriginalCellIds", "vtkOriginalPointIds"): + surface.cell_data.pop(array_name, None) + surface.point_data.pop(array_name, None) + if surface_reduction_rate > 0.0: + surface = self.remesh_and_smooth_surface( + surface, surface_reduction_rate + ) + # VTK winds faces for a right-handed direction matrix, so an LPS + # image with a negative-determinant direction comes out inward. + surface = surface.compute_normals( + auto_orient_normals=True, consistent_normals=True + ) + surface.field_data["SegmentationLabelIds"] = np.array( + [label_id], dtype=np.int32 + ) + surfaces[label_id] = surface + if self.logger.isEnabledFor(logging.DEBUG): + self.log_debug( + "Label %d: %d triangles, %.3f of its voxel volume", + label_id, + surfaces[label_id].n_cells, + float(surfaces[label_id].volume) + / (label_voxels[label_id] * voxel_volume), + ) + return surfaces + + @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)) + + 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 ``SegmentationLabelIds`` (which structure + each triangle belongs to, as :meth:`combine_surfaces` writes it) is + transferred back onto the new cells from their nearest original cell so + anatomy materials and structure ids still apply. 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 combined surface the walls where structures meet + are 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 + triangulated = conditioned.triangulate() + # ACVD's clustering graph assumes a single connected component; a + # surface merged from disjoint parts (e.g. left and right lung, + # merged by combine_surfaces) crashes + # pyacvd's native clustering step otherwise. Remesh each connected + # component on its own and recombine. + regions = triangulated.connectivity(extraction_mode="all") + remeshed_pieces = [] + for region_id in np.unique(regions.cell_data["RegionId"]): + piece = regions.threshold( + (region_id, region_id), scalars="RegionId", preference="cell" + ).extract_surface(algorithm="geometry") + clustering = pyacvd.Clustering(piece) + # 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(piece.n_points * (1.0 - surface_reduction_rate))) + ) + remeshed_pieces.append(clustering.create_mesh()) + conditioned = pv.merge(remeshed_pieces) + if "SegmentationLabelIds" in original.cell_data: + nearest = original.find_closest_cell(conditioned.cell_centers().points) + conditioned.cell_data["SegmentationLabelIds"] = np.asarray( + original.cell_data["SegmentationLabelIds"] + )[nearest] + if smoothing_iterations > 0: + conditioned = conditioned.smooth_taubin( + n_iter=smoothing_iterations, non_manifold_smoothing=True + ) + return conditioned + + @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") + + @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 = ProcessTransforms().transform_pvcontour( + contours, tfm, with_deformation_magnitude=with_deformation_magnitude + ) + + return new_contours + + 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 + + @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 + + @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 + + 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 + + @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) + + 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 + + @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 = ProcessImages() + deformation_field_img = image_tools.convert_array_to_image_of_vectors( + deformation_field, reference_image, ptype=ptype + ) + + return deformation_field_img + + @staticmethod + def combine_surfaces(surfaces: Mapping[Any, pv.PolyData]) -> pv.PolyData: + """Merge *surfaces* into one mesh that still tells its structures apart. + + Each cell gains a ``SegmentationLabelIds`` (int32) entry naming the + label it came from, taken from its surface's single-entry + ``field_data['SegmentationLabelIds']``; a surface holding several ids, + such as a group surface, is tagged ``0``. :class:`ConvertVTKToUSD` + splits on that array, giving one prim and one anatomy material per + structure. The per-cell ``Color`` survives the merge; per-object + ``field_data`` (``AnatomyGroup``, ``SegmentationLabelNames``, + ``AnatomyColor``) cannot, since one mesh holds one value per key. + + Raises: + ValueError: If *surfaces* is empty. + """ + if not surfaces: + raise ValueError("No surfaces to combine.") + # 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") + label_id = ( + int(label_ids[0]) + if label_ids is not None and len(label_ids) == 1 + else 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) + return cast(pv.PolyData, pv.merge(tagged, merge_points=False)) diff --git a/src/monai_physio/process_tests.py b/src/monai_physio/process_tests.py index 42b98b72..964b4c25 100644 --- a/src/monai_physio/process_tests.py +++ b/src/monai_physio/process_tests.py @@ -415,8 +415,12 @@ def compare_result_to_baseline_image( diff_magnitude = np.abs(arr_result - arr_baseline) total_absolute_error = float(np.sum(diff_magnitude)) + pixel_error = diff_magnitude + if image_result.GetNumberOfComponentsPerPixel() > 1: + # A pixel counts once, however many of its channels exceed the tolerance. + pixel_error = diff_magnitude.max(axis=-1) number_of_pixels_above_tol = int( - np.sum(diff_magnitude > per_pixel_absolute_error_tol) + np.sum(pixel_error > per_pixel_absolute_error_tol) ) self._last_image_per_pixel_absolute_error_tol = per_pixel_absolute_error_tol @@ -493,7 +497,9 @@ def compare_result_to_baseline_metrics( relative_tol: Allowed fractional difference per value. absolute_tol: Allowed absolute difference per value. ignore_keys: Metric names to skip, for values that legitimately - differ between runs (wall-clock timings, host paths). + differ between runs (wall-clock timings, host paths). A name + matches a flattened key exactly or as its last component, so + a CSV column is skipped in every row. Returns: True if every compared value is within tolerance. @@ -524,7 +530,7 @@ def compare_result_to_baseline_metrics( failures: list[str] = [] for key in sorted(set(result_values) | set(baseline_values)): - if key in skip: + if key in skip or key.rsplit(".", 1)[-1] in skip: continue if key not in result_values: failures.append(f"{key}: missing from result") diff --git a/src/monai_physio/process_usd.py b/src/monai_physio/process_usd.py index b35cb2af..b96cf3b8 100644 --- a/src/monai_physio/process_usd.py +++ b/src/monai_physio/process_usd.py @@ -733,9 +733,18 @@ def merge_usd_files_flattened( self.log_info("Exporting to %s", output_filename) output_stage.Export(output_filename) + def open_stage(self, usd_file: str | Path) -> Usd.Stage: + """Open *usd_file* for editing; write it back with :meth:`save_stage`.""" + return Usd.Stage.Open(str(usd_file)) + + def save_stage(self, stage: Usd.Stage) -> None: + """Write *stage*'s edits back to the file it was opened from.""" + stage.Save() + self.log_info("Saved USD file: %s", stage.GetRootLayer().realPath) + def list_mesh_primvars( self, - stage_or_path: Usd.Stage | str, + stage: Usd.Stage, mesh_path: str, time_code: float | None = None, ) -> list[dict]: @@ -748,7 +757,7 @@ def list_mesh_primvars( the mesh for visualization. Args: - stage_or_path: USD Stage or path to USD file + stage: USD stage, e.g. from :meth:`open_stage` mesh_path: Path to mesh prim (e.g., "/World/Meshes/MyMesh") time_code: Optional time code to sample values. If None, uses default. @@ -763,16 +772,11 @@ def list_mesh_primvars( Example: >>> usd_tools = ProcessUSD() - >>> primvars = usd_tools.list_mesh_primvars("valve.usd", "/World/Meshes/Valve") + >>> stage = usd_tools.open_stage("valve.usd") + >>> primvars = usd_tools.list_mesh_primvars(stage, "/World/Meshes/Valve") >>> for pv in primvars: ... print(f"{pv['name']}: {pv['interpolation']}, {pv['elements']} elements") """ - # Open stage if needed - if isinstance(stage_or_path, str): - stage = Usd.Stage.Open(stage_or_path) - else: - stage = stage_or_path - # Get mesh prim mesh_prim = stage.GetPrimAtPath(mesh_path) if not mesh_prim.IsValid(): @@ -854,7 +858,7 @@ def pick_color_primvar( str | None: Name of selected primvar, or None if no candidates found Example: - >>> primvars = usd_tools.list_mesh_primvars("valve.usd", "/World/Meshes/Valve") + >>> primvars = usd_tools.list_mesh_primvars(stage, "/World/Meshes/Valve") >>> color_primvar = usd_tools.pick_color_primvar(primvars) >>> print(f"Selected for coloring: {color_primvar}") """ @@ -884,7 +888,7 @@ def sort_key(item: tuple[dict[str, Any], int]) -> tuple[int, int, str]: def apply_colormap_from_primvar( self, - stage_or_path: Usd.Stage | str, + stage: Usd.Stage, mesh_path: str, source_primvar: str, *, @@ -915,7 +919,7 @@ def apply_colormap_from_primvar( - Writes both default and time-sampled displayColor for Omniverse compatibility Args: - stage_or_path: USD Stage or path to USD file + stage: USD stage, e.g. from :meth:`open_stage`; not saved here mesh_path: Path to mesh prim (e.g., "/World/Meshes/MyMesh") source_primvar: Name of primvar to visualize (e.g., "vtk_cell_stress") cmap: Matplotlib colormap name (default: "viridis") @@ -932,12 +936,14 @@ def apply_colormap_from_primvar( Example: >>> usd_tools = ProcessUSD() + >>> stage = usd_tools.open_stage("valve.usd") >>> usd_tools.apply_colormap_from_primvar( - ... "valve.usd", + ... stage, ... "/World/Meshes/Valve", ... "vtk_cell_stress", ... cmap="plasma" ... ) + >>> usd_tools.save_stage(stage) """ # Check matplotlib availability try: @@ -948,14 +954,6 @@ def apply_colormap_from_primvar( "Install with: pip install matplotlib" ) - # Open stage if needed - if isinstance(stage_or_path, str): - stage = Usd.Stage.Open(stage_or_path) - stage_path = stage_or_path - else: - stage = stage_or_path - stage_path = None - # Get mesh prim mesh_prim = stage.GetPrimAtPath(mesh_path) if not mesh_prim.IsValid(): @@ -1171,14 +1169,9 @@ def apply_colormap_from_primvar( if bind_vertex_color_material: self._ensure_vertex_color_material(stage, mesh_prim) - # Save stage if we opened it from a path - if stage_path: - stage.Save() - self.log_info(f"Saved USD file: {stage_path}") - def set_solid_display_color( self, - stage_or_path: Usd.Stage | str, + stage: Usd.Stage, mesh_path: str, color: tuple[float, float, float], *, @@ -1193,7 +1186,7 @@ def set_solid_display_color( color material so the color is visible in Omniverse. Args: - stage_or_path: USD Stage or path to USD file + stage: USD stage, e.g. from :meth:`open_stage`; not saved here mesh_path: Path to mesh prim (e.g., "/World/Meshes/MyMesh") color: RGB tuple in [0, 1] (e.g., (1, 0, 0) for red) time_codes: If provided, set displayColor at each time. If None, set default only. @@ -1201,13 +1194,6 @@ def set_solid_display_color( """ from pxr import Gf, Sdf, Vt - if isinstance(stage_or_path, str): - stage = Usd.Stage.Open(stage_or_path) - stage_path = stage_or_path - else: - stage = stage_or_path - stage_path = None - mesh_prim = stage.GetPrimAtPath(mesh_path) if not mesh_prim.IsValid() or not mesh_prim.IsA(UsdGeom.Mesh): raise ValueError(f"Invalid mesh prim at path: {mesh_path}") @@ -1265,12 +1251,10 @@ def set_solid_display_color( if bind_vertex_color_material: self._ensure_vertex_color_material(stage, mesh_prim) - if stage_path: - stage.Save() self.log_info(f"Set solid displayColor on {mesh_path}") def list_mesh_paths_under( - self, stage_or_path: Usd.Stage | str, parent_path: str = "/World/Meshes" + self, stage: Usd.Stage, parent_path: str = "/World/Meshes" ) -> list[str]: """ List paths of all mesh prims at any depth under a parent path. @@ -1280,17 +1264,12 @@ def list_mesh_paths_under( ``/World/{basename}/{anatomy_group}/{structure}`` - are found too. Args: - stage_or_path: USD Stage or path to USD file + stage: USD stage, e.g. from :meth:`open_stage` parent_path: Parent prim path (default: /World/Meshes) Returns: List of mesh prim paths (e.g. ["/World/Meshes/Mesh0", "/World/Meshes/Mesh1"]) """ - if isinstance(stage_or_path, str): - stage = Usd.Stage.Open(stage_or_path) - else: - stage = stage_or_path - parent = stage.GetPrimAtPath(parent_path) if not parent.IsValid(): return [] @@ -1302,11 +1281,10 @@ def list_mesh_paths_under( def repair_mesh_primvar_element_sizes( self, - stage_or_path: Usd.Stage | str, + stage: Usd.Stage, mesh_path: str, *, time_code: float | None = None, - save: bool = True, ) -> dict: """ Repair missing/incorrect primvar elementSize metadata for a mesh. @@ -1321,16 +1299,11 @@ def repair_mesh_primvar_element_sizes( - For uniform primvars: infer elementSize if raw_len % n_faces == 0 - Only updates when inferred elementSize > 1 + The stage is not saved here; see :meth:`save_stage`. + Returns: dict with keys: updated (list), skipped (list) """ - if isinstance(stage_or_path, str): - stage = Usd.Stage.Open(stage_or_path) - stage_path = stage_or_path - else: - stage = stage_or_path - stage_path = None - mesh_prim = stage.GetPrimAtPath(mesh_path) if not mesh_prim.IsValid() or not mesh_prim.IsA(UsdGeom.Mesh): raise ValueError(f"Invalid mesh prim at path: {mesh_path}") @@ -1411,10 +1384,6 @@ def repair_mesh_primvar_element_sizes( {"name": pv.GetName(), "reason": f"SetElementSize failed: {e}"} ) - if stage_path and save: - stage.Save() - self.log_info(f"Saved USD file: {stage_path}") - return {"updated": updated, "skipped": skipped} def _uniform_to_vertex_scalar( diff --git a/src/monai_physio/process_usd_anatomy.py b/src/monai_physio/process_usd_anatomy.py index fe5ab3a6..ca0ec177 100644 --- a/src/monai_physio/process_usd_anatomy.py +++ b/src/monai_physio/process_usd_anatomy.py @@ -29,14 +29,20 @@ :class:`monai_physio.AnatomyTaxonomy` will still render *something*. """ +from __future__ import annotations + import logging -from collections.abc import Mapping -from typing import Any, Optional +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, Optional +import numpy as np from pxr import Sdf, UsdGeom, UsdShade from .monai_physio_base import MONAIPhysioBase +if TYPE_CHECKING: + import pyvista as pv + # Brain grey matter: cortical gyri and deep nuclei alike. Shared by the # brain_parcellation group entry and the "basal_forebrain" override below, # which exists only to keep the basal forebrain off the whole-organ "brain" @@ -893,6 +899,53 @@ def get_anatomy_diffuse_color( color = params["diffuse_reflection_color"] return (float(color[0]), float(color[1]), float(color[2])) + def select_anatomy_type(self, anatomy_names: Sequence[str]) -> str: + """Return the first of *anatomy_names* that resolves, else ``"other"``. + + Args: + anatomy_names: Names tried in order, most specific first, e.g. an + organ name followed by its anatomy group. Some organs carry + their own entry (``myocardium``) and others do not + (``left_ventricle``), so a group name is the usual second entry. + + Returns: + The selected name, or ``"other"`` (with a warning) when none of + *anatomy_names* resolves. + """ + for name in anatomy_names: + if self.resolve_anatomy_type(name) is not None: + return name + self.log_warning( + "No anatomy entry matches %s; using 'other'", " or ".join(anatomy_names) + ) + return "other" + + def apply_anatomy_color( + self, mesh: pv.DataSet, anatomy_names: Sequence[str] + ) -> None: + """Attach the color :meth:`select_anatomy_type` picks to *mesh* **in-place**. + + Sets, so the mesh 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 x 4). + + Args: + mesh: Surface or volume mesh to annotate. + anatomy_names: Names tried in order, most specific first; see + :meth:`select_anatomy_type`. + """ + color_rgb = self.get_anatomy_diffuse_color( + self.select_anatomy_type(anatomy_names) + ) + 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)) + def apply_anatomy_material_to_mesh(self, mesh_path: str, anatomy_type: str) -> None: """Apply an anatomic OmniSurface material to a single mesh prim by type. diff --git a/src/monai_physio/process_volumetric_mesh.py b/src/monai_physio/process_volumetric_mesh.py new file mode 100644 index 00000000..34d827f9 --- /dev/null +++ b/src/monai_physio/process_volumetric_mesh.py @@ -0,0 +1,402 @@ +""" +Tools for creating and relaxing tetrahedral (volumetric) meshes. + +This module provides the :class:`ProcessVolumetricMesh` class: tetrahedral +meshes filling a binary mask, their relaxation onto a smooth surface, and the +repair of inverted elements. Surfaces come from :class:`ProcessSurfaceMesh`; +the GPU primitives live in :class:`ProcessMeshKernels`. +""" + +from __future__ import annotations + +import logging +from collections.abc import Sequence +from typing import ClassVar, Optional, cast + +import itk +import numpy as np +import pyvista as pv +from scipy import sparse + +from .monai_physio_base import MONAIPhysioBase +from .process_images import ProcessImages +from .process_mesh_kernels import ProcessMeshKernels +from .process_usd_anatomy import ProcessUSDAnatomy + + +class ProcessVolumetricMesh(MONAIPhysioBase): + """ + Tools for creating and relaxing tetrahedral meshes. + + Example: + >>> surfaces = ProcessSurfaceMesh().extract_label_surfaces(mask) + >>> volume_tools = ProcessVolumetricMesh() + >>> mesh = volume_tools.extract_tetrahedra(mask, element_size_mm=1.5) + >>> mesh = volume_tools.trim_tetrahedra_to_surface(mesh, surfaces[1]) + """ + + # 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: ClassVar[list[int]] = [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: ClassVar[list[int]] = [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 + + def __init__(self, log_level: int | str = logging.INFO): + """Initialize ProcessVolumetricMesh. + + Args: + log_level: Logging level (default: logging.INFO) + """ + super().__init__(class_name=self.__class__.__name__, log_level=log_level) + # ProcessUSDAnatomy builds its color tables in __init__ without touching + # the stage, so stage=None is safe for this lookup-only use. + self._anatomy_tools = ProcessUSDAnatomy(stage=None, log_level=log_level) + self._image_tools = ProcessImages(log_level=log_level) + self._kernels = ProcessMeshKernels(log_level=log_level) + + 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:`ProcessSurfaceMesh.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:`ProcessUSDAnatomy.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. + """ + if not (itk.GetArrayViewFromImage(mask_image) != 0).any(): + self.log_warning("Mask holds no voxel to mesh; its mesh is empty") + return pv.UnstructuredGrid() + # Cropping only translates the image, so the direction is unchanged and + # the winding correction below still applies after any resampling. + cropped = self._image_tools.crop_to_nonzero(mask_image) + cropped_arr = itk.GetArrayViewFromImage(cropped) != 0 + spacing = np.asarray(mask_image.GetSpacing(), dtype=np.float64) + direction = itk.array_from_matrix(mask_image.GetDirection()) + + 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._kernels.vote_resample_labelmap( + 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 = ( + self.VOXEL_TO_HEX + if np.linalg.det(direction) > 0.0 + else self.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._anatomy_tools.apply_anatomy_color(tetrahedra, anatomy_names) + return tetrahedra + + @staticmethod + def _edge_endpoints(connectivity: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + """Return both directions of every tetrahedron edge as (starts, ends). + + A point's neighbors are then the ends of the rows its id starts. + """ + edges = np.vstack( + [ + connectivity[:, pair] + for pair in ((0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)) + ] + ) + return ( + np.concatenate([edges[:, 0], edges[:, 1]]), + np.concatenate([edges[:, 1], edges[:, 0]]), + ) + + 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:`ProcessSurfaceMesh.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. + """ + surface_mesh = self._kernels.build_surface_mesh(surface) + # A crinkle clip keeps every cell with a point inside the surface, whole, + # so this only discards, never subdivides. + inside = self._kernels.points_inside_surface( + surface_mesh, np.asarray(tetrahedra.points) + ) + kept = inside[tetrahedra.cells.reshape(-1, 5)[:, 1:]].any(axis=1) + crinkled = tetrahedra.copy(deep=False) + scalars = crinkled.active_scalars_info + crinkled.cell_data["cell_ids"] = np.arange(tetrahedra.n_cells, dtype=np.int64) + relaxed = cast( + pv.UnstructuredGrid, + crinkled.extract_cells(kept, pass_cell_ids=False, pass_point_ids=False), + ) + if relaxed.n_cells == 0: + self.log_warning("Trimming against the surface removed every cell") + return relaxed + relaxed.set_active_scalars( + scalars.name, + preference=scalars.association, # type: ignore[arg-type] + ) + + connectivity = cast(np.ndarray, relaxed.cells_dict[np.uint8(pv.CellType.TETRA)]) + starts, ends = self._edge_endpoints(connectivity) + point_count = relaxed.n_points + neighbor_counts = np.bincount(starts, minlength=point_count).clip(1) + neighbor_sums = sparse.csr_matrix( + (np.ones(len(starts)), (starts, ends)), shape=(point_count, point_count) + ) + boundary_ids = np.asarray( + relaxed.extract_surface(algorithm="dataset_surface").point_data[ + "vtkOriginalPointIds" + ] + ) + + import torch + + gpu_connectivity = torch.as_tensor( + connectivity.astype(np.int64), device=self._kernels.device + ) + points = np.array(relaxed.points) + for _ in range(iterations): + target = (neighbor_sums @ points) / neighbor_counts[:, np.newaxis] + target[boundary_ids] = self._kernels.closest_points_on_surface( + surface_mesh, target[boundary_ids] + ) + step = torch.as_tensor( + relaxation * (target - points), device=self._kernels.device + ) + gpu_points = torch.as_tensor( + points, dtype=torch.float64, device=self._kernels.device + ) + + # 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 = torch.ones( + point_count, dtype=torch.float64, device=self._kernels.device + ) + while True: + moved = gpu_points + damping[:, None] * step + below_bound = ( + self._kernels.scaled_jacobian(moved, gpu_connectivity) + < min_scaled_jacobian + ) + if not bool(below_bound.any()): + break + damped_ids = torch.unique(gpu_connectivity[below_bound]) + damped_ids = damped_ids[damping[damped_ids] > 0.0] + if damped_ids.numel() == 0: + break + halved = damping[damped_ids] * 0.5 + damping[damped_ids] = torch.where( + halved < self.MIN_TRIM_DAMPING, torch.zeros_like(halved), halved + ) + points = moved.cpu().numpy() + if iterations > 0: + relaxed.points = points + return relaxed + + def repair_inverted_tetrahedra( + self, + tetrahedra: pv.UnstructuredGrid, + max_iterations: int = 100, + ) -> pv.UnstructuredGrid: + """Relax the nodes of any inverted or degenerate tetrahedron. + + :meth:`trim_tetrahedra_to_surface` holds every cell of the *template* + above a scaled Jacobian of 0.1, but warping that template onto a + specific subject -- a statistical-model fit, say -- carries no such + constraint, and can flip a handful of elements even though the + template it started from was clean. Only the nodes touching a bad + element are moved, each to the mean of its mesh neighbors, which + leaves geometry the fit got right alone. + + Args: + tetrahedra: Volume mesh to repair; its cell and field data + survive. + max_iterations: Smoothing passes to attempt before giving up. + + Returns: + The repaired mesh, or *tetrahedra* unchanged if every cell + already clears zero volume. + + Raises: + ValueError: If *tetrahedra* has no TETRA cells, or if elements + are still inverted or degenerate after *max_iterations* + passes. + """ + if np.uint8(pv.CellType.TETRA) not in tetrahedra.cells_dict: + raise ValueError( + "tetrahedra has no TETRA cells to repair; got cell types " + f"{sorted(tetrahedra.cells_dict)}." + ) + connectivity = cast( + np.ndarray, tetrahedra.cells_dict[np.uint8(pv.CellType.TETRA)] + ) + + def volumes(points: np.ndarray) -> np.ndarray: + corners = points[connectivity] + edges = corners[:, 1:, :] - corners[:, 0:1, :] + return cast(np.ndarray, np.linalg.det(edges) / 6.0) + + points = np.array(tetrahedra.points) + n_inverted = int(np.sum(volumes(points) <= 0.0)) + if n_inverted == 0: + return tetrahedra + + starts, ends = self._edge_endpoints(connectivity) + order = np.argsort(starts, kind="stable") + sorted_starts = starts[order] + sorted_ends = ends[order] + split_points = np.searchsorted(sorted_starts, np.arange(len(points) + 1)) + neighbors = { + node: np.unique(sorted_ends[split_points[node] : split_points[node + 1]]) + for node in np.unique(connectivity) + } + + for _ in range(max_iterations): + bad = volumes(points) <= 0.0 + if not np.any(bad): + break + snapshot = points.copy() + for node in np.unique(connectivity[bad]): + neighbor_points = snapshot[neighbors[node]] + if len(neighbor_points): + points[node] = neighbor_points.mean(axis=0) + + final_volumes = volumes(points) + still_bad_mask = final_volumes <= 0.0 + still_bad = int(np.sum(still_bad_mask)) + if still_bad: + _MAX_LISTED = 20 + bad_cell_ids = np.nonzero(still_bad_mask)[0] + details = "; ".join( + f"cell {cell_id} (nodes {connectivity[cell_id].tolist()}): " + f"volume={final_volumes[cell_id]:.3e}" + for cell_id in bad_cell_ids[:_MAX_LISTED] + ) + if len(bad_cell_ids) > _MAX_LISTED: + details += f"; ... and {len(bad_cell_ids) - _MAX_LISTED} more" + raise ValueError( + f"{still_bad} of {len(connectivity)} tetrahedra are still " + f"inverted or degenerate after {max_iterations} repair " + f"passes ({details}); the fitted mesh needs a real re-fit, " + "not just smoothing." + ) + + self.log_warning( + "%d of %d tetrahedra were inverted or degenerate; repaired by " + "relaxing their nodes onto their neighbors' mean.", + n_inverted, + len(connectivity), + ) + repaired = tetrahedra.copy() + repaired.points = points + return repaired diff --git a/src/monai_physio/register_images_greedy.py b/src/monai_physio/register_images_greedy.py index a77460dc..6cebc6ce 100644 --- a/src/monai_physio/register_images_greedy.py +++ b/src/monai_physio/register_images_greedy.py @@ -24,6 +24,10 @@ from .process_transforms import ProcessTransforms from .register_images_base import RegisterImagesBase +# Greedy jitters its affine sample points, and its default seed (0) draws a new +# random seed every run; a fixed seed makes registrations reproducible. +_GREEDY_COMMAND_PREFIX = "-d 3 -seed 1" + def _try_import_greedy() -> Any: """Import picsl_greedy; raise with helpful message if missing.""" @@ -313,7 +317,7 @@ def _registration_method_affine_or_rigid( Greedy3D = _try_import_greedy() g = Greedy3D() - cmd = "-d 3" + cmd = _GREEDY_COMMAND_PREFIX if fixed_labelmap_sitk is not None and moving_labelmap_sitk is not None: cmd += " -w 0.60" cmd += " -i fixed moving" @@ -359,7 +363,7 @@ def _registration_method_deformable( # Greedy seeds its own deformable stage with an affine pass, using the # configured metric. - cmd_aff = "-d 3" + cmd_aff = _GREEDY_COMMAND_PREFIX if fixed_labelmap_sitk is not None and moving_labelmap_sitk is not None: cmd_aff += " -w 0.60 -sv" cmd_aff += " -i fixed moving" @@ -385,7 +389,7 @@ def _registration_method_deformable( # Greedy crashes (heap corruption) when the affine init is passed as an # in-memory matrix via -it; write it to a temp file and pass the path. initial_affine_file = self._write_affine_matrix_file(initial_affine) - cmd_def = "-d 3" + cmd_def = _GREEDY_COMMAND_PREFIX if fixed_labelmap_sitk is not None and moving_labelmap_sitk is not None: cmd_def += " -w 0.60" cmd_def += " -i fixed moving" diff --git a/src/monai_physio/register_models_distance_maps.py b/src/monai_physio/register_models_distance_maps.py index 0bac5758..0342b453 100644 --- a/src/monai_physio/register_models_distance_maps.py +++ b/src/monai_physio/register_models_distance_maps.py @@ -49,8 +49,8 @@ import pyvista as pv from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours from .process_labelmaps import ProcessLabelmaps +from .process_surface_mesh import ProcessSurfaceMesh from .process_transforms import ProcessTransforms from .register_images_greedy import RegisterImagesGreedy from .register_images_icon import RegisterImagesICON @@ -88,7 +88,7 @@ class RegisterModelsDistanceMaps(MONAIPhysioBase): mask_dilation_mm (float): Dilation amount in mm for binary registration masks distance_squared_max (float): Maximum squared distance for distance map normalization transform_tools (ProcessTransforms): Transform utility instance - contour_tools (ProcessContours): Model utility instance + contour_tools (ProcessSurfaceMesh): Model utility instance registrar_Greedy (RegisterImagesGreedy): Greedy registration instance registrar_ICON (RegisterImagesICON): ICON registration instance fixed_to_moving_transform (itk.CompositeTransform): Optimized fixed-to-moving transform @@ -156,7 +156,7 @@ def __init__( # Utilities self.transform_tools = ProcessTransforms() - self.contour_tools = ProcessContours() + self.contour_tools = ProcessSurfaceMesh() self.labelmap_tools = ProcessLabelmaps(log_level=log_level) # Registration instances diff --git a/src/monai_physio/register_models_icp_itk.py b/src/monai_physio/register_models_icp_itk.py index 691c5ed1..ca538010 100644 --- a/src/monai_physio/register_models_icp_itk.py +++ b/src/monai_physio/register_models_icp_itk.py @@ -7,7 +7,7 @@ from scipy.optimize import minimize from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours +from .process_surface_mesh import ProcessSurfaceMesh from .process_transforms import ProcessTransforms @@ -81,7 +81,7 @@ def __init__( self.final_mean_distance = 0 # Transform utilities - self._contour_tools = ProcessContours() + self._contour_tools = ProcessSurfaceMesh() self._transform_tools = ProcessTransforms() # Image interpolator (created when needed) diff --git a/src/monai_physio/register_models_pca.py b/src/monai_physio/register_models_pca.py index 1a94a045..e37428a7 100644 --- a/src/monai_physio/register_models_pca.py +++ b/src/monai_physio/register_models_pca.py @@ -13,7 +13,7 @@ from scipy.spatial import cKDTree from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours +from .process_surface_mesh import ProcessSurfaceMesh from .process_transforms import ProcessTransforms @@ -180,7 +180,7 @@ def __init__( self.post_pca_transform = post_pca_transform - self._contour_tools = ProcessContours() + self._contour_tools = ProcessSurfaceMesh() self.fixed_model: Optional[pv.DataSet] = fixed_model self.fixed_distance_map = fixed_distance_map diff --git a/src/monai_physio/train_physicsnemo_physics_informed_motion.py b/src/monai_physio/train_physicsnemo_physics_informed_motion.py index d5dcacde..efe82163 100644 --- a/src/monai_physio/train_physicsnemo_physics_informed_motion.py +++ b/src/monai_physio/train_physicsnemo_physics_informed_motion.py @@ -39,8 +39,8 @@ import pyvista as pv from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours from .process_physicsnemo import DistributedContext, PhaseSampleDataset +from .process_volumetric_mesh import ProcessVolumetricMesh from .train_physicsnemo_mgn import TrainPhysicsNeMoMGN if TYPE_CHECKING: # typed for mypy; imported lazily at runtime @@ -98,7 +98,7 @@ def tet_volumes(points: np.ndarray, tets: np.ndarray) -> tuple[np.ndarray, np.nd Raises: ValueError: If any element is inverted or degenerate. Templates come - from :meth:`monai_physio.ProcessContours.trim_tetrahedra_to_surface`, + from :meth:`monai_physio.ProcessVolumetricMesh.trim_tetrahedra_to_surface`, which holds every cell above a scaled Jacobian of 0.1, so a violation here means the template is broken rather than merely tight. @@ -726,7 +726,7 @@ def _bind_reference_meshes( assert self._tets is not None self._reference_cache = {} device = context.device - contour_tools = ProcessContours(log_level=self.log_level) + volume_tools = ProcessVolumetricMesh(log_level=self.log_level) for subject_id in sorted(set(self._sample_subjects)): mesh = cast( "pv.UnstructuredGrid", pv.read(str(self._reference_meshes[subject_id])) @@ -746,7 +746,7 @@ def _bind_reference_meshes( # elements inverted. tet_grid = pv.UnstructuredGrid({pv.CellType.TETRA: self._tets}, mesh.points) try: - repaired = contour_tools.repair_inverted_tetrahedra(tet_grid) + repaired = volume_tools.repair_inverted_tetrahedra(tet_grid) except ValueError as error: raise ValueError( f"Subject {subject_id!r} " diff --git a/src/monai_physio/workflow_convert_image_to_usd.py b/src/monai_physio/workflow_convert_image_to_usd.py index 68167515..116c55fb 100644 --- a/src/monai_physio/workflow_convert_image_to_usd.py +++ b/src/monai_physio/workflow_convert_image_to_usd.py @@ -17,8 +17,8 @@ from .convert_vtk_to_usd import ConvertVTKToUSD from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours from .process_images import ProcessImages +from .process_surface_mesh import ProcessSurfaceMesh from .process_transforms import ProcessTransforms from .process_usd_anatomy import ProcessUSDAnatomy from .register_images_base import RegisterImagesBase @@ -134,7 +134,7 @@ def __init__( os.makedirs(output_directory, exist_ok=True) # Initialize processing components - self.contour_tools = ProcessContours() + self.contour_tools = ProcessSurfaceMesh() self.image_tools = ProcessImages() self.transform_tools = ProcessTransforms() @@ -357,44 +357,29 @@ def _generate_reference_contours(self) -> None: assert self.reference_segmentation_results is not None, ( "reference segmentation results must be set" ) - labelmap = self.reference_segmentation_results["labelmap"] - - # Generate all anatomy contours - all_contours = self.contour_tools.extract_contours( - labelmap, surface_reduction_rate=self.surface_reduction_rate + surfaces = self.contour_tools.extract_label_surfaces( + self.reference_segmentation_results["labelmap"], + surface_reduction_rate=self.surface_reduction_rate, ) - self.reference_contours = { - "all": all_contours, - } + # One extraction, split by label, so the dynamic and static structures + # still share the walls between them. if len(self.dynamic_labelmap_ids) > 0: - dynamic_labelmap_arr = itk.GetArrayFromImage(labelmap) - dynamic_labelmap_arr = np.where( - np.isin(dynamic_labelmap_arr, self.dynamic_labelmap_ids), - dynamic_labelmap_arr, - 0, - ) - dynamic_labelmap = itk.GetImageFromArray(dynamic_labelmap_arr) - dynamic_labelmap.CopyInformation(labelmap) - dynamic_contours = self.contour_tools.extract_contours( - dynamic_labelmap, surface_reduction_rate=self.surface_reduction_rate - ) - - static_labelmap_arr = itk.GetArrayFromImage(labelmap) - static_labelmap_arr = np.where( - np.isin(static_labelmap_arr, self.dynamic_labelmap_ids), - 0, - static_labelmap_arr, - ) - static_labelmap = itk.GetImageFromArray(static_labelmap_arr) - static_labelmap.CopyInformation(labelmap) - static_contours = self.contour_tools.extract_contours( - static_labelmap, surface_reduction_rate=self.surface_reduction_rate - ) - - # Store reference contours - self.reference_contours["dynamic"] = dynamic_contours - self.reference_contours["static"] = static_contours + is_dynamic = { + label_id: label_id in self.dynamic_labelmap_ids for label_id in surfaces + } + self.reference_contours = { + "dynamic": self.contour_tools.combine_surfaces( + {i: s for i, s in surfaces.items() if is_dynamic[i]} + ), + "static": self.contour_tools.combine_surfaces( + {i: s for i, s in surfaces.items() if not is_dynamic[i]} + ), + } + else: + self.reference_contours = { + "all": self.contour_tools.combine_surfaces(surfaces) + } def _transform_all_contours(self) -> None: """Transform contours for all time points using registration transforms.""" diff --git a/src/monai_physio/workflow_convert_image_to_vtk.py b/src/monai_physio/workflow_convert_image_to_vtk.py index 71674bff..26f9be2a 100644 --- a/src/monai_physio/workflow_convert_image_to_vtk.py +++ b/src/monai_physio/workflow_convert_image_to_vtk.py @@ -1,10 +1,11 @@ -"""Workflow for segmenting a CT image and converting anatomy groups to VTK surfaces. +"""Workflow for turning a CT image or a labelmap into anatomy VTK surfaces. -The workflow segments a 3D CT image using a chosen backend, then extracts one -VTP surface per non-empty anatomy group. Each output surface carries anatomy -metadata and solid color from :class:`ProcessUSDAnatomy` as field and cell data -so that downstream tools (PyVista, Paraview, USD pipeline) can use them -directly. +The workflow segments a 3D CT image with a chosen backend, or takes a labelmap +that backend already produced, then extracts one closed surface per anatomy +group and, optionally, per structure, and a tetrahedral mesh of each. Each +output carries anatomy metadata and the :class:`ProcessUSDAnatomy` color as +field and cell data so that downstream tools (PyVista, Paraview, USD pipeline) +can use them directly. Typical usage:: @@ -17,20 +18,18 @@ ct = itk.imread("chest_ct.nii.gz") segmenter = SegmentChestTotalSegmentatorWithContrast() workflow = WorkflowConvertImageToVTK(segmentation_method=segmenter) - result = workflow.process(ct, surface_reduction_rate=0.5) - - # Combined single-file output (default) - ProcessContours.save_combined_surfaces(result["surfaces"], "./out/patient.vtp") - - # Per-group split output - ProcessContours.save_surfaces(result["surfaces"], "./out", prefix="patient") - - # Per-label split output (one VTP per individual anatomical structure) result = workflow.process(ct, extract_label_surfaces=True) - ProcessContours.save_surfaces(result["label_surfaces"], "./out", prefix="patient") + + # Per-label surfaces in one file, per-group surfaces one file each + WorkflowConvertImageToVTK.save_combined_surfaces( + result["label_surfaces"], "./out/patient_surfaces.vtp" + ) + WorkflowConvertImageToVTK.save_surfaces(result["surfaces"], "./out", prefix="patient") """ import logging +import os +from collections.abc import Mapping from typing import Any, Optional import itk @@ -38,16 +37,29 @@ import pyvista as pv from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours +from .process_surface_mesh import ProcessSurfaceMesh from .process_usd_anatomy import ProcessUSDAnatomy +from .process_volumetric_mesh import ProcessVolumetricMesh from .segment_anatomy_base import SegmentAnatomyBase from .segment_chest_total_segmentator_with_contrast import ( SegmentChestTotalSegmentatorWithContrast, ) +def _save_whole(dataset: pv.DataSet, path: str) -> None: + """Write *dataset* beside *path*, then rename it into place. + + A reader never sees a half-written file, even if the writing process dies + partway through, as a worker in a process pool can. + """ + root, suffix = os.path.splitext(path) + partial = f"{root}.partial{suffix}" + dataset.save(partial) + os.replace(partial, path) + + class WorkflowConvertImageToVTK(MONAIPhysioBase): - """Segment a CT image and produce per-anatomy-group VTK surfaces. + """Turn a CT image or a labelmap into per-group and per-label VTK surfaces. ``segmentation_method`` accepts a pre-configured :class:`SegmentAnatomyBase` instance (e.g. :class:`SegmentChestTotalSegmentator`, @@ -55,34 +67,35 @@ class WorkflowConvertImageToVTK(MONAIPhysioBase): studies, :class:`SegmentHeartSimpleware`, or :class:`SegmentHeartSimplewareTrimmedBranches` for cardiac-only segmentation with pulmonary/great-vessel branches trimmed). Defaults to - a new :class:`SegmentChestTotalSegmentator` when omitted. + a new :class:`SegmentChestTotalSegmentatorWithContrast` when omitted. Its + taxonomy names the labels and groups, so it is also needed when + :meth:`process` is given a labelmap rather than an image. - **Output anatomy groups** + **Surfaces** - Determined by the active segmenter's :attr:`SegmentAnatomyBase.taxonomy` - (see :attr:`anatomy_groups`). Groups that are empty after segmentation - are silently skipped. Pass ``extract_label_surfaces=True`` to - :meth:`process` to additionally extract one surface per individual - structure (label) within each group, e.g. ``left_ventricle`` separately - from ``right_ventricle`` within ``heart``. + Every surface is closed and outward-oriented, from + :meth:`ProcessSurfaceMesh.extract_label_surfaces`. The per-label surfaces + are extracted together, so neighbors share the wall between them; the group + surfaces are extracted together in a second pass, so neighboring groups do + too. Groups default to the segmenter's taxonomy (see + :attr:`anatomy_groups`); pass ``groups`` to :meth:`process` for others, such + as a heart without its chambers. Empty groups and labels are skipped. **VTK object annotation** - Each :class:`pyvista.PolyData` surface returned by :meth:`process` carries: + Each surface and tetrahedral mesh returned by :meth:`process` carries: - ``field_data['AnatomyGroup']`` - anatomy group name, e.g. ``'heart'``. - - ``field_data['SegmentationLabelNames']`` - individual structure names within the - group (e.g. ``['left_ventricle', 'right_ventricle', ...]``). + - ``field_data['SegmentationLabelNames']`` - structure names it holds + (e.g. ``['left_ventricle', 'right_ventricle', ...]``). - ``field_data['SegmentationLabelIds']`` - corresponding integer label IDs. - ``field_data['AnatomyColor']`` - RGB float color from :class:`ProcessUSDAnatomy`. - ``cell_data['Color']`` - RGBA uint8 array (n_cells x 4) for direct VTK rendering. **I/O contract** - :meth:`process` performs *no* file I/O. Use - :class:`ProcessContours`'s static helpers - :meth:`ProcessContours.save_surfaces` and - :meth:`ProcessContours.save_combined_surfaces` - or the CLI + :meth:`process` performs *no* file I/O. Use :meth:`save_surfaces` and + :meth:`save_combined_surfaces` - or the CLI ``monai-physio-convert-image-to-vtk`` - to write results to disk. """ @@ -113,7 +126,11 @@ def __init__( "segmentation_method must be a SegmentAnatomyBase instance or None" ) self._segmenter: SegmentAnatomyBase = segmentation_method - self._contour_tools: ProcessContours = ProcessContours(log_level=log_level) + self._surface_tools = ProcessSurfaceMesh(log_level=log_level) + self._volume_tools = ProcessVolumetricMesh(log_level=log_level) + # ProcessUSDAnatomy builds its color tables in __init__ without touching + # the stage, so stage=None is safe for this lookup-only use. + self._anatomy_tools = ProcessUSDAnatomy(stage=None, log_level=log_level) #: Anatomy group names registered by the active segmenter's taxonomy, #: in the order they were first added. @@ -121,146 +138,121 @@ def __init__( self._segmenter.taxonomy.group_names() ) - # Build anatomy-group → RGB color from ProcessUSDAnatomy. - # ProcessUSDAnatomy sets up its color dicts entirely in __init__ without - # accessing the stage, so stage=None is safe for this lookup-only use. - _anatomy_tools = ProcessUSDAnatomy(stage=None, log_level=log_level) - supported_types = set(_anatomy_tools.get_anatomy_types()) - self._anatomy_color_map: dict[str, tuple[float, float, float]] = { - group: _anatomy_tools.get_anatomy_diffuse_color(group) - for group in self.anatomy_groups - if group in supported_types - } - # ─────────────────────────── Internal helpers ────────────────────────── - def _get_label_info_for_group(self, group: str) -> tuple[list[str], list[int]]: - """Return ``(label_names, label_ids)`` for *group* from the active segmenter. - - Reads the segmenter's :class:`AnatomyTaxonomy`. Returns empty lists if - the group is not present (e.g. HeartSimpleware does not register - lung/bone). - """ - mask_ids = self._segmenter.taxonomy.labels_in_group(group) - return list(mask_ids.values()), list(mask_ids.keys()) - - @staticmethod def _annotate( + self, vtk_obj: pv.DataSet, group: str, label_names: list[str], label_ids: list[int], - color_rgb: tuple[float, float, float], ) -> None: - """Attach anatomy metadata and solid RGBA color to a VTK object **in-place**. + """Attach anatomy metadata and solid color to a VTK object **in-place**. - Sets: - - - ``field_data['AnatomyGroup']`` - group name. - - ``field_data['SegmentationLabelNames']`` - individual label names. - - ``field_data['SegmentationLabelIds']`` - integer label IDs (int32). - - ``field_data['AnatomyColor']`` - RGB float32 color. - - ``cell_data['Color']`` - RGBA uint8 solid color (n_cells x 4). + Sets ``field_data['AnatomyGroup']``, ``field_data['SegmentationLabelNames']`` + and ``field_data['SegmentationLabelIds']`` (int32), then the color + :meth:`ProcessUSDAnatomy.apply_anatomy_color` picks for the most specific + name: the label for a single-label object, otherwise the group. """ vtk_obj.field_data["AnatomyGroup"] = np.array([group]) vtk_obj.field_data["SegmentationLabelNames"] = np.array( label_names if label_names else [group] ) vtk_obj.field_data["SegmentationLabelIds"] = np.array(label_ids, dtype=np.int32) - vtk_obj.field_data["AnatomyColor"] = np.array(color_rgb, dtype=np.float32) - - r, g, b = color_rgb - rgba = np.array([int(r * 255), int(g * 255), int(b * 255), 255], dtype=np.uint8) - if vtk_obj.n_cells > 0: - vtk_obj.cell_data["Color"] = np.tile(rgba, (vtk_obj.n_cells, 1)) - - def _extract_surface(self, mask_image: Any) -> Optional[pv.PolyData]: - """Extract a smoothed triangulated surface (VTP) from a binary mask image. - - Delegates to :meth:`ProcessContours.extract_contours`. - - Returns: - Smoothed :class:`pyvista.PolyData`, or ``None`` if the mask is empty. + names = [label_names[0], group] if len(label_names) == 1 else [group] + self._anatomy_tools.apply_anatomy_color(vtk_obj, names) + + def _tetrahedra( + self, mask: itk.Image, surface: pv.PolyData, element_size_mm: float + ) -> Optional[pv.UnstructuredGrid]: + """Return *mask*'s tetrahedral mesh relaxed onto *surface*, or ``None``. + + Resampling to the element size eats a structure thinner than it, so + the size is halved until the mesh holds most of the surface's volume + or is as fine as the mask itself. ``None`` means the structure is + thinner than one element even then. """ - arr = itk.GetArrayFromImage(mask_image) - if int(arr.sum()) == 0: + finest_spacing = float(np.min(np.asarray(mask.GetSpacing()))) + while True: + mesh = self._volume_tools.extract_tetrahedra(mask, element_size_mm) + volume = float(np.sum(mesh.compute_cell_sizes(volume=True)["Volume"])) + if volume > 0.5 * surface.volume or element_size_mm <= finest_spacing: + break + element_size_mm *= 0.5 + if mesh.n_cells == 0: return None - return self._contour_tools.extract_contours(mask_image) - - def _extract_label_surface( - self, labelmap_image: Any, labelmap_arr: np.ndarray, label_id: int - ) -> Optional[pv.PolyData]: - """Extract a smoothed triangulated surface for one individual label. - - Isolates *label_id* out of *labelmap_arr* into its own binary mask - before delegating to :meth:`ProcessContours.extract_contours`. + return self._volume_tools.trim_tetrahedra_to_surface(mesh, surface) - Args: - labelmap_image: Source image, used only for ``CopyInformation`` - (origin/spacing/direction) on the isolated label mask. - labelmap_arr: ``labelmap_image``'s voxel array. Callers extracting - multiple labels from the same labelmap (as :meth:`process` - does) should compute this once - e.g. via - ``itk.GetArrayViewFromImage`` - and pass the same array to - every call, rather than re-deriving it per label. - label_id: Integer label id to isolate. - - Returns: - Smoothed :class:`pyvista.PolyData`, or ``None`` if *label_id* has - no voxels in *labelmap_arr*. - """ - label_mask_arr = (labelmap_arr == label_id).astype(np.uint8) - if int(label_mask_arr.sum()) == 0: - return None - label_mask = itk.GetImageFromArray(label_mask_arr) - label_mask.CopyInformation(labelmap_image) - return self._contour_tools.extract_contours(label_mask) + @staticmethod + def _mask(keep: np.ndarray, like: itk.Image) -> itk.Image: + """Return *keep* as a binary mask with *like*'s geometry.""" + mask = itk.GetImageFromArray(keep.astype(np.uint8)) + mask.CopyInformation(like) + return mask # ─────────────────────────── Main workflow ───────────────────────────── def process( self, - input_image: Any, + input_image: Optional[itk.Image] = None, anatomy_groups: Optional[list[str]] = None, surface_reduction_rate: float = 0.0, extract_label_surfaces: bool = False, + *, + labelmap_image: Optional[itk.Image] = None, + extract_group_surfaces: bool = True, + groups: Optional[Mapping[str, list[int]]] = None, + isotropic_spacing_mm: Optional[float] = None, + smoothing_iterations: int = 30, + tetrahedra_element_size_mm: Optional[float] = None, ) -> dict[str, Any]: - """Segment the CT image and extract per-anatomy-group VTK surfaces. + """Extract per-group and per-label surfaces from an image or a labelmap. Args: - input_image: Input 3D CT image (``itk.Image``). - anatomy_groups: Subset of anatomy groups to process. ``None`` (default) - processes all non-empty groups. Valid names are given by - :attr:`anatomy_groups`, derived from the active segmenter's - taxonomy. - surface_reduction_rate: Fraction in ``[0, 1)`` of surface - triangles to remove via ``decimate_pro(surface_reduction_rate, - preserve_topology=True)``. ``0.0`` (default) skips decimation. - Applied to both group and (when requested) label surfaces. - extract_label_surfaces: When ``True``, also extract one surface per - individual anatomical structure (label) within each processed - group - e.g. ``left_ventricle`` and ``right_ventricle`` - separately within the ``heart`` group - in addition to the - per-group surfaces. ``False`` (default) skips this and leaves - ``'label_surfaces'`` empty. + input_image: CT image to segment. Give this or *labelmap_image*. + anatomy_groups: Subset of the taxonomy's groups to process. + ``None`` (default) processes all of them. Valid names are given + by :attr:`anatomy_groups`. + surface_reduction_rate: Fraction in ``[0, 1)`` of each surface's + triangles to remove by ACVD remeshing. ``0.0`` (default) keeps + them all, and only then do neighboring surfaces share their + walls exactly; see + :meth:`ProcessSurfaceMesh.extract_label_surfaces`. + extract_label_surfaces: Also extract one surface per structure + (label) of the processed groups, e.g. ``left_ventricle`` and + ``right_ventricle`` separately within ``heart``. + labelmap_image: Labelmap produced by this workflow's segmenter, used + in place of segmenting *input_image*. + extract_group_surfaces: Extract one surface per group (default). + groups: Group name → label ids, each extracted as one surface. + ``None`` uses the processed taxonomy groups. The groups must not + share a label. + isotropic_spacing_mm: Grid pitch the surfaces are contoured on, + which sets their triangle count. ``None`` uses the labelmap's + finest spacing. + smoothing_iterations: Taubin smoothing iterations (0 disables). + tetrahedra_element_size_mm: When set, also build a tetrahedral mesh + of every surface at this element size, relaxed onto the surface. Returns: ``dict`` with the following keys: - - ``'surfaces'`` - ``dict[str, pv.PolyData]``: smoothed surface per group. - - ``'label_surfaces'`` - ``dict[str, pv.PolyData]``: smoothed surface per - individual label, populated only when *extract_label_surfaces* is True. - - ``'labelmap'`` - ``itk.Image``: detailed per-structure segmentation - labelmap from the segmenter. - - ``'segmentation_masks'`` - ``dict[str, itk.Image]``: per-group binary - masks used to produce the VTK objects. + - ``'surfaces'`` - group name → surface. + - ``'label_surfaces'`` - label name → surface; empty unless + *extract_label_surfaces*. + - ``'tetrahedra'`` / ``'label_tetrahedra'`` - group / label name → + tetrahedral mesh; empty unless *tetrahedra_element_size_mm*. + - ``'labelmap'`` - ``itk.Image``: per-structure labelmap. Raises: - ValueError: If any name in *anatomy_groups* is invalid. + ValueError: If not exactly one of *input_image* and *labelmap_image* + is given, if any name in *anatomy_groups* is invalid, or if two + *groups* share a label. """ self.log_section("STARTING IMAGE TO VTK WORKFLOW") - # Validate requested groups + if (input_image is None) == (labelmap_image is None): + raise ValueError("Give exactly one of input_image and labelmap_image") if anatomy_groups is not None: invalid = [g for g in anatomy_groups if g not in self.anatomy_groups] if invalid: @@ -268,74 +260,87 @@ def process( f"Unknown anatomy groups: {invalid}. " f"Valid: {list(self.anatomy_groups)}" ) - groups_to_process: list[str] = list(anatomy_groups) - else: - groups_to_process = list(self.anatomy_groups) - - # Run segmenter - self.log_info("Running segmenter: %s", type(self._segmenter).__name__) - - self.log_section("Running segmentation") - seg_result: dict[str, Any] = self._segmenter.segment(input_image) - - # A zero-copy view, computed once and shared across every label - # surface extracted below so a multi-group/multi-label run doesn't - # repeatedly re-copy the (often large) labelmap volume. - labelmap_arr = itk.GetArrayViewFromImage(seg_result["labelmap"]) - - # Extract VTK objects per anatomy group - self.log_section("Extracting VTK objects") + groups_to_process = list(anatomy_groups or self.anatomy_groups) + + if labelmap_image is None: + self.log_info("Running segmenter: %s", type(self._segmenter).__name__) + labelmap_image = self._segmenter.segment(input_image)["labelmap"] + labels = itk.GetArrayViewFromImage(labelmap_image) + + taxonomy = self._segmenter.taxonomy + label_names = taxonomy.all_labels() + present_ids = [int(value) for value in np.unique(labels) if value != 0] + processed_ids = [ + label_id + for label_id in present_ids + if taxonomy.group_for_id(label_id) in groups_to_process + ] + if groups is None: + groups = { + group: [i for i in processed_ids if taxonomy.group_for_id(i) == group] + for group in groups_to_process + } + group_ids = [label_id for ids in groups.values() for label_id in ids] + if len(group_ids) != len(set(group_ids)): + raise ValueError(f"Groups must not share a label: {dict(groups)}") + + extract_options: dict[str, Any] = { + "isotropic_spacing_mm": isotropic_spacing_mm, + "smoothing_iterations": smoothing_iterations, + "surface_reduction_rate": surface_reduction_rate, + } surfaces: dict[str, pv.PolyData] = {} label_surfaces: dict[str, pv.PolyData] = {} - seg_masks: dict[str, Any] = {} - - for group in groups_to_process: - if group not in seg_result: - self.log_warning( - "Group %s absent from segmentation result - skipping", group - ) - continue - - mask_image = seg_result[group] - if int(itk.GetArrayFromImage(mask_image).sum()) == 0: - self.log_info("Group %s is empty - skipping", group) - continue - - self.log_info("Processing anatomy group: %s", group) - seg_masks[group] = mask_image - - label_names, label_ids = self._get_label_info_for_group(group) - color = self._anatomy_color_map.get(group, (0.7, 0.7, 0.7)) - - self.log_info(" Extracting surface for: %s", group) - base_surface = self._extract_surface(mask_image) - if base_surface is None: - continue - - export_surface = base_surface - if surface_reduction_rate > 0.0: - export_surface = export_surface.decimate_pro( - surface_reduction_rate, preserve_topology=True - ) - self._annotate(export_surface, group, label_names, label_ids, color) - surfaces[group] = export_surface - - if extract_label_surfaces: - self.log_info(" Extracting label surfaces for: %s", group) - for label_id, label_name in zip(label_ids, label_names, strict=True): - label_surface = self._extract_label_surface( - seg_result["labelmap"], labelmap_arr, label_id + tetrahedra: dict[str, pv.UnstructuredGrid] = {} + label_tetrahedra: dict[str, pv.UnstructuredGrid] = {} + + if extract_group_surfaces: + self.log_section("Extracting group surfaces") + # One id per group, so the groups are extracted together and the + # wall between two groups is shared like the wall between labels. + group_map = np.zeros(labels.shape, dtype=np.uint16) + for index, ids in enumerate(groups.values(), start=1): + group_map[np.isin(labels, ids)] = index + group_image = itk.GetImageFromArray(group_map) + group_image.CopyInformation(labelmap_image) + extracted = self._surface_tools.extract_label_surfaces( + group_image, **extract_options + ) + for index, (group, ids) in enumerate(groups.items(), start=1): + if index not in extracted: + continue + names = [label_names.get(i, f"label_{i}") for i in ids] + self._annotate(extracted[index], group, names, list(ids)) + surfaces[group] = extracted[index] + if tetrahedra_element_size_mm is not None: + mesh = self._tetrahedra( + self._mask(group_map == index, labelmap_image), + extracted[index], + tetrahedra_element_size_mm, ) - if label_surface is None: - continue - if surface_reduction_rate > 0.0: - label_surface = label_surface.decimate_pro( - surface_reduction_rate, preserve_topology=True - ) - self._annotate( - label_surface, group, [label_name], [label_id], color + if mesh is not None: + self._annotate(mesh, group, names, list(ids)) + tetrahedra[group] = mesh + + if extract_label_surfaces: + self.log_section("Extracting label surfaces") + extracted = self._surface_tools.extract_label_surfaces( + labelmap_image, label_ids=processed_ids, **extract_options + ) + for label_id, surface in extracted.items(): + name = label_names.get(label_id, f"label_{label_id}") + group = taxonomy.group_for_id(label_id) + self._annotate(surface, group, [name], [label_id]) + label_surfaces[name] = surface + if tetrahedra_element_size_mm is not None: + mesh = self._tetrahedra( + self._mask(labels == label_id, labelmap_image), + surface, + tetrahedra_element_size_mm, ) - label_surfaces[label_name] = label_surface + if mesh is not None: + self._annotate(mesh, group, [name], [label_id]) + label_tetrahedra[name] = mesh self.log_section("IMAGE TO VTK WORKFLOW COMPLETE") self.log_info("Surfaces extracted: %d", len(surfaces)) @@ -344,6 +349,67 @@ def process( return { "surfaces": surfaces, "label_surfaces": label_surfaces, - "labelmap": seg_result["labelmap"], - "segmentation_masks": seg_masks, + "tetrahedra": tetrahedra, + "label_tetrahedra": label_tetrahedra, + "labelmap": labelmap_image, } + + # ─────────────────────────── I/O helpers ─────────────────────────────── + + @staticmethod + def save_surfaces( + surfaces: Mapping[str, pv.DataSet], + output_dir: str, + prefix: str = "", + ) -> dict[str, str]: + """Save each named surface or mesh to its own VTK file. + + Args: + surfaces: Mapping of name → mesh, e.g. a value :meth:`process` + returns. Surfaces are written as ``.vtp``, volume meshes as + ``.vtu``. + output_dir: Directory to write files into (created if absent). + prefix: Optional filename prefix. Each file is named + ``{prefix}_{name}`` (or ``{name}`` when *prefix* is empty). + + Returns: + Mapping of name → 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 + suffix = ".vtp" if isinstance(surface, pv.PolyData) else ".vtu" + path = os.path.join(output_dir, f"{stem}{suffix}") + _save_whole(surface, path) + saved[name] = path + return saved + + @staticmethod + def save_combined_surfaces( + surfaces: Mapping[str, pv.PolyData], output_filename: str + ) -> str: + """Write :meth:`ProcessSurfaceMesh.combine_surfaces` of *surfaces* to one file. + + The file keeps each cell's structure as ``cell_data['SegmentationLabelIds']`` + and its color as ``cell_data['Color']``, but not the per-object + ``field_data``; use :meth:`save_surfaces` 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. + """ + combined = ProcessSurfaceMesh.combine_surfaces(surfaces) + output_dir = os.path.dirname(output_filename) + if output_dir: + os.makedirs(output_dir, exist_ok=True) + _save_whole(combined, output_filename) + return output_filename diff --git a/src/monai_physio/workflow_convert_vtk_to_usd.py b/src/monai_physio/workflow_convert_vtk_to_usd.py index 7572b56d..fae117a2 100644 --- a/src/monai_physio/workflow_convert_vtk_to_usd.py +++ b/src/monai_physio/workflow_convert_vtk_to_usd.py @@ -97,8 +97,8 @@ def __init__( :class:`WorkflowConvertImageToVTK`), and falls back to ``{usd_project_name}_{index}`` otherwise. label_names: Mapping of label id → structure name. Each input mesh - is then split on its per-cell ``SegmentationLabelIds`` (or - ``boundary_labels``) array, so every structure becomes its own + is then split on its per-cell ``SegmentationLabelIds`` array, + so every structure becomes its own prim at ``/World/{usd_project_name}/{group}/{structure}``: time-varying across frames, or static from a single mesh. This is the only way structure identity survives a time series; @@ -106,7 +106,7 @@ def __init__( which is positional per frame. None (default) reads the ids off the meshes themselves when they carry the per-cell array, and names them from *segmenter*'s taxonomy - so passing a mesh - merged by :meth:`ProcessContours.save_combined_surfaces` splits by + merged by :meth:`ProcessSurfaceMesh.combine_surfaces` splits by structure without further arguments. ``static_merge`` accepts only one labeled mesh: several would collide on one prim path per label. @@ -166,9 +166,8 @@ def _resolve_label_names(self) -> Optional[dict[int, str]]: An explicit ``label_names`` is used as given. Otherwise the meshes are searched for the per-cell label array that - :meth:`ProcessContours.save_combined_surfaces` writes on a merge, and that - contouring a multi-label labelmap leaves behind. That array is - preferred wherever it exists because it survives merging, which the + :meth:`ProcessSurfaceMesh.combine_surfaces` writes on a merge. That + array is preferred wherever it exists because it survives merging, which the per-object ``field_data`` naming does not - so a combined surface file splits back into its structures instead of collapsing onto one prim. @@ -191,18 +190,17 @@ def _resolve_label_names(self) -> Optional[dict[int, str]]: pv_mesh = self._as_pyvista(mesh) if pv_mesh is None: continue - for array_name in ("SegmentationLabelIds", "boundary_labels"): - if array_name in pv_mesh.cell_data: - label_ids.update( - int(value) for value in np.unique(pv_mesh.cell_data[array_name]) - ) - break + if "SegmentationLabelIds" in pv_mesh.cell_data: + label_ids.update( + int(value) + for value in np.unique(pv_mesh.cell_data["SegmentationLabelIds"]) + ) ids = pv_mesh.field_data.get("SegmentationLabelIds") names = pv_mesh.field_data.get("SegmentationLabelNames") if ids is not None and names is not None and len(ids) == len(names) == 1: field_names[int(ids[0])] = str(names[0]) - # 0 tags the cells save_combined_surfaces could not attribute to one + # 0 tags the cells combine_surfaces could not attribute to one # structure, so it names nothing. label_ids.discard(0) if not label_ids: @@ -373,7 +371,7 @@ def process(self) -> dict[str, Any]: root_prim_name = converter.data_basename usd_tools = ProcessUSD(log_level=self.log_level) mesh_paths = usd_tools.list_mesh_paths_under( - str(output_usd), parent_path=f"/World/{root_prim_name}" + stage, parent_path=f"/World/{root_prim_name}" ) if not mesh_paths: self.log_warning("No mesh prims found under /World/%s", root_prim_name) @@ -389,7 +387,7 @@ def process(self) -> dict[str, Any]: if self.appearance == "solid": for mesh_path in mesh_paths: usd_tools.set_solid_display_color( - str(output_usd), + stage, mesh_path, self.solid_color, time_codes=appearance_time_codes, @@ -407,34 +405,22 @@ def process(self) -> dict[str, Any]: ProcessUSDAnatomy(stage, log_level=self.log_level).enhance_meshes( self.segmenter ) - stage.Save() elif self.appearance == "anatomy": anatomy_tools = ProcessUSDAnatomy(stage, log_level=self.log_level) for mesh_path in mesh_paths: - candidates = self._anatomy_candidates(mesh_path, object_groups) - selected = next( - ( - candidate - for candidate in candidates - if anatomy_tools.resolve_anatomy_type(candidate) is not None + anatomy_tools.apply_anatomy_material_to_mesh( + mesh_path, + anatomy_tools.select_anatomy_type( + self._anatomy_candidates(mesh_path, object_groups) ), - None, ) - if selected is None: - self.log_warning( - "No anatomy material matches %s; using 'other'", - " or ".join(candidates), - ) - selected = "other" - anatomy_tools.apply_anatomy_material_to_mesh(mesh_path, selected) - stage.Save() elif self.appearance == "colormap": primvar = self.colormap_primvar for mesh_path in mesh_paths: if primvar is None: - primvars = usd_tools.list_mesh_primvars(str(output_usd), mesh_path) + primvars = usd_tools.list_mesh_primvars(stage, mesh_path) primvar = usd_tools.pick_color_primvar(primvars) if primvar is None: self.log_warning( @@ -446,7 +432,7 @@ def process(self) -> dict[str, Any]: "Applying colormap to %s from primvar %s", mesh_path, primvar ) usd_tools.apply_colormap_from_primvar( - str(output_usd), + stage, mesh_path, primvar, cmap=self.colormap_name, @@ -457,5 +443,6 @@ def process(self) -> dict[str, Any]: if self.colormap_primvar is None: primvar = None # next mesh: auto-pick again + usd_tools.save_stage(stage) self.log_info("Workflow complete: %s", output_usd) return {"usd_file": str(output_usd)} diff --git a/src/monai_physio/workflow_create_mean_surface.py b/src/monai_physio/workflow_create_mean_surface.py index 363303fc..a366eec2 100644 --- a/src/monai_physio/workflow_create_mean_surface.py +++ b/src/monai_physio/workflow_create_mean_surface.py @@ -33,7 +33,7 @@ import pyvista as pv from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours +from .process_surface_mesh import ProcessSurfaceMesh from .register_models_distance_maps import RegisterModelsDistanceMaps from .register_models_icp import RegisterModelsICP @@ -79,7 +79,7 @@ def __init__( if len(surfaces) < 2: raise ValueError(f"At least 2 surfaces are required, got {len(surfaces)}") - self.contour_tools = ProcessContours(log_level=log_level) + self.contour_tools = ProcessSurfaceMesh(log_level=log_level) self.surfaces = [self.contour_tools.extract_surface(s) for s in surfaces] self.template_surface = ( self.contour_tools.extract_surface(template_surface) diff --git a/src/monai_physio/workflow_create_statistical_model.py b/src/monai_physio/workflow_create_statistical_model.py index 0de42ccd..c6f75d09 100644 --- a/src/monai_physio/workflow_create_statistical_model.py +++ b/src/monai_physio/workflow_create_statistical_model.py @@ -17,7 +17,7 @@ from sklearn.decomposition import PCA from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours +from .process_surface_mesh import ProcessSurfaceMesh from .process_transforms import ProcessTransforms from .register_models_distance_maps import RegisterModelsDistanceMaps from .register_models_icp import RegisterModelsICP @@ -125,7 +125,7 @@ def __init__( self.projection_max_distance_mm = projection_max_distance_mm self.icon_weights_path: Optional[str] = None - self.contour_tools = ProcessContours() + self.contour_tools = ProcessSurfaceMesh() self.transform_tools = ProcessTransforms() # Set by pipeline @@ -403,7 +403,7 @@ def _step5_compute_pca(self) -> None: data_rows.append(model.points.flatten()) data_matrix = np.array(data_rows) - if data_matrix.shape[0] - 1 < 2: + if data_matrix.shape[0] < 2: raise ValueError( f"At least 2 samples are required for PCA. Got {data_matrix.shape[0]} samples." ) diff --git a/src/monai_physio/workflow_evaluate_movement.py b/src/monai_physio/workflow_evaluate_movement.py index f25523d1..fddb9997 100644 --- a/src/monai_physio/workflow_evaluate_movement.py +++ b/src/monai_physio/workflow_evaluate_movement.py @@ -40,8 +40,8 @@ from .evaluate_movement_base import EvaluateMovementBase, MovementGroundTruth from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours from .process_physicsnemo import ProcessPhysicsNemo +from .process_surface_mesh import ProcessSurfaceMesh from .report_evaluate_movement import ReportEvaluateMovement from .workflow_infer_movement import WorkflowInferMovement @@ -87,7 +87,7 @@ class WorkflowEvaluateMovement(MONAIPhysioBase): ) # Per-cell structure ids a shape model may carry, written by - # ``ProcessContours.save_combined_surfaces``. + # ``ProcessSurfaceMesh.combine_surfaces``. _MESH_LABEL_ARRAY = "SegmentationLabelIds" def __init__( @@ -110,7 +110,7 @@ def __init__( if label_names is not None else cast(EvaluateMovementBase, cohort).label_names() ) - self.contour_tools = ProcessContours(log_level=log_level) + self.contour_tools = ProcessSurfaceMesh(log_level=log_level) self.displacement_data_file: Optional[Path] = None self.report_tools = ReportEvaluateMovement(log_level=log_level) @@ -640,7 +640,7 @@ def _label_point_indices( that. A mesh that does not --- the heart is one surface, its chambers being cavities the model excludes --- is partitioned by nearest label surface instead, the same nearest-label rule - :meth:`ProcessContours.extract_label_surfaces` already scores the labelmap + :meth:`ProcessSurfaceMesh.extract_label_surfaces` already scores the labelmap metrics under. A chamber is then scored on the piece of wall bounding it. Either way a point belongs to at most one structure, and the partition is decided once, at the reference frame. diff --git a/src/monai_physio/workflow_finetune_icon_registration.py b/src/monai_physio/workflow_finetune_icon_registration.py index a3479376..b97eb69d 100644 --- a/src/monai_physio/workflow_finetune_icon_registration.py +++ b/src/monai_physio/workflow_finetune_icon_registration.py @@ -93,6 +93,7 @@ def __init__( gpus: Optional[list[int]] = None, eval_period: int = 10, save_period: int = 50, + seed: Optional[int] = 0, unigradicon_src_path: Optional[Path] = None, log_level: Union[int, str] = logging.INFO, ) -> None: @@ -136,6 +137,10 @@ def __init__( gpus: GPU device indices for training. Defaults to ``[0]``. eval_period: uniGradICON ``training.eval_period``. save_period: uniGradICON ``training.save_period``. + seed: uniGradICON ``training.seed``, seeding Python, NumPy and + PyTorch at startup; ``None`` leaves training unseeded. GPU + training is still not bit-reproducible, as uniGradICON's 3-D + ``grid_sample`` backward has no deterministic CUDA kernel. unigradicon_src_path: Optional path to a local uniGradICON source tree to prepend to ``PYTHONPATH`` when running finetuning. Useful for using a checked-out copy instead of the installed @@ -203,6 +208,7 @@ def __init__( self.gpus = list(gpus) if gpus is not None else [0] self.eval_period = eval_period self.save_period = save_period + self.seed = seed self.unigradicon_src_path = ( Path(unigradicon_src_path) if unigradicon_src_path is not None else None ) @@ -397,6 +403,7 @@ def prepare_config(self, dataset_json_path: Optional[Path] = None) -> Path: "loss_function_masking": self._use_masks, "use_label": False, "roi_masking": False, + "seed": self.seed, }, "datasets": [ { diff --git a/src/monai_physio/workflow_fit_statistical_model_to_patient.py b/src/monai_physio/workflow_fit_statistical_model_to_patient.py index 5779a2ff..75ea0f98 100644 --- a/src/monai_physio/workflow_fit_statistical_model_to_patient.py +++ b/src/monai_physio/workflow_fit_statistical_model_to_patient.py @@ -31,9 +31,9 @@ import pyvista as pv from .monai_physio_base import MONAIPhysioBase -from .process_contours import ProcessContours from .process_images import ProcessImages from .process_labelmaps import ProcessLabelmaps +from .process_surface_mesh import ProcessSurfaceMesh from .process_transforms import ProcessTransforms from .register_images_greedy import RegisterImagesGreedy from .register_images_icon import RegisterImagesICON @@ -218,7 +218,7 @@ def __init__( # Utilities (needed for create_reference_image when patient_image is None) self.transform_tools = ProcessTransforms() - self.contour_tools = ProcessContours() + self.contour_tools = ProcessSurfaceMesh() self.labelmap_tools = ProcessLabelmaps() if patient_image is not None: diff --git a/tests/README.md b/tests/README.md index aabaf226..a7b42bc0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -106,7 +106,7 @@ Each flag enables one marker family. Flags compose, so you can stack them. | `--run-simpleware` | `requires_simpleware` | Need a licensed Synopsys Simpleware Medical install (also marked `requires_gpu`) | | `--run-physicsnemo` | `requires_physicsnemo` | PhysicsNeMo-dependent tests | | `--run-tutorials` | `tutorial` | Tutorial scripts run end-to-end (hours to run) | -| `--run-all` | every bucket above | Equivalent to passing all `--run-*` flags at once | +| `--run-all` | every bucket above | Equivalent to passing all `--run-*` flags at once, plus `--require-tutorial-data` | ```bash # Slow tests @@ -167,7 +167,8 @@ tests. - `@pytest.mark.tutorial` - Tutorial scripts run end-to-end (SLOW, never in CI). Opt in: `--run-tutorials`. -`--run-all` is a convenience flag that turns on every `--run-*` bucket at once. +`--run-all` is a convenience flag that turns on every `--run-*` bucket at once, +and implies `--require-tutorial-data`, so missing datasets fail instead of skip. - `@pytest.mark.integration` - Integration tests vs unit tests (filter-only). - `@pytest.mark.timeout(seconds)` - Per-test timeout override. diff --git a/tests/baselines/registration_time_series_images/basic_forward_transform_0.hdf b/tests/baselines/registration_time_series_images/basic_forward_transform_0.hdf deleted file mode 100644 index 229d4d4f..00000000 --- a/tests/baselines/registration_time_series_images/basic_forward_transform_0.hdf +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3fdb6bab6c362e6d8b2f7f23db540e24eb6b732e7f6cb82ce713433daa0670bd -size 4512579 diff --git a/tests/baselines/registration_time_series_images/basic_time_series_registered_0.mha b/tests/baselines/registration_time_series_images/basic_time_series_registered_0.mha deleted file mode 100644 index 73f512a1..00000000 --- a/tests/baselines/registration_time_series_images/basic_time_series_registered_0.mha +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:fa8492f316a3c9c78b75a06ef45152fc8f5a8db36b0931386ef1ca22a6f6197e -size 4767196 diff --git a/tests/baselines/registration_time_series_images/middle_frame_forward_transform_0.hdf b/tests/baselines/registration_time_series_images/middle_frame_forward_transform_0.hdf deleted file mode 100644 index afb51242..00000000 --- a/tests/baselines/registration_time_series_images/middle_frame_forward_transform_0.hdf +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8b6d8ed46f2654a646111b6c6aad58641bf35573277d339d8d02d5e27e6737c4 -size 4158463 diff --git a/tests/baselines/registration_time_series_images/transform_application_time_series_0.mha b/tests/baselines/registration_time_series_images/transform_application_time_series_0.mha deleted file mode 100644 index e2ec568b..00000000 --- a/tests/baselines/registration_time_series_images/transform_application_time_series_0.mha +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:5615af55c808458fc9e9e7c999ad4ddfc85a6082f2db1bfb6ec5bc770c03d892 -size 4779024 diff --git a/tests/baselines/tutorial_01_heart_gated_ct_to_usd/cardiac_model_test.png b/tests/baselines/tutorial_01_heart_gated_ct_to_usd/cardiac_model_test.png new file mode 100644 index 00000000..9f6d7ca4 --- /dev/null +++ b/tests/baselines/tutorial_01_heart_gated_ct_to_usd/cardiac_model_test.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0a5c3ac4a28fa30c6af56de80534354e8adb80c723041fc48b64fdfe244e6758 +size 115846 diff --git a/tests/baselines/tutorial_01_lung_gated_ct_to_usd/lung_model_test.png b/tests/baselines/tutorial_01_lung_gated_ct_to_usd/lung_model_test.png new file mode 100644 index 00000000..dd232725 --- /dev/null +++ b/tests/baselines/tutorial_01_lung_gated_ct_to_usd/lung_model_test.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ac397dba329cccdf2be50eabd18587da15e448efd9849d9d56cfd44f7d6ef12 +size 92133 diff --git a/tests/baselines/tutorial_01_lung_gated_ct_to_usd/slice_001_registered_test.png b/tests/baselines/tutorial_01_lung_gated_ct_to_usd/slice_001_registered_test.png new file mode 100644 index 00000000..49e5a0e1 --- /dev/null +++ b/tests/baselines/tutorial_01_lung_gated_ct_to_usd/slice_001_registered_test.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c6c1f15e253f163f387a03d26de4341ded47ddb8638aa2d6cec764771294756c +size 40078 diff --git a/tests/baselines/tutorial_02_duke_heart_distancemap_finetune_icon/fixed_distance_map.png b/tests/baselines/tutorial_02_duke_heart_distancemap_finetune_icon/fixed_distance_map.png new file mode 100644 index 00000000..d4922109 --- /dev/null +++ b/tests/baselines/tutorial_02_duke_heart_distancemap_finetune_icon/fixed_distance_map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:409d596bfc0ea13b8e5adb8025308dc27ef10ba0304490ba869aa7f80f517fab +size 7800 diff --git a/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/fixed_distance_map.png b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/fixed_distance_map.png new file mode 100644 index 00000000..777ca814 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/fixed_distance_map.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8938c0b6d71d458d16342c8fb8169e7d8b2755c8c8728962b6a4cf1c91471af9 +size 30129 diff --git a/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_greedy_dmap.png b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_greedy_dmap.png new file mode 100644 index 00000000..c1789d91 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_greedy_dmap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e303f639cfd7e70e9e18d2faa49191ef6fc32b2f78c2209f23811b036fb553fe +size 33287 diff --git a/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_greedy_icon_finetuned_dmap.png b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_greedy_icon_finetuned_dmap.png new file mode 100644 index 00000000..fc8fdd19 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_greedy_icon_finetuned_dmap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5fe0fbe5a9ef3231bd786b64eabc7eacf325700e3ae61ce9633c5e7e8ddabd62 +size 33503 diff --git a/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_icon_finetuned_dmap.png b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_icon_finetuned_dmap.png new file mode 100644 index 00000000..da8a3c70 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_icon_finetuned_dmap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d71b09618d1fae443ba9b09d06406712d09f96dbf49329fccebcaf26ed988a0 +size 33555 diff --git a/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_icon_stock_ct.png b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_icon_stock_ct.png new file mode 100644 index 00000000..fe03c145 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_icon_stock_ct.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6ff88b4100ce6d1011ff3d90d60a92c439a00f6aba9bd1ed76e052ee9c351656 +size 34405 diff --git a/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_icon_stock_dmap.png b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_icon_stock_dmap.png new file mode 100644 index 00000000..07dec47e --- /dev/null +++ b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_icon_stock_dmap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:45371eebc59d0e9a289a17a031aa2634a1f820121a7ba862436f48d39722091d +size 33341 diff --git a/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_unregistered.png b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_unregistered.png new file mode 100644 index 00000000..6fb6acee --- /dev/null +++ b/tests/baselines/tutorial_02_lung_distancemap_finetune_icon/registered_distance_map_unregistered.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a8484922d58d7439a3d47fac48451ef893141a9a5037114b68a4cda8a00dafa +size 30601 diff --git a/tests/baselines/tutorial_02_lung_finetune_icon/fixed_frame.png b/tests/baselines/tutorial_02_lung_finetune_icon/fixed_frame.png new file mode 100644 index 00000000..771052de --- /dev/null +++ b/tests/baselines/tutorial_02_lung_finetune_icon/fixed_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93bd16d9afc4acf0b3bb78787176415534ba2adf1bf842c341075a98647774e8 +size 60617 diff --git a/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy.png b/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy.png new file mode 100644 index 00000000..ceea5594 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:267669beba297ceb866bc645029637cc3d4d21af77b9567f7d3ea1d59e7dbc71 +size 62297 diff --git a/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_finetuned_steps_10.png b/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_finetuned_steps_10.png new file mode 100644 index 00000000..3f1a2bc3 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_finetuned_steps_10.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbe5d57c61a29e4cc2a0a8ecc0a59cd44d6d0330d47e5e4c0efc90d82bcf1f92 +size 55997 diff --git a/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_finetuned_steps_50.png b/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_finetuned_steps_50.png new file mode 100644 index 00000000..3f1a2bc3 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_finetuned_steps_50.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbe5d57c61a29e4cc2a0a8ecc0a59cd44d6d0330d47e5e4c0efc90d82bcf1f92 +size 55997 diff --git a/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_finetuned_steps_None.png b/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_finetuned_steps_None.png new file mode 100644 index 00000000..3f1a2bc3 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_finetuned_steps_None.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbe5d57c61a29e4cc2a0a8ecc0a59cd44d6d0330d47e5e4c0efc90d82bcf1f92 +size 55997 diff --git a/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_stage0.png b/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_stage0.png new file mode 100644 index 00000000..ceea5594 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_finetune_icon/registered_greedy_icon_stage0.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:267669beba297ceb866bc645029637cc3d4d21af77b9567f7d3ea1d59e7dbc71 +size 62297 diff --git a/tests/baselines/tutorial_02_lung_finetune_icon/registered_icon_finetuned.png b/tests/baselines/tutorial_02_lung_finetune_icon/registered_icon_finetuned.png new file mode 100644 index 00000000..93331c87 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_finetune_icon/registered_icon_finetuned.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bb1034b12f686fd2a5193c00ea182a0897364e88bf10305c6e7fb4dc382d28b +size 56351 diff --git a/tests/baselines/tutorial_02_lung_finetune_icon/registered_icon_stock.png b/tests/baselines/tutorial_02_lung_finetune_icon/registered_icon_stock.png new file mode 100644 index 00000000..77e53d12 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_finetune_icon/registered_icon_stock.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5aa53e3c9e420561abaf6e4a622a8ead806fb69d319b77db43b72e0a51d02bfb +size 54231 diff --git a/tests/baselines/tutorial_02_lung_finetune_icon/registered_unregistered.png b/tests/baselines/tutorial_02_lung_finetune_icon/registered_unregistered.png new file mode 100644 index 00000000..bbc10b92 --- /dev/null +++ b/tests/baselines/tutorial_02_lung_finetune_icon/registered_unregistered.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fad3897bfc0a0738bc4b100b345f4effb9483f209993a33b049b955edfc72da7 +size 68209 diff --git a/tests/baselines/tutorial_03_heart_reconstruct_highres_4d_ct/reconstructed_frame.png b/tests/baselines/tutorial_03_heart_reconstruct_highres_4d_ct/reconstructed_frame.png new file mode 100644 index 00000000..3ef2a93b --- /dev/null +++ b/tests/baselines/tutorial_03_heart_reconstruct_highres_4d_ct/reconstructed_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f2a923db1ce715448cd1f0f8f53786b6d442cfdb5c953e11e1e04c75921b80c +size 93723 diff --git a/tests/baselines/tutorial_03_heart_reconstruct_highres_4d_ct/reference_frame.png b/tests/baselines/tutorial_03_heart_reconstruct_highres_4d_ct/reference_frame.png new file mode 100644 index 00000000..29409008 --- /dev/null +++ b/tests/baselines/tutorial_03_heart_reconstruct_highres_4d_ct/reference_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0253ea953d333f9455622b08e317bb332377fc81c5dfddd4160239ca52bf6097 +size 100556 diff --git a/tests/baselines/tutorial_03_lung_reconstruct_highres_4d_ct/reconstructed_frame.png b/tests/baselines/tutorial_03_lung_reconstruct_highres_4d_ct/reconstructed_frame.png new file mode 100644 index 00000000..490f364f --- /dev/null +++ b/tests/baselines/tutorial_03_lung_reconstruct_highres_4d_ct/reconstructed_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3fb1c528864183ea7898b5b67b038f2e4f08985fef8ca71d21e4d436759a805 +size 58436 diff --git a/tests/baselines/tutorial_03_lung_reconstruct_highres_4d_ct/reference_frame.png b/tests/baselines/tutorial_03_lung_reconstruct_highres_4d_ct/reference_frame.png new file mode 100644 index 00000000..03e37cb3 --- /dev/null +++ b/tests/baselines/tutorial_03_lung_reconstruct_highres_4d_ct/reference_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:555ac1c40e20dac1c0e348db2bab7a1b10b38d715c5a5c8df8dcdf4676f84f7f +size 60210 diff --git a/tests/baselines/tutorial_04_duke_heart_labelmap_to_vtk/heart_minus_interior_chambers.png b/tests/baselines/tutorial_04_duke_heart_labelmap_to_vtk/heart_minus_interior_chambers.png new file mode 100644 index 00000000..cd0f5425 --- /dev/null +++ b/tests/baselines/tutorial_04_duke_heart_labelmap_to_vtk/heart_minus_interior_chambers.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5597b0ebed3a19299be246de9162b437f2ab2a656a6c9188cd697cd6cf29c6a4 +size 132574 diff --git a/tests/baselines/tutorial_04_heart_ct_to_vtk/segmentation_overlay.png b/tests/baselines/tutorial_04_heart_ct_to_vtk/segmentation_overlay.png new file mode 100644 index 00000000..9409f7c1 --- /dev/null +++ b/tests/baselines/tutorial_04_heart_ct_to_vtk/segmentation_overlay.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5ecc64b9a1044f2bec876b9ab8435110f9ec5cd0509a55b7ac09ebe52b1a7f4b +size 136627 diff --git a/tests/baselines/tutorial_04_heart_ct_to_vtk/vtk_surfaces.png b/tests/baselines/tutorial_04_heart_ct_to_vtk/vtk_surfaces.png new file mode 100644 index 00000000..b9fedd8b --- /dev/null +++ b/tests/baselines/tutorial_04_heart_ct_to_vtk/vtk_surfaces.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5657746f146e385feae31000443d12ff040bcb6d9800a3218416b1bc1e9f73cf +size 165623 diff --git a/tests/baselines/tutorial_04_lung/tutorial_04_lung_segmentation_overlay.png b/tests/baselines/tutorial_04_lung/tutorial_04_lung_segmentation_overlay.png new file mode 100644 index 00000000..c40a8013 --- /dev/null +++ b/tests/baselines/tutorial_04_lung/tutorial_04_lung_segmentation_overlay.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80ae278360bcc8c70732ac984b59ef0755be258f49d6b3ff8f7a0e566ae2a5f7 +size 66228 diff --git a/tests/baselines/tutorial_04_lung/tutorial_04_lung_vtk_surfaces.png b/tests/baselines/tutorial_04_lung/tutorial_04_lung_vtk_surfaces.png new file mode 100644 index 00000000..b43beb7d --- /dev/null +++ b/tests/baselines/tutorial_04_lung/tutorial_04_lung_vtk_surfaces.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81ff676d666a8f4906eeecce986368bd54f261bff699c92b5f9b19afd87e056d +size 140575 diff --git a/tests/baselines/tutorial_05_duke_heart_vtk_to_usd/pm0002_usd_mesh_rendering.png b/tests/baselines/tutorial_05_duke_heart_vtk_to_usd/pm0002_usd_mesh_rendering.png new file mode 100644 index 00000000..65bfca9b --- /dev/null +++ b/tests/baselines/tutorial_05_duke_heart_vtk_to_usd/pm0002_usd_mesh_rendering.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4b97acc668210c883b65633711213df863c4a57be1e5cc94c3802fb01d2dda84 +size 73144 diff --git a/tests/baselines/tutorial_05_duke_heart_vtk_to_usd/pm0002_usd_mesh_rendering_last_frame.png b/tests/baselines/tutorial_05_duke_heart_vtk_to_usd/pm0002_usd_mesh_rendering_last_frame.png new file mode 100644 index 00000000..93a97ab4 --- /dev/null +++ b/tests/baselines/tutorial_05_duke_heart_vtk_to_usd/pm0002_usd_mesh_rendering_last_frame.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c86f8c92c200efccbca9704c8c5d8538ecd5013fdea320a417dc0585e219da1 +size 72047 diff --git a/tests/baselines/tutorial_05_heart_vtk_to_usd/tutorial_04_heart_usd_mesh_rendering.png b/tests/baselines/tutorial_05_heart_vtk_to_usd/tutorial_04_heart_usd_mesh_rendering.png new file mode 100644 index 00000000..a5ca93ce --- /dev/null +++ b/tests/baselines/tutorial_05_heart_vtk_to_usd/tutorial_04_heart_usd_mesh_rendering.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fc3a1680f12cd5f8eebfde3ba9bf482e491e9f6537c366989abceb772c1f5e8b +size 108724 diff --git a/tests/baselines/tutorial_06_duke_heart_create_statistical_model/pca_mean_model.png b/tests/baselines/tutorial_06_duke_heart_create_statistical_model/pca_mean_model.png new file mode 100644 index 00000000..5dc95865 --- /dev/null +++ b/tests/baselines/tutorial_06_duke_heart_create_statistical_model/pca_mean_model.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:27c7d05510584435e913c72961a0e80c02b3fa708d335d5f2e33fa47e2478d6e +size 108338 diff --git a/tests/baselines/tutorial_06_duke_heart_create_statistical_model/pca_mode_01.png b/tests/baselines/tutorial_06_duke_heart_create_statistical_model/pca_mode_01.png new file mode 100644 index 00000000..2e84378e --- /dev/null +++ b/tests/baselines/tutorial_06_duke_heart_create_statistical_model/pca_mode_01.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa35ab58d3a02aa906eddadd95011e5ef20ed3b72519b83ad7b6904422887ff4 +size 182081 diff --git a/tests/baselines/tutorial_06_duke_heart_create_statistical_model/pca_mode_02.png b/tests/baselines/tutorial_06_duke_heart_create_statistical_model/pca_mode_02.png new file mode 100644 index 00000000..7bb405b7 --- /dev/null +++ b/tests/baselines/tutorial_06_duke_heart_create_statistical_model/pca_mode_02.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:890977767e436ec6dfc9948941827e73ea31f81802eb19c26d02445c12ff270e +size 190321 diff --git a/tests/baselines/tutorial_06_heart_create_statistical_model/pca_mean_model.png b/tests/baselines/tutorial_06_heart_create_statistical_model/pca_mean_model.png new file mode 100644 index 00000000..e2c005fe --- /dev/null +++ b/tests/baselines/tutorial_06_heart_create_statistical_model/pca_mean_model.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3bb4dfab784e0aba6c082f3b0726e6191ab9e609c7db6ade4f7c0ab9a95ea196 +size 96799 diff --git a/tests/baselines/tutorial_06_heart_create_statistical_model/pca_mode_01.png b/tests/baselines/tutorial_06_heart_create_statistical_model/pca_mode_01.png new file mode 100644 index 00000000..ea57b795 --- /dev/null +++ b/tests/baselines/tutorial_06_heart_create_statistical_model/pca_mode_01.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:57b1014f9188fa61f1a41993178e4c6fc87d36dc2afdfef69f2142ca1fddaf7a +size 163648 diff --git a/tests/baselines/tutorial_06_heart_create_statistical_model/pca_mode_02.png b/tests/baselines/tutorial_06_heart_create_statistical_model/pca_mode_02.png new file mode 100644 index 00000000..bd68311c --- /dev/null +++ b/tests/baselines/tutorial_06_heart_create_statistical_model/pca_mode_02.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92797e84685b9b30892bc4ab1c5e2fd32864152d7094d745ea580ff5b3e19d35 +size 163210 diff --git a/tests/baselines/tutorial_06_lung_create_statistical_model/pca_mean_model.png b/tests/baselines/tutorial_06_lung_create_statistical_model/pca_mean_model.png new file mode 100644 index 00000000..962b3288 --- /dev/null +++ b/tests/baselines/tutorial_06_lung_create_statistical_model/pca_mean_model.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b66665e3b3cbe7d6d1294f09459c02851d89dcd909b276902874964bcc37fc6 +size 98791 diff --git a/tests/baselines/tutorial_06_lung_create_statistical_model/pca_mode_01.png b/tests/baselines/tutorial_06_lung_create_statistical_model/pca_mode_01.png new file mode 100644 index 00000000..dc05c880 --- /dev/null +++ b/tests/baselines/tutorial_06_lung_create_statistical_model/pca_mode_01.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e12fa4ccbb778370c223ff0ba2e17883bbb0360bfec02924675818a79cee7021 +size 181504 diff --git a/tests/baselines/tutorial_06_lung_create_statistical_model/pca_mode_02.png b/tests/baselines/tutorial_06_lung_create_statistical_model/pca_mode_02.png new file mode 100644 index 00000000..0f2ca741 --- /dev/null +++ b/tests/baselines/tutorial_06_lung_create_statistical_model/pca_mode_02.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:29f927e388a3044ac29d837cd11227eedc497d6e9f7c8de0617dc636316a267c +size 175133 diff --git a/tests/baselines/tutorial_07_duke_heart/tutorial_07_duke_heart_model_after_registration.png b/tests/baselines/tutorial_07_duke_heart/tutorial_07_duke_heart_model_after_registration.png new file mode 100644 index 00000000..ab5650c5 --- /dev/null +++ b/tests/baselines/tutorial_07_duke_heart/tutorial_07_duke_heart_model_after_registration.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:00172e780ff8e371104a55033d90d7a66fdff02f8974cd77cf0a0f96989ae70e +size 136698 diff --git a/tests/baselines/tutorial_07_duke_heart/tutorial_07_duke_heart_model_before_registration.png b/tests/baselines/tutorial_07_duke_heart/tutorial_07_duke_heart_model_before_registration.png new file mode 100644 index 00000000..a9027234 --- /dev/null +++ b/tests/baselines/tutorial_07_duke_heart/tutorial_07_duke_heart_model_before_registration.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:58108c554b6693aa97b41b9a2444ff3317c43147f1f4480cfcdc13ed007c6f52 +size 110178 diff --git a/tests/baselines/tutorial_07_duke_heart/tutorial_07_duke_heart_registered_coefficients.json b/tests/baselines/tutorial_07_duke_heart/tutorial_07_duke_heart_registered_coefficients.json new file mode 100644 index 00000000..ef44aa2a --- /dev/null +++ b/tests/baselines/tutorial_07_duke_heart/tutorial_07_duke_heart_registered_coefficients.json @@ -0,0 +1 @@ +[0.2698413751454639, 0.0045983690765939] \ No newline at end of file diff --git a/tests/baselines/tutorial_07_heart_fit_statistical_model_to_patient/tutorial_07_heart_model_after_registration.png b/tests/baselines/tutorial_07_heart_fit_statistical_model_to_patient/tutorial_07_heart_model_after_registration.png new file mode 100644 index 00000000..f5252d73 --- /dev/null +++ b/tests/baselines/tutorial_07_heart_fit_statistical_model_to_patient/tutorial_07_heart_model_after_registration.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:160c2454698a7629bf44f20e34241d42e04a8d2ce7b60848a135d895c558e883 +size 97596 diff --git a/tests/baselines/tutorial_07_heart_fit_statistical_model_to_patient/tutorial_07_heart_model_before_registration.png b/tests/baselines/tutorial_07_heart_fit_statistical_model_to_patient/tutorial_07_heart_model_before_registration.png new file mode 100644 index 00000000..af4c2fb8 --- /dev/null +++ b/tests/baselines/tutorial_07_heart_fit_statistical_model_to_patient/tutorial_07_heart_model_before_registration.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6f7995fa1578d8802579b23d384bde060311e19d67c3892f37a8c643d2478e7 +size 49934 diff --git a/tests/baselines/tutorial_07_heart_fit_statistical_model_to_patient/tutorial_07_heart_registered_coefficients.json b/tests/baselines/tutorial_07_heart_fit_statistical_model_to_patient/tutorial_07_heart_registered_coefficients.json new file mode 100644 index 00000000..94e57073 --- /dev/null +++ b/tests/baselines/tutorial_07_heart_fit_statistical_model_to_patient/tutorial_07_heart_registered_coefficients.json @@ -0,0 +1 @@ +[-0.9879102396857959, 2.2137699179249726] \ No newline at end of file diff --git a/tests/baselines/tutorial_07_lung/tutorial_07_lung_model_after_registration.png b/tests/baselines/tutorial_07_lung/tutorial_07_lung_model_after_registration.png new file mode 100644 index 00000000..267c68f6 --- /dev/null +++ b/tests/baselines/tutorial_07_lung/tutorial_07_lung_model_after_registration.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a9d9df73bd60ce15c25b1aacbe1de8c2a9fc1d128348ed72837aaa8917ab6b93 +size 116152 diff --git a/tests/baselines/tutorial_07_lung/tutorial_07_lung_model_before_registration.png b/tests/baselines/tutorial_07_lung/tutorial_07_lung_model_before_registration.png new file mode 100644 index 00000000..1a749089 --- /dev/null +++ b/tests/baselines/tutorial_07_lung/tutorial_07_lung_model_before_registration.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e4884c1a6e2a562ec6e68587e792399410af26ee456afa5be8297998daf6abbf +size 127314 diff --git a/tests/baselines/tutorial_07_lung/tutorial_07_lung_registered_coefficients.json b/tests/baselines/tutorial_07_lung/tutorial_07_lung_registered_coefficients.json new file mode 100644 index 00000000..dcd37337 --- /dev/null +++ b/tests/baselines/tutorial_07_lung/tutorial_07_lung_registered_coefficients.json @@ -0,0 +1 @@ +[0.04135481332608433, -0.7921949941283437] \ No newline at end of file diff --git a/tests/baselines/tutorial_08_duke_heart_fit_model_to_4d_patients/ssm_surface_first_phase.png b/tests/baselines/tutorial_08_duke_heart_fit_model_to_4d_patients/ssm_surface_first_phase.png new file mode 100644 index 00000000..bad4412b --- /dev/null +++ b/tests/baselines/tutorial_08_duke_heart_fit_model_to_4d_patients/ssm_surface_first_phase.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:547dec55b458ca47cb4fb7b29e749284bc87c7e82c61aaa4f3334b31788ef1e1 +size 101417 diff --git a/tests/baselines/tutorial_08_duke_heart_fit_model_to_4d_patients/ssm_surface_reference.png b/tests/baselines/tutorial_08_duke_heart_fit_model_to_4d_patients/ssm_surface_reference.png new file mode 100644 index 00000000..d4910963 --- /dev/null +++ b/tests/baselines/tutorial_08_duke_heart_fit_model_to_4d_patients/ssm_surface_reference.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0a359eece91f184b0c5e9c1d0954d6a8d39509035511c72293ed46c70a0b681 +size 106932 diff --git a/tests/baselines/tutorial_08_lung_fit_model_to_4d_patients/ssm_surface_first_phase.png b/tests/baselines/tutorial_08_lung_fit_model_to_4d_patients/ssm_surface_first_phase.png new file mode 100644 index 00000000..e29cefb9 --- /dev/null +++ b/tests/baselines/tutorial_08_lung_fit_model_to_4d_patients/ssm_surface_first_phase.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:08126b7adddf547e32f12d9ce4744bfdcd49735a0252b3aece085f090b30b200 +size 99256 diff --git a/tests/baselines/tutorial_08_lung_fit_model_to_4d_patients/ssm_surface_reference.png b/tests/baselines/tutorial_08_lung_fit_model_to_4d_patients/ssm_surface_reference.png new file mode 100644 index 00000000..56d71567 --- /dev/null +++ b/tests/baselines/tutorial_08_lung_fit_model_to_4d_patients/ssm_surface_reference.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1423496bf408c7b061a8a6678891d9a3a83d065f6475a6f878a86005340944af +size 102073 diff --git a/tests/baselines/tutorial_09_duke_heart_train_physicsnemo_mgn/predicted_surface.png b/tests/baselines/tutorial_09_duke_heart_train_physicsnemo_mgn/predicted_surface.png new file mode 100644 index 00000000..8b21a4ec --- /dev/null +++ b/tests/baselines/tutorial_09_duke_heart_train_physicsnemo_mgn/predicted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f25f3274f15ffedc8b11d5ba0d4d618d5c1be44bb7b093a36e2a3680905176c1 +size 102943 diff --git a/tests/baselines/tutorial_09_lung_train_physicsnemo_mgn/predicted_surface.png b/tests/baselines/tutorial_09_lung_train_physicsnemo_mgn/predicted_surface.png new file mode 100644 index 00000000..2193591e --- /dev/null +++ b/tests/baselines/tutorial_09_lung_train_physicsnemo_mgn/predicted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34eb1a70647c4f05dfbd1f5fbce2a6c38cdc4b8491c0116e9a74cb6e5093716a +size 95277 diff --git a/tests/baselines/tutorial_10_duke_heart_infer_physicsnemo_mgn/ground_truth_surface.png b/tests/baselines/tutorial_10_duke_heart_infer_physicsnemo_mgn/ground_truth_surface.png new file mode 100644 index 00000000..d0b94a26 --- /dev/null +++ b/tests/baselines/tutorial_10_duke_heart_infer_physicsnemo_mgn/ground_truth_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:906dbad550a6097a0faae596ce1a6c11f81564ca57f0e590f7f8a4ca342f68a9 +size 105521 diff --git a/tests/baselines/tutorial_10_duke_heart_infer_physicsnemo_mgn/predicted_surface.png b/tests/baselines/tutorial_10_duke_heart_infer_physicsnemo_mgn/predicted_surface.png new file mode 100644 index 00000000..8b21a4ec --- /dev/null +++ b/tests/baselines/tutorial_10_duke_heart_infer_physicsnemo_mgn/predicted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f25f3274f15ffedc8b11d5ba0d4d618d5c1be44bb7b093a36e2a3680905176c1 +size 102943 diff --git a/tests/baselines/tutorial_10_lung_infer_physicsnemo_mgn/ground_truth_surface.png b/tests/baselines/tutorial_10_lung_infer_physicsnemo_mgn/ground_truth_surface.png new file mode 100644 index 00000000..1b92a819 --- /dev/null +++ b/tests/baselines/tutorial_10_lung_infer_physicsnemo_mgn/ground_truth_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05fadc38286857c74b7e86b2d3d7c445b080742b6ec0c8003d9ea8d8d03858f5 +size 101109 diff --git a/tests/baselines/tutorial_10_lung_infer_physicsnemo_mgn/predicted_surface.png b/tests/baselines/tutorial_10_lung_infer_physicsnemo_mgn/predicted_surface.png new file mode 100644 index 00000000..2193591e --- /dev/null +++ b/tests/baselines/tutorial_10_lung_infer_physicsnemo_mgn/predicted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34eb1a70647c4f05dfbd1f5fbce2a6c38cdc4b8491c0116e9a74cb6e5093716a +size 95277 diff --git a/tests/baselines/tutorial_11_duke_heart_evaluate_physicsnemo/evaluation_metrics.csv b/tests/baselines/tutorial_11_duke_heart_evaluate_physicsnemo/evaluation_metrics.csv new file mode 100644 index 00000000..f66988e5 --- /dev/null +++ b/tests/baselines/tutorial_11_duke_heart_evaluate_physicsnemo/evaluation_metrics.csv @@ -0,0 +1,61 @@ +case_id,stage,label_id,label_name,dice,volume_truth_mm3,volume_predicted_mm3,volume_difference_mm3,volume_difference_percent,surface_rmse_mm,displacement_rms_mm,displacement_95th_mm,displacement_max_mm,shape_parameters_file,network_weights_file,network_weights_created,network_weights_modified,network_epoch,pca_c01,pca_c02 +pm0027,0.0,1,left_ventricle,0.8634388317135205,131960.0,105516.0,-26444.0,-20.03940588056987,2.2814421548119364,3.2502834219598604,5.638106822967529,6.834604263305664,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.0,2,right_ventricle,0.8428864257515943,170088.0,146040.0,-24048.0,-14.13856356709468,3.947280212461157,5.070778971521228,8.673553466796875,10.534096717834473,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.0,3,left_atrium,0.8063399684239854,70752.0,90763.0,20011.0,28.283299412030754,2.3870278591893297,3.445597803693893,5.197455406188965,6.178326606750488,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.0,4,right_atrium,0.7516541592901868,78704.0,112783.0,34079.0,43.300213458019925,4.917831444195868,5.185250898677096,8.987486839294434,10.80196762084961,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.0,5,myocardium,0.8324040240697838,162808.0,158757.0,-4051.0,-2.488206967716574,1.7443640645028076,2.5218473524088227,4.282098770141602,6.476954936981201,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.0,6,heart muscle,0.48361053361053363,140816.0,147784.0,6968.0,4.948301329394387,2.271475881032857,4.359970772145242,7.9451680183410645,10.745548248291016,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.1,1,left_ventricle,0.8657444494667639,118040.0,105407.0,-12633.0,-10.702304303625889,2.197391658275227,3.046665064193624,5.2090373039245605,7.559361934661865,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.1,2,right_ventricle,0.8600788928261015,152920.0,146220.0,-6700.0,-4.3813758828145435,3.0834660329582544,4.278083701605016,6.325867176055908,7.303558349609375,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.1,3,left_atrium,0.8223311957751792,73488.0,90874.0,17386.0,23.65828434574352,2.1719244394827206,2.9850057389721423,4.734958648681641,5.226353168487549,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.1,4,right_atrium,0.787870159453303,83872.0,112800.0,28928.0,34.490652422739416,3.9138729232738476,4.534334780607793,6.678016662597656,9.1063232421875,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.1,5,myocardium,0.8396498748668136,163968.0,158888.0,-5080.0,-3.0981654957064793,1.6755336209763518,2.13307262945351,3.4619345664978027,6.803287982940674,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.1,6,heart muscle,0.5031821333681182,143520.0,147794.0,4274.0,2.9779821627647713,2.033498837090625,3.8658314787065127,6.769759654998779,9.777618408203125,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.2,1,left_ventricle,0.8120796532419038,84456.0,105417.0,20961.0,24.818840579710145,3.0148721277120374,3.9303386171313694,6.661726951599121,7.783208847045898,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.2,2,right_ventricle,0.8602547031308683,117192.0,146409.0,29217.0,24.93088265410608,2.5328945147190516,3.1105626205709918,4.3754401206970215,5.3838348388671875,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.2,3,left_atrium,0.892062539223444,92192.0,91053.0,-1139.0,-1.2354651162790697,1.3383788308352667,1.881156479860298,2.9433634281158447,4.479748725891113,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.2,4,right_atrium,0.8955504629690957,113656.0,112817.0,-839.0,-0.7381924403463082,1.805805364576458,2.0234310578058796,3.3915960788726807,4.44731330871582,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.2,5,myocardium,0.7762978441210695,160784.0,158900.0,-1884.0,-1.171758383918798,2.5707733498137975,2.8960168093768734,5.249580383300781,6.872056484222412,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.2,6,heart muscle,0.5591965457966189,147608.0,147916.0,308.0,0.20866077719364803,1.5337964900497336,2.4004377752110857,4.299048900604248,5.292198657989502,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.3,1,left_ventricle,0.6814884241789809,58792.0,105516.0,46724.0,79.47339774118927,5.010473751019862,5.912403127668695,9.174623489379883,11.268267631530762,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.3,2,right_ventricle,0.7736691145059907,99264.0,146531.0,47267.0,47.617464539007095,4.2682569088116376,4.38986523040263,7.204024314880371,8.08535385131836,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.3,3,left_atrium,0.8749169939720893,103144.0,91119.0,-12025.0,-11.658458078026836,1.6963732481372915,2.19501828834201,4.0180535316467285,5.23501443862915,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.3,4,right_atrium,0.8670136053592684,133336.0,112817.0,-20519.0,-15.388942221155578,2.6796865705771227,2.566708488015827,5.290644645690918,6.785412788391113,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.3,5,myocardium,0.6922342328330352,162496.0,158979.0,-3517.0,-2.1643609688853878,3.649634372801347,3.646888038296382,5.8913373947143555,9.070855140686035,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.3,6,heart muscle,0.4960161106733211,148712.0,148234.0,-478.0,-0.32142665016945504,2.272887517335406,3.1024209400749774,5.608739852905273,8.07468318939209,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.4,1,left_ventricle,0.6422777581240208,52600.0,105696.0,53096.0,100.94296577946768,5.366169963381709,6.179492379842353,8.950209617614746,10.438936233520508,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.4,2,right_ventricle,0.7461392892049826,93896.0,146539.0,52643.0,56.06522109568033,4.894454795650142,4.968600265170254,8.548507690429688,9.531269073486328,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.4,3,left_atrium,0.8473707135503287,110896.0,91136.0,-19760.0,-17.81849660943587,2.126076677427093,2.640501261119481,4.919311046600342,6.201644420623779,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.4,4,right_atrium,0.8461467922828197,137896.0,112921.0,-24975.0,-18.11147531472994,3.19071796821738,3.1144464154349154,6.729096412658691,8.859382629394531,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.4,5,myocardium,0.6710814694627084,158936.0,159085.0,149.0,0.09374842703981477,3.93284134643765,3.9072092180128157,5.832981109619141,8.281386375427246,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.4,6,heart muscle,0.45057579793960445,148128.0,148417.0,289.0,0.19510153380859796,2.6615890917601313,3.6490212812365934,6.682876110076904,9.21353816986084,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.5,1,left_ventricle,0.756790444040509,67424.0,105871.0,38447.0,57.022721879449456,3.6735824891441844,4.178946740725674,6.643003463745117,8.291747093200684,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.5,2,right_ventricle,0.8175683850245675,109696.0,146537.0,36841.0,33.584633897316216,3.5791044334617865,3.9131887810229267,6.506562232971191,7.66019868850708,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.5,3,left_atrium,0.8621744638448943,108616.0,91093.0,-17523.0,-16.132982249392356,1.839997083410241,2.1820741298871416,3.5294225215911865,5.298614025115967,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.5,4,right_atrium,0.8853054716347768,125192.0,113128.0,-12064.0,-9.636398491916417,2.2190399920091965,2.627633034224062,4.585684776306152,6.446908950805664,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.5,5,myocardium,0.7469665600502529,152720.0,159302.0,6582.0,4.30984808800419,2.9832305946702524,3.0990811792399673,4.845368385314941,7.416656494140625,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.5,6,heart muscle,0.4937166648434884,153256.0,148415.0,-4841.0,-3.1587670303283395,2.1248215175238254,3.018529848363134,5.349288463592529,7.512984275817871,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.6,1,left_ventricle,0.8950245981636027,94608.0,106220.0,11612.0,12.273803483849147,1.6273265731731332,2.5987283760566915,3.8690857887268066,5.03783655166626,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.6,2,right_ventricle,0.9131691954354949,144352.0,146501.0,2149.0,1.4887220128574596,1.6192765372996771,2.447071784650255,3.0092296600341797,3.514404296875,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.6,3,left_atrium,0.8941430798394412,92960.0,91147.0,-1813.0,-1.9503012048192772,1.1912951207561178,2.0588670947462657,3.031055450439453,4.061551094055176,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.6,4,right_atrium,0.9162658281257634,111928.0,113225.0,1297.0,1.1587806447001643,1.3606863722745253,2.5500878042051283,3.460148334503174,3.7826433181762695,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.6,5,myocardium,0.8758726595456003,164808.0,159488.0,-5320.0,-3.2279986408426775,1.383722656612631,2.0205461664619015,2.9612090587615967,3.9430131912231445,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.6,6,heart muscle,0.6319790892240205,149208.0,148055.0,-1153.0,-0.7727467696102086,1.1245125194503511,2.200614371251536,3.0670197010040283,3.7753188610076904,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.7,1,left_ventricle,0.9342818898877757,109168.0,106739.0,-2429.0,-2.225010992232156,0.9795099752600616,1.6378126369747328,2.310664415359497,2.5547235012054443,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.7,2,right_ventricle,0.9292491735343227,151744.0,146514.0,-5230.0,-3.4465942640236187,1.2817097327179854,2.1252071082786346,2.5096094608306885,2.7933974266052246,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.7,3,left_atrium,0.8935862593760191,92768.0,91212.0,-1556.0,-1.677302518109693,1.2216804264434475,1.7907897968358737,2.2316083908081055,2.437544822692871,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.7,4,right_atrium,0.9267627375825563,114824.0,113202.0,-1622.0,-1.41259666968578,1.1512463867828653,2.1109851004152014,2.365269660949707,2.4618897438049316,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.7,5,myocardium,0.9088043387205367,162728.0,159579.0,-3149.0,-1.9351310161742294,0.9641329612087368,1.4982948172764954,2.3526110649108887,2.56811785697937,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.7,6,heart muscle,0.670274221073348,150256.0,148007.0,-2249.0,-1.4967788307954424,1.0027221703343177,1.9267538399844126,2.4367847442626953,2.8213162422180176,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.8,1,left_ventricle,0.940737949038497,110192.0,107384.0,-2808.0,-2.5482793669231887,0.8900063977034519,1.824832368433339,2.6243438720703125,3.2730071544647217,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.8,2,right_ventricle,0.9270988524260116,151440.0,146580.0,-4860.0,-3.2091917591125196,1.3665538131340464,2.359407560177353,3.2500932216644287,3.847301959991455,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.8,3,left_atrium,0.8980156061527951,95888.0,91474.0,-4414.0,-4.6032871683630905,1.2168225813781535,2.2122026310365293,3.0158262252807617,4.233987331390381,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.8,4,right_atrium,0.9224223338758757,113080.0,113016.0,-64.0,-0.05659709939865582,1.2766214093044768,2.5784037136727327,3.276618003845215,4.05595588684082,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.8,5,myocardium,0.9111285456017019,162840.0,159637.0,-3203.0,-1.9669614345369688,0.9501231359469924,1.8733551658683163,2.9225234985351562,3.238215684890747,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.8,6,heart muscle,0.664229701852586,149480.0,148158.0,-1322.0,-0.8843992507358844,1.030917492160192,2.2822618666317886,3.0714271068573,4.12446928024292,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.9,1,left_ventricle,0.9282695154357867,116424.0,107860.0,-8564.0,-7.355871641585927,1.1534448900315075,2.107739102805174,3.235832691192627,4.439443588256836,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.9,2,right_ventricle,0.9075544411942239,154896.0,146622.0,-8274.0,-5.341648590021692,2.276451885974126,2.7658994017761906,4.504847526550293,5.619327545166016,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.9,3,left_atrium,0.8905049052451534,91432.0,92147.0,715.0,0.7820019249278152,1.3725715250356214,2.3511502192718523,3.2424662113189697,4.129115104675293,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.9,4,right_atrium,0.8746602575236235,100072.0,112957.0,12885.0,12.87572947477816,2.556990047254684,2.9755222501844893,4.1613287925720215,6.539289474487305,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.9,5,myocardium,0.9010914581669841,162160.0,159703.0,-2457.0,-1.5151702022693636,1.0720866842673262,1.914305280767705,2.7436721324920654,4.059568405151367,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +pm0027,0.9,6,heart muscle,0.6248616423931072,148712.0,148525.0,-187.0,-0.1257464091667115,1.244132399569837,2.596216917995732,3.8457963466644287,6.505064487457275,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_duke_heart\pm0027\pm0027_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 diff --git a/tests/baselines/tutorial_11_duke_heart_evaluate_physicsnemo/predicted_surface.png b/tests/baselines/tutorial_11_duke_heart_evaluate_physicsnemo/predicted_surface.png new file mode 100644 index 00000000..8b21a4ec --- /dev/null +++ b/tests/baselines/tutorial_11_duke_heart_evaluate_physicsnemo/predicted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f25f3274f15ffedc8b11d5ba0d4d618d5c1be44bb7b093a36e2a3680905176c1 +size 102943 diff --git a/tests/baselines/tutorial_11_duke_heart_evaluate_physicsnemo/warped_labelmap.png b/tests/baselines/tutorial_11_duke_heart_evaluate_physicsnemo/warped_labelmap.png new file mode 100644 index 00000000..051c9e0c --- /dev/null +++ b/tests/baselines/tutorial_11_duke_heart_evaluate_physicsnemo/warped_labelmap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d659740b5c7a1ab63fdded019553350327013dc1c2bcc9708fa95336b2069431 +size 13729 diff --git a/tests/baselines/tutorial_11_lung_evaluate_physicsnemo/evaluation_metrics.csv b/tests/baselines/tutorial_11_lung_evaluate_physicsnemo/evaluation_metrics.csv new file mode 100644 index 00000000..34a123dd --- /dev/null +++ b/tests/baselines/tutorial_11_lung_evaluate_physicsnemo/evaluation_metrics.csv @@ -0,0 +1,51 @@ +case_id,stage,label_id,label_name,volume_truth_mm3,volume_predicted_mm3,volume_difference_mm3,volume_difference_percent,surface_rmse_mm,displacement_rms_mm,displacement_95th_mm,displacement_max_mm,shape_parameters_file,network_weights_file,network_weights_created,network_weights_modified,network_epoch,pca_c01,pca_c02 +100_HM10395,0.0,28,lung_upper_lobe_left,1247128.0,1125160.0,-121968.0,-9.779910321955725,22.59734088813882,1.3293611949054758,2.2988572120666504,2.6883907318115234,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.0,29,lung_lower_lobe_left,625808.0,482216.0,-143592.0,-22.945056630787718,6.003459977259531,1.2112032135696866,1.7983336448669434,2.2181460857391357,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.0,30,lung_upper_lobe_right,1147944.0,1092240.0,-55704.0,-4.8525015157533815,1.847232770869364,1.092989761572528,1.7153432369232178,2.090266466140747,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.0,31,lung_middle_lobe_right,608448.0,598032.0,-10416.0,-1.711896497317766,4.939933395148422,1.4769443196205432,2.060415267944336,2.4948136806488037,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.0,32,lung_lower_lobe_right,921248.0,750192.0,-171056.0,-18.567855778248635,7.157221705614155,1.2836061006332447,1.791468620300293,2.1965858936309814,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.1,28,lung_upper_lobe_left,1213624.0,1124816.0,-88808.0,-7.317587654825547,19.60077111050765,1.235757926044887,2.0906894207000732,2.633596658706665,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.1,29,lung_lower_lobe_left,551592.0,482216.0,-69376.0,-12.577412290243513,4.376188979360287,1.332280704837673,2.1049716472625732,2.8086655139923096,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.1,30,lung_upper_lobe_right,1140792.0,1092056.0,-48736.0,-4.272119720334644,1.6921912689743195,0.9623838206311158,1.4144086837768555,2.379509687423706,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.1,31,lung_middle_lobe_right,590208.0,597936.0,7728.0,1.3093689004554327,3.7021553987015223,1.3746573298856746,2.2832508087158203,2.703920841217041,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.1,32,lung_lower_lobe_right,869248.0,750192.0,-119056.0,-13.696436460020616,5.487258445226434,1.2859795742469067,1.9523770809173584,2.371945858001709,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.2,28,lung_upper_lobe_left,1161984.0,1124696.0,-37288.0,-3.20899427186605,2.1481982379815134,1.520459460054093,2.559300661087036,3.2253050804138184,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.2,29,lung_lower_lobe_left,511456.0,482216.0,-29240.0,-5.717011825064131,3.794728444034395,1.186790873114045,2.0139081478118896,2.907586097717285,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.2,30,lung_upper_lobe_right,1099024.0,1091904.0,-7120.0,-0.6478475447305974,1.639777665719424,1.4801240257310972,2.7325854301452637,3.8432695865631104,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.2,31,lung_middle_lobe_right,602488.0,597848.0,-4640.0,-0.7701398202121869,2.5610830228414767,1.6252433560015191,2.807868480682373,4.202273368835449,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.2,32,lung_lower_lobe_right,837584.0,750192.0,-87392.0,-10.433819175151388,3.4157779565471387,1.3408683360529152,2.4471845626831055,3.32138729095459,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.3,28,lung_upper_lobe_left,1142040.0,1124816.0,-17224.0,-1.5081783475184758,1.9941732586891676,1.448588980981815,2.240488052368164,2.734217882156372,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.3,29,lung_lower_lobe_left,483544.0,482216.0,-1328.0,-0.2746389160035074,3.6181356040526973,0.9452570942305312,1.4113980531692505,2.175870418548584,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.3,30,lung_upper_lobe_right,1068360.0,1091696.0,23336.0,2.1842824516080723,1.7578053573129016,1.6866027211831576,3.021479606628418,4.024045467376709,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.3,31,lung_middle_lobe_right,572064.0,597776.0,25712.0,4.494602002573139,4.046134520739337,1.5108073671887876,2.390963554382324,3.3211517333984375,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.3,32,lung_lower_lobe_right,810480.0,750192.0,-60288.0,-7.438554930411608,3.262206543024269,1.3444891208577123,2.2703943252563477,4.300925254821777,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.4,28,lung_upper_lobe_left,1118888.0,1124400.0,5512.0,0.4926319703133826,1.9117652678520265,1.4394164568166394,2.288428783416748,2.752718210220337,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.4,29,lung_lower_lobe_left,465040.0,482216.0,17176.0,3.693445725098916,3.7379215045593055,1.0005182734624554,1.7812836170196533,2.6884047985076904,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.4,30,lung_upper_lobe_right,1055928.0,1091512.0,35584.0,3.369926737429067,1.9726177708513501,1.8265510776941698,3.190497636795044,4.203304290771484,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.4,31,lung_middle_lobe_right,551064.0,597672.0,46608.0,8.457819781368407,4.46078588011691,1.605488381163337,2.553335428237915,3.4330928325653076,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.4,32,lung_lower_lobe_right,790528.0,750192.0,-40336.0,-5.10241256476684,3.7023804736766857,1.4440532647653623,2.824445962905884,4.020665168762207,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.5,28,lung_upper_lobe_left,1092008.0,1124344.0,32336.0,2.961150467762141,2.0304094607907768,1.4049298824251462,2.274627208709717,2.753195285797119,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.5,29,lung_lower_lobe_left,452488.0,482216.0,29728.0,6.569897986244939,4.1907064915667505,1.0657237949334257,1.9877759218215942,2.8016915321350098,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.5,30,lung_upper_lobe_right,1044376.0,1091264.0,46888.0,4.489570805916643,1.9445347230993397,1.8157327495128508,3.0918819904327393,3.972146511077881,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.5,31,lung_middle_lobe_right,535968.0,597560.0,61592.0,11.49173084960296,4.06600612779634,1.5798295054691056,2.3993844985961914,3.0450222492218018,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.5,32,lung_lower_lobe_right,773376.0,750192.0,-23184.0,-2.9977656405163855,4.122870330406736,1.5471723154147168,3.0641872882843018,4.710415840148926,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.6,28,lung_upper_lobe_left,1077192.0,1124560.0,47368.0,4.397359059480576,1.4810083958198195,1.374556951272856,2.1888010501861572,2.6975951194763184,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.6,29,lung_lower_lobe_left,460048.0,482216.0,22168.0,4.818627621465586,3.820420828319244,1.3787702254219822,2.7093417644500732,3.9727723598480225,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.6,30,lung_upper_lobe_right,1061896.0,1091056.0,29160.0,2.7460316264492945,1.5905485703019935,1.7351279712933914,2.880733013153076,3.5141656398773193,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.6,31,lung_middle_lobe_right,528872.0,597424.0,68552.0,12.961926515300489,3.476800695395329,1.6501909882627448,2.351189374923706,2.801684617996216,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.6,32,lung_lower_lobe_right,760728.0,750192.0,-10536.0,-1.384989115689182,3.1636877412059183,1.796030778364978,3.8484885692596436,5.032223701477051,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.7,28,lung_upper_lobe_left,1116864.0,1124792.0,7928.0,0.7098447080396539,0.6750635885623405,1.0798528545914785,1.2750544548034668,1.3190172910690308,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.7,29,lung_lower_lobe_left,482216.0,482216.0,0.0,0.0,0.000686907251553814,0.943850750161703,1.139747142791748,1.2172459363937378,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.7,30,lung_upper_lobe_right,1088816.0,1090848.0,2032.0,0.18662473732935592,0.27883310615255624,1.0009153409022327,1.2305805683135986,1.2820329666137695,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.7,31,lung_middle_lobe_right,597160.0,597216.0,56.0,0.00937772121374506,0.040629709857462455,1.0887787722471307,1.2395585775375366,1.2785011529922485,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.7,32,lung_lower_lobe_right,750192.0,750192.0,0.0,0.0,9.725457184803924e-20,0.9361043103411814,1.071929931640625,1.1197010278701782,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.8,28,lung_upper_lobe_left,1159160.0,1124816.0,-34344.0,-2.9628351564926327,10.71586526966627,1.1330075570404354,1.769662857055664,2.292872190475464,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.8,29,lung_lower_lobe_left,533304.0,482216.0,-51088.0,-9.579526873978068,3.9827276109658243,1.4167171554599685,2.302337646484375,3.1248161792755127,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.8,30,lung_upper_lobe_right,1101920.0,1090688.0,-11232.0,-1.0193117467692754,1.1059790564835421,1.005331848147252,1.5207074880599976,2.1204030513763428,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.8,31,lung_middle_lobe_right,627664.0,597160.0,-30504.0,-4.859925055443677,2.0209107052925463,1.47454875411981,2.1354572772979736,2.6988723278045654,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.8,32,lung_lower_lobe_right,823624.0,750192.0,-73432.0,-8.915718823152313,4.5613673093701435,1.4791363641999113,2.2645530700683594,2.858551502227783,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.9,28,lung_upper_lobe_left,1227872.0,1124744.0,-103128.0,-8.398921060175654,19.23413742938751,1.311652844743838,2.2204177379608154,2.891554832458496,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.9,29,lung_lower_lobe_left,586960.0,482248.0,-104712.0,-17.83971650538367,4.876496669599361,1.3771084446266648,2.1110615730285645,2.6375181674957275,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.9,30,lung_upper_lobe_right,1127688.0,1090216.0,-37472.0,-3.3229049169628477,1.687525102356186,1.0416776002633863,1.6292734146118164,1.920794129371643,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.9,31,lung_middle_lobe_right,613520.0,597160.0,-16360.0,-2.666579736601904,4.442918679104028,1.518274927320177,2.274901866912842,2.7643096446990967,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +100_HM10395,0.9,32,lung_lower_lobe_right,891672.0,750192.0,-141480.0,-15.86682098350066,6.1506046354200175,1.3398189709186605,1.906567096710205,2.381338596343994,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_08_lung\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 diff --git a/tests/baselines/tutorial_11_lung_evaluate_physicsnemo/predicted_surface.png b/tests/baselines/tutorial_11_lung_evaluate_physicsnemo/predicted_surface.png new file mode 100644 index 00000000..2193591e --- /dev/null +++ b/tests/baselines/tutorial_11_lung_evaluate_physicsnemo/predicted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34eb1a70647c4f05dfbd1f5fbce2a6c38cdc4b8491c0116e9a74cb6e5093716a +size 95277 diff --git a/tests/baselines/tutorial_11_lung_evaluate_physicsnemo/warped_labelmap.png b/tests/baselines/tutorial_11_lung_evaluate_physicsnemo/warped_labelmap.png new file mode 100644 index 00000000..db06288c --- /dev/null +++ b/tests/baselines/tutorial_11_lung_evaluate_physicsnemo/warped_labelmap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e800042e70f0907d9a0309f2793ea247eb5bec2e2039b342aeb38291a3678e2d +size 19980 diff --git a/tests/baselines/tutorial_12_duke_heart_end_to_end_inference/fitted_reference_surface.png b/tests/baselines/tutorial_12_duke_heart_end_to_end_inference/fitted_reference_surface.png new file mode 100644 index 00000000..d4910963 --- /dev/null +++ b/tests/baselines/tutorial_12_duke_heart_end_to_end_inference/fitted_reference_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0a359eece91f184b0c5e9c1d0954d6a8d39509035511c72293ed46c70a0b681 +size 106932 diff --git a/tests/baselines/tutorial_12_duke_heart_end_to_end_inference/pm0027_ssm_pca_coefficients.json b/tests/baselines/tutorial_12_duke_heart_end_to_end_inference/pm0027_ssm_pca_coefficients.json new file mode 100644 index 00000000..ef44aa2a --- /dev/null +++ b/tests/baselines/tutorial_12_duke_heart_end_to_end_inference/pm0027_ssm_pca_coefficients.json @@ -0,0 +1 @@ +[0.2698413751454639, 0.0045983690765939] \ No newline at end of file diff --git a/tests/baselines/tutorial_12_duke_heart_end_to_end_inference/predicted_surface.png b/tests/baselines/tutorial_12_duke_heart_end_to_end_inference/predicted_surface.png new file mode 100644 index 00000000..8b21a4ec --- /dev/null +++ b/tests/baselines/tutorial_12_duke_heart_end_to_end_inference/predicted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f25f3274f15ffedc8b11d5ba0d4d618d5c1be44bb7b093a36e2a3680905176c1 +size 102943 diff --git a/tests/baselines/tutorial_12_lung_end_to_end_inference/100_HM10395_ssm_pca_coefficients.json b/tests/baselines/tutorial_12_lung_end_to_end_inference/100_HM10395_ssm_pca_coefficients.json new file mode 100644 index 00000000..3495ee2c --- /dev/null +++ b/tests/baselines/tutorial_12_lung_end_to_end_inference/100_HM10395_ssm_pca_coefficients.json @@ -0,0 +1 @@ +[-0.6662175077768214, 0.6316875738635941] \ No newline at end of file diff --git a/tests/baselines/tutorial_12_lung_end_to_end_inference/fitted_reference_surface.png b/tests/baselines/tutorial_12_lung_end_to_end_inference/fitted_reference_surface.png new file mode 100644 index 00000000..85dd7c5b --- /dev/null +++ b/tests/baselines/tutorial_12_lung_end_to_end_inference/fitted_reference_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80c518eb306dce87547737a888e66cedc1056dfe52c9f8406661aa5a7b624ef2 +size 99751 diff --git a/tests/baselines/tutorial_12_lung_end_to_end_inference/predicted_surface.png b/tests/baselines/tutorial_12_lung_end_to_end_inference/predicted_surface.png new file mode 100644 index 00000000..2193591e --- /dev/null +++ b/tests/baselines/tutorial_12_lung_end_to_end_inference/predicted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34eb1a70647c4f05dfbd1f5fbce2a6c38cdc4b8491c0116e9a74cb6e5093716a +size 95277 diff --git a/tests/baselines/tutorial_13_heart_and_lung_motion/combined_labelmap.png b/tests/baselines/tutorial_13_heart_and_lung_motion/combined_labelmap.png new file mode 100644 index 00000000..1e4d09ee --- /dev/null +++ b/tests/baselines/tutorial_13_heart_and_lung_motion/combined_labelmap.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a0a49264ecbd635a0196c986fd1126b8ce8c4a0edcd9904ddd2ae4fdb9e191c +size 4243 diff --git a/tests/baselines/tutorial_13_heart_and_lung_motion/combined_motion_surface.png b/tests/baselines/tutorial_13_heart_and_lung_motion/combined_motion_surface.png new file mode 100644 index 00000000..2d625ba3 --- /dev/null +++ b/tests/baselines/tutorial_13_heart_and_lung_motion/combined_motion_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c33865fdf64769b9f44d39a221ec83785a029fb396376fb700a5a6e50ac1a0fb +size 139801 diff --git a/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/perturbed_surface.png b/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/perturbed_surface.png new file mode 100644 index 00000000..b97cbe80 --- /dev/null +++ b/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/perturbed_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e093fbb18a98abdd02b7889d6c5458e35e8107545b9399b057d889188ef88619 +size 101372 diff --git a/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/shape_sweep_metrics.csv b/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/shape_sweep_metrics.csv new file mode 100644 index 00000000..c9aea575 --- /dev/null +++ b/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/shape_sweep_metrics.csv @@ -0,0 +1,181 @@ +combination,mode_00_offset,case_id,stage,label_id,label_name,dice,volume_truth_mm3,volume_predicted_mm3,volume_difference_mm3,volume_difference_percent,surface_rmse_mm,displacement_rms_mm,displacement_95th_mm,displacement_max_mm,shape_parameters_file,network_weights_file,network_weights_created,network_weights_modified,network_epoch,pca_c01,pca_c02 +0,-1.0,pm0027,0.0,1,left_ventricle,0.8723022337043592,131960.0,106478.0,-25482.0,-19.31039709002728,2.1811513882849205,3.197325087514369,5.128337383270264,5.956072807312012,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.0,2,right_ventricle,0.8476280912736095,170088.0,146893.0,-23195.0,-13.637058463854006,3.804104392908385,4.810463927386163,8.249835014343262,10.131515502929688,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.0,3,left_atrium,0.8162199552561554,70752.0,89717.0,18965.0,26.804895974672093,2.227810437871792,3.2507484931577686,4.731041431427002,5.545411586761475,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.0,4,right_atrium,0.7654284771368853,78704.0,112193.0,33489.0,42.55056922138646,4.684785842729726,5.002469593733026,8.471931457519531,10.088789939880371,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.0,5,myocardium,0.8388184867685426,162808.0,159152.0,-3656.0,-2.245589897302344,1.7317319873633217,2.510100742285489,4.036837577819824,5.8746490478515625,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.0,6,heart muscle,0.49805460975922977,140816.0,147301.0,6485.0,4.605300534030224,2.1785597396412206,4.157703557705394,7.47147274017334,10.05687427520752,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.1,1,left_ventricle,0.8800370335750308,118040.0,106621.0,-11419.0,-9.673839376482547,2.0682754238776795,2.866915071650756,4.8258795738220215,6.895648956298828,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.1,2,right_ventricle,0.8637470397918682,152920.0,146890.0,-6030.0,-3.943238294533089,2.971954938694668,4.043650953581642,5.852945327758789,7.160029888153076,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.1,3,left_atrium,0.835005574136009,73488.0,89766.0,16278.0,22.15055519268452,1.9929187235951629,2.7702559581960817,4.155952453613281,4.580832004547119,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.1,4,right_atrium,0.8001162470555845,83872.0,112262.0,28390.0,33.84919877909195,3.730588053705038,4.439122777383481,6.658151626586914,8.536657333374023,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.1,5,myocardium,0.8611438560665832,163968.0,159237.0,-4731.0,-2.885319086651054,1.57073317611928,1.974640610547561,3.1757054328918457,6.096802234649658,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.1,6,heart muscle,0.5170870850230256,143520.0,147460.0,3940.0,2.7452619843924193,1.9261783350709463,3.6759204051794496,6.139378547668457,9.327751159667969,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.2,1,left_ventricle,0.8144287276742495,84456.0,106635.0,22179.0,26.261011651037226,2.903998896089346,3.6041240341761482,5.544872760772705,6.959298610687256,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.2,2,right_ventricle,0.8636828431409679,117192.0,146854.0,29662.0,25.310601406239332,2.514411838702987,2.962099616015902,4.3312530517578125,5.239577293395996,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.2,3,left_atrium,0.8880526396866266,92192.0,89572.0,-2620.0,-2.841895175286359,1.309359154646661,1.6709225112006736,2.747797727584839,3.957869052886963,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.2,4,right_atrium,0.9006137249609508,113656.0,112341.0,-1315.0,-1.157000070387837,1.6978145026923552,2.1373366124932827,3.5439043045043945,4.175117492675781,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.2,5,myocardium,0.7856183844880882,160784.0,159486.0,-1298.0,-0.8072942581351378,2.5364616190892453,2.5296172421162386,4.257918357849121,5.514776706695557,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.2,6,heart muscle,0.5671006093735694,147608.0,147285.0,-323.0,-0.21882282803100103,1.5019612944589167,2.22199822526563,4.080682277679443,5.115999698638916,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.3,1,left_ventricle,0.6694223865984988,58792.0,106682.0,47890.0,81.45666077017282,5.067452896360443,5.765168536311452,8.470610618591309,10.220577239990234,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.3,2,right_ventricle,0.778237138355074,99264.0,146972.0,47708.0,48.061734364925854,4.223792368949297,4.345177020466603,7.287367820739746,8.232664108276367,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.3,3,left_atrium,0.873174832141634,103144.0,89578.0,-13566.0,-13.152485845032189,1.698580587644131,2.009057607776737,3.7840147018432617,5.019970417022705,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.3,4,right_atrium,0.8630537861983353,133336.0,112359.0,-20977.0,-15.732435351292974,2.793843702071333,2.785028130752271,5.9664106369018555,7.412437915802002,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.3,5,myocardium,0.6901145402876537,162496.0,159836.0,-2660.0,-1.6369633714060654,3.751767583744682,3.44723235277016,5.397900581359863,7.643704891204834,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.3,6,heart muscle,0.5014979009122503,148712.0,147369.0,-1343.0,-0.9030878476518371,2.2430671809634104,2.971069371450337,5.69838285446167,7.992060661315918,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.4,1,left_ventricle,0.6339504235958582,52600.0,106750.0,54150.0,102.9467680608365,5.479260578777881,6.127788520200941,8.864564895629883,10.37524700164795,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.4,2,right_ventricle,0.7512605251254301,93896.0,147075.0,53179.0,56.6360654340973,4.860188936501859,5.001599301124213,8.685564994812012,9.775555610656738,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.4,3,left_atrium,0.8458638743455498,110896.0,89654.0,-21242.0,-19.15488385514356,2.174369511447235,2.4904734067924936,4.7570481300354,6.10214376449585,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.4,4,right_atrium,0.8393748426516838,137896.0,112345.0,-25551.0,-18.529181412078668,3.3190837565007447,3.3766054104723953,7.344672203063965,9.484707832336426,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.4,5,myocardium,0.668658602974764,158936.0,160686.0,1750.0,1.101072129662254,4.027880956756922,3.8106429249834224,5.857476234436035,8.514799118041992,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.4,6,heart muscle,0.45667372809659496,148128.0,147374.0,-754.0,-0.5090192266148196,2.659249374304474,3.562211521920679,6.827654838562012,9.413131713867188,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.5,1,left_ventricle,0.7513860988348734,67424.0,106806.0,39382.0,58.409468438538205,3.7276850385084033,4.045374482472068,6.286413669586182,8.325846672058105,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.5,2,right_ventricle,0.8215680477976071,109696.0,147388.0,37692.0,34.36041423570595,3.558949720335874,3.8718325454539326,6.43193244934082,7.816004753112793,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.5,3,left_atrium,0.8559754645790036,108616.0,89628.0,-18988.0,-17.48177064152611,1.9218231080308295,2.0396707418736915,3.5862340927124023,4.839521408081055,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.5,4,right_atrium,0.8859464466150219,125192.0,112328.0,-12864.0,-10.275416959550132,2.2263253667282945,2.7429014466374806,4.831624984741211,6.7413010597229,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.5,5,myocardium,0.7482455468789874,152720.0,160768.0,8048.0,5.269774751178628,3.0140469465897564,2.9344863182655434,4.6457414627075195,7.000385761260986,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.5,6,heart muscle,0.5063199350709828,153256.0,147380.0,-5876.0,-3.83410763689513,2.1043434731023942,2.8823756783135575,5.429906368255615,7.75805139541626,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.6,1,left_ventricle,0.9019962080979939,94608.0,106874.0,12266.0,12.965076949095215,1.614308045330067,2.341490572363836,3.838693618774414,5.109086990356445,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.6,2,right_ventricle,0.9218706110452649,144352.0,147574.0,3222.0,2.232043892706717,1.4928855054923909,2.2469544777309824,2.905182361602783,3.3770358562469482,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.6,3,left_atrium,0.9016748207114564,92960.0,89567.0,-3393.0,-3.6499569707401034,1.0590229424817874,1.91874888162571,2.810450315475464,4.189711570739746,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.6,4,right_atrium,0.928736083182108,111928.0,112351.0,423.0,0.37792152097777143,1.1934882280500383,2.4328615853663766,3.305511236190796,3.7580068111419678,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.6,5,myocardium,0.8919187885861719,164808.0,160762.0,-4046.0,-2.45497791369351,1.3147566677152178,1.8623350684937026,2.8543508052825928,4.131130695343018,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.6,6,heart muscle,0.6684061488673139,149208.0,147432.0,-1776.0,-1.1902847032330706,1.0253066952026146,2.0265955829375932,2.9302115440368652,4.023420810699463,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.7,1,left_ventricle,0.9460846230365281,109168.0,106967.0,-2201.0,-2.016158581269236,0.7837119788055505,1.3819643383342801,1.8354049921035767,2.1397922039031982,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.7,2,right_ventricle,0.9393699498618779,151744.0,147629.0,-4115.0,-2.7118040911008015,1.1218019550015932,2.0158721349882707,2.5668785572052,2.7644388675689697,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.7,3,left_atrium,0.8980702649613449,92768.0,89485.0,-3283.0,-3.5389358399448088,1.115801620508639,1.840483388080927,2.2158255577087402,2.4891583919525146,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.7,4,right_atrium,0.9361065389795002,114824.0,112398.0,-2426.0,-2.1127987180380408,1.0350989077580215,1.9842346403865883,2.7095963954925537,2.8897268772125244,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.7,5,myocardium,0.9266202530080255,162728.0,160740.0,-1988.0,-1.221670517673664,0.786370548457041,1.4115152178762982,2.0691542625427246,2.292844533920288,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.7,6,heart muscle,0.6959007811870822,150256.0,147752.0,-2504.0,-1.6664891917793632,0.9354905078595885,1.956195681064313,2.5262787342071533,2.960219621658325,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.8,1,left_ventricle,0.9411277520877908,110192.0,107143.0,-3049.0,-2.766988529112821,0.8568755414411208,1.7268814055670598,2.372119665145874,2.839686632156372,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.8,2,right_ventricle,0.9322147247059138,151440.0,147622.0,-3818.0,-2.5211304807184365,1.2922834852273897,2.268182268353416,3.030355453491211,3.7766683101654053,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.8,3,left_atrium,0.8931883870411113,95888.0,89560.0,-6328.0,-6.599365926914734,1.206421202067111,2.2947455577938327,3.445887565612793,5.210544586181641,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.8,4,right_atrium,0.9266672578928699,113080.0,112440.0,-640.0,-0.5659709939865581,1.1950699741597022,2.5866722094715446,3.3174915313720703,3.8190572261810303,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.8,5,myocardium,0.9191932617262962,162840.0,160685.0,-2155.0,-1.3233849177106363,0.8772752083436974,1.7954142529563357,2.607945680618286,3.0269057750701904,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.8,6,heart muscle,0.6710828796128252,149480.0,148060.0,-1420.0,-0.94995986085095,1.0064556904810196,2.30382981215571,3.2495369911193848,3.962618589401245,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.9,1,left_ventricle,0.9282815461079129,116424.0,107312.0,-9112.0,-7.826564969422113,1.0825521056674856,1.9617017999062367,2.9250922203063965,3.935936212539673,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.9,2,right_ventricle,0.9101155534401608,154896.0,147648.0,-7248.0,-4.679268670591881,2.248848976085303,2.7142638311603084,4.472189903259277,5.5867438316345215,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.9,3,left_atrium,0.8892812606905989,91432.0,89802.0,-1630.0,-1.7827456470382361,1.2437406140891092,2.2471221324863535,3.0472798347473145,4.417016983032227,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.9,4,right_atrium,0.8807923028064645,100072.0,112473.0,12401.0,12.392077704053081,2.4688913754457196,2.926931933084625,4.22500467300415,6.500915050506592,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.9,5,myocardium,0.9083519962333466,162160.0,160673.0,-1487.0,-0.9169955599407992,0.9795414482806307,1.8124091158665419,2.5973076820373535,3.2890756130218506,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +0,-1.0,pm0027,0.9,6,heart muscle,0.6300066013714686,148712.0,148196.0,-516.0,-0.346979396417236,1.2239961328295081,2.5623259064907145,3.9734017848968506,6.445974826812744,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,-0.7301586270332336,0.004598368890583515 +1,0.0,pm0027,0.0,1,left_ventricle,0.8634388317135205,131960.0,105516.0,-26444.0,-20.03940588056987,2.2814421548119364,3.2502834219598604,5.638106822967529,6.834604263305664,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.0,2,right_ventricle,0.8428864257515943,170088.0,146040.0,-24048.0,-14.13856356709468,3.947280212461157,5.070778971521228,8.673553466796875,10.534096717834473,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.0,3,left_atrium,0.8063399684239854,70752.0,90763.0,20011.0,28.283299412030754,2.3870278591893297,3.445597803693893,5.197455406188965,6.178326606750488,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.0,4,right_atrium,0.7516541592901868,78704.0,112783.0,34079.0,43.300213458019925,4.917831444195868,5.185250898677096,8.987486839294434,10.80196762084961,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.0,5,myocardium,0.8324040240697838,162808.0,158757.0,-4051.0,-2.488206967716574,1.7443640645028076,2.5218473524088227,4.282098770141602,6.476954936981201,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.0,6,heart muscle,0.48361053361053363,140816.0,147784.0,6968.0,4.948301329394387,2.271475881032857,4.359970772145242,7.9451680183410645,10.745548248291016,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.1,1,left_ventricle,0.8657444494667639,118040.0,105407.0,-12633.0,-10.702304303625889,2.197391658275227,3.046665064193624,5.2090373039245605,7.559361934661865,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.1,2,right_ventricle,0.8600788928261015,152920.0,146220.0,-6700.0,-4.3813758828145435,3.0834660329582544,4.278083701605016,6.325867176055908,7.303558349609375,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.1,3,left_atrium,0.8223311957751792,73488.0,90874.0,17386.0,23.65828434574352,2.1719244394827206,2.9850057389721423,4.734958648681641,5.226353168487549,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.1,4,right_atrium,0.787870159453303,83872.0,112800.0,28928.0,34.490652422739416,3.9138729232738476,4.534334780607793,6.678016662597656,9.1063232421875,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.1,5,myocardium,0.8396498748668136,163968.0,158888.0,-5080.0,-3.0981654957064793,1.6755336209763518,2.13307262945351,3.4619345664978027,6.803287982940674,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.1,6,heart muscle,0.5031821333681182,143520.0,147794.0,4274.0,2.9779821627647713,2.033498837090625,3.8658314787065127,6.769759654998779,9.777618408203125,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.2,1,left_ventricle,0.8120796532419038,84456.0,105417.0,20961.0,24.818840579710145,3.0148721277120374,3.9303386171313694,6.661726951599121,7.783208847045898,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.2,2,right_ventricle,0.8602547031308683,117192.0,146409.0,29217.0,24.93088265410608,2.5328945147190516,3.1105626205709918,4.3754401206970215,5.3838348388671875,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.2,3,left_atrium,0.892062539223444,92192.0,91053.0,-1139.0,-1.2354651162790697,1.3383788308352667,1.881156479860298,2.9433634281158447,4.479748725891113,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.2,4,right_atrium,0.8955504629690957,113656.0,112817.0,-839.0,-0.7381924403463082,1.805805364576458,2.0234310578058796,3.3915960788726807,4.44731330871582,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.2,5,myocardium,0.7762978441210695,160784.0,158900.0,-1884.0,-1.171758383918798,2.5707733498137975,2.8960168093768734,5.249580383300781,6.872056484222412,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.2,6,heart muscle,0.5591965457966189,147608.0,147916.0,308.0,0.20866077719364803,1.5337964900497336,2.4004377752110857,4.299048900604248,5.292198657989502,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.3,1,left_ventricle,0.6814884241789809,58792.0,105516.0,46724.0,79.47339774118927,5.010473751019862,5.912403127668695,9.174623489379883,11.268267631530762,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.3,2,right_ventricle,0.7736691145059907,99264.0,146531.0,47267.0,47.617464539007095,4.2682569088116376,4.38986523040263,7.204024314880371,8.08535385131836,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.3,3,left_atrium,0.8749169939720893,103144.0,91119.0,-12025.0,-11.658458078026836,1.6963732481372915,2.19501828834201,4.0180535316467285,5.23501443862915,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.3,4,right_atrium,0.8670136053592684,133336.0,112817.0,-20519.0,-15.388942221155578,2.6796865705771227,2.566708488015827,5.290644645690918,6.785412788391113,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.3,5,myocardium,0.6922342328330352,162496.0,158979.0,-3517.0,-2.1643609688853878,3.649634372801347,3.646888038296382,5.8913373947143555,9.070855140686035,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.3,6,heart muscle,0.4960161106733211,148712.0,148234.0,-478.0,-0.32142665016945504,2.272887517335406,3.1024209400749774,5.608739852905273,8.07468318939209,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.4,1,left_ventricle,0.6422777581240208,52600.0,105696.0,53096.0,100.94296577946768,5.366169963381709,6.179492379842353,8.950209617614746,10.438936233520508,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.4,2,right_ventricle,0.7461392892049826,93896.0,146539.0,52643.0,56.06522109568033,4.894454795650142,4.968600265170254,8.548507690429688,9.531269073486328,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.4,3,left_atrium,0.8473707135503287,110896.0,91136.0,-19760.0,-17.81849660943587,2.126076677427093,2.640501261119481,4.919311046600342,6.201644420623779,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.4,4,right_atrium,0.8461467922828197,137896.0,112921.0,-24975.0,-18.11147531472994,3.19071796821738,3.1144464154349154,6.729096412658691,8.859382629394531,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.4,5,myocardium,0.6710814694627084,158936.0,159085.0,149.0,0.09374842703981477,3.93284134643765,3.9072092180128157,5.832981109619141,8.281386375427246,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.4,6,heart muscle,0.45057579793960445,148128.0,148417.0,289.0,0.19510153380859796,2.6615890917601313,3.6490212812365934,6.682876110076904,9.21353816986084,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.5,1,left_ventricle,0.756790444040509,67424.0,105871.0,38447.0,57.022721879449456,3.6735824891441844,4.178946740725674,6.643003463745117,8.291747093200684,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.5,2,right_ventricle,0.8175683850245675,109696.0,146537.0,36841.0,33.584633897316216,3.5791044334617865,3.9131887810229267,6.506562232971191,7.66019868850708,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.5,3,left_atrium,0.8621744638448943,108616.0,91093.0,-17523.0,-16.132982249392356,1.839997083410241,2.1820741298871416,3.5294225215911865,5.298614025115967,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.5,4,right_atrium,0.8853054716347768,125192.0,113128.0,-12064.0,-9.636398491916417,2.2190399920091965,2.627633034224062,4.585684776306152,6.446908950805664,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.5,5,myocardium,0.7469665600502529,152720.0,159302.0,6582.0,4.30984808800419,2.9832305946702524,3.0990811792399673,4.845368385314941,7.416656494140625,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.5,6,heart muscle,0.4937166648434884,153256.0,148415.0,-4841.0,-3.1587670303283395,2.1248215175238254,3.018529848363134,5.349288463592529,7.512984275817871,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.6,1,left_ventricle,0.8950245981636027,94608.0,106220.0,11612.0,12.273803483849147,1.6273265731731332,2.5987283760566915,3.8690857887268066,5.03783655166626,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.6,2,right_ventricle,0.9131691954354949,144352.0,146501.0,2149.0,1.4887220128574596,1.6192765372996771,2.447071784650255,3.0092296600341797,3.514404296875,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.6,3,left_atrium,0.8941430798394412,92960.0,91147.0,-1813.0,-1.9503012048192772,1.1912951207561178,2.0588670947462657,3.031055450439453,4.061551094055176,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.6,4,right_atrium,0.9162658281257634,111928.0,113225.0,1297.0,1.1587806447001643,1.3606863722745253,2.5500878042051283,3.460148334503174,3.7826433181762695,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.6,5,myocardium,0.8758726595456003,164808.0,159488.0,-5320.0,-3.2279986408426775,1.383722656612631,2.0205461664619015,2.9612090587615967,3.9430131912231445,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.6,6,heart muscle,0.6319790892240205,149208.0,148055.0,-1153.0,-0.7727467696102086,1.1245125194503511,2.200614371251536,3.0670197010040283,3.7753188610076904,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.7,1,left_ventricle,0.9342818898877757,109168.0,106739.0,-2429.0,-2.225010992232156,0.9795099752600616,1.6378126369747328,2.310664415359497,2.5547235012054443,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.7,2,right_ventricle,0.9292491735343227,151744.0,146514.0,-5230.0,-3.4465942640236187,1.2817097327179854,2.1252071082786346,2.5096094608306885,2.7933974266052246,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.7,3,left_atrium,0.8935862593760191,92768.0,91212.0,-1556.0,-1.677302518109693,1.2216804264434475,1.7907897968358737,2.2316083908081055,2.437544822692871,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.7,4,right_atrium,0.9267627375825563,114824.0,113202.0,-1622.0,-1.41259666968578,1.1512463867828653,2.1109851004152014,2.365269660949707,2.4618897438049316,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.7,5,myocardium,0.9088043387205367,162728.0,159579.0,-3149.0,-1.9351310161742294,0.9641329612087368,1.4982948172764954,2.3526110649108887,2.56811785697937,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.7,6,heart muscle,0.670274221073348,150256.0,148007.0,-2249.0,-1.4967788307954424,1.0027221703343177,1.9267538399844126,2.4367847442626953,2.8213162422180176,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.8,1,left_ventricle,0.940737949038497,110192.0,107384.0,-2808.0,-2.5482793669231887,0.8900063977034519,1.824832368433339,2.6243438720703125,3.2730071544647217,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.8,2,right_ventricle,0.9270988524260116,151440.0,146580.0,-4860.0,-3.2091917591125196,1.3665538131340464,2.359407560177353,3.2500932216644287,3.847301959991455,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.8,3,left_atrium,0.8980156061527951,95888.0,91474.0,-4414.0,-4.6032871683630905,1.2168225813781535,2.2122026310365293,3.0158262252807617,4.233987331390381,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.8,4,right_atrium,0.9224223338758757,113080.0,113016.0,-64.0,-0.05659709939865582,1.2766214093044768,2.5784037136727327,3.276618003845215,4.05595588684082,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.8,5,myocardium,0.9111285456017019,162840.0,159637.0,-3203.0,-1.9669614345369688,0.9501231359469924,1.8733551658683163,2.9225234985351562,3.238215684890747,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.8,6,heart muscle,0.664229701852586,149480.0,148158.0,-1322.0,-0.8843992507358844,1.030917492160192,2.2822618666317886,3.0714271068573,4.12446928024292,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.9,1,left_ventricle,0.9282695154357867,116424.0,107860.0,-8564.0,-7.355871641585927,1.1534448900315075,2.107739102805174,3.235832691192627,4.439443588256836,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.9,2,right_ventricle,0.9075544411942239,154896.0,146622.0,-8274.0,-5.341648590021692,2.276451885974126,2.7658994017761906,4.504847526550293,5.619327545166016,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.9,3,left_atrium,0.8905049052451534,91432.0,92147.0,715.0,0.7820019249278152,1.3725715250356214,2.3511502192718523,3.2424662113189697,4.129115104675293,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.9,4,right_atrium,0.8746602575236235,100072.0,112957.0,12885.0,12.87572947477816,2.556990047254684,2.9755222501844893,4.1613287925720215,6.539289474487305,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.9,5,myocardium,0.9010914581669841,162160.0,159703.0,-2457.0,-1.5151702022693636,1.0720866842673262,1.914305280767705,2.7436721324920654,4.059568405151367,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +1,0.0,pm0027,0.9,6,heart muscle,0.6248616423931072,148712.0,148525.0,-187.0,-0.1257464091667115,1.244132399569837,2.596216917995732,3.8457963466644287,6.505064487457275,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,0.26984137296676636,0.004598368890583515 +2,1.0,pm0027,0.0,1,left_ventricle,0.8573646516752642,131960.0,107644.0,-24316.0,-18.42679599878751,2.3397626687593625,3.521465203311755,5.737312316894531,6.61720085144043,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.0,2,right_ventricle,0.8456787606372468,170088.0,148721.0,-21367.0,-12.562320681059216,3.8629793430448234,4.987853948516521,8.373924255371094,10.194015502929688,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.0,3,left_atrium,0.793359271757236,70752.0,91941.0,21189.0,29.94827001356852,2.6111545841956527,3.909044526382145,5.963220596313477,7.198519706726074,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.0,4,right_atrium,0.7508022832020821,78704.0,114183.0,35479.0,45.0790302907095,4.912176429840525,5.13914835041559,9.024751663208008,10.90324592590332,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.0,5,myocardium,0.8175227184153712,162808.0,160498.0,-2310.0,-1.4188491965996757,1.8791413630496578,2.930112846722237,4.651979923248291,6.371145248413086,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.0,6,heart muscle,0.46769458202944203,140816.0,148702.0,7886.0,5.6002158845585726,2.334725333260417,4.4668926031262295,7.759372711181641,10.847783088684082,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.1,1,left_ventricle,0.857840357282104,118040.0,107664.0,-10376.0,-8.790240596407997,2.3425281370643076,3.427089255026906,5.581583499908447,6.97042989730835,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.1,2,right_ventricle,0.8612236239368367,152920.0,148774.0,-4146.0,-2.711221553753597,3.0424509706101945,4.23671668761631,6.158285617828369,7.3356757164001465,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.1,3,left_atrium,0.8094336541195115,73488.0,91835.0,18347.0,24.96598084040932,2.35499313967795,3.466477451180394,5.171605110168457,6.232150077819824,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.1,4,right_atrium,0.7892876975112323,83872.0,114218.0,30346.0,36.18132392216711,3.8912185026570314,4.406737527690346,6.605430603027344,9.229901313781738,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.1,5,myocardium,0.822450639650965,163968.0,160584.0,-3384.0,-2.0638173302107727,1.9189772554995248,2.6416426550094063,4.108247756958008,6.427367687225342,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.1,6,heart muscle,0.49019460206854537,143520.0,148666.0,5146.0,3.5855629877369006,2.0701917068524325,3.9771954475872784,6.7363691329956055,9.717546463012695,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.2,1,left_ventricle,0.8055330044338974,84456.0,107700.0,23244.0,27.52202330207445,3.26309357277239,4.499370753315435,7.6878132820129395,9.078044891357422,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.2,2,right_ventricle,0.8469684557049553,117192.0,148910.0,31718.0,27.064987371151613,2.7863643825070716,3.5787386842333904,5.221804141998291,5.782859802246094,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.2,3,left_atrium,0.8755330258463145,92192.0,91664.0,-528.0,-0.572717806317251,1.5924304849834698,2.596904043497166,4.229825496673584,5.781779766082764,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.2,4,right_atrium,0.8850834499980251,113656.0,114205.0,549.0,0.48303653128739354,2.0355132013308164,2.2864722716180292,3.5263736248016357,5.5874199867248535,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.2,5,myocardium,0.767806555735569,160784.0,160797.0,13.0,0.008085381630012937,2.7119019396614283,3.4476785602521063,6.383563995361328,8.141557693481445,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.2,6,heart muscle,0.5166741524464992,147608.0,148509.0,901.0,0.6104005202970029,1.7393988630935835,2.984311572335737,5.087074279785156,6.049045562744141,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.3,1,left_ventricle,0.6742831084202884,58792.0,107865.0,49073.0,83.46883929786365,5.207381783391476,6.273597663238545,10.126601219177246,12.395867347717285,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.3,2,right_ventricle,0.7581171052949369,99264.0,149162.0,49898.0,50.267972275951,4.538244072050686,4.789405473223067,8.066828727722168,9.225574493408203,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.3,3,left_atrium,0.8601498192811348,103144.0,91357.0,-11787.0,-11.427712712324517,1.962067324034468,2.843004083292262,5.0820231437683105,6.080600261688232,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.3,4,right_atrium,0.8592014288773857,133336.0,114131.0,-19205.0,-14.403461930761384,2.8520400578250067,2.872129823690663,5.648481845855713,7.122880458831787,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.3,5,myocardium,0.6820376196863583,162496.0,161058.0,-1438.0,-0.8849448601811737,3.703308979599151,4.032087208192496,7.126053333282471,10.356640815734863,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.3,6,heart muscle,0.4599585313640219,148712.0,148380.0,-332.0,-0.22325030932271775,2.46701132887411,3.6776001443824247,6.367023944854736,9.194971084594727,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.4,1,left_ventricle,0.6319683156478852,52600.0,107984.0,55384.0,105.29277566539923,5.52361951169001,6.471857188929005,9.358423233032227,11.554556846618652,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.4,2,right_ventricle,0.7310746165606352,93896.0,149428.0,55532.0,59.142029479424046,5.183413196719607,5.346569733184095,9.295660018920898,10.602607727050781,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.4,3,left_atrium,0.8315640866873065,110896.0,90979.0,-19917.0,-17.96007069686914,2.3855426220616187,3.2391778532497684,5.73470401763916,7.046415328979492,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.4,4,right_atrium,0.8393057800048441,137896.0,113955.0,-23941.0,-17.361634855253236,3.342534167876116,3.4228870294599933,6.992234706878662,9.084859848022461,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.4,5,myocardium,0.6612005636918693,158936.0,161097.0,2161.0,1.3596667841143606,3.978161202226637,4.215066536903229,6.88163948059082,9.609560012817383,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.4,6,heart muscle,0.4189304376829856,148128.0,148685.0,557.0,0.3760261395549795,2.862623172475216,4.203585631011767,7.525787353515625,10.372981071472168,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.5,1,left_ventricle,0.7439024390243902,67424.0,108056.0,40632.0,60.263407688656855,3.890307038404242,4.688720235984693,7.4527387619018555,8.696693420410156,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.5,2,right_ventricle,0.8028527920738642,109696.0,149699.0,40003.0,36.46714556592765,3.88314206625641,4.3896529892529434,7.563589572906494,8.986663818359375,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.5,3,left_atrium,0.8472188121639701,108616.0,90957.0,-17659.0,-16.258194004566548,2.089446731938503,2.8143650042147716,4.474920272827148,6.118739604949951,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.5,4,right_atrium,0.8765929327528303,125192.0,113911.0,-11281.0,-9.010959166719918,2.391392023509909,3.027524907300597,4.997877597808838,7.142983913421631,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.5,5,myocardium,0.7367300903564555,152720.0,161148.0,8428.0,5.518596123624935,3.1315045465862763,3.5643367268956894,6.0348896980285645,8.290351867675781,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.5,6,heart muscle,0.4557858508097508,153256.0,148627.0,-4629.0,-3.0204363940074126,2.3507847624716165,3.618339286128865,6.258666038513184,8.869783401489258,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.6,1,left_ventricle,0.8762874393275719,94608.0,108120.0,13512.0,14.28209030948757,1.9129952714485587,2.988608155852931,4.488259792327881,6.175120830535889,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.6,2,right_ventricle,0.9076884391374377,144352.0,149934.0,5582.0,3.8669363777432944,1.743827349629188,2.513043030112206,3.286736488342285,3.926391839981079,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.6,3,left_atrium,0.8738669751426973,92960.0,91171.0,-1789.0,-1.9244836488812394,1.4592473545465405,2.4181084746115697,3.3982913494110107,4.824270248413086,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.6,4,right_atrium,0.917341130086163,111928.0,113807.0,1879.0,1.6787577728539775,1.3484735226667426,2.586537186329831,3.683007001876831,4.144018173217773,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.6,5,myocardium,0.8484088386269832,164808.0,161306.0,-3502.0,-2.124896849667492,1.704345350709761,2.573031131959568,3.5384602546691895,4.176690578460693,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.6,6,heart muscle,0.5902530289238294,149208.0,148505.0,-703.0,-0.4711543616964238,1.2530712964438864,2.4863873562345256,3.411813735961914,4.340840816497803,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.7,1,left_ventricle,0.9074722453542887,109168.0,108183.0,-985.0,-0.9022790561336655,1.3027108858279128,2.1874065017801927,2.5586891174316406,2.6867904663085938,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.7,2,right_ventricle,0.9241817465128374,151744.0,150149.0,-1595.0,-1.0511123998312948,1.370500193250435,2.1496508318133767,2.548837661743164,2.7176618576049805,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.7,3,left_atrium,0.8751398985124578,92768.0,91294.0,-1474.0,-1.588909968954812,1.4055269631445177,2.022784705267509,2.492900848388672,2.727403163909912,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.7,4,right_atrium,0.9299737790170852,114824.0,113619.0,-1205.0,-1.0494321744583015,1.1075300914949755,2.204813839131599,2.7329342365264893,2.908803939819336,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.7,5,myocardium,0.8701495759444873,162728.0,161522.0,-1206.0,-0.7411140061943857,1.3321224652338663,2.1605203459116953,2.646733283996582,2.8343448638916016,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.7,6,heart muscle,0.6315821173195386,150256.0,148603.0,-1653.0,-1.1001224576722395,1.095467151503713,2.0636151739675994,2.6025636196136475,3.0175278186798096,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.8,1,left_ventricle,0.9163846826592428,110192.0,108201.0,-1991.0,-1.8068462320313634,1.2144016935981181,2.20453780536952,3.0158231258392334,4.043867588043213,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.8,2,right_ventricle,0.9220247325937413,151440.0,150348.0,-1092.0,-0.7210776545166403,1.4347057356133393,2.3198761372292434,3.3046555519104004,3.8638980388641357,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.8,3,left_atrium,0.881334037930832,95888.0,91453.0,-4435.0,-4.625187719005506,1.3738759018524793,2.2952168009305534,3.4010472297668457,4.86860990524292,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.8,4,right_atrium,0.9245063161518702,113080.0,113482.0,402.0,0.35550053059780684,1.2434925072174419,2.439454292747516,3.5207695960998535,4.031513214111328,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.8,5,myocardium,0.8754959904547851,162840.0,161511.0,-1329.0,-0.816138540899042,1.2988534397843141,2.3898864406771225,3.1342015266418457,3.5084547996520996,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.8,6,heart muscle,0.6286900307139313,149480.0,148756.0,-724.0,-0.48434573187048435,1.1100349766189614,2.3508671155286556,3.337925434112549,4.265781402587891,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.9,1,left_ventricle,0.9055661402040562,116424.0,108220.0,-8204.0,-7.046657046657047,1.4422467386847142,2.4053941021072616,3.4130756855010986,4.535868167877197,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.9,2,right_ventricle,0.9052089097272926,154896.0,150523.0,-4373.0,-2.8231845883689703,2.2440906295516463,2.611587445290635,4.325176239013672,5.3091721534729,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.9,3,left_atrium,0.8759711916193802,91432.0,91848.0,416.0,0.45498293813981977,1.509340515056913,2.5080201927916237,3.5623779296875,4.943024158477783,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.9,4,right_atrium,0.8728902459584512,100072.0,113460.0,13388.0,13.37836757534575,2.5540622578419847,2.784605060062416,3.8579814434051514,6.2348809242248535,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.9,5,myocardium,0.8675376188961005,162160.0,161547.0,-613.0,-0.3780217069560928,1.4028477360966174,2.4282612034439435,3.12217116355896,3.7526662349700928,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 +2,1.0,pm0027,0.9,6,heart muscle,0.5921890229556683,148712.0,148818.0,106.0,0.07127871321749421,1.3025453301115928,2.620137455969067,3.6281285285949707,6.22665548324585,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_duke_heart\pm0027\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_duke_heart_motion\mgn_stage_model.pt,2026-10-21T00:22:23+00:00,2026-10-21T00:22:23+00:00,final,1.2698414325714111,0.004598368890583515 diff --git a/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/shape_sweep_summary.csv b/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/shape_sweep_summary.csv new file mode 100644 index 00000000..f6ee9342 --- /dev/null +++ b/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/shape_sweep_summary.csv @@ -0,0 +1,4 @@ +combination,mode_00_offset,mode_00_coefficient,mean_dice,mean_volume_difference_percent,mean_abs_volume_difference_percent,mean_surface_rmse_mm,mean_displacement_rms_mm,mean_displacement_95th_mm,displacement_rms_mm,displacement_95th_mm,displacement_max_mm,n_rows +0,-1.0,-0.7301586270332336,0.8057144722528762,6.52857058045709,13.487645186059062,2.192408436493999,2.87756404220281,4.58980949918429,3.0112119289045802,5.691985607147217,10.37524700164795,60 +1,0.0,0.26984137296676636,0.7997678828006557,6.62274830305431,13.227293619071625,2.2484185303634647,2.9836291666123236,4.743883426984151,3.1232969968307076,5.883333683013916,11.268267631530762,60 +2,1.0,1.2698414325714111,0.784191165843379,7.842361634109263,13.400748042007166,2.409964030412999,3.2951018448582,5.200154030323029,3.4359397349893035,6.354207515716553,12.395867347717285,60 diff --git a/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/unperturbed_surface.png b/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/unperturbed_surface.png new file mode 100644 index 00000000..8b21a4ec --- /dev/null +++ b/tests/baselines/tutorial_14_duke_heart_shape_parameter_sweep/unperturbed_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f25f3274f15ffedc8b11d5ba0d4d618d5c1be44bb7b093a36e2a3680905176c1 +size 102943 diff --git a/tests/baselines/tutorial_14_lung_shape_parameter_sweep/perturbed_surface.png b/tests/baselines/tutorial_14_lung_shape_parameter_sweep/perturbed_surface.png new file mode 100644 index 00000000..a94803de --- /dev/null +++ b/tests/baselines/tutorial_14_lung_shape_parameter_sweep/perturbed_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b9494fc59b71bc0a81b5c8e6363fc55c22a7005cdacab214f3bd59700d51c11a +size 95287 diff --git a/tests/baselines/tutorial_14_lung_shape_parameter_sweep/shape_sweep_metrics.csv b/tests/baselines/tutorial_14_lung_shape_parameter_sweep/shape_sweep_metrics.csv new file mode 100644 index 00000000..fe077111 --- /dev/null +++ b/tests/baselines/tutorial_14_lung_shape_parameter_sweep/shape_sweep_metrics.csv @@ -0,0 +1,151 @@ +combination,mode_00_offset,case_id,stage,label_id,label_name,dice,volume_truth_mm3,volume_predicted_mm3,volume_difference_mm3,volume_difference_percent,surface_rmse_mm,displacement_rms_mm,displacement_95th_mm,displacement_max_mm,shape_parameters_file,network_weights_file,network_weights_created,network_weights_modified,network_epoch,pca_c01,pca_c02 +0,-1.0,100_HM10395,0.0,28,lung_upper_lobe_left,0.9177686927729162,1247128.0,1120632.0,-126496.0,-10.142984521235991,22.429578442651646,1.3573593578065102,2.284961462020874,2.8480608463287354,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.0,29,lung_lower_lobe_left,0.8251951221273185,625808.0,482216.0,-143592.0,-22.945056630787718,6.003459977259531,1.303081489634745,1.8691917657852173,2.3308589458465576,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.0,30,lung_upper_lobe_right,0.9552817468123536,1147944.0,1088816.0,-59128.0,-5.150773905347299,1.8661419505248795,1.061097957014504,1.5651979446411133,1.9881525039672852,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.0,31,lung_middle_lobe_right,0.8516997232931434,608448.0,597160.0,-11288.0,-1.8552119490901442,4.92944839091215,1.4598860538131508,2.107142210006714,2.391730308532715,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.0,32,lung_lower_lobe_right,0.8201024266500742,921248.0,750192.0,-171056.0,-18.567855778248635,7.157221705614155,1.3370879562906328,1.8317389488220215,2.2595431804656982,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.1,28,lung_upper_lobe_left,0.9247809453288905,1213624.0,1120040.0,-93584.0,-7.711119753729326,19.445641422039326,1.2277382335892197,1.9829680919647217,2.473268747329712,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.1,29,lung_lower_lobe_left,0.858217386594029,551592.0,482216.0,-69376.0,-12.577412290243513,4.376188979360287,1.3970124452336892,2.173961877822876,2.9183154106140137,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.1,30,lung_upper_lobe_right,0.9627809013961198,1140792.0,1088816.0,-51976.0,-4.5561329322085005,1.6553802821541166,0.97373939034953,1.4745070934295654,2.465956687927246,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.1,31,lung_middle_lobe_right,0.8910464152646863,590208.0,597160.0,6952.0,1.1778898286705703,3.69116307294227,1.3714777210779163,2.3509562015533447,2.772050619125366,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.1,32,lung_lower_lobe_right,0.8769945166230302,869248.0,750192.0,-119056.0,-13.696436460020616,5.487258445226434,1.3431299903131926,1.9927101135253906,2.369669198989868,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.2,28,lung_upper_lobe_left,0.9551865124727367,1161984.0,1119472.0,-42512.0,-3.65857016964089,1.9820239126224153,1.4555972183687078,2.437352418899536,3.139786720275879,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.2,29,lung_lower_lobe_left,0.9273563107343268,511456.0,482216.0,-29240.0,-5.717011825064131,3.794728444034395,1.2299674663835058,2.012392044067383,3.0014073848724365,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.2,30,lung_upper_lobe_right,0.9667617376042124,1099024.0,1088816.0,-10208.0,-0.9288241203103845,1.5367647085740344,1.5310906147123817,2.820413827896118,3.964733600616455,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.2,31,lung_middle_lobe_right,0.9189228840459869,602488.0,597160.0,-5328.0,-0.8843329659677869,2.54405658784262,1.6653439822316718,2.855931520462036,4.383897304534912,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.2,32,lung_lower_lobe_right,0.9154439920996412,837584.0,750192.0,-87392.0,-10.433819175151388,3.4157779565471387,1.4118414424746593,2.5289504528045654,3.385767698287964,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.3,28,lung_upper_lobe_left,0.9618310347511658,1142040.0,1119064.0,-22976.0,-2.011838464502119,1.7439935793295926,1.3746850229882583,2.0942158699035645,2.5309362411499023,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.3,29,lung_lower_lobe_left,0.9399602385685885,483544.0,482216.0,-1328.0,-0.2746389160035074,3.6181356040526973,1.0057489147549725,1.4156017303466797,2.1359996795654297,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.3,30,lung_upper_lobe_right,0.9614125133971452,1068360.0,1088816.0,20456.0,1.9147103972443746,1.6359837335481715,1.7349624751280774,3.08819317817688,4.157956600189209,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.3,31,lung_middle_lobe_right,0.9120579119142269,572064.0,597160.0,25096.0,4.38692174302176,4.037755454951444,1.572974418320664,2.543562173843384,3.465203285217285,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.3,32,lung_lower_lobe_right,0.9151134895737221,810480.0,750192.0,-60288.0,-7.438554930411608,3.262206543024269,1.4318786947270525,2.2955524921417236,4.378610134124756,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.4,28,lung_upper_lobe_left,0.9628300105832213,1118888.0,1118616.0,-272.0,-0.024309850494419458,1.6170023310822874,1.3734067371201026,2.1396772861480713,2.6549062728881836,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.4,29,lung_lower_lobe_left,0.9351811970576064,465040.0,482216.0,17176.0,3.693445725098916,3.7379215089689293,1.0631786296858767,1.8389511108398438,2.6730546951293945,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.4,30,lung_upper_lobe_right,0.9526843296915622,1055928.0,1088816.0,32888.0,3.1146062989143197,1.8534238860062686,1.878116581345812,3.2766494750976562,4.326803207397461,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.4,31,lung_middle_lobe_right,0.8988908087620534,551064.0,597160.0,46096.0,8.364908613155642,4.452980959874436,1.6799068673485862,2.678516149520874,3.579089641571045,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.4,32,lung_lower_lobe_right,0.9008671270574796,790528.0,750192.0,-40336.0,-5.10241256476684,3.7023804736766857,1.531664322346443,2.855956792831421,4.101104736328125,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.5,28,lung_upper_lobe_left,0.9600683384490647,1092008.0,1118168.0,26160.0,2.3955868455176152,1.7448220724567627,1.3581987945984293,2.1550419330596924,2.656646966934204,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.5,29,lung_lower_lobe_left,0.9183142470771496,452488.0,482216.0,29728.0,6.569897986244939,4.190707432536258,1.1326412771913683,2.0306222438812256,2.794610023498535,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.5,30,lung_upper_lobe_right,0.9532156505368481,1044376.0,1088816.0,44440.0,4.255172466621217,1.8520255587177046,1.8675413064772346,3.1889171600341797,4.084600925445557,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.5,31,lung_middle_lobe_right,0.8947832901490388,535968.0,597160.0,61192.0,11.41709952833005,4.061897745496454,1.6550653417842334,2.465524911880493,3.2185111045837402,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.5,32,lung_lower_lobe_right,0.88810476460519,773376.0,750192.0,-23184.0,-2.9977656405163855,4.122870330406736,1.630391192226797,3.1327457427978516,4.733166217803955,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.6,28,lung_upper_lobe_left,0.9710141230068338,1077192.0,1117808.0,40616.0,3.770544155545158,1.2149690086875276,1.3648342949591055,2.1469860076904297,2.5944018363952637,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.6,29,lung_lower_lobe_left,0.9308643862017438,460048.0,482216.0,22168.0,4.818627621465586,3.8204211441985434,1.4411716173448066,2.744798183441162,3.962379217147827,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.6,30,lung_upper_lobe_right,0.9673001313053537,1061896.0,1088816.0,26920.0,2.535088181893519,1.5294689358802165,1.7771755448769373,2.981097459793091,3.59600830078125,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.6,31,lung_middle_lobe_right,0.9114341333105986,528872.0,597160.0,68288.0,12.912008954907805,3.475309381028822,1.7055813630284768,2.374176263809204,2.8044490814208984,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.6,32,lung_lower_lobe_right,0.9124612818680009,760728.0,750192.0,-10536.0,-1.384989115689182,3.1636877412059183,1.8733794058352742,3.8935718536376953,5.053657054901123,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.7,28,lung_upper_lobe_left,0.9997099819902396,1116864.0,1117480.0,616.0,0.0551544324107501,0.09704303645803437,1.0809051295879504,1.1780270338058472,1.2359619140625,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.7,29,lung_lower_lobe_left,1.0,482216.0,482216.0,0.0,0.0,1.133343320927685e-19,1.0315121170764996,1.1180564165115356,1.1802887916564941,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.7,30,lung_upper_lobe_right,1.0,1088816.0,1088816.0,0.0,0.0,1.0812015722217158e-08,1.0432842384403,1.1603525876998901,1.186661720275879,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.7,31,lung_middle_lobe_right,1.0,597160.0,597160.0,0.0,0.0,0.0,1.1382562759970083,1.1917065382003784,1.2066329717636108,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.7,32,lung_lower_lobe_right,1.0,750192.0,750192.0,0.0,0.0,9.725457184803924e-20,1.0650608184948558,1.1594722270965576,1.191084861755371,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.8,28,lung_upper_lobe_left,0.968984130107814,1159160.0,1117352.0,-41808.0,-3.6067497153110875,10.609713529811904,1.1367572006899846,1.6995892524719238,2.172473192214966,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.8,29,lung_lower_lobe_left,0.9229872380652276,533304.0,482216.0,-51088.0,-9.579526873978068,3.982432415468789,1.4548350961013836,2.2988088130950928,3.161911725997925,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.8,30,lung_upper_lobe_right,0.9755698541494731,1101920.0,1088816.0,-13104.0,-1.189197037897488,1.1148938838057414,1.0286139893631927,1.5861845016479492,2.225581645965576,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.8,31,lung_middle_lobe_right,0.947492864280909,627664.0,597160.0,-30504.0,-4.859925055443677,2.0209107028881763,1.528762159484767,2.283623695373535,2.7053120136260986,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.8,32,lung_lower_lobe_right,0.9191315884449008,823624.0,750192.0,-73432.0,-8.915718823152313,4.5613673093701435,1.5590382823891928,2.3524363040924072,2.8491604328155518,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.9,28,lung_upper_lobe_left,0.9324168514412416,1227872.0,1117328.0,-110544.0,-9.002892809673973,19.06472939563673,1.3222881677190539,2.1923232078552246,2.7498223781585693,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.9,29,lung_lower_lobe_left,0.8551183341189851,586960.0,482216.0,-104744.0,-17.845168324928444,4.860043934967924,1.4182886662368688,2.128041982650757,2.6816980838775635,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.9,30,lung_upper_lobe_right,0.9595795902015065,1127688.0,1088816.0,-38872.0,-3.4470527308971985,1.700733791292443,1.049643912976219,1.5943814516067505,2.0201656818389893,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.9,31,lung_middle_lobe_right,0.8720917170515743,613520.0,597160.0,-16360.0,-2.666579736601904,4.442918675043759,1.540919112030378,2.339918375015259,2.7117583751678467,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +0,-1.0,100_HM10395,0.9,32,lung_lower_lobe_right,0.8498535810517802,891672.0,750192.0,-141480.0,-15.86682098350066,6.1506046354200175,1.4156462427159175,1.972009301185608,2.5459609031677246,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_000\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-1.666217565536499,0.631687581539154 +1,0.0,100_HM10395,0.0,28,lung_upper_lobe_left,0.918957563331265,1247128.0,1125160.0,-121968.0,-9.779910321955725,22.59734088813882,1.3293611949054758,2.2988572120666504,2.6883907318115234,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.0,29,lung_lower_lobe_left,0.8251951221273185,625808.0,482216.0,-143592.0,-22.945056630787718,6.003459977259531,1.2112032135696866,1.7983336448669434,2.2181460857391357,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.0,30,lung_upper_lobe_right,0.9564571481628295,1147944.0,1092240.0,-55704.0,-4.8525015157533815,1.847232770869364,1.092989761572528,1.7153432369232178,2.090266466140747,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.0,31,lung_middle_lobe_right,0.8511106690537763,608448.0,598032.0,-10416.0,-1.711896497317766,4.939933395148422,1.4769443196205432,2.060415267944336,2.4948136806488037,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.0,32,lung_lower_lobe_right,0.8201024266500742,921248.0,750192.0,-171056.0,-18.567855778248635,7.157221705614155,1.2836061006332447,1.791468620300293,2.1965858936309814,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.1,28,lung_upper_lobe_left,0.9238432459246335,1213624.0,1124816.0,-88808.0,-7.317587654825547,19.60077111050765,1.235757926044887,2.0906894207000732,2.633596658706665,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.1,29,lung_lower_lobe_left,0.858217386594029,551592.0,482216.0,-69376.0,-12.577412290243513,4.376188979360287,1.332280704837673,2.1049716472625732,2.8086655139923096,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.1,30,lung_upper_lobe_right,0.9624658731807987,1140792.0,1092056.0,-48736.0,-4.272119720334644,1.6921912689743195,0.9623838206311158,1.4144086837768555,2.379509687423706,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.1,31,lung_middle_lobe_right,0.8903432580562626,590208.0,597936.0,7728.0,1.3093689004554327,3.7021553987015223,1.3746573298856746,2.2832508087158203,2.703920841217041,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.1,32,lung_lower_lobe_right,0.8769945166230302,869248.0,750192.0,-119056.0,-13.696436460020616,5.487258445226434,1.2859795742469067,1.9523770809173584,2.371945858001709,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.2,28,lung_upper_lobe_left,0.9494358633477356,1161984.0,1124696.0,-37288.0,-3.20899427186605,2.1481982379815134,1.520459460054093,2.559300661087036,3.2253050804138184,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.2,29,lung_lower_lobe_left,0.9273563107343268,511456.0,482216.0,-29240.0,-5.717011825064131,3.794728444034395,1.186790873114045,2.0139081478118896,2.907586097717285,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.2,30,lung_upper_lobe_right,0.964084625327715,1099024.0,1091904.0,-7120.0,-0.6478475447305974,1.639777665719424,1.4801240257310972,2.7325854301452637,3.8432695865631104,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.2,31,lung_middle_lobe_right,0.9179696351688194,602488.0,597848.0,-4640.0,-0.7701398202121869,2.5610830228414767,1.6252433560015191,2.807868480682373,4.202273368835449,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.2,32,lung_lower_lobe_right,0.9154439920996412,837584.0,750192.0,-87392.0,-10.433819175151388,3.4157779565471387,1.3408683360529152,2.4471845626831055,3.32138729095459,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.3,28,lung_upper_lobe_left,0.9529674580123307,1142040.0,1124816.0,-17224.0,-1.5081783475184758,1.9941732586891676,1.448588980981815,2.240488052368164,2.734217882156372,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.3,29,lung_lower_lobe_left,0.9399602385685885,483544.0,482216.0,-1328.0,-0.2746389160035074,3.6181356040526973,0.9452570942305312,1.4113980531692505,2.175870418548584,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.3,30,lung_upper_lobe_right,0.9576714677767614,1068360.0,1091696.0,23336.0,2.1842824516080723,1.7578053573129016,1.6866027211831576,3.021479606628418,4.024045467376709,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.3,31,lung_middle_lobe_right,0.9112630787116187,572064.0,597776.0,25712.0,4.494602002573139,4.046134520739337,1.5108073671887876,2.390963554382324,3.3211517333984375,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.3,32,lung_lower_lobe_right,0.9151134895737221,810480.0,750192.0,-60288.0,-7.438554930411608,3.262206543024269,1.3444891208577123,2.2703943252563477,4.300925254821777,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.4,28,lung_upper_lobe_left,0.9527087025829942,1118888.0,1124400.0,5512.0,0.4926319703133826,1.9117652678520265,1.4394164568166394,2.288428783416748,2.752718210220337,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.4,29,lung_lower_lobe_left,0.9351811970576064,465040.0,482216.0,17176.0,3.693445725098916,3.7379215045593055,1.0005182734624554,1.7812836170196533,2.6884047985076904,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.4,30,lung_upper_lobe_right,0.9490146406884477,1055928.0,1091512.0,35584.0,3.369926737429067,1.9726177708513501,1.8265510776941698,3.190497636795044,4.203304290771484,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.4,31,lung_middle_lobe_right,0.8980723160064628,551064.0,597672.0,46608.0,8.457819781368407,4.46078588011691,1.605488381163337,2.553335428237915,3.4330928325653076,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.4,32,lung_lower_lobe_right,0.9008671270574796,790528.0,750192.0,-40336.0,-5.10241256476684,3.7023804736766857,1.4440532647653623,2.824445962905884,4.020665168762207,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.5,28,lung_upper_lobe_left,0.9491777479389556,1092008.0,1124344.0,32336.0,2.961150467762141,2.0304094607907768,1.4049298824251462,2.274627208709717,2.753195285797119,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.5,29,lung_lower_lobe_left,0.9183142470771496,452488.0,482216.0,29728.0,6.569897986244939,4.1907064915667505,1.0657237949334257,1.9877759218215942,2.8016915321350098,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.5,30,lung_upper_lobe_right,0.9499878256635014,1044376.0,1091264.0,46888.0,4.489570805916643,1.9445347230993397,1.8157327495128508,3.0918819904327393,3.972146511077881,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.5,31,lung_middle_lobe_right,0.8939170448370045,535968.0,597560.0,61592.0,11.49173084960296,4.06600612779634,1.5798295054691056,2.3993844985961914,3.0450222492218018,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.5,32,lung_lower_lobe_right,0.88810476460519,773376.0,750192.0,-23184.0,-2.9977656405163855,4.122870330406736,1.5471723154147168,3.0641872882843018,4.710415840148926,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.6,28,lung_upper_lobe_left,0.9604860129569542,1077192.0,1124560.0,47368.0,4.397359059480576,1.4810083958198195,1.374556951272856,2.1888010501861572,2.6975951194763184,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.6,29,lung_lower_lobe_left,0.9308643862017438,460048.0,482216.0,22168.0,4.818627621465586,3.820420828319244,1.3787702254219822,2.7093417644500732,3.9727723598480225,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.6,30,lung_upper_lobe_right,0.9647479367863288,1061896.0,1091056.0,29160.0,2.7460316264492945,1.5905485703019935,1.7351279712933914,2.880733013153076,3.5141656398773193,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.6,31,lung_middle_lobe_right,0.9107374970700419,528872.0,597424.0,68552.0,12.961926515300489,3.476800695395329,1.6501909882627448,2.351189374923706,2.801684617996216,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.6,32,lung_lower_lobe_right,0.9124612818680009,760728.0,750192.0,-10536.0,-1.384989115689182,3.1636877412059183,1.796030778364978,3.8484885692596436,5.032223701477051,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.7,28,lung_upper_lobe_left,0.9863993404875682,1116864.0,1124792.0,7928.0,0.7098447080396539,0.6750635885623405,1.0798528545914785,1.2750544548034668,1.3190172910690308,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.7,29,lung_lower_lobe_left,1.0,482216.0,482216.0,0.0,0.0,0.000686907251553814,0.943850750161703,1.139747142791748,1.2172459363937378,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.7,30,lung_upper_lobe_right,0.9974234560923152,1088816.0,1090848.0,2032.0,0.18662473732935592,0.27883310615255624,1.0009153409022327,1.2305805683135986,1.2820329666137695,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.7,31,lung_middle_lobe_right,0.999752171845382,597160.0,597216.0,56.0,0.00937772121374506,0.040629709857462455,1.0887787722471307,1.2395585775375366,1.2785011529922485,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.7,32,lung_lower_lobe_right,1.0,750192.0,750192.0,0.0,0.0,9.725457184803924e-20,0.9361043103411814,1.071929931640625,1.1197010278701782,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.8,28,lung_upper_lobe_left,0.9646195932006291,1159160.0,1124816.0,-34344.0,-2.9628351564926327,10.71586526966627,1.1330075570404354,1.769662857055664,2.292872190475464,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.8,29,lung_lower_lobe_left,0.9229872380652276,533304.0,482216.0,-51088.0,-9.579526873978068,3.9827276109658243,1.4167171554599685,2.302337646484375,3.1248161792755127,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.8,30,lung_upper_lobe_right,0.9752185525182796,1101920.0,1090688.0,-11232.0,-1.0193117467692754,1.1059790564835421,1.005331848147252,1.5207074880599976,2.1204030513763428,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.8,31,lung_middle_lobe_right,0.947492864280909,627664.0,597160.0,-30504.0,-4.859925055443677,2.0209107052925463,1.47454875411981,2.1354572772979736,2.6988723278045654,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.8,32,lung_lower_lobe_right,0.9191315884449008,823624.0,750192.0,-73432.0,-8.915718823152313,4.5613673093701435,1.4791363641999113,2.2645530700683594,2.858551502227783,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.9,28,lung_upper_lobe_left,0.9331161566528494,1227872.0,1124744.0,-103128.0,-8.398921060175654,19.23413742938751,1.311652844743838,2.2204177379608154,2.891554832458496,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.9,29,lung_lower_lobe_left,0.8550628128483887,586960.0,482248.0,-104712.0,-17.83971650538367,4.876496669599361,1.3771084446266648,2.1110615730285645,2.6375181674957275,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.9,30,lung_upper_lobe_right,0.9602146891840224,1127688.0,1090216.0,-37472.0,-3.3229049169628477,1.687525102356186,1.0416776002633863,1.6292734146118164,1.920794129371643,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.9,31,lung_middle_lobe_right,0.8720917170515743,613520.0,597160.0,-16360.0,-2.666579736601904,4.442918679104028,1.518274927320177,2.274901866912842,2.7643096446990967,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +1,0.0,100_HM10395,0.9,32,lung_lower_lobe_right,0.8498535810517802,891672.0,750192.0,-141480.0,-15.86682098350066,6.1506046354200175,1.3398189709186605,1.906567096710205,2.381338596343994,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_001\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,-0.6662175059318542,0.631687581539154 +2,1.0,100_HM10395,0.0,28,lung_upper_lobe_left,0.9170081898035932,1247128.0,1122648.0,-124480.0,-9.981333110955731,22.412653439858307,1.3191783757457103,2.291673421859741,2.8901283740997314,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.0,29,lung_lower_lobe_left,0.8251951221273185,625808.0,482216.0,-143592.0,-22.945056630787718,6.003459977259531,1.12396668735226,1.678227186203003,2.050804853439331,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.0,30,lung_upper_lobe_right,0.9568551571069835,1147944.0,1101224.0,-46720.0,-4.069884942122612,1.7992686254328665,1.282283623845635,2.01133131980896,2.405189275741577,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.0,31,lung_middle_lobe_right,0.8516997232931434,608448.0,597160.0,-11288.0,-1.8552119490901442,4.92944839091215,1.5113419856571906,2.052525758743286,2.5582404136657715,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.0,32,lung_lower_lobe_right,0.8201024266500742,921248.0,750192.0,-171056.0,-18.567855778248635,7.157221705614155,1.3009574501827115,1.8107439279556274,2.2060534954071045,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.1,28,lung_upper_lobe_left,0.9241815206152034,1213624.0,1122904.0,-90720.0,-7.4751323309361055,19.427819162948804,1.2289988977546091,2.205095052719116,2.806804895401001,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.1,29,lung_lower_lobe_left,0.858217386594029,551592.0,482216.0,-69376.0,-12.577412290243513,4.376188979360287,1.2435017458485296,1.985903024673462,2.4958062171936035,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.1,30,lung_upper_lobe_right,0.9625426301223106,1140792.0,1099408.0,-41384.0,-3.6276551728974256,1.620647614768487,1.0658201842785664,1.4714258909225464,1.9654244184494019,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.1,31,lung_middle_lobe_right,0.8910464152646863,590208.0,597160.0,6952.0,1.1778898286705703,3.69116307294227,1.3755422902991148,2.0664758682250977,2.6152853965759277,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.1,32,lung_lower_lobe_right,0.8769945166230302,869248.0,750192.0,-119056.0,-13.696436460020616,5.487258445226434,1.2870208874055125,1.9291012287139893,2.375399589538574,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.2,28,lung_upper_lobe_left,0.9544431335618236,1161984.0,1123160.0,-38824.0,-3.3411819784093413,1.9777823577220233,1.5163893137185747,2.69884991645813,3.553880453109741,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.2,29,lung_lower_lobe_left,0.9273563107343268,511456.0,482216.0,-29240.0,-5.717011825064131,3.794728444034395,1.1179053958843177,1.9877594709396362,2.9721407890319824,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.2,30,lung_upper_lobe_right,0.9651974017363046,1099024.0,1096904.0,-2120.0,-0.19289842624000933,1.5527821761459135,1.4019426220651157,2.534660816192627,3.6169915199279785,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.2,31,lung_middle_lobe_right,0.9189228840459869,602488.0,597160.0,-5328.0,-0.8843329659677869,2.54405658784262,1.5228240567970908,2.6281981468200684,3.695044755935669,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.2,32,lung_lower_lobe_right,0.9154439920996412,837584.0,750192.0,-87392.0,-10.433819175151388,3.4157779565471387,1.3296153466589633,2.3252205848693848,3.378950834274292,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.3,28,lung_upper_lobe_left,0.960339263140797,1142040.0,1123576.0,-18464.0,-1.616755980526076,1.7554858291706381,1.405912706001565,2.21580171585083,2.597540855407715,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.3,29,lung_lower_lobe_left,0.9399602385685885,483544.0,482216.0,-1328.0,-0.2746389160035074,3.6181356040526973,0.9218982135132955,1.486504077911377,2.50911283493042,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.3,30,lung_upper_lobe_right,0.960865396362344,1068360.0,1092592.0,24232.0,2.268149312965667,1.6306732086891842,1.584589104621546,2.798062801361084,3.770935297012329,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.3,31,lung_middle_lobe_right,0.9120579119142269,572064.0,597160.0,25096.0,4.38692174302176,4.037755454951444,1.3832094377460722,2.1449530124664307,2.8535261154174805,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.3,32,lung_lower_lobe_right,0.9151134895737221,810480.0,750192.0,-60288.0,-7.438554930411608,3.262206543024269,1.3615878838565023,2.379396438598633,4.315117359161377,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.4,28,lung_upper_lobe_left,0.9609530773483619,1118888.0,1123952.0,5064.0,0.4525922165578682,1.6448003476249273,1.3650064508460775,2.1516342163085938,2.7588155269622803,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.4,29,lung_lower_lobe_left,0.9351811970576064,465040.0,482216.0,17176.0,3.693445725098916,3.7379215089689293,1.0097130616385819,1.9261020421981812,2.9846351146698,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.4,30,lung_upper_lobe_right,0.9527640663912791,1055928.0,1089896.0,33968.0,3.216885999803017,1.8447732677267925,1.7079985767817631,2.9458389282226562,3.9219741821289062,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.4,31,lung_middle_lobe_right,0.8988908087620534,551064.0,597160.0,46096.0,8.364908613155642,4.452980959874436,1.4658503716453337,2.208979845046997,2.967522621154785,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.4,32,lung_lower_lobe_right,0.9008671270574796,790528.0,750192.0,-40336.0,-5.10241256476684,3.7023804736766857,1.4735215465123952,2.8712902069091797,4.04964542388916,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.5,28,lung_upper_lobe_left,0.957788375106946,1092008.0,1124064.0,32056.0,2.935509629966081,1.7846333211118977,1.2987330318723633,2.1037724018096924,2.757939338684082,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.5,29,lung_lower_lobe_left,0.9183142470771496,452488.0,482216.0,29728.0,6.569897986244939,4.190707432536258,1.0967095203227182,2.1682052612304688,3.132239580154419,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.5,30,lung_upper_lobe_right,0.9532156505368481,1044376.0,1088816.0,44440.0,4.255172466621217,1.8520255558315089,1.6978720493145647,2.8497040271759033,3.6766433715820312,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.5,31,lung_middle_lobe_right,0.8947832901490388,535968.0,597160.0,61192.0,11.41709952833005,4.061897745496454,1.4512872244971697,2.112049102783203,2.6220624446868896,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.5,32,lung_lower_lobe_right,0.88810476460519,773376.0,750192.0,-23184.0,-2.9977656405163855,4.122870330406736,1.5882229661623857,3.1604340076446533,4.833686828613281,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.6,28,lung_upper_lobe_left,0.9680926838486474,1077192.0,1124184.0,46992.0,4.362453490185594,1.2957241010301412,1.2438192149628426,2.034414052963257,2.6801576614379883,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.6,29,lung_lower_lobe_left,0.9308643862017438,460048.0,482216.0,22168.0,4.818627621465586,3.8204211441985434,1.4429070762014211,2.975701093673706,4.352942943572998,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.6,30,lung_upper_lobe_right,0.9673001313053537,1061896.0,1088816.0,26920.0,2.535088181893519,1.5294689336349234,1.6490990271508714,2.6687614917755127,3.2292110919952393,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.6,31,lung_middle_lobe_right,0.9114341333105986,528872.0,597160.0,68288.0,12.912008954907805,3.475309381028822,1.5707786324110558,2.290639638900757,3.073047637939453,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.6,32,lung_lower_lobe_right,0.9124612818680009,760728.0,750192.0,-10536.0,-1.384989115689182,3.1636877412059183,1.8406569423564467,3.957144260406494,5.17465877532959,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.7,28,lung_upper_lobe_left,0.9965337645601522,1116864.0,1124184.0,7320.0,0.6554065669589135,0.37856502631096267,0.8862084188993339,1.055275559425354,1.132511854171753,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.7,29,lung_lower_lobe_left,1.0,482216.0,482216.0,0.0,0.0,1.133343320927685e-19,0.6719294916337645,0.8158870935440063,0.9105563759803772,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.7,30,lung_upper_lobe_right,1.0,1088816.0,1088816.0,0.0,0.0,1.0812015722217158e-08,0.9822568896714998,1.0721900463104248,1.0966068506240845,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.7,31,lung_middle_lobe_right,1.0,597160.0,597160.0,0.0,0.0,0.0,0.9516957979913082,1.0453993082046509,1.0650256872177124,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.7,32,lung_lower_lobe_right,1.0,750192.0,750192.0,0.0,0.0,9.725457184803924e-20,0.8482448910529587,0.943404495716095,0.9888618588447571,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.8,28,lung_upper_lobe_left,0.9677896322769398,1159160.0,1124080.0,-35080.0,-3.026329410952759,10.602324208116029,1.0289523961247384,1.68070650100708,2.379542112350464,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.8,29,lung_lower_lobe_left,0.9229872380652276,533304.0,482216.0,-51088.0,-9.579526873978068,3.982432415468789,1.1934664830733028,1.924324631690979,2.6426234245300293,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.8,30,lung_upper_lobe_right,0.9755698541494731,1101920.0,1088816.0,-13104.0,-1.189197037897488,1.1148938838057414,1.0852398783347155,1.4926851987838745,2.0557503700256348,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.8,31,lung_middle_lobe_right,0.947492864280909,627664.0,597160.0,-30504.0,-4.859925055443677,2.0209107028881763,1.3537156255247416,1.9160922765731812,2.5158531665802,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.8,32,lung_lower_lobe_right,0.9191315884449008,823624.0,750192.0,-73432.0,-8.915718823152313,4.5613673093701435,1.4143750094995058,2.13299298286438,2.754802703857422,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.9,28,lung_upper_lobe_left,0.9332766391699975,1227872.0,1123928.0,-103944.0,-8.46537749863178,19.048987849833363,1.262814548114119,2.2505719661712646,3.0123441219329834,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.9,29,lung_lower_lobe_left,0.8551183341189851,586960.0,482216.0,-104744.0,-17.845168324928444,4.860043934967924,1.149551174994992,1.788503646850586,2.1186323165893555,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.9,30,lung_upper_lobe_right,0.9595795902015065,1127688.0,1088816.0,-38872.0,-3.4470527308971985,1.700733791292443,1.1706677278582207,1.8392212390899658,2.1670923233032227,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.9,31,lung_middle_lobe_right,0.8720917170515743,613520.0,597160.0,-16360.0,-2.666579736601904,4.442918675043759,1.4511528594431164,2.141165018081665,2.686594247817993,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 +2,1.0,100_HM10395,0.9,32,lung_lower_lobe_right,0.8498535810517802,891672.0,750192.0,-141480.0,-15.86682098350066,6.1506046354200175,1.2805338417311354,1.762378454208374,2.2463128566741943,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_14_lung\100_HM10395\combo_002\shape_parameters.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\network_weights\test\physicsnemo_mgn_lung_motion\mgn_stage_model.pt,2026-10-21T00:22:17+00:00,2026-10-21T00:22:17+00:00,final,0.33378249406814575,0.631687581539154 diff --git a/tests/baselines/tutorial_14_lung_shape_parameter_sweep/shape_sweep_summary.csv b/tests/baselines/tutorial_14_lung_shape_parameter_sweep/shape_sweep_summary.csv new file mode 100644 index 00000000..9dfe5241 --- /dev/null +++ b/tests/baselines/tutorial_14_lung_shape_parameter_sweep/shape_sweep_summary.csv @@ -0,0 +1,4 @@ +combination,mode_00_offset,mode_00_coefficient,mean_dice,mean_volume_difference_percent,mean_abs_volume_difference_percent,mean_surface_rmse_mm,mean_displacement_rms_mm,mean_displacement_95th_mm,displacement_rms_mm,displacement_95th_mm,displacement_max_mm,n_rows +0,-1.0,-1.666217565536499,0.9283772810517944,-2.8731604254354597,5.728426936597149,4.155163780488215,1.387835310613722,2.1976733136177065,1.3942076972721036,2.3369216918945312,5.053657054901123,50 +1,0.0,-0.6662175059318542,0.9266591971829393,-2.7058634042445364,5.719632190950608,4.221759691839414,1.3497052878540077,2.1786379861831664,1.3629384729379819,2.3372809886932373,5.032223701477051,50 +2,1.0,0.33378249406814575,0.9280796626787177,-2.7203995758837176,5.681281890517604,4.160757965283719,1.2983493387171268,2.099747573137283,1.315790105442396,2.2422573566436768,5.17465877532959,50 diff --git a/tests/baselines/tutorial_14_lung_shape_parameter_sweep/unperturbed_surface.png b/tests/baselines/tutorial_14_lung_shape_parameter_sweep/unperturbed_surface.png new file mode 100644 index 00000000..2193591e --- /dev/null +++ b/tests/baselines/tutorial_14_lung_shape_parameter_sweep/unperturbed_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34eb1a70647c4f05dfbd1f5fbce2a6c38cdc4b8491c0116e9a74cb6e5093716a +size 95277 diff --git a/tests/baselines/tutorial_15_duke_heart_leave_one_out/held_out_fitted_surface.png b/tests/baselines/tutorial_15_duke_heart_leave_one_out/held_out_fitted_surface.png new file mode 100644 index 00000000..c72b731d --- /dev/null +++ b/tests/baselines/tutorial_15_duke_heart_leave_one_out/held_out_fitted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b89148488ff9fc72790c4d7b9674fdb75abab38af68bcef1ba76e7c62b3ae356 +size 105711 diff --git a/tests/baselines/tutorial_15_duke_heart_leave_one_out/loo_metrics.csv b/tests/baselines/tutorial_15_duke_heart_leave_one_out/loo_metrics.csv new file mode 100644 index 00000000..43c2b087 --- /dev/null +++ b/tests/baselines/tutorial_15_duke_heart_leave_one_out/loo_metrics.csv @@ -0,0 +1,121 @@ +fold_index,held_out_case,case_id,stage,label_id,label_name,dice,volume_truth_mm3,volume_predicted_mm3,volume_difference_mm3,volume_difference_percent,surface_rmse_mm,displacement_rms_mm,displacement_95th_mm,displacement_max_mm,shape_parameters_file,network_weights_file,network_weights_created,network_weights_modified,network_epoch,pca_c01,pca_c02,fold_displacement_rms_mm,fold_displacement_95th_mm,fold_displacement_max_mm +0,pm0002,pm0002,0.2,1,left_ventricle,0.6329149679399663,35648.0,70559.0,34911.0,97.93256283662478,4.764770675907643,5.533035660240294,9.119300842285156,11.027463912963867,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.2,2,right_ventricle,0.7413827206762651,72408.0,119706.0,47298.0,65.32151143520053,4.735546963862307,5.267241314904775,9.699551582336426,12.541747093200684,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.2,3,left_atrium,0.8376371326367521,98096.0,85850.0,-12246.0,-12.483689447072257,2.5101951901226194,3.608039880681034,7.068826675415039,8.388888359069824,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.2,4,right_atrium,0.8216917417855188,137592.0,112789.0,-24803.0,-18.026484097912668,3.670604848673321,5.127521570220625,9.118199348449707,10.599919319152832,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.2,5,myocardium,0.6235402278722076,95440.0,87294.0,-8146.0,-8.535205364626991,3.617101645509286,3.811013162826906,6.468474864959717,9.614753723144531,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.2,6,heart muscle,0.45133707154105407,133928.0,137860.0,3932.0,2.9359058598650023,2.5637756507906184,4.362023664632644,8.135883331298828,12.440378189086914,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.3,1,left_ventricle,0.4551977881294848,22184.0,70769.0,48585.0,219.0091958168049,7.090756901208763,6.067409291845579,9.598746299743652,11.136638641357422,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.3,2,right_ventricle,0.6433368522719616,58256.0,119828.0,61572.0,105.6921175501236,6.587786810969447,5.894473026517929,11.866476058959961,14.795646667480469,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.3,3,left_atrium,0.7899835754895768,112048.0,85827.0,-26221.0,-23.401577895187778,3.4021851303609534,4.188827334808606,8.494638442993164,10.47179126739502,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.3,4,right_atrium,0.7563776522379881,151968.0,112667.0,-39301.0,-25.86136555064224,5.361933465337642,6.175505157067799,11.288946151733398,13.966296195983887,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.3,5,myocardium,0.525155747475429,90488.0,87364.0,-3124.0,-3.45239147732296,4.549097517035363,4.615307669669831,7.1541290283203125,11.85660171508789,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.3,6,heart muscle,0.38364045141950276,134520.0,137688.0,3168.0,2.3550401427297056,3.285190437999159,4.9004871864237325,9.988570213317871,14.057839393615723,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.33,1,left_ventricle,0.4388798727821285,20976.0,70835.0,49859.0,237.69546147978642,7.333979393240644,6.087072801890529,9.744752883911133,11.721901893615723,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.33,2,right_ventricle,0.611032743561546,53816.0,119867.0,66051.0,122.73487438679946,7.003717716228803,6.014134112193085,11.556674003601074,14.638001441955566,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.33,3,left_atrium,0.782552790666381,114800.0,85851.0,-28949.0,-25.216898954703833,3.4962531189524126,4.130276760129166,8.497127532958984,10.248470306396484,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.33,4,right_atrium,0.753142086735076,154400.0,112620.0,-41780.0,-27.059585492227978,5.466437314753186,5.841927210445807,10.942407608032227,13.340712547302246,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.33,5,myocardium,0.5161380062426626,92352.0,87379.0,-4973.0,-5.384831947331947,4.707287420327688,4.553715145691599,7.21399450302124,12.378016471862793,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.33,6,heart muscle,0.3783962694361494,134280.0,137636.0,3356.0,2.499255287459041,3.355662467766633,4.819167292416481,9.568609237670898,14.255126953125,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.4,1,left_ventricle,0.4814736306596574,23352.0,71000.0,47648.0,204.04248030147312,6.894769998967018,6.316357967046674,9.319973945617676,11.977953910827637,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.4,2,right_ventricle,0.6824325797379494,63504.0,119972.0,56468.0,88.92038296800202,5.828444658837796,5.713155514937798,11.853299140930176,13.543560981750488,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.4,3,left_atrium,0.8027975396100704,113448.0,85870.0,-27578.0,-24.30893448981031,3.1136125665995595,3.7650315954716875,7.675386905670166,9.180020332336426,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.4,4,right_atrium,0.7761949786283044,143440.0,112506.0,-30934.0,-21.56581148912437,4.763277803403074,5.860140565919786,10.309380531311035,12.28503131866455,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.4,5,myocardium,0.5737167942368303,90264.0,87416.0,-2848.0,-3.1551892227244527,4.255784822228907,4.0540496899341,6.415799140930176,12.375173568725586,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.4,6,heart muscle,0.4019702565635721,136680.0,137599.0,919.0,0.6723734269827334,3.087354120127536,4.837984366433236,10.075942993164062,13.75825309753418,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.5,1,left_ventricle,0.8712434815332509,58976.0,71231.0,12255.0,20.77963917525773,1.572181460128897,1.6459496448783408,2.618135929107666,3.4813554286956787,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.5,2,right_ventricle,0.9173965204322434,107392.0,120165.0,12773.0,11.89380959475566,1.4442770790623471,1.8450283375439576,3.1979410648345947,4.376413345336914,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.5,3,left_atrium,0.9185845031615186,90120.0,85903.0,-4217.0,-4.6793164669329785,0.8898629960262394,1.178668335948767,1.9799245595932007,3.7569730281829834,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.5,4,right_atrium,0.9203279871029979,117768.0,112363.0,-5405.0,-4.589531961143944,1.2363554249078401,1.6509179831925742,2.5919108390808105,3.922649621963501,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.5,5,myocardium,0.8337186650397106,99272.0,87580.0,-11692.0,-11.77774196147957,1.398329280420255,1.3647388122055526,1.8613848686218262,3.039266347885132,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.5,6,heart muscle,0.6881942142287277,137240.0,137261.0,21.0,0.015301661323229378,1.1235734093234435,1.4165512096964736,2.2078099250793457,4.910209655761719,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.6,1,left_ventricle,0.9024354552106199,71976.0,71529.0,-447.0,-0.6210403467822607,1.0667613836662193,1.5163165260213165,2.385446310043335,3.2031209468841553,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.6,2,right_ventricle,0.9377886354367072,122584.0,120153.0,-2431.0,-1.983129935391242,0.9233734225924076,1.3086907604696314,2.0935463905334473,2.860896348953247,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.6,3,left_atrium,0.9178030776929813,89864.0,85982.0,-3882.0,-4.319861123475474,0.8903878280765682,1.105807698714737,2.0191428661346436,3.533003330230713,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.6,4,right_atrium,0.9342214528311006,115480.0,112117.0,-3363.0,-2.9121925874610324,0.9524708043798511,1.3141572805762056,2.3679661750793457,3.725499391555786,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.6,5,myocardium,0.8442055836845529,88984.0,87781.0,-1203.0,-1.351928436572867,1.0078284024134283,1.4298152266500948,2.1490020751953125,2.486726999282837,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.6,6,heart muscle,0.7095941730418877,135768.0,137033.0,1265.0,0.9317364916622474,0.909138924248843,1.1956652507934757,1.989298701286316,3.978269338607788,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.7,1,left_ventricle,0.908892178701385,72784.0,71770.0,-1014.0,-1.3931633326005715,0.9370453580906759,1.282456075365146,1.9022043943405151,3.3706257343292236,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.7,2,right_ventricle,0.9397820006105964,122368.0,120018.0,-2350.0,-1.9204367154811715,0.8456695610962611,1.223984427850306,2.045764684677124,3.4325754642486572,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.7,3,left_atrium,0.9145799792187624,89048.0,86110.0,-2938.0,-3.2993441739286675,0.9073522507416827,1.1359930612317453,2.0067014694213867,3.0088508129119873,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.7,4,right_atrium,0.9350807867931156,116048.0,111712.0,-4336.0,-3.7363849441610366,0.9001643984468211,1.2919732248611246,2.0713067054748535,2.553332805633545,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.7,5,myocardium,0.8542264357255988,89344.0,88075.0,-1269.0,-1.420352793696275,0.9036691887246898,1.252355464581626,1.7709615230560303,2.0175280570983887,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.7,6,heart muscle,0.713692135782245,139600.0,136931.0,-2669.0,-1.91189111747851,0.829331968950013,1.1954027017672118,1.979087233543396,4.898792266845703,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.71,1,left_ventricle,0.9112706201352997,73512.0,71795.0,-1717.0,-2.3356730873870934,0.9443029620570892,1.4707209557062149,1.9091627597808838,2.0164976119995117,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.71,2,right_ventricle,0.941019407474556,122680.0,120010.0,-2670.0,-2.1763938702314967,0.8465154139968458,1.463226867395522,1.9139785766601562,2.037323236465454,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.71,3,left_atrium,0.9224217927186815,89528.0,86127.0,-3401.0,-3.798811544991511,0.8465104449787187,1.0214154538756615,1.163814902305603,1.2366784811019897,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.71,4,right_atrium,0.9402568446555069,116160.0,111682.0,-4478.0,-3.8550275482093666,0.8533367674976944,1.0957796167778027,1.3932666778564453,1.4769576787948608,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.71,5,myocardium,0.8521479478295163,89408.0,88087.0,-1321.0,-1.4774964209019328,0.9434355110794376,1.495283718857466,1.9642140865325928,2.054306745529175,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.71,6,heart muscle,0.7351245387453874,140576.0,136928.0,-3648.0,-2.595037559754154,0.7866579201973521,1.0855999534322838,1.5829582214355469,2.0364840030670166,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.8,1,left_ventricle,0.8997214295881089,75600.0,71939.0,-3661.0,-4.842592592592593,1.1215903570926842,1.462144570643422,2.297220230102539,3.932398557662964,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.8,2,right_ventricle,0.9303009879506189,123360.0,119972.0,-3388.0,-2.7464332036316472,1.1039417300226169,1.321730343824903,2.002842664718628,3.6028778553009033,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.8,3,left_atrium,0.9063774373736335,88472.0,86614.0,-1858.0,-2.100099466497875,1.0341038886106262,1.3119157248515032,2.209097146987915,3.8762400150299072,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.8,4,right_atrium,0.9250277633983285,114592.0,111425.0,-3167.0,-2.763718235129852,1.159509507292163,1.4944079048292334,2.431251049041748,3.6786248683929443,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.8,5,myocardium,0.845006783195614,90184.0,88198.0,-1986.0,-2.2021644637629736,0.9892570661729797,1.3135933586943187,1.9559636116027832,2.217951774597168,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +0,pm0002,pm0002,0.8,6,heart muscle,0.6962771142179612,137944.0,137004.0,-940.0,-0.681435945021168,0.9238063784004783,1.3542657958490865,2.0784928798675537,4.939401149749756,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\fits\pm0002\pm0002_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0002\weights\mgn_stage_model.pt,2026-10-21T00:45:40+00:00,2026-10-21T00:45:40+00:00,final,-0.7329533100128174,-0.04821782931685448,3.471568031181663,7.8409104347229,14.795646667480469 +1,pm0003,pm0003,0.0,1,left_ventricle,0.8362675404494798,122944.0,93128.0,-29816.0,-24.251691827173346,2.758199635635238,3.7082628233518267,5.626396179199219,6.6442461013793945,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.0,2,right_ventricle,0.8659775899208949,149424.0,126791.0,-22633.0,-15.146830495770425,2.880328753100141,4.740461571439376,8.39052963256836,9.917374610900879,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.0,3,left_atrium,0.7722833376362503,66080.0,92727.0,26647.0,40.325363196125906,3.1178591930880613,3.9533617260443727,5.592374801635742,6.708593368530273,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.0,4,right_atrium,0.7498467949503616,54744.0,75800.0,21056.0,38.462662574894054,4.036918523455701,5.9004452892800945,9.576106071472168,10.285438537597656,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.0,5,myocardium,0.7822802760645481,139720.0,140939.0,1219.0,0.8724592041225308,2.2363342237516033,3.1672323041192687,5.34695291519165,6.962301731109619,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.0,6,heart muscle,0.4716206933286313,132344.0,139879.0,7535.0,5.69349573837877,2.3529363754062746,4.491923376653917,7.7918782234191895,10.105714797973633,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.1,1,left_ventricle,0.8725306430877634,110480.0,93318.0,-17162.0,-15.534033309196234,2.35585077293423,2.542450729126641,4.7365570068359375,7.539018630981445,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.1,2,right_ventricle,0.8499196092707195,136896.0,126816.0,-10080.0,-7.363253856942497,3.0143007560610973,3.99840243513471,6.430381774902344,8.113149642944336,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.1,3,left_atrium,0.8520753917831427,77208.0,92784.0,15576.0,20.174075225365247,2.07465956883914,3.0645621469455966,4.490946292877197,5.545742988586426,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.1,4,right_atrium,0.7995326822838655,57728.0,75800.0,18072.0,31.305432372505543,3.2138773499295046,4.321596318825283,7.0617780685424805,7.631931304931641,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.1,5,myocardium,0.8230636418958124,141720.0,140924.0,-796.0,-0.5616709003669207,1.9233860432952339,2.6530607726980144,3.8353378772735596,6.265205383300781,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.1,6,heart muscle,0.5354970017945463,134376.0,139788.0,5412.0,4.027504911591356,1.9470559139842438,3.589108125491559,5.942200660705566,8.027907371520996,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.2,1,left_ventricle,0.8851261620185923,90264.0,93468.0,3204.0,3.549587875565009,1.942236994814227,2.922299294625668,4.308115005493164,6.596616744995117,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.2,2,right_ventricle,0.8540026343988107,109248.0,126859.0,17611.0,16.120203573520797,2.6953798754823204,3.7983858803142714,5.421947956085205,6.56334114074707,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.2,3,left_atrium,0.8807836204644129,87120.0,92763.0,5643.0,6.4772727272727275,1.4494813665014687,2.9609370718294508,4.1830220222473145,5.0757551193237305,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.2,4,right_atrium,0.9029152764863534,70464.0,75800.0,5336.0,7.572661217075386,1.330292786366921,2.6123638137849383,3.8314831256866455,4.713351726531982,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.2,5,myocardium,0.8180583049064653,132880.0,140922.0,8042.0,6.052077062010837,2.0978455669820795,3.375793894069402,5.28107213973999,6.221386432647705,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.2,6,heart muscle,0.5631433873205182,137768.0,139767.0,1999.0,1.450990070263051,1.7630549586341566,3.2091100976025513,5.304494380950928,7.132256507873535,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.3,1,left_ventricle,0.6757077832801633,56176.0,93658.0,37482.0,66.72244374821987,4.72457699713543,5.71018804941817,8.903964042663574,9.74344539642334,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.3,2,right_ventricle,0.8202543204803324,91984.0,127031.0,35047.0,38.10119151156723,3.3499863143415514,4.1798187542970915,6.550422191619873,8.546931266784668,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.3,3,left_atrium,0.8718267570081042,95520.0,92655.0,-2865.0,-2.9993718592964824,1.6181742509698471,2.7243608782442723,3.884333848953247,5.016368389129639,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.3,4,right_atrium,0.8938896702609783,77776.0,75800.0,-1976.0,-2.5406295001028596,1.6034084346066986,2.660510895291975,3.9793050289154053,4.870967864990234,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.3,5,myocardium,0.6619591787067889,138552.0,140861.0,2309.0,1.6665223165309775,3.8551678407609757,4.55413888445798,6.701254844665527,7.878185749053955,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.3,6,heart muscle,0.5209520104987259,135256.0,139825.0,4569.0,3.378038682202638,2.107335600371803,3.3545527415681566,6.581012725830078,9.10163402557373,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.4,1,left_ventricle,0.6687830104876084,51352.0,93867.0,42515.0,82.7913226359246,4.874727712736409,6.051812278768932,9.349878311157227,10.322036743164062,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.4,2,right_ventricle,0.7962879025060396,86768.0,127235.0,40467.0,46.63816153420616,3.70119226651011,4.789989561627666,7.085556507110596,9.348793029785156,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.4,3,left_atrium,0.8748315424308107,102536.0,92617.0,-9919.0,-9.673675587110868,1.674811599059474,3.0354965049477944,4.5922088623046875,6.5387115478515625,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.4,4,right_atrium,0.8674458096094296,84376.0,75800.0,-8576.0,-10.1640276855978,2.1440029064636605,3.4126682347893995,5.297759056091309,6.238498687744141,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.4,5,myocardium,0.6089803867639462,128232.0,140821.0,12589.0,9.81736228086593,4.16186465284118,5.989529323917202,9.58584213256836,14.820911407470703,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.4,6,heart muscle,0.5166526447686387,139528.0,139797.0,269.0,0.1927928444469927,2.147955152003096,3.7029593489642822,6.775928497314453,9.743047714233398,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.5,1,left_ventricle,0.6934002241845969,52288.0,94020.0,41732.0,79.81181150550796,4.787855349907496,5.403450133194399,9.202677726745605,10.686324119567871,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.5,2,right_ventricle,0.836937541377671,97608.0,127453.0,29845.0,30.576387181378575,2.879232143862353,3.6935512374183364,5.040815353393555,5.994612693786621,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.5,3,left_atrium,0.870590959894475,106144.0,92482.0,-13662.0,-12.871193849864335,1.8499547102586855,2.6140426078409904,3.782714366912842,5.842451095581055,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.5,4,right_atrium,0.897964482293073,78264.0,75800.0,-2464.0,-3.1483185117039763,1.5453634574876807,2.8422579942791426,4.505563259124756,5.804710388183594,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.5,5,myocardium,0.644710830081668,129240.0,140878.0,11638.0,9.00495202723615,3.876712282136848,5.13280718119869,8.470831871032715,11.005965232849121,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.5,6,heart muscle,0.5423447741157383,138656.0,139938.0,1282.0,0.9245903531040849,1.8290529175336228,2.9357531132235204,4.910798072814941,6.444274425506592,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.6,1,left_ventricle,0.8831060829189695,78968.0,94189.0,15221.0,19.274896160470064,1.8774285167347944,2.4085169285066756,3.3751935958862305,3.700535297393799,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.6,2,right_ventricle,0.9007379076680039,112704.0,127841.0,15137.0,13.43075667234526,1.844007586377001,2.6082979977948764,4.000335693359375,5.025157451629639,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.6,3,left_atrium,0.8799769494274707,94920.0,92494.0,-2426.0,-2.555836493889591,1.6019974222004791,2.6428751791860217,3.9343442916870117,5.004648685455322,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.6,4,right_atrium,0.9050616449045414,77984.0,75800.0,-2184.0,-2.800574476815757,1.3228593528971506,2.3253964455479004,3.086315155029297,3.7993083000183105,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.6,5,myocardium,0.8455726143805165,133008.0,140946.0,7938.0,5.968062071454349,1.7131046052280168,2.6577723823435835,3.9841742515563965,4.780869483947754,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.6,6,heart muscle,0.6280133738557783,136144.0,139917.0,3773.0,2.771330356093548,1.3570040593621326,2.562366890701628,3.8705434799194336,4.8066606521606445,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.7,1,left_ventricle,0.9166275797373359,93280.0,94336.0,1056.0,1.1320754716981132,1.1439053330218327,2.1961741530027115,2.7587857246398926,3.6780900955200195,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.7,2,right_ventricle,0.934441924117411,123512.0,128219.0,4707.0,3.8109657361228058,1.1731514020182394,2.1109746653135755,3.0328145027160645,4.25352668762207,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.7,3,left_atrium,0.8906109761169851,90160.0,92564.0,2404.0,2.666370896184561,1.2621593519973178,2.7484627260364145,3.867497682571411,5.069550514221191,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.7,4,right_atrium,0.9236055617708167,74368.0,75800.0,1432.0,1.9255593803786575,1.0301595572187379,2.38505356064453,3.272949457168579,4.178952217102051,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.7,5,myocardium,0.8708604991814344,137496.0,141040.0,3544.0,2.5775295281317274,1.2747719346601214,2.5283727068174264,3.180208921432495,4.196619033813477,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.7,6,heart muscle,0.6752538656819755,137440.0,139872.0,2432.0,1.769499417927823,1.1720200569458679,2.516537415944215,3.7310590744018555,5.106321811676025,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.75,1,left_ventricle,0.9068165414172292,94056.0,94368.0,312.0,0.33171727481500385,1.236090723425616,2.0815482619229724,2.3216142654418945,2.495873212814331,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.75,2,right_ventricle,0.9333795623565894,128336.0,128357.0,21.0,0.016363296347088892,1.1382708905633334,2.1205652996546704,2.4495410919189453,2.5812416076660156,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.75,3,left_atrium,0.8738853640292106,93224.0,92599.0,-625.0,-0.6704282159100661,1.4130566639858377,2.514996689187491,2.7592477798461914,2.9421286582946777,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.75,4,right_atrium,0.9093931398416887,75800.0,75800.0,0.0,0.0,1.199405747035284,2.3651673973925957,2.609468460083008,2.714433193206787,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.75,5,myocardium,0.8711586598973285,141720.0,141124.0,-596.0,-0.42054755856618686,1.222891670641284,2.0886903595044353,2.3694686889648438,2.563608407974243,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.75,6,heart muscle,0.6427392356865024,140536.0,139951.0,-585.0,-0.41626344851141345,1.0708569767227651,2.371082360926069,2.6787304878234863,2.9304442405700684,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.8,1,left_ventricle,0.9146960753747867,94856.0,94385.0,-471.0,-0.49654212701357847,1.1482811599764944,2.273424146976176,2.8640620708465576,3.8091437816619873,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.8,2,right_ventricle,0.9287496427440348,130488.0,128430.0,-2058.0,-1.577156520139783,1.2912492975166587,2.2773450700075073,3.3913519382476807,4.625159740447998,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.8,3,left_atrium,0.8861588156123822,93128.0,92622.0,-506.0,-0.5433382011854652,1.3029651244348446,2.9418151493017692,3.912188768386841,5.0503106117248535,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.8,4,right_atrium,0.9040891400543797,74256.0,75800.0,1544.0,2.079293255763844,1.3032121549749183,2.6461448452858596,3.579702615737915,4.2027153968811035,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.8,5,myocardium,0.8739489797739972,138848.0,141237.0,2389.0,1.7205865406775755,1.2098394938673342,2.504508112685904,3.0450665950775146,4.332890033721924,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.8,6,heart muscle,0.6554388731586681,138992.0,140018.0,1026.0,0.7381719811212156,1.0771110794024992,2.691776217302307,3.6699535846710205,5.400887966156006,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.9,1,left_ventricle,0.8955306546321701,100592.0,94403.0,-6189.0,-6.15257674566566,1.5386884391981193,2.903295493344496,3.9056713581085205,4.501893997192383,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.9,2,right_ventricle,0.8980011084426381,142072.0,128578.0,-13494.0,-9.498001013570583,2.1376587509506546,3.7893253996526832,6.166567802429199,7.411038398742676,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.9,3,left_atrium,0.8255083610091476,78984.0,92646.0,13662.0,17.297174111212396,2.1874158823888723,3.229113787391282,4.332559585571289,5.53692102432251,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.9,4,right_atrium,0.8152050113895216,64680.0,75800.0,11120.0,17.192331478045762,2.888977410794155,4.483974107224608,6.91291618347168,8.01117992401123,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.9,5,myocardium,0.8501292907478972,140096.0,141440.0,1344.0,0.9593421653723161,1.5138261964560844,2.7169453115368225,3.7217633724212646,5.147378444671631,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 +1,pm0003,pm0003,0.9,6,heart muscle,0.5507007716038286,138040.0,140082.0,2042.0,1.4792813677195016,1.8401254281694601,3.672297483295044,5.638147830963135,7.713545322418213,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\fits\pm0003\pm0003_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_duke_heart\fold_pm0003\weights\mgn_stage_model.pt,2026-10-21T00:46:55+00:00,2026-10-21T00:46:55+00:00,final,-1.0941905975341797,-0.5313431024551392,3.401459027503457,5.941938877105713,14.820911407470703 diff --git a/tests/baselines/tutorial_15_duke_heart_leave_one_out/loo_metrics_by_label.png b/tests/baselines/tutorial_15_duke_heart_leave_one_out/loo_metrics_by_label.png new file mode 100644 index 00000000..a2fe7732 --- /dev/null +++ b/tests/baselines/tutorial_15_duke_heart_leave_one_out/loo_metrics_by_label.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25a9d8b60d369c5f9c78d7fd7364a656c1e48b0d9048f4849723d6951c5f6daa +size 70878 diff --git a/tests/baselines/tutorial_15_lung_leave_one_out/held_out_fitted_surface.png b/tests/baselines/tutorial_15_lung_leave_one_out/held_out_fitted_surface.png new file mode 100644 index 00000000..d2c5f6c8 --- /dev/null +++ b/tests/baselines/tutorial_15_lung_leave_one_out/held_out_fitted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1362075994275fde3df58f1814b743ff71478f0bb2305e83409707432839647b +size 107826 diff --git a/tests/baselines/tutorial_15_lung_leave_one_out/loo_metrics.csv b/tests/baselines/tutorial_15_lung_leave_one_out/loo_metrics.csv new file mode 100644 index 00000000..ae782b4d --- /dev/null +++ b/tests/baselines/tutorial_15_lung_leave_one_out/loo_metrics.csv @@ -0,0 +1,101 @@ +fold_index,held_out_case,case_id,stage,label_id,label_name,dice,volume_truth_mm3,volume_predicted_mm3,volume_difference_mm3,volume_difference_percent,surface_rmse_mm,displacement_rms_mm,displacement_95th_mm,displacement_max_mm,shape_parameters_file,network_weights_file,network_weights_created,network_weights_modified,network_epoch,pca_c01,fold_displacement_rms_mm,fold_displacement_95th_mm,fold_displacement_max_mm +0,100_HM10395,100_HM10395,0.0,28,lung_upper_lobe_left,0.9178140727376317,1243880.0,1115064.0,-128816.0,-10.355982892240409,21.914987181561074,1.3510394689830247,2.3479042053222656,2.832632303237915,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.0,29,lung_lower_lobe_left,0.8224626451487347,628600.0,481296.0,-147304.0,-23.433662106267896,6.178186699911529,1.272639515025084,1.9605408906936646,2.5071890354156494,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.0,30,lung_upper_lobe_right,0.9545978781778978,1144896.0,1088656.0,-56240.0,-4.912236569959193,1.9087514548591422,1.2012514107500398,1.977987289428711,2.711656093597412,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.0,31,lung_middle_lobe_right,0.8411946529645538,610392.0,585336.0,-25056.0,-4.104903078677309,5.171845192106714,1.3979080919637057,2.0110325813293457,2.4021823406219482,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.0,32,lung_lower_lobe_right,0.8032874444834738,920056.0,742512.0,-177544.0,-19.297086264314345,7.692731560545681,1.3117904292482743,1.9281096458435059,2.628549575805664,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.1,28,lung_upper_lobe_left,0.9249699012761858,1210616.0,1115064.0,-95552.0,-7.892841330364046,18.723354343850446,1.2366594982135435,2.2749273777008057,2.950307607650757,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.1,29,lung_lower_lobe_left,0.8554335759220427,555672.0,482024.0,-73648.0,-13.253861990526785,4.4918615102336075,1.4877835399795492,2.3068065643310547,2.6302428245544434,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.1,30,lung_upper_lobe_right,0.9625065124050087,1137864.0,1088656.0,-49208.0,-4.32459415184943,1.650318309194512,0.9403720992801744,1.3654512166976929,1.8917921781539917,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.1,31,lung_middle_lobe_right,0.8819079438808078,591864.0,586200.0,-5664.0,-0.9569766027330603,3.8565207941532758,1.3511953183347907,2.0925822257995605,2.636089324951172,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.1,32,lung_lower_lobe_right,0.860005254574206,871664.0,742168.0,-129496.0,-14.856183116430184,5.985529553614686,1.4172471529007464,2.221951723098755,2.583082675933838,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.2,28,lung_upper_lobe_left,0.9555635279055699,1159464.0,1115064.0,-44400.0,-3.829355633292625,1.9324401543139411,1.4647514424900605,2.805649757385254,3.9808921813964844,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.2,29,lung_lower_lobe_left,0.9256165218227881,514904.0,482952.0,-31952.0,-6.205428584745895,3.4030348841812774,1.3938488771742847,2.384279489517212,3.457019329071045,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.2,30,lung_upper_lobe_right,0.9661855896394667,1098104.0,1088656.0,-9448.0,-0.8603920940093106,1.4861999733914322,1.2756413634077917,2.3522870540618896,3.470308780670166,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.2,31,lung_middle_lobe_right,0.9223503171020101,602024.0,588760.0,-13264.0,-2.203234422547938,2.3808155434476075,1.4976560142108026,2.7217977046966553,3.8579764366149902,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.2,32,lung_lower_lobe_right,0.9116145242340736,838048.0,741216.0,-96832.0,-11.55446943373172,3.634788245968938,1.4882481084407362,2.7655961513519287,3.809877634048462,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.3,28,lung_upper_lobe_left,0.9618552970476171,1139928.0,1115064.0,-24864.0,-2.1811903909720614,1.767912749010805,1.2679623906252235,2.1884028911590576,2.644362449645996,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.3,29,lung_lower_lobe_left,0.9371183363591352,485528.0,483912.0,-1616.0,-0.33283353380237596,3.2008671447697044,1.0362521783157281,1.7211425304412842,2.3780438899993896,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.3,30,lung_upper_lobe_right,0.9608607446583614,1068408.0,1088608.0,20200.0,1.8906634918495555,1.5968754519088497,1.3877334309565557,2.575252056121826,3.67266583442688,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.3,31,lung_middle_lobe_right,0.9151854501430773,570984.0,592024.0,21040.0,3.684866826390932,3.6110839594657502,1.2318225615580147,2.124781847000122,2.890000581741333,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.3,32,lung_lower_lobe_right,0.9127764761737659,810288.0,739936.0,-70352.0,-8.682345042750232,3.1940380309379415,1.2779225671985763,2.291733503341675,3.720045328140259,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.4,28,lung_upper_lobe_left,0.9628827536751524,1116136.0,1115064.0,-1072.0,-0.09604564318326798,1.6237150263677214,1.2017415745195559,1.9896315336227417,2.564976692199707,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.4,29,lung_lower_lobe_left,0.9328263411597446,466328.0,484640.0,18312.0,3.9268497709766517,3.343317502186818,0.9785690467743222,1.712482213973999,2.6157894134521484,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.4,30,lung_upper_lobe_right,0.951942263580926,1056336.0,1088584.0,32248.0,3.0528165280744006,1.8383868694759784,1.5127116827391143,2.7641172409057617,3.795145034790039,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.4,31,lung_middle_lobe_right,0.9017500402348315,550248.0,593040.0,42792.0,7.77685698085227,4.210840925543146,1.2805767790783598,2.036687135696411,3.0490593910217285,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.4,32,lung_lower_lobe_right,0.9051979461034992,791072.0,740456.0,-50616.0,-6.398406213340884,3.3883016339229766,1.2995683842621095,2.2900471687316895,3.5846474170684814,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.5,28,lung_upper_lobe_left,0.9601710171017102,1089160.0,1115064.0,25904.0,2.3783466157405706,1.7599687773579686,1.0883062773983483,1.8236653804779053,2.5730226039886475,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.5,29,lung_lower_lobe_left,0.9178771092334006,455072.0,485080.0,30008.0,6.594121369805218,3.7938084998220813,0.9822937812769316,1.7924391031265259,2.7757179737091064,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.5,30,lung_upper_lobe_right,0.9524495352971623,1044512.0,1088464.0,43952.0,4.207898042339389,1.896041903444608,1.4861907793932225,2.6503992080688477,3.5307579040527344,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.5,31,lung_middle_lobe_right,0.8969328543796629,535320.0,593808.0,58488.0,10.92580138982291,4.095827256105396,1.2249505475586984,1.778875470161438,2.565524101257324,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.5,32,lung_lower_lobe_right,0.8960367544160748,774648.0,740272.0,-34376.0,-4.4376284454358625,3.7738470392988193,1.3492241563045684,2.5539510250091553,4.28975772857666,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.6,28,lung_upper_lobe_left,0.9710186220147428,1075032.0,1115064.0,40032.0,3.72379612885942,1.2213775632935837,1.0282197954730568,1.8309094905853271,2.5049426555633545,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.6,29,lung_lower_lobe_left,0.9290160210122712,461816.0,485440.0,23624.0,5.115457238380654,3.393126286047788,1.2710484764623264,2.5776636600494385,3.9755373001098633,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.6,30,lung_upper_lobe_right,0.9668833754470316,1061096.0,1088640.0,27544.0,2.5958065999683346,1.3727131147413867,1.4397950944687041,2.4883549213409424,2.994204044342041,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.6,31,lung_middle_lobe_right,0.9152245563330652,529696.0,594120.0,64424.0,12.162447894641454,3.312580724446899,1.301512349467103,1.8742526769638062,2.985581159591675,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.6,32,lung_lower_lobe_right,0.9199505156398307,759672.0,740600.0,-19072.0,-2.510557187838962,2.855991963257547,1.6236385695745823,3.3772923946380615,4.800646781921387,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.7,28,lung_upper_lobe_left,1.0,1115064.0,1115064.0,0.0,0.0,0.0,0.7635149429907659,0.8560166954994202,0.8806405663490295,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.7,29,lung_lower_lobe_left,1.0,486136.0,486136.0,0.0,0.0,7.743922306841675e-06,0.8628810531662268,0.9577856659889221,1.040747880935669,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.7,30,lung_upper_lobe_right,1.0,1088656.0,1088656.0,0.0,0.0,0.007638541923029659,0.7930469474203572,0.9577457308769226,1.015751838684082,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.7,31,lung_middle_lobe_right,0.9972404068889112,596984.0,594496.0,-2488.0,-0.4167615882502714,0.27692242389153415,0.897426135454477,1.0069249868392944,1.0527749061584473,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.7,32,lung_lower_lobe_right,0.980741152749774,754776.0,741056.0,-13720.0,-1.8177578513360255,0.7624357551162179,1.0105339803127542,1.1735944747924805,1.2484071254730225,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.8,28,lung_upper_lobe_left,0.9696171229088655,1155952.0,1115064.0,-40888.0,-3.537171093609423,10.266594590165667,0.9133325949835853,1.6746623516082764,2.244924545288086,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.8,29,lung_lower_lobe_left,0.925572668282386,537144.0,486136.0,-51008.0,-9.496150008191472,3.52493023469875,1.3734704140057958,2.209634304046631,2.7466602325439453,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.8,30,lung_upper_lobe_right,0.9751873925920509,1099264.0,1088656.0,-10608.0,-0.9650093153237075,1.0956394977855017,0.8662964453460622,1.2819260358810425,1.8017253875732422,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.8,31,lung_middle_lobe_right,0.9437474706596519,630776.0,594840.0,-35936.0,-5.697109591994622,2.1306575357555513,1.1089575618794418,1.9451663494110107,2.4167215824127197,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.8,32,lung_lower_lobe_right,0.9090184008303125,823040.0,741672.0,-81368.0,-9.886275272161742,4.6616203230467,1.4777179541827254,2.277134895324707,2.84745192527771,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.9,28,lung_upper_lobe_left,0.933175138306675,1224664.0,1115064.0,-109600.0,-8.949393466289528,18.625872426396068,1.2415477510040314,2.261054277420044,2.9040656089782715,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.9,29,lung_lower_lobe_left,0.8577269314541008,590048.0,486136.0,-103912.0,-17.610770649167524,4.8166065223865475,1.2893087206493483,2.0377678871154785,2.381556272506714,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.9,30,lung_upper_lobe_right,0.958990559081643,1124688.0,1088656.0,-36032.0,-3.2037329463815745,1.6913859527969282,1.1022598234176855,1.819146752357483,2.2168362140655518,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.9,31,lung_middle_lobe_right,0.8691773772135863,617368.0,595144.0,-22224.0,-3.5997978515245364,4.510618977386978,1.3145399287091837,2.0842833518981934,2.597325086593628,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +0,100_HM10395,100_HM10395,0.9,32,lung_lower_lobe_right,0.8406548778220322,892440.0,742552.0,-149888.0,-16.795302765452018,6.399738641904991,1.3106727169097168,2.015723943710327,2.5736966133117676,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\fits\100_HM10395\100_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_100_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:42:21+00:00,2026-10-21T00:42:21+00:00,final,-0.08452751487493515,1.2598091991908034,2.1876964569091797,4.800646781921387 +1,101_HM10395,101_HM10395,0.0,28,lung_upper_lobe_left,0.9327204892495865,1105040.0,974880.0,-130160.0,-11.778759139940636,3.2511109231223845,1.7228970924074491,2.9915170669555664,4.171566963195801,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.0,29,lung_lower_lobe_left,0.8831718478097407,769504.0,720000.0,-49504.0,-6.433234914958207,4.479691771469443,1.3910252836246846,2.6067018508911133,3.1349401473999023,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.0,30,lung_upper_lobe_right,0.9418127952961002,927288.0,838736.0,-88552.0,-9.549568203190379,2.5937193980814195,1.6288970749219083,2.5888595581054688,3.157487630844116,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.0,31,lung_middle_lobe_right,0.8271225043613103,339344.0,321008.0,-18336.0,-5.403366495355745,4.518608633546878,2.0299204770978663,2.9793102741241455,3.3132505416870117,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.0,32,lung_lower_lobe_right,0.8639738021563268,912480.0,817128.0,-95352.0,-10.449763282482904,5.726006784911832,1.729206076919504,2.9168899059295654,3.9815356731414795,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.1,28,lung_upper_lobe_left,0.9449243050250238,1077568.0,974880.0,-102688.0,-9.529607412246838,2.3392517081860267,1.2800502034704053,2.0535120964050293,2.8613922595977783,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.1,29,lung_lower_lobe_left,0.9037426799490027,760864.0,720000.0,-40864.0,-5.370736425957858,3.6882109187601597,1.4249162076552904,2.7367708683013916,3.3895649909973145,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.1,30,lung_upper_lobe_right,0.9540979554847657,909160.0,838736.0,-70424.0,-7.746051300101192,2.1248883257018045,1.3432859392022554,2.0433759689331055,2.582426071166992,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.1,31,lung_middle_lobe_right,0.8665266512166859,341776.0,321008.0,-20768.0,-6.07649454613548,3.551904343826769,1.6572133787446872,2.2993826866149902,2.7249960899353027,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.1,32,lung_lower_lobe_right,0.8862884975367165,900904.0,817128.0,-83776.0,-9.299104011082202,4.856069042640559,1.655567331673664,2.7760908603668213,3.78806734085083,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.2,28,lung_upper_lobe_left,0.9598959410650926,1036288.0,974880.0,-61408.0,-5.92576581027668,1.7366569073827542,1.0989193871985319,1.870711088180542,2.831285238265991,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.2,29,lung_lower_lobe_left,0.9447017041543926,732920.0,720000.0,-12920.0,-1.7628117666321017,2.125900283161273,1.4679310761535782,2.6956355571746826,3.102515935897827,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.2,30,lung_upper_lobe_right,0.9605881755290162,893536.0,838736.0,-54800.0,-6.132937005336103,1.7831932877997958,1.2888924707025102,2.0154037475585938,2.7687251567840576,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.2,31,lung_middle_lobe_right,0.8966350044194356,321608.0,321008.0,-600.0,-0.18656252332031542,2.7156504662898038,1.6623671035988161,2.320749282836914,2.9446165561676025,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.2,32,lung_lower_lobe_right,0.9323073120650975,866008.0,817128.0,-48880.0,-5.644289660141708,2.695721535308458,1.9353613586057583,3.5365493297576904,4.264346122741699,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.3,28,lung_upper_lobe_left,0.9750433105612395,983072.0,974880.0,-8192.0,-0.8333062074802252,1.0599412474670156,1.137474080330328,1.8918920755386353,3.1575331687927246,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.3,29,lung_lower_lobe_left,0.9715463184612907,720376.0,720000.0,-376.0,-0.05219496485168856,1.097889599133623,1.3029556991193167,2.585085868835449,3.3760547637939453,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.3,30,lung_upper_lobe_right,0.9761292726526647,857400.0,838736.0,-18664.0,-2.1768136225798926,1.0305173132009766,1.3873808542276207,2.25225830078125,3.0952186584472656,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.3,31,lung_middle_lobe_right,0.9456057596822244,323472.0,321008.0,-2464.0,-0.7617351733689469,1.5363546593460975,1.4888988266312102,2.2384285926818848,2.6020352840423584,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.3,32,lung_lower_lobe_right,0.9658820161200317,831504.0,817128.0,-14376.0,-1.7289153148992669,1.4197845897920343,1.5941173825862514,3.031672954559326,4.274285793304443,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.4,28,lung_upper_lobe_left,0.9725423672437592,949248.0,974880.0,25632.0,2.700242718446602,1.152478962174137,1.016777520683168,1.7390668392181396,2.591068744659424,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.4,29,lung_lower_lobe_left,0.9677571008814887,709400.0,720000.0,10600.0,1.4942204680011277,1.3884391762168646,1.3101952823479686,3.062720775604248,4.565352439880371,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.4,30,lung_upper_lobe_right,0.9693711057889565,838112.0,838736.0,624.0,0.07445305639341758,1.3156598875464671,1.4525591404982845,2.4293253421783447,3.470731496810913,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.4,31,lung_middle_lobe_right,0.9355394080563081,310944.0,321008.0,10064.0,3.2365956570958114,1.7829016823245538,1.3016084981748304,2.4537811279296875,3.353972911834717,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.4,32,lung_lower_lobe_right,0.9427180647064608,809912.0,817128.0,7216.0,0.8909609932930985,2.494963206730642,1.950373956069238,4.433964252471924,5.721100330352783,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.5,28,lung_upper_lobe_left,0.9641010078019296,923112.0,974880.0,51768.0,5.607986896497933,1.4008630857173212,0.9755201709252901,1.7387081384658813,2.436178207397461,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.5,29,lung_lower_lobe_left,0.954357547562995,700432.0,720000.0,19568.0,2.793704456678164,1.8532940120641586,1.3344999873141854,3.232011318206787,4.793246746063232,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.5,30,lung_upper_lobe_right,0.9632542884509332,815008.0,838736.0,23728.0,2.9113824649573994,1.6274319683673149,1.3566029597491758,2.310124635696411,3.5171685218811035,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.5,31,lung_middle_lobe_right,0.9225535168195719,306832.0,321008.0,14176.0,4.620117849507222,2.1362486193206465,1.387287061952023,2.5024707317352295,3.0634865760803223,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.5,32,lung_lower_lobe_right,0.9108182410229131,792080.0,817128.0,25048.0,3.1623068376931625,3.538766854939139,2.3073656331590517,4.945698261260986,6.275704383850098,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.6,28,lung_upper_lobe_left,0.9686071858404101,926192.0,974880.0,48688.0,5.256793407846321,2.412867538332989,0.9748979386825178,1.6702194213867188,2.2184033393859863,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.6,29,lung_lower_lobe_left,0.9581722042459159,699152.0,720000.0,20848.0,2.981898070805776,1.6789052184914728,1.1690617685436833,2.7480204105377197,3.635526180267334,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.6,30,lung_upper_lobe_right,0.9693444305755691,821256.0,838736.0,17480.0,2.1284471589857485,1.3410438198618582,1.1500846563818785,1.936333417892456,2.373533010482788,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.6,31,lung_middle_lobe_right,0.9272975972333342,298944.0,321008.0,22064.0,7.380646542496254,2.0091563216101864,1.203925229239996,2.362867832183838,3.2141222953796387,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.6,32,lung_lower_lobe_right,0.9210098331123177,781552.0,817128.0,35576.0,4.551968391098737,3.3396726130173895,1.8877063088841943,4.146571636199951,5.320823669433594,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.7,28,lung_upper_lobe_left,1.0,974880.0,974880.0,0.0,0.0,0.0,0.7914845368750327,0.873724102973938,0.8956640362739563,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.7,29,lung_lower_lobe_left,1.0,720000.0,720000.0,0.0,0.0,1.007862012205677e-19,0.7594650341983847,0.8198293447494507,0.8629022836685181,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.7,30,lung_upper_lobe_right,1.0,838736.0,838736.0,0.0,0.0,0.0,0.860154609738537,0.9124966859817505,0.9353204965591431,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.7,31,lung_middle_lobe_right,1.0,321008.0,321008.0,0.0,0.0,0.0,0.8985477664880457,0.9421494603157043,0.9550992846488953,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.7,32,lung_lower_lobe_right,1.0,817128.0,817128.0,0.0,0.0,0.0,0.8657244231160338,0.9354435801506042,0.9559297561645508,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.8,28,lung_upper_lobe_left,0.9667628275272069,1035640.0,974880.0,-60760.0,-5.866903557220656,1.3550178523042105,1.3633818269707687,2.201721668243408,3.121610164642334,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.8,29,lung_lower_lobe_left,0.9493010548766745,735336.0,720000.0,-15336.0,-2.0855772055223736,1.8910637963316341,1.723360225393916,3.226499080657959,4.355719566345215,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.8,30,lung_upper_lobe_right,0.9686084067197992,882744.0,838736.0,-44008.0,-4.985363820088271,1.5536694282206975,1.2119240629626862,1.8214805126190186,2.357658863067627,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.8,31,lung_middle_lobe_right,0.8968013738402046,326520.0,321008.0,-5512.0,-1.6881048634080607,2.853310066947127,1.7315509554063426,2.804861307144165,3.2760987281799316,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.8,32,lung_lower_lobe_right,0.9311197422811575,849184.0,817128.0,-32056.0,-3.7749180389644645,2.776105490839984,2.0990207561890726,3.7071359157562256,4.818637847900391,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.9,28,lung_upper_lobe_left,0.9446224967464989,1084440.0,974880.0,-109560.0,-10.102910257828926,2.2225142367576027,1.8560436236055675,3.0580215454101562,4.222433567047119,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.9,29,lung_lower_lobe_left,0.9068885248728339,764672.0,720000.0,-44672.0,-5.841981921660529,3.555088372464736,1.973633624830751,3.6884589195251465,4.563296318054199,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.9,30,lung_upper_lobe_right,0.954466684393424,906272.0,838736.0,-67536.0,-7.452067370502454,2.055733216179971,1.595148732587604,2.607919931411743,3.211838960647583,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.9,31,lung_middle_lobe_right,0.8542570629640344,342736.0,321008.0,-21728.0,-6.339573315904953,3.9053444963298736,2.1141870877329767,2.9906461238861084,3.4422945976257324,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 +1,101_HM10395,101_HM10395,0.9,32,lung_lower_lobe_right,0.8833376978040477,893656.0,817128.0,-76528.0,-8.56347408846357,4.884780529336753,2.2293967749028236,3.6289782524108887,4.990013122558594,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\fits\101_HM10395\101_HM10395_ssm_pca_coefficients.json,C:\src\Projects\MONAI-Physio\monai-physio\.claude\worktrees\t04-full-perf\tutorials\output\test\tutorial_15_lung\fold_101_HM10395\weights\mgn_stage_model.pt,2026-10-21T00:44:07+00:00,2026-10-21T00:44:07+00:00,final,0.11908475309610367,1.5361244315205802,2.865128993988037,6.275704383850098 diff --git a/tests/baselines/tutorial_15_lung_leave_one_out/loo_metrics_by_label.png b/tests/baselines/tutorial_15_lung_leave_one_out/loo_metrics_by_label.png new file mode 100644 index 00000000..cfa014cf --- /dev/null +++ b/tests/baselines/tutorial_15_lung_leave_one_out/loo_metrics_by_label.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39a3cfe53456573d058f2312797da88002b5ab3627849ac519c945ada61d6016 +size 88589 diff --git a/tests/baselines/tutorial_16_duke_heart_physics_informed_motion_prep/fitted_reference_model.png b/tests/baselines/tutorial_16_duke_heart_physics_informed_motion_prep/fitted_reference_model.png new file mode 100644 index 00000000..5db49fd3 --- /dev/null +++ b/tests/baselines/tutorial_16_duke_heart_physics_informed_motion_prep/fitted_reference_model.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6aff7ece1f549df4cc699038358b5b99c62143d3c89aa312e0ac6be6972596c4 +size 130417 diff --git a/tests/baselines/tutorial_16_duke_heart_physics_informed_motion_prep/ssm_template_section.png b/tests/baselines/tutorial_16_duke_heart_physics_informed_motion_prep/ssm_template_section.png new file mode 100644 index 00000000..9e1a017c --- /dev/null +++ b/tests/baselines/tutorial_16_duke_heart_physics_informed_motion_prep/ssm_template_section.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ab3436a434b6f00ff51875d35b31f5a451725ba4c14c2d8c06876f66692d269 +size 116543 diff --git a/tests/baselines/tutorial_17_duke_heart_physics_informed_motion_train/trained_template_boundary.png b/tests/baselines/tutorial_17_duke_heart_physics_informed_motion_train/trained_template_boundary.png new file mode 100644 index 00000000..57b11b05 --- /dev/null +++ b/tests/baselines/tutorial_17_duke_heart_physics_informed_motion_train/trained_template_boundary.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9506e487efec925f0b35bc2669c8eee0f1ec1ea8acb399136af7b5dbfee9d48 +size 126031 diff --git a/tests/baselines/tutorial_17_duke_heart_physics_informed_motion_train/training_losses.png b/tests/baselines/tutorial_17_duke_heart_physics_informed_motion_train/training_losses.png new file mode 100644 index 00000000..e8aff31c --- /dev/null +++ b/tests/baselines/tutorial_17_duke_heart_physics_informed_motion_train/training_losses.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e1f496c117d0c98c637d7422617a0aa9dd6a741db124bc3b5b349ce189ded8af +size 29509 diff --git a/tests/baselines/tutorial_18_duke_heart_physics_informed_motion_infer/predicted_surface.png b/tests/baselines/tutorial_18_duke_heart_physics_informed_motion_infer/predicted_surface.png new file mode 100644 index 00000000..798b1d88 --- /dev/null +++ b/tests/baselines/tutorial_18_duke_heart_physics_informed_motion_infer/predicted_surface.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f95a801519b46a470fdbab60b92f1b51ddd124fb66680dc5c519c330075d674 +size 124222 diff --git a/tests/baselines/tutorial_18_duke_heart_physics_informed_motion_infer/von_mises_stress.png b/tests/baselines/tutorial_18_duke_heart_physics_informed_motion_infer/von_mises_stress.png new file mode 100644 index 00000000..70978fa5 --- /dev/null +++ b/tests/baselines/tutorial_18_duke_heart_physics_informed_motion_infer/von_mises_stress.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7da9184ee5dfa630e46533e97db3a516037dde45510e03690ec2b55ae58ca9da +size 137775 diff --git a/tests/conftest.py b/tests/conftest.py index d332fd67..90e49004 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,14 +12,19 @@ from pathlib import Path from typing import Any, Optional +# cuBLAS reads this when CUDA starts; deterministic cuBLAS needs it, and the +# tutorial tests turn deterministic algorithms on. +os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8") + import itk import numpy as np import pytest from parameters_base import ParametersBase from monai_physio.download_data import DownloadData -from monai_physio.process_contours import ProcessContours +from monai_physio.process_surface_mesh import ProcessSurfaceMesh from monai_physio.process_transforms import ProcessTransforms +from monai_physio.process_volumetric_mesh import ProcessVolumetricMesh from monai_physio.register_images_ants import RegisterImagesANTS from monai_physio.register_images_greedy import RegisterImagesGreedy from monai_physio.register_images_icon import RegisterImagesICON @@ -96,7 +101,11 @@ def pytest_addoption(parser: pytest.Parser) -> None: "--run-all", action="store_true", default=False, - help=("Enable every --run-* bucket: " + ", ".join(_RUN_BUCKET_FLAGS) + "."), + help=( + "Enable every --run-* bucket: " + + ", ".join(_RUN_BUCKET_FLAGS) + + "; also implies --require-tutorial-data." + ), ) parser.addoption( "--create-baselines", @@ -122,22 +131,25 @@ def pytest_addoption(parser: pytest.Parser) -> None: help=( "Fail rather than skip when a tutorial's dataset is missing. For " "runners that are supposed to have every dataset, where a skip " - "would report a green run that tested nothing." + "would report a green run that tested nothing. Implied by --run-all." ), ) def tutorial_data_is_required() -> bool: - """True when --require-tutorial-data turns missing-data skips into failures.""" + """True when --require-tutorial-data or --run-all makes missing data fail.""" if _pytest_config is None: return False - return bool(_pytest_config.getoption("--require-tutorial-data", default=False)) + return bool( + _pytest_config.getoption("--require-tutorial-data", default=False) + or _pytest_config.getoption("--run-all", default=False) + ) def skip_or_fail_missing_data(reason: str) -> None: """Skip because a dataset is absent, or fail if the run demands it be there.""" if tutorial_data_is_required(): - pytest.fail(f"{reason} (--require-tutorial-data is set)") + pytest.fail(f"{reason} (--require-tutorial-data or --run-all is set)") pytest.skip(reason) @@ -842,9 +854,15 @@ def segmenter_simpleware() -> SegmentHeartSimpleware: @pytest.fixture(scope="session") -def contour_tools() -> ProcessContours: - """Create a ProcessContours instance.""" - return ProcessContours() +def contour_tools() -> ProcessSurfaceMesh: + """Create a ProcessSurfaceMesh instance.""" + return ProcessSurfaceMesh() + + +@pytest.fixture(scope="session") +def volume_tools() -> ProcessVolumetricMesh: + """Create a ProcessVolumetricMesh instance.""" + return ProcessVolumetricMesh() @pytest.fixture(scope="session") diff --git a/tests/test_contour_mesh_extraction.py b/tests/test_contour_mesh_extraction.py index d8ee0172..06f39874 100644 --- a/tests/test_contour_mesh_extraction.py +++ b/tests/test_contour_mesh_extraction.py @@ -1,4 +1,4 @@ -"""Tests for ProcessContours' per-label surface and tetrahedral mesh extraction. +"""Tests for ProcessSurfaceMesh and ProcessVolumetricMesh label extraction. These use ``data/test/slicer_heart_small``, whose direction matrix is right-handed, plus a synthetic left-handed image for the cases that only a @@ -7,14 +7,25 @@ from __future__ import annotations +import importlib +import logging +import sys +from collections.abc import Sequence from pathlib import Path +from typing import Any, Optional, cast import itk import numpy as np import pytest import pyvista as pv +import torch +import vtk +from scipy import ndimage -from monai_physio.process_contours import ProcessContours +from monai_physio.process_images import ProcessImages +from monai_physio.process_mesh_kernels import ProcessMeshKernels +from monai_physio.process_surface_mesh import ProcessSurfaceMesh +from monai_physio.process_volumetric_mesh import ProcessVolumetricMesh #: A label that reaches the volume border, so its surface only closes if the #: mask is padded first. @@ -22,18 +33,9 @@ @pytest.fixture(scope="module") -def heart_labelmap() -> itk.Image: - """Read one small labelmap.""" - path = ( - Path(__file__).resolve().parents[1] - / "data" - / "test" - / "slicer_heart_small" - / "slice_000_labelmap.mha" - ) - if not path.exists(): - pytest.skip(f"Test labelmap not available: {path}") - return itk.imread(str(path)) +def heart_labelmap(test_labelmaps: list[dict[str, Any]]) -> itk.Image: + """Return one small labelmap.""" + return test_labelmaps[0]["labelmap"] def _label_mask(labelmap: itk.Image, label_id: int) -> itk.Image: @@ -95,69 +97,26 @@ def _roughness(surface: pv.PolyData) -> float: return float(np.linalg.norm(offset, axis=1).mean() / edge_length.mean()) -def _label_pairs(contours: pv.PolyData) -> set[tuple[int, ...]]: - """Return the set of label pairs the contour's cells separate.""" - pairs = np.asarray(contours.cell_data["boundary_labels"]) - return {tuple(row) for row in np.unique(pairs.reshape(len(pairs), -1), axis=0)} - - -class TestExtractContours: - """The multi-label contour must be smooth and keep its labels in contact.""" +@pytest.mark.requires_gpu +class TestLabelSurfaceSmoothing: + """The label surfaces must lose the voxel blocks they are drawn on.""" def test_smoothing_takes_the_voxel_blocks_out( - self, contour_tools: ProcessContours - ) -> None: - """Taubin smoothing leaves the surface markedly less faceted. - - These boxes are flat over most of their area, where there is nothing to - take out, so the margin here is smaller than on anatomy: the same - measure halves on a segmented chest CT. - """ - blocky = contour_tools.extract_contours(_touching_boxes(), taubin_iterations=0) - smooth = contour_tools.extract_contours(_touching_boxes(), taubin_iterations=20) - - assert _roughness(smooth) < 0.75 * _roughness(blocky) - - def test_smoothing_keeps_every_cell_and_its_labels( - self, contour_tools: ProcessContours + self, contour_tools: ProcessSurfaceMesh ) -> None: - """Smoothing only moves points, so the label of each cell survives. - - Downstream splits a contour into one prim per structure on - ``boundary_labels``, so a lost pair is a lost structure. - """ - blocky = contour_tools.extract_contours(_touching_boxes(), taubin_iterations=0) - smooth = contour_tools.extract_contours(_touching_boxes(), taubin_iterations=20) - - assert smooth.n_cells == blocky.n_cells - assert smooth.n_points == blocky.n_points - assert _label_pairs(smooth) == _label_pairs(blocky) - - def test_labels_stay_in_contact(self, contour_tools: ProcessContours) -> None: - """The wall between the labels is not torn open by smoothing. - - Regression guard for ``non_manifold_smoothing``: the surface net shares - its points between neighboring labels, so smoothing the mesh as one - keeps them together, and the edges where three labels meet must move - with the rest instead of being pinned. - """ - blocky = contour_tools.extract_contours(_touching_boxes(), taubin_iterations=0) - smooth = contour_tools.extract_contours(_touching_boxes(), taubin_iterations=20) - - def open_edges(contours: pv.PolyData) -> int: - return int( - contours.extract_feature_edges( - boundary_edges=True, - feature_edges=False, - manifold_edges=False, - non_manifold_edges=False, - ).n_cells - ) + """Taubin smoothing leaves the surface markedly less faceted.""" + blocky = contour_tools.extract_label_surfaces( + _touching_boxes(), distance_sigma_mm=0.0, smoothing_iterations=0 + ) + smooth = contour_tools.extract_label_surfaces( + _touching_boxes(), distance_sigma_mm=0.0, smoothing_iterations=20 + ) - assert open_edges(smooth) == open_edges(blocky) + for label_id in (1, 2): + assert _roughness(smooth[label_id]) < _roughness(blocky[label_id]) def test_anisotropic_labelmap_is_contoured_isotropically( - self, contour_tools: ProcessContours + self, contour_tools: ProcessSurfaceMesh ) -> None: """Boundaries land between the slices rather than terracing at them. @@ -168,17 +127,18 @@ def test_anisotropic_labelmap_is_contoured_isotropically( labelmap = _touching_boxes() origin_z = float(np.asarray(labelmap.GetOrigin())[2]) - contours = contour_tools.extract_contours(labelmap) + surface = contour_tools.extract_label_surfaces(labelmap)[1] - offsets = (np.asarray(contours.points)[:, 2] - origin_z) / 4.0 + offsets = (np.asarray(surface.points)[:, 2] - origin_z) / 4.0 assert np.any(np.abs(offsets - np.round(offsets)) > 0.1) +@pytest.mark.requires_gpu class TestExtractLabelSurfaces: """Labels extracted together must stay closed and stay in contact.""" def test_surfaces_are_watertight_and_outward( - self, contour_tools: ProcessContours + self, contour_tools: ProcessSurfaceMesh ) -> None: """Every label closes and encloses a positive volume.""" surfaces = contour_tools.extract_label_surfaces(_touching_boxes()) @@ -191,7 +151,7 @@ def test_surfaces_are_watertight_and_outward( assert surface.volume > 0.0, f"label {label_id} must face outward" def test_neighbors_share_the_wall_between_them( - self, contour_tools: ProcessContours + self, contour_tools: ProcessSurfaceMesh ) -> None: """The two surfaces meet on identical vertices, smoothing included. @@ -210,7 +170,9 @@ def test_neighbors_share_the_wall_between_them( ).point_data["implicit_distance"] assert np.count_nonzero(np.abs(distances) < 1e-9) == len(shared) - def test_volume_matches_the_labelmap(self, contour_tools: ProcessContours) -> None: + def test_volume_matches_the_labelmap( + self, contour_tools: ProcessSurfaceMesh + ) -> None: """Neither label is thinned or fattened by the smoothing. Not to the voxel exactly: the distance map is zero at the outermost @@ -227,98 +189,163 @@ def test_volume_matches_the_labelmap(self, contour_tools: ProcessContours) -> No voxels = int(np.count_nonzero(labels == label_id)) assert surface.volume == pytest.approx(voxels * voxel_volume, rel=0.15) + def test_walls_match_where_three_labels_meet( + self, contour_tools: ProcessSurfaceMesh + ) -> None: + """A vertex lying on a neighbor's surface is one of that neighbor's vertices. + + Label 5 overlaps the wall between labels 1 and 2, so the three meet + along lines; the margin field keeps every wall exact there too. + """ + surfaces = contour_tools.extract_label_surfaces( + _blob_labelmap((1.0, 1.0, 1.0), 1.0) + ) + + for first, second in ((1, 2), (1, 5), (2, 5)): + on_wall = ( + np.abs( + surfaces[first] + .copy() + .compute_implicit_distance(surfaces[second]) + .point_data["implicit_distance"] + ) + < 1e-3 + ) + assert np.count_nonzero(on_wall) > 0, f"{first} and {second} must touch" + wall_points = np.round(np.asarray(surfaces[first].points)[on_wall], 6) + assert {tuple(p) for p in wall_points.tolist()} <= _point_keys( + surfaces[second] + ) + + def test_label_ids_treat_other_labels_as_background( + self, contour_tools: ProcessSurfaceMesh + ) -> None: + """A subset gives the surfaces of a labelmap holding only that subset.""" + labelmap = _blob_labelmap((1.0, 1.0, 1.0), 1.0) + arr = itk.GetArrayFromImage(labelmap) + subset_only = itk.GetImageFromArray(np.where(np.isin(arr, [1, 2]), arr, 0)) + subset_only.CopyInformation(labelmap) + + actual = contour_tools.extract_label_surfaces(labelmap, label_ids=[1, 2]) + expected = contour_tools.extract_label_surfaces(subset_only) + + assert sorted(actual) == sorted(expected) == [1, 2] + for label_id, surface in actual.items(): + assert np.array_equal(surface.points, expected[label_id].points) + + def test_surfaces_carry_their_label_id( + self, contour_tools: ProcessSurfaceMesh + ) -> None: + surfaces = contour_tools.extract_label_surfaces(_touching_boxes()) + + for label_id, surface in surfaces.items(): + assert surface.field_data["SegmentationLabelIds"].tolist() == [label_id] + + def test_reduction_lowers_the_triangle_count( + self, contour_tools: ProcessSurfaceMesh + ) -> None: + full = contour_tools.extract_label_surfaces(_touching_boxes()) + reduced = contour_tools.extract_label_surfaces( + _touching_boxes(), surface_reduction_rate=0.5 + ) + + for label_id, surface in reduced.items(): + assert surface.n_cells == pytest.approx(full[label_id].n_cells / 2, rel=0.1) + assert surface.field_data["SegmentationLabelIds"].tolist() == [label_id] + assert surface.volume > 0.0 -class TestExtractWatertightSurface: - """The per-label surface must be closed and outward-oriented.""" +class TestSingleMaskSurface: + """A binary mask's surface must be closed and outward-oriented.""" + + @pytest.mark.requires_gpu def test_border_label_surface_is_watertight( - self, contour_tools: ProcessContours, heart_labelmap: itk.Image + self, contour_tools: ProcessSurfaceMesh, heart_labelmap: itk.Image ) -> None: """A label touching the volume border still closes. - Regression guard for the one-voxel background pad: without it the - isosurface is cut open where the structure reaches the image edge. + Regression guard for the background pad: without it the isosurface is + cut open where the structure reaches the image edge. """ - surface = contour_tools.extract_watertight_surface( + surface = contour_tools.extract_label_surfaces( _label_mask(heart_labelmap, BORDER_LABEL) - ) + )[1] assert surface.n_cells > 0 assert contour_tools.is_watertight(surface), ( "every edge must be shared by exactly two faces" ) - def test_normals_point_outward(self, contour_tools: ProcessContours) -> None: + @pytest.mark.requires_gpu + def test_normals_point_outward(self, contour_tools: ProcessSurfaceMesh) -> None: """A left-handed direction still yields a positive enclosed volume. Regression guard for ``auto_orient_normals``: VTK winds faces for a right-handed direction matrix, so LPS images with a negative-determinant direction otherwise come out with inward normals. """ - surface = contour_tools.extract_watertight_surface(_left_handed_box()) + surface = contour_tools.extract_label_surfaces(_left_handed_box())[1] - volume = surface.triangulate().compute_cell_sizes(volume=True) - assert float(np.sum(volume["Volume"])) == pytest.approx(0.0, abs=1e-9), ( - "cell volumes of a surface sum to zero; enclosed volume is checked below" - ) assert surface.volume > 0.0, "enclosed volume must be positive" def test_empty_surface_is_not_watertight( - self, contour_tools: ProcessContours + self, contour_tools: ProcessSurfaceMesh ) -> None: """A surface with no face fails the test rather than passing it vacuously.""" assert not contour_tools.is_watertight(pv.PolyData()) - def test_decimation_reduces_triangle_count( - self, contour_tools: ProcessContours, heart_labelmap: itk.Image + @pytest.mark.requires_gpu + def test_reduction_keeps_the_surface_closed( + self, contour_tools: ProcessSurfaceMesh, heart_labelmap: itk.Image ) -> None: - """surface_reduction_rate removes roughly that fraction of triangles.""" + """ACVD remeshing removes triangles without opening the surface.""" mask = _label_mask(heart_labelmap, BORDER_LABEL) - full = contour_tools.extract_watertight_surface(mask) - reduced = contour_tools.extract_watertight_surface( + reduced = contour_tools.extract_label_surfaces( mask, surface_reduction_rate=0.5 - ) + )[1] - assert reduced.n_cells == pytest.approx(full.n_cells / 2, rel=0.1) + assert contour_tools.is_watertight(reduced) class TestExtractTetrahedra: """The per-label volume mesh must be tetrahedral and positively oriented.""" + @pytest.mark.requires_gpu def test_cells_are_tetrahedra( - self, contour_tools: ProcessContours, heart_labelmap: itk.Image + self, volume_tools: ProcessVolumetricMesh, heart_labelmap: itk.Image ) -> None: """Every cell is a tetrahedron, six per labeled voxel.""" mask = _label_mask(heart_labelmap, BORDER_LABEL) - mesh = contour_tools.extract_tetrahedra(mask) + mesh = volume_tools.extract_tetrahedra(mask) assert set(mesh.celltypes) == {pv.CellType.TETRA} voxels = int(np.count_nonzero(itk.GetArrayViewFromImage(mask))) assert mesh.n_cells == 6 * voxels def test_left_handed_direction_yields_positive_volumes( - self, contour_tools: ProcessContours + self, volume_tools: ProcessVolumetricMesh ) -> None: """No tetrahedron is inverted when the direction determinant is negative. Regression guard for the hexahedron winding flip: triangulating the raw voxel cells of a left-handed image inverts every tetrahedron. """ - mesh = contour_tools.extract_tetrahedra(_left_handed_box()) + mesh = volume_tools.extract_tetrahedra(_left_handed_box()) volumes = mesh.compute_cell_sizes(volume=True)["Volume"] assert np.all(volumes > 0.0), "no tetrahedron may have negative volume" # 4 x 4 x 4 voxels of 1 x 2 x 3 mm. assert float(np.sum(volumes)) == pytest.approx(4 * 4 * 4 * 6.0) + @pytest.mark.requires_gpu def test_volume_survives_coarsening( - self, contour_tools: ProcessContours, heart_labelmap: itk.Image + self, volume_tools: ProcessVolumetricMesh, heart_labelmap: itk.Image ) -> None: """Elements twice the voxel keep the volume while shedding cells cubically.""" mask = _label_mask(heart_labelmap, BORDER_LABEL) voxel = float(np.min(np.asarray(mask.GetSpacing()))) - full = contour_tools.extract_tetrahedra(mask, element_size_mm=voxel) - coarse = contour_tools.extract_tetrahedra(mask, element_size_mm=2.0 * voxel) + full = volume_tools.extract_tetrahedra(mask, element_size_mm=voxel) + coarse = volume_tools.extract_tetrahedra(mask, element_size_mm=2.0 * voxel) full_volume = float(np.sum(full.compute_cell_sizes(volume=True)["Volume"])) coarse_volume = float(np.sum(coarse.compute_cell_sizes(volume=True)["Volume"])) @@ -326,27 +353,29 @@ def test_volume_survives_coarsening( assert coarse.n_cells == pytest.approx(full.n_cells * 0.125, rel=0.3) def test_empty_mask_yields_an_empty_mesh( - self, contour_tools: ProcessContours + self, volume_tools: ProcessVolumetricMesh ) -> None: """A mask with nothing in it has no bounding box to mesh.""" empty = itk.GetImageFromArray(np.zeros((4, 4, 4), dtype=np.uint8)) - assert contour_tools.extract_tetrahedra(empty).n_cells == 0 + assert volume_tools.extract_tetrahedra(empty).n_cells == 0 - def test_elements_are_isotropic(self, contour_tools: ProcessContours) -> None: + @pytest.mark.requires_gpu + def test_elements_are_isotropic(self, volume_tools: ProcessVolumetricMesh) -> None: """The requested element size is what the mesh is built on. Regression guard for the anisotropic default: meshing a mask's own voxels inherits their slice pitch, which is what made these meshes blocky along one axis. """ - mesh = contour_tools.extract_tetrahedra(_left_handed_box(), element_size_mm=1.0) + mesh = volume_tools.extract_tetrahedra(_left_handed_box(), element_size_mm=1.0) volumes = np.asarray(mesh.compute_cell_sizes(volume=True)["Volume"]) # Six tetrahedra fill a 1 mm cube. assert float(np.median(volumes)) == pytest.approx(1.0 / 6.0, rel=1e-6) +@pytest.mark.requires_gpu class TestTrimTetrahedraToSurface: """Relaxing must land the boundary on the surface without wrecking cells.""" @@ -360,7 +389,10 @@ def _boundary_gap(mesh: pv.UnstructuredGrid, surface: pv.PolyData) -> float: return float(np.abs(distance).mean()) def test_boundary_lands_on_the_surface( - self, contour_tools: ProcessContours, heart_labelmap: itk.Image + self, + contour_tools: ProcessSurfaceMesh, + volume_tools: ProcessVolumetricMesh, + heart_labelmap: itk.Image, ) -> None: """The voxel staircase is relaxed onto the surface, not merely clipped. @@ -370,15 +402,18 @@ def test_boundary_lands_on_the_surface( """ mask = _label_mask(heart_labelmap, BORDER_LABEL) surface = contour_tools.extract_label_surfaces(mask)[1] - mesh = contour_tools.extract_tetrahedra(mask) - relaxed = contour_tools.trim_tetrahedra_to_surface(mesh, surface) + mesh = volume_tools.extract_tetrahedra(mask) + relaxed = volume_tools.trim_tetrahedra_to_surface(mesh, surface) before = self._boundary_gap(mesh, surface) assert before > 0.0, "the staircase must start off the surface" assert self._boundary_gap(relaxed, surface) < 0.25 * before def test_cells_are_kept_whole_and_well_shaped( - self, contour_tools: ProcessContours, heart_labelmap: itk.Image + self, + contour_tools: ProcessSurfaceMesh, + volume_tools: ProcessVolumetricMesh, + heart_labelmap: itk.Image, ) -> None: """Cells are only dropped, never cut into slivers. @@ -388,8 +423,8 @@ def test_cells_are_kept_whole_and_well_shaped( """ mask = _label_mask(heart_labelmap, BORDER_LABEL) surface = contour_tools.extract_label_surfaces(mask)[1] - mesh = contour_tools.extract_tetrahedra(mask) - relaxed = contour_tools.trim_tetrahedra_to_surface(mesh, surface) + mesh = volume_tools.extract_tetrahedra(mask) + relaxed = volume_tools.trim_tetrahedra_to_surface(mesh, surface) assert set(relaxed.celltypes) == {pv.CellType.TETRA} assert 0 < relaxed.n_cells <= mesh.n_cells @@ -398,12 +433,14 @@ def test_cells_are_kept_whole_and_well_shaped( ) assert np.min(quality) >= 0.1, "no tetrahedron may be left a sliver" - def test_anatomy_color_survives(self, contour_tools: ProcessContours) -> None: + def test_anatomy_color_survives( + self, contour_tools: ProcessSurfaceMesh, volume_tools: ProcessVolumetricMesh + ) -> None: """Cell and field data attached at extraction are carried through.""" mask = _left_handed_box() surface = contour_tools.extract_label_surfaces(mask)[1] - mesh = contour_tools.extract_tetrahedra(mask, anatomy_names=["heart"]) - relaxed = contour_tools.trim_tetrahedra_to_surface(mesh, surface) + mesh = volume_tools.extract_tetrahedra(mask, anatomy_names=["heart"]) + relaxed = volume_tools.trim_tetrahedra_to_surface(mesh, surface) assert "Color" in relaxed.cell_data assert np.array_equal( @@ -421,7 +458,7 @@ def _single_tetra(points: np.ndarray) -> pv.UnstructuredGrid: return pv.UnstructuredGrid(cells, [pv.CellType.TETRA], points) def test_repairs_an_inverted_tetrahedron( - self, contour_tools: ProcessContours + self, volume_tools: ProcessVolumetricMesh ) -> None: """Swapping two corners inverts the cell; relaxation must restore it.""" points = np.array( @@ -429,13 +466,16 @@ def test_repairs_an_inverted_tetrahedron( ) mesh = self._single_tetra(points) - repaired = contour_tools.repair_inverted_tetrahedra(mesh) + repaired = volume_tools.repair_inverted_tetrahedra(mesh) - corners = repaired.points[repaired.cells_dict[np.uint8(pv.CellType.TETRA)]][0] + tets = cast(np.ndarray, repaired.cells_dict[np.uint8(pv.CellType.TETRA)]) + corners = repaired.points[tets][0] edges = corners[1:, :] - corners[0:1, :] assert np.linalg.det(edges) / 6.0 > 0.0 - def test_raises_when_unrecoverable(self, contour_tools: ProcessContours) -> None: + def test_raises_when_unrecoverable( + self, volume_tools: ProcessVolumetricMesh + ) -> None: """A degenerate cell with no neighbors to average toward can't be fixed.""" points = np.array( [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [3.0, 0.0, 0.0]] @@ -443,14 +483,787 @@ def test_raises_when_unrecoverable(self, contour_tools: ProcessContours) -> None mesh = self._single_tetra(points) with pytest.raises(ValueError, match="still inverted or degenerate"): - contour_tools.repair_inverted_tetrahedra(mesh, max_iterations=2) + volume_tools.repair_inverted_tetrahedra(mesh, max_iterations=2) - def test_raises_when_no_tetra_cells(self, contour_tools: ProcessContours) -> None: + def test_raises_when_no_tetra_cells( + self, volume_tools: ProcessVolumetricMesh + ) -> None: """A mesh without TETRA cells fails with a clear message, not a KeyError.""" mesh = pv.UnstructuredGrid() with pytest.raises(ValueError, match="no TETRA cells"): - contour_tools.repair_inverted_tetrahedra(mesh) + volume_tools.repair_inverted_tetrahedra(mesh) + + +def _frozen_base_trim( + tetrahedra: pv.UnstructuredGrid, + surface: pv.PolyData, + iterations: int = 5, + relaxation: float = 0.6, + min_scaled_jacobian: float = 0.1, +) -> tuple[pv.UnstructuredGrid, int]: + """Run the pre-S1 relaxation; return the mesh and its most damping passes.""" + relaxed = tetrahedra.clip_surface(surface, invert=True, crinkle=True) + connectivity = np.asarray(relaxed.cells_dict[np.uint8(pv.CellType.TETRA)]) + 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" + ] + ) + most_passes = 0 + 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) + damping = np.ones(point_count) + passes = 0 + while True: + passes += 1 + 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 < 2.0**-10, 0.0, halved) + most_passes = max(most_passes, passes) + points = np.array(relaxed.points) + return relaxed, most_passes + + +def _ball_mask(spacing: tuple[float, float, float], direction_sign: float) -> itk.Image: + """Build a ball whose surface is smooth but whose voxel mesh is a staircase.""" + zz, yy, xx = np.indices((26, 26, 26)) + arr = ((xx - 12.5) ** 2 + (yy - 12.5) ** 2 + (zz - 12.5) ** 2 < 9.0**2).astype( + np.uint8 + ) + mask = itk.GetImageFromArray(arr) + mask.SetSpacing(list(spacing)) + mask.SetDirection(itk.matrix_from_array(np.diag([1.0, 1.0, direction_sign]))) + return mask + + +def _point_rmse(first: pv.DataSet, second: pv.DataSet) -> float: + """Return the point-to-point RMSE between two meshes of equal point count.""" + assert first.n_points == second.n_points + delta = np.asarray(first.points, dtype=np.float64) - np.asarray( + second.points, dtype=np.float64 + ) + return float(np.sqrt(np.mean(np.sum(delta**2, axis=1)))) + + +def _min_scaled_jacobian(mesh: pv.UnstructuredGrid) -> float: + """Return the worst scaled Jacobian over *mesh*'s cells.""" + quality = mesh.cell_quality(["scaled_jacobian"]).cell_data["scaled_jacobian"] + return float(np.min(quality)) + + +@pytest.mark.requires_gpu +class TestS1TrimMatchesFrozenBase: + """The GPU relaxation must land where the pre-S1 CPU relaxation did.""" + + @pytest.mark.parametrize( + ("spacing", "direction_sign"), + [((1.0, 1.0, 1.0), 1.0), ((1.0, 1.5, 2.0), -1.0)], + ) + def test_s1_2_trim_matches_frozen_base( + self, + contour_tools: ProcessSurfaceMesh, + volume_tools: ProcessVolumetricMesh, + spacing: tuple[float, float, float], + direction_sign: float, + ) -> None: + """Default arguments, both direction handednesses.""" + mask = _ball_mask(spacing, direction_sign) + surface = contour_tools.extract_label_surfaces(mask)[1] + mesh = volume_tools.extract_tetrahedra(mask) + + expected, _ = _frozen_base_trim(mesh, surface) + relaxed = volume_tools.trim_tetrahedra_to_surface(mesh, surface) + + assert relaxed.n_cells == expected.n_cells + assert np.array_equal(relaxed.cells, expected.cells) + assert _point_rmse(relaxed, expected) < 0.1 + assert _min_scaled_jacobian(relaxed) >= 0.1 + + def test_s1_2_damping_loop_iterates_more_than_once( + self, contour_tools: ProcessSurfaceMesh, volume_tools: ProcessVolumetricMesh + ) -> None: + """A tight bound and a long step force the back-off to repeat.""" + mask = _ball_mask((1.0, 1.0, 1.0), 1.0) + surface = contour_tools.extract_label_surfaces(mask)[1] + mesh = volume_tools.extract_tetrahedra(mask) + + expected, passes = _frozen_base_trim( + mesh, surface, relaxation=0.9, min_scaled_jacobian=0.4 + ) + relaxed = volume_tools.trim_tetrahedra_to_surface( + mesh, surface, relaxation=0.9, min_scaled_jacobian=0.4 + ) + + assert passes > 2, "the frozen base must damp more than once" + assert relaxed.n_cells == expected.n_cells + assert _point_rmse(relaxed, expected) < 0.1 + assert _min_scaled_jacobian(relaxed) >= 0.4 + + def test_s1_2_zero_iterations_leave_points_alone( + self, contour_tools: ProcessSurfaceMesh, volume_tools: ProcessVolumetricMesh + ) -> None: + """No sweep means only the crinkle clip is applied.""" + mask = _ball_mask((1.0, 1.0, 1.0), 1.0) + surface = contour_tools.extract_label_surfaces(mask)[1] + mesh = volume_tools.extract_tetrahedra(mask) + + expected, _ = _frozen_base_trim(mesh, surface, iterations=0) + relaxed = volume_tools.trim_tetrahedra_to_surface(mesh, surface, iterations=0) + + assert np.array_equal(np.asarray(relaxed.points), np.asarray(expected.points)) + + def test_s1_2_scaled_jacobian_matches_vtk(self) -> None: + """The GPU quality equals VTK's on random, flat and inverted tetrahedra.""" + import torch + + rng = np.random.default_rng(0) + points = rng.normal(size=(400, 3)) * 3.0 + connectivity = rng.integers(0, 400, size=(2000, 4)) + connectivity = connectivity[ + np.all(np.diff(np.sort(connectivity, axis=1), axis=1) > 0, axis=1) + ] + points[connectivity[0, 3]] = ( + points[connectivity[0, 0]] + + points[connectivity[0, 1]] + - points[connectivity[0, 2]] + ) + cells = np.hstack([np.full((len(connectivity), 1), 4), connectivity]).ravel() + grid = pv.UnstructuredGrid( + cells, np.full(len(connectivity), pv.CellType.TETRA, np.uint8), points + ) + expected = np.asarray( + grid.cell_quality(["scaled_jacobian"]).cell_data["scaled_jacobian"] + ) + + actual = ( + ProcessMeshKernels() + .scaled_jacobian( + torch.as_tensor(points, device="cuda:0"), + torch.as_tensor(connectivity, device="cuda:0"), + ) + .cpu() + ) + + assert np.max(np.abs(actual.numpy() - expected)) < 1e-9 + + def test_s1_2_closest_points_match_vtk( + self, contour_tools: ProcessSurfaceMesh + ) -> None: + """Warp's closest surface point equals the VTK locator's.""" + kernels = ProcessMeshKernels() + + surface = contour_tools.extract_label_surfaces( + _ball_mask((1.0, 1.0, 1.0), 1.0) + )[1] + rng = np.random.default_rng(1) + queries = rng.uniform(-2.0, 28.0, size=(500, 3)) + queries = queries + np.asarray(surface.center) - 13.0 + + _, expected = cast( + "tuple[np.ndarray, np.ndarray]", + surface.find_closest_cell(queries, return_closest_point=True), + ) + actual = kernels.closest_points_on_surface( + kernels.build_surface_mesh(surface), queries + ) + + gap = np.linalg.norm(actual - queries, axis=1) + assert np.max(np.abs(gap - np.linalg.norm(expected - queries, axis=1))) < 1e-4 + assert np.max(np.abs(actual - expected)) < 5e-3 + + +def _itk_signed_distance( + labels: np.ndarray, spacing: Sequence[float], label_id: int, sigma_mm: float +) -> np.ndarray: + """Run the pre-S2 ITK signed Maurer map and recursive Gaussian blur.""" + inside = labels != 0 if label_id == 0 else labels == label_id + mask = itk.GetImageFromArray(np.ascontiguousarray(inside.astype(np.uint8))) + mask.SetSpacing([float(value) for value in spacing[::-1]]) + 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 + + +def _itk_resample_labelmap_isotropic( + labelmap_image: itk.Image, + isotropic_spacing_mm: float, + sigma_mm: Optional[np.ndarray] = None, +) -> itk.Image: + """Run ITK's label-vote resample, which vote_resample_labelmap reproduces.""" + 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, + ) + + +class _ItkKernels(ProcessMeshKernels): + """ProcessMeshKernels with the pre-S2 ITK distance maps.""" + + def signed_distance_mm( + self, + labels: torch.Tensor, + spacing: Sequence[float], + label_id: int, + sigma_mm: float, + ) -> torch.Tensor: + return torch.as_tensor( + _itk_signed_distance(labels.cpu().numpy(), spacing, label_id, sigma_mm), + device=labels.device, + ) + + +def _itk_surface_tools() -> ProcessSurfaceMesh: + """Return surface tools running on the pre-S2 ITK distance maps.""" + tools = ProcessSurfaceMesh() + tools._kernels = _ItkKernels() + return tools + + +def _itk_volume_tools() -> ProcessVolumetricMesh: + """Return volume tools whose kernels run on ITK maps.""" + tools = ProcessVolumetricMesh() + tools._kernels = _ItkKernels() + return tools + + +def _blob_labelmap( + spacing: tuple[float, float, float], direction_sign: float +) -> itk.Image: + """Build three touching labels, plus a one-voxel label too small to survive.""" + zz, yy, xx = np.indices((24, 30, 34)).astype(np.float64) + arr = np.zeros(zz.shape, dtype=np.uint16) + arr[(xx - 11) ** 2 + (yy - 14) ** 2 + (zz - 12) ** 2 < 8.0**2] = 1 + arr[(xx - 22) ** 2 + (yy - 14) ** 2 + (zz - 12) ** 2 < 7.0**2] = 2 + arr[(xx - 16) ** 2 + (yy - 14) ** 2 + (zz - 12) ** 2 < 3.5**2] = 5 + arr[3, 3, 3] = 9 + labelmap = itk.GetImageFromArray(arr) + labelmap.SetSpacing(list(spacing)) + labelmap.SetOrigin([5.0, -3.0, 2.0]) + labelmap.SetDirection(itk.matrix_from_array(np.diag([1.0, 1.0, direction_sign]))) + return labelmap + + +def _irregular_mask( + spacing: tuple[float, float, float], direction_sign: float +) -> itk.Image: + """Build a lumpy blob with no mirror symmetry for a vote to tie across.""" + noise = ndimage.gaussian_filter( + np.random.default_rng(5).normal(size=(30, 34, 38)), 3.0 + ) + mask = itk.GetImageFromArray((noise > 0.04).astype(np.uint8)) + mask.SetSpacing(list(spacing)) + mask.SetDirection(itk.matrix_from_array(np.diag([1.0, 1.0, direction_sign]))) + return mask + + +def _surface_rmse(first: pv.PolyData, second: pv.PolyData) -> float: + """Return the RMSE of each surface's points to the other, both pooled.""" + squared = [] + for source, target in ((first, second), (second, first)): + points = np.asarray(source.points, dtype=np.float64) + _, closest = cast( + "tuple[np.ndarray, np.ndarray]", + target.find_closest_cell(points, return_closest_point=True), + ) + squared.append(np.sum((points - closest) ** 2, axis=1)) + return float(np.sqrt(np.mean(np.concatenate(squared)))) + + +_S2_GEOMETRIES = [ + pytest.param((1.0, 1.0, 1.0), 1.0, id="isotropic-right-handed"), + pytest.param((0.8, 1.0, 2.0), -1.0, id="anisotropic-left-handed"), +] + + +class TestS2GpuMatchesItk: + """The GPU distance maps must do what the ITK calls did.""" + + @pytest.mark.requires_gpu + @pytest.mark.parametrize("label_id", [1, 2, 5, 0]) + @pytest.mark.parametrize("sigma_mm", [0.0, 0.8]) + def test_s2_2_signed_distance_matches_itk( + self, label_id: int, sigma_mm: float + ) -> None: + """Same sign, same metric, same blur kernel.""" + labelmap = _blob_labelmap((0.8, 1.0, 2.0), -1.0) + fine = _itk_resample_labelmap_isotropic(labelmap, 0.8) + labels = itk.GetArrayFromImage(fine) + spacing = [0.8, 0.8, 0.8] + + expected = _itk_signed_distance(labels, spacing, label_id, sigma_mm) + actual = ( + ProcessMeshKernels() + .signed_distance_mm( + torch.as_tensor(labels.astype(np.int32), device="cuda:0"), + spacing, + label_id, + sigma_mm, + ) + .cpu() + .numpy() + ) + + assert np.max(np.abs(actual - expected)) < 1e-4 + + @pytest.mark.requires_gpu + def test_s2_2_squared_distance_matches_scipy(self) -> None: + """The line kernel is the exact anisotropic Euclidean transform.""" + + rng = np.random.default_rng(3) + features = rng.random((12, 17, 23)) > 0.97 + spacing = (0.7, 1.1, 2.3) + + actual = ( + ProcessMeshKernels() + .squared_distance_to_features( + torch.as_tensor(features, device="cuda:0"), spacing + ) + .cpu() + .numpy() + ) + expected = ( + np.asarray(ndimage.distance_transform_edt(~features, sampling=spacing)) ** 2 + ) + + assert np.allclose(actual, expected, rtol=1e-5, atol=1e-5) + empty = ProcessMeshKernels().squared_distance_to_features( + torch.zeros((4, 5, 6), dtype=torch.bool, device="cuda:0"), spacing + ) + assert bool(torch.all(empty > ProcessMeshKernels.NO_FEATURE_LIMIT)) + + def test_crop_to_nonzero_keeps_voxel_positions(self) -> None: + """The crop spans the nonzero bounding box and keeps physical positions.""" + rng = np.random.default_rng(4) + occupied = np.zeros((9, 11, 13), dtype=np.uint8) + occupied[2:7, 3:10, 4:5] = rng.random((5, 7, 1)) > 0.3 + occupied[3, 3, 12] = 1 + image = itk.GetImageFromArray(occupied) + image.SetSpacing([0.5, 1.0, 2.0]) + image.SetOrigin([3.0, -1.0, 4.0]) + image.SetDirection(itk.matrix_from_array(np.diag([1.0, -1.0, 1.0]))) + + cropped = ProcessImages().crop_to_nonzero(image, pad_voxels=2) + + extents = np.nonzero(occupied) + start = [int(axis.min()) for axis in extents] + stop = [int(axis.max()) + 1 for axis in extents] + assert itk.GetArrayFromImage(cropped).shape == tuple( + b - a + 4 for a, b in zip(start, stop) + ) + first = image.TransformIndexToPhysicalPoint([start[2], start[1], start[0]]) + assert np.allclose(cropped.TransformIndexToPhysicalPoint([2, 2, 2]), first) + + @pytest.mark.requires_gpu + @pytest.mark.parametrize(("spacing", "direction_sign"), _S2_GEOMETRIES) + def test_s2_2_label_surfaces_match_itk_pipeline( + self, + contour_tools: ProcessSurfaceMesh, + spacing: tuple[float, float, float], + direction_sign: float, + ) -> None: + """Three labels give the same surfaces; the one-voxel label is dropped.""" + labelmap = _blob_labelmap(spacing, direction_sign) + + expected = _itk_surface_tools().extract_label_surfaces(labelmap) + actual = contour_tools.extract_label_surfaces(labelmap) + + assert sorted(actual) == sorted(expected) == [1, 2, 5] + for label_id, surface in actual.items(): + assert _surface_rmse(surface, expected[label_id]) < 0.1 + assert surface.is_all_triangles + assert surface.volume > 0.0 + + @pytest.mark.requires_gpu + def test_s2_2_label_surfaces_with_options_match_itk_pipeline( + self, contour_tools: ProcessSurfaceMesh + ) -> None: + """Non-default grid, blur and smoothing, as the other callers pass them.""" + labelmap = _blob_labelmap((0.8, 1.0, 2.0), 1.0) + + expected = _itk_surface_tools().extract_label_surfaces( + labelmap, + isotropic_spacing_mm=1.0, + distance_sigma_mm=1.2, + smoothing_iterations=5, + ) + actual = contour_tools.extract_label_surfaces( + labelmap, + isotropic_spacing_mm=1.0, + distance_sigma_mm=1.2, + smoothing_iterations=5, + ) + + assert sorted(actual) == sorted(expected) + for label_id, surface in actual.items(): + assert _surface_rmse(surface, expected[label_id]) < 0.1 + + @pytest.mark.requires_gpu + def test_s2_2_empty_labelmap_has_no_surface( + self, contour_tools: ProcessSurfaceMesh + ) -> None: + """A labelmap with no label returns an empty mapping.""" + labelmap = itk.GetImageFromArray(np.zeros((5, 6, 7), dtype=np.uint16)) + + assert contour_tools.extract_label_surfaces(labelmap) == {} + + @pytest.mark.requires_gpu + @pytest.mark.parametrize(("spacing", "direction_sign"), _S2_GEOMETRIES) + def test_s2_2_tetrahedra_match_itk_pipeline( + self, + volume_tools: ProcessVolumetricMesh, + spacing: tuple[float, float, float], + direction_sign: float, + ) -> None: + """A coarser element size votes to the same tetrahedral mesh.""" + mask = _irregular_mask(spacing, direction_sign) + + expected = _itk_volume_tools().extract_tetrahedra(mask, element_size_mm=1.5) + actual = volume_tools.extract_tetrahedra(mask, element_size_mm=1.5) + + assert abs(actual.n_cells - expected.n_cells) <= 0.01 * expected.n_cells + assert set(actual.celltypes) == {int(pv.CellType.TETRA)} + assert ( + _surface_rmse( + actual.extract_surface(algorithm="dataset_surface"), + expected.extract_surface(algorithm="dataset_surface"), + ) + < 0.1 + ) + + @pytest.mark.requires_gpu + def test_s2_2_tetrahedra_too_coarse_are_empty( + self, volume_tools: ProcessVolumetricMesh + ) -> None: + """An element larger than the structure drops it, as before.""" + arr = np.zeros((26, 26, 26), dtype=np.uint8) + for step in range(10): + arr[step + 5, step + 5, step + 5] = 1 + thin_line = itk.GetImageFromArray(arr) + + expected = _itk_volume_tools().extract_tetrahedra( + thin_line, element_size_mm=8.0 + ) + actual = volume_tools.extract_tetrahedra(thin_line, element_size_mm=8.0) + + assert actual.n_cells == expected.n_cells == 0 + + @pytest.mark.requires_gpu + def test_s2_2_closest_point_distance_matches_implicit_distance( + self, contour_tools: ProcessSurfaceMesh, volume_tools: ProcessVolumetricMesh + ) -> None: + """The mean surface-to-mesh distance equals VTK's implicit distance.""" + kernels = ProcessMeshKernels() + + mask = _ball_mask((1.0, 1.0, 1.0), 1.0) + surface = contour_tools.extract_label_surfaces(mask)[1] + mesh = volume_tools.extract_tetrahedra(mask, element_size_mm=1.5) + boundary = mesh.extract_surface(algorithm="dataset_surface") + + expected = float( + np.abs( + surface.copy().compute_implicit_distance(boundary)["implicit_distance"] + ).mean() + ) + points = np.asarray(surface.points, dtype=np.float64) + closest = kernels.closest_points_on_surface( + kernels.build_surface_mesh(boundary), points + ) + actual = float(np.linalg.norm(closest - points, axis=1).mean()) + + assert actual == pytest.approx(expected, abs=1e-4) + + +@pytest.mark.requires_gpu +class TestS2UnsmoothedMatchesItk: + """Without a blur, an edge voxel's side of zero decides the contour.""" + + @pytest.mark.parametrize("label_id", [1, 2, 0]) + def test_s2_2_edge_voxels_sit_on_the_same_side_of_zero_as_itk( + self, label_id: int + ) -> None: + """Exact zeros and rounding-error depths fall on the same voxels.""" + labels = itk.GetArrayFromImage(_blob_labelmap((0.4, 0.4, 0.4), 1.0)) + spacing = [0.4, 0.4, 0.4] + + expected = _itk_signed_distance(labels, spacing, label_id, 0.0) + actual = ( + ProcessMeshKernels() + .signed_distance_mm( + torch.as_tensor(labels.astype(np.int32), device="cuda:0"), + spacing, + label_id, + 0.0, + ) + .cpu() + .numpy() + ) + + assert np.array_equal(expected == 0.0, actual == 0.0) + assert np.array_equal(expected < 0.0, actual < 0.0) + assert np.max(np.abs(actual - expected)) < 1e-5 + + def test_s2_2_unsmoothed_label_surfaces_match_itk_pipeline( + self, contour_tools: ProcessSurfaceMesh + ) -> None: + """No blur and no smoothing leaves the contour at the labels' own edges.""" + labelmap = _blob_labelmap((0.4, 0.4, 1.2), 1.0) + + expected = _itk_surface_tools().extract_label_surfaces( + labelmap, distance_sigma_mm=0.0, smoothing_iterations=0 + ) + actual = contour_tools.extract_label_surfaces( + labelmap, distance_sigma_mm=0.0, smoothing_iterations=0 + ) + + assert sorted(actual) == sorted(expected) == [1, 2, 5] + for label_id, surface in actual.items(): + assert _surface_rmse(surface, expected[label_id]) < 0.01 + + @pytest.mark.parametrize(("spacing", "direction_sign"), _S2_GEOMETRIES) + def test_s2_2_tetrahedra_keep_the_itk_vote_voxels( + self, + volume_tools: ProcessVolumetricMesh, + spacing: tuple[float, float, float], + direction_sign: float, + ) -> None: + """Tetrahedra fill exactly the voxels ITK's vote keeps, ties included.""" + mask = _irregular_mask(spacing, direction_sign) + + mesh = volume_tools.extract_tetrahedra(mask, element_size_mm=1.5) + + cropped = ProcessImages().crop_to_nonzero(mask) + voted = _itk_resample_labelmap_isotropic( + cropped, 1.5, np.maximum(np.asarray(spacing), 1.5) + ) + assert mesh.n_cells == 6 * int(np.count_nonzero(itk.GetArrayFromImage(voted))) + + +@pytest.mark.requires_gpu +@pytest.mark.parametrize(("spacing", "direction_sign"), _S2_GEOMETRIES) +@pytest.mark.parametrize("isotropic_spacing_mm", [0.6, 1.5]) +def test_s3_1_vote_resample_matches_itk_but_for_exact_ties( + spacing: tuple[float, float, float], + direction_sign: float, + isotropic_spacing_mm: float, +) -> None: + """The GPU vote keeps ITK's grid and its labels, bar a few exact ties.""" + labelmap = _blob_labelmap(spacing, direction_sign) + + expected = _itk_resample_labelmap_isotropic(labelmap, isotropic_spacing_mm) + actual = ProcessMeshKernels().vote_resample_labelmap(labelmap, isotropic_spacing_mm) + + assert itk.size(actual) == itk.size(expected) + assert tuple(actual.GetSpacing()) == tuple(expected.GetSpacing()) + assert tuple(actual.GetOrigin()) == tuple(expected.GetOrigin()) + assert np.array_equal( + itk.array_from_matrix(actual.GetDirection()), + itk.array_from_matrix(expected.GetDirection()), + ) + expected_arr = itk.GetArrayViewFromImage(expected) + actual_arr = itk.GetArrayViewFromImage(actual) + assert actual_arr.dtype == expected_arr.dtype + assert np.mean(actual_arr != expected_arr) < 1e-3 + + +@pytest.mark.requires_gpu +def test_s3_1_vote_resample_with_a_wide_kernel_matches_itk() -> None: + """A per-axis kernel wider than the voxels, as the tetrahedra use.""" + mask = _irregular_mask((0.8, 1.0, 2.0), -1.0) + sigma = np.maximum(np.asarray(mask.GetSpacing()), 1.5) + + expected = _itk_resample_labelmap_isotropic(mask, 1.5, sigma) + actual = ProcessMeshKernels().vote_resample_labelmap(mask, 1.5, sigma) + + expected_arr = itk.GetArrayViewFromImage(expected) + actual_arr = itk.GetArrayViewFromImage(actual) + assert actual_arr.shape == expected_arr.shape + assert np.mean(actual_arr != expected_arr) < 1e-3 + + +@pytest.mark.requires_gpu +@pytest.mark.parametrize( + ("spacing", "direction_sign"), + [((1.0, 1.0, 1.0), 1.0), ((1.0, 1.5, 2.0), -1.0)], +) +def test_s4_1_gpu_crinkle_matches_vtk_clip( + contour_tools: ProcessSurfaceMesh, + volume_tools: ProcessVolumetricMesh, + spacing: tuple[float, float, float], + direction_sign: float, +) -> None: + """The GPU cell selection keeps the cells, arrays and scalars VTK's does.""" + mask = _ball_mask(spacing, direction_sign) + surface = contour_tools.extract_label_surfaces(mask)[1] + mesh = volume_tools.extract_tetrahedra(mask, anatomy_names=["myocardium", "heart"]) + + expected = mesh.clip_surface(surface, invert=True, crinkle=True) + relaxed = volume_tools.trim_tetrahedra_to_surface(mesh, surface, iterations=0) + + assert 0 < relaxed.n_cells < mesh.n_cells + assert np.array_equal(relaxed.cells, expected.cells) + assert np.array_equal(relaxed.points, expected.points) + assert relaxed.cell_data.keys() == expected.cell_data.keys() + assert np.array_equal(relaxed.cell_data["cell_ids"], expected.cell_data["cell_ids"]) + assert relaxed.point_data.keys() == expected.point_data.keys() + assert relaxed.active_scalars_info.name == expected.active_scalars_info.name + + +@pytest.mark.requires_gpu +def test_s4_1_gpu_inside_test_matches_vtk_distance_sign( + contour_tools: ProcessSurfaceMesh, +) -> None: + """Points off the surface are inside exactly where VTK's distance is negative.""" + kernels = ProcessMeshKernels() + + surface = contour_tools.extract_label_surfaces(_ball_mask((1.0, 1.0, 1.0), 1.0))[1] + rng = np.random.default_rng(2) + queries = rng.uniform(-3.0, 29.0, size=(4000, 3)) + np.asarray(surface.center) - 13 + distance = np.asarray( + pv.PolyData(queries).compute_implicit_distance(surface)["implicit_distance"] + ) + away = np.abs(distance) > 1e-3 + + inside = kernels.points_inside_surface(kernels.build_surface_mesh(surface), queries) + + assert away.sum() > 3000 + assert 0 < inside.sum() < len(queries) + assert np.array_equal(inside[away], distance[away] < 0.0) + + +@pytest.mark.requires_gpu +def test_s4_1_trim_keeps_the_empty_result_when_the_surface_misses( + contour_tools: ProcessSurfaceMesh, + volume_tools: ProcessVolumetricMesh, +) -> None: + """A surface that holds no point of the mesh trims every cell away.""" + mask = _ball_mask((1.0, 1.0, 1.0), 1.0) + surface = contour_tools.extract_label_surfaces(mask)[1] + mesh = volume_tools.extract_tetrahedra(mask) + far = surface.translate((500.0, 0.0, 0.0), inplace=False) + + relaxed = volume_tools.trim_tetrahedra_to_surface(mesh, far) + + assert relaxed.n_cells == 0 + + +@pytest.mark.requires_gpu +def test_s5_1_trim_keeps_vtk_cells_on_a_thin_tube( + volume_tools: ProcessVolumetricMesh, +) -> None: + """A thin tube, far from the origin, is trimmed to the cells VTK keeps.""" + kernels = ProcessMeshKernels() + + angle = np.linspace(0.0, 2.5, 40) + spine = np.column_stack([3.0 * np.cos(angle), 3.0 * np.sin(angle), 0.4 * angle]) + tube = ( + pv.Spline(spine + (-20.0, 35.0, 1800.0), 120) + .tube(radius=0.15, n_sides=7, capping=True) + .triangulate() + .clean() + ) + assert tube.n_open_edges == 0 + grid = pv.ImageData( + dimensions=(30, 30, 20), + spacing=(0.17, 0.17, 0.11), + origin=np.asarray(tube.bounds[::2]) - 0.2, + ).triangulate() + + expected = grid.clip_surface(tube, invert=True, crinkle=True) + function = vtk.vtkImplicitPolyDataDistance() + function.SetInput(tube) + values = np.array([function.EvaluateFunction(point) for point in grid.points]) + inside = kernels.points_inside_surface( + kernels.build_surface_mesh(tube), grid.points + ) + relaxed = volume_tools.trim_tetrahedra_to_surface(grid, tube, iterations=0) + + assert 0 < expected.n_cells < grid.n_cells + assert np.array_equal(inside, values < 0.0) + assert np.array_equal(relaxed.cells, expected.cells) + assert np.array_equal(relaxed.cell_data["cell_ids"], expected.cell_data["cell_ids"]) + + +@pytest.mark.requires_gpu +def test_tutorial_4_frame_writes_every_output(tmp_path: Path) -> None: + """One Tutorial 4 (Duke Heart) frame writes its surfaces and meshes.""" + tutorials = Path(__file__).resolve().parents[1] / "tutorials" + if str(tutorials) not in sys.path: + sys.path.insert(0, str(tutorials)) + tutorial = importlib.import_module("tutorial_04_duke_heart_labelmap_to_vtk") + labelmap_file = tmp_path / "pm0001_g000_labelmap.nii.gz" + itk.imwrite(_blob_labelmap((1.0, 1.0, 1.0), 1.0), str(labelmap_file)) + + displacements = tutorial.convert_frame( + labelmap_file, tmp_path, full=True, log_level=logging.WARNING + ) + + written = sorted(path.name for path in tmp_path.glob("pm0001_g000_*.vt?")) + assert "pm0001_g000_heart_minus_interior_chambers.vtp" in written + assert "pm0001_g000_heart_minus_interior_chambers.vtu" in written + assert "pm0001_g000_surfaces.vtp" in written + assert len(displacements) == len([n for n in written if n.endswith(".vtu")]) + assert all(0.0 <= value < 1.0 for value in displacements) if __name__ == "__main__": diff --git a/tests/test_convert_vtk_to_usd.py b/tests/test_convert_vtk_to_usd.py index 19da50e7..dce837e3 100644 --- a/tests/test_convert_vtk_to_usd.py +++ b/tests/test_convert_vtk_to_usd.py @@ -15,19 +15,20 @@ from pxr import UsdGeom from monai_physio import ConvertVTKToUSD -from monai_physio.process_contours import ProcessContours +from monai_physio.process_surface_mesh import ProcessSurfaceMesh def _make_poly(label_ids: list[int] | None = None) -> pv.PolyData: """Return a small synthetic PolyData (9 quad cells, 16 points). Args: - label_ids: If given, attached as ``cell_data['boundary_labels']`` (int32). + label_ids: If given, attached as ``cell_data['SegmentationLabelIds']`` + (int32). Must have exactly 9 elements to match the plane's cell count. """ mesh = pv.Plane(i_resolution=3, j_resolution=3) if label_ids is not None: - mesh.cell_data["boundary_labels"] = np.array(label_ids, dtype=np.int32) + mesh.cell_data["SegmentationLabelIds"] = np.array(label_ids, dtype=np.int32) return mesh @@ -38,7 +39,7 @@ class TestConvertVTKToUSD: @pytest.fixture(scope="class") def contour_meshes( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_directories: dict[str, Path], ) -> list[Any]: @@ -58,7 +59,9 @@ def contour_meshes( meshes = [] for i, result in enumerate(test_labelmaps): heart_mask = result["heart"] - contours = contour_tools.extract_contours(heart_mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(heart_mask) + ) meshes.append(contours) # Save contours @@ -168,7 +171,7 @@ def test_convert_multiple_time_points( def test_convert_with_deformation( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_directories: dict[str, Path], ) -> None: @@ -179,7 +182,9 @@ def test_convert_with_deformation( # Extract contours heart_mask = test_labelmaps[0]["heart"] - contours = contour_tools.extract_contours(heart_mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(heart_mask) + ) # Add deformation magnitude (simulate with random values) import numpy as np @@ -374,7 +379,7 @@ def test_time_varying_topology( def test_batch_conversion( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_directories: dict[str, Path], ) -> None: @@ -394,7 +399,9 @@ def test_batch_conversion( import numpy as np if np.sum(mask_arr > 0) > 100: - contours = contour_tools.extract_contours(mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(mask) + ) meshes_dict[group] = contours if len(meshes_dict) >= 2: @@ -525,7 +532,7 @@ def test_constant_topology_leaves_faces_untimed(self, tmp_path: Path) -> None: assert mesh.GetPointsAttr().GetTimeSamples() == [0.0, 1.0] def test_mask_ids_split_on_segmentation_label_ids(self, tmp_path: Path) -> None: - """A merged surface file splits on the array save_combined_surfaces writes.""" + """A merged surface file splits on the array combine_surfaces writes.""" mesh = _make_poly() mesh.cell_data["SegmentationLabelIds"] = np.array( [141] * 4 + [142] * 5, dtype=np.int32 @@ -648,9 +655,9 @@ def test_mask_ids_missing_label_filters_time_codes(self, tmp_path: Path) -> None f"atrium should only appear at t=0 and t=2, got {a_samples}" ) - def test_mask_ids_missing_boundary_labels_falls_back(self, tmp_path: Path) -> None: - """Mesh without boundary_labels array falls back to a 'default' prim.""" - mesh = _make_poly() # no boundary_labels + def test_mask_ids_missing_label_array_falls_back(self, tmp_path: Path) -> None: + """Mesh without a SegmentationLabelIds array falls back to a 'default' prim.""" + mesh = _make_poly() # no SegmentationLabelIds converter = ConvertVTKToUSD( data_basename="FB", input_polydata=[mesh], diff --git a/tests/test_evaluate_movement_cohorts.py b/tests/test_evaluate_movement_cohorts.py index fb26c0a1..d422b621 100644 --- a/tests/test_evaluate_movement_cohorts.py +++ b/tests/test_evaluate_movement_cohorts.py @@ -25,8 +25,8 @@ def test_lung_scores_the_five_lobes() -> None: """The lobe ids resolve to the segmenter's own names, not restated ones.""" names = EvaluateMovementLung().label_names() - assert list(names) == [10, 11, 12, 13, 14] - assert names[10] == "lung_upper_lobe_left" + assert list(names) == [28, 29, 30, 31, 32] + assert names[28] == "lung_upper_lobe_left" assert all("lobe" in name for name in names.values()) diff --git a/tests/test_physics_informed_motion.py b/tests/test_physics_informed_motion.py index 283df2fd..fa161322 100644 --- a/tests/test_physics_informed_motion.py +++ b/tests/test_physics_informed_motion.py @@ -72,7 +72,7 @@ def _oriented(points: np.ndarray, tets: np.ndarray) -> np.ndarray: """Return *tets* with every element positively oriented. A real template arrives pre-oriented from - ``ProcessContours.trim_tetrahedra_to_surface``; a mesh built ad hoc for a test + ``ProcessVolumetricMesh.trim_tetrahedra_to_surface``; a mesh built ad hoc for a test does not, so this stands in for that guarantee. """ corners = points[tets] diff --git a/tests/test_process_physicsnemo.py b/tests/test_process_physicsnemo.py index 2fd400e1..2b4cc24d 100644 --- a/tests/test_process_physicsnemo.py +++ b/tests/test_process_physicsnemo.py @@ -148,6 +148,37 @@ def test_parse_manifest_round_trips_the_new_schema(tmp_path: Path) -> None: ] +def test_write_manifest_stores_displacements_parse_manifest_reads( + tmp_path: Path, +) -> None: + reference = _sphere() + reference_file = tmp_path / "reference.vtp" + reference.save(str(reference_file)) + phase_files = [] + for index, offset in enumerate((0.0, 2.0)): + phase = reference.copy() + phase.points = np.asarray(phase.points) + offset + phase_files.append(tmp_path / f"phase_{index}.vtp") + phase.save(str(phase_files[-1])) + + manifest_path = pnt.ProcessPhysicsNemo.write_manifest( + "subject_01", + reference_file, + tmp_path / "coefficients.json", + phase_files, + [0.0, 0.5], + tmp_path / "manifests", + ) + manifest = pnt.ProcessPhysicsNemo.parse_manifest(manifest_path) + + assert manifest.subject_id == "subject_01" + assert [phase.stage for phase in manifest.phases] == [0.0, 0.5] + targets = pnt.ProcessPhysicsNemo.load_target_array( + manifest.phases[1].mesh, manifest.target_array + ) + assert np.allclose(targets, 2.0) + + def test_parse_manifest_requires_the_target_array(tmp_path: Path) -> None: n_points = _sphere().n_points manifest_path = _write_subject(tmp_path, [_targets(n_points, 3, 0.0)] * 2) diff --git a/tests/test_process_contours.py b/tests/test_process_surface_mesh.py similarity index 83% rename from tests/test_process_contours.py rename to tests/test_process_surface_mesh.py index a1005274..60fc7783 100644 --- a/tests/test_process_contours.py +++ b/tests/test_process_surface_mesh.py @@ -13,21 +13,23 @@ import pytest import pyvista as pv -from monai_physio.process_contours import ProcessContours +from monai_physio.process_surface_mesh import ProcessSurfaceMesh @pytest.mark.slow -class TestProcessContours: - """Test suite for ProcessContours functionality.""" +class TestProcessSurfaceMesh: + """Test suite for ProcessSurfaceMesh functionality.""" - def test_contour_tools_initialization(self, contour_tools: ProcessContours) -> None: - """Test that ProcessContours initializes correctly.""" - assert contour_tools is not None, "ProcessContours not initialized" - print("\nProcessContours initialized successfully") + def test_contour_tools_initialization( + self, contour_tools: ProcessSurfaceMesh + ) -> None: + """Test that ProcessSurfaceMesh initializes correctly.""" + assert contour_tools is not None, "ProcessSurfaceMesh not initialized" + print("\nProcessSurfaceMesh initialized successfully") def test_extract_contours_from_heart_mask( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_directories: dict[str, Path], ) -> None: @@ -42,7 +44,9 @@ def test_extract_contours_from_heart_mask( print("\nExtracting contours from heart mask...") print(f" Heart mask size: {itk.size(heart_mask)}") - contours = contour_tools.extract_contours(heart_mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(heart_mask) + ) # Verify contours assert contours is not None, "Contours not extracted" @@ -60,7 +64,7 @@ def test_extract_contours_from_heart_mask( def test_extract_contours_from_lung_mask( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_directories: dict[str, Path], ) -> None: @@ -73,7 +77,9 @@ def test_extract_contours_from_lung_mask( lung_mask = test_labelmaps[0]["lung"] print("\nExtracting contours from lung mask...") - contours = contour_tools.extract_contours(lung_mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(lung_mask) + ) assert contours is not None, "Lung contours not extracted" assert contours.n_points > 0, "Lung contours should have points" @@ -89,7 +95,7 @@ def test_extract_contours_from_lung_mask( def test_extract_contours_multiple_anatomy( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_directories: dict[str, Path], ) -> None: @@ -109,7 +115,9 @@ def test_extract_contours_multiple_anatomy( mask_arr = itk.array_from_image(mask) if np.sum(mask_arr > 0) > 100: # Only extract if sufficient voxels print(f"\nExtracting contours from {group} mask...") - contours = contour_tools.extract_contours(mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(mask) + ) contours_dict[group] = contours print( @@ -127,7 +135,7 @@ def test_extract_contours_multiple_anatomy( def test_create_mask_from_mesh( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_images: list[Any], test_directories: dict[str, Path], @@ -139,7 +147,9 @@ def test_create_mask_from_mesh( # Extract contours from heart mask heart_mask = test_labelmaps[0]["heart"] - contours = contour_tools.extract_contours(heart_mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(heart_mask) + ) # Create mask from the extracted mesh print("\nCreating mask from extracted heart contours...") @@ -168,7 +178,7 @@ def test_create_mask_from_mesh( def test_merge_meshes( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_directories: dict[str, Path], ) -> None: @@ -186,7 +196,9 @@ def test_merge_meshes( mask_arr = itk.array_from_image(mask) if np.sum(mask_arr > 0) > 100: - contours = contour_tools.extract_contours(mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(mask) + ) meshes.append(contours) if len(meshes) >= 2: @@ -218,7 +230,7 @@ def test_merge_meshes( def test_transform_contours_identity( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_directories: dict[str, Path], ) -> None: @@ -229,7 +241,9 @@ def test_transform_contours_identity( # Extract contours heart_mask = test_labelmaps[0]["heart"] - contours = contour_tools.extract_contours(heart_mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(heart_mask) + ) # Create identity transform identity_tfm = itk.AffineTransform[itk.D, 3].New() @@ -264,7 +278,7 @@ def test_transform_contours_identity( def test_transform_contours_with_deformation( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_directories: dict[str, Path], ) -> None: @@ -275,7 +289,9 @@ def test_transform_contours_with_deformation( # Extract contours heart_mask = test_labelmaps[0]["heart"] - contours = contour_tools.extract_contours(heart_mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(heart_mask) + ) # Create a translation transform (10mm in each direction) translation_tfm = itk.TranslationTransform[itk.D, 3].New() @@ -313,7 +329,7 @@ def test_transform_contours_with_deformation( def test_contours_from_both_time_points( self, - contour_tools: ProcessContours, + contour_tools: ProcessSurfaceMesh, test_labelmaps: list[dict[str, Any]], test_directories: dict[str, Path], ) -> None: @@ -326,7 +342,9 @@ def test_contours_from_both_time_points( for i, result in enumerate(test_labelmaps): heart_mask = result["heart"] - contours = contour_tools.extract_contours(heart_mask) + contours = contour_tools.combine_surfaces( + contour_tools.extract_label_surfaces(heart_mask) + ) print(f" Time point {i}: {contours.n_points} points") @@ -337,8 +355,8 @@ def test_contours_from_both_time_points( print(f"Extracted contours from {len(test_labelmaps)} time points") -class TestSaveCombinedSurfaces: - """Structure identity must survive merging into one file.""" +class TestCombineSurfaces: + """Structure identity must survive merging into one mesh.""" @staticmethod def _annotated_sphere( @@ -350,19 +368,14 @@ def _annotated_sphere( ) return surface - def test_per_cell_label_ids_from_single_label_surfaces( - self, tmp_path: Path - ) -> None: + def test_per_cell_label_ids_from_single_label_surfaces(self) -> None: """Each merged cell carries the label ID of the surface it came from.""" surfaces = { "atrium_left": self._annotated_sphere((0.0, 0.0, 0.0), 141), "ventricle_left": self._annotated_sphere((3.0, 0.0, 0.0), 142), } - output_file = tmp_path / "combined.vtp" - ProcessContours.save_combined_surfaces(surfaces, str(output_file)) - - merged = pv.read(str(output_file)) + merged = ProcessSurfaceMesh.combine_surfaces(surfaces) label_ids = merged.cell_data["SegmentationLabelIds"] assert set(np.unique(label_ids)) == {141, 142} for name, surface in surfaces.items(): @@ -374,19 +387,14 @@ def test_per_cell_label_ids_from_single_label_surfaces( for surface in surfaces.values(): assert "SegmentationLabelIds" not in surface.cell_data - def test_multi_label_surfaces_tagged_unknown(self, tmp_path: Path) -> None: + def test_multi_label_surfaces_tagged_unknown(self) -> None: """Per-group surfaces list several labels, so no cell can be attributed.""" group_surface = pv.Sphere(radius=1.0) group_surface.field_data["SegmentationLabelIds"] = np.array( [141, 142], dtype=np.int32 ) - output_file = tmp_path / "combined_group.vtp" - ProcessContours.save_combined_surfaces( - {"heart": group_surface}, str(output_file) - ) - - merged = pv.read(str(output_file)) + merged = ProcessSurfaceMesh.combine_surfaces({"heart": group_surface}) assert set(np.unique(merged.cell_data["SegmentationLabelIds"])) == {0} @@ -395,7 +403,7 @@ class TestRemeshCarriesCellLabels: @staticmethod def _two_label_sphere() -> pv.PolyData: - """A sphere whose cells carry two structure ids and their label pairs. + """A sphere whose cells carry two structure ids. Split by the sign of the cell centre's x, so both labels own a large, contiguous patch that uniform re-tiling cannot absorb. @@ -410,15 +418,12 @@ def _two_label_sphere() -> pv.PolyData: surface.cell_data["SegmentationLabelIds"] = np.where(left, 141, 142).astype( np.int32 ) - surface.cell_data["boundary_labels"] = np.column_stack( - (np.where(left, 141, 142), np.zeros(surface.n_cells)) - ).astype(np.int32) return surface - def test_remeshing_carries_both_cell_arrays( - self, contour_tools: ProcessContours + def test_remeshing_carries_the_cell_labels( + self, contour_tools: ProcessSurfaceMesh ) -> None: - """Anatomy splitting downstream reads these off the remeshed surface.""" + """Anatomy splitting downstream reads them off the remeshed surface.""" original = self._two_label_sphere() remeshed = contour_tools.remesh_and_smooth_surface( @@ -426,17 +431,11 @@ def test_remeshing_carries_both_cell_arrays( ) assert remeshed.n_cells < original.n_cells - for name in ("SegmentationLabelIds", "boundary_labels"): - assert name in remeshed.cell_data, f"{name} did not survive remeshing" - carried = np.asarray(remeshed.cell_data[name]) - assert len(carried) == remeshed.n_cells - # Nearest-cell transfer, so every value is one the source carried - # rather than an interpolation between two of them. - source = np.asarray(original.cell_data[name]) - assert set(np.unique(carried).tolist()) <= set(np.unique(source).tolist()) - - # Both structures survive: a transfer that collapsed onto one label - # would still satisfy the membership check above. + carried = np.asarray(remeshed.cell_data["SegmentationLabelIds"]) + assert len(carried) == remeshed.n_cells + + # Nearest-cell transfer, so the values are the source's own, and both + # structures survive it. assert set(np.unique(remeshed.cell_data["SegmentationLabelIds"])) == {141, 142} diff --git a/tests/test_process_usd_anatomy.py b/tests/test_process_usd_anatomy.py new file mode 100644 index 00000000..2e88e2e1 --- /dev/null +++ b/tests/test_process_usd_anatomy.py @@ -0,0 +1,46 @@ +"""Tests for ProcessUSDAnatomy's stage-free color lookups.""" + +import logging + +import numpy as np +import pytest +import pyvista as pv + +from monai_physio.process_usd_anatomy import ProcessUSDAnatomy + + +@pytest.fixture +def anatomy_tools() -> ProcessUSDAnatomy: + return ProcessUSDAnatomy(stage=None) + + +def test_select_anatomy_type_prefers_first_resolvable( + anatomy_tools: ProcessUSDAnatomy, +) -> None: + assert anatomy_tools.select_anatomy_type(["myocardium", "heart"]) == "myocardium" + assert anatomy_tools.select_anatomy_type(["unknown_xyz", "heart"]) == "heart" + + +def test_select_anatomy_type_matches_substring( + anatomy_tools: ProcessUSDAnatomy, +) -> None: + assert anatomy_tools.select_anatomy_type(["kidney_left"]) == "kidney_left" + + +def test_select_anatomy_type_falls_back_to_other( + anatomy_tools: ProcessUSDAnatomy, caplog: pytest.LogCaptureFixture +) -> None: + caplog.set_level(logging.WARNING) + assert anatomy_tools.select_anatomy_type(["unknown_xyz", "nothing_qq"]) == "other" + assert "unknown_xyz or nothing_qq" in caplog.text + + +def test_apply_anatomy_color_sets_field_and_cell_color( + anatomy_tools: ProcessUSDAnatomy, +) -> None: + mesh = pv.Sphere() + anatomy_tools.apply_anatomy_color(mesh, ["heart"]) + expected = np.array(anatomy_tools.get_anatomy_diffuse_color("heart")) + assert np.allclose(mesh.field_data["AnatomyColor"], expected) + assert mesh.cell_data["Color"].shape == (mesh.n_cells, 4) + assert mesh.cell_data["Color"].dtype == np.uint8 diff --git a/tests/test_tutorials.py b/tests/test_tutorials.py index 7a929fe0..ff572a54 100644 --- a/tests/test_tutorials.py +++ b/tests/test_tutorials.py @@ -40,15 +40,19 @@ import importlib.util import runpy import sys +from collections.abc import Iterator from pathlib import Path from typing import Any import numpy as np import pytest import pyvista as pv +import torch from parameters_base import ParametersBase +from monai_physio import register_images_greedy from monai_physio.process_tests import ProcessTests +from monai_physio.register_images_icon import RegisterImagesICON from .conftest import skip_or_fail_missing_data, tutorial_data_is_required @@ -56,6 +60,10 @@ # differences across OS / GPU / driver versions. _PX_TOL = 10.0 # per-pixel absolute error (0-255 range) _MAX_PX = 2000 # maximum number of pixels allowed above _PX_TOL +# Renderings of meshes from a GPU segmentation: nnU-Net's GPU inference is not +# bit-reproducible, so boundary voxels flip between runs and about 1% of an +# 800x600 screenshot moves along mesh edges. 2% still catches a real change. +_MAX_PX_GPU_SEGMENTATION = 9600 _TOT_TOL = float("inf") # use the pixel-count criterion only # Tolerances for the numeric metrics each tutorial reports. Loose enough to # survive the nondeterminism of GPU reductions and a driver change, tight @@ -63,6 +71,14 @@ # covers the metrics that sit near zero, where a relative one never triggers. _METRIC_REL_TOL = 0.05 _METRIC_ABS_TOL = 1.0e-3 +# Provenance WorkflowEvaluateMovement writes beside the metrics: absolute paths +# and file dates that differ on every run and every machine. +_METRIC_IGNORE_KEYS = [ + "shape_parameters_file", + "network_weights_file", + "network_weights_created", + "network_weights_modified", +] _REPO_ROOT = Path(__file__).parent.parent # Where every tutorial writes under ``MONAI_PHYSIO_RUNNING_AS_TEST``, and where # Tutorial 9 trains its networks to. Kept apart from the ``output`` and @@ -74,14 +90,58 @@ @pytest.fixture(autouse=True) -def _enable_tutorial_test_mode(monkeypatch: pytest.MonkeyPatch) -> None: - """Run tutorials against repo data/test through ProcessTests mode switching.""" +def _enable_tutorial_test_mode( + request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch +) -> Iterator[None]: + """Run tutorials against repo data/test through ProcessTests mode switching. + + ICON is made reproducible too. Its per-pair finetuning and Tutorial 2's + training both back-propagate through 3-D ``grid_sample``, which has no + deterministic CUDA kernel, so their results change from run to run and no + screenshot baseline could hold. Every ICON registration therefore runs the + network forward only, and every tutorial after Tutorial 2 keeps the stock + uniGradICON weights rather than the ones Tutorial 2 retrains each run. + PyTorch's deterministic algorithms make that forward pass bit-identical; + ops without a deterministic kernel only warn. Greedy runs single-threaded: + its multithreaded metric sums change with thread timing, and its optimizer + turns that into registrations millimeters apart. + """ monkeypatch.setenv("MONAI_PHYSIO_RUNNING_AS_TEST", "1") + monkeypatch.setattr( + register_images_greedy, + "_GREEDY_COMMAND_PREFIX", + register_images_greedy._GREEDY_COMMAND_PREFIX + " -threads 1", + ) + monkeypatch.setattr(torch.backends.cudnn, "benchmark", False) + monkeypatch.setattr(torch.backends.cudnn, "deterministic", True) + deterministic = torch.are_deterministic_algorithms_enabled() + warn_only = torch.is_deterministic_algorithms_warn_only_enabled() + torch.use_deterministic_algorithms(True, warn_only=True) + + registration_method = RegisterImagesICON.registration_method + + def forward_only(self: RegisterImagesICON, *args: Any, **kwargs: Any) -> Any: + self.number_of_iterations = None + return registration_method(self, *args, **kwargs) + + monkeypatch.setattr(RegisterImagesICON, "registration_method", forward_only) + + test_class = request.node.cls + if test_class is not None and not test_class.__name__.startswith("TestTutorial02"): + + def keep_stock_weights(self: RegisterImagesICON, weights_path: str) -> None: + return None + + monkeypatch.setattr(RegisterImagesICON, "set_weights_path", keep_stock_weights) + + yield + torch.use_deterministic_algorithms(deterministic, warn_only=warn_only) def _compare_screenshots( screenshots: list[Path], tt: ProcessTests, + max_pixels: int = _MAX_PX, ) -> None: """Read each PNG as itk.Image and compare against baseline.""" if not screenshots: @@ -93,7 +153,7 @@ def _compare_screenshots( assert tt.compare_result_to_baseline_image( png_path.name, per_pixel_absolute_error_tol=_PX_TOL, - max_number_of_pixels_above_tol=_MAX_PX, + max_number_of_pixels_above_tol=max_pixels, total_absolute_error_tol=_TOT_TOL, ), f"Screenshot baseline mismatch: {png_path.name}" @@ -109,6 +169,7 @@ def _compare_metrics(tt: ProcessTests, filenames: list[str]) -> None: filename, relative_tol=_METRIC_REL_TOL, absolute_tol=_METRIC_ABS_TOL, + ignore_keys=_METRIC_IGNORE_KEYS, ), f"Metrics baseline mismatch: {filename}" @@ -172,7 +233,9 @@ def test_run(self, test_directories: dict[str, Path]) -> None: results_dir=out_dir, baselines_dir=test_directories["baselines"] / self._class_name, ) - _compare_screenshots(results["screenshots"], tt) + _compare_screenshots( + results["screenshots"], tt, max_pixels=_MAX_PX_GPU_SEGMENTATION + ) @pytest.mark.tutorial @@ -493,7 +556,9 @@ def test_run( results_dir=out_dir, baselines_dir=test_directories["baselines"] / self._class_name, ) - _compare_screenshots(results["screenshots"], tt) + _compare_screenshots( + results["screenshots"], tt, max_pixels=_MAX_PX_GPU_SEGMENTATION + ) @pytest.mark.tutorial @@ -861,7 +926,7 @@ def _require_physicsnemo() -> None: "monai-physio; the environment may be broken." ) if tutorial_data_is_required(): - pytest.fail(f"{reason} (--require-tutorial-data is set)") + pytest.fail(f"{reason} (--require-tutorial-data or --run-all is set)") pytest.skip(reason) if importlib.util.find_spec("torch_geometric") is None: reason = ( @@ -869,7 +934,7 @@ def _require_physicsnemo() -> None: "of monai-physio; the environment may be broken." ) if tutorial_data_is_required(): - pytest.fail(f"{reason} (--require-tutorial-data is set)") + pytest.fail(f"{reason} (--require-tutorial-data or --run-all is set)") pytest.skip(reason) diff --git a/tests/test_workflow_convert_image_to_vtk.py b/tests/test_workflow_convert_image_to_vtk.py index 6803ae35..11bfd381 100644 --- a/tests/test_workflow_convert_image_to_vtk.py +++ b/tests/test_workflow_convert_image_to_vtk.py @@ -2,11 +2,13 @@ from __future__ import annotations +from pathlib import Path from typing import Any import itk import numpy as np import pytest +import pyvista as pv from monai_physio.segment_chest_total_segmentator import SegmentChestTotalSegmentator from monai_physio.segment_heart_simpleware import SegmentHeartSimpleware @@ -34,25 +36,103 @@ def test_caller_supplied_instance_is_used_as_is() -> None: assert workflow._segmenter is segmenter -def test_extract_label_surface_isolates_single_label() -> None: - """_extract_label_surface must isolate one label id from a multi-value labelmap. - - Regression coverage for the extract_label_surfaces=True path added to - process(): each label's surface should come from only that label's - voxels, not the whole labelmap, and an absent label id should yield - None (mirrors the empty-mask skip used for whole-group surfaces). - """ - arr = np.zeros((10, 10, 10), dtype=np.uint8) - arr[2:5, 2:5, 2:5] = 1 - arr[6:9, 6:9, 6:9] = 2 - labelmap = itk.image_from_array(arr) - - workflow = WorkflowConvertImageToVTK() - - surface_1 = workflow._extract_label_surface(labelmap, arr, 1) - surface_2 = workflow._extract_label_surface(labelmap, arr, 2) - surface_absent = workflow._extract_label_surface(labelmap, arr, 99) - - assert surface_1 is not None and surface_1.n_points > 0 - assert surface_2 is not None and surface_2.n_points > 0 - assert surface_absent is None +def _heart_labelmap() -> itk.Image: + """Myocardium (5) wrapping a left ventricle (1), next to an aorta (7).""" + arr = np.zeros((20, 24, 32), dtype=np.uint8) + arr[4:16, 4:20, 4:18] = 5 + arr[7:13, 8:16, 8:14] = 1 + arr[6:14, 8:16, 18:28] = 7 + return itk.GetImageFromArray(arr) + + +@pytest.fixture(scope="module") +def heart_workflow() -> WorkflowConvertImageToVTK: + return WorkflowConvertImageToVTK(segmentation_method=SegmentHeartSimpleware()) + + +@pytest.mark.requires_gpu +def test_labelmap_gives_annotated_group_and_label_surfaces( + heart_workflow: WorkflowConvertImageToVTK, +) -> None: + result = heart_workflow.process( + labelmap_image=_heart_labelmap(), extract_label_surfaces=True + ) + + assert sorted(result["surfaces"]) == ["heart", "major_vessels"] + assert sorted(result["label_surfaces"]) == ["aorta", "left_ventricle", "myocardium"] + heart = result["surfaces"]["heart"] + assert heart.field_data["AnatomyGroup"].tolist() == ["heart"] + assert sorted(heart.field_data["SegmentationLabelIds"].tolist()) == [1, 5] + myocardium = result["label_surfaces"]["myocardium"] + assert myocardium.field_data["SegmentationLabelIds"].tolist() == [5] + assert myocardium.cell_data["Color"].shape == (myocardium.n_cells, 4) + # Myocardium has its own color; the ventricle falls back to its group's. + assert not np.array_equal( + myocardium.field_data["AnatomyColor"], heart.field_data["AnatomyColor"] + ) + assert np.array_equal( + result["label_surfaces"]["left_ventricle"].field_data["AnatomyColor"], + heart.field_data["AnatomyColor"], + ) + + +@pytest.mark.requires_gpu +def test_custom_groups_and_tetrahedra( + heart_workflow: WorkflowConvertImageToVTK, +) -> None: + """A group of chosen labels gets one surface and its tetrahedral mesh.""" + result = heart_workflow.process( + labelmap_image=_heart_labelmap(), + groups={"heart_minus_interior_chambers": [5]}, + tetrahedra_element_size_mm=1.0, + ) + + assert list(result["surfaces"]) == ["heart_minus_interior_chambers"] + assert list(result["tetrahedra"]) == ["heart_minus_interior_chambers"] + mesh = result["tetrahedra"]["heart_minus_interior_chambers"] + assert mesh.n_cells > 0 + assert mesh.field_data["SegmentationLabelIds"].tolist() == [5] + assert result["label_surfaces"] == {} + + +def test_process_needs_exactly_one_input( + heart_workflow: WorkflowConvertImageToVTK, +) -> None: + with pytest.raises(ValueError, match="exactly one"): + heart_workflow.process() + with pytest.raises(ValueError, match="exactly one"): + heart_workflow.process( + input_image=_heart_labelmap(), labelmap_image=_heart_labelmap() + ) + + +def test_groups_must_not_share_a_label( + heart_workflow: WorkflowConvertImageToVTK, +) -> None: + with pytest.raises(ValueError, match="must not share"): + heart_workflow.process( + labelmap_image=_heart_labelmap(), groups={"a": [1, 5], "b": [5]} + ) + + +@pytest.mark.requires_gpu +def test_save_surfaces_and_combined( + heart_workflow: WorkflowConvertImageToVTK, tmp_path: Path +) -> None: + result = heart_workflow.process( + labelmap_image=_heart_labelmap(), extract_label_surfaces=True + ) + + saved = WorkflowConvertImageToVTK.save_surfaces( + result["surfaces"], str(tmp_path), prefix="p" + ) + combined_file = WorkflowConvertImageToVTK.save_combined_surfaces( + result["label_surfaces"], str(tmp_path / "sub" / "p_surfaces.vtp") + ) + + assert sorted(Path(path).name for path in saved.values()) == [ + "p_heart.vtp", + "p_major_vessels.vtp", + ] + combined = pv.read(combined_file) + assert sorted(np.unique(combined.cell_data["SegmentationLabelIds"])) == [1, 5, 7] diff --git a/tests/test_workflow_convert_vtk_to_usd.py b/tests/test_workflow_convert_vtk_to_usd.py index 657dc03f..2d2714f3 100644 --- a/tests/test_workflow_convert_vtk_to_usd.py +++ b/tests/test_workflow_convert_vtk_to_usd.py @@ -11,7 +11,7 @@ import pyvista as pv from pxr import Usd, UsdGeom, UsdShade -from monai_physio import SegmentHeartSimpleware, WorkflowConvertVTKToUSD +from monai_physio import ProcessUSD, SegmentHeartSimpleware, WorkflowConvertVTKToUSD def _labeled_sphere( @@ -339,3 +339,23 @@ def test_several_labeled_static_meshes_raise(self, tmp_path: Path) -> None: ) with pytest.raises(ValueError, match="static_merge with mask_ids"): workflow.process() + + +class TestSolidAppearance: + """Edits made on the converter's stage reach the file once it is saved.""" + + def test_solid_color_is_saved_to_file(self, tmp_path: Path) -> None: + workflow = WorkflowConvertVTKToUSD( + input_meshes=[_labeled_sphere((0.0, 0.0, 0.0), "blob")], + usd_project_name="solid", + output_directory=tmp_path, + appearance="solid", + solid_color=(0.0, 1.0, 0.0), + static_merge=True, + ) + result = workflow.process() + + stage = ProcessUSD().open_stage(result["usd_file"]) + mesh = UsdGeom.Mesh(stage.GetPrimAtPath("/World/solid/blob_object1")) + colors = np.asarray(mesh.GetDisplayColorPrimvar().Get()) + assert np.allclose(colors, [0.0, 1.0, 0.0]) diff --git a/tests/test_workflow_evaluate_movement.py b/tests/test_workflow_evaluate_movement.py index 1035e6b1..58a87d62 100644 --- a/tests/test_workflow_evaluate_movement.py +++ b/tests/test_workflow_evaluate_movement.py @@ -338,17 +338,7 @@ def _trained_model_directory(tmp_path: Path) -> Path: @pytest.mark.requires_gpu def test_every_stage_and_structure_reaches_the_report(tmp_path: Path) -> None: - """One row per stage and structure, with the run's provenance on it. - - Trains a MeshGraphNet the same way as - ``test_workflow_train_physicsnemo.test_first_checkpoint_has_its_companions``, - and fails the same way: ``CUBLAS_STATUS_NOT_INITIALIZED`` under pytest - specifically, not as a standalone script with identical code -- cov, - timeout, output capture, faulthandler, import order and env vars were - all ruled out as the cause. Likely a pytest-harness / very-new-GPU - (Blackwell, sm_120) / CUDA-13.2-driver-combo interaction rather than a - code bug; real (non-pytest) training runs are unaffected. - """ + """One row per stage and structure, with the run's provenance on it.""" pytest.importorskip("torch") pytest.importorskip("physicsnemo") pytest.importorskip("torch_geometric") diff --git a/tests/test_workflow_fit_statistical_model_to_patient.py b/tests/test_workflow_fit_statistical_model_to_patient.py index bb856b01..08b7ec16 100644 --- a/tests/test_workflow_fit_statistical_model_to_patient.py +++ b/tests/test_workflow_fit_statistical_model_to_patient.py @@ -283,7 +283,9 @@ def test_empty_pca_model_reduces_a_positive_request_to_zero() -> None: assert workflow.number_of_pca_components == 0 -def test_clamped_component_count_reaches_the_pca_registrar() -> None: +def test_clamped_component_count_reaches_the_pca_registrar( + monkeypatch: pytest.MonkeyPatch, +) -> None: """The reduced count must be what register_model_to_model_pca passes on. Reducing the stored count would be pointless if the registrar were built @@ -304,12 +306,10 @@ def _capture(**kwargs: Any) -> None: from monai_physio import workflow_fit_statistical_model_to_patient as module - original = module.RegisterModelsPCA.from_pca_model - module.RegisterModelsPCA.from_pca_model = staticmethod(_capture) - try: - with pytest.raises(RuntimeError): - workflow.register_model_to_model_pca() - finally: - module.RegisterModelsPCA.from_pca_model = original + monkeypatch.setattr( + module.RegisterModelsPCA, "from_pca_model", staticmethod(_capture) + ) + with pytest.raises(RuntimeError): + workflow.register_model_to_model_pca() assert captured["pca_number_of_modes"] == 2 diff --git a/tests/test_workflow_train_physicsnemo.py b/tests/test_workflow_train_physicsnemo.py index 13b22b30..5668f637 100644 --- a/tests/test_workflow_train_physicsnemo.py +++ b/tests/test_workflow_train_physicsnemo.py @@ -133,7 +133,7 @@ def __init__(self, model_directory: Path) -> None: def build_checkpoint(self, model: Any, stats: dict) -> dict[str, Any]: self.snapshots.append({path.name for path in self.model_directory.iterdir()}) - return cast("dict[str, Any]", super().build_checkpoint(model, stats)) + return super().build_checkpoint(model, stats) def _train(tmp_path: Path) -> tuple[Path, _RecordingMGN]: @@ -169,15 +169,7 @@ def _train(tmp_path: Path) -> tuple[Path, _RecordingMGN]: @pytest.mark.requires_gpu def test_first_checkpoint_has_its_companions(tmp_path: Path) -> None: - """Inference's inputs are on disk before the first checkpoint is written. - - Fails under pytest specifically (not as a standalone script, with the - identical code) with ``CUBLAS_STATUS_NOT_INITIALIZED`` on this dev box's - GPU (Blackwell, sm_120) with CUDA 13.2 -- cov, timeout, output capture, - faulthandler, import order and env vars were all ruled out as the cause. - Likely a pytest-harness / very-new-GPU-driver-combo interaction rather - than a code bug; real (non-pytest) training runs are unaffected. - """ + """Inference's inputs are on disk before the first checkpoint is written.""" _, method = _train(tmp_path) assert len(method.snapshots) > 1, "expected intermittent checkpoints, not just one" @@ -193,11 +185,7 @@ def test_first_checkpoint_has_its_companions(tmp_path: Path) -> None: @pytest.mark.requires_gpu def test_an_intermittent_checkpoint_can_be_inferred_from(tmp_path: Path) -> None: - """The model directory loads at an epoch, not only at the final weights. - - See :func:`test_first_checkpoint_has_its_companions` -- same probable - pytest-harness / GPU-driver-combo bug, not a code issue. - """ + """The model directory loads at an epoch, not only at the final weights.""" model_directory, _ = _train(tmp_path) infer = WorkflowInferPhysicsNeMo(model_directory=model_directory, epoch=1) diff --git a/tutorials/README.md b/tutorials/README.md index cc2aebf5..cb959f9b 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -35,7 +35,7 @@ current working directory. | 3 | [tutorial_03_lung_reconstruct_highres_4d_ct.py](tutorial_03_lung_reconstruct_highres_4d_ct.py) | `WorkflowReconstructHighres4DCT` | TCIA-4DLung (manual) | | 4 | [tutorial_04_heart_ct_to_vtk.py](tutorial_04_heart_ct_to_vtk.py) | `WorkflowConvertImageToVTK` | Slicer-Heart-CT (prepare first) | | 4 | [tutorial_04_lung_ct_to_vtk.py](tutorial_04_lung_ct_to_vtk.py) | `WorkflowConvertImageToVTK` | Lung gated 4D CT (prepare first) | -| 4 | [duke heart labelmap variant](tutorial_04_duke_heart_labelmap_to_vtk.py) | `ProcessContours.extract_label_surfaces`, `ProcessContours.extract_tetrahedra` | Duke-Heart-4DLabelmaps (releasing soon) | +| 4 | [duke heart labelmap variant](tutorial_04_duke_heart_labelmap_to_vtk.py) | `WorkflowConvertImageToVTK` (labelmap input, tetrahedral meshes) | Duke-Heart-4DLabelmaps (releasing soon) | | 5 | [tutorial_05_heart_vtk_to_usd.py](tutorial_05_heart_vtk_to_usd.py) | `WorkflowConvertVTKToUSD` | Output of tutorial 4 | | 5 | [duke heart variant](tutorial_05_duke_heart_vtk_to_usd.py) | `ConvertVTKToUSD`, `ProcessUSDAnatomy` | Output of tutorial 4 (duke heart labelmap) | | 6 | [tutorial_06_heart_create_statistical_model.py](tutorial_06_heart_create_statistical_model.py) | `WorkflowCreateStatisticalModel` | KCL-Heart-Model | @@ -53,13 +53,13 @@ current working directory. | 11 | [tutorial_11_lung_evaluate_physicsnemo.py](tutorial_11_lung_evaluate_physicsnemo.py) | `WorkflowEvaluateMovement`, `SegmentNVSegmentCTMRI` | TCIA-4DLung plus Tutorial 8 and 9 (lung) output | | 11 | [duke heart variant](tutorial_11_duke_heart_evaluate_physicsnemo.py) | `WorkflowEvaluateMovement` | Duke-Heart-4DLabelmaps plus Tutorial 8 and 9 (duke heart) output | | 12 | [tutorial_12_lung_end_to_end_inference.py](tutorial_12_lung_end_to_end_inference.py) | `WorkflowConvertImageToVTK`, `WorkflowFitStatisticalModelToPatient`, `WorkflowInferMovement` | TCIA-4DLung plus Tutorial 6 and 9 (lung) output | -| 12 | [duke heart variant](tutorial_12_duke_heart_end_to_end_inference.py) | `ProcessContours`, `WorkflowFitStatisticalModelToPatient`, `WorkflowInferMovement` | Duke-Heart-4DLabelmaps plus Tutorial 6 and 9 (duke heart) output | +| 12 | [duke heart variant](tutorial_12_duke_heart_end_to_end_inference.py) | `ProcessSurfaceMesh`, `WorkflowFitStatisticalModelToPatient`, `WorkflowInferMovement` | Duke-Heart-4DLabelmaps plus Tutorial 6 and 9 (duke heart) output | | 13 | [tutorial_13_heart_and_lung_motion.py](tutorial_13_heart_and_lung_motion.py) | `WorkflowInferMovement`, `WorkflowFitStatisticalModelToPatient`, `ConvertVTKToUSD` (requires Simpleware Medical) | Chest-CT plus Tutorial 7 (lung) and Tutorial 9 (lung and duke heart) output | | 14 | [tutorial_14_lung_shape_parameter_sweep.py](tutorial_14_lung_shape_parameter_sweep.py) | `WorkflowEvaluateMovement`, `SegmentNVSegmentCTMRI` | TCIA-4DLung plus Tutorial 8 and 9 (lung) output | | 14 | [duke heart variant](tutorial_14_duke_heart_shape_parameter_sweep.py) | `WorkflowEvaluateMovement` | Duke-Heart-4DLabelmaps plus Tutorial 8 and 9 (duke heart) output | | 15 | [tutorial_15_lung_leave_one_out.py](tutorial_15_lung_leave_one_out.py) | `WorkflowCreateStatisticalModel`, `WorkflowFitStatisticalModelToPatient`, `WorkflowTrainPhysicsNeMo`, `WorkflowEvaluateMovement` | TCIA-4DLung | | 15 | [duke heart variant](tutorial_15_duke_heart_leave_one_out.py) | `WorkflowCreateStatisticalModel`, `WorkflowFitStatisticalModelToPatient`, `WorkflowTrainPhysicsNeMo`, `WorkflowEvaluateMovement` | Duke-Heart-4DLabelmaps | -| 16 | [tutorial_16_duke_heart_physics_informed_motion_prep.py](tutorial_16_duke_heart_physics_informed_motion_prep.py) | `ProcessContours.extract_tetrahedra`, `WorkflowCreateStatisticalModel`, `WorkflowFitStatisticalModelToPatient` | Duke-Heart-4DLabelmaps plus Tutorial 4 (duke heart) output | +| 16 | [tutorial_16_duke_heart_physics_informed_motion_prep.py](tutorial_16_duke_heart_physics_informed_motion_prep.py) | `ProcessVolumetricMesh.extract_tetrahedra`, `WorkflowCreateStatisticalModel`, `WorkflowFitStatisticalModelToPatient` | Duke-Heart-4DLabelmaps plus Tutorial 4 (duke heart) output | | 17 | [tutorial_17_duke_heart_physics_informed_motion_train.py](tutorial_17_duke_heart_physics_informed_motion_train.py) | `TrainPhysicsNeMoPhysicsInformedMotion`, `WorkflowTrainPhysicsNeMo` | Tutorial 16 output | | 18 | [tutorial_18_duke_heart_physics_informed_motion_infer.py](tutorial_18_duke_heart_physics_informed_motion_infer.py) | `WorkflowEvaluateMovement`, `ConvertVTKToUSD` | Tutorial 16 and 17 output | diff --git a/tutorials/parameters_duke_heart_labelmaps.py b/tutorials/parameters_duke_heart_labelmaps.py index 51325df2..a0ad73c8 100644 --- a/tutorials/parameters_duke_heart_labelmaps.py +++ b/tutorials/parameters_duke_heart_labelmaps.py @@ -20,13 +20,19 @@ from pathlib import Path from typing import cast +import itk +import numpy as np +import pyvista as pv from parameters_base import ParametersBase from monai_physio import ( + ProcessSurfaceMesh, RegisterImagesBase, RegisterImagesGreedy, + RegisterModelsDistanceMaps, SegmentAnatomyBase, SegmentHeartSimplewareTrimmedBranches, + WorkflowConvertImageToVTK, ) @@ -77,6 +83,13 @@ class ParametersDukeHeartLabelmaps(ParametersBase): coronaries (7-10) vary too much in extent between patients to be part of a shape model. What remains is the myocardium (5) and the heart wall (6). + whole_heart_name: Name of the structure the labels outside + ``interior_object_ids`` form, and of the surface files Tutorial 4 + writes for it. + frame_registration_spacing_mm: Pitch of the grid a case's frame + distance maps are rasterized on. Coarser than + ``surface_spacing_mm``: it carries a distance field, not a + boundary. hold_out_case: Case held out of every fit: Tutorial 6 builds the shape model without it and Tutorial 7 fits that model to it, so the fit measures generalization rather than reconstruction. Tutorial 2 @@ -102,6 +115,9 @@ class ParametersDukeHeartLabelmaps(ParametersBase): interior_object_ids: list[int] = field( default_factory=lambda: [1, 2, 3, 4, 7, 8, 9, 10] ) + whole_heart_name: str = "heart_minus_interior_chambers" + + frame_registration_spacing_mm: float = 1.0 hold_out_case: str = "pm0027" @@ -175,6 +191,127 @@ def registrar( registrar.set_metric("CC") return registrar + def distancemap_icon_weights_file(self, test_mode: bool) -> Path: + """Return the distance-map ICON weights Tutorial 2 (Duke Heart) trains.""" + return ( + self.weights_directory(test_mode) + / "icon_duke_heart_distancemap" + / "icon_duke_heart_distancemap_model" + / "checkpoints" + / "network_weights_final.trch" + ) + + def surface_groups(self, labelmap: itk.Image) -> dict[str, list[int]]: + """Return the whole-heart structure of *labelmap* as a surface group. + + For ``WorkflowConvertImageToVTK.process(groups=...)``: the labels present + in *labelmap* outside ``interior_object_ids``, extracted as one surface. + """ + labels = itk.GetArrayViewFromImage(labelmap) + return { + self.whole_heart_name: [ + int(value) + for value in np.unique(labels) + if value != 0 and int(value) not in self.interior_object_ids + ] + } + + def whole_heart_surface( + self, labelmap_file: Path, test_mode: bool, log_level: int | str = logging.INFO + ) -> pv.PolyData: + """Return the whole-heart surface of one ``*_labelmap.nii.gz`` frame. + + Tutorial 4 (Duke Heart) writes this surface, so its file is read when + present; otherwise the surface is extracted the way Tutorial 4 does. + """ + stem = labelmap_file.name.split(".", 1)[0].removesuffix("_labelmap") + surface_file = ( + self.input_directory(test_mode) / f"{stem}_{self.whole_heart_name}.vtp" + ) + if surface_file.exists(): + return cast(pv.PolyData, pv.read(str(surface_file))) + labelmap = itk.imread(str(labelmap_file)) + workflow = WorkflowConvertImageToVTK( + self.segmenter(test_mode, log_level), log_level=log_level + ) + result = workflow.process( + labelmap_image=labelmap, + groups=self.surface_groups(labelmap), + isotropic_spacing_mm=self.surface_spacing_mm, + smoothing_iterations=self.surface_smoothing_iterations, + ) + return cast(pv.PolyData, result["surfaces"][self.whole_heart_name]) + + def frame_registration_grid(self, reference_surface: pv.DataSet) -> itk.Image: + """Return the grid a case's frames are registered on. + + Built once around the reference frame's heart and reused by every + frame, so the whole case is registered in a common space; its buffer + holds the frames the heart moves into. + """ + return ProcessSurfaceMesh.create_reference_image( + mesh=reference_surface, + spatial_resolution=self.frame_registration_spacing_mm, + buffer_factor=0.25, + ptype=itk.F, + ) + + def frames_to_register( + self, + frame_files: list[Path], + reference_file: Path, + test_mode: bool, + scored: bool, + ) -> list[Path]: + """Return the gated frames a case is registered to. + + Every frame costs a contour, two distance maps and a registration, so a + test run keeps every other frame; the reference frame, which every fit + starts from, is always kept. A *scored* case keeps every frame, as its + ground truth needs a fit for each one. + """ + if not test_mode or scored: + return frame_files + return [ + path + for index, path in enumerate(frame_files) + if index % 2 == 0 or path == reference_file + ] + + def register_to_frame( + self, + model: pv.DataSet, + frame_surface: pv.PolyData, + registration_grid: itk.Image, + test_mode: bool, + log_level: int | str = logging.INFO, + ) -> pv.DataSet: + """Warp *model* onto one frame's whole-heart surface. + + Greedy affine, then ICON, on the two distance maps; the finetuned + Tutorial 2 weights are used when they exist. The transform is applied + point-wise, so *model* keeps its topology, tetrahedra included. + """ + registrar = RegisterModelsDistanceMaps( + moving_model=cast(pv.PolyData, model), + fixed_model=frame_surface, + reference_image=registration_grid, + distance_squared_max=self.distancemap_squared_max, + mask_dilation_mm=self.mask_dilation_mm, + log_level=log_level, + ) + weights_file = self.distancemap_icon_weights_file(test_mode) + if weights_file.exists(): + registrar.set_icon_weights_path(str(weights_file)) + if test_mode: + # A test run registers many frames, so the affine stage is kept as + # light as the other test-mode registrars. + registrar.registrar_Greedy.set_number_of_iterations([1, 0]) + return cast( + pv.DataSet, + registrar.register(transform_type="Deformable")["registered_model"], + ) + #: The single instance every Duke heart tutorial imports. DUKE_HEART = ParametersDukeHeartLabelmaps() diff --git a/tutorials/parameters_tcia_4d_lung.py b/tutorials/parameters_tcia_4d_lung.py index c4fd5d8d..3a82058e 100644 --- a/tutorials/parameters_tcia_4d_lung.py +++ b/tutorials/parameters_tcia_4d_lung.py @@ -23,7 +23,7 @@ RegisterImagesBase, RegisterImagesGreedy, SegmentAnatomyBase, - SegmentChestTotalSegmentator, + SegmentNVSegmentCTMRI, ) @@ -88,7 +88,7 @@ class ParametersTCIA4DLung(ParametersBase): number_of_pca_components: int = 6 number_of_pca_components_test: int = 5 - segmenter_class: type[SegmentAnatomyBase] = SegmentChestTotalSegmentator + segmenter_class: type[SegmentAnatomyBase] = SegmentNVSegmentCTMRI registration_class: type[RegisterImagesBase] = RegisterImagesGreedy anatomy_group: str = "lung" @@ -138,7 +138,7 @@ def points_per_model(self, test_mode: bool) -> int: def segmenter( self, test_mode: bool, log_level: int | str = logging.INFO ) -> SegmentAnatomyBase: - """Return the shared lung segmenter, fast mode enabled. + """Return the shared lung segmenter, fast mode enabled where it has one. Args: test_mode: Unused today (fast mode is always on); kept for diff --git a/tutorials/tutorial_00_lung_demo.ipynb b/tutorials/tutorial_00_lung_demo.ipynb index 19b1043d..0ff258d3 100644 --- a/tutorials/tutorial_00_lung_demo.ipynb +++ b/tutorials/tutorial_00_lung_demo.ipynb @@ -18,8 +18,8 @@ " ``monai_physio.DownloadData``. The checkpoint directory also carries the\n", " lung PCA shape model (``pca_model.json``, ``pca_mean_surface.vtp``) that\n", " network was trained against, so no separate Tutorial 6 run is needed.\n", - "2. Segment the lungs with ``SegmentChestTotalSegmentator`` and extract their\n", - " surface.\n", + "2. Segment the lungs with ``TCIA_4D_LUNG.segmenter`` (``SegmentNVSegmentCTMRI``)\n", + " and extract their surface.\n", "3. Fit that PCA shape model to the surface with\n", " ``WorkflowFitStatisticalModelToPatient``, giving this patient's PCA\n", " coefficients and fitted SSM surface.\n", @@ -43,7 +43,7 @@ "\n", "## Outputs (under ``output/tutorial_00_lung_demo/``)\n", "\n", - " * ``Chest-CT_lung_surface.vtp``, ``Chest-CT_lung_labelmap.nii.gz`` -- segmentation\n", + " * ``Chest-CT_lung_labelmap.nii.gz`` -- segmentation\n", " * ``Chest-CT_surface.json`` -- this patient's shape parameters\n", " * ``Chest-CT_ssm_surface.vtp`` -- the model fitted to the patient\n", " * ``Chest-CT_surface_s{TTT}_pred.vtp`` -- predicted surface per stage\n", @@ -70,8 +70,7 @@ "\n", "from monai_physio import (\n", " DownloadData,\n", - " ProcessContours,\n", - " SegmentChestTotalSegmentator,\n", + " ProcessSurfaceMesh,\n", " WorkflowConvertImageToVTK,\n", " WorkflowFitStatisticalModelToPatient,\n", " WorkflowInferMovement,\n", @@ -167,93 +166,19 @@ "id": "dacbb80f", "metadata": {}, "source": [ - "## Step 2: Segment the lungs with TotalSegmentator" + "## Step 2: Segment the lungs" ] }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "14953c63", "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-09-20 14:06:13 INFO WorkflowConvertImageToVTK ======================================================================\n", - "2026-09-20 14:06:13 INFO WorkflowConvertImageToVTK STARTING IMAGE TO VTK WORKFLOW\n", - "2026-09-20 14:06:13 INFO WorkflowConvertImageToVTK ======================================================================\n", - "2026-09-20 14:06:13 INFO WorkflowConvertImageToVTK Running segmenter: SegmentChestTotalSegmentator\n", - "2026-09-20 14:06:13 INFO WorkflowConvertImageToVTK ======================================================================\n", - "2026-09-20 14:06:13 INFO WorkflowConvertImageToVTK Running segmentation\n", - "2026-09-20 14:06:13 INFO WorkflowConvertImageToVTK ======================================================================\n", - "2026-09-20 14:06:13 WARNING SegmentChestTotalSegmentator The input image should have isotropic spacing\n", - "2026-09-20 14:06:13 INFO SegmentChestTotalSegmentator Input image has spacing: itkVectorD3 ([0.814453, 0.814453, 2])\n", - "2026-09-20 14:06:13 INFO SegmentChestTotalSegmentator Resampling to isotropic: 1.000\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n", - "If you use this tool please cite: https://pubs.rsna.org/doi/10.1148/ryai.230024\n", - "\n", - "Using 'fast' option: resampling to lower resolution (3mm)\n", - "Resampling...\n", - " Resampled in 1.52s\n", - "Predicting...\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "`torch.jit.interface` is deprecated. Please use `torch.compile` instead.\n", - "100%|██████████| 4/4 [00:00<00:00, 4.68it/s]\n" - ] - }, - { - "name": "stdout", - "output_type": "stream", - "text": [ - " Predicted in 11.09s\n", - "Resampling...\n", - " Resampled in 2.14s\n" - ] - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2026-09-20 14:06:36 INFO WorkflowConvertImageToVTK ======================================================================\n", - "2026-09-20 14:06:36 INFO WorkflowConvertImageToVTK Extracting VTK objects\n", - "2026-09-20 14:06:36 INFO WorkflowConvertImageToVTK ======================================================================\n", - "2026-09-20 14:06:36 INFO WorkflowConvertImageToVTK Processing anatomy group: lung\n", - "2026-09-20 14:06:36 INFO WorkflowConvertImageToVTK Extracting surface for: lung\n", - "2026-09-20 14:06:36 INFO ProcessContours Contouring on a 0.814 mm isotropic grid rather than the labelmap's 0.814 x 0.814 x 2 mm one\n", - "2026-09-20 14:06:50 INFO WorkflowConvertImageToVTK Extracting label surfaces for: lung\n", - "2026-09-20 14:06:50 INFO ProcessContours Contouring on a 0.814 mm isotropic grid rather than the labelmap's 0.814 x 0.814 x 2 mm one\n", - "2026-09-20 14:07:02 INFO ProcessContours Contouring on a 0.814 mm isotropic grid rather than the labelmap's 0.814 x 0.814 x 2 mm one\n", - "2026-09-20 14:07:14 INFO ProcessContours Contouring on a 0.814 mm isotropic grid rather than the labelmap's 0.814 x 0.814 x 2 mm one\n", - "2026-09-20 14:07:26 INFO ProcessContours Contouring on a 0.814 mm isotropic grid rather than the labelmap's 0.814 x 0.814 x 2 mm one\n", - "2026-09-20 14:07:37 INFO ProcessContours Contouring on a 0.814 mm isotropic grid rather than the labelmap's 0.814 x 0.814 x 2 mm one\n", - "2026-09-20 14:07:49 INFO WorkflowConvertImageToVTK ======================================================================\n", - "2026-09-20 14:07:49 INFO WorkflowConvertImageToVTK IMAGE TO VTK WORKFLOW COMPLETE\n", - "2026-09-20 14:07:49 INFO WorkflowConvertImageToVTK ======================================================================\n", - "2026-09-20 14:07:49 INFO WorkflowConvertImageToVTK Surfaces extracted: 1\n", - "2026-09-20 14:07:49 INFO WorkflowConvertImageToVTK Label surfaces extracted: 5\n", - "INFO:tutorial_00_lung_demo:Segmented lungs in Chest-CT.mha\n" - ] - } - ], + "outputs": [], "source": [ - "lung_surface_file = output_dir / f\"{case_id}_lung_surface.vtp\"\n", "lung_labelmap_file = output_dir / f\"{case_id}_lung_labelmap.nii.gz\"\n", "\n", - "contour_tools = ProcessContours(log_level=log_level)\n", - "segmentation_method = SegmentChestTotalSegmentator(log_level=log_level)\n", - "segmentation_method.fast_mode = True\n", + "segmentation_method = TCIA_4D_LUNG.segmenter(test_mode, log_level=log_level)\n", "segmentation_result = WorkflowConvertImageToVTK(\n", " segmentation_method=segmentation_method,\n", " log_level=log_level,\n", @@ -262,18 +187,16 @@ " anatomy_groups=[TCIA_4D_LUNG.anatomy_group],\n", " surface_reduction_rate=TCIA_4D_LUNG.surface_reduction_rate,\n", " extract_label_surfaces=True,\n", + " extract_group_surfaces=False,\n", ")\n", - "contour_tools.save_combined_surfaces(\n", - " segmentation_result[\"label_surfaces\"], str(lung_surface_file)\n", + "lung_surface = ProcessSurfaceMesh.combine_surfaces(\n", + " segmentation_result[\"label_surfaces\"]\n", ")\n", "# anatomy_groups only selects which structures are contoured into\n", "# label_surfaces above; the labelmap itself is the segmenter's full,\n", "# unfiltered whole-body output (ribs, spine, heart, etc. included).\n", "lung_labelmap = segmentation_result[\"labelmap\"]\n", "itk.imwrite(lung_labelmap, str(lung_labelmap_file), compression=True)\n", - "# Read back rather than combining in memory: save_combined_surfaces is what\n", - "# merges the per-label surfaces into the one surface the fit is given.\n", - "lung_surface = cast(pv.PolyData, pv.read(str(lung_surface_file)))\n", "logger.info(\"Segmented lungs in %s\", patient_image_file.name)\n", "\n", "# Isolate just the lung labels into a binary mask, the same way\n", @@ -359,14 +282,14 @@ "2026-09-20 14:08:50 INFO RegisterModelsDistanceMaps DEFORMABLE Distance-Map-based Registration\n", "2026-09-20 14:08:50 INFO RegisterModelsDistanceMaps ======================================================================\n", "2026-09-20 14:08:50 INFO RegisterModelsDistanceMaps Generating distance maps and registration masks from models...\n", - "2026-09-20 14:08:50 INFO ProcessContours Computing signed distance map...\n", - "2026-09-20 14:08:52 INFO ProcessContours Distance map: 9855431/9855431 surface samples within reference image\n", + "2026-09-20 14:08:50 INFO ProcessSurfaceMesh Computing signed distance map...\n", + "2026-09-20 14:08:52 INFO ProcessSurfaceMesh Distance map: 9855431/9855431 surface samples within reference image\n", "2026-09-20 14:08:54 INFO RegisterModelsDistanceMaps Dilating fixed mask by 20.0mm for registration mask...\n", "`n_faces_strict` is deprecated. Use `n_faces` instead, which now returns the number of polygonal faces.\n", "`n_faces_strict` is deprecated. Use `n_faces` instead, which now returns the number of polygonal faces.\n", "`n_faces_strict` is deprecated. Use `n_faces` instead, which now returns the number of polygonal faces.\n", - "2026-09-20 14:09:28 INFO ProcessContours Computing signed distance map...\n", - "2026-09-20 14:09:29 INFO ProcessContours Distance map: 7136144/7136144 surface samples within reference image\n", + "2026-09-20 14:09:28 INFO ProcessSurfaceMesh Computing signed distance map...\n", + "2026-09-20 14:09:29 INFO ProcessSurfaceMesh Distance map: 7136144/7136144 surface samples within reference image\n", "2026-09-20 14:09:32 INFO RegisterModelsDistanceMaps Dilating moving mask by 20.0mm for registration mask...\n", "2026-09-20 14:09:56 INFO RegisterModelsDistanceMaps Distance map and mask generation complete\n", "2026-09-20 14:09:56 INFO RegisterModelsDistanceMaps Performing Greedy Affine registration...\n", diff --git a/tutorials/tutorial_01_heart_gated_ct_to_usd.py b/tutorials/tutorial_01_heart_gated_ct_to_usd.py index 58d2d959..51278289 100644 --- a/tutorials/tutorial_01_heart_gated_ct_to_usd.py +++ b/tutorials/tutorial_01_heart_gated_ct_to_usd.py @@ -50,7 +50,7 @@ Deep-learning segmentation of 117 anatomical structures (used internally). - RegisterImagesGreedy (register_images_greedy.py): Frame-to-frame image registration (used internally). -- ProcessContours (process_contours.py): +- ProcessSurfaceMesh (process_surface_mesh.py): Extracts and transforms surface meshes from segmentation masks (used internally). - ProcessUSDAnatomy (process_usd_anatomy.py): Applies clinical material colours to USD prims (used internally). diff --git a/tutorials/tutorial_01_lung_gated_ct_to_usd.py b/tutorials/tutorial_01_lung_gated_ct_to_usd.py index 3fc07bb5..c21f67fa 100644 --- a/tutorials/tutorial_01_lung_gated_ct_to_usd.py +++ b/tutorials/tutorial_01_lung_gated_ct_to_usd.py @@ -29,12 +29,13 @@ Strengths --------- - Single call (``WorkflowConvertImageToUSD.process()``) runs the full pipeline. -- Registers on the CPU with ``RegisterImagesICON``; no GPU needed for this stage. +- Registers on the CPU with ``RegisterImagesGreedy``; no GPU needed for this + stage. - Output is Omniverse-ready with anatomical materials (ProcessUSDAnatomy). Weaknesses / Limitations ------------------------ -- Segmentation quality depends on TotalSegmentator's training distribution; +- Segmentation quality depends on the segmenter's training distribution; unusual pathologies or pediatric anatomy may degrade results. - Large 4D datasets (>20 phases, high resolution) can require 32 GB+ RAM. @@ -43,11 +44,11 @@ - WorkflowConvertImageToUSD (workflow_convert_image_to_usd.py): Orchestrates the full pipeline: CT phases -> segmentation -> registration -> contour extraction -> USD export. -- SegmentChestTotalSegmentator (segment_chest_total_segmentator.py): - Deep-learning segmentation of 117 anatomical structures (used internally). -- RegisterImagesICON (register_images_icon.py): - Frame-to-frame image registration (used internally). -- ProcessContours (process_contours.py): +- SegmentNVSegmentCTMRI (segment_nv_segment_ct_mri.py): + Deep-learning anatomy segmentation (``TCIA_4D_LUNG.segmenter``). +- RegisterImagesGreedy (register_images_greedy.py): + Frame-to-frame image registration (``TCIA_4D_LUNG.registrar``). +- ProcessSurfaceMesh (process_surface_mesh.py): Extracts and transforms surface meshes from segmentation masks (used internally). - ProcessUSDAnatomy (process_usd_anatomy.py): Applies clinical material colours to USD prims (used internally). @@ -69,12 +70,7 @@ import itk from parameters_tcia_4d_lung import TCIA_4D_LUNG -from monai_physio import ( - ProcessTests, - RegisterImagesGreedy, - SegmentNVSegmentCTMRI, - WorkflowConvertImageToUSD, -) +from monai_physio import ProcessTests, WorkflowConvertImageToUSD # Only run if this script is not imported as a module @@ -95,19 +91,14 @@ data_dir = TCIA_4D_LUNG.input_directory(test_mode) if test_mode: - number_of_iterations_greedy = [1, 0] frame_files = sorted(data_dir.glob("100_HM10395_g0??.nii.gz"))[0:2] else: - number_of_iterations_greedy = [100, 100, 10, 5] frame_files = sorted(data_dir.glob("100_HM10395_g0??.nii.gz")) log_level = logging.INFO - registration_method = RegisterImagesGreedy(log_level=log_level) - registration_method.set_number_of_iterations(number_of_iterations_greedy) - registration_method.set_metric("CC") - - segmentation_method = SegmentNVSegmentCTMRI(log_level=log_level) + registration_method = TCIA_4D_LUNG.registrar(test_mode, log_level=log_level) + segmentation_method = TCIA_4D_LUNG.segmenter(test_mode, log_level=log_level) # Directory setup and data reading diff --git a/tutorials/tutorial_01_lung_gated_ct_to_usd_tetmesh.py b/tutorials/tutorial_01_lung_gated_ct_to_usd_tetmesh.py index f54a643a..c1fa4295 100644 --- a/tutorials/tutorial_01_lung_gated_ct_to_usd_tetmesh.py +++ b/tutorials/tutorial_01_lung_gated_ct_to_usd_tetmesh.py @@ -34,23 +34,26 @@ - Registers on the CPU with ``RegisterImagesGreedy``; no GPU needed for this stage. - Inverted or degenerate elements introduced by large deformations are - repaired per frame via ``ProcessContours.repair_inverted_tetrahedra``. + repaired per frame via ``ProcessVolumetricMesh.repair_inverted_tetrahedra``. Weaknesses / Limitations ------------------------ -- Segmentation quality depends on TotalSegmentator's training distribution; +- Segmentation quality depends on the segmenter's training distribution; unusual pathologies or pediatric anatomy may degrade results. - Large 4D datasets (>20 phases, high resolution) can require 32 GB+ RAM. Classes Used ------------ -- SegmentChestTotalSegmentator (segment_chest_total_segmentator.py): - Deep-learning segmentation of the lung from the reference phase. -- RegisterImagesGreedy (register_images_greedy.py): - Frame-to-frame image registration. -- ProcessContours (process_contours.py): - Extracts the reference lung surface and tetmesh, and warps/repairs the - tetmesh per phase. +- WorkflowConvertImageToVTK (workflow_convert_image_to_vtk.py): + Segments the reference phase with ``TCIA_4D_LUNG.segmenter`` + (SegmentNVSegmentCTMRI) and builds its lung surface and tetmesh. +- WorkflowReconstructHighres4DCT (workflow_reconstruct_highres_4d_ct.py): + Registers every phase to the reference with ``TCIA_4D_LUNG.registrar`` + (RegisterImagesGreedy). +- ProcessTransforms (process_transforms.py): + Warps the reference tetmesh to each phase. +- ProcessVolumetricMesh (process_volumetric_mesh.py): + Repairs the warped tetmesh per phase. Data Required ------------- @@ -71,8 +74,11 @@ from monai_physio import ( MONAIPhysioBase, - ProcessContours, ProcessTests, + ProcessTransforms, + ProcessVolumetricMesh, + WorkflowConvertImageToVTK, + WorkflowReconstructHighres4DCT, ) # Only run if this script is not imported as a module @@ -104,7 +110,8 @@ registration_method = TCIA_4D_LUNG.registrar(test_mode, log_level=log_level) segmentation_method = TCIA_4D_LUNG.segmenter(test_mode, log_level=log_level) - contour_tools = ProcessContours(log_level=log_level) + transform_tools = ProcessTransforms(log_level=log_level) + volume_tools = ProcessVolumetricMesh(log_level=log_level) # Directory setup and data reading @@ -127,24 +134,16 @@ # Reference-frame segmentation and tetmesh construction - reporter.log_section("Segmenting reference frame") - seg_result = segmentation_method.segment(reference_image) - lung_mask = seg_result["lung"] - - reporter.log_section("Building reference lung tetmesh") - reference_surface = contour_tools.extract_watertight_surface( - lung_mask, + lung_result = WorkflowConvertImageToVTK( + segmentation_method=segmentation_method, log_level=log_level + ).process( + input_image=reference_image, + anatomy_groups=[TCIA_4D_LUNG.anatomy_group], surface_reduction_rate=TCIA_4D_LUNG.surface_reduction_rate, - anatomy_names=["lung"], - ) - reference_tetmesh = contour_tools.extract_tetrahedra( - lung_mask, - element_size_mm=TCIA_4D_LUNG.mesh_element_size_mm, - anatomy_names=["lung"], - ) - reference_tetmesh = contour_tools.trim_tetrahedra_to_surface( - reference_tetmesh, reference_surface + tetrahedra_element_size_mm=TCIA_4D_LUNG.mesh_element_size_mm, ) + reference_surface = lung_result["surfaces"][TCIA_4D_LUNG.anatomy_group] + reference_tetmesh = lung_result["tetrahedra"][TCIA_4D_LUNG.anatomy_group] reporter.log_info( "Reference tetmesh: %d points, %d cells", reference_tetmesh.n_points, @@ -153,22 +152,22 @@ # Registration and per-phase tetmesh warping - registration_method.set_fixed_image(reference_image) + fixed_to_moving_transforms = WorkflowReconstructHighres4DCT( + time_series_images=time_series_images, + reference_image=reference_image, + reference_time_frame=reference_index, + register_reference_time_frame_to_reference_image=False, + registration_method=registration_method, + log_level=log_level, + ).register_time_series()["fixed_to_moving_transforms"] tetmesh_files: list[Path] = [] - for i, moving_image in enumerate(time_series_images): - reporter.log_progress( - i + 1, len(time_series_images), prefix="Registering and warping phases" - ) - if i == reference_index: - phase_tetmesh = reference_tetmesh - else: - reg_results = registration_method.register(moving_image) - phase_tetmesh = contour_tools.transform_contours( - reference_tetmesh, reg_results["fixed_to_moving_transform"] + for i, fixed_to_moving_transform in enumerate(fixed_to_moving_transforms): + phase_tetmesh = volume_tools.repair_inverted_tetrahedra( + transform_tools.transform_pvcontour( + reference_tetmesh, fixed_to_moving_transform ) - phase_tetmesh = contour_tools.repair_inverted_tetrahedra(phase_tetmesh) - + ) tetmesh_file = output_dir / f"lung_tetmesh_{i:03d}.vtu" phase_tetmesh.save(tetmesh_file) tetmesh_files.append(tetmesh_file) diff --git a/tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py b/tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py index 848d9082..6cf2e985 100644 --- a/tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py +++ b/tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py @@ -58,7 +58,7 @@ from monai_physio import ( MONAIPhysioBase, - ProcessContours, + ProcessSurfaceMesh, ProcessTests, ProcessTransforms, RegisterImagesBase, @@ -119,13 +119,9 @@ derived_dir.mkdir(parents=True, exist_ok=True) - contour_tools = ProcessContours(log_level=log_level) + contour_tools = ProcessSurfaceMesh(log_level=log_level) transform_tools = ProcessTransforms() - # Labels left out of the surface a distance map is measured to; see the - # parameters module. - interior_object_ids = DUKE_HEART.interior_object_ids - # Cohort discovery case_dirs = sorted(path for path in data_dir.glob("pm*") if path.is_dir()) if len(case_dirs) < 2: @@ -163,19 +159,9 @@ def distance_map_for(labelmap_file: Path) -> Path: reporter.log_info("Rasterizing distance map for %s", stem) labelmap_image = itk.imread(str(labelmap_file)) - labels = itk.GetArrayViewFromImage(labelmap_image) - # The chamber labels cover the cavities rather than their walls, so a - # distance measured to them would be a distance to the inside of the - # heart. Dropping them leaves the myocardium and vessels, whose - # boundary is the surface RegisterModelsDistanceMaps measures to at - # inference. - heart_mask = itk.GetImageFromArray( - np.where((labels > 0) & ~np.isin(labels, interior_object_ids), 1, 0).astype( - np.uint8 - ) - ) - heart_mask.CopyInformation(labelmap_image) - surface = contour_tools.extract_contours(heart_mask) + # The heart without its chamber cavities: the surface + # RegisterModelsDistanceMaps measures to at inference. + surface = DUKE_HEART.whole_heart_surface(labelmap_file, test_mode, log_level) distance_map = contour_tools.create_distance_map( surface, labelmap_image, @@ -269,12 +255,7 @@ def distance_map_for(labelmap_file: Path) -> Path: ) weights_path = workflow.process() else: - weights_path = ( - experiment_dir - / f"{finetune_name}_model" - / "checkpoints" - / "network_weights_final.trch" - ) + weights_path = DUKE_HEART.distancemap_icon_weights_file(test_mode) # Checked here rather than at the first set_weights_path() call, which # only happens after the greedy and stock-ICON rows have already run. if not weights_path.exists(): diff --git a/tutorials/tutorial_02_lung_distancemap_finetune_icon.py b/tutorials/tutorial_02_lung_distancemap_finetune_icon.py index 1d2e31b3..7e88a668 100644 --- a/tutorials/tutorial_02_lung_distancemap_finetune_icon.py +++ b/tutorials/tutorial_02_lung_distancemap_finetune_icon.py @@ -78,12 +78,11 @@ import itk import numpy as np -import pyvista as pv from parameters_tcia_4d_lung import TCIA_4D_LUNG from monai_physio import ( MONAIPhysioBase, - ProcessContours, + ProcessSurfaceMesh, ProcessTests, ProcessTransforms, RegisterImagesBase, @@ -179,15 +178,15 @@ lung_label_ids = np.array( sorted(segmenter.taxonomy.labels_in_group("lung")), dtype=np.uint16 ) - contour_tools = ProcessContours(log_level=log_level) + contour_tools = ProcessSurfaceMesh(log_level=log_level) transform_tools = ProcessTransforms() def segment_phase(image_file: Path) -> tuple[Path, Path]: """Segment one phase's lungs and rasterize their distance map. - Returns the distance map and lung labelmap paths. Both, plus the - combined lung surface, are cached under ``derived_dir``; an existing - pair short-circuits the segmentation. + Returns the distance map and lung labelmap paths. Both are cached + under ``derived_dir``; an existing pair short-circuits the + segmentation. """ # ``.stem`` only strips ``.gz``, leaving a stray ``.nii`` in the name, # since these are ``.nii.gz`` (TCIA) rather than ``.mha`` (DIR-Lab). @@ -199,7 +198,6 @@ def segment_phase(image_file: Path) -> tuple[Path, Path]: cache_stem = f"{image_stem}_{segmenter_name}" distance_map_file = derived_dir / f"{cache_stem}_distance_map.mha" labelmap_file = derived_dir / f"{cache_stem}_lung_labelmap.nii.gz" - surface_file = derived_dir / f"{cache_stem}_lung_surface.vtp" if distance_map_file.exists() and labelmap_file.exists(): return distance_map_file, labelmap_file @@ -207,11 +205,13 @@ def segment_phase(image_file: Path) -> tuple[Path, Path]: image = itk.imread(str(image_file), pixel_type=itk.F) segmentation_result = segmentation_workflow.process( input_image=image, - anatomy_groups=["lung"], + anatomy_groups=[TCIA_4D_LUNG.anatomy_group], + surface_reduction_rate=TCIA_4D_LUNG.surface_reduction_rate, extract_label_surfaces=True, + extract_group_surfaces=False, ) - contour_tools.save_combined_surfaces( - segmentation_result["label_surfaces"], str(surface_file) + surface = ProcessSurfaceMesh.combine_surfaces( + segmentation_result["label_surfaces"] ) whole_body_labelmap = segmentation_result["labelmap"] whole_body_arr = itk.GetArrayViewFromImage(whole_body_labelmap) @@ -220,7 +220,6 @@ def segment_phase(image_file: Path) -> tuple[Path, Path]: ) lung_labelmap.CopyInformation(whole_body_labelmap) itk.imwrite(lung_labelmap, str(labelmap_file), compression=True) - surface = cast(pv.PolyData, pv.read(str(surface_file))) # Rasterize the lung surface into ICON's distance-map representation, # mirroring ``RegisterModelsDistanceMaps._create_masks_from_models`` so # the finetuning inputs match what that class feeds ICON at inference: diff --git a/tutorials/tutorial_04_duke_heart_labelmap_to_vtk.py b/tutorials/tutorial_04_duke_heart_labelmap_to_vtk.py index 8875382c..14b0a291 100644 --- a/tutorials/tutorial_04_duke_heart_labelmap_to_vtk.py +++ b/tutorials/tutorial_04_duke_heart_labelmap_to_vtk.py @@ -3,9 +3,10 @@ Purpose ------- -Turn the Duke-Heart-4DLabelmaps labelmaps into VTK geometry. ``outputs`` -below chooses how much of it to build, because the full pass costs about a -hundred times the short one: +Turn the Duke-Heart-4DLabelmaps labelmaps into VTK geometry with +``WorkflowConvertImageToVTK``, which takes these labelmaps in place of a CT +image. ``outputs`` below chooses how much of it to build, because the full +pass costs about a hundred times the short one: ``"shape_model"`` Each case's reference frame only (``*_ref_labelmap.nii.gz``), and only the @@ -19,31 +20,22 @@ Files written per frame, the per-label ones only under ``"full"``: -- ``_surfaces.vtp`` -- every label's watertight, outward-oriented - surface in one file, via ``ProcessContours.extract_label_surfaces`` on the whole - labelmap. Extracting the labels together is what keeps neighbors touching: - a wall between two of them is contoured from the same field on the same - isotropic grid, so both surfaces carry the same vertices there. Per-cell - ``SegmentationLabelIds`` says which label each triangle came from. -- ``_.vtu`` -- one tetrahedral mesh per structure, six - ``VTK_TETRA`` per isotropic voxel, via ``ProcessContours.extract_tetrahedra``, - then relaxed onto that structure's surface by - ``ProcessContours.trim_tetrahedra_to_surface``. - -The mesh starts as a voxel staircase and ends up bounded by the smooth -surface: the relaxation projects its boundary onto that surface while smoothing -the interior to make room, so no trace of the voxel blocks is left. What -remains between the two geometries is faceting at the element size, logged per -structure in millimeters. - -Both carry the structure's ``ProcessUSDAnatomy`` color, as ``AnatomyColor`` and -as a per-cell ``Color``, so they render the same way as the surfaces -``WorkflowConvertImageToVTK`` writes. - -The whole-heart structure drops the labels ``interior_object_ids`` names in -``parameters_duke_heart_labelmaps.py`` -- the chamber cavities, and the vessels -whose extent varies too much between patients -- which is the same definition of -"heart" that Tutorials 2, 6 and 7 use. +- ``_heart_minus_interior_chambers.vtp`` / ``.vtu`` -- the whole + heart, without the labels ``interior_object_ids`` names in + ``parameters_duke_heart_labelmaps.py``: the chamber cavities, and the vessels + whose extent varies too much between patients. This is the same "heart" + that Tutorials 2, 6 and 7 use. +- ``_surfaces.vtp`` -- every label's watertight surface in one + file, per-cell ``SegmentationLabelIds`` saying which label each triangle came + from. The labels are extracted together, so neighbors share the wall + between them vertex for vertex. +- ``_.vtu`` -- one tetrahedral mesh per label, relaxed onto + that label's surface. + +Every output carries its structure's ``ProcessUSDAnatomy`` color, as +``AnatomyColor`` and as a per-cell ``Color``. What remains between a surface +and its mesh is faceting at the element size, logged per structure in +millimeters. Label names come from ``SegmentHeartSimplewareTrimmedBranches``'s taxonomy, the segmenter that produced these labelmaps. Labels with no taxonomy entry are @@ -57,163 +49,224 @@ # Imports from __future__ import annotations +import functools +import importlib import logging +import multiprocessing +import os +import sys +from concurrent.futures import ProcessPoolExecutor +from concurrent.futures.process import BrokenProcessPool from pathlib import Path -from typing import Optional +from typing import TYPE_CHECKING import itk import numpy as np import pyvista as pv -from parameters_duke_heart_labelmaps import DUKE_HEART -from monai_physio import ( - MONAIPhysioBase, - ProcessContours, - ProcessTests, - SegmentHeartSimplewareTrimmedBranches, -) +if TYPE_CHECKING: + from monai_physio import WorkflowConvertImageToVTK + +CLASS_NAME = "tutorial_04_duke_heart_labelmap_to_vtk" + +# Frames run in at most this many worker processes, each limited to a couple of +# ITK threads: the ITK filters stop scaling past a few threads, so several +# frames at a time use the machine better than one frame on all of it. A large +# frame (439x512x512) peaks near 3.5 GB resident and 9.6 GB committed, so the +# count is also capped by the memory free at start, at WORKER_COMMIT_BYTES each. +MAX_WORKERS = 12 +WORKER_ITK_THREADS = 2 +WORKER_COMMIT_BYTES = 12 * 2**30 + + +@functools.cache +def _workflow(log_level: int) -> WorkflowConvertImageToVTK: + """Build this process's workflow once; a worker reuses it for every frame.""" + from parameters_duke_heart_labelmaps import DUKE_HEART + + from monai_physio import WorkflowConvertImageToVTK + + # The segmenter only names the labels: these labelmaps are its output. + return WorkflowConvertImageToVTK( + DUKE_HEART.segmenter(False, log_level=logging.WARNING), log_level=log_level + ) + + +def surface_to_mesh_displacement( + surface: pv.PolyData, mesh: pv.UnstructuredGrid +) -> float: + """Return the mean distance, in mm, from *surface*'s points to *mesh*.""" + from monai_physio import ProcessMeshKernels + + boundary = mesh.extract_surface(algorithm="dataset_surface") + if boundary.n_points == 0: + return 0.0 + points = np.asarray(surface.points, dtype=np.float64) + kernels = ProcessMeshKernels() + closest = kernels.closest_points_on_surface( + kernels.build_surface_mesh(boundary), points + ) + return float(np.linalg.norm(closest - points, axis=1).mean()) + + +def frame_stem(labelmap_file: Path) -> str: + """Return the name *labelmap_file*'s outputs start with.""" + return labelmap_file.name[: -len("_labelmap.nii.gz")] + + +def convert_frame( + labelmap_file: Path, output_dir: Path, full: bool, log_level: int +) -> list[float]: + """Write one labelmap's surfaces and meshes; the worker's entry point. + + Returns: + The mean surface-to-mesh displacement of every mesh written, in mm. + """ + from parameters_duke_heart_labelmaps import DUKE_HEART + + from monai_physio import WorkflowConvertImageToVTK + + workflow = _workflow(log_level) + whole_heart = DUKE_HEART.whole_heart_name + stem = frame_stem(labelmap_file) + + labelmap = itk.imread(str(labelmap_file)) + result = workflow.process( + labelmap_image=labelmap, + groups=DUKE_HEART.surface_groups(labelmap), + extract_label_surfaces=full, + isotropic_spacing_mm=DUKE_HEART.surface_spacing_mm, + smoothing_iterations=DUKE_HEART.surface_smoothing_iterations, + tetrahedra_element_size_mm=DUKE_HEART.mesh_element_size_mm, + ) + if whole_heart not in result["surfaces"]: + workflow.log_warning("%s: no whole-heart surface; skipping", labelmap_file.name) + return [] + + if full: + WorkflowConvertImageToVTK.save_combined_surfaces( + result["label_surfaces"], str(output_dir / f"{stem}_surfaces.vtp") + ) + meshes = {**result["tetrahedra"], **result["label_tetrahedra"]} + WorkflowConvertImageToVTK.save_surfaces(meshes, str(output_dir), prefix=stem) + + surfaces = {**result["surfaces"], **result["label_surfaces"]} + displacements = [] + for name, mesh in meshes.items(): + displacements.append(surface_to_mesh_displacement(surfaces[name], mesh)) + workflow.log_info( + " %-24s %7d triangles %8d tetrahedra %6.3f mm", + name, + surfaces[name].n_cells, + mesh.n_cells, + displacements[-1], + ) + + # Written last, so an interrupted frame is redone rather than skipped by + # the overwrite check. + WorkflowConvertImageToVTK.save_surfaces( + {whole_heart: result["surfaces"][whole_heart]}, str(output_dir), prefix=stem + ) + return displacements + + +def available_memory_bytes() -> int: + """Return the memory a new process can still commit, in bytes.""" + if sys.platform != "win32": + return os.sysconf("SC_AVPHYS_PAGES") * os.sysconf("SC_PAGE_SIZE") + + import ctypes + + class MemoryStatus(ctypes.Structure): + _fields_ = [ + ("length", ctypes.c_uint32), + ("memory_load", ctypes.c_uint32), + ("total_phys", ctypes.c_uint64), + ("avail_phys", ctypes.c_uint64), + ("total_commit", ctypes.c_uint64), + ("avail_commit", ctypes.c_uint64), + ("total_virtual", ctypes.c_uint64), + ("avail_virtual", ctypes.c_uint64), + ("avail_extended_virtual", ctypes.c_uint64), + ] + + status = MemoryStatus() + status.length = ctypes.sizeof(MemoryStatus) + ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(status)) + return int(min(status.avail_commit, status.avail_phys)) + + +def worker_count(available_bytes: int) -> int: + """Return how many workers *available_bytes* holds at a frame's peak.""" + return max(1, min(MAX_WORKERS, available_bytes // WORKER_COMMIT_BYTES)) + + +def _init_worker() -> None: + itk.MultiThreaderBase.SetGlobalDefaultNumberOfThreads(WORKER_ITK_THREADS) + + +def _warm_up() -> None: + """Import what a frame needs and open the GPU, before any frame arrives.""" + import torch + import warp as wp + + importlib.import_module("monai_physio") + wp.init() + torch.zeros(1, device="cuda:0") + + +def start_workers(count: int) -> ProcessPoolExecutor: + """Spawn *count* worker processes and have each warm itself up. + + Workers are spawned and the parent never touches the GPU, so no CUDA + context exists before they start. Importing ``monai_physio`` takes longer + than a frame, so the caller starts them before it imports it itself and the + two imports overlap. + """ + pool = ProcessPoolExecutor( + max_workers=count, + mp_context=multiprocessing.get_context("spawn"), + initializer=_init_worker, + ) + for _ in range(count): + pool.submit(_warm_up) + return pool + # Only run if this script is not imported as a module if __name__ == "__main__": + workers = start_workers(worker_count(available_memory_bytes())) + + from parameters_duke_heart_labelmaps import DUKE_HEART + + from monai_physio import MONAIPhysioBase, ProcessTests + # Data directory specification - class_name = "tutorial_04_duke_heart_labelmap_to_vtk" + class_name = CLASS_NAME test_mode = ProcessTests.running_as_test() - output_dir = ( - DUKE_HEART.output_directory(test_mode) / "tutorial_04_duke_heart_labelmap" - ) + output_dir = DUKE_HEART.input_directory(test_mode) + data_dir = DUKE_HEART.hold_out_directory(test_mode) # How much to build; see this module's docstring. "shape_model" is what # Tutorial 6 (Duke Heart) consumes and runs in minutes over the cohort; - # "full" also feeds Tutorial 5 (Duke Heart) and takes hours, because it - # contours and meshes every label of all 348 gated frames rather than the - # whole heart of the 29 reference ones. - outputs = "shape_model" + # "full" also feeds Tutorial 5 (Duke Heart) and takes much longer, because + # it contours and meshes every label of all 348 gated frames rather than + # the whole heart of the 29 reference ones. Frames run in parallel worker + # processes, so the time shrinks with the cores and memory available. + outputs = "full" if outputs not in ("shape_model", "full"): raise ValueError(f"outputs must be 'shape_model' or 'full'; got {outputs!r}") - reference_frames_only = outputs == "shape_model" - write_per_label_surfaces = outputs == "full" - - # Taubin smoothing iterations applied to the extracted surfaces (0 disables). - smoothing_iterations = DUKE_HEART.surface_smoothing_iterations - - # Surfaces are contoured on an isotropic grid of this pitch, which also sets - # the triangle count in place of decimation: decimating a label would move - # its vertices off the neighbor it shares them with. - surface_spacing_mm = DUKE_HEART.surface_spacing_mm - - # Element size of the tetrahedral meshes. Isotropic, so the elements do - # not inherit the slice pitch, and small enough that relaxing them onto the - # surface leaves no trace of the voxel staircase. - mesh_element_size_mm = DUKE_HEART.mesh_element_size_mm - - # Labels left out of the whole-heart structure; see the parameters module. - interior_object_ids = DUKE_HEART.interior_object_ids - whole_heart_name = "heart_minus_interior_chambers" + full = outputs == "full" # Set False to keep outputs a previous run already wrote. overwrite = True - if test_mode: - data_dir = DUKE_HEART.data_directory(test_mode) / "Duke-Heart-4DLabelmaps" - else: - data_dir = DUKE_HEART.data_directory(test_mode) / "Duke-Heart-4DLabelmaps" - log_level = logging.INFO reporter = MONAIPhysioBase(class_name=class_name, log_level=log_level) - contour_tools = ProcessContours(log_level=log_level) - taxonomy = SegmentHeartSimplewareTrimmedBranches(log_level=logging.WARNING).taxonomy - label_names = taxonomy.all_labels() - - def mask_from(labelmap_image: itk.Image, keep: np.ndarray) -> itk.Image: - """Return *keep* as a binary mask carrying *labelmap_image*'s geometry.""" - mask = itk.GetImageFromArray(keep.astype(np.uint8)) - mask.CopyInformation(labelmap_image) - return mask - - def annotate( - mesh: pv.DataSet, label_ids: list[int], name: str, source: str - ) -> None: - """Record the originating labels and file on *mesh* in-place. - - ``SegmentationLabelIds`` holds one id for a per-label structure and - every retained id for the whole-heart one; a single id is what - ``ProcessContours.save_combined_surfaces`` needs to tag the merged file's - cells with the structure they came from. The anatomy color is attached - separately, by ``ProcessContours.apply_anatomy_color``. - """ - mesh.field_data["SegmentationLabelIds"] = np.asarray(label_ids, dtype=np.int32) - mesh.field_data["LabelName"] = np.array([name]) - mesh.field_data["SourceLabelmap"] = np.array([source]) - - def surface_to_mesh_displacement( - surface: pv.PolyData, mesh: pv.UnstructuredGrid - ) -> float: - """Return the mean distance, in mm, from *surface*'s points to *mesh*.""" - boundary = mesh.extract_surface(algorithm="dataset_surface") - # On a copy, so the distances are not written into the saved surface. - distances = surface.copy().compute_implicit_distance(boundary) - return float(np.abs(distances["implicit_distance"]).mean()) - - def write_tetrahedra( - mask: itk.Image, - surface: pv.PolyData, - case_output_dir: Path, - stem: str, - name: str, - label_ids: list[int], - source: str, - ) -> Optional[float]: - """Write one structure's VTU mesh, relaxed onto its *surface*. - - Returns: - The mean displacement, in millimeters, between the two geometries, - or ``None`` for a structure too thin to hold an element. - """ - # Resampling to the element size eats a structure thinner than it -- - # the coronary arteries here are a voxel or two across -- so the size - # is halved until the mesh accounts for most of the surface's volume, - # or until it is as fine as the labelmap itself. - finest_spacing = float(np.min(np.asarray(mask.GetSpacing()))) - element_size = mesh_element_size_mm - while True: - # ProcessUSDAnatomy has no override for names like "left_ventricle", - # so the structure's anatomy group is offered as the fallback color. - tet_mesh = contour_tools.extract_tetrahedra( - mask, - element_size_mm=element_size, - anatomy_names=[name, taxonomy.group_for_label(name)], - ) - volume = float(np.sum(tet_mesh.compute_cell_sizes(volume=True)["Volume"])) - if volume > 0.5 * surface.volume or element_size <= finest_spacing: - break - element_size *= 0.5 - reporter.log_debug( - " %s: %.3g mm elements hold %.0f%% of it; halving", - name, - element_size * 2.0, - 100.0 * volume / float(surface.volume), - ) - if tet_mesh.n_cells == 0: - reporter.log_warning(" %-24s thinner than one element; no mesh", name) - return None - tet_mesh = contour_tools.trim_tetrahedra_to_surface(tet_mesh, surface) - annotate(tet_mesh, label_ids, name, source) - tet_mesh.save(case_output_dir / f"{stem}_{name}.vtu") - - displacement = surface_to_mesh_displacement(surface, tet_mesh) - reporter.log_info( - " %-24s %7d triangles %8d tetrahedra %6.3f mm", - name, - surface.n_cells, - tet_mesh.n_cells, - displacement, - ) - return displacement - # Cohort discovery case_dirs = sorted( path for path in data_dir.glob("pm[0-9][0-9][0-9][0-9]") if path.is_dir() @@ -224,124 +277,70 @@ def write_tetrahedra( "See data/README.md for download instructions." ) - # Extraction - labelmap_pattern = ( - "*_ref_labelmap.nii.gz" if reference_frames_only else "*_labelmap.nii.gz" - ) - displacements: list[float] = [] - surface_count = 0 - whole_heart_surface: Optional[pv.PolyData] = None - # Every case writes into this one directory: the frame stems already start # with their case id, so the names stay unique and the readers downstream # group on that prefix rather than on a directory. - case_output_dir = output_dir - case_output_dir.mkdir(parents=True, exist_ok=True) - - for case_dir in case_dirs: - labelmap_files = sorted(case_dir.glob(labelmap_pattern)) - reporter.log_section(f"{case_dir.name}: {len(labelmap_files)} labelmaps") - - for labelmap_file in labelmap_files: - stem = labelmap_file.name[: -len("_labelmap.nii.gz")] - surfaces_file = case_output_dir / f"{stem}_surfaces.vtp" - whole_heart_file = case_output_dir / f"{stem}_{whole_heart_name}.vtp" - # The whole-heart surface is written last, so its presence means - # the frame finished rather than stopped part way. - if not overwrite and whole_heart_file.exists(): - reporter.log_debug("%s: outputs exist, skipping", labelmap_file.name) - continue - - reporter.log_info("%s", labelmap_file.name) - labelmap_image = itk.imread(str(labelmap_file)) - labels = itk.GetArrayViewFromImage(labelmap_image) - present_ids = [int(value) for value in np.unique(labels) if value != 0] - - # The whole heart, hollowed of the chamber cavities. It overlaps - # the per-label surfaces, so it is kept out of their file. - whole_heart_ids = [ - label_id - for label_id in present_ids - if label_id not in interior_object_ids - ] - whole_heart_mask = mask_from( - labelmap_image, np.isin(labels, whole_heart_ids) - ) - # The mask carries one label, so its surface is the only entry; an - # empty mapping means the frame held no heart to contour. - whole_heart_surfaces = contour_tools.extract_label_surfaces( - whole_heart_mask, - isotropic_spacing_mm=surface_spacing_mm, - smoothing_iterations=smoothing_iterations, - ) - if 1 not in whole_heart_surfaces: - reporter.log_warning( - "%s: no whole-heart surface; skipping the frame", - labelmap_file.name, - ) - continue - whole_heart = whole_heart_surfaces[1] - contour_tools.apply_anatomy_color(whole_heart, [whole_heart_name, "heart"]) - annotate(whole_heart, whole_heart_ids, whole_heart_name, labelmap_file.name) - surface_count += 1 - displacement = write_tetrahedra( - whole_heart_mask, - whole_heart, - case_output_dir, - stem, - whole_heart_name, - whole_heart_ids, - labelmap_file.name, - ) - if displacement is not None: - displacements.append(displacement) - - if write_per_label_surfaces: - # Every label at once, so that neighbors share the wall between - # them rather than each contouring its own copy of it. - surfaces = contour_tools.extract_label_surfaces( - labelmap_image, - isotropic_spacing_mm=surface_spacing_mm, - smoothing_iterations=smoothing_iterations, - ) - named_surfaces: dict[str, pv.PolyData] = {} - for label_id, surface in surfaces.items(): - name = label_names.get(label_id, f"label_{label_id}") - contour_tools.apply_anatomy_color( - surface, [name, taxonomy.group_for_label(name)] - ) - annotate(surface, [label_id], name, labelmap_file.name) - named_surfaces[name] = surface - surface_count += 1 - displacement = write_tetrahedra( - mask_from(labelmap_image, labels == label_id), - surface, - case_output_dir, - stem, - name, - [label_id], - labelmap_file.name, - ) - if displacement is not None: - displacements.append(displacement) - ProcessContours.save_combined_surfaces( - named_surfaces, str(surfaces_file) - ) - - # Written last, so an interrupted frame is redone rather than - # skipped by the check above. - whole_heart.save(whole_heart_file) - if whole_heart_surface is None: - whole_heart_surface = whole_heart + output_dir.mkdir(parents=True, exist_ok=True) - mean_displacement = float(np.mean(displacements)) if displacements else 0.0 - if displacements: - reporter.log_section( - f"Wrote {surface_count} surfaces and {len(displacements)} tetrahedral " - f"meshes, mean surface-to-mesh displacement {mean_displacement:.3f} mm" + def whole_heart_file(labelmap_file: Path) -> Path: + return ( + output_dir + / f"{frame_stem(labelmap_file)}_{DUKE_HEART.whole_heart_name}.vtp" ) - else: - reporter.log_section(f"Wrote {surface_count} surfaces") + + labelmap_pattern = "*_labelmap.nii.gz" if full else "*_ref_labelmap.nii.gz" + labelmap_files = [ + labelmap_file + for case_dir in case_dirs + for labelmap_file in sorted(case_dir.glob(labelmap_pattern)) + ] + pending_files = [ + labelmap_file + for labelmap_file in labelmap_files + if overwrite or not whole_heart_file(labelmap_file).exists() + ] + reporter.log_section( + f"{len(pending_files)} of {len(labelmap_files)} labelmaps to convert" + ) + + # Extraction. A worker that dies takes the whole pool with it, so the + # frames it left unfinished are retried once in a fresh pool of half the + # size; the frames that did finish keep their results. + convert = functools.partial( + convert_frame, output_dir=output_dir, full=full, log_level=log_level + ) + frame_displacements: dict[Path, list[float]] = {} + remaining = list(pending_files) + for attempt in (1, 2): + futures = {} + try: + with workers: + futures = {path: workers.submit(convert, path) for path in remaining} + for path, future in futures.items(): + frame_displacements[path] = future.result() + break + except BrokenProcessPool: + if attempt == 2: + raise + for path, future in futures.items(): + if future.done() and not future.cancelled() and not future.exception(): + frame_displacements[path] = future.result() + remaining = [path for path in remaining if path not in frame_displacements] + retry_count = max(1, worker_count(available_memory_bytes()) // 2) + reporter.log_warning( + "A worker process died; retrying %d frame(s) with %d worker(s)", + len(remaining), + retry_count, + ) + workers = start_workers(retry_count) + displacements = [ + value for path in pending_files for value in frame_displacements[path] + ] + mean_displacement = float(np.mean(displacements)) if displacements else 0.0 + reporter.log_section( + f"Wrote {len(displacements)} tetrahedral meshes, mean surface-to-mesh " + f"displacement {mean_displacement:.3f} mm" + ) # Testing tt = ProcessTests( @@ -351,11 +350,11 @@ def write_tetrahedra( ) screenshots: list[Path] = [] - if whole_heart_surface is not None: + if labelmap_files and whole_heart_file(labelmap_files[0]).exists(): screenshots.append( tt.save_screenshot_mesh( - whole_heart_surface, - f"{whole_heart_name}.png", + pv.read(str(whole_heart_file(labelmap_files[0]))), + f"{DUKE_HEART.whole_heart_name}.png", camera_position="iso", color="lightblue", opacity=0.85, diff --git a/tutorials/tutorial_04_heart_ct_to_vtk.py b/tutorials/tutorial_04_heart_ct_to_vtk.py index 460edac4..6ee97a94 100644 --- a/tutorials/tutorial_04_heart_ct_to_vtk.py +++ b/tutorials/tutorial_04_heart_ct_to_vtk.py @@ -3,9 +3,10 @@ Purpose ------- -Segment one 3D CT frame into anatomical groups and save a combined VTK -surface file. The output can be inspected directly in PyVista or used as -input for Tutorial 5. +Segment one 3D CT frame into anatomical groups and save its VTK surfaces: one +file per anatomy group, one per structure, and every structure combined in one +file. The output can be inspected directly in PyVista or used as input for +Tutorial 5. Data Required ------------- @@ -20,15 +21,11 @@ from pathlib import Path import itk -import pyvista as pv from parameters_heart_ct_kcl import HEART_CT_KCL from monai_physio import ( - ProcessContours, + ProcessSurfaceMesh, ProcessTests, - SegmentAnatomyBase, - SegmentChestTotalSegmentatorWithContrast, - SegmentHeartSimpleware, WorkflowConvertImageToVTK, ) @@ -48,14 +45,6 @@ output_dir = HEART_CT_KCL.output_directory(test_mode) / "tutorial_04_heart" - # In addition to the combined surface file always saved below, also - # save one VTP per anatomy group (e.g. heart.vtp, lung.vtp) and/or one - # VTP per individual anatomical structure (e.g. left_ventricle.vtp). - save_group_surfaces = True - save_label_surfaces = True - - use_simpleware = False - if test_mode: data_dir = HEART_CT_KCL.data_directory(test_mode) / "slicer_heart_small" else: @@ -65,15 +54,6 @@ log_level = logging.INFO - if use_simpleware: - segmentation_method: SegmentAnatomyBase = SegmentHeartSimpleware( - log_level=log_level - ) - else: - segmentation_method = SegmentChestTotalSegmentatorWithContrast( - log_level=log_level - ) - # Directory setup and data reading output_dir.mkdir(parents=True, exist_ok=True) @@ -85,49 +65,37 @@ + "See data/README.md for download instructions." ) - ct_file = frame_files[0] - ct_image = itk.imread(str(ct_file)) - - # Workflow initialization + ct_image = itk.imread(str(frame_files[0])) + # Workflow execution + # + # surface_reduction_rate remeshes each surface to fewer triangles. workflow = WorkflowConvertImageToVTK( - segmentation_method=segmentation_method, + segmentation_method=HEART_CT_KCL.segmenter(test_mode, log_level=log_level), log_level=log_level, ) - - # Workflow execution - # - # surface_reduction_rate decimates each exported VTP surface. result = workflow.process( input_image=ct_image, surface_reduction_rate=HEART_CT_KCL.surface_reduction_rate, - extract_label_surfaces=save_label_surfaces, + extract_label_surfaces=True, ) # Result saving # - # Merging the per-structure surfaces, rather than the per-group ones, lets - # the combined file carry a per-cell SegmentationLabelIds array: structure - # identity survives the merge, so the file can still be split per structure - # downstream. Per-group surfaces are contoured from a merged binary mask - # and have no per-cell identity to record. - combined_input = ( - result["label_surfaces"] if save_label_surfaces else result["surfaces"] - ) + # The combined file merges the per-structure surfaces, so each cell keeps + # the id of the structure it came from and the file can be split per + # structure downstream. surface_file = Path( - ProcessContours.save_combined_surfaces( - combined_input, - str(output_dir / "patient_surfaces.vtp"), + WorkflowConvertImageToVTK.save_combined_surfaces( + result["label_surfaces"], str(output_dir / "patient_surfaces.vtp") ) ) - if save_group_surfaces: - ProcessContours.save_surfaces( - result["surfaces"], str(output_dir), prefix="patient" - ) - if save_label_surfaces: - ProcessContours.save_surfaces( - result["label_surfaces"], str(output_dir), prefix="patient" - ) + WorkflowConvertImageToVTK.save_surfaces( + result["surfaces"], str(output_dir), prefix="patient" + ) + WorkflowConvertImageToVTK.save_surfaces( + result["label_surfaces"], str(output_dir), prefix="patient" + ) labelmap_file = output_dir / "patient_labelmap.mha" itk.imwrite(result["labelmap"], str(labelmap_file), compression=True) @@ -138,8 +106,7 @@ log_level=log_level, ) - screenshots: list[Path] = [] - screenshots.append( + screenshots: list[Path] = [ tt.save_screenshot_image_slice( ct_image, "segmentation_overlay.png", @@ -150,16 +117,11 @@ vmax=600, overlay_mask=result["labelmap"], ) - ) - - surfaces = [ - surface for surface in result["surfaces"].values() if surface is not None ] - if surfaces: - combined_surface = pv.merge(surfaces) if len(surfaces) > 1 else surfaces[0] + if result["surfaces"]: screenshots.append( tt.save_screenshot_mesh( - combined_surface, + ProcessSurfaceMesh.combine_surfaces(result["surfaces"]), "vtk_surfaces.png", camera_position="iso", color="lightblue", diff --git a/tutorials/tutorial_04_lung_ct_to_vtk.py b/tutorials/tutorial_04_lung_ct_to_vtk.py index ff9b9043..4f7585b2 100644 --- a/tutorials/tutorial_04_lung_ct_to_vtk.py +++ b/tutorials/tutorial_04_lung_ct_to_vtk.py @@ -3,9 +3,10 @@ Purpose ------- -Segment one 3D CT frame into anatomical groups and save a combined VTK -surface file. The output can be inspected directly in PyVista or used as -input for Tutorial 5. +Segment one 3D CT frame into anatomical groups and save its VTK surfaces: one +file per anatomy group, one per structure, and every structure combined in one +file. The output can be inspected directly in PyVista or used as input for +Tutorial 5. Data Required ------------- @@ -20,18 +21,17 @@ from pathlib import Path import itk -import pyvista as pv from parameters_tcia_4d_lung import TCIA_4D_LUNG from monai_physio import ( - ProcessContours, + ProcessSurfaceMesh, ProcessTests, WorkflowConvertImageToVTK, ) # Only run if this script is not imported as a module -# nnUNetv2 (used by TotalSegmentator inside several workflows) spawns a +# nnUNetv2 (used by the segmenters inside several workflows) spawns a # multiprocessing.Pool. On Windows the spawn start method re-imports this # script in each child; without the __name__ == "__main__" guard around # top-level work, that re-import fires the segmenter again and Python's @@ -39,17 +39,11 @@ if __name__ == "__main__": # Data directory specification - project_name = "tutorial_04_lung" + class_name = "tutorial_04_lung" test_mode = ProcessTests.running_as_test() - output_dir = TCIA_4D_LUNG.output_directory(test_mode) / project_name - - # In addition to the combined surface file always saved below, also - # save one VTP per anatomy group (e.g. heart.vtp, lung.vtp) and/or one - # VTP per individual anatomical structure (e.g. left_ventricle.vtp). - save_group_surfaces = True - save_label_surfaces = True + output_dir = TCIA_4D_LUNG.output_directory(test_mode) / class_name data_dir = TCIA_4D_LUNG.input_directory(test_mode) @@ -57,9 +51,8 @@ log_level = logging.INFO - segmentation_method = TCIA_4D_LUNG.segmenter(test_mode, log_level=log_level) - # Directory setup and data reading + output_dir.mkdir(parents=True, exist_ok=True) if not frame_files: @@ -69,64 +62,51 @@ + "See data/README.md for download instructions." ) - ct_file = frame_files[0] - ct_image = itk.imread(str(ct_file)) - - # Workflow initialization + ct_image = itk.imread(str(frame_files[0])) + # Workflow execution + # + # surface_reduction_rate remeshes each surface to fewer triangles. workflow = WorkflowConvertImageToVTK( - segmentation_method=segmentation_method, + segmentation_method=TCIA_4D_LUNG.segmenter(test_mode, log_level=log_level), log_level=log_level, ) - - # Workflow execution - # - # surface_reduction_rate decimates each exported VTP surface. result = workflow.process( input_image=ct_image, surface_reduction_rate=TCIA_4D_LUNG.surface_reduction_rate, - extract_label_surfaces=save_label_surfaces, + extract_label_surfaces=True, ) # Result saving # - # Merging the per-structure surfaces, rather than the per-group ones, lets - # the combined file carry a per-cell SegmentationLabelIds array: structure - # identity survives the merge, so the file can still be split per structure - # downstream. Per-group surfaces are contoured from a merged binary mask - # and have no per-cell identity to record. - combined_input = ( - result["label_surfaces"] if save_label_surfaces else result["surfaces"] - ) + # The combined file merges the per-structure surfaces, so each cell keeps + # the id of the structure it came from and the file can be split per + # structure downstream. surface_file = Path( - ProcessContours.save_combined_surfaces( - combined_input, - str(output_dir / "patient_surfaces.vtp"), + WorkflowConvertImageToVTK.save_combined_surfaces( + result["label_surfaces"], str(output_dir / "patient_surfaces.vtp") ) ) - if save_group_surfaces: - ProcessContours.save_surfaces( - result["surfaces"], str(output_dir), prefix="patient" - ) - if save_label_surfaces: - ProcessContours.save_surfaces( - result["label_surfaces"], str(output_dir), prefix="patient" - ) + WorkflowConvertImageToVTK.save_surfaces( + result["surfaces"], str(output_dir), prefix="patient" + ) + WorkflowConvertImageToVTK.save_surfaces( + result["label_surfaces"], str(output_dir), prefix="patient" + ) labelmap_file = output_dir / "patient_labelmap.mha" itk.imwrite(result["labelmap"], str(labelmap_file), compression=True) # Testing tt = ProcessTests( - class_name=project_name, + class_name=class_name, results_dir=output_dir, log_level=log_level, ) - screenshots: list[Path] = [] - screenshots.append( + screenshots: list[Path] = [ tt.save_screenshot_image_slice( ct_image, - f"{project_name}_segmentation_overlay.png", + f"{class_name}_segmentation_overlay.png", axis=0, slice_fraction=0.5, colormap="gray", @@ -134,17 +114,12 @@ vmax=600, overlay_mask=result["labelmap"], ) - ) - - surfaces = [ - surface for surface in result["surfaces"].values() if surface is not None ] - if surfaces: - combined_surface = pv.merge(surfaces) if len(surfaces) > 1 else surfaces[0] + if result["surfaces"]: screenshots.append( tt.save_screenshot_mesh( - combined_surface, - f"{project_name}_vtk_surfaces.png", + ProcessSurfaceMesh.combine_surfaces(result["surfaces"]), + f"{class_name}_vtk_surfaces.png", camera_position="iso", color="lightblue", opacity=0.85, diff --git a/tutorials/tutorial_05_heart_vtk_to_usd.py b/tutorials/tutorial_05_heart_vtk_to_usd.py index e3ca4f7d..5f7af15d 100644 --- a/tutorials/tutorial_05_heart_vtk_to_usd.py +++ b/tutorials/tutorial_05_heart_vtk_to_usd.py @@ -23,6 +23,7 @@ import logging from pathlib import Path +from typing import cast import pyvista as pv from parameters_base import ParametersBase @@ -72,7 +73,7 @@ meshes: list[pv.PolyData] = [] structure_names: list[str] = [] for vtk_file in sorted(input_dir.glob("patient_*.vtp")): - mesh = pv.read(str(vtk_file)) + mesh = cast(pv.PolyData, pv.read(str(vtk_file))) label_names = mesh.field_data.get("SegmentationLabelNames") if label_names is None or len(label_names) != 1: continue diff --git a/tutorials/tutorial_06_duke_heart_create_statistical_model.py b/tutorials/tutorial_06_duke_heart_create_statistical_model.py index 8662a86f..15499795 100644 --- a/tutorials/tutorial_06_duke_heart_create_statistical_model.py +++ b/tutorials/tutorial_06_duke_heart_create_statistical_model.py @@ -48,7 +48,7 @@ from parameters_duke_heart_labelmaps import DUKE_HEART from monai_physio import ( - ProcessContours, + ProcessSurfaceMesh, ProcessTests, WorkflowCreateMeanSurface, WorkflowCreateStatisticalModel, @@ -98,7 +98,7 @@ output_dir.mkdir(parents=True, exist_ok=True) - contour_tools = ProcessContours(log_level=log_level) + contour_tools = ProcessSurfaceMesh(log_level=log_level) # One reference-frame surface per case, less the held-out one: Tutorial 7 # fits this model to that case, so the model must not have seen it. diff --git a/tutorials/tutorial_06_heart_create_statistical_model.py b/tutorials/tutorial_06_heart_create_statistical_model.py index da2061d6..1910e98e 100644 --- a/tutorials/tutorial_06_heart_create_statistical_model.py +++ b/tutorials/tutorial_06_heart_create_statistical_model.py @@ -27,7 +27,7 @@ from parameters_heart_ct_kcl import HEART_CT_KCL from monai_physio import ( - ProcessContours, + ProcessSurfaceMesh, ProcessTests, WorkflowCreateStatisticalModel, ) @@ -108,7 +108,7 @@ "See data/README.md for download instructions." ) - contour_tools = ProcessContours(log_level=log_level) + contour_tools = ProcessSurfaceMesh(log_level=log_level) def read_model_surface(path: Path) -> pv.DataSet: """Read a mesh, reduced to ``model_points`` when a budget is set.""" diff --git a/tutorials/tutorial_06_lung_create_statistical_model.py b/tutorials/tutorial_06_lung_create_statistical_model.py index ccc80924..e12bb69d 100644 --- a/tutorials/tutorial_06_lung_create_statistical_model.py +++ b/tutorials/tutorial_06_lung_create_statistical_model.py @@ -44,7 +44,7 @@ from parameters_tcia_4d_lung import TCIA_4D_LUNG from monai_physio import ( - ProcessContours, + ProcessSurfaceMesh, ProcessTests, WorkflowConvertImageToVTK, WorkflowCreateMeanSurface, @@ -110,7 +110,7 @@ segmentation_method=segmentation_method, log_level=log_level ) - contour_tools = ProcessContours(log_level=log_level) + contour_tools = ProcessSurfaceMesh(log_level=log_level) # Tutorial 7 fits this model to the held-out study, so the model must not # have seen it. That study lives in another dataset, so this drops nothing @@ -121,25 +121,26 @@ if case_dir.name != TCIA_4D_LUNG.hold_out_case and (case_dir / f"{case_dir.name}_g070.nii.gz").exists() ] - sample_surfaces = [] + sample_surfaces: list[pv.DataSet] = [] for sample_image_file in sample_image_files: # ``.stem`` only strips ``.gz``, leaving a stray ``.nii`` in the name, # since these are ``.nii.gz`` (TCIA) rather than ``.mha`` (DIR-Lab). sample_image_stem = sample_image_file.name.removesuffix(".nii.gz") sample_surface_file = output_dir / f"{sample_image_stem}.vtp" + # Cached for Tutorials 8 and 15, which read these instead of segmenting. if not sample_surface_file.exists(): - sample_image = itk.imread(str(sample_image_file)) result = workflow_method.process( - input_image=sample_image, - anatomy_groups=["lung"], + input_image=itk.imread(str(sample_image_file)), + anatomy_groups=[TCIA_4D_LUNG.anatomy_group], + surface_reduction_rate=TCIA_4D_LUNG.surface_reduction_rate, extract_label_surfaces=True, + extract_group_surfaces=False, + ) + WorkflowConvertImageToVTK.save_combined_surfaces( + result["label_surfaces"], str(sample_surface_file) ) - surfaces = result["label_surfaces"] - contour_tools.save_combined_surfaces(surfaces, str(sample_surface_file)) - - sample_labelmap = result["labelmap"] sample_labelmap_file = output_dir / f"{sample_image_stem}_labelmap.nii.gz" - itk.imwrite(sample_labelmap, str(sample_labelmap_file), compression=True) + itk.imwrite(result["labelmap"], str(sample_labelmap_file), compression=True) sample_surface = cast(pv.PolyData, pv.read(str(sample_surface_file))) if model_points: sample_surface = contour_tools.remesh_and_smooth_surface( diff --git a/tutorials/tutorial_07_duke_heart_fit_statistical_model_to_patient.py b/tutorials/tutorial_07_duke_heart_fit_statistical_model_to_patient.py index 1509dd49..37cf2f18 100644 --- a/tutorials/tutorial_07_duke_heart_fit_statistical_model_to_patient.py +++ b/tutorials/tutorial_07_duke_heart_fit_statistical_model_to_patient.py @@ -9,11 +9,11 @@ patient. Duke-Heart-4DLabelmaps ships labelmaps rather than CT, so there is nothing to -segment: the patient surface is contoured straight from the case's labelmap, -with the same labels dropped that Tutorial 4 drops, so the patient and the model -describe the same structure. Nothing being segmented also means there -is no patient intensity image; the workflow then rasterizes its own reference -grid from the patient surface. +segment: the patient surface is the whole-heart surface Tutorial 4 writes for +the case's labelmap (extracted the same way when it is absent), so the patient +and the model describe the same structure. Nothing being segmented also means +there is no patient intensity image; the workflow then rasterizes its own +reference grid from the patient surface. The patient is ``ParametersDukeHeartLabelmaps.hold_out_case``, which Tutorial 6 leaves out of the population it builds the model from, so this fit measures @@ -40,12 +40,10 @@ from typing import Any, cast import itk -import numpy as np import pyvista as pv from parameters_duke_heart_labelmaps import DUKE_HEART from monai_physio import ( - ProcessContours, ProcessTests, WorkflowFitStatisticalModelToPatient, ) @@ -60,7 +58,6 @@ test_mode = ProcessTests.running_as_test() output_dir = DUKE_HEART.output_directory(test_mode) / project_name - weights_dir = DUKE_HEART.weights_directory(test_mode) baselines_dir = repo_root / "tests" / "baselines" @@ -80,28 +77,15 @@ # and the model describe the same structure. interior_object_ids = DUKE_HEART.interior_object_ids - # Contouring grid, shared with Tutorial 4 so the patient surface carries the - # same level of detail as the model's training surfaces. - surface_spacing_mm = DUKE_HEART.surface_spacing_mm - smoothing_iterations = DUKE_HEART.surface_smoothing_iterations - # Distance-map weights finetuned by # tutorial_02_duke_heart_distancemap_finetune_icon.py. The heart has its # own finetuning run rather than reusing the lung one's: the heart # registration mask is far tighter, so heart distance maps saturate over a # shorter radius and do not share an intensity distribution with lung ones. - icon_weights_path = ( - weights_dir - / "icon_duke_heart_distancemap" - / "icon_duke_heart_distancemap_model" - / "checkpoints" - / "network_weights_final.trch" - ) + icon_weights_path = DUKE_HEART.distancemap_icon_weights_file(test_mode) log_level = logging.INFO - contour_tools = ProcessContours(log_level=log_level) - # Directory setup and data reading output_dir.mkdir(parents=True, exist_ok=True) @@ -133,25 +117,11 @@ ) patient_labelmap = itk.imread(str(patient_labelmap_files[0])) - # The patient surface, contoured once and cached: the whole heart minus its - # chamber cavities, which is the structure the model describes. - heart_surface_file = output_dir / f"{project_name}_heart_surface.vtp" - if not heart_surface_file.exists(): - labels = itk.GetArrayViewFromImage(patient_labelmap) - heart_ids = [ - int(value) - for value in np.unique(labels) - if value != 0 and int(value) not in interior_object_ids - ] - heart_mask = itk.GetImageFromArray(np.isin(labels, heart_ids).astype(np.uint8)) - heart_mask.CopyInformation(patient_labelmap) - heart_surface = contour_tools.extract_label_surfaces( - heart_mask, - isotropic_spacing_mm=surface_spacing_mm, - smoothing_iterations=smoothing_iterations, - )[1] - heart_surface.save(str(heart_surface_file)) - heart_surface = cast(pv.PolyData, pv.read(str(heart_surface_file))) + # The patient surface: the whole heart minus its chamber cavities, which is + # the structure the model describes. + heart_surface = DUKE_HEART.whole_heart_surface( + patient_labelmap_files[0], test_mode, log_level + ) # Workflow initialization diff --git a/tutorials/tutorial_07_heart_fit_statistical_model_to_patient.py b/tutorials/tutorial_07_heart_fit_statistical_model_to_patient.py index e48f2e18..014b6295 100644 --- a/tutorials/tutorial_07_heart_fit_statistical_model_to_patient.py +++ b/tutorials/tutorial_07_heart_fit_statistical_model_to_patient.py @@ -27,8 +27,8 @@ from parameters_heart_ct_kcl import HEART_CT_KCL from monai_physio import ( - ProcessContours, ProcessTests, + WorkflowConvertImageToVTK, WorkflowFitStatisticalModelToPatient, ) @@ -76,6 +76,9 @@ log_level = logging.INFO segmentation_method = HEART_CT_KCL.segmenter(test_mode, log_level=log_level) + segmentation_workflow = WorkflowConvertImageToVTK( + segmentation_method=segmentation_method, log_level=log_level + ) # Directory setup and data reading @@ -100,45 +103,25 @@ ) patient_image = itk.imread(str(patient_image_file)) - if not (output_dir / f"{project_name}_patient_image.nii.gz").exists(): - itk.imwrite( - patient_image, - output_dir / f"{project_name}_patient_image.nii.gz", - compression=True, - ) - - segmentation_result = segmentation_method.segment(patient_image) - patient_labelmap = segmentation_result["labelmap"] - itk.imwrite( - patient_labelmap, - output_dir / f"{project_name}_patient_labelmap.nii.gz", - compression=True, - ) - - heart_labelmap = segmentation_result["heart"] - itk.imwrite( - heart_labelmap, - output_dir / f"{project_name}_heart_labelmap.nii.gz", - compression=True, - ) - - contour_tools = ProcessContours() - heart_surface = contour_tools.extract_contours( - labelmap_image=heart_labelmap, + heart_surface_file = output_dir / f"{project_name}_heart_surface.vtp" + heart_labelmap_file = output_dir / f"{project_name}_heart_labelmap.nii.gz" + if not (heart_surface_file.exists() and heart_labelmap_file.exists()): + segmentation_result = segmentation_workflow.process( + input_image=patient_image, + anatomy_groups=[HEART_CT_KCL.anatomy_group], surface_reduction_rate=HEART_CT_KCL.surface_reduction_rate, ) - heart_surface.save(output_dir / f"{project_name}_heart_surface.vtp") - - else: - patient_labelmap = itk.imread( - output_dir / f"{project_name}_patient_labelmap.nii.gz" - ) - heart_labelmap = itk.imread( - output_dir / f"{project_name}_heart_labelmap.nii.gz" - ) - heart_surface = cast( - pv.PolyData, pv.read(output_dir / f"{project_name}_heart_surface.vtp") + segmentation_result["surfaces"][HEART_CT_KCL.anatomy_group].save( + str(heart_surface_file) ) + # Only the heart's labels, so the fit's distance map measures to the + # heart and not to the rest of the chest. + heart_labelmap = segmentation_method.create_anatomy_group_labelmaps( + segmentation_result["labelmap"] + )[HEART_CT_KCL.anatomy_group] + itk.imwrite(heart_labelmap, str(heart_labelmap_file), compression=True) + heart_surface = cast(pv.PolyData, pv.read(str(heart_surface_file))) + heart_labelmap = itk.imread(str(heart_labelmap_file)) # Workflow initialization diff --git a/tutorials/tutorial_07_lung_fit_statistical_model_to_patient.py b/tutorials/tutorial_07_lung_fit_statistical_model_to_patient.py index 1aeb0118..c546ecd0 100644 --- a/tutorials/tutorial_07_lung_fit_statistical_model_to_patient.py +++ b/tutorials/tutorial_07_lung_fit_statistical_model_to_patient.py @@ -35,7 +35,7 @@ from parameters_tcia_4d_lung import TCIA_4D_LUNG from monai_physio import ( - ProcessContours, + ProcessSurfaceMesh, ProcessTests, WorkflowConvertImageToVTK, WorkflowFitStatisticalModelToPatient, @@ -91,7 +91,6 @@ segmentation_workflow = WorkflowConvertImageToVTK( segmentation_method=segmentation_method, log_level=log_level ) - contour_tools = ProcessContours(log_level=log_level) # Directory setup and data reading @@ -116,23 +115,17 @@ ) patient_image = itk.imread(str(patient_image_file)) - lung_surface_file = output_dir / f"{project_name}_lung_surface.vtp" - lung_labelmap_file = output_dir / f"{project_name}_lung_labelmap.nii.gz" - if not (lung_surface_file.exists() and lung_labelmap_file.exists()): - segmentation_result = segmentation_workflow.process( - input_image=patient_image, - anatomy_groups=["lung"], - surface_reduction_rate=TCIA_4D_LUNG.surface_reduction_rate, - extract_label_surfaces=True, - ) - contour_tools.save_combined_surfaces( - segmentation_result["label_surfaces"], str(lung_surface_file) - ) - itk.imwrite( - segmentation_result["labelmap"], str(lung_labelmap_file), compression=True - ) - lung_surface = cast(pv.PolyData, pv.read(str(lung_surface_file))) - lung_labelmap = itk.imread(str(lung_labelmap_file)) + segmentation_result = segmentation_workflow.process( + input_image=patient_image, + anatomy_groups=[TCIA_4D_LUNG.anatomy_group], + surface_reduction_rate=TCIA_4D_LUNG.surface_reduction_rate, + extract_label_surfaces=True, + extract_group_surfaces=False, + ) + lung_surface = ProcessSurfaceMesh.combine_surfaces( + segmentation_result["label_surfaces"] + ) + lung_labelmap = segmentation_result["labelmap"] # Workflow initialization @@ -195,14 +188,6 @@ str(output_dir / f"{project_name}_template_surface_registered.vtp") ) - registered_pca_surface = workflow.pca_template_model_surface - assert registered_pca_surface is not None, ( - "pca_template_model_surface must be set after process()" - ) - registered_pca_surface.save( - str(output_dir / f"{project_name}_pca_surface_registered.vtp") - ) - # Testing ProcessTests( class_name=project_name, diff --git a/tutorials/tutorial_08_duke_heart_fit_model_to_4d_patients.py b/tutorials/tutorial_08_duke_heart_fit_model_to_4d_patients.py index e711c634..ad2bbd81 100644 --- a/tutorials/tutorial_08_duke_heart_fit_model_to_4d_patients.py +++ b/tutorials/tutorial_08_duke_heart_fit_model_to_4d_patients.py @@ -50,7 +50,6 @@ * ``_ssm_pca_surface.vtp`` - PCA template before the final warp * ``_ssm_surface.vtp`` - fitted reference-frame SSM surface * ``_ssm_surface.vtp`` - SSM warped to that gated frame - * ``_heart_surface.vtp`` - contoured frame surface, cached """ # Imports @@ -62,19 +61,15 @@ from typing import Any, cast import itk -import numpy as np import pyvista as pv from parameters_duke_heart_labelmaps import DUKE_HEART from monai_physio import ( - ProcessContours, + MONAIPhysioBase, ProcessTests, - RegisterModelsDistanceMaps, WorkflowFitStatisticalModelToPatient, ) -# Structure name Tutorial 4 (Duke Heart) writes its whole-heart surfaces under. -WHOLE_HEART_NAME = "heart_minus_interior_chambers" LABELMAP_SUFFIX = "_labelmap.nii.gz" # Only run if this script is not imported as a module @@ -92,14 +87,11 @@ test_mode = ProcessTests.running_as_test() output_dir = DUKE_HEART.output_directory(test_mode) / "tutorial_08_duke_heart" - weights_dir = DUKE_HEART.weights_directory(test_mode) baselines_dir = repo_root / "tests" / "baselines" # The gated labelmaps, one directory per case. data_dir = DUKE_HEART.hold_out_directory(test_mode) - # Tutorial 4's surfaces, read when its "full" pass wrote the frame. - tutorial_04_dir = DUKE_HEART.input_directory(test_mode) # PCA model + mean surface produced by Tutorial 6 (Duke Heart). pca_model_file = DUKE_HEART.pca_model_file(test_mode) @@ -111,30 +103,14 @@ # 6 and 7 drop, so the frames and the model describe the same structure. interior_object_ids = DUKE_HEART.interior_object_ids - # Contouring grid, shared with Tutorial 4 so every surface here carries the - # same level of detail as the model's training surfaces. - surface_spacing_mm = DUKE_HEART.surface_spacing_mm - smoothing_iterations = DUKE_HEART.surface_smoothing_iterations - - # Pitch of the grid the phase distance maps are rasterized on. Coarser than - # the contouring pitch: it carries a distance field, not a boundary. - registration_spacing_mm = 1.0 - # Distance-map weights finetuned by # tutorial_02_duke_heart_distancemap_finetune_icon.py, used both by the # labelmap-to-labelmap stage of the SSM fit and by the phase registrations. - icon_weights_path = ( - weights_dir - / "icon_duke_heart_distancemap" - / "icon_duke_heart_distancemap_model" - / "checkpoints" - / "network_weights_final.trch" - ) + icon_weights_path = DUKE_HEART.distancemap_icon_weights_file(test_mode) log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) # Directory setup and data reading @@ -155,12 +131,12 @@ # the tutorial still runs, on the stock uniGradICON weights. use_finetuned_weights = icon_weights_path.exists() if use_finetuned_weights: - logger.info( + logger.log_info( "Registering with finetuned distance-map ICON weights: %s", icon_weights_path, ) else: - logger.warning( + logger.log_warning( "Finetuned distance-map ICON weights not found at %s; registering " "with the stock uniGradICON weights. Run " "tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py to " @@ -177,42 +153,6 @@ "See data/Duke-Heart-4DLabelmaps/README.md." ) - contour_tools = ProcessContours(log_level=log_level) - - def heart_surface_for(labelmap_file: Path, case_output_dir: Path) -> pv.PolyData: - """Return one frame's whole heart, minus its chamber cavities. - - Tutorial 4's ``"full"`` pass contours this surface for every gated - frame, so its output is read when present. Otherwise the surface is - contoured here and cached beside this tutorial's other outputs, so a - re-run pays for it once. - """ - stem = labelmap_file.name[: -len(LABELMAP_SUFFIX)] - tutorial_04_file = tutorial_04_dir / f"{stem}_{WHOLE_HEART_NAME}.vtp" - if tutorial_04_file.exists(): - return cast(pv.PolyData, pv.read(str(tutorial_04_file))) - - surface_file = case_output_dir / f"{stem}_heart_surface.vtp" - if not surface_file.exists(): - labelmap = itk.imread(str(labelmap_file)) - labels = itk.GetArrayViewFromImage(labelmap) - heart_ids = [ - int(value) - for value in np.unique(labels) - if value != 0 and int(value) not in interior_object_ids - ] - heart_mask = itk.GetImageFromArray( - np.isin(labels, heart_ids).astype(np.uint8) - ) - heart_mask.CopyInformation(labelmap) - heart_surface = contour_tools.extract_label_surfaces( - heart_mask, - isotropic_spacing_mm=surface_spacing_mm, - smoothing_iterations=smoothing_iterations, - )[1] - heart_surface.save(str(surface_file)) - return cast(pv.PolyData, pv.read(str(surface_file))) - tutorial_results: dict[str, Any] = {"cases": {}, "screenshots": []} for case_dir in case_dirs: @@ -222,20 +162,28 @@ def heart_surface_for(labelmap_file: Path, case_output_dir: Path) -> pv.PolyData path for path in frame_files if path.name.endswith(f"_ref{LABELMAP_SUFFIX}") ] if not reference_files: - logger.warning("Skipping %s: no *_ref_labelmap.nii.gz frame", case_id) + logger.log_warning("Skipping %s: no *_ref_labelmap.nii.gz frame", case_id) continue reference_file = reference_files[0] + frame_files = DUKE_HEART.frames_to_register( + frame_files, + reference_file, + test_mode, + scored=case_id == DUKE_HEART.hold_out_case, + ) - logger.info("%s", "=" * 48) - logger.info("Processing case %s: %d gated frames", case_id, len(frame_files)) - logger.info("%s", "=" * 48) + logger.log_section( + "Processing case %s: %d gated frames", case_id, len(frame_files), width=48 + ) case_output_dir = output_dir / case_id case_output_dir.mkdir(parents=True, exist_ok=True) # Step 1: contour the reference frame's heart. reference_labelmap = itk.imread(str(reference_file)) - reference_surface = heart_surface_for(reference_file, case_output_dir) + reference_surface = DUKE_HEART.whole_heart_surface( + reference_file, test_mode, log_level + ) # Step 2: fit the statistical model to the reference frame. This data # carries no intensity image, so the workflow rasterizes its own @@ -285,39 +233,26 @@ def heart_surface_for(labelmap_file: Path, case_output_dir: Path) -> pv.PolyData fitted_reference_mesh_file = case_output_dir / f"{case_id}_ssm_surface.vtp" fitted_reference_mesh.save(str(fitted_reference_mesh_file)) - # Step 3: warp the fitted SSM surface onto every gated frame. One grid - # is built around the reference frame's heart and reused by every frame, - # so the whole case is registered in a common space; its buffer holds - # the frames the heart moves into. - registration_grid = contour_tools.create_reference_image( - mesh=reference_surface, - spatial_resolution=registration_spacing_mm, - buffer_factor=0.25, - ptype=itk.F, - ) + # Step 3: warp the fitted SSM surface onto every gated frame, all on one + # grid built around the reference frame's heart. + registration_grid = DUKE_HEART.frame_registration_grid(reference_surface) phase_outputs = [] for frame_file in frame_files: stem = frame_file.name[: -len(LABELMAP_SUFFIX)] if frame_file == reference_file: # The fit already placed the SSM on this frame. - logger.info("Case %s: reference frame %s", case_id, stem) + logger.log_info("Case %s: reference frame %s", case_id, stem) phase_surface = fitted_reference_mesh else: - logger.info("Case %s: warping to frame %s", case_id, stem) - registrar = RegisterModelsDistanceMaps( - moving_model=fitted_reference_mesh, - fixed_model=heart_surface_for(frame_file, case_output_dir), - reference_image=registration_grid, - distance_squared_max=DUKE_HEART.distancemap_squared_max, - mask_dilation_mm=DUKE_HEART.mask_dilation_mm, - log_level=log_level, + logger.log_info("Case %s: warping to frame %s", case_id, stem) + phase_surface = DUKE_HEART.register_to_frame( + fitted_reference_mesh, + DUKE_HEART.whole_heart_surface(frame_file, test_mode, log_level), + registration_grid, + test_mode, + log_level, ) - if use_finetuned_weights: - registrar.set_icon_weights_path(str(icon_weights_path)) - phase_surface = registrar.register(transform_type="Deformable")[ - "registered_model" - ] surface_file = case_output_dir / f"{stem}_ssm_surface.vtp" phase_surface.save(str(surface_file)) diff --git a/tutorials/tutorial_08_lung_fit_model_to_4d_patients.py b/tutorials/tutorial_08_lung_fit_model_to_4d_patients.py index f828bf6d..cafe95e0 100644 --- a/tutorials/tutorial_08_lung_fit_model_to_4d_patients.py +++ b/tutorials/tutorial_08_lung_fit_model_to_4d_patients.py @@ -57,7 +57,8 @@ from parameters_tcia_4d_lung import TCIA_4D_LUNG from monai_physio import ( - ProcessContours, + MONAIPhysioBase, + ProcessSurfaceMesh, ProcessTests, ProcessTransforms, WorkflowConvertImageToVTK, @@ -112,8 +113,7 @@ log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) # Directory setup and data reading @@ -133,12 +133,12 @@ # the tutorial still runs, on the stock uniGradICON weights. use_finetuned_distancemap_weights = icon_distancemap_weights_path.exists() if use_finetuned_distancemap_weights: - logger.info( + logger.log_info( "Fitting the SSM with finetuned distance-map ICON weights: %s", icon_distancemap_weights_path, ) else: - logger.warning( + logger.log_warning( "Finetuned distance-map ICON weights not found at %s; fitting the SSM " "with the stock uniGradICON weights. Run " "tutorials/tutorial_02_lung_distancemap_finetune_icon.py to create " @@ -159,7 +159,6 @@ segmentation_workflow = WorkflowConvertImageToVTK( segmentation_method=segmentation_method, log_level=log_level ) - contour_tools = ProcessContours(log_level=log_level) transform_tools = ProcessTransforms(log_level=log_level) tutorial_results: dict[str, Any] = {"cases": {}, "screenshots": []} @@ -168,9 +167,9 @@ # The case directory name (e.g. "107a_HM10395") is the case id; TCIA # nests each case's phases under one, unlike DIR-Lab's flat layout. case_id = reference_file.parent.name - logger.info("%s", "=" * 48) - logger.info("Processing case %s", case_id) - logger.info("%s", "=" * 48) + logger.log_info("%s", "=" * 48) + logger.log_info("Processing case %s", case_id) + logger.log_info("%s", "=" * 48) case_output_dir = output_dir / case_id case_output_dir.mkdir(parents=True, exist_ok=True) @@ -188,25 +187,21 @@ # present. lung_surface_file = tutorial_06_dir / f"{reference_stem}.vtp" lung_labelmap_file = tutorial_06_dir / f"{reference_stem}_labelmap.nii.gz" - if not (lung_surface_file.exists() and lung_labelmap_file.exists()): - lung_surface_file = case_output_dir / f"{reference_stem}.vtp" - lung_labelmap_file = case_output_dir / f"{reference_stem}_labelmap.nii.gz" + if lung_surface_file.exists() and lung_labelmap_file.exists(): + lung_surface = cast(pv.PolyData, pv.read(str(lung_surface_file))) + lung_labelmap = itk.imread(str(lung_labelmap_file)) + else: segmentation_result = segmentation_workflow.process( input_image=reference_image, - anatomy_groups=["lung"], + anatomy_groups=[TCIA_4D_LUNG.anatomy_group], surface_reduction_rate=TCIA_4D_LUNG.surface_reduction_rate, extract_label_surfaces=True, + extract_group_surfaces=False, ) - contour_tools.save_combined_surfaces( - segmentation_result["label_surfaces"], str(lung_surface_file) + lung_surface = ProcessSurfaceMesh.combine_surfaces( + segmentation_result["label_surfaces"] ) - itk.imwrite( - segmentation_result["labelmap"], - str(lung_labelmap_file), - compression=True, - ) - lung_surface = cast(pv.PolyData, pv.read(str(lung_surface_file))) - lung_labelmap = itk.imread(str(lung_labelmap_file)) + lung_labelmap = segmentation_result["labelmap"] # Step 2: fit the statistical model to the reference phase fit_workflow = WorkflowFitStatisticalModelToPatient( @@ -277,7 +272,7 @@ # Step 4: warp the fitted SSM surface to every respiratory phase phase_outputs = [] for phase_index, phase_id in enumerate(phase_ids): - logger.info("Case %s: warping to phase %s", case_id, phase_id) + logger.log_info("Case %s: warping to phase %s", case_id, phase_id) itk.imwrite( reg_result["reconstructed_images"][phase_index], diff --git a/tutorials/tutorial_09_duke_heart_train_physicsnemo_mgn.py b/tutorials/tutorial_09_duke_heart_train_physicsnemo_mgn.py index 35a1ac4b..fd4ccf52 100644 --- a/tutorials/tutorial_09_duke_heart_train_physicsnemo_mgn.py +++ b/tutorials/tutorial_09_duke_heart_train_physicsnemo_mgn.py @@ -75,16 +75,17 @@ # Imports from __future__ import annotations -import json import logging from pathlib import Path from typing import Any, Optional, cast -import numpy as np import pyvista as pv from parameters_duke_heart_labelmaps import DUKE_HEART from monai_physio import ( + EvaluateMovementDukeHeart, + MONAIPhysioBase, + ProcessPhysicsNemo, ProcessTests, TrainPhysicsNeMoMGN, WorkflowInferMovement, @@ -92,49 +93,25 @@ WorkflowTrainPhysicsNeMo, ) -# Point-data array the tutorial writes its targets into and the manifests name. -TARGET_ARRAY = "displacement" - # Gated frames carry a ``g{PPP}`` tag naming their percentage of the R-R # interval; this is what a per-frame SSM surface is matched and staged by. PHASE_SURFACE_PATTERN = "*_g[0-9][0-9][0-9]_*_ssm_surface.vtp" -def _cardiac_stage_from_filename(surface_file: Path) -> float: - """Extract the normalized cardiac stage [0, 1] from a ``g{PPP}`` filename stem.""" - for part in surface_file.stem.split("_"): - if part.startswith("g") and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse cardiac gate from filename: {surface_file}") - - -def _write_target_mesh( - phase_file: Path, ref_points: np.ndarray, targets_dir: Path -) -> Path: - """Write one frame's training target and return the mesh path. - - The target is the per-vertex displacement from the case's reference surface, - stored as the ``TARGET_ARRAY`` point-data array on a copy of the frame - surface. Any other per-vertex quantity could be written here instead -- the - training workflow reads whatever array the manifest names. - """ - phase_mesh = pv.read(str(phase_file)) - phase_points = np.asarray(phase_mesh.points, dtype=np.float32) - phase_mesh.point_data[TARGET_ARRAY] = phase_points - ref_points - target_path = targets_dir / f"{phase_file.stem}_target.vtp" - phase_mesh.save(str(target_path)) - return target_path - - def _write_case_manifest( - case_dir: Path, manifests_dir: Path, logger: logging.Logger + case_dir: Path, + manifests_dir: Path, + logger: MONAIPhysioBase, + evaluator: EvaluateMovementDukeHeart, ) -> Optional[Path]: """Write a per-case manifest JSON; return its path (or None if incomplete). A case needs a reference SSM surface, a PCA coefficient file, and at least two gated-frame surfaces. A case that is missing any of them is skipped with the reason logged, so a half-finished Tutorial 8 run is distinguishable - from one that never ran. + from one that never ran. The training target of each frame is its + per-vertex displacement from the reference surface, and *evaluator* stages + it by its ``g{PPP}`` tag. """ case_id = case_dir.name fitted_reference_mesh_file = case_dir / f"{case_id}_ssm_surface.vtp" @@ -148,28 +125,17 @@ def _write_case_manifest( if len(phase_files) < 2: missing.append(f"at least 2 frame surfaces (found {len(phase_files)})") if missing: - logger.warning("Skipping %s: missing %s", case_id, "; ".join(missing)) + logger.log_warning("Skipping %s: missing %s", case_id, "; ".join(missing)) return None - manifests_dir.mkdir(parents=True, exist_ok=True) - ref_points = np.asarray( - pv.read(str(fitted_reference_mesh_file)).points, dtype=np.float32 + manifest_path: Path = ProcessPhysicsNemo.write_manifest( + subject_id=case_id, + fitted_reference_mesh=fitted_reference_mesh_file, + pca_coefficients=pca_file, + phase_meshes=phase_files, + stages=[evaluator.stage_from_filename(path) for path in phase_files], + output_directory=manifests_dir, ) - manifest = { - "subject_id": case_id, - "fitted_reference_mesh": str(fitted_reference_mesh_file), - "pca_coefficients": str(pca_file), - "target_array": TARGET_ARRAY, - "phases": [ - { - "mesh": str(_write_target_mesh(phase_file, ref_points, manifests_dir)), - "stage": _cardiac_stage_from_filename(phase_file), - } - for phase_file in phase_files - ], - } - manifest_path = manifests_dir / f"{case_id}_manifest.json" - manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") return manifest_path @@ -223,8 +189,8 @@ def _write_case_manifest( log_level = logging.INFO class_name = "tutorial_09_duke_heart_train_physicsnemo_mgn" - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) + evaluator = EvaluateMovementDukeHeart(log_level=log_level) # In test mode, train for a couple of epochs to keep the run tractable. if test_mode: @@ -241,7 +207,7 @@ def _write_case_manifest( for case_dir in sorted( p for p in data_dir.glob("pm[0-9][0-9][0-9][0-9]") if p.is_dir() ): - manifest_path = _write_case_manifest(case_dir, manifests_dir, logger) + manifest_path = _write_case_manifest(case_dir, manifests_dir, logger, evaluator) if manifest_path is not None: manifests[case_dir.name] = manifest_path @@ -266,7 +232,7 @@ def _write_case_manifest( for case_id, manifest_path in manifests.items() if case_id not in test_cases and case_id not in val_cases ] - logger.info( + logger.log_info( "Case split - train: %d, val: %d, test: %d", len(train_manifests), len(val_manifests), @@ -310,7 +276,7 @@ def _write_case_manifest( "cases": {}, } for case_id in test_cases: - logger.info("Evaluating held-out case %s", case_id) + logger.log_info("Evaluating held-out case %s", case_id) tutorial_results["cases"][case_id] = displacement_workflow.process( manifests[case_id], output_directory=eval_dir / case_id, diff --git a/tutorials/tutorial_09_lung_train_physicsnemo_mgn.py b/tutorials/tutorial_09_lung_train_physicsnemo_mgn.py index 3f1cb492..4795d9fd 100644 --- a/tutorials/tutorial_09_lung_train_physicsnemo_mgn.py +++ b/tutorials/tutorial_09_lung_train_physicsnemo_mgn.py @@ -89,16 +89,17 @@ # Imports from __future__ import annotations -import json import logging from pathlib import Path from typing import Any, Optional, cast -import numpy as np import pyvista as pv from parameters_tcia_4d_lung import TCIA_4D_LUNG from monai_physio import ( + EvaluateMovementLung, + MONAIPhysioBase, + ProcessPhysicsNemo, ProcessTests, TrainPhysicsNeMoMGN, WorkflowInferMovement, @@ -106,45 +107,21 @@ WorkflowTrainPhysicsNeMo, ) -# Point-data array the tutorial writes its targets into and the manifests name. -TARGET_ARRAY = "displacement" - - -def _respiratory_stage_from_filename(surface_file: Path) -> float: - """Extract the normalized respiratory stage [0, 1] from a ``g{PPP}`` filename stem.""" - for part in surface_file.stem.split("_"): - if part.startswith("g") and len(part) == 4 and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse respiratory phase from filename: {surface_file}") - - -def _write_target_mesh( - phase_file: Path, ref_points: np.ndarray, targets_dir: Path -) -> Path: - """Write one phase's training target and return the mesh path. - - The target is the per-vertex displacement from the case's reference surface, - stored as the ``TARGET_ARRAY`` point-data array on a copy of the phase - surface. Any other per-vertex quantity could be written here instead - the - training workflow reads whatever array the manifest names. - """ - phase_mesh = pv.read(str(phase_file)) - phase_points = np.asarray(phase_mesh.points, dtype=np.float32) - phase_mesh.point_data[TARGET_ARRAY] = phase_points - ref_points - target_path = targets_dir / f"{phase_file.stem}_target.vtp" - phase_mesh.save(str(target_path)) - return target_path - def _write_case_manifest( - case_dir: Path, manifests_dir: Path, logger: logging.Logger + case_dir: Path, + manifests_dir: Path, + cohort: EvaluateMovementLung, + logger: MONAIPhysioBase, ) -> Optional[Path]: """Write a per-case manifest JSON; return its path (or None if incomplete). A case needs a reference SSM surface, a PCA coefficient file, and at least two respiratory-phase surfaces. A case that is missing any of them is skipped with the reason logged, so a half-finished Tutorial 8 run is distinguishable - from one that never ran. + from one that never ran. Each phase's training target is its per-vertex + displacement from the reference surface, and its stage is read from its + ``g{PPP}`` filename by *cohort*. """ case_id = case_dir.name fitted_reference_mesh_file = case_dir / f"{case_id}_ssm_surface.vtp" @@ -158,29 +135,17 @@ def _write_case_manifest( if len(phase_files) < 2: missing.append(f"at least 2 phase surfaces (found {len(phase_files)})") if missing: - logger.warning("Skipping %s: missing %s", case_id, "; ".join(missing)) + logger.log_warning("Skipping %s: missing %s", case_id, "; ".join(missing)) return None - manifests_dir.mkdir(parents=True, exist_ok=True) - ref_points = np.asarray( - pv.read(str(fitted_reference_mesh_file)).points, dtype=np.float32 + return ProcessPhysicsNemo.write_manifest( + subject_id=case_id, + fitted_reference_mesh=fitted_reference_mesh_file, + pca_coefficients=pca_file, + phase_meshes=phase_files, + stages=[cohort.stage_from_filename(path) for path in phase_files], + output_directory=manifests_dir, ) - manifest = { - "subject_id": case_id, - "fitted_reference_mesh": str(fitted_reference_mesh_file), - "pca_coefficients": str(pca_file), - "target_array": TARGET_ARRAY, - "phases": [ - { - "mesh": str(_write_target_mesh(phase_file, ref_points, manifests_dir)), - "stage": _respiratory_stage_from_filename(phase_file), - } - for phase_file in phase_files - ], - } - manifest_path = manifests_dir / f"{case_id}_manifest.json" - manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") - return manifest_path # Only run if this script is not imported as a module @@ -232,8 +197,7 @@ def _write_case_manifest( log_level = logging.INFO class_name = "tutorial_09_lung_train_physicsnemo_mgn" - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) # In test mode, train for a couple of epochs to keep the run tractable. if test_mode: @@ -246,9 +210,10 @@ def _write_case_manifest( ) # Step 1: build one manifest per valid case and partition into splits. + cohort = EvaluateMovementLung(log_level=log_level) manifests: dict[str, Path] = {} for case_dir in sorted(p for p in data_dir.glob("*_HM10395") if p.is_dir()): - manifest_path = _write_case_manifest(case_dir, manifests_dir, logger) + manifest_path = _write_case_manifest(case_dir, manifests_dir, cohort, logger) if manifest_path is not None: manifests[case_dir.name] = manifest_path @@ -273,7 +238,7 @@ def _write_case_manifest( for case_id, manifest_path in manifests.items() if case_id not in test_cases and case_id not in val_cases ] - logger.info( + logger.log_info( "Case split - train: %d, val: %d, test: %d", len(train_manifests), len(val_manifests), @@ -320,7 +285,7 @@ def _write_case_manifest( "cases": {}, } for case_id in test_cases: - logger.info("Evaluating held-out case %s", case_id) + logger.log_info("Evaluating held-out case %s", case_id) tutorial_results["cases"][case_id] = displacement_workflow.process( manifests[case_id], output_directory=output_dir / "eval_mgn" / case_id, diff --git a/tutorials/tutorial_10_duke_heart_infer_physicsnemo_mgn.py b/tutorials/tutorial_10_duke_heart_infer_physicsnemo_mgn.py index fe33def0..18088794 100644 --- a/tutorials/tutorial_10_duke_heart_infer_physicsnemo_mgn.py +++ b/tutorials/tutorial_10_duke_heart_infer_physicsnemo_mgn.py @@ -60,6 +60,8 @@ from parameters_duke_heart_labelmaps import DUKE_HEART from monai_physio import ( + EvaluateMovementDukeHeart, + MONAIPhysioBase, ProcessTests, WorkflowInferMovement, WorkflowInferPhysicsNeMo, @@ -71,14 +73,6 @@ LABELMAP_SUFFIX = "_labelmap.nii.gz" -def _cardiac_stage_from_filename(surface_file: Path) -> float: - """Extract the normalized cardiac stage [0, 1] from a ``g{PPP}`` filename stem.""" - for part in surface_file.stem.split("_"): - if part.startswith("g") and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse cardiac gate from filename: {surface_file}") - - # Only run if this script is not imported as a module # PhysicsNeMo and torch spawn worker processes. On Windows the spawn start @@ -109,8 +103,7 @@ def _cardiac_stage_from_filename(surface_file: Path) -> float: log_level = logging.INFO class_name = "tutorial_10_duke_heart_infer_physicsnemo_mgn" - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) labelmap_dir = DUKE_HEART.hold_out_directory(test_mode) / case_id @@ -148,8 +141,9 @@ def _cardiac_stage_from_filename(surface_file: Path) -> float: # Step 1: read every gated frame of the case and its ground-truth surface, # in gate order. - stages = [_cardiac_stage_from_filename(f) for f in phase_files] - logger.info("Case %s: predicting %d gated frames", case_id, len(stages)) + evaluator = EvaluateMovementDukeHeart(log_level=log_level) + stages = [evaluator.stage_from_filename(f) for f in phase_files] + logger.log_info("Case %s: predicting %d gated frames", case_id, len(stages)) # Step 2 and 3: predict the whole cycle, warp the reference frame through # each stage's deformation, and write the animated USD. The SSM is one diff --git a/tutorials/tutorial_10_lung_infer_physicsnemo_mgn.py b/tutorials/tutorial_10_lung_infer_physicsnemo_mgn.py index b3eaf4a5..24ae2fa8 100644 --- a/tutorials/tutorial_10_lung_infer_physicsnemo_mgn.py +++ b/tutorials/tutorial_10_lung_infer_physicsnemo_mgn.py @@ -58,20 +58,13 @@ from parameters_tcia_4d_lung import TCIA_4D_LUNG from monai_physio import ( + EvaluateMovementLung, + MONAIPhysioBase, ProcessTests, WorkflowInferMovement, WorkflowInferPhysicsNeMo, ) - -def _respiratory_stage_from_filename(surface_file: Path) -> float: - """Extract the normalized respiratory stage [0, 1] from a ``g{PPP}`` filename stem.""" - for part in surface_file.stem.split("_"): - if part.startswith("g") and len(part) == 4 and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse respiratory phase from filename: {surface_file}") - - # Only run if this script is not imported as a module # PhysicsNeMo and torch spawn worker processes. On Windows the spawn start @@ -106,8 +99,7 @@ def _respiratory_stage_from_filename(surface_file: Path) -> float: log_level = logging.INFO class_name = "tutorial_10_lung_infer_physicsnemo_mgn" - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) # Directory setup and data reading @@ -148,8 +140,9 @@ def _respiratory_stage_from_filename(surface_file: Path) -> float: # Step 1: read every respiratory phase of the case and its ground-truth # surface, in phase order. - stages = [_respiratory_stage_from_filename(f) for f in phase_files] - logger.info("Case %s: predicting %d respiratory phases", case_id, len(stages)) + cohort = EvaluateMovementLung(reference_phase=reference_phase, log_level=log_level) + stages = [cohort.stage_from_filename(f) for f in phase_files] + logger.log_info("Case %s: predicting %d respiratory phases", case_id, len(stages)) # Step 2 and 3: predict the whole cycle, warp the reference CT through each # stage's deformation, and write the animated USD. The network predicts diff --git a/tutorials/tutorial_11_duke_heart_evaluate_physicsnemo.py b/tutorials/tutorial_11_duke_heart_evaluate_physicsnemo.py index a08025a7..d8bcf576 100644 --- a/tutorials/tutorial_11_duke_heart_evaluate_physicsnemo.py +++ b/tutorials/tutorial_11_duke_heart_evaluate_physicsnemo.py @@ -69,6 +69,7 @@ from monai_physio import ( EvaluateMovementDukeHeart, + MONAIPhysioBase, ProcessTests, WorkflowEvaluateMovement, WorkflowInferMovement, @@ -126,8 +127,7 @@ ) log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) labelmap_dir = DUKE_HEART.hold_out_directory(test_mode) / case_id @@ -179,11 +179,11 @@ ) # Step 3: the report and the CSV are written by the workflow. - logger.info("Report: %s", result["report_file"]) - logger.info("Metrics: %s", result["csv_file"]) + logger.log_info("Report: %s", result["report_file"]) + logger.log_info("Metrics: %s", result["csv_file"]) if result["displacement_data_file"] is not None: - logger.info("Displacements: %s", result["displacement_data_file"]) - logger.info( + logger.log_info("Displacements: %s", result["displacement_data_file"]) + logger.log_info( "Displacement error: rms=%.3f mm 95th=%.3f mm max=%.3f mm", result["displacement_rms_mm"], result["displacement_95th_mm"], diff --git a/tutorials/tutorial_11_lung_evaluate_physicsnemo.py b/tutorials/tutorial_11_lung_evaluate_physicsnemo.py index f1896079..647771c1 100644 --- a/tutorials/tutorial_11_lung_evaluate_physicsnemo.py +++ b/tutorials/tutorial_11_lung_evaluate_physicsnemo.py @@ -70,6 +70,7 @@ from monai_physio import ( EvaluateMovementLung, + MONAIPhysioBase, ProcessTests, WorkflowEvaluateMovement, WorkflowInferMovement, @@ -127,8 +128,7 @@ ground_truth_dir = output_dir / "ground_truth" log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) data_dir = TCIA_4D_LUNG.cases_directory(test_mode) / case_id @@ -184,11 +184,11 @@ ) # Step 3: the report and the CSV are written by the workflow. - logger.info("Report: %s", result["report_file"]) - logger.info("Metrics: %s", result["csv_file"]) + logger.log_info("Report: %s", result["report_file"]) + logger.log_info("Metrics: %s", result["csv_file"]) if result["displacement_data_file"] is not None: - logger.info("Displacements: %s", result["displacement_data_file"]) - logger.info( + logger.log_info("Displacements: %s", result["displacement_data_file"]) + logger.log_info( "Displacement error: rms=%.3f mm 95th=%.3f mm max=%.3f mm", result["displacement_rms_mm"], result["displacement_95th_mm"], diff --git a/tutorials/tutorial_12_duke_heart_end_to_end_inference.py b/tutorials/tutorial_12_duke_heart_end_to_end_inference.py index 7839b10e..4527f8a6 100644 --- a/tutorials/tutorial_12_duke_heart_end_to_end_inference.py +++ b/tutorials/tutorial_12_duke_heart_end_to_end_inference.py @@ -70,13 +70,14 @@ from typing import Any, Optional, cast import itk -import numpy as np import pyvista as pv from parameters_duke_heart_labelmaps import DUKE_HEART from monai_physio import ( - ProcessContours, + EvaluateMovementDukeHeart, + MONAIPhysioBase, ProcessTests, + WorkflowConvertImageToVTK, WorkflowFitStatisticalModelToPatient, WorkflowInferMovement, WorkflowInferPhysicsNeMo, @@ -85,14 +86,6 @@ LABELMAP_SUFFIX = "_labelmap.nii.gz" -def _cardiac_stage_from_filename(labelmap_file: Path) -> float: - """Extract the normalized cardiac stage [0, 1] from a ``g{PPP}`` filename stem.""" - for part in labelmap_file.name.split("_"): - if part.startswith("g") and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse cardiac gate from filename: {labelmap_file}") - - def _record_step(times_s: dict[str, float], step: str, started: float) -> float: """Store the seconds elapsed since ``started`` under ``step``; return a new mark.""" now = time.perf_counter() @@ -116,8 +109,6 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: case_id = DUKE_HEART.hold_out_case test_mode = ProcessTests.running_as_test() - # Keep a test run out of the directories a full run reads and writes. - weights_dir = DUKE_HEART.weights_directory(test_mode) # PCA model + mean surface produced by Tutorial 6 (Duke Heart). pca_model_file = DUKE_HEART.pca_model_file(test_mode) @@ -130,13 +121,7 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: # Distance-map weights finetuned by # tutorial_02_duke_heart_distancemap_finetune_icon.py, used by the # labelmap-to-labelmap stage of the SSM fit. - icon_weights_path = ( - weights_dir - / "icon_duke_heart_distancemap" - / "icon_duke_heart_distancemap_model" - / "checkpoints" - / "network_weights_final.trch" - ) + icon_weights_path = DUKE_HEART.distancemap_icon_weights_file(test_mode) # Gaussian sigma, in mm, that spreads the predicted surface displacements # into the continuous field the labelmap is resampled through. @@ -147,8 +132,7 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: ) log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) # Wall-clock per step. Every run starts from an empty output directory, so # these are the cost of computing each step, not of re-reading it. @@ -203,7 +187,7 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: # the tutorial still runs, on the stock uniGradICON weights. use_finetuned_weights = icon_weights_path.exists() if not use_finetuned_weights: - logger.warning( + logger.log_warning( "Finetuned distance-map ICON weights not found at %s; fitting the SSM " "with the stock uniGradICON weights. Run " "tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py to " @@ -212,30 +196,26 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: ) # Step 1: the acquisition says which stages there are to predict. - stages = [_cardiac_stage_from_filename(path) for path in frame_files] + evaluator = EvaluateMovementDukeHeart(log_level=log_level) + stages = [evaluator.stage_from_filename(path) for path in frame_files] reference_labelmap = itk.imread(str(reference_file)) - logger.info("Case %s: %d gated frames", case_id, len(stages)) + logger.log_info("Case %s: %d gated frames", case_id, len(stages)) step_start = _record_step(step_times_s, "read_inputs", step_start) # Step 2: contour the reference frame's heart, minus the cavities and the - # vessels the shape model leaves out, on the pitch Tutorial 4 contoured the - # model's training surfaces at. - heart_surface_file = output_dir / f"{case_id}_heart_surface.vtp" - contour_tools = ProcessContours(log_level=log_level) - labels = itk.GetArrayViewFromImage(reference_labelmap) - wall_ids = [ - int(value) - for value in np.unique(labels) - if value != 0 and int(value) not in DUKE_HEART.interior_object_ids - ] - heart_mask = itk.GetImageFromArray(np.isin(labels, wall_ids).astype(np.uint8)) - heart_mask.CopyInformation(reference_labelmap) - heart_surface = contour_tools.extract_label_surfaces( - heart_mask, + # vessels the shape model leaves out, the way Tutorial 4 contoured the + # model's training surfaces. Extracted here rather than through + # DUKE_HEART.whole_heart_surface, which would read Tutorial 4's file and so + # leave this step's cost out of the from-scratch runtimes. + heart_surface = WorkflowConvertImageToVTK( + DUKE_HEART.segmenter(test_mode, log_level), log_level=log_level + ).process( + labelmap_image=reference_labelmap, + groups=DUKE_HEART.surface_groups(reference_labelmap), isotropic_spacing_mm=DUKE_HEART.surface_spacing_mm, smoothing_iterations=DUKE_HEART.surface_smoothing_iterations, - )[1] - heart_surface.save(str(heart_surface_file)) + )["surfaces"][DUKE_HEART.whole_heart_name] + heart_surface.save(str(output_dir / f"{case_id}_heart_surface.vtp")) step_start = _record_step(step_times_s, "contour_reference", step_start) # Step 3: fit the shape model to the reference frame. The coefficients are @@ -273,7 +253,7 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: # only, so the model *is* a surface here: only the .vtp is written. fitted_reference_mesh_file = output_dir / f"{case_id}_ssm_surface.vtp" fit_result["fitted_reference_mesh"].save(str(fitted_reference_mesh_file)) - logger.info("Fitted the heart model to %s", reference_file.name) + logger.log_info("Fitted the heart model to %s", reference_file.name) step_start = _record_step(step_times_s, "fit_shape_model", step_start) # Step 4: predict the whole cycle, warp the reference frame's labelmap @@ -300,7 +280,7 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: anatomy_type="heart", separate_by_connectivity=False, ) - logger.info("USD: %s", infer_result["usd_file"]) + logger.log_info("USD: %s", infer_result["usd_file"]) step_start = _record_step(step_times_s, "predict_and_warp", step_start) tutorial_results: dict[str, Any] = dict(infer_result) @@ -340,10 +320,10 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: [case_id, step, f"{seconds:.3f}"] for step, seconds in step_times_s.items() ) - logger.info("Runtime, seconds") + logger.log_info("Runtime, seconds") for step, seconds in step_times_s.items(): - logger.info(" %-18s %8.1f", step, seconds) - logger.info("Runtime CSV: %s", runtime_file) + logger.log_info(" %-18s %8.1f", step, seconds) + logger.log_info("Runtime CSV: %s", runtime_file) tutorial_results["step_times_s"] = step_times_s tutorial_results["runtime_file"] = runtime_file diff --git a/tutorials/tutorial_12_lung_end_to_end_inference.py b/tutorials/tutorial_12_lung_end_to_end_inference.py index 393f15bd..c0df6434 100644 --- a/tutorials/tutorial_12_lung_end_to_end_inference.py +++ b/tutorials/tutorial_12_lung_end_to_end_inference.py @@ -48,7 +48,7 @@ The directory is deleted and rebuilt on every run: nothing is reused, so the runtimes below are the cost of the whole pipeline from scratch. - * ``_g070.vtp``, ``_g070_labelmap.nii.gz`` - reference segmentation + * ``_g070_labelmap.nii.gz`` - reference segmentation * ``_ssm_pca_coefficients.json`` - this patient's shape parameters * ``_ssm_surface.vtp`` - the model fitted to the reference phase * ``_ssm_pca_coefficients_s{TTT}_pred.vtp`` - predicted surface per stage @@ -73,7 +73,9 @@ from parameters_tcia_4d_lung import TCIA_4D_LUNG from monai_physio import ( - ProcessContours, + EvaluateMovementLung, + MONAIPhysioBase, + ProcessSurfaceMesh, ProcessTests, WorkflowConvertImageToVTK, WorkflowFitStatisticalModelToPatient, @@ -82,18 +84,6 @@ ) -def _respiratory_stage_from_filename(image_file: Path) -> float: - """Extract the normalized respiratory stage [0, 1] from a ``g{PPP}`` filename. - - ``.stem`` only strips ``.gz``, leaving a stray ``.nii`` in the name, since - these are ``.nii.gz`` (TCIA) rather than ``.mha`` (DIR-Lab). - """ - for part in image_file.name.removesuffix(".nii.gz").split("_"): - if part.startswith("g") and len(part) == 4 and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse respiratory phase from filename: {image_file}") - - def _record_step(times_s: dict[str, float], step: str, started: float) -> float: """Store the seconds elapsed since ``started`` under ``step``; return a new mark.""" now = time.perf_counter() @@ -151,8 +141,7 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: output_dir = TCIA_4D_LUNG.output_directory(test_mode) / "tutorial_12_lung" / case_id log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) # Wall-clock per step. Every run starts from an empty output directory, so # these are the cost of computing each step, not of re-reading it. @@ -201,7 +190,7 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: # the tutorial still runs, on the stock uniGradICON weights. use_finetuned_distancemap_weights = icon_distancemap_weights_path.exists() if not use_finetuned_distancemap_weights: - logger.warning( + logger.log_warning( "Finetuned distance-map ICON weights not found at %s; fitting the SSM " "with the stock uniGradICON weights. Run " "tutorials/tutorial_02_lung_distancemap_finetune_icon.py to create " @@ -210,9 +199,10 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: ) # Step 1: the acquisition says which stages there are to predict. - stages = [_respiratory_stage_from_filename(path) for path in frame_files] + cohort = EvaluateMovementLung(reference_phase=reference_phase, log_level=log_level) + stages = [cohort.stage_from_filename(path) for path in frame_files] reference_image = itk.imread(str(reference_file)) - logger.info("Case %s: %d respiratory phases", case_id, len(stages)) + logger.log_info("Case %s: %d respiratory phases", case_id, len(stages)) step_start = _record_step(step_times_s, "read_inputs", step_start) # Step 2: segment the lungs in the reference phase. This is the segmenter the @@ -221,10 +211,8 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: # ``.stem`` only strips ``.gz``, leaving a stray ``.nii`` in the name, since # these are ``.nii.gz`` (TCIA) rather than ``.mha`` (DIR-Lab). reference_stem = reference_file.name.removesuffix(".nii.gz") - lung_surface_file = output_dir / f"{reference_stem}.vtp" lung_labelmap_file = output_dir / f"{reference_stem}_labelmap.nii.gz" - logger.info("Segmenting the reference phase %s", reference_file.name) - contour_tools = ProcessContours(log_level=log_level) + logger.log_info("Segmenting the reference phase %s", reference_file.name) segmentation_method = TCIA_4D_LUNG.segmenter(test_mode, log_level=log_level) segmentation_result = WorkflowConvertImageToVTK( segmentation_method=segmentation_method, @@ -234,15 +222,13 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: anatomy_groups=[TCIA_4D_LUNG.anatomy_group], surface_reduction_rate=TCIA_4D_LUNG.surface_reduction_rate, extract_label_surfaces=True, + extract_group_surfaces=False, ) - contour_tools.save_combined_surfaces( - segmentation_result["label_surfaces"], str(lung_surface_file) + lung_surface = ProcessSurfaceMesh.combine_surfaces( + segmentation_result["label_surfaces"] ) lung_labelmap = segmentation_result["labelmap"] itk.imwrite(lung_labelmap, str(lung_labelmap_file), compression=True) - # Read back rather than combining in memory: save_combined_surfaces is what - # merges the per-label surfaces into the one surface the fit is given. - lung_surface = cast(pv.PolyData, pv.read(str(lung_surface_file))) step_start = _record_step(step_times_s, "segment_reference", step_start) # Step 3: fit the shape model to the reference phase. The coefficients are @@ -280,7 +266,7 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: # model *is* a surface here: only the .vtp is written. fitted_reference_mesh_file = output_dir / f"{case_id}_ssm_surface.vtp" fit_result["fitted_reference_mesh"].save(str(fitted_reference_mesh_file)) - logger.info("Fitted the lung model to %s", reference_file.name) + logger.log_info("Fitted the lung model to %s", reference_file.name) step_start = _record_step(step_times_s, "fit_shape_model", step_start) # Step 4: predict the whole cycle, warp the reference CT through each stage's @@ -305,7 +291,7 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: anatomy_type="lung", separate_by_connectivity=True, ) - logger.info("USD: %s", infer_result["usd_file"]) + logger.log_info("USD: %s", infer_result["usd_file"]) step_start = _record_step(step_times_s, "predict_and_warp", step_start) tutorial_results: dict[str, Any] = dict(infer_result) @@ -345,10 +331,10 @@ def _record_step(times_s: dict[str, float], step: str, started: float) -> float: [case_id, step, f"{seconds:.3f}"] for step, seconds in step_times_s.items() ) - logger.info("Runtime, seconds") + logger.log_info("Runtime, seconds") for step, seconds in step_times_s.items(): - logger.info(" %-18s %8.1f", step, seconds) - logger.info("Runtime CSV: %s", runtime_file) + logger.log_info(" %-18s %8.1f", step, seconds) + logger.log_info("Runtime CSV: %s", runtime_file) tutorial_results["step_times_s"] = step_times_s tutorial_results["runtime_file"] = runtime_file diff --git a/tutorials/tutorial_13_heart_and_lung_motion.py b/tutorials/tutorial_13_heart_and_lung_motion.py index 4d4e1355..08717cad 100644 --- a/tutorials/tutorial_13_heart_and_lung_motion.py +++ b/tutorials/tutorial_13_heart_and_lung_motion.py @@ -89,12 +89,11 @@ Anatomy materials ----------------- -The per-cell ``boundary_labels`` produced by contouring the labelmap propagate -unchanged through every warp (each frame is a deep copy with only its points -moved; remeshing carries them over to the new cells when enabled). -``ConvertVTKToUSD``, -given ``segmenter.taxonomy.all_labels()`` and the segmenter, splits each frame -into per-organ prims, and ``ProcessUSDAnatomy.enhance_meshes`` then binds the +The per-cell ``SegmentationLabelIds`` that ``ProcessSurfaceMesh.combine_surfaces`` +tags the contoured labels with propagate unchanged through every warp (each +frame is a deep copy with only its points moved). ``ConvertVTKToUSD``, given +``segmenter.taxonomy.all_labels()`` and the segmenter, splits each frame into +per-organ prims, and ``ProcessUSDAnatomy.enhance_meshes`` then binds the matching OmniSurface material (diffuse color, subsurface scattering, etc.). Prerequisites @@ -147,8 +146,9 @@ from monai_physio import ( ConvertVTKToUSD, - ProcessContours, + MONAIPhysioBase, ProcessImages, + ProcessSurfaceMesh, ProcessTests, ProcessTransforms, ProcessUSDAnatomy, @@ -167,15 +167,14 @@ # restart the whole pipeline in every worker. if __name__ == "__main__": log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger("tutorial_13_heart_and_lung_motion") + logger = MONAIPhysioBase( + class_name="tutorial_13_heart_and_lung_motion", log_level=log_level + ) tutorials_dir = Path(__file__).resolve().parent # ---- Inputs ------------------------------------------------------------ test_mode = ProcessTests.running_as_test() - # Keep a test run out of the directories a full run reads and writes. - weights_dir = DUKE_HEART.weights_directory(test_mode) # The ungated clinical scan every rhythm is inferred onto. patient_image_file = ( @@ -202,13 +201,7 @@ # Distance-map weights finetuned by # tutorial_02_duke_heart_distancemap_finetune_icon.py, used by the heart fit # below; optional, the stock uniGradICON weights are used without them. - heart_icon_weights_path = ( - weights_dir - / "icon_duke_heart_distancemap" - / "icon_duke_heart_distancemap_model" - / "checkpoints" - / "network_weights_final.trch" - ) + heart_icon_weights_path = DUKE_HEART.distancemap_icon_weights_file(test_mode) # Needs about 43 GB free: a hundred frames of warped CT is 40 GB of it, # since resampling costs the compression the original scan had. @@ -258,16 +251,17 @@ # full-resolution field of a 512x512x526 scan costs 1.6 GB and the frame loop # blends four of them at a time. Raise it toward 1.0 to sample them finer. deformation_field_scale = 0.3 - # Pitch the animated surface is contoured at. ProcessContours.extract_contours - # works on an isotropic grid of the labelmap's finest pitch, which for this - # scan is 0.6 mm and yields a 4.5-million-point thorax: a hundred frames of - # it is 5.5 GB of warped points held at once and 135 MB per frame on disk. - # Coarsening the labelmap first sets that pitch instead, and the cost falls - # with its square. Lower it for a finer animation, if the memory is there. + # Pitch the animated surface is contoured at. + # ProcessSurfaceMesh.extract_label_surfaces otherwise contours on an + # isotropic grid of the labelmap's finest pitch, which for this scan is + # 0.6 mm and yields a 4.5-million-point thorax: a hundred frames of it is + # 5.5 GB of warped points held at once and 135 MB per frame on disk. The + # cost falls with the square of this pitch. Lower it for a finer animation, + # if the memory is there. surface_spacing_mm = 2.0 # One-time conditioning of the patient surface, reused for every frame. surface_reduction_rate = 0.0 # fraction of triangles removed by remeshing - surface_smoothing_iterations = 0 # Taubin (non-shrinking) iterations + surface_smoothing_iterations = 20 # Taubin (non-shrinking) iterations # USD playback rate; 10 stages/second ~= one heartbeat per second. frames_per_second = 10.0 @@ -303,7 +297,7 @@ f"Required input not found: {required}\nRun tutorials/{hint} first." ) - contour_tools = ProcessContours(log_level=log_level) + contour_tools = ProcessSurfaceMesh(log_level=log_level) transform_tools = ProcessTransforms(log_level=log_level) image_tools = ProcessImages(log_level=log_level) @@ -315,7 +309,7 @@ deformation_grid = image_tools.resample_image_by_scale( patient_image, deformation_field_scale ) - logger.info( + logger.log_info( "Deformation grid: %s voxels at %s mm (scan is %s at %s mm)", list(itk.size(deformation_grid)), [round(s, 2) for s in deformation_grid.GetSpacing()], @@ -421,21 +415,9 @@ # The whole heart minus its chamber cavities and the vessels whose # extent varies too much between patients: the structure the shape # model describes, named by DUKE_HEART.interior_object_ids. - heart_labels = itk.GetArrayViewFromImage(heart_labelmap) - wall_ids = [ - int(value) - for value in np.unique(heart_labels) - if value != 0 and int(value) not in DUKE_HEART.interior_object_ids - ] - heart_mask = itk.GetImageFromArray( - np.isin(heart_labels, wall_ids).astype(np.uint8) + heart_surface = DUKE_HEART.whole_heart_surface( + heart_labelmap_file, test_mode, log_level ) - heart_mask.CopyInformation(heart_labelmap) - heart_surface = contour_tools.extract_label_surfaces( - heart_mask, - isotropic_spacing_mm=DUKE_HEART.surface_spacing_mm, - smoothing_iterations=DUKE_HEART.surface_smoothing_iterations, - )[1] with heart_pca_json.open(encoding="utf-8") as f: heart_pca_model: dict[str, Any] = json.load(f) @@ -485,7 +467,7 @@ heart_fit_result["fitted_reference_mesh"].save( str(heart_fitted_reference_mesh_file) ) - logger.info("Fitted the Duke heart model to %s", patient_image_file.name) + logger.log_info("Fitted the Duke heart model to %s", patient_image_file.name) # ======================================================================== # Interior masks: where the whole field propagates, vs. only its normal @@ -668,7 +650,7 @@ def stage_transforms( ) - reference_points ) - logger.info( + logger.log_info( "%s stage %.2f: max %.2f mm predicted, %.2f mm after smoothing", tag, stage, @@ -717,23 +699,22 @@ def stage_transforms( # ======================================================================== # Stage 3: combine the two rhythms. # ======================================================================== - # Labeled contour surface: each cell carries `boundary_labels`, which survive - # the warps (deep copies) and let ConvertVTKToUSD split the USD by anatomy so - # ProcessUSDAnatomy.enhance_meshes can bind per-organ OmniSurface materials. - # Contoured from a coarsened copy of the labelmap, so the animated surface - # is sampled at surface_spacing_mm rather than at the scan's finest pitch. - # Its heart cells come from the Simpleware segmentation merged in at Stage 0. - # The full-resolution labelmap is still what gets warped per frame below. - surface_labelmap = image_tools.resample_image_by_scale( - chest_labelmap, - min(1.0, float(np.min(chest_labelmap.GetSpacing())) / surface_spacing_mm), - interpolate=False, - ) - patient_surface = contour_tools.extract_contours(surface_labelmap) - patient_surface = contour_tools.remesh_and_smooth_surface( - patient_surface, surface_reduction_rate, surface_smoothing_iterations + # Labeled contour surface: each cell carries `SegmentationLabelIds`, which + # survive the warps (deep copies) and let ConvertVTKToUSD split the USD by + # anatomy so ProcessUSDAnatomy.enhance_meshes can bind per-organ OmniSurface + # materials. Contoured at surface_spacing_mm rather than at the scan's + # finest pitch. Its heart cells come from the Simpleware segmentation merged + # in at Stage 0. The full-resolution labelmap is still what gets warped per + # frame below. + patient_surface = ProcessSurfaceMesh.combine_surfaces( + contour_tools.extract_label_surfaces( + chest_labelmap, + isotropic_spacing_mm=surface_spacing_mm, + smoothing_iterations=surface_smoothing_iterations, + surface_reduction_rate=surface_reduction_rate, + ) ) - logger.info( + logger.log_info( "Patient surface: %d points, %d cells (reduction=%.2f, smoothing_iters=%d)", patient_surface.n_points, patient_surface.n_cells, @@ -754,7 +735,7 @@ def stage_transforms( # resp_points[phase][stage] = forward_phase(cardiac_surface[stage]).points. # All of them are held at once, so this is the run's memory high-water mark; # raise surface_reduction_rate if it does not fit. - logger.info( + logger.log_info( "Precomputed warped points: %d x %d x %d vertices, %.1f GB", n_phases, n_stages, @@ -774,7 +755,7 @@ def stage_transforms( for cardiac_surface in cardiac_surfaces ] ) - logger.info("respiratory warp phase %d/%d done", phase_idx + 1, n_phases) + logger.log_info("respiratory warp phase %d/%d done", phase_idx + 1, n_phases) for pattern in ( "combined_frame_*.vtp", @@ -897,14 +878,14 @@ def to_signed_short(image: itk.Image) -> itk.Image: del field_cache[key] del resp_points - logger.info( + logger.log_info( "Wrote %d combined-motion surfaces, CT and labelmap volumes to %s", len(combined_files), output_dir, ) # Assemble the ordered frames into a single animated 4D USD, split by anatomy - # label (via the mesh `boundary_labels`) so each organ becomes its own prim + # label (via the mesh `SegmentationLabelIds`) so each organ becomes its own prim # under /World/heart_and_lung_motion/{group}/{label}. enhance_meshes then # binds the per-organ OmniSurface materials (colors, subsurface scatter, ...). # The segmenter is the one Stage 0 wrote the labelmap with, so its taxonomy @@ -921,7 +902,7 @@ def to_signed_short(image: itk.Image) -> itk.Image: stage = converter.convert(str(usd_file)) ProcessUSDAnatomy(stage).enhance_meshes(segmenter) stage.Save() - logger.info("Wrote 4D USD with anatomy materials: %s", usd_file) + logger.log_info("Wrote 4D USD with anatomy materials: %s", usd_file) # Testing: the first combined frame, as geometry and as the labelmap the # same frame rasterizes to. diff --git a/tutorials/tutorial_14_duke_heart_shape_parameter_sweep.py b/tutorials/tutorial_14_duke_heart_shape_parameter_sweep.py index d3d23793..a480b4ff 100644 --- a/tutorials/tutorial_14_duke_heart_shape_parameter_sweep.py +++ b/tutorials/tutorial_14_duke_heart_shape_parameter_sweep.py @@ -105,6 +105,7 @@ from monai_physio import ( EvaluateMovementDukeHeart, + MONAIPhysioBase, ProcessTests, WorkflowEvaluateMovement, WorkflowInferMovement, @@ -176,8 +177,7 @@ def _mean_of_measured(values: list[float]) -> float: ) log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) labelmap_dir = DUKE_HEART.hold_out_directory(test_mode) / case_id @@ -195,6 +195,7 @@ def _mean_of_measured(values: list[float]) -> float: # runs and reports, not that the grid is finely sampled. number_of_modes_to_vary = 1 perturbation_range = 1.0 + perturbation_step = 1.0 # Directory setup and data reading @@ -236,7 +237,7 @@ def _mean_of_measured(values: list[float]) -> float: combinations = _offset_grid( number_of_modes_to_vary, perturbation_range, perturbation_step ) - logger.info( + logger.log_info( "Sweeping the first %d mode(s) from %+.2f to %+.2f in steps of %.2f " "standard deviations: %d combinations", number_of_modes_to_vary, @@ -332,7 +333,7 @@ def _mean_of_measured(values: list[float]) -> float: } summary_rows.append(summary) - logger.info( + logger.log_info( "combination %d/%d offsets %s dice=%.4f dV=%+.2f%% rmse=%.3f mm " "displacement rms=%.3f mm 95th=%.3f mm max=%.3f mm " "(per structure: 95th=%.3f mm)", @@ -361,8 +362,8 @@ def _mean_of_measured(values: list[float]) -> float: summary_writer.writeheader() summary_writer.writerows(summary_rows) - logger.info("Metrics: %s", metrics_csv_file) - logger.info("Summary: %s", summary_csv_file) + logger.log_info("Metrics: %s", metrics_csv_file) + logger.log_info("Summary: %s", summary_csv_file) tutorial_results: dict[str, Any] = { "rows": metric_rows, diff --git a/tutorials/tutorial_14_lung_shape_parameter_sweep.py b/tutorials/tutorial_14_lung_shape_parameter_sweep.py index 6960d307..5de66ea4 100644 --- a/tutorials/tutorial_14_lung_shape_parameter_sweep.py +++ b/tutorials/tutorial_14_lung_shape_parameter_sweep.py @@ -113,6 +113,7 @@ from monai_physio import ( EvaluateMovementLung, + MONAIPhysioBase, ProcessTests, WorkflowEvaluateMovement, WorkflowInferMovement, @@ -184,8 +185,7 @@ def _mean_of_measured(values: list[float]) -> float: ground_truth_dir = output_dir / "ground_truth" log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) data_dir = TCIA_4D_LUNG.cases_directory(test_mode) / case_id @@ -240,7 +240,7 @@ def _mean_of_measured(values: list[float]) -> float: combinations = _offset_grid( number_of_modes_to_vary, perturbation_range, perturbation_step ) - logger.info( + logger.log_info( "Sweeping the first %d mode(s) from %+.2f to %+.2f in steps of %.2f " "standard deviations: %d combinations", number_of_modes_to_vary, @@ -340,7 +340,7 @@ def _mean_of_measured(values: list[float]) -> float: } summary_rows.append(summary) - logger.info( + logger.log_info( "combination %d/%d offsets %s dice=%.4f dV=%+.2f%% rmse=%.3f mm " "displacement rms=%.3f mm 95th=%.3f mm max=%.3f mm " "(per structure: 95th=%.3f mm)", @@ -369,8 +369,8 @@ def _mean_of_measured(values: list[float]) -> float: summary_writer.writeheader() summary_writer.writerows(summary_rows) - logger.info("Metrics: %s", metrics_csv_file) - logger.info("Summary: %s", summary_csv_file) + logger.log_info("Metrics: %s", metrics_csv_file) + logger.log_info("Summary: %s", summary_csv_file) tutorial_results: dict[str, Any] = { "rows": metric_rows, diff --git a/tutorials/tutorial_15_duke_heart_leave_one_out.py b/tutorials/tutorial_15_duke_heart_leave_one_out.py index 69bd313d..75ebdb11 100644 --- a/tutorials/tutorial_15_duke_heart_leave_one_out.py +++ b/tutorials/tutorial_15_duke_heart_leave_one_out.py @@ -45,10 +45,10 @@ What is *not* recomputed per fold --------------------------------- -The per-frame heart contours and the remeshed model-population surfaces do not -depend on which case is held out, so they are contoured once into ``shared/`` -and reused by every fold. Tutorial 4's surfaces are read in place of the cache -when they exist. +The remeshed model-population surfaces do not depend on which case is held +out, so they are made once into ``shared/`` and reused by every fold. The +per-frame heart contours are Tutorial 4's when it wrote them; otherwise each +fold contours the frames it registers to. Unlike the lung variant, the *phase propagation* here cannot be hoisted: it registers the fold's own fitted SSM surface to each frame, and that surface @@ -81,7 +81,7 @@ displacement 95th percentile included, plus each fold's whole-model error * ``loo_metrics_by_label.png`` - the same, as a per-chamber box plot * ``fold_/`` - that fold's shape model, fits and weights - * ``shared/`` - the fold-independent contours + * ``shared/`` - the fold-independent population surfaces Runtime ------- @@ -109,10 +109,10 @@ from monai_physio import ( DistributedContext, EvaluateMovementDukeHeart, - ProcessContours, + MONAIPhysioBase, ProcessPhysicsNemo, + ProcessSurfaceMesh, ProcessTests, - RegisterModelsDistanceMaps, TrainPhysicsNeMoMGN, WorkflowCreateMeanSurface, WorkflowCreateStatisticalModel, @@ -123,8 +123,6 @@ WorkflowTrainPhysicsNeMo, ) -# Structure name Tutorial 4 (Duke Heart) writes its whole-heart surfaces under. -WHOLE_HEART_NAME = "heart_minus_interior_chambers" LABELMAP_SUFFIX = "_labelmap.nii.gz" # The four chambers, plus the myocardium and the whole heart for context: 5 and @@ -133,22 +131,11 @@ # between frames and are not part of the model. HEART_LABEL_IDS = [1, 2, 3, 4, 5, 6] -# Point-data array the tutorial writes its targets into and the manifests name. -TARGET_ARRAY = "displacement" - # Gated frames carry a ``g{PPP}`` tag naming their percentage of the R-R # interval; this is what a per-frame SSM surface is matched and staged by. PHASE_SURFACE_PATTERN = "*_g[0-9][0-9][0-9]_*_ssm_surface.vtp" -def _cardiac_stage_from_filename(frame_file: Path) -> float: - """Extract the normalized cardiac stage [0, 1] from a ``g{PPP}`` filename stem.""" - for part in frame_file.name.split("_"): - if part.startswith("g") and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse cardiac gate from filename: {frame_file}") - - def _rank_share(items: list[Any], context: DistributedContext) -> list[Any]: """Return the slice of ``items`` this rank is responsible for. @@ -158,51 +145,6 @@ def _rank_share(items: list[Any], context: DistributedContext) -> list[Any]: return items[context.rank :: context.world_size] -def _write_target_mesh( - phase_file: Path, ref_points: np.ndarray, targets_dir: Path -) -> Path: - """Write one frame's training target and return the mesh path. - - The target is the per-vertex displacement from the case's reference surface, - stored as the ``TARGET_ARRAY`` point-data array on a copy of the frame - surface. The training workflow reads whatever array the manifest names and - never derives one. - """ - phase_mesh = pv.read(str(phase_file)) - phase_points = np.asarray(phase_mesh.points, dtype=np.float32) - phase_mesh.point_data[TARGET_ARRAY] = phase_points - ref_points - target_path = targets_dir / f"{phase_file.stem}_target.vtp" - phase_mesh.save(str(target_path)) - return target_path - - -def _write_case_manifest(case_dir: Path, manifests_dir: Path) -> Path: - """Write one case's training manifest and return its path.""" - case_id = case_dir.name - fitted_reference_mesh_file = case_dir / f"{case_id}_ssm_surface.vtp" - phase_files = sorted(case_dir.glob(PHASE_SURFACE_PATTERN)) - manifests_dir.mkdir(parents=True, exist_ok=True) - ref_points = np.asarray( - pv.read(str(fitted_reference_mesh_file)).points, dtype=np.float32 - ) - manifest = { - "subject_id": case_id, - "fitted_reference_mesh": str(fitted_reference_mesh_file), - "pca_coefficients": str(case_dir / f"{case_id}_ssm_pca_coefficients.json"), - "target_array": TARGET_ARRAY, - "phases": [ - { - "mesh": str(_write_target_mesh(phase_file, ref_points, manifests_dir)), - "stage": _cardiac_stage_from_filename(phase_file), - } - for phase_file in phase_files - ], - } - manifest_path = manifests_dir / f"{case_id}_manifest.json" - manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") - return manifest_path - - def _summarize(values: list[float]) -> tuple[float, float, int]: """Return ``(mean, sample standard deviation, count)`` ignoring NaNs. @@ -406,10 +348,6 @@ def _plot_metrics_by_label( hidden_dim = 128 num_layers = 2 # MLP layers inside each encoder / processor / decoder block - # Pitch of the grid the frame distance maps are rasterized on. Coarser than - # the contouring pitch: it carries a distance field, not a boundary. - registration_spacing_mm = 1.0 - # Gaussian sigma, in mm, that spreads the predicted surface displacements # into the continuous field the labelmap is resampled through. smoothing_sigma_mm = 10.0 @@ -420,14 +358,11 @@ def _plot_metrics_by_label( test_mode = ProcessTests.running_as_test() # Keep a test run out of the directories a full run reads and writes. - weights_dir = DUKE_HEART.weights_directory(test_mode) - output_dir = DUKE_HEART.output_directory(test_mode) / "tutorial_15_duke_heart" shared_dir = output_dir / "shared" log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) # Under torchrun / SLURM / mpirun this is one rank of many; started plainly # it reports a world of one and every branch below collapses to a single @@ -435,7 +370,6 @@ def _plot_metrics_by_label( context = ProcessPhysicsNemo.distributed_context() data_dir = DUKE_HEART.hold_out_directory(test_mode) - tutorial_04_dir = DUKE_HEART.input_directory(test_mode) number_of_pca_components = DUKE_HEART.pca_components(test_mode) # Labels left out of the whole-heart structure, the same ones Tutorials 4, @@ -452,21 +386,13 @@ def _plot_metrics_by_label( # Distance-map weights finetuned by # tutorial_02_duke_heart_distancemap_finetune_icon.py, used both by the # labelmap-to-labelmap stage of the SSM fit and by the frame registrations. - icon_weights_path = ( - weights_dir - / "icon_duke_heart_distancemap" - / "icon_duke_heart_distancemap_model" - / "checkpoints" - / "network_weights_final.trch" - ) + icon_weights_path = DUKE_HEART.distancemap_icon_weights_file(test_mode) # Directory setup and data reading - contour_dir = shared_dir / "contours" samples_dir = shared_dir / "model_samples" if context.is_main: - for directory in (contour_dir, samples_dir): - directory.mkdir(parents=True, exist_ok=True) + samples_dir.mkdir(parents=True, exist_ok=True) context.barrier() case_dirs = sorted( @@ -506,7 +432,7 @@ def reference_frame_file(case_dir: Path) -> Path: f"only {len(case_ids)} cases are available under {data_dir}." ) held_out_cases = case_ids[:number_of_leave_one_out_runs] - logger.info( + logger.log_info( "%d cases, %d folds; holding out %s", len(case_ids), len(held_out_cases), @@ -515,7 +441,7 @@ def reference_frame_file(case_dir: Path) -> Path: use_finetuned_weights = icon_weights_path.exists() if not use_finetuned_weights: - logger.warning( + logger.log_warning( "Finetuned distance-map ICON weights not found at %s; registering " "with the stock uniGradICON weights. Run " "tutorials/tutorial_02_duke_heart_distancemap_finetune_icon.py to " @@ -523,62 +449,24 @@ def reference_frame_file(case_dir: Path) -> Path: icon_weights_path, ) - contour_tools = ProcessContours(log_level=log_level) + contour_tools = ProcessSurfaceMesh(log_level=log_level) # The cohort names the structures every fold reports and, in Step 5, reads # each fold's ground truth: the acquired labelmaps and this fold's own fits. cohort = EvaluateMovementDukeHeart(log_level=log_level) heart_names = cohort.label_names() - def heart_surface_for(labelmap_file: Path) -> Path: - """Return the path to one frame's whole heart, minus its chamber cavities. - - Tutorial 4's ``"full"`` pass contours this surface for every gated - frame, so its output is used when present. Otherwise the surface is - contoured into the shared cache, which no fold recomputes: the contour - of an acquired frame does not depend on which case was held out. - """ - stem = labelmap_file.name[: -len(LABELMAP_SUFFIX)] - tutorial_04_file = tutorial_04_dir / f"{stem}_{WHOLE_HEART_NAME}.vtp" - if tutorial_04_file.exists(): - return tutorial_04_file - - surface_file = contour_dir / f"{stem}_heart_surface.vtp" - if not surface_file.exists(): - labelmap = itk.imread(str(labelmap_file)) - labels = itk.GetArrayViewFromImage(labelmap) - heart_ids = [ - int(value) - for value in np.unique(labels) - if value != 0 and int(value) not in interior_object_ids - ] - heart_mask = itk.GetImageFromArray( - np.isin(labels, heart_ids).astype(np.uint8) - ) - heart_mask.CopyInformation(labelmap) - contour_tools.extract_label_surfaces( - heart_mask, - isotropic_spacing_mm=DUKE_HEART.surface_spacing_mm, - smoothing_iterations=DUKE_HEART.surface_smoothing_iterations, - )[1].save(str(surface_file)) - return surface_file - - # Step 1: the shared cache. Contouring a frame and remeshing a case's - # reference surface to the model's point budget are both independent of - # which case is held out, so they happen once. Cases are split across the - # ranks; the barrier below is what lets every rank read every case's files. + # Step 1: the shared cache. Remeshing a case's reference surface to the + # model's point budget is independent of which case is held out, so it + # happens once. Cases are split across the ranks; the barrier below is what + # lets every rank read every case's files. for case_id in _rank_share(case_ids, context): - case_dir = case_dir_by_id[case_id] - for frame_file in sorted(case_dir.glob(f"*{LABELMAP_SUFFIX}")): - heart_surface_for(frame_file) - # The population surface: the model's fixed point budget, which the # contours carry thirty times over. See the parameters module. sample_file = samples_dir / f"{case_id}_ref_model_surface.vtp" if not sample_file.exists(): - surface = cast( - pv.PolyData, - pv.read(str(heart_surface_for(reference_frame_file(case_dir)))), + surface = DUKE_HEART.whole_heart_surface( + reference_frame_file(case_dir_by_id[case_id]), test_mode, log_level ) contour_tools.remesh_and_smooth_surface( surface, 1.0 - DUKE_HEART.model_points / surface.n_points, 0 @@ -590,14 +478,13 @@ def heart_surface_for(labelmap_file: Path) -> Path: fold_results: dict[str, Any] = {} for fold_index, held_out_case in enumerate(held_out_cases): - logger.info("%s", "=" * 48) - logger.info( + logger.log_section( "Fold %d/%d: holding out %s", fold_index + 1, len(held_out_cases), held_out_case, + width=48, ) - logger.info("%s", "=" * 48) fold_dir = output_dir / f"fold_{held_out_case}" fits_dir = fold_dir / "fits" @@ -671,13 +558,13 @@ def heart_surface_for(labelmap_file: Path) -> Path: ) reference_file = reference_frame_file(case_dir) - reference_surface = cast( - pv.PolyData, pv.read(str(heart_surface_for(reference_file))) + reference_surface = DUKE_HEART.whole_heart_surface( + reference_file, test_mode, log_level ) if not ( fitted_reference_mesh_file.exists() and pca_coefficients_file.exists() ): - logger.info("Fold %s: fitting %s", held_out_case, case_id) + logger.log_info("Fold %s: fitting %s", held_out_case, case_id) # This data carries no intensity image, so the workflow # rasterizes its own reference grid from the patient surface. fit_workflow = WorkflowFitStatisticalModelToPatient( @@ -720,15 +607,14 @@ def heart_surface_for(labelmap_file: Path) -> Path: ) # One grid built around the reference frame's heart and reused by - # every frame, so the whole case is registered in a common space; - # its buffer holds the frames the heart moves into. - registration_grid = contour_tools.create_reference_image( - mesh=reference_surface, - spatial_resolution=registration_spacing_mm, - buffer_factor=0.25, - ptype=itk.F, - ) - for frame_file in sorted(case_dir.glob(f"*{LABELMAP_SUFFIX}")): + # every frame, so the whole case is registered in a common space. + registration_grid = DUKE_HEART.frame_registration_grid(reference_surface) + for frame_file in DUKE_HEART.frames_to_register( + sorted(case_dir.glob(f"*{LABELMAP_SUFFIX}")), + reference_file, + test_mode, + scored=case_id == held_out_case, + ): stem = frame_file.name[: -len(LABELMAP_SUFFIX)] frame_surface_file = case_fit_dir / f"{stem}_ssm_surface.vtp" if frame_surface_file.exists(): @@ -737,22 +623,16 @@ def heart_surface_for(labelmap_file: Path) -> Path: # The fit already placed the SSM on this frame. fitted_reference_mesh.save(str(frame_surface_file)) continue - logger.info("Fold %s: %s warping to %s", held_out_case, case_id, stem) - registrar = RegisterModelsDistanceMaps( - moving_model=fitted_reference_mesh, - fixed_model=cast( - pv.PolyData, pv.read(str(heart_surface_for(frame_file))) - ), - reference_image=registration_grid, - distance_squared_max=DUKE_HEART.distancemap_squared_max, - mask_dilation_mm=DUKE_HEART.mask_dilation_mm, - log_level=log_level, + logger.log_info( + "Fold %s: %s warping to %s", held_out_case, case_id, stem ) - if use_finetuned_weights: - registrar.set_icon_weights_path(str(icon_weights_path)) - registrar.register(transform_type="Deformable")[ - "registered_model" - ].save(str(frame_surface_file)) + DUKE_HEART.register_to_frame( + fitted_reference_mesh, + DUKE_HEART.whole_heart_surface(frame_file, test_mode, log_level), + registration_grid, + test_mode, + log_level, + ).save(str(frame_surface_file)) context.barrier() @@ -761,7 +641,16 @@ def heart_surface_for(labelmap_file: Path) -> Path: # having to be told which ones the others wrote. manifests_dir = fold_dir / "manifests_mgn" for case_id in _rank_share(case_ids, context): - _write_case_manifest(fits_dir / case_id, manifests_dir) + case_fit_dir = fits_dir / case_id + phase_files = sorted(case_fit_dir.glob(PHASE_SURFACE_PATTERN)) + ProcessPhysicsNemo.write_manifest( + subject_id=case_id, + fitted_reference_mesh=case_fit_dir / f"{case_id}_ssm_surface.vtp", + pca_coefficients=case_fit_dir / f"{case_id}_ssm_pca_coefficients.json", + phase_meshes=phase_files, + stages=[cohort.stage_from_filename(path) for path in phase_files], + output_directory=manifests_dir, + ) context.barrier() manifests = { @@ -868,8 +757,8 @@ def heart_surface_for(labelmap_file: Path) -> Path: plot_file = _plot_metrics_by_label( all_rows, heart_names, output_dir / "loo_metrics_by_label.png" ) - logger.info("Metrics: %s", metrics_file) - logger.info("Report: %s", report_file) + logger.log_info("Metrics: %s", metrics_file) + logger.log_info("Report: %s", report_file) tutorial_results["metrics_file"] = metrics_file tutorial_results["report_file"] = report_file @@ -903,4 +792,4 @@ def heart_surface_for(labelmap_file: Path) -> Path: color="steelblue", ), ] - logger.info("Last fold report: %s", fold_results[last_case]["report_file"]) + logger.log_info("Last fold report: %s", fold_results[last_case]["report_file"]) diff --git a/tutorials/tutorial_15_lung_leave_one_out.py b/tutorials/tutorial_15_lung_leave_one_out.py index 82346271..c19fa90a 100644 --- a/tutorials/tutorial_15_lung_leave_one_out.py +++ b/tutorials/tutorial_15_lung_leave_one_out.py @@ -113,12 +113,10 @@ from monai_physio import ( DistributedContext, EvaluateMovementLung, - ProcessContours, + MONAIPhysioBase, ProcessPhysicsNemo, ProcessTests, ProcessTransforms, - RegisterImagesGreedy, - SegmentNVSegmentCTMRI, TrainPhysicsNeMoMGN, WorkflowConvertImageToVTK, WorkflowCreateMeanSurface, @@ -135,17 +133,6 @@ # whole-lung, tumor and airway labels, which are not lobes. LOBE_LABEL_IDS = [28, 29, 30, 31, 32] -# Point-data array the tutorial writes its targets into and the manifests name. -TARGET_ARRAY = "displacement" - - -def _respiratory_stage_from_filename(surface_file: Path) -> float: - """Extract the normalized respiratory stage [0, 1] from a ``g{PPP}`` filename stem.""" - for part in surface_file.stem.split("_"): - if part.startswith("g") and len(part) == 4 and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse respiratory phase from filename: {surface_file}") - def _rank_share(items: list[Any], context: DistributedContext) -> list[Any]: """Return the slice of ``items`` this rank is responsible for. @@ -156,51 +143,6 @@ def _rank_share(items: list[Any], context: DistributedContext) -> list[Any]: return items[context.rank :: context.world_size] -def _write_target_mesh( - phase_file: Path, ref_points: np.ndarray, targets_dir: Path -) -> Path: - """Write one phase's training target and return the mesh path. - - The target is the per-vertex displacement from the case's reference surface, - stored as the ``TARGET_ARRAY`` point-data array on a copy of the phase - surface. The training workflow reads whatever array the manifest names and - never derives one. - """ - phase_mesh = pv.read(str(phase_file)) - phase_points = np.asarray(phase_mesh.points, dtype=np.float32) - phase_mesh.point_data[TARGET_ARRAY] = phase_points - ref_points - target_path = targets_dir / f"{phase_file.stem}_target.vtp" - phase_mesh.save(str(target_path)) - return target_path - - -def _write_case_manifest(case_dir: Path, manifests_dir: Path) -> Path: - """Write one case's training manifest and return its path.""" - case_id = case_dir.name - fitted_reference_mesh_file = case_dir / f"{case_id}_ssm_surface.vtp" - phase_files = sorted(case_dir.glob(f"{case_id}_g0??_ssm_surface.vtp")) - manifests_dir.mkdir(parents=True, exist_ok=True) - ref_points = np.asarray( - pv.read(str(fitted_reference_mesh_file)).points, dtype=np.float32 - ) - manifest = { - "subject_id": case_id, - "fitted_reference_mesh": str(fitted_reference_mesh_file), - "pca_coefficients": str(case_dir / f"{case_id}_ssm_pca_coefficients.json"), - "target_array": TARGET_ARRAY, - "phases": [ - { - "mesh": str(_write_target_mesh(phase_file, ref_points, manifests_dir)), - "stage": _respiratory_stage_from_filename(phase_file), - } - for phase_file in phase_files - ], - } - manifest_path = manifests_dir / f"{case_id}_manifest.json" - manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") - return manifest_path - - def _summarize(values: list[float]) -> tuple[float, float, int]: """Return ``(mean, sample standard deviation, count)`` ignoring NaNs. @@ -429,8 +371,7 @@ def _plot_metrics_by_label( shared_dir = output_dir / "shared" log_level = logging.INFO - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) # Under torchrun / SLURM / mpirun this is one rank of many; started plainly # it reports a world of one and every branch below collapses to a single @@ -496,7 +437,7 @@ def case_phase_file(case_id: str, phase_id: str) -> Path: f"only {len(case_ids)} cases are available under {cases_dir}." ) held_out_cases = case_ids[:number_of_leave_one_out_runs] - logger.info( + logger.log_info( "%d cases, %d folds; holding out %s", len(case_ids), len(held_out_cases), @@ -505,7 +446,7 @@ def case_phase_file(case_id: str, phase_id: str) -> Path: use_finetuned_distancemap_weights = icon_distancemap_weights_path.exists() if not use_finetuned_distancemap_weights: - logger.warning( + logger.log_warning( "Finetuned distance-map ICON weights not found at %s; fitting the " "SSM with the stock uniGradICON weights. Run " "tutorials/tutorial_02_lung_distancemap_finetune_icon.py to create " @@ -513,11 +454,10 @@ def case_phase_file(case_id: str, phase_id: str) -> Path: icon_distancemap_weights_path, ) - segmentation_method = SegmentNVSegmentCTMRI(log_level=log_level) + segmentation_method = TCIA_4D_LUNG.segmenter(test_mode, log_level=log_level) segmentation_workflow = WorkflowConvertImageToVTK( segmentation_method=segmentation_method, log_level=log_level ) - contour_tools = ProcessContours(log_level=log_level) transform_tools = ProcessTransforms(log_level=log_level) # The cohort names the structures every fold reports and, in Step 5, reads @@ -556,14 +496,15 @@ def shared_segmentation_files(case_id: str) -> tuple[Path, Path]: for case_id in _rank_share(case_ids, context): surface_file, labelmap_file = shared_segmentation_files(case_id) if not (surface_file.exists() and labelmap_file.exists()): - logger.info("Segmenting %s %s", case_id, reference_phase) + logger.log_info("Segmenting %s %s", case_id, reference_phase) segmentation_result = segmentation_workflow.process( input_image=itk.imread(str(case_phase_file(case_id, reference_phase))), - anatomy_groups=["lung"], + anatomy_groups=[TCIA_4D_LUNG.anatomy_group], surface_reduction_rate=TCIA_4D_LUNG.surface_reduction_rate, extract_label_surfaces=True, + extract_group_surfaces=False, ) - contour_tools.save_combined_surfaces( + WorkflowConvertImageToVTK.save_combined_surfaces( segmentation_result["label_surfaces"], str(surface_file) ) itk.imwrite( @@ -592,11 +533,13 @@ def shared_segmentation_files(case_id: str) -> tuple[Path, Path]: for p in phase_ids ] if all(path.exists() for path in existing): - logger.info("Reusing Tutorial 8 phase transforms for %s", case_id) + logger.log_info("Reusing Tutorial 8 phase transforms for %s", case_id) for source, destination in zip(existing, wanted): destination.write_bytes(source.read_bytes()) else: - logger.info("Registering %d phases of %s", len(phase_files), case_id) + logger.log_info( + "Registering %d phases of %s", len(phase_files), case_id + ) reg_workflow = WorkflowReconstructHighres4DCT( time_series_images=[itk.imread(str(path)) for path in phase_files], reference_image=itk.imread( @@ -604,7 +547,9 @@ def shared_segmentation_files(case_id: str) -> tuple[Path, Path]: ), reference_time_frame=phase_ids.index(reference_phase), register_reference_time_frame_to_reference_image=False, - registration_method=RegisterImagesGreedy(log_level=log_level), + registration_method=TCIA_4D_LUNG.registrar( + test_mode, log_level=log_level + ), log_level=log_level, ) reg_workflow.set_modality("ct") @@ -626,7 +571,7 @@ def shared_segmentation_files(case_id: str) -> tuple[Path, Path]: labelmap_file = case_ground_truth_dir / f"{frame_stem}_labelmap.nii.gz" if labelmap_file.exists(): continue - logger.info("Segmenting ground-truth frame %s", frame_file.name) + logger.log_info("Segmenting ground-truth frame %s", frame_file.name) itk.imwrite( segmentation_method.segment(itk.imread(str(frame_file)))["labelmap"], str(labelmap_file), @@ -639,14 +584,14 @@ def shared_segmentation_files(case_id: str) -> tuple[Path, Path]: fold_results: dict[str, Any] = {} for fold_index, held_out_case in enumerate(held_out_cases): - logger.info("%s", "=" * 48) - logger.info( + logger.log_info("%s", "=" * 48) + logger.log_info( "Fold %d/%d: holding out %s", fold_index + 1, len(held_out_cases), held_out_case, ) - logger.info("%s", "=" * 48) + logger.log_info("%s", "=" * 48) fold_dir = output_dir / f"fold_{held_out_case}" fits_dir = fold_dir / "fits" @@ -719,7 +664,7 @@ def shared_segmentation_files(case_id: str) -> tuple[Path, Path]: if not ( fitted_reference_mesh_file.exists() and pca_coefficients_file.exists() ): - logger.info("Fold %s: fitting %s", held_out_case, case_id) + logger.log_info("Fold %s: fitting %s", held_out_case, case_id) surface_file, labelmap_file = shared_segmentation_files(case_id) fit_workflow = WorkflowFitStatisticalModelToPatient( template_model=pca_mean_surface, @@ -793,7 +738,16 @@ def shared_segmentation_files(case_id: str) -> tuple[Path, Path]: # having to be told which ones the others wrote. manifests_dir = fold_dir / "manifests_mgn" for case_id in _rank_share(case_ids, context): - _write_case_manifest(fits_dir / case_id, manifests_dir) + case_fit_dir = fits_dir / case_id + phase_files = sorted(case_fit_dir.glob(f"{case_id}_g0??_ssm_surface.vtp")) + ProcessPhysicsNemo.write_manifest( + subject_id=case_id, + fitted_reference_mesh=case_fit_dir / f"{case_id}_ssm_surface.vtp", + pca_coefficients=case_fit_dir / f"{case_id}_ssm_pca_coefficients.json", + phase_meshes=phase_files, + stages=[cohort.stage_from_filename(path) for path in phase_files], + output_directory=manifests_dir, + ) context.barrier() manifests = { @@ -903,8 +857,8 @@ def shared_segmentation_files(case_id: str) -> tuple[Path, Path]: plot_file = _plot_metrics_by_label( all_rows, lobe_names, output_dir / "loo_metrics_by_label.png" ) - logger.info("Metrics: %s", metrics_file) - logger.info("Report: %s", report_file) + logger.log_info("Metrics: %s", metrics_file) + logger.log_info("Report: %s", report_file) tutorial_results["metrics_file"] = metrics_file tutorial_results["report_file"] = report_file @@ -938,4 +892,4 @@ def shared_segmentation_files(case_id: str) -> tuple[Path, Path]: color="steelblue", ), ] - logger.info("Last fold report: %s", last_fold["report_file"]) + logger.log_info("Last fold report: %s", last_fold["report_file"]) diff --git a/tutorials/tutorial_16_duke_heart_physics_informed_motion_prep.py b/tutorials/tutorial_16_duke_heart_physics_informed_motion_prep.py index fd30d7f7..9ccd9dc6 100644 --- a/tutorials/tutorial_16_duke_heart_physics_informed_motion_prep.py +++ b/tutorials/tutorial_16_duke_heart_physics_informed_motion_prep.py @@ -15,7 +15,7 @@ 1. Build the unbiased mean surface of the population, exactly as Tutorial 6 does. 2. Fill that surface with tetrahedra - (``ProcessContours.extract_tetrahedra`` then ``trim_tetrahedra_to_surface``). + (``ProcessVolumetricMesh.extract_tetrahedra`` then ``trim_tetrahedra_to_surface``). Those cells become the elements the strain energy is summed over, and because every subject and phase inherits the template's topology, one set of element node ids stays valid across the whole cohort. @@ -81,60 +81,39 @@ from parameters_duke_heart_physics_informed import DUKE_HEART_PHYSICS_INFORMED from monai_physio import ( - ProcessContours, + EvaluateMovementDukeHeart, + MONAIPhysioBase, + ProcessPhysicsNemo, + ProcessSurfaceMesh, ProcessTests, - RegisterModelsDistanceMaps, + ProcessVolumetricMesh, WorkflowCreateMeanSurface, WorkflowCreateStatisticalModel, WorkflowFitStatisticalModelToPatient, ) -# Structure name Tutorial 4 (Duke Heart) writes its whole-heart surfaces under. -WHOLE_HEART_NAME = "heart_minus_interior_chambers" LABELMAP_SUFFIX = "_labelmap.nii.gz" -# Point-data array this tutorial writes its targets into and the manifests name. -TARGET_ARRAY = "displacement" - # Gated frames carry a ``g{PPP}`` tag naming their percentage of the R-R # interval; this is what a per-frame model is matched and staged by. PHASE_MODEL_PATTERN = "*_g[0-9][0-9][0-9]_*_ssm_model.vtu" -def _cardiac_stage_from_filename(model_file: Path) -> float: - """Extract the normalized cardiac stage [0, 1] from a ``g{PPP}`` filename stem.""" - for part in model_file.stem.split("_"): - if part.startswith("g") and part[1:].isdigit(): - return int(part[1:]) / 100.0 - raise ValueError(f"Cannot parse cardiac gate from filename: {model_file}") - - -def _write_target_mesh( - phase_file: Path, reference_points: np.ndarray, targets_dir: Path -) -> Path: - """Write one frame's training target and return the mesh path. - - The target is the per-vertex displacement from the case's own fitted - reference model, stored as the ``TARGET_ARRAY`` point-data array on a copy - of the frame's mesh. Written as a volume, so the network's outputs land on - the same nodes the physics elements are indexed against. - """ - phase_mesh = pv.read(str(phase_file)) - phase_points = np.asarray(phase_mesh.points, dtype=np.float32) - phase_mesh.point_data[TARGET_ARRAY] = phase_points - reference_points - target_path = targets_dir / f"{phase_file.stem}_target.vtu" - phase_mesh.save(str(target_path)) - return target_path - - def _write_case_manifest( - case_dir: Path, manifests_dir: Path, logger: logging.Logger + case_dir: Path, + manifests_dir: Path, + logger: MONAIPhysioBase, + evaluator: EvaluateMovementDukeHeart, ) -> Optional[Path]: """Write a per-case manifest JSON; return its path (or None if incomplete). A case needs a fitted reference model, a PCA coefficient file and at least two gated frames. One missing any of them is skipped with the reason logged, so a half-finished run is distinguishable from one that never ran. + Each frame's target is its per-node displacement from the case's own + fitted reference model, written as a volume so the network's outputs land + on the nodes the physics elements are indexed against; *evaluator* stages + it by its ``g{PPP}`` tag. """ case_id = case_dir.name fitted_reference_model_file = case_dir / f"{case_id}_ssm_model.vtu" @@ -148,30 +127,17 @@ def _write_case_manifest( if len(phase_files) < 2: missing.append(f"at least 2 frame models (found {len(phase_files)})") if missing: - logger.warning("Skipping %s: missing %s", case_id, "; ".join(missing)) + logger.log_warning("Skipping %s: missing %s", case_id, "; ".join(missing)) return None - manifests_dir.mkdir(parents=True, exist_ok=True) - reference_points = np.asarray( - pv.read(str(fitted_reference_model_file)).points, dtype=np.float32 + manifest_path: Path = ProcessPhysicsNemo.write_manifest( + subject_id=case_id, + fitted_reference_mesh=fitted_reference_model_file, + pca_coefficients=pca_file, + phase_meshes=phase_files, + stages=[evaluator.stage_from_filename(path) for path in phase_files], + output_directory=manifests_dir, ) - manifest = { - "subject_id": case_id, - "fitted_reference_mesh": str(fitted_reference_model_file), - "pca_coefficients": str(pca_file), - "target_array": TARGET_ARRAY, - "phases": [ - { - "mesh": str( - _write_target_mesh(phase_file, reference_points, manifests_dir) - ), - "stage": _cardiac_stage_from_filename(phase_file), - } - for phase_file in phase_files - ], - } - manifest_path = manifests_dir / f"{case_id}_manifest.json" - manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") return manifest_path @@ -191,7 +157,6 @@ def _write_case_manifest( parameters = DUKE_HEART_PHYSICS_INFORMED output_dir = parameters.prep_directory(test_mode) - weights_dir = parameters.weights_directory(test_mode) baselines_dir = repo_root / "tests" / "baselines" # Tutorial 4's reference-frame surfaces, which the model is built from. @@ -222,33 +187,17 @@ def _write_case_manifest( # and 6 drop, so the frames and the model describe the same structure. interior_object_ids = parameters.interior_object_ids - # Contouring grid, shared with Tutorial 4 so every surface here carries the - # same level of detail as the model's training surfaces. - surface_spacing_mm = parameters.surface_spacing_mm - smoothing_iterations = parameters.surface_smoothing_iterations - - # Pitch of the grid the phase distance maps are rasterized on. Coarser than - # the contouring pitch: it carries a distance field, not a boundary. - registration_spacing_mm = 1.0 - # Distance-map weights finetuned by # tutorial_02_duke_heart_distancemap_finetune_icon.py. Stock uniGradICON # weights are out of distribution for distance maps, so without these the # correspondences barely move off the template and the modes come out far # too tight. - icon_weights_path = ( - weights_dir - / "icon_duke_heart_distancemap" - / "icon_duke_heart_distancemap_model" - / "checkpoints" - / "network_weights_final.trch" - ) + icon_weights_path = parameters.distancemap_icon_weights_file(test_mode) log_level = logging.INFO # Directory setup and data reading - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) # A GPU is assumed. Every registration, fit and network pass below runs on # one, so fail here rather than hours into the cohort at the first call. @@ -261,7 +210,8 @@ def _write_case_manifest( ) output_dir.mkdir(parents=True, exist_ok=True) - contour_tools = ProcessContours(log_level=log_level) + contour_tools = ProcessSurfaceMesh(log_level=log_level) + volume_tools = ProcessVolumetricMesh(log_level=log_level) tutorial_results: dict[str, Any] = {"cases": {}, "screenshots": []} @@ -269,7 +219,7 @@ def _write_case_manifest( # scores the surrogate on that case, so the model must not have seen it. sample_files = [ path - for path in sorted(input_dir.glob(f"*_ref_{WHOLE_HEART_NAME}.vtp")) + for path in sorted(input_dir.glob(f"*_ref_{parameters.whole_heart_name}.vtp")) if not path.name.startswith(parameters.hold_out_case) ] if test_mode: @@ -352,8 +302,8 @@ def _write_case_manifest( reference_mask = contour_tools.create_mask_from_mesh( reference_surface, voxelization_grid ) - template_mesh = contour_tools.trim_tetrahedra_to_surface( - contour_tools.extract_tetrahedra( + template_mesh = volume_tools.trim_tetrahedra_to_surface( + volume_tools.extract_tetrahedra( reference_mask, element_size_mm=ssm_element_size_mm ), reference_surface, @@ -366,7 +316,7 @@ def _write_case_manifest( element_quality = np.asarray( template_mesh.cell_quality(["scaled_jacobian"]).cell_data["scaled_jacobian"] ) - logger.info( + logger.log_info( "Template: %d nodes, %d elements, %.0f mm^3 (%.1f%% of the %.0f mm^3 the " "mean surface encloses), scaled Jacobian min %.3f mean %.3f", template_mesh.n_points, @@ -432,38 +382,6 @@ def _write_case_manifest( "See data/Duke-Heart-4DLabelmaps/README.md." ) - def heart_surface_for(labelmap_file: Path, case_output_dir: Path) -> pv.PolyData: - """Return one frame's whole heart, minus its chamber cavities. - - Tutorial 4's ``"full"`` pass contours this surface for every gated - frame, so its output is read when present. Otherwise the surface is - contoured here and cached, so a re-run pays for it once. - """ - stem = labelmap_file.name[: -len(LABELMAP_SUFFIX)] - tutorial_04_file = input_dir / f"{stem}_{WHOLE_HEART_NAME}.vtp" - if tutorial_04_file.exists(): - return cast(pv.PolyData, pv.read(str(tutorial_04_file))) - - surface_file = case_output_dir / f"{stem}_heart_surface.vtp" - if not surface_file.exists(): - labelmap = itk.imread(str(labelmap_file)) - labels = itk.GetArrayViewFromImage(labelmap) - heart_ids = [ - int(value) - for value in np.unique(labels) - if value != 0 and int(value) not in interior_object_ids - ] - heart_mask = itk.GetImageFromArray( - np.isin(labels, heart_ids).astype(np.uint8) - ) - heart_mask.CopyInformation(labelmap) - contour_tools.extract_label_surfaces( - heart_mask, - isotropic_spacing_mm=surface_spacing_mm, - smoothing_iterations=smoothing_iterations, - )[1].save(str(surface_file)) - return cast(pv.PolyData, pv.read(str(surface_file))) - for case_dir in case_dirs: case_id = case_dir.name frame_files = sorted(case_dir.glob(f"*{LABELMAP_SUFFIX}")) @@ -471,19 +389,27 @@ def heart_surface_for(labelmap_file: Path, case_output_dir: Path) -> pv.PolyData path for path in frame_files if path.name.endswith(f"_ref{LABELMAP_SUFFIX}") ] if not reference_files: - logger.warning("Skipping %s: no *_ref_labelmap.nii.gz frame", case_id) + logger.log_warning("Skipping %s: no *_ref_labelmap.nii.gz frame", case_id) continue reference_file = reference_files[0] + frame_files = parameters.frames_to_register( + frame_files, + reference_file, + test_mode, + scored=case_id == parameters.hold_out_case, + ) - logger.info("%s", "=" * 48) - logger.info("Processing case %s: %d gated frames", case_id, len(frame_files)) - logger.info("%s", "=" * 48) + logger.log_section( + "Processing case %s: %d gated frames", case_id, len(frame_files), width=48 + ) case_output_dir = output_dir / case_id case_output_dir.mkdir(parents=True, exist_ok=True) reference_labelmap = itk.imread(str(reference_file)) - reference_frame_surface = heart_surface_for(reference_file, case_output_dir) + reference_frame_surface = parameters.whole_heart_surface( + reference_file, test_mode, log_level + ) fitted_reference_model_file = case_output_dir / f"{case_id}_ssm_model.vtu" pca_coefficients_file = case_output_dir / f"{case_id}_ssm_pca_coefficients.json" @@ -539,11 +465,11 @@ def heart_surface_for(labelmap_file: Path, case_output_dir: Path) -> pv.PolyData # that dies on it -- skip the subject and keep the population # going rather than losing every case after it. try: - fitted_reference_model = contour_tools.repair_inverted_tetrahedra( + fitted_reference_model = volume_tools.repair_inverted_tetrahedra( cast(pv.UnstructuredGrid, fit_result["fitted_reference_model"]) ) except ValueError as error: - logger.warning( + logger.log_warning( "Skipping %s: fitted reference model could not be repaired: %s", case_id, error, @@ -554,17 +480,12 @@ def heart_surface_for(labelmap_file: Path, case_output_dir: Path) -> pv.PolyData str(case_output_dir / f"{case_id}_ssm_surface.vtp") ) fitted_reference_model = cast( - pv.DataSet, pv.read(str(fitted_reference_model_file)) + pv.UnstructuredGrid, pv.read(str(fitted_reference_model_file)) ) # One grid is built around the reference frame's heart and reused by # every frame, so the whole case is registered in a common space. - registration_grid = contour_tools.create_reference_image( - mesh=reference_frame_surface, - spatial_resolution=registration_spacing_mm, - buffer_factor=0.25, - ptype=itk.F, - ) + registration_grid = parameters.frame_registration_grid(reference_frame_surface) phase_outputs = [] for frame_file in frame_files: @@ -578,26 +499,22 @@ def heart_surface_for(labelmap_file: Path, case_output_dir: Path) -> pv.PolyData if not (model_path.exists() and surface_path.exists()): if frame_file == reference_file: # The fit already placed the model on this frame. - logger.info("Case %s: reference frame %s", case_id, stem) - phase_model = fitted_reference_model + logger.log_info("Case %s: reference frame %s", case_id, stem) + phase_model: pv.DataSet = fitted_reference_model else: - logger.info("Case %s: warping to frame %s", case_id, stem) - registrar = RegisterModelsDistanceMaps( - moving_model=cast(pv.PolyData, fitted_reference_model), - fixed_model=heart_surface_for(frame_file, case_output_dir), - reference_image=registration_grid, - distance_squared_max=parameters.distancemap_squared_max, - mask_dilation_mm=parameters.mask_dilation_mm, - log_level=log_level, - ) - if use_finetuned_weights: - registrar.set_icon_weights_path(str(icon_weights_path)) + logger.log_info("Case %s: warping to frame %s", case_id, stem) # The transform is applied point-wise, so the tetrahedra # ride along and the warped frame keeps the template's # topology. - phase_model = registrar.register(transform_type="Deformable")[ - "registered_model" - ] + phase_model = parameters.register_to_frame( + fitted_reference_model, + parameters.whole_heart_surface( + frame_file, test_mode, log_level + ), + registration_grid, + test_mode, + log_level, + ) phase_model.save(str(model_path)) phase_model.extract_surface(algorithm="dataset_surface").save( str(surface_path) @@ -624,9 +541,10 @@ def heart_surface_for(labelmap_file: Path, case_output_dir: Path) -> pv.PolyData # Step 5: one manifest per case, which is what Tutorial 17 trains from. manifests_dir = output_dir / "manifests" manifests: dict[str, Path] = {} + evaluator = EvaluateMovementDukeHeart(log_level=log_level) for case_id in tutorial_results["cases"]: manifest_path = _write_case_manifest( - output_dir / case_id, manifests_dir, logger + output_dir / case_id, manifests_dir, logger, evaluator ) if manifest_path is not None: manifests[case_id] = manifest_path @@ -639,7 +557,7 @@ def heart_surface_for(labelmap_file: Path, case_output_dir: Path) -> pv.PolyData tutorial_results["template_file"] = template_file tutorial_results["model_file"] = model_file tutorial_results["mean_volume_file"] = mean_volume_file - logger.info("Wrote %d manifests under %s", len(manifests), manifests_dir) + logger.log_info("Wrote %d manifests under %s", len(manifests), manifests_dir) # Testing tt = ProcessTests( diff --git a/tutorials/tutorial_17_duke_heart_physics_informed_motion_train.py b/tutorials/tutorial_17_duke_heart_physics_informed_motion_train.py index b708acc1..74fa54cd 100644 --- a/tutorials/tutorial_17_duke_heart_physics_informed_motion_train.py +++ b/tutorials/tutorial_17_duke_heart_physics_informed_motion_train.py @@ -79,7 +79,7 @@ import pyvista as pv from parameters_duke_heart_physics_informed import DUKE_HEART_PHYSICS_INFORMED -from monai_physio import ProcessTests, WorkflowTrainPhysicsNeMo +from monai_physio import MONAIPhysioBase, ProcessTests, WorkflowTrainPhysicsNeMo from monai_physio.train_physicsnemo_physics_informed_motion import ( PhysicsInformedMotion, TrainPhysicsNeMoPhysicsInformedMotion, @@ -87,7 +87,7 @@ def _report_reference_mesh_quality( - reference_meshes: dict[str, Path], tets: np.ndarray, logger: logging.Logger + reference_meshes: dict[str, Path], tets: np.ndarray, logger: MONAIPhysioBase ) -> None: """Log which subjects' fitted reference meshes still have inverted or degenerate tetrahedra, and which specific elements, before training @@ -105,7 +105,7 @@ def _report_reference_mesh_quality( print_bad = bad[:100].tolist() else: print_bad = bad.tolist() - logger.warning( + logger.log_warning( "%s (%s): %d of %d tetrahedra inverted or degenerate before repair: %s", subject_id, mesh_path, @@ -199,8 +199,7 @@ def _plot_losses(loss_curves: dict[str, list[float]], plot_file: Path) -> Path: log_level = logging.INFO # Directory setup and data reading - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) for required_file in (template_file, mean_volume_file): if not required_file.exists(): @@ -243,7 +242,7 @@ def _plot_losses(loss_curves: dict[str, list[float]], plot_file: Path) -> Path: for case_id, manifest_path in manifests.items() if case_id not in test_cases and case_id not in val_cases ] - logger.info( + logger.log_info( "Case split - train: %d, val: %d, test: %d", len(train_manifests), len(val_manifests), @@ -253,8 +252,8 @@ def _plot_losses(loss_curves: dict[str, list[float]], plot_file: Path) -> Path: # The template's own cells are the physics elements: every subject and phase # inherits its topology, so one set of node ids is valid for all of them. template_mesh = cast(pv.UnstructuredGrid, pv.read(str(template_file))) - tets = template_mesh.cells_dict[np.uint8(pv.CellType.TETRA)] - logger.info( + tets = cast(np.ndarray, template_mesh.cells_dict[np.uint8(pv.CellType.TETRA)]) + logger.log_info( "Physics elements: %d tetrahedra over %d nodes", len(tets), template_mesh.n_points, @@ -276,9 +275,9 @@ def _train( weight_of_physics: float, output_directory: Path, label: str ) -> dict[str, Any]: """Train one model and return the workflow's result.""" - logger.info("%s", "=" * 48) - logger.info("Training %s (lambda_physics=%.4g)", label, weight_of_physics) - logger.info("%s", "=" * 48) + logger.log_info("%s", "=" * 48) + logger.log_info("Training %s (lambda_physics=%.4g)", label, weight_of_physics) + logger.log_info("%s", "=" * 48) training_method = TrainPhysicsNeMoPhysicsInformedMotion(log_level=log_level) training_method.set_epochs(epochs) @@ -321,7 +320,7 @@ def _train( result: dict[str, Any] = workflow.process() inverted = training_method.inverted_element_count if inverted: - logger.warning( + logger.log_warning( "%s: %d element evaluations inverted during training; the " "clamp kept them finite, but the predicted motion turns tissue " "inside out somewhere.", @@ -354,7 +353,7 @@ def _train( tutorial_results["model_directory"] = physics_result["output_directory"] tutorial_results["checkpoint"] = physics_result["checkpoint"] tutorial_results["plot_file"] = plot_file - logger.info("Physics-informed model: %s", physics_result["output_directory"]) + logger.log_info("Physics-informed model: %s", physics_result["output_directory"]) # Testing tt = ProcessTests( diff --git a/tutorials/tutorial_18_duke_heart_physics_informed_motion_infer.py b/tutorials/tutorial_18_duke_heart_physics_informed_motion_infer.py index 7b9a8a25..f912526a 100644 --- a/tutorials/tutorial_18_duke_heart_physics_informed_motion_infer.py +++ b/tutorials/tutorial_18_duke_heart_physics_informed_motion_infer.py @@ -66,6 +66,7 @@ ConvertVTKToUSD, EvaluateMovementDukeHeart, InferPhysicsNeMoMGN, + MONAIPhysioBase, ProcessTests, WorkflowEvaluateMovement, WorkflowInferMovement, @@ -209,8 +210,7 @@ def _render_stress(surface: pv.DataSet, plot_file: Path) -> Path: log_level = logging.INFO # Directory setup and data reading - logging.basicConfig(level=log_level) - logger = logging.getLogger(class_name) + logger = MONAIPhysioBase(class_name=class_name, log_level=log_level) # A GPU is assumed. Every registration, fit and network pass below runs on # one, so fail here rather than hours into the cohort at the first call. @@ -246,7 +246,7 @@ def _render_stress(surface: pv.DataSet, plot_file: Path) -> Path: if ablation_directory.exists(): model_directories["ablation"] = ablation_directory else: - logger.warning( + logger.log_warning( "No ablation model at %s; scoring the physics-informed model alone. " "Set train_ablation_baseline and re-run Tutorial 17 to compare.", ablation_directory, @@ -271,9 +271,9 @@ def _render_stress(surface: pv.DataSet, plot_file: Path) -> Path: # Step 2: predict and score with each model in turn. tutorial_results: dict[str, Any] = {"models": {}} for name, model_directory in model_directories.items(): - logger.info("%s", "=" * 48) - logger.info("Scoring %s model from %s", name, model_directory) - logger.info("%s", "=" * 48) + logger.log_info("%s", "=" * 48) + logger.log_info("Scoring %s model from %s", name, model_directory) + logger.log_info("%s", "=" * 48) infer_workflow = WorkflowInferPhysicsNeMo( model_directory=model_directory, @@ -314,13 +314,13 @@ def _render_stress(surface: pv.DataSet, plot_file: Path) -> Path: for row in rows: writer.writerow({"model": name, **row}) tutorial_results["comparison_file"] = comparison_file - logger.info("Comparison: %s", comparison_file) + logger.log_info("Comparison: %s", comparison_file) # Step 4: the stress the physics-informed prediction implies. The # deformation gradient is formed against the case's own fitted reference, # which is the configuration its displacements are measured from. template_mesh = cast(pv.UnstructuredGrid, pv.read(str(template_file))) - tets = template_mesh.cells_dict[np.uint8(pv.CellType.TETRA)] + tets = cast(np.ndarray, template_mesh.cells_dict[np.uint8(pv.CellType.TETRA)]) reference_model = cast(pv.DataSet, pv.read(str(fitted_reference_model_file))) law = NeoHookeanResidual(mu_kpa, lambda_lame_kpa, log_level=log_level) @@ -348,7 +348,7 @@ def _render_stress(surface: pv.DataSet, plot_file: Path) -> Path: stress_files.append(stress_file) if law.inverted_element_count: - logger.warning( + logger.log_warning( "%d element evaluations inverted across the predicted phases; the " "predicted motion turns tissue inside out somewhere.", law.inverted_element_count, @@ -369,7 +369,7 @@ def _render_stress(surface: pv.DataSet, plot_file: Path) -> Path: converter.set_colormap("von_mises_stress", "rainbow", STRESS_RANGE_KPA) converter.convert(str(usd_file)) tutorial_results["usd_file"] = usd_file - logger.info("USD: %s", usd_file) + logger.log_info("USD: %s", usd_file) # Testing tt = ProcessTests( diff --git a/utils/migrate_process_contours.py b/utils/migrate_process_contours.py new file mode 100644 index 00000000..d7c9050a --- /dev/null +++ b/utils/migrate_process_contours.py @@ -0,0 +1,187 @@ +"""Migrate code from ``ProcessContours`` to the split mesh tools. + +``ProcessContours`` was split: its tetrahedral methods (``extract_tetrahedra``, +``trim_tetrahedra_to_surface``, ``repair_inverted_tetrahedra``) moved to +:class:`ProcessVolumetricMesh`, ``apply_anatomy_color`` to +:class:`ProcessUSDAnatomy`, the savers (``save_surfaces``, +``save_combined_surfaces``) to :class:`WorkflowConvertImageToVTK`, and +everything else to :class:`ProcessSurfaceMesh`. This rewrites, in ``.py`` and +``.ipynb`` files: + +- ``monai_physio.process_contours`` → ``monai_physio.process_surface_mesh``; +- the whole word ``ProcessContours`` → ``ProcessSurfaceMesh``; +- on a surface-tools receiver - ``ProcessSurfaceMesh`` itself, or a name the + file assigns ``ProcessSurfaceMesh(...)`` to, such as ``contour_tools`` or + ``self.contour_tools`` - ``.extract_tetrahedra(`` (and the other + two tetrahedral methods) → ``ProcessVolumetricMesh().extract_tetrahedra(``, + ``.apply_anatomy_color(`` → + ``ProcessUSDAnatomy(stage=None).apply_anatomy_color(``, and + ``.save_surfaces(`` / ``save_combined_surfaces(`` → + ``WorkflowConvertImageToVTK.save_surfaces(`` and so on; + +adding ``from monai_physio import `` for each class a rewrite needs, +after the first single-line ``from monai_physio... import`` of the file. A +call on any other receiver is left alone, so check calls on objects passed in +from elsewhere by hand. + +These have no mechanical rewrite, so lines using them are flagged instead: + +- ``extract_contours`` and ``extract_watertight_surface``: use + ``ProcessSurfaceMesh.extract_label_surfaces``, then + ``ProcessSurfaceMesh.combine_surfaces`` for one multi-label mesh, or ``[1]`` + for a binary mask's surface; +- ``boundary_labels``: read per-cell ``SegmentationLabelIds`` instead; +- ``segmentation_masks``: no longer returned by + ``WorkflowConvertImageToVTK.process``; +- a path passed to ``ProcessUSD``'s stage methods: open the stage with + ``ProcessUSD.open_stage`` and write it with ``ProcessUSD.save_stage``. + +Calls already made on the right class are left alone. Every changed line is +printed, and so is every flagged line and any import that could not be placed; +fix those by hand. + +Usage:: + + py utils/migrate_process_contours.py --dry-run PATH [PATH ...] + py utils/migrate_process_contours.py PATH [PATH ...] + +Each ``PATH`` is a file or a directory searched recursively. +""" + +from __future__ import annotations + +import argparse +import difflib +import re +from pathlib import Path + +TETRA_METHODS = ( + "extract_tetrahedra", + "trim_tetrahedra_to_surface", + "repair_inverted_tetrahedra", +) +SAVE_METHODS = ("save_surfaces", "save_combined_surfaces") +#: Class → (methods that moved to it off ProcessContours, and the expression +#: their calls are now made on). +MOVED_METHODS = { + "ProcessVolumetricMesh": (TETRA_METHODS, "ProcessVolumetricMesh()"), + "ProcessUSDAnatomy": (("apply_anatomy_color",), "ProcessUSDAnatomy(stage=None)"), + "WorkflowConvertImageToVTK": (SAVE_METHODS, "WorkflowConvertImageToVTK"), +} +USD_STAGE_METHODS = ( + "list_mesh_primvars", + "apply_colormap_from_primvar", + "set_solid_display_color", + "list_mesh_paths_under", + "repair_mesh_primvar_element_sizes", +) +FLAGGED = re.compile( + r"\b(extract_contours|extract_watertight_surface|boundary_labels" + r"|segmentation_masks)\b" + rf"|\.({'|'.join(USD_STAGE_METHODS)})\(\s*(str\(|[\"'])" +) + + +def _surface_receivers(text: str) -> set[str]: + """Return the names *text* uses for surface tools, by their last component.""" + assigned = re.findall( + r"([A-Za-z_][\w.]*)\s*(?::[^=\n]+)?=\s*ProcessSurfaceMesh\(", text + ) + return {"ProcessSurfaceMesh"} | {name.split(".")[-1] for name in assigned} + + +def _call_pattern(receivers: set[str], methods: tuple[str, ...]) -> re.Pattern[str]: + """Match ``.(``, the receiver optionally called or dotted.""" + names = "|".join(re.escape(name) for name in sorted(receivers)) + return re.compile( + rf"(? tuple[str, bool]: + """Return *text* importing *name*, and whether no place for it was found.""" + if re.search(rf"import[^\n]*\b{name}\b", text): + return text, False + lines = text.split("\n") + for index, line in enumerate(lines): + if re.match(r"\s*from monai_physio[\w.]* import [^(\n]+$", line): + indent = line[: len(line) - len(line.lstrip())] + lines.insert(index + 1, f"{indent}from monai_physio import {name}") + return "\n".join(lines), False + return text, True + + +def migrate(text: str) -> tuple[str, list[str]]: + """Return *text* migrated, and the imports that must be added by hand.""" + text = text.replace( + "monai_physio.process_contours", "monai_physio.process_surface_mesh" + ) + text = text.replace( + "from .process_contours import", "from .process_surface_mesh import" + ) + text = re.sub(r"\bProcessContours\b", "ProcessSurfaceMesh", text) + receivers = _surface_receivers(text) + missing: list[str] = [] + for owner, (methods, receiver) in MOVED_METHODS.items(): + text, calls = _call_pattern(receivers, methods).subn(rf"{receiver}.\1(", text) + # References that are not calls, such as ":meth:" roles in docstrings. + text = re.sub( + rf"\bProcessSurfaceMesh\.({'|'.join(methods)})\b", rf"{owner}.\1", text + ) + if calls: + text, not_placed = _add_import(text, owner) + if not_placed: + missing.append(owner) + return text, missing + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("paths", nargs="+", type=Path) + parser.add_argument("--dry-run", action="store_true", help="report, write nothing") + args = parser.parse_args() + + files: list[Path] = [] + for path in args.paths: + if path.is_dir(): + files += sorted(p for p in path.rglob("*") if p.suffix in (".py", ".ipynb")) + else: + files.append(path) + + changed = 0 + flagged = 0 + for path in files: + original = path.read_text(encoding="utf-8") + migrated, missing_imports = migrate(original) + hits = [ + (number, line.strip()) + for number, line in enumerate(migrated.split("\n"), start=1) + if FLAGGED.search(line) + ] + if migrated == original and not hits: + continue + print(f"== {path}") + if migrated != original: + changed += 1 + for line in difflib.unified_diff( + original.split("\n"), migrated.split("\n"), lineterm="", n=0 + ): + if line[:1] in "-+" and line[:3] not in ("---", "+++"): + print(f" {line[0]} {line[1:].strip()}") + for name in missing_imports: + print(f" ! add `from monai_physio import {name}` to {path} by hand") + for number, line in hits: + flagged += 1 + print(f" ? line {number}, convert by hand: {line}") + if not args.dry_run and migrated != original: + path.write_text(migrated, encoding="utf-8") + print( + f"{changed} file(s) {'would change' if args.dry_run else 'changed'}, " + f"{flagged} line(s) to convert by hand" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/utils/setup_feature_worktree.py b/utils/setup_feature_worktree.py index a266726e..a68bf9cf 100644 --- a/utils/setup_feature_worktree.py +++ b/utils/setup_feature_worktree.py @@ -451,7 +451,6 @@ def install_dependencies( + [ "torch", "torchvision", - "torchaudio", "--index-url", "https://download.pytorch.org/whl/cu130", ],