From 0e6cab31f46f894695ad466b7986ad3b0ebcad21 Mon Sep 17 00:00:00 2001 From: Stephen Aylward Date: Thu, 17 Sep 2026 13:14:38 -0400 Subject: [PATCH 1/3] ENH: NV-Segment-CT, pretrained weights, lung tutorial fixes --- README.md | 5 +- docs/api/index.rst | 1 + docs/api/segmentation/index.rst | 10 +- docs/api/segmentation/nv_segment_ct.rst | 96 ++++ docs/architecture.rst | 4 +- docs/assets/tutorial_15_lung.png | 3 + docs/cli_scripts/download_data.rst | 32 +- docs/developer/migration_next.md | 39 ++ docs/tutorials.rst | 46 +- src/monai_physio/__init__.py | 2 + src/monai_physio/cli/_method_factories.py | 4 + src/monai_physio/cli/download_data.py | 15 +- src/monai_physio/convert_vtk_to_usd.py | 7 +- src/monai_physio/download_data.py | 43 ++ src/monai_physio/evaluate_movement_lung.py | 12 +- src/monai_physio/process_contours.py | 32 +- src/monai_physio/segment_anatomy_base.py | 11 + src/monai_physio/segment_nv_segment_ct.py | 435 ++++++++++++++++++ .../workflow_create_mean_surface.py | 12 + tutorials/parameters_tcia_4d_lung.py | 12 +- ...utorial_01_lung_gated_ct_to_usd_tetmesh.py | 203 ++++++++ ...torial_06_lung_create_statistical_model.py | 14 +- ...7_lung_fit_statistical_model_to_patient.py | 5 +- ...torial_08_lung_fit_model_to_4d_patients.py | 5 +- .../tutorial_09_lung_train_physicsnemo_mgn.py | 18 +- 25 files changed, 1021 insertions(+), 45 deletions(-) create mode 100644 docs/api/segmentation/nv_segment_ct.rst create mode 100644 docs/assets/tutorial_15_lung.png create mode 100644 docs/developer/migration_next.md create mode 100644 src/monai_physio/segment_nv_segment_ct.py create mode 100644 tutorials/tutorial_01_lung_gated_ct_to_usd_tetmesh.py diff --git a/README.md b/README.md index 8ae17809..6bd377a4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ diagnosis, treatment planning, or clinical decision-making. for users and contributors. Key sections: - [Installation](https://project-monai.github.io/monai-physio/installation.html) and [Quickstart](https://project-monai.github.io/monai-physio/quickstart.html) -- [Tutorials](https://project-monai.github.io/monai-physio/tutorials.html) - runnable end-to-end workflows and their datasets +- [Tutorials](https://project-monai.github.io/monai-physio/tutorials.html) - runnable end-to-end workflows and their datasets. Most numbered tutorials ship multiple organ/dataset variant scripts (heart, lung, duke_heart) that share the same workflow class, so you can see how to adapt a workflow to your own anatomy and data - [CLI & Scripts Guide](https://project-monai.github.io/monai-physio/cli_scripts/overview.html) - command-line tools for conversion, segmentation, registration, and USD workflows - [API Reference](https://project-monai.github.io/monai-physio/api/index.html) - workflow, registration, segmentation, and USD classes - [Developer Guides](https://project-monai.github.io/monai-physio/developer/architecture.html) - architecture, extension points, and implementation conventions @@ -146,5 +146,8 @@ free for academic and commercial use. https://docs.omniverse.nvidia.com/ov/late ### Non-commercial Licenses (optional) * NVIDIA Segment CT MRI AI weights (used in the SegmentNVSegmentCTMRI class, are restricted from commercial use. https://github.com/NVIDIA-Medtech/NV-Segment-CTMR +* NVIDIA Segment CT AI weights (used in the SegmentNVSegmentCT class) are +released under the NVIDIA Open Model License Agreement, research use only, +not for clinical use. https://huggingface.co/nvidia/NV-Segment-CT * TotalSegmentator includes the optional use of some of their research-only models. Using those models assumes that you have the appropriate license key install, otherwise an error occurs. Those models can be disabled by calling ```set_has_academic_license(False)``` member function of the ```SegmentChestTotalSegmentator``` class. diff --git a/docs/api/index.rst b/docs/api/index.rst index 81309155..0cb24650 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -53,6 +53,7 @@ By Category * :class:`~monai_physio.SegmentHeartSimpleware` - Simpleware cardiac segmentation * :class:`~monai_physio.SegmentHeartSimplewareTrimmedBranches` - Simpleware with trimmed great vessels * :class:`~monai_physio.SegmentNVSegmentCTMRI` - NV-Segment-CTMR, CT *and* MRI + * :class:`~monai_physio.SegmentNVSegmentCT` - NV-Segment-CT, CT only **Image Registration** * :class:`~monai_physio.RegisterImagesBase` - Base registration class diff --git a/docs/api/segmentation/index.rst b/docs/api/segmentation/index.rst index d9af6fd6..008ad631 100644 --- a/docs/api/segmentation/index.rst +++ b/docs/api/segmentation/index.rst @@ -15,6 +15,8 @@ MONAI Physio supports multiple segmentation approaches: * **Simpleware**: Cardiac-focused segmentation (requires Simpleware Medical) * **NV-Segment-CTMR**: Whole-body CT *and* MRI segmentation (345 structures, non-commercial license) +* **NV-Segment-CT**: Whole-body CT-only segmentation (117 structures, + research-use-only license) All segmentation classes inherit from :class:`SegmentAnatomyBase` and provide consistent interfaces. @@ -26,6 +28,7 @@ Quick Links * :doc:`totalsegmentator` - TotalSegmentator implementation * :doc:`simpleware` - Simpleware ASCardio cardiac segmentation * :doc:`nv_segment_ct_mri` - NVIDIA NV-Segment-CTMR CT/MRI segmentation + * :doc:`nv_segment_ct` - NVIDIA NV-Segment-CT CT-only segmentation Choosing a Method ================= @@ -39,6 +42,9 @@ Choosing a Method +------------------+------------------+------------------+------------------+ | NV-Segment-CTMR | Medium | Good | CT and MRI | +------------------+------------------+------------------+------------------+ +| NV-Segment-CT | Medium | Good | CT-only, | +| | | | research use | ++------------------+------------------+------------------+------------------+ Quick Start =========== @@ -64,6 +70,7 @@ Module Documentation totalsegmentator simpleware nv_segment_ct_mri + nv_segment_ct Common Operations ================= @@ -120,4 +127,5 @@ See Also .. rubric:: Navigation -:doc:`../index` | :doc:`base` | :doc:`totalsegmentator` | :doc:`simpleware` +:doc:`../index` | :doc:`base` | :doc:`totalsegmentator` | :doc:`simpleware` | +:doc:`nv_segment_ct_mri` | :doc:`nv_segment_ct` diff --git a/docs/api/segmentation/nv_segment_ct.rst b/docs/api/segmentation/nv_segment_ct.rst new file mode 100644 index 00000000..4a01ca0f --- /dev/null +++ b/docs/api/segmentation/nv_segment_ct.rst @@ -0,0 +1,96 @@ +============= +NV-Segment-CT +============= + +.. module:: monai_physio.segment_nv_segment_ct +.. currentmodule:: monai_physio + +``SegmentNVSegmentCT`` runs NVIDIA's NV-Segment-CT model (a VISTA3D +derivative finetuned on CT scans) and groups its labelmap into the anatomy +masks used by MONAI Physio workflows. It covers 117 "segment everything" +classes (132 addressable via label prompt) and supports only automatic +(label-prompt) segmentation - there is no point-click interactive branch +exposed here. + +.. warning:: + + The NV-Segment-CT *weights* are released under the NVIDIA Open Model + License Agreement (research use only, not for clinical use); the + surrounding bundle code is Apache 2.0. NV-Segment-CTMR's weights carry a + different restrictive license (NVIDIA OneWay Non-Commercial License) - + both models are research-use-only, just under different named terms, so + check the license text for your use case. Use + ``SegmentChestTotalSegmentator`` if you need no license restriction at + all. + +Class Reference +=============== + +.. autoclass:: SegmentNVSegmentCT + :members: + :undoc-members: + :show-inheritance: + +Basic Usage +=========== + +.. code-block:: python + + import itk + + from monai_physio import SegmentNVSegmentCT + + image = itk.imread("chest_ct.nrrd") + segmenter = SegmentNVSegmentCT() + + masks = segmenter.segment(image) + + heart = masks["heart"] + lungs = masks["lung"] + labelmap = masks["labelmap"] + + itk.imwrite(labelmap, "labelmap.nrrd", compression=True) + +NV-Segment-CT is CT-only: unlike ``SegmentNVSegmentCTMRI`` there is no +``set_modality()`` call or MRI code path. + +Returned Keys +============= + +For this segmenter, ``segment()`` returns a dictionary with the following +keys: + +* ``labelmap`` +* ``heart`` +* ``major_vessels`` +* ``lung`` +* ``bone`` +* ``soft_tissue`` +* ``other`` + +Label Ids +========= + +Label ids are the model's own published class indices, used verbatim (see +``NV-Segment-CT/configs/label_dict.json`` in +https://github.com/NVIDIA-Medtech/NV-Segment-CTMR), which run to 132. For +example, 6 is the aorta and 115 the heart. The full group->id mapping is +available through the segmenter's ``taxonomy`` attribute +(``segmenter.taxonomy.labels_in_group("heart")``, +``segmenter.taxonomy.all_labels()``). + +Operational Notes +================= + +The first call to ``segment()`` downloads ~872 MB of model weights from +https://huggingface.co/nvidia/NV-Segment-CT into the Hugging Face cache +(override the destination with the ``model_cache_dir`` attribute). Inference +requires a CUDA GPU. + +See Also +======== + +* :doc:`index` +* :doc:`nv_segment_ct_mri` +* :doc:`totalsegmentator` +* :doc:`../../tutorials` diff --git a/docs/architecture.rst b/docs/architecture.rst index 1c9993fc..8a1c2038 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -28,6 +28,7 @@ Data Flow SegmentChestTotalSegmentator / SegmentChestTotalSegmentatorWithContrast SegmentHeartSimpleware / SegmentHeartSimplewareTrimmedBranches SegmentNVSegmentCTMRI (CT + MRI) + SegmentNVSegmentCT (CT only) | v ProcessContours + ProcessTransforms @@ -165,7 +166,8 @@ Component Boundaries Segmentation classes produce anatomy masks or labelmaps from ITK images. ``SegmentAnatomyBase`` subclasses (``SegmentChestTotalSegmentator``, ``SegmentChestTotalSegmentatorWithContrast``, ``SegmentHeartSimpleware``, -``SegmentHeartSimplewareTrimmedBranches``, ``SegmentNVSegmentCTMRI``) share the +``SegmentHeartSimplewareTrimmedBranches``, ``SegmentNVSegmentCTMRI``, +``SegmentNVSegmentCT``) share the same segment/taxonomy interface, so new segmentation methods or anatomy groups slot in without touching the workflow layer. diff --git a/docs/assets/tutorial_15_lung.png b/docs/assets/tutorial_15_lung.png new file mode 100644 index 00000000..895ce292 --- /dev/null +++ b/docs/assets/tutorial_15_lung.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8d7bd6de4f2c6bacb83c6d8519cd71af40978e24deb45f502e996e34372204ef +size 108582 diff --git a/docs/cli_scripts/download_data.rst b/docs/cli_scripts/download_data.rst index 75864a43..61312c5a 100644 --- a/docs/cli_scripts/download_data.rst +++ b/docs/cli_scripts/download_data.rst @@ -33,6 +33,10 @@ Supported Datasets Physio GitHub release. See ``data/TCIA-4DLung/README.md`` for the data source, the full-collection manual download, and required citation. + * - ``PhysicsNeMo-MGN-Lung-Motion`` + - Pretrained PhysicsNeMo MeshGraphNet checkpoint for lung motion, from + the MONAI Physio GitHub release. Used by Lung Tutorial 9 (train) and + Tutorial 10+ (infer). Basic Usage =========== @@ -51,12 +55,13 @@ Options .. code-block:: bash - monai-physio-download-data [Slicer-Heart-CT|KCL-Heart-Model|CHOP-Valve4D|Chest-CT|TCIA-4DLung] [--directory DIRECTORY] + monai-physio-download-data [Slicer-Heart-CT|KCL-Heart-Model|CHOP-Valve4D|Chest-CT|TCIA-4DLung|PhysicsNeMo-MGN-Lung-Motion] [--directory DIRECTORY] ``data_name`` Dataset to download. One of ``Slicer-Heart-CT``, ``KCL-Heart-Model``, - ``CHOP-Valve4D``, ``Chest-CT``, or ``TCIA-4DLung``. Required - omitting - it prints help and exits. + ``CHOP-Valve4D``, ``Chest-CT``, ``TCIA-4DLung``, or + ``PhysicsNeMo-MGN-Lung-Motion``. Required - omitting it prints help and + exits. ``--directory`` Directory where the dataset is stored. Defaults to ``data/``. @@ -131,6 +136,22 @@ the download entirely once any case's phase volumes are already present - this is a converted tutorial subset; see ``data/TCIA-4DLung/README.md`` for how to obtain the full TCIA 4D-Lung collection manually. +For ``PhysicsNeMo-MGN-Lung-Motion``, the command downloads, extracts, and +reuses: + +.. code-block:: text + + tutorials/network_weights/physicsnemo_mgn_lung_motion/mgn_stage_model.pt + tutorials/network_weights/physicsnemo_mgn_lung_motion/ (other epoch + checkpoints and metadata) + +The command uses +:meth:`monai_physio.download_data.DownloadData.DownloadPhysicsNeMoMGNLungMotionData`, +which fetches ``physicsnemo_mgn_lung_motion.zip`` from the MONAI Physio +GitHub release and skips the download once ``mgn_stage_model.pt`` is +already present. Unlike every other dataset, its default ``--directory`` is +``tutorials/network_weights``, not ``data/``. + See Also ======== @@ -142,7 +163,10 @@ See Also individually and may require registration, so it is manual-only, see ``data/DirLab-4DCT/README.md``. ``Duke-Heart-4DLabelmaps``, which drives the ten ``duke_heart`` variants, is being released soon; see - ``data/Duke-Heart-4DLabelmaps/README.md``. + ``data/Duke-Heart-4DLabelmaps/README.md``. ``PhysicsNeMo-MGN-Lung-Motion`` + is the pretrained-checkpoint shortcut for Lung Tutorial 10 (and everything + downstream of it: 11-14), letting a reader skip running Tutorial 9's + training themselves. * :doc:`byod_tutorials` * :doc:`heart_gated_ct` * :doc:`overview` diff --git a/docs/developer/migration_next.md b/docs/developer/migration_next.md new file mode 100644 index 00000000..2e329377 --- /dev/null +++ b/docs/developer/migration_next.md @@ -0,0 +1,39 @@ +# Migration Guide - Unreleased + +Breaking changes committed since the last release, and how to update code that +depends on them. + +MONAI Physio prefers compatibility: public APIs are broken only when the change +is generally beneficial to future users. When a break is unavoidable, the +project does **not** ship deprecation shims or removed-symbol stubs. Instead, +substantial changes ship with code that automates the conversion, and every +break is recorded here in the commit that introduces it. + +At release time this file is renamed `migration_.md` and a fresh +`migration_next.md` is started for the next cycle. + +## Entry template + +Append one section per breaking change, newest last, using this shape: + +````markdown +## - + +**Change:** what moved, was renamed, or changed signature. + +**Why:** the benefit to future users that justified the break. + +**Before** + +```python +old_call(argument) +``` + +**After** + +```python +new_call(argument, required_option="value") +``` + +**Automated conversion:** ``, or `None needed`. +```` diff --git a/docs/tutorials.rst b/docs/tutorials.rst index e91347d4..736361f0 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -34,6 +34,12 @@ package - ``pip install monai-physio`` gives you the library and the See :doc:`quickstart` for version-matched clones and the release tarball link. +Each numbered tutorial below may ship one to three organ/dataset variant +scripts - ``heart``, ``lung``, and ``duke_heart`` - that share the same +workflow class and the same numbered section; where a variant's data, output +path or behaviour differs from the others, that section's ``Script`` and +``Adapt to your data`` blocks call it out inline. + **2. Get the data**, running every download from the top level of the clone. The tutorials resolve their inputs against the repository root (``/data/``), while the CLI writes to ``data/`` @@ -45,6 +51,7 @@ relative to the current working directory: monai-physio-download-data KCL-Heart-Model --directory data/KCL-Heart-Model monai-physio-download-data Chest-CT --directory data/Chest-CT monai-physio-download-data TCIA-4DLung --directory data/TCIA-4DLung + monai-physio-download-data PhysicsNeMo-MGN-Lung-Motion --directory tutorials/network_weights That covers Heart Tutorials 1, 3, 4 and 6 (``Slicer-Heart-CT`` and ``KCL-Heart-Model``), Lung Tutorial 7 (``Chest-CT``, which Tutorial 13 also @@ -54,7 +61,10 @@ collection). The full collection is obtained separately, by downloading its DICOM series from TCIA and running ``data/TCIA-4DLung/convert.py``, if more than the tutorial subset is needed. ``DirLab-4DCT``, used by Heart Tutorial 7, is **not** auto-downloaded: DIR-Lab distributes each case individually -and may require registration. +and may require registration. ``PhysicsNeMo-MGN-Lung-Motion`` fetches the +pretrained lung-motion checkpoint Tutorial 9 otherwise trains, letting a +reader skip Tutorial 9 and start directly at Tutorial 10 (and the chain of +Tutorials 11 through 14, which build on Tutorial 10's output). Tutorials 5 and 9 need no dataset of their own; they consume the outputs of Tutorials 4 and 8. ``Duke-Heart-4DLabelmaps`` drives the fifteen ``duke_heart`` @@ -231,6 +241,13 @@ Script ``tutorials/tutorial_01_lung_gated_ct_to_usd.py`` (TCIA-4DLung) + ``tutorials/tutorial_01_lung_gated_ct_to_usd_tetmesh.py`` (TCIA-4DLung) - + fills the reference-phase lung surface with tetrahedra and warps that one + volume mesh through every phase's registration instead of exporting a + surface-only USD, so every frame shares the reference mesh's connectivity - + needed for downstream finite-element work (e.g. strain energy) that a + surface cannot support. + Workflow :class:`~monai_physio.WorkflowConvertImageToUSD`, driving :class:`~monai_physio.RegisterImagesGreedy` and a @@ -277,11 +294,15 @@ Run python tutorials/tutorial_01_heart_gated_ct_to_usd.py python tutorials/tutorial_01_lung_gated_ct_to_usd.py + python tutorials/tutorial_01_lung_gated_ct_to_usd_tetmesh.py Outputs The animated USD named after ``usd_project_name``, the per-phase registered volumes and labelmaps, and screenshots - all under - ``tutorials/output/tutorial_01_{heart,lung}/``. + ``tutorials/output/tutorial_01_{heart,lung}/``. The tetmesh variant instead + writes one ``lung_tetmesh_.vtu`` per respiratory phase, plus a + reference-surface screenshot, under + ``tutorials/output/tutorial_01_lung_tetmesh/``. Adapt to your data Point ``data_dir`` and the file glob near the top of the script at your own @@ -292,6 +313,8 @@ Adapt to your data matching your anatomy and contrast - see :doc:`api/segmentation/index`. For command-line use without editing code, run ``monai-physio-convert-image-to-usd`` (:doc:`cli_scripts/heart_gated_ct`). + The tetmesh variant points at the same data via the same knobs; tetrahedra + repair for inverted or degenerate elements runs automatically per phase. Tutorial 2: Finetune ICON Registration ====================================== @@ -910,6 +933,10 @@ Outputs (``tutorials/network_weights/physicsnemo_mgn_lung_motion/``, a fresh sibling of it when resuming). The per-case manifests and the held-out evaluation under ``eval_mgn/`` stay in ``tutorials/output/tutorial_09_lung_mgn/``. + A pretrained checkpoint is also available via + ``monai-physio-download-data PhysicsNeMo-MGN-Lung-Motion``, which extracts + to that same weights directory - a shortcut to skip this tutorial and + start at Tutorial 10. Adapt to your data The contract is the manifest, not the tutorial. Each JSON names a reference @@ -936,7 +963,9 @@ Workflow and :class:`~monai_physio.WorkflowConvertVTKToUSD` to export it. Dataset - Tutorial 8's fitted surfaces for one case, and Tutorial 9's checkpoint. + Tutorial 8's fitted surfaces for one case, and Tutorial 9's checkpoint - + or, to skip Tutorial 9 entirely, the same checkpoint fetched with + ``monai-physio-download-data PhysicsNeMo-MGN-Lung-Motion``. Requirements Trivial - one forward pass per stage replaces the per-phase registration @@ -1354,9 +1383,14 @@ Requirements Written for a multi-GPU Linux host, though it runs as a single process too. -.. TODO(image): no preview media exists yet for Tutorial 15. Add a - ``loo_metrics_by_label.png``-derived figure (or similar) here once one is - captured, matching the ``Preview`` pattern used by the other tutorials. +Preview + .. figure:: assets/tutorial_15_lung.png + :alt: Leave-one-out cross-validation error by fold for the lung cohort + :width: 90% + + Held-out prediction error for each fold's lung case, scored against + that fold's own PCA model, fits and network rather than one built from + the whole cohort. What it does Tutorials 6 through 11 report accuracy for one fixed held-out case, which is diff --git a/src/monai_physio/__init__.py b/src/monai_physio/__init__.py index eccf100e..d400f046 100644 --- a/src/monai_physio/__init__.py +++ b/src/monai_physio/__init__.py @@ -106,6 +106,7 @@ from .segment_heart_simpleware_trimmed_branches import ( SegmentHeartSimplewareTrimmedBranches, ) +from .segment_nv_segment_ct import SegmentNVSegmentCT from .segment_nv_segment_ct_mri import SegmentNVSegmentCTMRI from .train_physicsnemo_base import TrainPhysicsNeMoBase from .train_physicsnemo_mgn import TrainPhysicsNeMoMGN @@ -175,6 +176,7 @@ "SegmentChestTotalSegmentatorWithContrast", "SegmentHeartSimpleware", "SegmentHeartSimplewareTrimmedBranches", + "SegmentNVSegmentCT", "SegmentNVSegmentCTMRI", "ProcessTests", # Training method classes diff --git a/src/monai_physio/cli/_method_factories.py b/src/monai_physio/cli/_method_factories.py index 9b0356d0..467fbf94 100644 --- a/src/monai_physio/cli/_method_factories.py +++ b/src/monai_physio/cli/_method_factories.py @@ -15,6 +15,7 @@ SegmentChestTotalSegmentatorWithContrast, SegmentHeartSimpleware, SegmentHeartSimplewareTrimmedBranches, + SegmentNVSegmentCT, SegmentNVSegmentCTMRI, ) @@ -23,6 +24,7 @@ "ChestTotalSegmentator", "HeartSimpleware", "HeartSimplewareTrimmedBranches", + "NVSegmentCT", "NVSegmentCTMR", ) @@ -59,6 +61,8 @@ def build_segmentation_method(name: str, contrast: bool = False) -> SegmentAnato return SegmentHeartSimpleware() if name == "HeartSimplewareTrimmedBranches": return SegmentHeartSimplewareTrimmedBranches() + if name == "NVSegmentCT": + return SegmentNVSegmentCT() if name == "NVSegmentCTMR": return SegmentNVSegmentCTMRI() raise ValueError( diff --git a/src/monai_physio/cli/download_data.py b/src/monai_physio/cli/download_data.py index 28ac3059..7379491f 100644 --- a/src/monai_physio/cli/download_data.py +++ b/src/monai_physio/cli/download_data.py @@ -14,6 +14,7 @@ CHOP_VALVE4D = "CHOP-Valve4D" CHEST_CT = "Chest-CT" TCIA_4D_LUNG = "TCIA-4DLung" +PHYSICSNEMO_MGN_LUNG_MOTION = "PhysicsNeMo-MGN-Lung-Motion" def main(argv: Optional[list[str]] = None) -> int: @@ -28,6 +29,7 @@ def main(argv: Optional[list[str]] = None) -> int: %(prog)s {CHOP_VALVE4D} --directory data/CHOP-Valve4D %(prog)s {CHEST_CT} --directory data/Chest-CT %(prog)s {TCIA_4D_LUNG} --directory data/TCIA-4DLung + %(prog)s {PHYSICSNEMO_MGN_LUNG_MOTION} --directory tutorials/network_weights """, ) parser.add_argument( @@ -39,6 +41,7 @@ def main(argv: Optional[list[str]] = None) -> int: CHOP_VALVE4D, CHEST_CT, TCIA_4D_LUNG, + PHYSICSNEMO_MGN_LUNG_MOTION, ], default=None, help="Dataset to download", @@ -54,7 +57,12 @@ def main(argv: Optional[list[str]] = None) -> int: parser.print_help() return 1 - directory = args.directory or f"data/{args.data_name}" + default_directory = ( + "tutorials/network_weights" + if args.data_name == PHYSICSNEMO_MGN_LUNG_MOTION + else f"data/{args.data_name}" + ) + directory = args.directory or default_directory output_dir = Path(directory) if args.data_name == SLICER_HEART_CT: @@ -82,6 +90,11 @@ def main(argv: Optional[list[str]] = None) -> int: print(f"Downloaded {TCIA_4D_LUNG} to: {data_dir}") return 0 + if args.data_name == PHYSICSNEMO_MGN_LUNG_MOTION: + data_dir = DownloadData.DownloadPhysicsNeMoMGNLungMotionData(output_dir) + print(f"Downloaded {PHYSICSNEMO_MGN_LUNG_MOTION} to: {data_dir}") + return 0 + parser.error(f"Unsupported dataset: {args.data_name}") diff --git a/src/monai_physio/convert_vtk_to_usd.py b/src/monai_physio/convert_vtk_to_usd.py index e13f60a1..706fdd4b 100644 --- a/src/monai_physio/convert_vtk_to_usd.py +++ b/src/monai_physio/convert_vtk_to_usd.py @@ -37,6 +37,7 @@ add_framing_camera, cell_type_name_for_vertex_count, read_vtk_file, + sanitize_primvar_name, split_mesh_data_by_cell_type, split_mesh_data_by_connectivity, validate_time_series_topology, @@ -138,11 +139,15 @@ def __init__( super().__init__(class_name=self.__class__.__name__, log_level=log_level) suffix = Path(data_basename).suffix - self.data_basename = ( + stripped_basename = ( data_basename[: -len(suffix)] if suffix.lower() in {".usd", ".usda", ".usdc"} else data_basename ) + # data_basename becomes a USD prim path component (root_path and, per + # part/label, its descendants), so it must be a valid USD identifier - + # e.g. not start with a digit, as case names like "100_HM10395" do. + self.data_basename = sanitize_primvar_name(stripped_basename) self.input_polydata = list(input_polydata) self.mask_ids = mask_ids self.compute_normals = compute_normals diff --git a/src/monai_physio/download_data.py b/src/monai_physio/download_data.py index 0dd2fd40..af077de6 100644 --- a/src/monai_physio/download_data.py +++ b/src/monai_physio/download_data.py @@ -485,6 +485,49 @@ def FixDirLab4DCTData( _logger.info("Fixed %s -> %s", mhd_file.name, output_file.name) return output_files + PHYSICSNEMO_MGN_LUNG_MOTION_URL = ( + "https://github.com/Project-MONAI/monai-physio/releases/download/2026.07.1/" + "physicsnemo_mgn_lung_motion.zip" + ) + PHYSICSNEMO_MGN_LUNG_MOTION_DIRNAME = "physicsnemo_mgn_lung_motion" + + @staticmethod + def DownloadPhysicsNeMoMGNLungMotionData(dirname: Union[str, Path]) -> Path: + """Download the pretrained lung-motion PhysicsNeMo MGN checkpoint. + + Fetches ``physicsnemo_mgn_lung_motion.zip`` from the MONAI Physio + 2026.07.1 GitHub release and extracts it into ``dirname``, giving + the ``physicsnemo_mgn_lung_motion/mgn_stage_model.pt`` checkpoint + Tutorial 10 and later lung tutorials read. Already-extracted data is + left alone, so re-running is a no-op once it is present. + + Args: + dirname: Directory where ``physicsnemo_mgn_lung_motion/`` should + live, typically ``tutorials/network_weights``. + + Returns: + Path to ``dirname/physicsnemo_mgn_lung_motion``. + """ + data_dir = Path(dirname) + target_dir = data_dir / DownloadData.PHYSICSNEMO_MGN_LUNG_MOTION_DIRNAME + if not DownloadData.VerifyPhysicsNeMoMGNLungMotionData(data_dir): + DownloadData._DownloadAndExtractZip( + DownloadData.PHYSICSNEMO_MGN_LUNG_MOTION_URL, target_dir + ) + _logger.info( + "Downloaded %s", DownloadData.PHYSICSNEMO_MGN_LUNG_MOTION_DIRNAME + ) + return target_dir + + @staticmethod + def VerifyPhysicsNeMoMGNLungMotionData(dirname: Union[str, Path]) -> bool: + """Return True when the lung-motion PhysicsNeMo MGN checkpoint exists.""" + return ( + Path(dirname) + / DownloadData.PHYSICSNEMO_MGN_LUNG_MOTION_DIRNAME + / "mgn_stage_model.pt" + ).is_file() + @staticmethod def VerifyKCLHeartModelData(dirname: Union[str, Path]) -> bool: """Return True when KCL-Heart-Model has its expected mesh inputs.""" diff --git a/src/monai_physio/evaluate_movement_lung.py b/src/monai_physio/evaluate_movement_lung.py index c078c1f0..75376f37 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_nv_segment_ct_mri import SegmentNVSegmentCTMRI +from .segment_chest_total_segmentator import SegmentChestTotalSegmentator class EvaluateMovementLung(EvaluateMovementBase): @@ -29,10 +29,10 @@ class EvaluateMovementLung(EvaluateMovementBase): log_level: Logging level. Default: ``logging.INFO``. """ - segmenter_class = SegmentNVSegmentCTMRI - # The five lobes of ``SegmentNVSegmentCTMRI``. Its "lung" group also carries + segmenter_class = SegmentChestTotalSegmentator + # The five lobes of ``SegmentChestTotalSegmentator``. Its "lung" group also carries # whole-lung, tumor and airway labels, which are not lobes. - label_ids = (28, 29, 30, 31, 32) + label_ids = (10, 11, 12, 13, 14) # 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. @@ -91,9 +91,11 @@ def assemble_ground_truth( cache_directory.mkdir(parents=True, exist_ok=True) segmenter = self.segmenter_class(log_level=self.log_level) + segmenter.set_fast_mode(True) labelmaps: dict[float, itk.Image] = {} for frame_file in frame_files: - labelmap_file = cache_directory / f"{frame_file.stem}_labelmap.nii.gz" + frame_stem = frame_file.name[: -len("".join(frame_file.suffixes))] + labelmap_file = cache_directory / f"{frame_stem}_labelmap.nii.gz" if not labelmap_file.exists(): self.log_info("Segmenting ground-truth frame %s", frame_file.name) segmentation = segmenter.segment(itk.imread(str(frame_file))) diff --git a/src/monai_physio/process_contours.py b/src/monai_physio/process_contours.py index 829bd421..2ebb361a 100644 --- a/src/monai_physio/process_contours.py +++ b/src/monai_physio/process_contours.py @@ -900,15 +900,29 @@ def remesh_and_smooth_surface( conditioned = surface if surface_reduction_rate > 0.0: original = conditioned - clustering = pyacvd.Clustering(conditioned.triangulate()) - # One cluster per retained point. A closed surface carries about - # twice as many triangles as points, so scaling the point count by - # (1 - rate) scales the triangle count by the same fraction; four - # is the fewest clusters that can still close a surface. - clustering.cluster( - max(4, round(original.n_points * (1.0 - surface_reduction_rate))) - ) - conditioned = clustering.create_mesh() + 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") diff --git a/src/monai_physio/segment_anatomy_base.py b/src/monai_physio/segment_anatomy_base.py index 5ebf71fd..be7215c3 100644 --- a/src/monai_physio/segment_anatomy_base.py +++ b/src/monai_physio/segment_anatomy_base.py @@ -109,6 +109,17 @@ def label_to_type(self, label_name: str) -> str: """ return self.taxonomy.group_for_label(label_name) + def set_fast_mode(self, fast_mode: bool) -> None: + """Set the fast mode for the segmenter. + + Args: + fast_mode (bool): Whether to run in fast mode. + + Example: + >>> segmenter.set_fast_mode(True) + """ + self.fast_mode = fast_mode + def set_target_spacing(self, target_spacing: float) -> None: """Set the target isotropic spacing for image resampling. diff --git a/src/monai_physio/segment_nv_segment_ct.py b/src/monai_physio/segment_nv_segment_ct.py new file mode 100644 index 00000000..40015c0e --- /dev/null +++ b/src/monai_physio/segment_nv_segment_ct.py @@ -0,0 +1,435 @@ +"""Module for segmenting CT images using NVIDIA NV-Segment-CT. + +This module provides the SegmentNVSegmentCT class, which implements CT +segmentation using NVIDIA's NV-Segment-CT model (a VISTA3D derivative +finetuned on CT scans). Model weights are downloaded on first use from +https://huggingface.co/nvidia/NV-Segment-CT. + +The labelmap ids emitted by this class are the model's own published class +indices, taken verbatim from ``NV-Segment-CT/configs/label_dict.json`` in +https://github.com/NVIDIA-Medtech/NV-Segment-CTMR (e.g. 6 = aorta, +115 = heart). Those indices run to 132, and the model omits several ids +(2, 16, 18, 20, 21) as deprecated, so unclaimed ids in ``[1, 133)`` end up in +the ``other`` group. +""" + +import glob +import logging +import os +import sys +import tempfile +from typing import Any, Optional + +import itk + +from .segment_anatomy_base import SegmentAnatomyBase + + +class SegmentNVSegmentCT(SegmentAnatomyBase): + """CT segmentation using NVIDIA's NV-Segment-CT model. + + NV-Segment-CT is a VISTA3D-architecture network finetuned on CT scans. It + covers 117 "segment everything" classes (132 addressable via label + prompt) and, like NV-Segment-CTMR, supports only automatic (label-prompt) + segmentation - there is no point-click interactive branch exposed here. + + Model weights (~872 MB) are downloaded from :attr:`hf_repo_id` on the + first call to :meth:`segmentation_method` and cached by ``huggingface_hub`` + thereafter. + + Labelmap ids are the model's published class indices, used verbatim. + + Anatomy groups (heart, major_vessels, lung, bone, soft_tissue) are + populated into :attr:`SegmentAnatomyBase.taxonomy`, reusing the names the + TotalSegmentator and NV-Segment-CTMR backends use so downstream consumers + see the same group keys. + + Licensing: + The NV-Segment-CT *weights* are released under the NVIDIA Open Model + License Agreement (research use only, not for clinical use); the + surrounding bundle code is Apache 2.0. :attr:`license_warning` is + logged at ``WARNING`` on the first call to :meth:`segmentation_method`. + + Attributes: + target_spacing (float): 1.5mm, matching the model bundle's internal + resampling, so the image is interpolated once rather than twice. + model_cache_dir (Optional[str]): Download destination passed to + ``huggingface_hub``. ``None`` uses the default Hugging Face cache. + hf_repo_id (str): Hugging Face repository holding the bundle and + weights. + hf_revision (str): Pinned commit of :attr:`hf_repo_id` to download. + hf_allow_patterns (tuple[str, ...]): Files pulled from + :attr:`hf_repo_id`. + license_warning (str): Banner logged at ``WARNING`` on first use. + + The anatomy labels populated by this class are accessed through the + inherited :attr:`SegmentAnatomyBase.taxonomy` + (``taxonomy.labels_in_group("heart")`` etc.). + + Note: + :attr:`SegmentAnatomyBase.fast_mode` is ignored: this model has a + single network and no reduced-accuracy variant. + + Example: + >>> segmenter = SegmentNVSegmentCT() + >>> result = segmenter.segment(ct_image) + >>> labelmap = result['labelmap'] + >>> heart_labelmap = result['heart'] + """ + + def __init__(self, log_level: int | str = logging.INFO): + """Initialize the NV-Segment-CT-based segmentation. + + Populates :attr:`SegmentAnatomyBase.taxonomy` with the model's class + indices, then calls + :meth:`SegmentAnatomyBase._finalize_other_group` over the model's full + ``[1, 133)`` class index space so unclaimed ids end up in the ``other`` + group. Constructing the class downloads nothing; weights are fetched + lazily by :meth:`segmentation_method`. + + Args: + log_level: Logging level (default: logging.INFO) + """ + super().__init__(log_level=log_level) + + # The bundle resamples to 1.5mm isotropic internally (Spacingd), so + # preprocessing to the same spacing avoids a second interpolation. + self.target_spacing = 1.5 + + self.hf_repo_id = "nvidia/NV-Segment-CT" + + # Pinned to a commit rather than tracking main: the repo publishes no + # tags, and an unpinned download would silently swap the weights (and + # the bundle's pipeline code, which is imported and executed here) + # whenever upstream pushes. Bump deliberately after re-testing. + self.hf_revision = "afb51518689f71e6abb367ee6301b2cd0225c66a" + + # model.safetensors and model_monai1.3.pt are deliberately excluded: + # the former holds the same weights under the raw MONAI keys (no + # 'network.' prefix), so it is unusable here, and the latter is an + # older checkpoint format. Pulling either would bloat the download. + self.hf_allow_patterns = ( + "*.py", + "config.json", + "metadata.json", + "scripts/*.py", + "vista3d_pretrained_model/config.json", + "vista3d_pretrained_model/model.pt", + ) + + self.model_cache_dir: Optional[str] = None + + # The weights carry a more restrictive license than the rest of this + # repository, so the restriction is surfaced at run time rather than + # left to the class docstring. + self.license_warning = ( + "\n" + " ==============================================================\n" + " RESEARCH-ONLY LICENSE\n" + " --------------------------------------------------------------\n" + " NV-Segment-CT weights are released under the NVIDIA Open Model\n" + " License Agreement: research use only, not for clinical use.\n" + " This is more restrictive than the rest of MONAI Physio.\n" + " https://huggingface.co/nvidia/NV-Segment-CT\n" + " ==============================================================" + ) + + # NV-Segment-CT class indices, grouped by anatomy. Ids omitted from + # NV-Segment-CT/configs/label_dict.json (2, 16, 18, 20, 21, and + # everything above 128 except 132) are deprecated or unused and are + # never emitted by the bundle. + for group_name, organs in ( + ( + "heart", + { + 108: "atrial_appendage_left", + 115: "heart", + }, + ), + ( + "major_vessels", + { + 6: "aorta", + 7: "inferior_vena_cava", + 17: "portal_vein_and_splenic_vein", + 25: "hepatic_vessel", + 58: "iliac_artery_left", + 59: "iliac_artery_right", + 60: "iliac_vena_left", + 61: "iliac_vena_right", + 109: "brachiocephalic_trunk", + 110: "brachiocephalic_vein_left", + 111: "brachiocephalic_vein_right", + 112: "common_carotid_artery_left", + 113: "common_carotid_artery_right", + 119: "pulmonary_vein", + 123: "subclavian_artery_left", + 124: "subclavian_artery_right", + 125: "superior_vena_cava", + }, + ), + ( + "lung", + { + 23: "lung_tumor", + 28: "lung_upper_lobe_left", + 29: "lung_lower_lobe_left", + 30: "lung_upper_lobe_right", + 31: "lung_middle_lobe_right", + 32: "lung_lower_lobe_right", + 132: "airway", + }, + ), + ( + "bone", + { + 33: "vertebrae_l5", + 34: "vertebrae_l4", + 35: "vertebrae_l3", + 36: "vertebrae_l2", + 37: "vertebrae_l1", + 38: "vertebrae_t12", + 39: "vertebrae_t11", + 40: "vertebrae_t10", + 41: "vertebrae_t9", + 42: "vertebrae_t8", + 43: "vertebrae_t7", + 44: "vertebrae_t6", + 45: "vertebrae_t5", + 46: "vertebrae_t4", + 47: "vertebrae_t3", + 48: "vertebrae_t2", + 49: "vertebrae_t1", + 50: "vertebrae_c7", + 51: "vertebrae_c6", + 52: "vertebrae_c5", + 53: "vertebrae_c4", + 54: "vertebrae_c3", + 55: "vertebrae_c2", + 56: "vertebrae_c1", + 63: "rib_1_left", + 64: "rib_2_left", + 65: "rib_3_left", + 66: "rib_4_left", + 67: "rib_5_left", + 68: "rib_6_left", + 69: "rib_7_left", + 70: "rib_8_left", + 71: "rib_9_left", + 72: "rib_10_left", + 73: "rib_11_left", + 74: "rib_12_left", + 75: "rib_1_right", + 76: "rib_2_right", + 77: "rib_3_right", + 78: "rib_4_right", + 79: "rib_5_right", + 80: "rib_6_right", + 81: "rib_7_right", + 82: "rib_8_right", + 83: "rib_9_right", + 84: "rib_10_right", + 85: "rib_11_right", + 86: "rib_12_right", + 87: "humerus_left", + 88: "humerus_right", + 89: "scapula_left", + 90: "scapula_right", + 91: "clavicula_left", + 92: "clavicula_right", + 93: "femur_left", + 94: "femur_right", + 95: "hip_left", + 96: "hip_right", + 97: "sacrum", + 114: "costal_cartilages", + 120: "skull", + 122: "sternum", + 127: "vertebrae_s1", + 128: "bone_lesion", + }, + ), + ( + "soft_tissue", + { + 1: "liver", + 3: "spleen", + 4: "pancreas", + 5: "kidney_right", + 8: "adrenal_gland_right", + 9: "adrenal_gland_left", + 10: "gallbladder", + 11: "esophagus", + 12: "stomach", + 13: "duodenum", + 14: "kidney_left", + 15: "bladder", + 19: "small_bowel", + 22: "brain", + 24: "pancreatic_tumor", + 26: "hepatic_tumor", + 27: "colon_cancer_primaries", + 57: "trachea", + 62: "colon", + 98: "gluteus_maximus_left", + 99: "gluteus_maximus_right", + 100: "gluteus_medius_left", + 101: "gluteus_medius_right", + 102: "gluteus_minimus_left", + 103: "gluteus_minimus_right", + 104: "autochthon_left", + 105: "autochthon_right", + 106: "iliopsoas_left", + 107: "iliopsoas_right", + 116: "kidney_cyst_left", + 117: "kidney_cyst_right", + 118: "prostate", + 121: "spinal_cord", + 126: "thyroid_gland", + }, + ), + ): + for label_id, organ_name in organs.items(): + self.taxonomy.add_organ(group_name, label_id, organ_name) + + self._finalize_other_group(range(1, 133)) + + self._snapshot_dir: Optional[str] = None + self._pipeline: Optional[Any] = None + + def _ensure_model(self) -> str: + """Download the NV-Segment-CT bundle if needed and return its path. + + The snapshot is fetched once per instance and cached on disk by + ``huggingface_hub``, so repeated calls are cheap. Logs + :attr:`license_warning` on the first call, before the weights are + obtained. + + Returns: + str: Local directory holding the downloaded bundle. + """ + if self._snapshot_dir is None: + from huggingface_hub import snapshot_download + + self.log_warning(self.license_warning) + self.log_info("Downloading %s (cached after first use)", self.hf_repo_id) + self._snapshot_dir = snapshot_download( + repo_id=self.hf_repo_id, + revision=self.hf_revision, + cache_dir=self.model_cache_dir, + allow_patterns=list(self.hf_allow_patterns), + ) + return self._snapshot_dir + + def _ensure_pipeline(self) -> Any: + """Build the VISTA3D pipeline if needed and return it. + + Weight loading takes seconds and the pipeline is stateless across + calls, so it is built once per instance and reused for every + subsequent image or timepoint. + + Returns: + Any: The bundle's ``VISTA3DPipeline`` on the current CUDA device. + """ + if self._pipeline is None: + snapshot_dir = self._ensure_model() + + # The bundle ships hugging_face_pipeline / vista3d_pipeline as + # top-level modules inside the snapshot rather than as an installed + # package, so the snapshot directory has to be importable. + if snapshot_dir not in sys.path: + sys.path.insert(0, snapshot_dir) + + import torch + from vista3d_config import VISTA3DConfig + from vista3d_model import VISTA3DModel + from vista3d_pipeline import VISTA3DPipeline + + # The bundle's HuggingFacePipelineHelper builds the model through + # PreTrainedModel.from_pretrained, which reads only + # model.safetensors. That file stores the weights under the raw + # MONAI keys, so loading it leaves every parameter of + # VISTA3DModel.network randomly initialized. Load model.pt into the + # network directly instead. + model = VISTA3DModel(VISTA3DConfig()) + model.network.load_state_dict( + torch.load( + os.path.join(snapshot_dir, "vista3d_pretrained_model", "model.pt"), + map_location="cpu", + weights_only=True, + ) + ) + + # Unindexed, so the pipeline follows torch.cuda.set_device: under a + # distributed launcher each rank segments on its own GPU instead of + # every rank piling onto GPU 0. Identical in a single process, + # where the current device is 0. + self._pipeline = VISTA3DPipeline(model, device=torch.device("cuda")) + return self._pipeline + + def segmentation_method(self, preprocessed_image: itk.image) -> itk.image: + """Run NV-Segment-CT on the preprocessed image and return the result. + + The model's Hugging Face pipeline reads and writes NIfTI files, so the + image is written to a temporary file and the prediction read back with + ITK. That round trip also handles the coordinate-system conversion + between ITK (LPS) and the bundle's internal RAS orientation. + + The bundle inverts its own preprocessing before saving, so the + prediction is returned on the same grid as *preprocessed_image*. + + Args: + preprocessed_image (itk.image): The preprocessed CT image with + isotropic spacing + + Returns: + itk.image: The segmentation labelmap with NV-Segment-CT class + indices, as ``uint8``. + + Raises: + RuntimeError: If the model pipeline produced no output volume. + + Note: + Requires a CUDA GPU; the segmentation runs on whichever CUDA + device is current in this process. + + Example: + >>> labelmap = segmenter.segmentation_method(preprocessed_ct) + """ + pipeline = self._ensure_pipeline() + + with tempfile.TemporaryDirectory() as tmp_dir: + in_file = os.path.join(tmp_dir, "in.nii.gz") + out_dir = os.path.join(tmp_dir, "out") + itk.imwrite(preprocessed_image, in_file, compression=True) + + self.log_info("Running NV-Segment-CT") + pipeline([{"image": in_file}], output_dir=out_dir) + + # The bundle saves with separate_folder=True and its own postfix, + # so locate the result rather than reconstructing its name. + out_files = glob.glob( + os.path.join(out_dir, "**", "*.nii.gz"), recursive=True + ) + # One input dict in, so exactly one output is expected; anything + # else means the bundle's output layout changed and picking a file + # would be a guess. + if len(out_files) != 1: + raise RuntimeError( + f"NV-Segment-CT produced {len(out_files)} outputs in " + f"{out_dir}, expected 1." + ) + + labelmap_arr = itk.array_from_image(itk.imread(out_files[0])).astype( + self.labelmap_dtype + ) + + # The bundle's postprocessing maps unpredicted voxels to 255 via + # nan_to_num(nan=255). 255 is not a valid NV-Segment-CT class (ids run + # to 132), so it can always be cleared. + labelmap_arr[labelmap_arr == 255] = 0 + + labelmap_image = itk.image_from_array(labelmap_arr) + labelmap_image.CopyInformation(preprocessed_image) + + return labelmap_image diff --git a/src/monai_physio/workflow_create_mean_surface.py b/src/monai_physio/workflow_create_mean_surface.py index 7d5cbc7e..363303fc 100644 --- a/src/monai_physio/workflow_create_mean_surface.py +++ b/src/monai_physio/workflow_create_mean_surface.py @@ -96,6 +96,8 @@ def __init__( # "Affine" to average only the residual, size-and-pose-normalized shape. self.alignment_transform_type: str = "Rigid" self.registration_transform_type: str = "Deformable" + self.greedy_iterations: list[int] = [40, 20, 10] + self.icon_iterations: int = 20 # Correspondence tuning, mirroring WorkflowFitStatisticalModelToPatient # so that a mean built here and a fit against it see distance maps on @@ -119,6 +121,14 @@ def set_number_of_iterations(self, number_of_iterations: int) -> None: ) self.number_of_iterations = number_of_iterations + def set_greedy_iterations(self, greedy_iterations: list[int]) -> None: + """Set the number of greedy iterations.""" + self.greedy_iterations = greedy_iterations + + def set_icon_iterations(self, icon_iterations: int) -> None: + """Set the number of icon iterations.""" + self.icon_iterations = icon_iterations + def set_convergence_tolerance(self, convergence_tolerance: float) -> None: """Set the RMS point motion (mm) below which iteration stops.""" if convergence_tolerance < 0.0: @@ -349,6 +359,8 @@ def _correspond( ) if self.icon_weights_path is not None: registrar.set_icon_weights_path(self.icon_weights_path) + registrar.registrar_Greedy.set_number_of_iterations(self.greedy_iterations) + registrar.registrar_ICON.set_number_of_iterations(self.icon_iterations) result = registrar.register(transform_type=self.registration_transform_type) # fixed_to_moving_transform maps template points into the sample's diff --git a/tutorials/parameters_tcia_4d_lung.py b/tutorials/parameters_tcia_4d_lung.py index 2fb584e5..f8348732 100644 --- a/tutorials/parameters_tcia_4d_lung.py +++ b/tutorials/parameters_tcia_4d_lung.py @@ -17,7 +17,7 @@ from parameters_base import ParametersBase -from monai_physio import SegmentAnatomyBase, SegmentNVSegmentCTMRI +from monai_physio import SegmentAnatomyBase, SegmentChestTotalSegmentator @dataclass(frozen=True) @@ -64,7 +64,7 @@ class ParametersTCIA4DLung(ParametersBase): no lettered re-scan variant, unlike every other case. """ - surface_reduction_rate: float = 0.0 + surface_reduction_rate: float = 0.25 mesh_element_size_mm: float = 3.0 number_of_iterations_icon: int = 20 @@ -77,16 +77,16 @@ class ParametersTCIA4DLung(ParametersBase): icp_transform_type: str = "Affine" - mask_dilation_mm: float = 40.0 - distancemap_squared_max: float = (1.25 * 40.0) ** 2 + mask_dilation_mm: float = 20.0 + distancemap_squared_max: float = (1.25 * 20.0) ** 2 - model_points: int = 0 + model_points: int = 80000 model_points_test: int = 20000 number_of_pca_components: int = 6 number_of_pca_components_test: int = 5 - segmenter_class: type[SegmentAnatomyBase] = SegmentNVSegmentCTMRI + segmenter_class: type[SegmentAnatomyBase] = SegmentChestTotalSegmentator anatomy_group: str = "lung" hold_out_case: str = "Chest-CT.mha" diff --git a/tutorials/tutorial_01_lung_gated_ct_to_usd_tetmesh.py b/tutorials/tutorial_01_lung_gated_ct_to_usd_tetmesh.py new file mode 100644 index 00000000..d6f8d900 --- /dev/null +++ b/tutorials/tutorial_01_lung_gated_ct_to_usd_tetmesh.py @@ -0,0 +1,203 @@ +""" +Tutorial 1 (Tetmesh Variant): Lung-Gated 4D CT to Animated Tetrahedral Mesh + +Purpose +------- +Convert a respiratory-gated 4D lung CT scan (multiple breathing phases) into a +time series of tetrahedral volume meshes of the lung, one per respiratory +phase. Unlike Tutorial 1, which builds a surface-only animated USD model, this +variant fills the reference-frame lung surface with tetrahedra and warps that +single volume mesh through every phase's registration transform, giving a +4D tetmesh suitable for physics-based simulation (e.g. finite-element lung +motion) rather than visualization alone. + +Inputs +------ +- A set of 3D CT volumes (``*.nii.gz``) representing successive respiratory + phases of one TCIA 4D-Lung case. + Expected location: ``data/TCIA-4DLung/100_HM10395/100_HM10395_g0??.nii.gz``. +- The mid-inspiration phase (index ~0.7 through the series) is used as the + reference frame for segmentation, meshing, and registration. + +Outputs (under ``tutorials/output/tutorial_01_lung_tetmesh/``) +---------------------------------------------------------------- +- One tetrahedral mesh (``*.vtu``) per respiratory phase, named + ``lung_tetmesh_.vtu``, all sharing the reference mesh's connectivity. +- A screenshot (PNG) of the reference-phase lung surface for documentation and + regression testing: ``lung_surface_test.png``. + +Strengths +--------- +- Single reference tetmesh, warped rather than re-meshed per phase, so every + frame shares the same connectivity -- required for a 4D finite-element + simulation to track individual elements across phases. +- 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``. + +Weaknesses / Limitations +------------------------ +- Segmentation quality depends on TotalSegmentator'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. + +Data Required +------------- +See data/README.md for download instructions and dataset licensing. +Dataset: TCIA 4D-Lung - see ``data/TCIA-4DLung/README.md``. +This script expects the ``100_HM10395_g0??.nii.gz`` phase volumes to already +exist under ``data/TCIA-4DLung/100_HM10395/``. +""" + +# Imports +from __future__ import annotations + +import logging +from pathlib import Path + +import itk +from parameters_tcia_4d_lung import TCIA_4D_LUNG + +from monai_physio import ( + MONAIPhysioBase, + ProcessContours, + ProcessTests, + RegisterImagesGreedy, + SegmentChestTotalSegmentator, +) + +# Only run if this script is not imported as a module + +# nnUNetv2 (used by TotalSegmentator) spawns a multiprocessing.Pool. On +# Windows the spawn start method re-imports this script in each child; +# without the __name__ == "__main__" guard around the top-level work, that +# re-import fires the pipeline again and Python's spawn-cascade detector +# raises RuntimeError. +if __name__ == "__main__": + # Data directory specification + + class_name = "tutorial_01_lung_gated_ct_to_usd_tetmesh" + + test_mode = ProcessTests.running_as_test() + + output_dir = TCIA_4D_LUNG.output_directory(test_mode) / "tutorial_01_lung_tetmesh" + + 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 = [30, 15, 7, 3] + frame_files = sorted(data_dir.glob("100_HM10395_g0??.nii.gz")) + + log_level = logging.INFO + reporter = MONAIPhysioBase(class_name=class_name, log_level=log_level) + + registration_method = RegisterImagesGreedy(log_level=log_level) + registration_method.set_number_of_iterations(number_of_iterations_greedy) + + segmentation_method = SegmentChestTotalSegmentator(log_level=log_level) + segmentation_method.set_has_academic_license(True) + + contour_tools = ProcessContours(log_level=log_level) + + # Directory setup and data reading + + output_dir.mkdir(parents=True, exist_ok=True) + + input_filenames = [str(path) for path in frame_files] + if not input_filenames: + raise FileNotFoundError( + "TCIA-4DLung data not found. Checked:\n" + + f" - {data_dir}" + + "\n" + + "See data/README.md for download instructions." + ) + + time_series_images = [itk.imread(str(path)) for path in input_filenames] + reference_index = int(0.7 * len(time_series_images)) + reference_image = time_series_images[reference_index] + + reporter.log_info("Number of time-series images: %d", len(time_series_images)) + + # 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, + 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 + ) + reporter.log_info( + "Reference tetmesh: %d points, %d cells", + reference_tetmesh.n_points, + reference_tetmesh.n_cells, + ) + + # Registration and per-phase tetmesh warping + + registration_method.set_fixed_image(reference_image) + + 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"] + ) + 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) + + # Result saving + tt = ProcessTests( + class_name=class_name, + results_dir=output_dir, + log_level=log_level, + ) + + screenshots: list[Path] = [ + tt.save_screenshot_mesh( + reference_surface, + "lung_surface_test.png", + camera_position="iso", + color="lightblue", + opacity=0.85, + ) + ] + + tutorial_results = { + "tetmesh_files": [str(path) for path in tetmesh_files], + "screenshots": screenshots, + } diff --git a/tutorials/tutorial_06_lung_create_statistical_model.py b/tutorials/tutorial_06_lung_create_statistical_model.py index d361245a..8f7c1c18 100644 --- a/tutorials/tutorial_06_lung_create_statistical_model.py +++ b/tutorials/tutorial_06_lung_create_statistical_model.py @@ -45,7 +45,6 @@ from monai_physio import ( ProcessContours, ProcessTests, - SegmentNVSegmentCTMRI, WorkflowConvertImageToVTK, WorkflowCreateMeanSurface, WorkflowCreateStatisticalModel, @@ -105,7 +104,8 @@ output_dir.mkdir(parents=True, exist_ok=True) # Create lung surface files - segmentation_method = SegmentNVSegmentCTMRI(log_level=log_level) + segmentation_method = TCIA_4D_LUNG.segmenter_class(log_level=log_level) + segmentation_method.set_fast_mode(True) workflow_method = WorkflowConvertImageToVTK( segmentation_method=segmentation_method, log_level=log_level ) @@ -161,6 +161,8 @@ "model_points": model_points, "mask_dilation_mm": TCIA_4D_LUNG.mask_dilation_mm, "distance_squared_max": TCIA_4D_LUNG.distancemap_squared_max, + "alignment_transform_type": TCIA_4D_LUNG.icp_transform_type, + "registration_transform_type": "Affine", "icon_weights": ( [str(icon_weights_path), icon_weights_path.stat().st_mtime_ns] if icon_weights_path.exists() @@ -182,6 +184,12 @@ # the template is not itself built from under-fitting registrations. mean_workflow.set_mask_dilation_mm(TCIA_4D_LUNG.mask_dilation_mm) mean_workflow.set_distance_squared_max(TCIA_4D_LUNG.distancemap_squared_max) + mean_workflow.set_alignment_transform_type(TCIA_4D_LUNG.icp_transform_type) + mean_workflow.set_registration_transform_type("Affine") + mean_workflow.spatial_resolution = 2.0 + mean_workflow.set_greedy_iterations([40, 20, 10]) + mean_workflow.set_icon_iterations(20) + if icon_weights_path.exists(): mean_workflow.set_icon_weights_path(str(icon_weights_path)) mean_result = mean_workflow.process() @@ -200,7 +208,7 @@ # The distance maps step 3 registers are rasterized at this resolution, # and generating, dilating and affinely registering them is what the # step costs. 2 mm is an eighth of the voxels of the 1 mm default. - reference_spatial_resolution=2.0 if test_mode else 1.0, + reference_spatial_resolution=3.0 if test_mode else 2.0, icp_transform_type=TCIA_4D_LUNG.icp_transform_type, mask_dilation_mm=TCIA_4D_LUNG.mask_dilation_mm, distance_squared_max=TCIA_4D_LUNG.distancemap_squared_max, 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 293631bd..3c781836 100644 --- a/tutorials/tutorial_07_lung_fit_statistical_model_to_patient.py +++ b/tutorials/tutorial_07_lung_fit_statistical_model_to_patient.py @@ -36,7 +36,7 @@ from monai_physio import ( ProcessContours, ProcessTests, - SegmentNVSegmentCTMRI, + SegmentChestTotalSegmentator, WorkflowConvertImageToVTK, WorkflowFitStatisticalModelToPatient, ) @@ -87,7 +87,8 @@ # The same segmenter and surface-extraction workflow used by Tutorial 6, so # the patient surface matches the topology the PCA model was built from. - segmentation_method = SegmentNVSegmentCTMRI(log_level=log_level) + segmentation_method = SegmentChestTotalSegmentator(log_level=log_level) + segmentation_method.fast_mode = True segmentation_workflow = WorkflowConvertImageToVTK( segmentation_method=segmentation_method, log_level=log_level ) 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 cec57704..915a15a8 100644 --- a/tutorials/tutorial_08_lung_fit_model_to_4d_patients.py +++ b/tutorials/tutorial_08_lung_fit_model_to_4d_patients.py @@ -61,7 +61,7 @@ ProcessTests, ProcessTransforms, RegisterImagesGreedy, - SegmentNVSegmentCTMRI, + SegmentChestTotalSegmentator, WorkflowConvertImageToVTK, WorkflowFitStatisticalModelToPatient, WorkflowReconstructHighres4DCT, @@ -157,7 +157,8 @@ "See data/TCIA-4DLung/README.md for download instructions." ) - segmentation_method = SegmentNVSegmentCTMRI(log_level=log_level) + segmentation_method = SegmentChestTotalSegmentator(log_level=log_level) + segmentation_method.fast_mode = True segmentation_workflow = WorkflowConvertImageToVTK( segmentation_method=segmentation_method, log_level=log_level ) diff --git a/tutorials/tutorial_09_lung_train_physicsnemo_mgn.py b/tutorials/tutorial_09_lung_train_physicsnemo_mgn.py index a4e3d2ba..3f1cb492 100644 --- a/tutorials/tutorial_09_lung_train_physicsnemo_mgn.py +++ b/tutorials/tutorial_09_lung_train_physicsnemo_mgn.py @@ -42,9 +42,15 @@ takes ~430 ms and peaks near 43 GiB of GPU memory, giving ~9 s per epoch and roughly 4 hours for the 1500 epochs below. TCIA-4DLung's population is roughly 8x larger (~83 cases including lettered re-scan variants, vs. DIR-Lab's -curated 10), so a full run scales accordingly. Lower ``batch_size``, or call -``training_method.set_num_processor_checkpoint_segments(...)`` to trade compute -for memory, on a smaller card. +curated 10), so a full run scales accordingly. Processor gradient +checkpointing is on by default below (``num_processor_checkpoint_segments``): +even the ~43 GiB unchecked peak leaves little headroom on a 96 GiB card once +driver/framework overhead is added, and on Windows a CUDA process that +exceeds dedicated VRAM silently pages the overflow into system RAM (WDDM's +shared-GPU-memory fallback) instead of raising an out-of-memory error - +training keeps running, just 10-50x slower, with no error to explain why. +Lower ``batch_size`` as a further lever if memory is still tight on a smaller +card. Data Required ------------- @@ -212,6 +218,9 @@ def _write_case_manifest( processor_size = 3 # message-passing hops hidden_dim = 128 num_layers = 2 # MLP layers inside each encoder / processor / decoder block + # Checkpoint every processor layer (max granularity for processor_size=3); + # see the Runtime section above. + num_processor_checkpoint_segments = 3 # Explicit held-out splits; every other discovered case is used for training. # The held-out case is the one Tutorial 10 predicts, and is also the case held @@ -280,6 +289,9 @@ def _write_case_manifest( training_method.set_processor_size(processor_size) training_method.set_hidden_dim(hidden_dim) training_method.set_num_layers(num_layers) + training_method.set_num_processor_checkpoint_segments( + num_processor_checkpoint_segments + ) train_workflow = WorkflowTrainPhysicsNeMo( train_manifests=train_manifests, From b31d1aa4c7b5f292c041b8bebe92ca00e22bee16 Mon Sep 17 00:00:00 2001 From: Stephen Aylward Date: Thu, 17 Sep 2026 14:38:23 -0400 Subject: [PATCH 2/3] ENH: Coderabbit --- docs/api/segmentation/index.rst | 2 +- docs/api/segmentation/nv_segment_ct.rst | 5 ++++- docs/cli_scripts/download_data.rst | 4 ++-- docs/tutorials.rst | 2 +- src/monai_physio/convert_vtk_to_usd.py | 12 +++++++++++- src/monai_physio/segment_nv_segment_ct.py | 11 +++++++++++ src/monai_physio/segment_nv_segment_ct_mri.py | 11 +++++++++++ tests/test_evaluate_movement_cohorts.py | 4 ++-- tests/test_workflow_create_mean_surface.py | 14 ++++++++++++++ 9 files changed, 57 insertions(+), 8 deletions(-) diff --git a/docs/api/segmentation/index.rst b/docs/api/segmentation/index.rst index 008ad631..248b22fd 100644 --- a/docs/api/segmentation/index.rst +++ b/docs/api/segmentation/index.rst @@ -16,7 +16,7 @@ MONAI Physio supports multiple segmentation approaches: * **NV-Segment-CTMR**: Whole-body CT *and* MRI segmentation (345 structures, non-commercial license) * **NV-Segment-CT**: Whole-body CT-only segmentation (117 structures, - research-use-only license) + weights for research use only, not for clinical use) All segmentation classes inherit from :class:`SegmentAnatomyBase` and provide consistent interfaces. diff --git a/docs/api/segmentation/nv_segment_ct.rst b/docs/api/segmentation/nv_segment_ct.rst index 4a01ca0f..57548bc0 100644 --- a/docs/api/segmentation/nv_segment_ct.rst +++ b/docs/api/segmentation/nv_segment_ct.rst @@ -21,7 +21,10 @@ exposed here. both models are research-use-only, just under different named terms, so check the license text for your use case. Use ``SegmentChestTotalSegmentator`` if you need no license restriction at - all. + all - its default task set is unrestricted; only its optional + ``heartchambers_highres`` and ``tissue_4_types`` tasks + (``set_has_academic_license(True)``) require a TotalSegmentator academic + license. Class Reference =============== diff --git a/docs/cli_scripts/download_data.rst b/docs/cli_scripts/download_data.rst index 61312c5a..c454e67a 100644 --- a/docs/cli_scripts/download_data.rst +++ b/docs/cli_scripts/download_data.rst @@ -35,8 +35,8 @@ Supported Datasets citation. * - ``PhysicsNeMo-MGN-Lung-Motion`` - Pretrained PhysicsNeMo MeshGraphNet checkpoint for lung motion, from - the MONAI Physio GitHub release. Used by Lung Tutorial 9 (train) and - Tutorial 10+ (infer). + the MONAI Physio GitHub release. Used by Lung Tutorial 10 and later + when skipping Tutorial 9's training. Basic Usage =========== diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 736361f0..f6486162 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -1034,7 +1034,7 @@ Script Workflow :class:`~monai_physio.WorkflowEvaluateMovement`, driving :class:`~monai_physio.WorkflowInferMovement` and, for the lung variant, - :class:`~monai_physio.SegmentNVSegmentCTMRI`. + :class:`~monai_physio.SegmentChestTotalSegmentator`. Dataset The gated sequence itself - TCIA-4DLung for the lung, Duke-Heart-4DLabelmaps diff --git a/src/monai_physio/convert_vtk_to_usd.py b/src/monai_physio/convert_vtk_to_usd.py index 706fdd4b..4b524078 100644 --- a/src/monai_physio/convert_vtk_to_usd.py +++ b/src/monai_physio/convert_vtk_to_usd.py @@ -147,7 +147,17 @@ def __init__( # data_basename becomes a USD prim path component (root_path and, per # part/label, its descendants), so it must be a valid USD identifier - # e.g. not start with a digit, as case names like "100_HM10395" do. - self.data_basename = sanitize_primvar_name(stripped_basename) + # sanitize_primvar_name collapses/strips underscores, so only run it + # when the name isn't already a valid identifier as-is. + self.data_basename = ( + stripped_basename + if Sdf.Path.IsValidIdentifier(stripped_basename) + else sanitize_primvar_name(stripped_basename) + ) + if not self.data_basename: + raise ValueError( + f"data_basename {data_basename!r} sanitizes to an empty USD identifier." + ) self.input_polydata = list(input_polydata) self.mask_ids = mask_ids self.compute_normals = compute_normals diff --git a/src/monai_physio/segment_nv_segment_ct.py b/src/monai_physio/segment_nv_segment_ct.py index 40015c0e..2ee8222e 100644 --- a/src/monai_physio/segment_nv_segment_ct.py +++ b/src/monai_physio/segment_nv_segment_ct.py @@ -340,6 +340,17 @@ def _ensure_pipeline(self) -> Any: if snapshot_dir not in sys.path: sys.path.insert(0, snapshot_dir) + # NV-Segment-CT and NV-Segment-CTMR ship modules under these same + # top-level names; if the other backend already imported them + # from its own snapshot dir, drop the cached entries so this + # backend's copy loads instead. + for module_name in ("vista3d_config", "vista3d_model", "vista3d_pipeline"): + cached = sys.modules.get(module_name) + if cached is not None and not ( + getattr(cached, "__file__", "") or "" + ).startswith(snapshot_dir): + del sys.modules[module_name] + import torch from vista3d_config import VISTA3DConfig from vista3d_model import VISTA3DModel diff --git a/src/monai_physio/segment_nv_segment_ct_mri.py b/src/monai_physio/segment_nv_segment_ct_mri.py index ca682da8..b4680489 100644 --- a/src/monai_physio/segment_nv_segment_ct_mri.py +++ b/src/monai_physio/segment_nv_segment_ct_mri.py @@ -595,6 +595,17 @@ def _ensure_pipeline(self) -> Any: if snapshot_dir not in sys.path: sys.path.insert(0, snapshot_dir) + # NV-Segment-CT and NV-Segment-CTMR ship modules under these same + # top-level names; if the other backend already imported them + # from its own snapshot dir, drop the cached entries so this + # backend's copy loads instead. + for module_name in ("vista3d_config", "vista3d_model", "vista3d_pipeline"): + cached = sys.modules.get(module_name) + if cached is not None and not ( + getattr(cached, "__file__", "") or "" + ).startswith(snapshot_dir): + del sys.modules[module_name] + import torch from vista3d_config import VISTA3DConfig from vista3d_model import VISTA3DModel diff --git a/tests/test_evaluate_movement_cohorts.py b/tests/test_evaluate_movement_cohorts.py index d422b621..fb26c0a1 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) == [28, 29, 30, 31, 32] - assert names[28] == "lung_upper_lobe_left" + assert list(names) == [10, 11, 12, 13, 14] + assert names[10] == "lung_upper_lobe_left" assert all("lobe" in name for name in names.values()) diff --git a/tests/test_workflow_create_mean_surface.py b/tests/test_workflow_create_mean_surface.py index 1ed22ae6..8a062482 100644 --- a/tests/test_workflow_create_mean_surface.py +++ b/tests/test_workflow_create_mean_surface.py @@ -128,10 +128,19 @@ def test_correspondence_tuning_reaches_the_registrar(monkeypatch: Any) -> None: """Stock distance maps and stock ICON weights under-fit, so both are tunable.""" seen: list[_Registrar] = [] + class _SubRegistrar: + def __init__(self) -> None: + self.number_of_iterations: Any = None + + def set_number_of_iterations(self, number_of_iterations: Any) -> None: + self.number_of_iterations = number_of_iterations + class _Registrar: def __init__(self, **kwargs: Any) -> None: self.kwargs = kwargs self.weights_path: Optional[str] = None + self.registrar_Greedy = _SubRegistrar() + self.registrar_ICON = _SubRegistrar() seen.append(self) def set_icon_weights_path(self, weights_path: str) -> None: @@ -154,3 +163,8 @@ def register(self, transform_type: str) -> dict[str, Any]: assert registrar.kwargs["mask_dilation_mm"] == 10.0 assert registrar.kwargs["distance_squared_max"] == (1.25 * 10.0) ** 2 assert registrar.weights_path == "finetuned.trch" + assert ( + registrar.registrar_Greedy.number_of_iterations + == workflow.greedy_iterations + ) + assert registrar.registrar_ICON.number_of_iterations == workflow.icon_iterations From 70318611eed7aab69f7fd004168fb3820278610d Mon Sep 17 00:00:00 2001 From: Stephen Aylward Date: Thu, 17 Sep 2026 14:45:31 -0400 Subject: [PATCH 3/3] ENH: Coderabbit --- src/monai_physio/segment_nv_segment_ct.py | 10 +++++++--- src/monai_physio/segment_nv_segment_ct_mri.py | 10 +++++++--- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/monai_physio/segment_nv_segment_ct.py b/src/monai_physio/segment_nv_segment_ct.py index 2ee8222e..3ad9d3a0 100644 --- a/src/monai_physio/segment_nv_segment_ct.py +++ b/src/monai_physio/segment_nv_segment_ct.py @@ -336,9 +336,13 @@ def _ensure_pipeline(self) -> Any: # The bundle ships hugging_face_pipeline / vista3d_pipeline as # top-level modules inside the snapshot rather than as an installed - # package, so the snapshot directory has to be importable. - if snapshot_dir not in sys.path: - sys.path.insert(0, snapshot_dir) + # package, so the snapshot directory has to be importable. Move it + # to the front rather than just ensuring it's present: if the + # other backend's snapshot dir precedes it, that one would still + # win the import even after the cache purge below. + if snapshot_dir in sys.path: + sys.path.remove(snapshot_dir) + sys.path.insert(0, snapshot_dir) # NV-Segment-CT and NV-Segment-CTMR ship modules under these same # top-level names; if the other backend already imported them diff --git a/src/monai_physio/segment_nv_segment_ct_mri.py b/src/monai_physio/segment_nv_segment_ct_mri.py index b4680489..1e54b29f 100644 --- a/src/monai_physio/segment_nv_segment_ct_mri.py +++ b/src/monai_physio/segment_nv_segment_ct_mri.py @@ -591,9 +591,13 @@ def _ensure_pipeline(self) -> Any: # The bundle ships hugging_face_pipeline / vista3d_pipeline as # top-level modules inside the snapshot rather than as an installed - # package, so the snapshot directory has to be importable. - if snapshot_dir not in sys.path: - sys.path.insert(0, snapshot_dir) + # package, so the snapshot directory has to be importable. Move it + # to the front rather than just ensuring it's present: if the + # other backend's snapshot dir precedes it, that one would still + # win the import even after the cache purge below. + if snapshot_dir in sys.path: + sys.path.remove(snapshot_dir) + sys.path.insert(0, snapshot_dir) # NV-Segment-CT and NV-Segment-CTMR ship modules under these same # top-level names; if the other backend already imported them