diff --git a/.agents/agents/docs.md b/.agents/agents/docs.md index fd7ee33..5e43302 100644 --- a/.agents/agents/docs.md +++ b/.agents/agents/docs.md @@ -20,7 +20,7 @@ and the API map accurate and concise. - Read the changed code before writing any docs. - Keep docstrings factual — describe what the code does, not what you wish it did. - State image/tensor shapes and axis orders explicitly: - e.g. `Returns an ITK image with shape (X, Y, Z, T) in RAS world space.` + e.g. `Returns an ITK image with shape (X, Y, Z, T) in LPS world space.` - Double quotes for docstrings; single quotes for inline strings. - Do **not** create new `.md` files unless explicitly asked. - After any public API change, regenerate: `python utils/generate_api_map.py` @@ -34,7 +34,7 @@ def register(self, moving_image: itk.Image) -> dict[str, Any]: Parameters ---------- moving_image : itk.Image - 3-D image in RAS world space, shape (X, Y, Z). + 3-D image in LPS world space, shape (X, Y, Z). Returns ------- diff --git a/.agents/agents/implementation.md b/.agents/agents/implementation.md index 2626bc0..137906d 100644 --- a/.agents/agents/implementation.md +++ b/.agents/agents/implementation.md @@ -35,7 +35,7 @@ Key modules: `physiomotion4d_base.py`, `segment_chest_*.py`, `register_images_*. ## Data shapes — state them explicitly -- ITK images: axes X, Y, Z [, T] in RAS world space. +- ITK images: axes X, Y, Z [, T] in LPS world space (ITK's native frame). - 4D time series: shape `(X, Y, Z, T)`. Never silently squeeze or permute. - PyVista surfaces: RAS internally; Y-up only at USD export. - Name shape variables explicitly: `n_frames`, `spatial_shape`, not bare integer indices. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4e656ff..c721fe5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,7 +219,7 @@ jobs: - name: Run data conversion tests run: | - pytest tests/test_convert_nrrd_4d_to_3d.py -v --cov=physiomotion4d --cov-append --cov-report=xml + pytest tests/test_convert_image_4d_to_3d.py -v --cov=physiomotion4d --cov-append --cov-report=xml continue-on-error: true - name: Run contour tools tests diff --git a/CLAUDE.md b/CLAUDE.md index 7ec2ae5..7119223 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,9 +56,12 @@ Consult `docs/API_MAP.md` for the full index of classes, methods, and signatures Regenerate it after any public API change: `py utils/generate_api_map.py` **Key data conventions:** -- Images: `itk.Image`, axes X, Y, Z [, T] in RAS world space +- Images: `itk.Image`, axes X, Y, Z [, T] in LPS world space (ITK's native + frame; `itk.imread` normalizes DICOM, NIfTI, MHA, and NRRD inputs to LPS) - 4D time series: shape `(X, Y, Z, T)` — never silently squeeze or permute axes -- Surfaces: `pv.PolyData` in RAS; converted to Y-up only at USD export +- Surfaces: `pv.PolyData` in LPS (inherited from the source `itk.Image` via + `itk.vtk_image_from_image`); converted to USD right-handed Y-up only at USD + export by `vtk_to_usd.lps_points_to_usd` (USD +X=Left, +Y=Superior, +Z=Anterior) - Masks: ITK images with integer labels; consistent anatomy group IDs across all segmenters - Transforms: ITK composite transforms stored in `.hdf` files - State axis order and shape explicitly in every docstring and comment that touches arrays diff --git a/README.md b/README.md index b33d13b..8a165a3 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,10 @@ There is no need to install PyTorch separately. ```python import physiomotion4d -from physiomotion4d import WorkflowConvertHeartGatedCTToUSD +from physiomotion4d import WorkflowConvertImageToUSD print(f"PhysioMotion4D version: {physiomotion4d.__version__}") -print(WorkflowConvertHeartGatedCTToUSD.__name__) +print(WorkflowConvertImageToUSD.__name__) ``` ## Package Architecture @@ -114,7 +114,7 @@ print(WorkflowConvertHeartGatedCTToUSD.__name__) ### Core Components - **Workflow Classes**: Complete end-to-end pipeline processors - - `WorkflowConvertHeartGatedCTToUSD`: Heart-gated CT to USD processing workflow + - `WorkflowConvertImageToUSD`: 3D/4D image to USD processing workflow - `WorkflowCreateStatisticalModel`: Create PCA statistical shape model from sample meshes - `WorkflowFitStatisticalModelToPatient`: Model-to-patient registration workflow - **Segmentation Classes**: Multiple AI-based chest segmentation implementations @@ -195,7 +195,7 @@ a CUDA-capable GPU are required for practical runtime. ```bash python -c "from physiomotion4d import DataDownloadTools; DataDownloadTools.DownloadSlicerHeartCTData('data/test')" -physiomotion4d-heart-gated-ct data/test/TruncalValve_4DCT.seq.nrrd \ +physiomotion4d-convert-image-to-usd data/test/TruncalValve_4DCT.seq.nrrd \ --registration-method ants \ --output-dir output/quickstart \ --project-name slicer_heart_quickstart @@ -213,13 +213,13 @@ Process 4D cardiac CT images into dynamic USD models: ```bash # Process a single 4D cardiac CT file -physiomotion4d-heart-gated-ct cardiac_4d.nrrd --contrast --output-dir ./results +physiomotion4d-convert-image-to-usd cardiac_4d.nrrd --contrast --output-dir ./results # Process multiple time frames -physiomotion4d-heart-gated-ct frame_*.nrrd --contrast --project-name patient_001 +physiomotion4d-convert-image-to-usd frame_*.nrrd --contrast --project-name patient_001 # With custom settings -physiomotion4d-heart-gated-ct cardiac.nrrd \ +physiomotion4d-convert-image-to-usd cardiac.nrrd \ --contrast \ --reference-image ref.mha \ --registration-iterations 50 \ @@ -278,10 +278,10 @@ For implementation details and advanced usage, see the CLI modules in `src/physi ### Python API - Basic Heart-Gated CT Processing ```python -from physiomotion4d import WorkflowConvertHeartGatedCTToUSD +from physiomotion4d import WorkflowConvertImageToUSD # Initialize processor -processor = WorkflowConvertHeartGatedCTToUSD( +processor = WorkflowConvertImageToUSD( input_filenames=["path/to/cardiac_4d_ct.nrrd"], contrast_enhanced=True, output_directory="./results", @@ -443,7 +443,7 @@ stage = convert_vtk_file( ``` Features: -- Automatic coordinate system conversion (RAS to Y-up) +- Automatic coordinate system conversion (LPS to USD right-handed Y-up) - Material system with UsdPreviewSurface - Preserves all VTK data arrays as USD primvars - Supports VTP, VTK, and VTU file formats @@ -721,7 +721,7 @@ Use `/impl` for end-to-end implementation: read → summarize → plan → diff ``` ```text -/impl fix the RAS-to-Y-up transform being applied twice in vtk_to_usd/usd_utils.py +/impl fix the LPS-to-Y-up transform being applied twice in vtk_to_usd/usd_utils.py ``` The agent will read the affected module, propose a numbered plan, implement in the diff --git a/data/Slicer-Heart-CT/download_and_convert.py b/data/Slicer-Heart-CT/download_and_convert.py index ef52f74..525777e 100644 --- a/data/Slicer-Heart-CT/download_and_convert.py +++ b/data/Slicer-Heart-CT/download_and_convert.py @@ -3,7 +3,7 @@ import os import shutil -from physiomotion4d.convert_nrrd_4d_to_3d import ConvertNRRD4DTo3D +from physiomotion4d.convert_image_4d_to_3d import ConvertImage4DTo3D from physiomotion4d.data_download_tools import DataDownloadTools # %% @@ -19,8 +19,8 @@ input_image_filename = DataDownloadTools.DownloadSlicerHeartCTData(data_dir) # %% -conv = ConvertNRRD4DTo3D() -conv.load_nrrd_4d(str(input_image_filename)) +conv = ConvertImage4DTo3D() +conv.load_image_4d(str(input_image_filename)) conv.save_3d_images(output_dir, "slice") # Save the mid-stroke slice as the fixed/reference image diff --git a/docs/API_MAP.md b/docs/API_MAP.md index 7624f31..411e2cf 100644 --- a/docs/API_MAP.md +++ b/docs/API_MAP.md @@ -66,9 +66,13 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def main()` (line 26): CLI entry point for CT to VTK conversion. -## src/physiomotion4d/cli/convert_heart_gated_ct_to_usd.py +## src/physiomotion4d/cli/convert_image_4d_to_3d.py -- `def main()` (line 14): Command-line interface for Heart-gated CT processing. +- `def main()` (line 20): CLI entry point for 4D-to-3D image conversion. + +## src/physiomotion4d/cli/convert_image_to_usd.py + +- `def main()` (line 14): Command-line interface for the Image-to-USD workflow. ## src/physiomotion4d/cli/convert_vtk_to_usd.py @@ -102,15 +106,14 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def create_distance_map(self, mesh, reference_image, squared_distance=False, negative_inside=True, zero_inside=False, norm_to_max_distance=0.0)` (line 257) - `def create_deformation_field(self, points, point_displacements, reference_image, blur_sigma=2.5, ptype=itk.D)` (line 324): Create a displacement map from model points and displacements. -## src/physiomotion4d/convert_nrrd_4d_to_3d.py +## src/physiomotion4d/convert_image_4d_to_3d.py -- **class ConvertNRRD4DTo3D** (line 13) - - `def __init__(self, log_level=logging.INFO)` (line 14): Initialize the NRRD 4D to 3D converter. - - `def load_nrrd_3d(self, filenames)` (line 24) - - `def load_nrrd_4d(self, filename)` (line 30) - - `def get_3d_image(self, index)` (line 64) - - `def get_number_of_3d_images(self)` (line 67) - - `def save_3d_images(self, directory, basename)` (line 70) +- **class ConvertImage4DTo3D** (line 34): Split a 3D/4D ITK image (X, Y, Z [, T]) into a list of 3D ITK images. + - `def __init__(self, log_level=logging.INFO)` (line 37): Initialize the 4D-to-3D image converter. + - `def load_image_4d(self, filename)` (line 62): Load a 3D or 4D image and populate ``self.img_3d`` with 3D frames. + - `def get_3d_image(self, index)` (line 269): Return the 3D ITK image at the given time index. + - `def get_number_of_3d_images(self)` (line 273): Return the number of 3D images currently held. + - `def save_3d_images(self, directory, basename, suffix='mha')` (line 277): Write each held 3D image to ``{directory}/{basename}_{i:03d}.{suffix}``. ## src/physiomotion4d/convert_vtk_to_usd.py @@ -399,16 +402,16 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## src/physiomotion4d/vtk_to_usd/usd_utils.py -- `def ras_to_usd(point)` (line 20): Convert RAS (Right-Anterior-Superior) coordinates to USD's right-handed Y-up system. -- `def ras_points_to_usd(points)` (line 55): Convert array of RAS points (mm) to USD coordinates (m). -- `def ras_normals_to_usd(normals)` (line 78): Convert array of RAS normals to USD Y-up coordinates. -- `def numpy_to_vt_array(array, data_type)` (line 101): Convert numpy array to appropriate VtArray type. -- `def get_sdf_value_type(data_type, num_components)` (line 173): Get appropriate SDF value type for primvar creation. -- `def sanitize_primvar_name(name)` (line 220): Sanitize a name to be USD-compliant. -- `def create_primvar(geom, array, array_name_prefix='', time_code=None)` (line 255): Create a USD primvar from a GenericArray. -- `def triangulate_face(face_counts, face_indices)` (line 369): Triangulate polygonal faces using fan triangulation. -- `def compute_mesh_extent(points)` (line 420): Compute bounding box extent for a mesh. -- `def add_framing_camera(stage, *, parent_path='/World', name='Camera', bounds_min=None, bounds_max=None, focal_length_mm=50.0, horizontal_aperture_mm=36.0, distance_factor=3.0)` (line 432): Define a USD camera that frames stage geometry with tight clipping planes. +- `def lps_to_usd(point)` (line 20): Convert LPS (Left-Posterior-Superior) coordinates to USD's right-handed Y-up frame. +- `def lps_points_to_usd(points)` (line 58): Convert array of LPS points (mm) to USD Y-up coordinates (m). +- `def lps_normals_to_usd(normals)` (line 82): Convert array of LPS normals to USD Y-up coordinates. +- `def numpy_to_vt_array(array, data_type)` (line 105): Convert numpy array to appropriate VtArray type. +- `def get_sdf_value_type(data_type, num_components)` (line 177): Get appropriate SDF value type for primvar creation. +- `def sanitize_primvar_name(name)` (line 224): Sanitize a name to be USD-compliant. +- `def create_primvar(geom, array, array_name_prefix='', time_code=None)` (line 259): Create a USD primvar from a GenericArray. +- `def triangulate_face(face_counts, face_indices)` (line 373): Triangulate polygonal faces using fan triangulation. +- `def compute_mesh_extent(points)` (line 424): Compute bounding box extent for a mesh. +- `def add_framing_camera(stage, *, parent_path='/World', name='Camera', bounds_min=None, bounds_max=None, focal_length_mm=50.0, horizontal_aperture_mm=36.0, distance_factor=3.0)` (line 436): Define a USD camera that frames stage geometry with tight clipping planes. ## src/physiomotion4d/vtk_to_usd/vtk_reader.py @@ -432,11 +435,11 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def save_combined_surface(surfaces, output_dir, prefix='')` (line 397): Merge all group surfaces into a single VTP file. - `def save_combined_mesh(meshes, output_dir, prefix='')` (line 432): Merge all group meshes into a single VTU file. -## src/physiomotion4d/workflow_convert_heart_gated_ct_to_usd.py +## src/physiomotion4d/workflow_convert_image_to_usd.py -- **class WorkflowConvertHeartGatedCTToUSD** (line 28): Complete workflow for Heart-gated CT images to dynamic USD models. - - `def __init__(self, input_filenames, contrast_enhanced, output_directory, project_name, reference_image_filename=None, number_of_registration_iterations=1, registration_method='icon', log_level=logging.INFO, save_registered_images=True, save_registration_transforms=True, save_labelmaps=True)` (line 36): Initialize the Heart-gated CT to USD workflow. - - `def process(self)` (line 177): Execute the complete workflow from 4D CT to dynamic USD models. +- **class WorkflowConvertImageToUSD** (line 30): Complete workflow for converting 4D CT images to dynamic USD models. + - `def __init__(self, input_filenames, contrast_enhanced, output_directory, project_name, reference_image_filename=None, number_of_registration_iterations=1, registration_method='icon', log_level=logging.INFO, save_registered_images=True, save_registration_transforms=True, save_labelmaps=True)` (line 38): Initialize the image-to-USD workflow. + - `def process(self)` (line 184): Execute the complete workflow from 4D CT to dynamic USD models. ## src/physiomotion4d/workflow_convert_vtk_to_usd.py @@ -520,7 +523,7 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## tests/test_cli_smoke.py -- `def test_cli_help(module_name, monkeypatch, capsys)` (line 23): Each CLI module exits successfully for --help. +- `def test_cli_help(module_name, monkeypatch, capsys)` (line 24): Each CLI module exits successfully for --help. ## tests/test_contour_tools.py @@ -535,13 +538,13 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def test_transform_contours_with_deformation(self, contour_tools, test_labelmaps, test_directories)` (line 267): Test transforming contours with deformation magnitude calculation. - `def test_contours_from_both_time_points(self, contour_tools, test_labelmaps, test_directories)` (line 316): Test extracting contours from both time points. -## tests/test_convert_nrrd_4d_to_3d.py +## tests/test_convert_image_4d_to_3d.py -- **class TestConvertNRRD4DTo3D** (line 17): Test suite for converting 4D NRRD to 3D time series. - - `def test_convert_4d_to_3d(self, download_test_data, test_directories)` (line 20): Test conversion of 4D NRRD to 3D time series. - - `def test_slice_files_created(self, download_test_data, test_directories)` (line 46): Test that all expected slice files are present after conversion. - - `def test_load_nrrd_4d(self, download_test_data)` (line 67): Test loading 4D NRRD file. - - `def test_save_3d_images(self, download_test_data, test_directories)` (line 80): Test saving 3D images from 4D NRRD. +- **class TestConvertImage4DTo3D** (line 17): Test suite for converting a 4D image to a 3D time series. + - `def test_convert_4d_to_3d(self, download_test_data, test_directories)` (line 20): Test conversion of 4D image to 3D time series. + - `def test_slice_files_created(self, download_test_data, test_directories)` (line 43): Test that all expected slice files are present after conversion. + - `def test_load_image_4d(self, download_test_data)` (line 66): Test loading a 4D image. + - `def test_save_3d_images(self, download_test_data, test_directories)` (line 77): Test saving 3D images from a 4D source. ## tests/test_convert_vtk_to_usd.py diff --git a/docs/PYPI_RELEASE_GUIDE.md b/docs/PYPI_RELEASE_GUIDE.md index 45b1634..c670794 100644 --- a/docs/PYPI_RELEASE_GUIDE.md +++ b/docs/PYPI_RELEASE_GUIDE.md @@ -98,7 +98,7 @@ python -c "import physiomotion4d; print(physiomotion4d.__version__)" # Test CLI commands physiomotion4d --help -physiomotion4d-heart-gated-ct --help +physiomotion4d-convert-image-to-usd --help ``` ## Building the Package @@ -159,15 +159,15 @@ Note: `--extra-index-url` is needed because dependencies are on PyPI, not TestPy ```python # Test imports import physiomotion4d -from physiomotion4d import WorkflowConvertHeartGatedCTToUSD +from physiomotion4d import WorkflowConvertImageToUSD print(f"Version: {physiomotion4d.__version__}") -print(WorkflowConvertHeartGatedCTToUSD.__name__) +print(WorkflowConvertImageToUSD.__name__) ``` ```bash # Test CLI -physiomotion4d-heart-gated-ct --help +physiomotion4d-convert-image-to-usd --help ``` ## Publishing to PyPI diff --git a/docs/api/cli/convert_heart_gated_ct_to_usd.rst b/docs/api/cli/convert_heart_gated_ct_to_usd.rst deleted file mode 100644 index 2f1af1f..0000000 --- a/docs/api/cli/convert_heart_gated_ct_to_usd.rst +++ /dev/null @@ -1,10 +0,0 @@ -=================================== -convert_heart_gated_ct_to_usd (CLI) -=================================== - -.. automodule:: physiomotion4d.cli.convert_heart_gated_ct_to_usd - :members: - :undoc-members: - :show-inheritance: - -See :doc:`../../cli_scripts/heart_gated_ct` for usage examples. diff --git a/docs/api/cli/convert_image_to_usd.rst b/docs/api/cli/convert_image_to_usd.rst new file mode 100644 index 0000000..033f8ae --- /dev/null +++ b/docs/api/cli/convert_image_to_usd.rst @@ -0,0 +1,10 @@ +========================== +convert_image_to_usd (CLI) +========================== + +.. automodule:: physiomotion4d.cli.convert_image_to_usd + :members: + :undoc-members: + :show-inheritance: + +See :doc:`../../cli_scripts/heart_gated_ct` for usage examples. diff --git a/docs/api/cli/index.rst b/docs/api/cli/index.rst index 61799e0..aa885c3 100644 --- a/docs/api/cli/index.rst +++ b/docs/api/cli/index.rst @@ -18,7 +18,7 @@ Module Index. .. toctree:: :maxdepth: 1 - convert_heart_gated_ct_to_usd + convert_image_to_usd convert_ct_to_vtk convert_vtk_to_usd create_statistical_model diff --git a/docs/api/index.rst b/docs/api/index.rst index 42bd8ab..2b6d870 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -29,7 +29,7 @@ By Category * :class:`~physiomotion4d.PhysioMotion4DBase` - Base class for all components **Workflows** - * :class:`~physiomotion4d.WorkflowConvertHeartGatedCTToUSD` - Heart CT to USD + * :class:`~physiomotion4d.WorkflowConvertImageToUSD` - Heart CT to USD * :class:`~physiomotion4d.WorkflowCreateStatisticalModel` - Create PCA statistical shape model * :class:`~physiomotion4d.WorkflowFitStatisticalModelToPatient` - Heart model registration diff --git a/docs/api/utilities/image_conversion.rst b/docs/api/utilities/image_conversion.rst new file mode 100644 index 0000000..b90ac1e --- /dev/null +++ b/docs/api/utilities/image_conversion.rst @@ -0,0 +1,22 @@ +==================================== +4D Image Conversion +==================================== + +.. currentmodule:: physiomotion4d + +Utilities for converting 4D medical images into a 3D time-series sequence. +``.nrrd`` inputs (including Slicer ``.seq.nrrd`` heart sequences, whose +per-voxel vector dimension exceeds ITK Python's wrapped Vector sizes) are read +with ``pynrrd``; all other formats (NIfTI ``.nii.gz``, MHA, …) are read with +``itk.imread`` and must be true 4-dimensional images. + +Module Reference +================ + +.. automodule:: physiomotion4d.convert_image_4d_to_3d + :members: + :undoc-members: + +.. rubric:: Navigation + +:doc:`contour_tools` | :doc:`index` | :doc:`../index` diff --git a/docs/api/utilities/index.rst b/docs/api/utilities/index.rst index 508ad35..0e4d2c0 100644 --- a/docs/api/utilities/index.rst +++ b/docs/api/utilities/index.rst @@ -14,7 +14,7 @@ Utility modules provide low-level operations: * **Image Tools**: Image I/O, preprocessing, manipulation * **Transform Tools**: Coordinate transforms and warping * **Contour Tools**: Contour extraction and processing -* **NRRD Conversion**: 4D NRRD to 3D conversion utilities +* **4D Image Conversion**: 4D image to 3D time-series conversion utilities Quick Links =========== @@ -23,7 +23,7 @@ Quick Links * :doc:`image_tools` - Image processing utilities * :doc:`transform_tools` - Transform operations * :doc:`contour_tools` - Contour processing - * :doc:`nrrd_conversion` - NRRD file utilities + * :doc:`image_conversion` - 4D image to 3D time-series utilities * :doc:`test_tools` - Baseline / result comparison helpers * :doc:`data_download` - Optional dataset download helpers @@ -36,7 +36,7 @@ Module Documentation image_tools transform_tools contour_tools - nrrd_conversion + image_conversion test_tools data_download diff --git a/docs/api/utilities/nrrd_conversion.rst b/docs/api/utilities/nrrd_conversion.rst deleted file mode 100644 index 3926ef9..0000000 --- a/docs/api/utilities/nrrd_conversion.rst +++ /dev/null @@ -1,18 +0,0 @@ -==================================== -NRRD Conversion -==================================== - -.. currentmodule:: physiomotion4d - -Utilities for converting 4D NRRD files to 3D sequences. - -Module Reference -================ - -.. automodule:: physiomotion4d.convert_nrrd_4d_to_3d - :members: - :undoc-members: - -.. rubric:: Navigation - -:doc:`contour_tools` | :doc:`index` | :doc:`../index` diff --git a/docs/api/workflows.rst b/docs/api/workflows.rst index d3e5be2..0fe4a36 100644 --- a/docs/api/workflows.rst +++ b/docs/api/workflows.rst @@ -2,7 +2,7 @@ Workflow Classes ================ -.. module:: physiomotion4d.workflow_convert_heart_gated_ct_to_usd +.. module:: physiomotion4d.workflow_convert_image_to_usd .. module:: physiomotion4d.workflow_convert_ct_to_vtk .. module:: physiomotion4d.workflow_convert_vtk_to_usd .. module:: physiomotion4d.workflow_create_statistical_model @@ -24,7 +24,7 @@ Available Workflows * - Workflow - Typical use - * - :class:`WorkflowConvertHeartGatedCTToUSD` + * - :class:`WorkflowConvertImageToUSD` - Convert a 4D cardiac CT sequence into animated USD anatomy. * - :class:`WorkflowConvertCTToVTK` - Segment one CT image and export anatomy-group VTK surfaces and meshes. @@ -38,19 +38,19 @@ Available Workflows - Reconstruct a high-resolution 4D CT series from phase images and a high-resolution reference. -Heart-Gated CT to USD -===================== +Convert Image to USD +==================== -.. autoclass:: WorkflowConvertHeartGatedCTToUSD +.. autoclass:: WorkflowConvertImageToUSD :members: :undoc-members: :show-inheritance: .. code-block:: python - from physiomotion4d import WorkflowConvertHeartGatedCTToUSD + from physiomotion4d import WorkflowConvertImageToUSD - workflow = WorkflowConvertHeartGatedCTToUSD( + workflow = WorkflowConvertImageToUSD( input_filenames=["cardiac_4d.nrrd"], contrast_enhanced=True, output_directory="./results", diff --git a/docs/architecture.rst b/docs/architecture.rst index 49b707d..93f4a40 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -20,7 +20,7 @@ Data Flow 4D CT / time-series CT | v - ConvertNRRD4DTo3D / ImageTools + ConvertImage4DTo3D / ImageTools | v RegisterTimeSeriesImages @@ -41,7 +41,7 @@ Data Flow Primary Workflows ================= -``WorkflowConvertHeartGatedCTToUSD`` +``WorkflowConvertImageToUSD`` Converts a 4D cardiac CT file or 3D CT time series into registered anatomy contours and painted animated USD files. @@ -77,9 +77,10 @@ OpenUSD stage creation, material assignment, coordinate conversion, and time samples. The high-risk boundary is the ITK-to-PyVista-to-USD path. Image data remains in -ITK image space until contours are extracted. Meshes are represented as PyVista -objects before USD export. The VTK-to-USD layer applies the repository's -RAS-to-Y-up coordinate transform during USD conversion. +ITK's native LPS world space until contours are extracted. Meshes are +represented as PyVista objects (still in LPS) before USD export. The VTK-to-USD +layer applies the repository's LPS-to-USD-Y-up coordinate transform during USD +conversion. CLI Boundary ============ @@ -87,7 +88,7 @@ CLI Boundary The installed CLI commands in ``pyproject.toml`` are thin wrappers around the workflow classes. They are the preferred examples for executable API usage: -* ``physiomotion4d-heart-gated-ct`` +* ``physiomotion4d-convert-image-to-usd`` * ``physiomotion4d-convert-ct-to-vtk`` * ``physiomotion4d-create-statistical-model`` * ``physiomotion4d-fit-statistical-model-to-patient`` diff --git a/docs/cli_scripts/best_practices.rst b/docs/cli_scripts/best_practices.rst index c59214c..fd01ed6 100644 --- a/docs/cli_scripts/best_practices.rst +++ b/docs/cli_scripts/best_practices.rst @@ -103,7 +103,7 @@ Workflow Optimization patient_id=$(basename "$patient_dir") echo "Processing $patient_id" - physiomotion4d-heart-gated-ct \ + physiomotion4d-convert-image-to-usd \ ${patient_dir}/*.nrrd \ --contrast \ --output-dir processing/${patient_id} \ @@ -279,7 +279,7 @@ For non-gated multi-phase acquisitions (e.g., arterial/venous/delayed): .. code-block:: bash - physiomotion4d-heart-gated-ct \ + physiomotion4d-convert-image-to-usd \ arterial.nrrd \ venous.nrrd \ delayed.nrrd \ @@ -293,7 +293,7 @@ Process multiple patients in parallel: .. code-block:: bash # Using GNU parallel - parallel -j 4 'physiomotion4d-heart-gated-ct {}/*.nrrd \ + parallel -j 4 'physiomotion4d-convert-image-to-usd {}/*.nrrd \ --output-dir results/{/} \ --project-name {/}' ::: raw_data/patient_* diff --git a/docs/cli_scripts/heart_gated_ct.rst b/docs/cli_scripts/heart_gated_ct.rst index fb32a91..059c2d8 100644 --- a/docs/cli_scripts/heart_gated_ct.rst +++ b/docs/cli_scripts/heart_gated_ct.rst @@ -7,7 +7,7 @@ Process cardiac gated CT images into dynamic, animated heart models for visualiz Overview ======== -The ``physiomotion4d-heart-gated-ct`` script processes 4D cardiac CT scans through a complete pipeline that includes: +The ``physiomotion4d-convert-image-to-usd`` script processes 4D cardiac CT scans through a complete pipeline that includes: * AI-based anatomical segmentation * Deformable image registration across cardiac phases @@ -56,21 +56,21 @@ Single 4D File .. code-block:: bash - physiomotion4d-heart-gated-ct cardiac_4d.nrrd --contrast + physiomotion4d-convert-image-to-usd cardiac_4d.nrrd --contrast Multiple 3D Files ----------------- .. code-block:: bash - physiomotion4d-heart-gated-ct phase_*.nrrd --contrast --project-name patient_001 + physiomotion4d-convert-image-to-usd phase_*.nrrd --contrast --project-name patient_001 With Output Directory --------------------- .. code-block:: bash - physiomotion4d-heart-gated-ct cardiac.nrrd \ + physiomotion4d-convert-image-to-usd cardiac.nrrd \ --contrast \ --output-dir ./results/patient_001 \ --project-name patient_001 @@ -186,7 +186,7 @@ Contrast-Enhanced Cardiac CTA .. code-block:: bash - physiomotion4d-heart-gated-ct cardiac_cta.nrrd \ + physiomotion4d-convert-image-to-usd cardiac_cta.nrrd \ --contrast \ --output-dir ./output/patient_123 \ --project-name PatientXYZ_CTA @@ -196,7 +196,7 @@ Non-Contrast Study with Custom Reference .. code-block:: bash - physiomotion4d-heart-gated-ct phase_*.nrrd \ + physiomotion4d-convert-image-to-usd phase_*.nrrd \ --reference-image phase_05.mha \ --output-dir ./results \ --project-name noncontrast_cardiac @@ -209,7 +209,7 @@ Research Dataset Processing # Batch process multiple cases for case_dir in /data/cardiac_studies/case_*/; do case_name=$(basename "$case_dir") - physiomotion4d-heart-gated-ct ${case_dir}/*.nrrd \ + physiomotion4d-convert-image-to-usd ${case_dir}/*.nrrd \ --contrast \ --output-dir ./results/${case_name} \ --project-name ${case_name} @@ -220,7 +220,7 @@ With ANTs Registration .. code-block:: bash - physiomotion4d-heart-gated-ct cardiac.nrrd \ + physiomotion4d-convert-image-to-usd cardiac.nrrd \ --contrast \ --registration-method ants \ --registration-iterations 50 @@ -340,4 +340,4 @@ Next Steps * See :doc:`best_practices` for optimization strategies * Review :doc:`../troubleshooting` for common issues -* For Python API access, see :class:`physiomotion4d.WorkflowConvertHeartGatedCTToUSD` in :doc:`../developer/workflows` +* For Python API access, see :class:`physiomotion4d.WorkflowConvertImageToUSD` in :doc:`../developer/workflows` diff --git a/docs/cli_scripts/overview.rst b/docs/cli_scripts/overview.rst index f4ac64f..60058b4 100644 --- a/docs/cli_scripts/overview.rst +++ b/docs/cli_scripts/overview.rst @@ -53,6 +53,8 @@ Current Scripts - Process cardiac gated CT to animated heart models with physiological motion * - ``physiomotion4d-convert-ct-to-vtk`` - Segment one CT image and export anatomy-group VTK surfaces and meshes + * - ``physiomotion4d-convert-image-4d-to-3d`` + - Split a 4D medical image into a 3D time series using ITK readers * - :doc:`create_statistical_model` - Build a PCA statistical shape model from sample meshes aligned to a reference * - :doc:`fit_statistical_model_to_patient` @@ -77,7 +79,7 @@ After installation, scripts are available as command-line tools with the prefix .. code-block:: bash - physiomotion4d-heart-gated-ct --help + physiomotion4d-convert-image-to-usd --help General Workflow ================ diff --git a/docs/developer/architecture.rst b/docs/developer/architecture.rst index 473f341..ccbf06b 100644 --- a/docs/developer/architecture.rst +++ b/docs/developer/architecture.rst @@ -34,7 +34,7 @@ Architecture Diagram Workflow Classes ================ -``WorkflowConvertHeartGatedCTToUSD`` +``WorkflowConvertImageToUSD`` Orchestrates 4D CT loading, segmentation, image registration, contour transformation, and animated USD generation. @@ -58,9 +58,10 @@ Workflow Classes Key Boundaries ============== -Image processing uses ITK images. Surface and volume meshes use PyVista/VTK. -USD export converts those meshes to OpenUSD and applies the repository's -RAS-to-Y-up transform at the VTK-to-USD boundary. +Image processing uses ITK images in LPS world space. Surface and volume meshes +use PyVista/VTK and inherit that LPS frame. USD export converts those meshes to +OpenUSD and applies the repository's LPS-to-USD-Y-up transform at the +VTK-to-USD boundary. The installed CLI commands are thin wrappers around these workflow classes. They are the best executable references for supported API usage. diff --git a/docs/developer/workflows.rst b/docs/developer/workflows.rst index 10b79e5..a8869d5 100644 --- a/docs/developer/workflows.rst +++ b/docs/developer/workflows.rst @@ -14,8 +14,8 @@ Current Workflow Mapping * - CLI command - Workflow class - * - ``physiomotion4d-heart-gated-ct`` - - :class:`physiomotion4d.WorkflowConvertHeartGatedCTToUSD` + * - ``physiomotion4d-convert-image-to-usd`` + - :class:`physiomotion4d.WorkflowConvertImageToUSD` * - ``physiomotion4d-convert-ct-to-vtk`` - :class:`physiomotion4d.WorkflowConvertCTToVTK` * - ``physiomotion4d-convert-vtk-to-usd`` @@ -32,9 +32,9 @@ Workflow Example .. code-block:: python - from physiomotion4d import WorkflowConvertHeartGatedCTToUSD + from physiomotion4d import WorkflowConvertImageToUSD - workflow = WorkflowConvertHeartGatedCTToUSD( + workflow = WorkflowConvertImageToUSD( input_filenames=["cardiac_4d.nrrd"], contrast_enhanced=True, output_directory="./results", @@ -59,7 +59,7 @@ Risk Areas ========== Changes at the ITK-to-PyVista boundary, time-series transform direction, or -RAS-to-Y-up USD coordinate conversion are high risk and should include focused +LPS-to-USD-Y-up coordinate conversion are high risk and should include focused tests plus visual or metadata validation. See Also diff --git a/docs/examples.rst b/docs/examples.rst index d1cd0cb..3ca3d99 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -7,7 +7,7 @@ see the :doc:`cli_scripts/overview` section. .. note:: - **For Production Workflows:** The CLI commands (``physiomotion4d-heart-gated-ct``, + **For Production Workflows:** The CLI commands (``physiomotion4d-convert-image-to-usd``, ``physiomotion4d-create-statistical-model``, ``physiomotion4d-fit-statistical-model-to-patient``) and their implementations in ``src/physiomotion4d/cli/`` are the definitive source for proper library usage, class instantiation, and best practices. @@ -27,10 +27,10 @@ Complete end-to-end cardiac CT processing: .. code-block:: python - from physiomotion4d import WorkflowConvertHeartGatedCTToUSD + from physiomotion4d import WorkflowConvertImageToUSD # Initialize workflow - workflow = WorkflowConvertHeartGatedCTToUSD( + workflow = WorkflowConvertImageToUSD( input_filenames=["cardiac_4d.nrrd"], contrast_enhanced=True, output_directory="./results", @@ -390,7 +390,7 @@ Batch process multiple datasets: .. code-block:: python - from physiomotion4d import WorkflowConvertHeartGatedCTToUSD + from physiomotion4d import WorkflowConvertImageToUSD import glob import os @@ -406,7 +406,7 @@ Batch process multiple datasets: print(f"Processing {patient_id}...") - workflow = WorkflowConvertHeartGatedCTToUSD( + workflow = WorkflowConvertImageToUSD( input_filenames=[input_file], contrast_enhanced=True, output_directory=f"results/{patient_id}", @@ -474,9 +474,9 @@ Run the supported end-to-end workflow API: .. code-block:: python - from physiomotion4d import WorkflowConvertHeartGatedCTToUSD + from physiomotion4d import WorkflowConvertImageToUSD - workflow = WorkflowConvertHeartGatedCTToUSD( + workflow = WorkflowConvertImageToUSD( input_filenames=["cardiac_4d.nrrd"], contrast_enhanced=True, output_directory="./results", diff --git a/docs/installation.rst b/docs/installation.rst index 74ae2ea..8fa03d8 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -162,17 +162,17 @@ After installation, verify that PhysioMotion4D is correctly installed: .. code-block:: python import physiomotion4d - from physiomotion4d import WorkflowConvertHeartGatedCTToUSD + from physiomotion4d import WorkflowConvertImageToUSD print(f"PhysioMotion4D version: {physiomotion4d.__version__}") - print(WorkflowConvertHeartGatedCTToUSD.__name__) + print(WorkflowConvertImageToUSD.__name__) Expected output: .. code-block:: text PhysioMotion4D version: 2026.05.07 - WorkflowConvertHeartGatedCTToUSD + WorkflowConvertImageToUSD Command-Line Tools ================== @@ -183,7 +183,7 @@ PhysioMotion4D provides command-line interfaces that should be available after i # Check CLI is available physiomotion4d --help - physiomotion4d-heart-gated-ct --help + physiomotion4d-convert-image-to-usd --help GPU Setup ========= diff --git a/docs/quickstart.rst b/docs/quickstart.rst index ef29a4a..68fed1c 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -70,7 +70,7 @@ CUDA-capable GPU are required for practical runtime. python -c "from physiomotion4d import DataDownloadTools; DataDownloadTools.DownloadSlicerHeartCTData('data/test')" - physiomotion4d-heart-gated-ct data/test/TruncalValve_4DCT.seq.nrrd \ + physiomotion4d-convert-image-to-usd data/test/TruncalValve_4DCT.seq.nrrd \ --registration-method ants \ --output-dir output/quickstart \ --project-name slicer_heart_quickstart @@ -83,13 +83,13 @@ The fastest way to process cardiac CT data is using the command-line interface: .. code-block:: bash # Process a single 4D cardiac CT file - physiomotion4d-heart-gated-ct cardiac_4d.nrrd --contrast --output-dir ./results + physiomotion4d-convert-image-to-usd cardiac_4d.nrrd --contrast --output-dir ./results # Process multiple time frames - physiomotion4d-heart-gated-ct frame_*.nrrd --contrast --project-name patient_001 + physiomotion4d-convert-image-to-usd frame_*.nrrd --contrast --project-name patient_001 # With custom settings - physiomotion4d-heart-gated-ct cardiac.nrrd \ + physiomotion4d-convert-image-to-usd cardiac.nrrd \ --contrast \ --reference-image ref.mha \ --registration-iterations 50 \ @@ -104,13 +104,13 @@ For more control, use the Python API: .. code-block:: python - from physiomotion4d import WorkflowConvertHeartGatedCTToUSD + from physiomotion4d import WorkflowConvertImageToUSD **Step 2: Initialize with your data** .. code-block:: python - processor = WorkflowConvertHeartGatedCTToUSD( + processor = WorkflowConvertImageToUSD( input_filenames=["path/to/cardiac_4d_ct.nrrd"], contrast_enhanced=True, output_directory="./results", @@ -142,10 +142,10 @@ For more control over individual steps: .. code-block:: python - from physiomotion4d import WorkflowConvertHeartGatedCTToUSD + from physiomotion4d import WorkflowConvertImageToUSD # Initialize workflow - workflow = WorkflowConvertHeartGatedCTToUSD( + workflow = WorkflowConvertImageToUSD( input_filenames=["cardiac_4d.nrrd"], contrast_enhanced=True, output_directory="./results", @@ -297,7 +297,7 @@ Now that you've completed your first workflow: **About CLI Commands and Experiments:** * **CLI Commands** ⭐ **PRIMARY RESOURCE** - Production-ready workflows with proper class usage - (``physiomotion4d-heart-gated-ct``, ``physiomotion4d-create-statistical-model``, + (``physiomotion4d-convert-image-to-usd``, ``physiomotion4d-create-statistical-model``, ``physiomotion4d-fit-statistical-model-to-patient``). See ``src/physiomotion4d/cli/`` for implementation details. diff --git a/docs/testing.rst b/docs/testing.rst index a0e0f26..b946fcf 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -37,7 +37,7 @@ Specific Areas .. code-block:: bash pytest tests/test_convert_vtk_to_usd.py -v - pytest tests/test_convert_nrrd_4d_to_3d.py -v + pytest tests/test_convert_image_4d_to_3d.py -v pytest tests/test_contour_tools.py -v pytest tests/test_transform_tools.py -v pytest tests/test_image_tools.py -v diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 5a1f292..659d733 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -73,7 +73,7 @@ Poor Segmentation Quality .. code-block:: python - workflow = WorkflowConvertHeartGatedCTToUSD( + workflow = WorkflowConvertImageToUSD( ..., contrast_enhanced=True # or False ) @@ -93,7 +93,7 @@ Registration Not Converging .. code-block:: bash - physiomotion4d-heart-gated-ct cardiac_4d.nrrd --registration-method ants + physiomotion4d-convert-image-to-usd cardiac_4d.nrrd --registration-method ants 3. Check image orientation and spacing diff --git a/docs/tutorials.rst b/docs/tutorials.rst index 2e5313b..781884e 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -100,7 +100,7 @@ Script ``tutorials/tutorial_01_heart_gated_ct_to_usd.py`` Workflow - ``WorkflowConvertHeartGatedCTToUSD`` + ``WorkflowConvertImageToUSD`` Dataset Slicer-Heart-CT, prepared before running the tutorial. 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 cf6ce24..88121e7 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 @@ -13,7 +13,7 @@ # - **Data Arrays**: VTK point and cell data arrays → USD primvars # - **Time-Series**: Support for animated/time-varying data # - **Materials**: UsdPreviewSurface materials with customizable properties -# - **Coordinate System**: Automatic conversion from RAS (medical imaging) to USD Y-up +# - **Coordinate System**: Automatic conversion from LPS (ITK's native medical imaging frame) to USD right-handed Y-up # # ## Test Data # @@ -375,7 +375,7 @@ def verify_usd_file(usd_path): # 5. **Material System**: Custom materials with UsdPreviewSurface # 6. **Time-Series**: Animated meshes with time-varying attributes # 7. **Data Preservation**: All VTK arrays preserved as USD primvars -# 8. **Coordinate Systems**: Automatic RAS to Y-up conversion +# 8. **Coordinate Systems**: Automatic LPS to USD right-handed Y-up conversion # # The library is production-ready and can be used for converting medical imaging data, simulation results, and other VTK-based datasets to USD for visualization in Omniverse, USDView, or other USD-compatible applications. # diff --git a/experiments/Heart-GatedCT_To_USD/0-download_and_convert_4d_to_3d.py b/experiments/Heart-GatedCT_To_USD/0-download_and_convert_4d_to_3d.py index eaada36..33d3514 100644 --- a/experiments/Heart-GatedCT_To_USD/0-download_and_convert_4d_to_3d.py +++ b/experiments/Heart-GatedCT_To_USD/0-download_and_convert_4d_to_3d.py @@ -3,7 +3,7 @@ import shutil from pathlib import Path -from physiomotion4d.convert_nrrd_4d_to_3d import ConvertNRRD4DTo3D +from physiomotion4d.convert_image_4d_to_3d import ConvertImage4DTo3D from physiomotion4d.data_download_tools import DataDownloadTools _HERE = Path(__file__).resolve().parent @@ -18,8 +18,8 @@ input_image_filename = DataDownloadTools.DownloadSlicerHeartCTData(data_dir) # %% -conv = ConvertNRRD4DTo3D() -conv.load_nrrd_4d(str(input_image_filename)) +conv = ConvertImage4DTo3D() +conv.load_image_4d(str(input_image_filename)) conv.save_3d_images(data_dir, "slice") # Save the mid-stroke slice as the fixed/reference image diff --git a/experiments/Heart-Simpleware_Segmentation/README.md b/experiments/Heart-Simpleware_Segmentation/README.md index 0dcf470..b15fd59 100644 --- a/experiments/Heart-Simpleware_Segmentation/README.md +++ b/experiments/Heart-Simpleware_Segmentation/README.md @@ -218,9 +218,9 @@ This experiment can be combined with other PhysioMotion4D workflows: Use segmentation results with `Heart-GatedCT_To_USD` workflow: ```python # After segmentation -from physiomotion4d.workflow_convert_heart_gated_ct_to_usd import WorkflowConvertHeartGatedCTToUSD +from physiomotion4d.workflow_convert_image_to_usd import WorkflowConvertImageToUSD -workflow = WorkflowConvertHeartGatedCTToUSD() +workflow = WorkflowConvertImageToUSD() workflow.set_static_labelmap(result["labelmap"]) # Continue with 4D USD generation ``` diff --git a/experiments/Heart-VTKSeries_To_USD/0-download_and_convert_4d_to_3d.py b/experiments/Heart-VTKSeries_To_USD/0-download_and_convert_4d_to_3d.py index dfc0eb2..3b8b68f 100644 --- a/experiments/Heart-VTKSeries_To_USD/0-download_and_convert_4d_to_3d.py +++ b/experiments/Heart-VTKSeries_To_USD/0-download_and_convert_4d_to_3d.py @@ -2,7 +2,7 @@ # %% import os -from physiomotion4d.convert_nrrd_4d_to_3d import ConvertNRRD4DTo3D +from physiomotion4d.convert_image_4d_to_3d import ConvertImage4DTo3D from physiomotion4d.data_download_tools import DataDownloadTools _HERE = os.path.dirname(os.path.abspath(__file__)) @@ -21,6 +21,6 @@ # %% if not os.path.exists(f"{data_dir}/slice_000.mha"): - conv = ConvertNRRD4DTo3D() - conv.load_nrrd_4d(str(input_image_filename)) + conv = ConvertImage4DTo3D() + conv.load_image_4d(str(input_image_filename)) conv.save_3d_images(data_dir, "slice") diff --git a/experiments/README.md b/experiments/README.md index 7f975a6..eaeeb40 100644 --- a/experiments/README.md +++ b/experiments/README.md @@ -14,7 +14,7 @@ of the PhysioMotion4D library. - Command-line tools and parameter specifications See: -- **CLI Commands**: Run `physiomotion4d-heart-gated-ct --help`, `physiomotion4d-create-statistical-model --help`, and `physiomotion4d-fit-statistical-model-to-patient --help` +- **CLI Commands**: Run `physiomotion4d-convert-image-to-usd --help`, `physiomotion4d-create-statistical-model --help`, and `physiomotion4d-fit-statistical-model-to-patient --help` - **CLI Implementation**: `src/physiomotion4d/cli/` for Python API examples - **Library Classes**: `src/physiomotion4d/` for all workflow and utility classes @@ -36,7 +36,7 @@ These experiments demonstrate key digital twin workflows that can be adapted to anatomical regions, imaging modalities, and physiological motion tasks. > **Note:** For production implementations of these workflows, use the CLI commands -> (`physiomotion4d-heart-gated-ct`, `physiomotion4d-create-statistical-model`, `physiomotion4d-fit-statistical-model-to-patient`) or consult +> (`physiomotion4d-convert-image-to-usd`, `physiomotion4d-create-statistical-model`, `physiomotion4d-fit-statistical-model-to-patient`) or consult > the CLI implementation in `src/physiomotion4d/cli/` for proper class usage and parameter specifications. ### `Reconstruct4DCT` - High-Resolution 4D Reconstruction @@ -274,7 +274,7 @@ Each subdirectory represents a different experimental domain: ### For Production Use, Consult: 1. **CLI Commands** ⭐ **PRIMARY RESOURCE** - - `physiomotion4d-heart-gated-ct` - Complete heart-gated CT workflow + - `physiomotion4d-convert-image-to-usd` - Complete heart-gated CT workflow - `physiomotion4d-create-statistical-model` - Create PCA statistical shape model from sample meshes - `physiomotion4d-fit-statistical-model-to-patient` - Model-to-patient registration - Run with `--help` for all options and parameter specifications @@ -337,7 +337,7 @@ The typical evolution path was: When exploring new digital twin applications, you can follow a similar path: - Start by understanding relevant experiments here as conceptual references - Examine CLI implementations in `src/physiomotion4d/cli/` for proper library usage -- Use CLI commands (`physiomotion4d-heart-gated-ct`, `physiomotion4d-create-statistical-model`, `physiomotion4d-fit-statistical-model-to-patient`) as starting points +- Use CLI commands (`physiomotion4d-convert-image-to-usd`, `physiomotion4d-create-statistical-model`, `physiomotion4d-fit-statistical-model-to-patient`) as starting points - Extend and adapt production code with your domain-specific requirements - Contribute back improvements and new capabilities to the community diff --git a/pyproject.toml b/pyproject.toml index d273666..379c8f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,7 @@ dependencies = [ "itk-tubetk>=1.4.0", "nibabel>=4.0.0", "numpy>=1.21.0", + "pydicom>=2.4.0", "pynrrd>=1.0.0", "vtk>=9.2.0", @@ -160,7 +161,8 @@ Repository = "https://github.com/Project-MONAI/physiomotion4d.git" # CLI commands installed via pip # Entry points reference the main() functions in the cli submodule physiomotion4d-convert-ct-to-vtk = "physiomotion4d.cli.convert_ct_to_vtk:main" -physiomotion4d-heart-gated-ct = "physiomotion4d.cli.convert_heart_gated_ct_to_usd:main" +physiomotion4d-convert-image-4d-to-3d = "physiomotion4d.cli.convert_image_4d_to_3d:main" +physiomotion4d-convert-image-to-usd = "physiomotion4d.cli.convert_image_to_usd:main" physiomotion4d-convert-vtk-to-usd = "physiomotion4d.cli.convert_vtk_to_usd:main" physiomotion4d-create-statistical-model = "physiomotion4d.cli.create_statistical_model:main" physiomotion4d-fit-statistical-model-to-patient = "physiomotion4d.cli.fit_statistical_model_to_patient:main" @@ -223,6 +225,8 @@ module = [ "numpy", "numpy.*", "picsl_greedy", + "pydicom", + "pydicom.*", "pxr", "pxr.*", "pyvista", diff --git a/src/physiomotion4d/__init__.py b/src/physiomotion4d/__init__.py index 5439e35..c1f5071 100644 --- a/src/physiomotion4d/__init__.py +++ b/src/physiomotion4d/__init__.py @@ -9,7 +9,7 @@ segmentation, registration, and USD file generation. Main Components: - - WorkflowConvertHeartGatedCTToUSD: Heart-gated CT to USD workflow + - WorkflowConvertImageToUSD: 4D CT image to USD workflow - Segmentation classes: Multiple AI-based chest segmentation implementations - Registration tools: Deep learning-based image registration - Transform utilities: Tools for image and contour transformations @@ -37,7 +37,7 @@ from .contour_tools import ContourTools # Data processing utilities -from .convert_nrrd_4d_to_3d import ConvertNRRD4DTo3D +from .convert_image_4d_to_3d import ConvertImage4DTo3D from .convert_vtk_to_usd import ConvertVTKToUSD from .data_download_tools import DataDownloadTools @@ -69,7 +69,7 @@ # Core workflow processor from .workflow_convert_ct_to_vtk import WorkflowConvertCTToVTK -from .workflow_convert_heart_gated_ct_to_usd import WorkflowConvertHeartGatedCTToUSD +from .workflow_convert_image_to_usd import WorkflowConvertImageToUSD from .workflow_convert_vtk_to_usd import WorkflowConvertVTKToUSD from .workflow_reconstruct_highres_4d_ct import WorkflowReconstructHighres4DCT from .workflow_create_statistical_model import WorkflowCreateStatisticalModel @@ -80,7 +80,7 @@ __all__ = [ # Workflow classes "WorkflowConvertCTToVTK", - "WorkflowConvertHeartGatedCTToUSD", + "WorkflowConvertImageToUSD", "WorkflowConvertVTKToUSD", "WorkflowCreateStatisticalModel", "WorkflowReconstructHighres4DCT", @@ -110,7 +110,7 @@ "USDAnatomyTools", "DataDownloadTools", # Data processing utilities - "ConvertNRRD4DTo3D", + "ConvertImage4DTo3D", "ConvertVTKToUSD", # Anatomy taxonomy (shared between segmenters and USD renderer) "AnatomyTaxonomy", diff --git a/src/physiomotion4d/cli/__init__.py b/src/physiomotion4d/cli/__init__.py index 3889cf9..ca3f06d 100644 --- a/src/physiomotion4d/cli/__init__.py +++ b/src/physiomotion4d/cli/__init__.py @@ -1,7 +1,8 @@ """Command-line interface modules for PhysioMotion4D.""" __all__ = [ - "convert_heart_gated_ct_to_usd", + "convert_image_to_usd", + "convert_image_4d_to_3d", "convert_vtk_to_usd", "create_statistical_model", "fit_statistical_model_to_patient", diff --git a/src/physiomotion4d/cli/convert_image_4d_to_3d.py b/src/physiomotion4d/cli/convert_image_4d_to_3d.py new file mode 100644 index 0000000..1cdfcea --- /dev/null +++ b/src/physiomotion4d/cli/convert_image_4d_to_3d.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python +"""Command-line interface for splitting a 3D/4D image into a 3D time series. + +Reads a 3D or 4D medical image and writes one ``.mha`` file per temporal frame +to the chosen output directory. Supported inputs: + +* A single 4D file readable by ITK (NRRD, NIfTI, MHA, …); each temporal frame + is written separately. +* A single 3D file readable by ITK; it is written as a one-frame series. +* A directory containing a DICOM series (3D or 4D / gated); slices are grouped + by temporal phase and each phase is written as a 3D frame. +""" + +import argparse +import os +import sys +import traceback + + +def main() -> int: + """CLI entry point for 4D-to-3D image conversion.""" + parser = argparse.ArgumentParser( + description=( + "Split a 4D medical image into a 3D time series using ITK readers." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Output files +------------ + {output_dir}/{basename}_000.mha + {output_dir}/{basename}_001.mha + ... + +Examples +-------- + # Split a 4D NRRD file into 3D MHA frames + %(prog)s \\ + --input-image heart_4d.nrrd \\ + --output-dir ./frames + """, + ) + + parser.add_argument( + "--input-image", + required=True, + help=( + "Path to a 3D or 4D image file (ITK-readable, e.g. NRRD/NIfTI/MHA) " + "or a directory containing a DICOM series (3D or 4D)." + ), + ) + parser.add_argument( + "--output-dir", + required=True, + help="Directory for output .mha files (created if absent).", + ) + parser.add_argument( + "--basename", + default="slice", + help="Filename stem for each output frame (default: slice).", + ) + parser.add_argument( + "--suffix", + default="mha", + help="Suffix for each output frame (default: mha).", + ) + + args = parser.parse_args() + + if not os.path.exists(args.input_image): + print(f"Error: input image not found: {args.input_image}") + return 1 + try: + from physiomotion4d import ConvertImage4DTo3D + + converter = ConvertImage4DTo3D() + print(f"Loading 4D image: {args.input_image}") + converter.load_image_4d(args.input_image) + except (FileNotFoundError, OSError, RuntimeError, ValueError) as exc: + print(f"Error loading input: {exc}") + traceback.print_exc() + return 1 + + num_time_points = converter.get_number_of_3d_images() + if num_time_points <= 0: + print("No time points were extracted from the input.") + return 1 + + print(f"Extracted {num_time_points} time point(s).") + print(f"Writing to: {args.output_dir}") + + try: + converter.save_3d_images( + args.output_dir, + args.basename, + args.suffix, + ) + except (OSError, RuntimeError) as exc: + print(f"Error saving images: {exc}") + traceback.print_exc() + return 1 + + print( + f"Done. Files: {args.basename}_000.{args.suffix} … " + f"{args.basename}_{num_time_points - 1:03d}.{args.suffix}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/physiomotion4d/cli/convert_heart_gated_ct_to_usd.py b/src/physiomotion4d/cli/convert_image_to_usd.py similarity index 78% rename from src/physiomotion4d/cli/convert_heart_gated_ct_to_usd.py rename to src/physiomotion4d/cli/convert_image_to_usd.py index 6959168..317b130 100644 --- a/src/physiomotion4d/cli/convert_heart_gated_ct_to_usd.py +++ b/src/physiomotion4d/cli/convert_image_to_usd.py @@ -1,9 +1,9 @@ #!/usr/bin/env python """ -Command-line interface for Heart-gated CT to USD workflow. +Command-line interface for the Image-to-USD workflow. -This script provides a CLI to process 4D cardiac CT images through the complete -workflow, generating dynamic USD models suitable for visualization in NVIDIA Omniverse. +This script provides a CLI to process 4D CT images through the complete workflow, +generating dynamic USD models suitable for visualization in NVIDIA Omniverse. """ import argparse @@ -12,9 +12,9 @@ def main() -> int: - """Command-line interface for Heart-gated CT processing.""" + """Command-line interface for the Image-to-USD workflow.""" parser = argparse.ArgumentParser( - description="Process 4D cardiac CT images to dynamic USD models for Omniverse", + description="Process 4D CT images to dynamic USD models for Omniverse", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: @@ -33,7 +33,13 @@ def main() -> int: ) parser.add_argument( - "input_files", nargs="+", help="Path to 4D NRRD file or list of 3D NRRD files" + "input_files", + nargs="+", + help=( + "Input image source(s): a single 4D file (NRRD/NIfTI/MHA/...), " + "a directory containing a DICOM series (3D or 4D), or a list of " + "3D files representing a time series." + ), ) parser.add_argument( "--output-dir", @@ -74,11 +80,11 @@ def main() -> int: return 1 # Initialize processor - print("Initializing Heart-gated CT processor...") + print("Initializing Image-to-USD processor...") try: - from physiomotion4d import WorkflowConvertHeartGatedCTToUSD + from physiomotion4d import WorkflowConvertImageToUSD - processor = WorkflowConvertHeartGatedCTToUSD( + processor = WorkflowConvertImageToUSD( input_filenames=args.input_files, contrast_enhanced=args.contrast, output_directory=args.output_dir, @@ -93,7 +99,7 @@ def main() -> int: try: # Execute complete workflow - print("\nStarting Heart-gated CT processing pipeline...") + print("\nStarting Image-to-USD processing pipeline...") print("=" * 60) processor.process() diff --git a/src/physiomotion4d/convert_image_4d_to_3d.py b/src/physiomotion4d/convert_image_4d_to_3d.py new file mode 100644 index 0000000..642510b --- /dev/null +++ b/src/physiomotion4d/convert_image_4d_to_3d.py @@ -0,0 +1,297 @@ +"""Convert a 3D or 4D image into a sequence of 3D images. + +Reads a 3D or 4D medical image and, for the 4D case, splits it along the +temporal axis into individual 3D ITK volumes. Origin, spacing, and direction +are preserved in each per-frame volume. A pure 3D input becomes a one-element +time series. + +Three reader paths are used: + +* A *directory* path is treated as a DICOM series and read with ``pydicom``. + The slices are grouped by temporal position (``TemporalPositionIdentifier`` + or ``TriggerTime``); each group yields one 3D ITK image. Directories that + contain a single phase produce a single 3D image. +* ``.nrrd`` files: 4D Slicer ``.seq.nrrd`` heart sequences (whose per-voxel + vector dimension exceeds ITK Python's wrapped Vector sizes) go through + ``pynrrd``. Plain 3D NRRDs fall back to ``itk.imread``. +* Every other format goes through ``itk.imread`` and may be either 3D or 4D + (e.g. NIfTI ``.nii.gz`` with ``dim[0] == 3`` or ``4``). +""" + +import logging +from collections import defaultdict +from pathlib import Path +from typing import Any, Union + +import itk +import nrrd +import numpy as np +import pydicom + +from physiomotion4d.physiomotion4d_base import PhysioMotion4DBase + + +class ConvertImage4DTo3D(PhysioMotion4DBase): + """Split a 3D/4D ITK image (X, Y, Z [, T]) into a list of 3D ITK images.""" + + def __init__(self, log_level: int | str = logging.INFO) -> None: + """Initialize the 4D-to-3D image converter. + + Args: + log_level: Logging level (default: logging.INFO) + """ + super().__init__(class_name=self.__class__.__name__, log_level=log_level) + self.img_3d: list[itk.Image] = [] + + # Public DICOM tags used to split a 4D series into 3D phases. Maps + # tag keyword → default value (the value type also implies how the + # tag is parsed: ``float`` for numeric tags, ``str`` for string tags). + # External users may add, remove, or replace entries to tune phase + # grouping for their vendor-specific exports. + self.dicom_phase_keys: dict[str, Union[float, str]] = { + "TemporalPositionIdentifier": 0.0, + "TriggerTime": 0.0, + "NominalCardiacTriggerDelayTime": 0.0, + "ActualCardiacTriggerDelayTime": 0.0, + "NominalPercentageOfCardiacPhase": 0.0, + "FrameReferenceDateTime": "", + # "AcquisitionTime": "", + "ScanOptions": "", + } + + def load_image_4d(self, filename: Union[str, Path]) -> None: + """Load a 3D or 4D image and populate ``self.img_3d`` with 3D frames. + + Dispatch rules: + + * A *directory* path is read as a DICOM series via ``pydicom``. + Slices are grouped by temporal phase; each group becomes one 3D + ITK image. A 3D-only directory produces a single 3D image. + * ``.nrrd`` files use ``pynrrd`` for true 4D Slicer ``.seq.nrrd`` + inputs and fall back to ``itk.imread`` for plain 3D NRRDs. + * All other formats go through ``itk.imread``; the array may be + 3D or 4D and is treated uniformly as a (1 or T)-frame sequence. + + Args: + filename: Path to a 3D/4D image file, or a DICOM series directory. + """ + path = Path(filename) + if path.is_dir(): + self._load_dicom_directory(path) + return + + name = str(path) + if name.lower().endswith(".nrrd"): + data, header = nrrd.read(name) + arr_data = np.asarray(data) + if arr_data.ndim == 4: + self._load_nrrd_4d(name, arr_data, header) + return + if arr_data.ndim != 3: + raise ValueError( + f"Expected 3D or 4D NRRD, got array shape {arr_data.shape}: {name}" + ) + # 3D NRRD: defer to the standard ITK reader for correctness. + + self._load_itk_file(name) + + def _load_itk_file(self, filename: str) -> None: + """Read a 3D or 4D image with ``itk.imread`` and slice along T.""" + img = itk.imread(filename) + arr = itk.array_view_from_image(img) + if arr.ndim not in (3, 4): + raise ValueError( + f"Expected a 3D or 4D image, got array shape {arr.shape}: {filename}" + ) + origin_3d = np.asarray(img.GetOrigin())[:3] + spacing_3d = np.asarray(img.GetSpacing())[:3] + direction_3d = itk.array_from_matrix(img.GetDirection())[:3, :3] + + if arr.ndim == 3: + arr_4d = arr[np.newaxis, ...] + else: + arr_4d = arr + + self._build_frames(arr_4d, origin_3d, spacing_3d, direction_3d) + + def _load_nrrd_4d( + self, + filename: str, + data: np.ndarray, + header: dict[str, Any], + ) -> None: + """Build per-frame 3D ITK images from a Slicer 4D ``.seq.nrrd``.""" + # pynrrd returns the data in (T, X, Y, Z) order for a 4D NRRD. + # ITK numpy views use (T, Z, Y, X) — transpose the spatial axes. + arr_4d = np.ascontiguousarray(data.transpose(0, 3, 2, 1)) + + required_keys = ("space origin", "space directions", "measurement frame") + missing = [k for k in required_keys if k not in header] + if missing: + raise ValueError( + f"{filename!r} is not a valid Slicer 4D .seq.nrrd: " + f"missing NRRD header field(s) {missing}" + ) + space_directions = np.asarray(header["space directions"]) + measurement_frame = np.asarray(header["measurement frame"]) + if ( + space_directions.ndim != 2 + or space_directions.shape[0] < 4 + or space_directions.shape[1] < 3 + ): + raise ValueError( + f"{filename!r} is not a valid Slicer 4D .seq.nrrd: " + f"'space directions' has shape {space_directions.shape}, " + "expected a 2-D array of at least (4, 3)" + ) + if ( + measurement_frame.ndim != 2 + or measurement_frame.shape[0] < 3 + or measurement_frame.shape[1] < 3 + ): + raise ValueError( + f"{filename!r} is not a valid Slicer 4D .seq.nrrd: " + f"'measurement frame' has shape {measurement_frame.shape}, " + "expected a 2-D array of at least (3, 3)" + ) + + origin_3d = np.asarray(header["space origin"], dtype=float) + spacing_3d = np.array( + [abs(space_directions[x + 1][x]) for x in range(3)], + dtype=float, + ) + direction_3d = np.array([measurement_frame[x] for x in range(3)], dtype=float) + space = header.get("space", "") + if "right" in space: + direction_3d[0][0] *= -1 + if "anterior" in space: + direction_3d[1][1] *= -1 + if "inferior" in space: + direction_3d[2][2] *= -1 + + self._build_frames(arr_4d, origin_3d, spacing_3d, direction_3d) + + def _build_frames( + self, + arr_4d: np.ndarray, + origin_3d: np.ndarray, + spacing_3d: np.ndarray, + direction_3d: np.ndarray, + ) -> None: + """Materialize ``self.img_3d`` from a (T, Z, Y, X) array + geometry.""" + direction_matrix = itk.matrix_from_array(np.ascontiguousarray(direction_3d)) + self.img_3d = [] + for t in range(arr_4d.shape[0]): + # Copy so each 3D image owns its buffer independently. + arr_3d = np.ascontiguousarray(arr_4d[t]) + img3d = itk.image_from_array(arr_3d) + img3d.SetOrigin(origin_3d.tolist()) + img3d.SetSpacing(spacing_3d.tolist()) + img3d.SetDirection(direction_matrix) + self.img_3d.append(img3d) + + def _load_dicom_directory(self, dirpath: Path) -> None: + """Read a DICOM directory and build one 3D image per temporal phase. + + Files in ``dirpath`` are inspected with ``pydicom`` to identify valid + DICOM image slices, group them by temporal phase, and sort them along + the slice normal. The resulting ordered filename list for each phase + is handed to ``itk.imread``, which constructs the 3D image with proper + origin, spacing, and direction in LPS world space via its DICOM IO. + + Slices are grouped by a composite key built from the DICOM tags + listed in ``self.dicom_phase_keys`` (the default set covers + ``TemporalPositionIdentifier``, ``TriggerTime``, the cardiac trigger + delay / phase tags, ``FrameReferenceDateTime``, and ``ScanOptions``). + Any tag whose + value differs between slices will split them into separate phases; + missing tags fall back to the per-tag default. When none of the + configured tags differ across slices, all slices form a single 3D + volume. Non-DICOM files and files without the geometry tags are + skipped. + + Args: + dirpath: Directory holding a DICOM series (3D or 4D). + """ + entries: list[tuple[str, pydicom.Dataset]] = [] + for fp in sorted(dirpath.iterdir()): + if not fp.is_file(): + continue + try: + ds = pydicom.dcmread(str(fp), stop_before_pixels=True, force=False) + except (pydicom.errors.InvalidDicomError, OSError): + continue + if "ImageOrientationPatient" not in ds: + continue + if "ImagePositionPatient" not in ds: + continue + entries.append((str(fp), ds)) + + if not entries: + raise ValueError(f"No readable DICOM image slices in {dirpath}") + + self.log_info(f"Read {len(entries)} DICOM slice file(s) from {dirpath}") + + groups: dict[ + tuple[Union[float, str], ...], list[tuple[str, pydicom.Dataset]] + ] = defaultdict(list) + for fname, slice_ds in entries: + key_parts: list[Union[float, str]] = [] + for tag, default in self.dicom_phase_keys.items(): + if tag not in slice_ds: + key_parts.append(default) + elif isinstance(default, str): + key_parts.append(str(slice_ds[tag].value)) + else: + key_parts.append(float(slice_ds[tag].value)) + groups[tuple(key_parts)].append((fname, slice_ds)) + + sorted_keys = sorted(groups.keys()) + self.log_info(f"Grouped DICOM slices into {len(sorted_keys)} phase(s)") + + self.img_3d = [] + for key in sorted_keys: + group_entries = groups[key] + iop = np.asarray(group_entries[0][1].ImageOrientationPatient, dtype=float) + slice_normal = np.cross(iop[:3], iop[3:6]) + + def proj( + ds: pydicom.Dataset, + normal: np.ndarray = slice_normal, + ) -> float: + ipp = np.asarray(ds.ImagePositionPatient, dtype=float) + return float(np.dot(ipp, normal)) + + ordered = sorted(group_entries, key=lambda item: proj(item[1])) + filenames = [fname for fname, _ in ordered] + self.img_3d.append(itk.imread(filenames)) + + def get_3d_image(self, index: int) -> itk.Image: + """Return the 3D ITK image at the given time index.""" + return self.img_3d[index] + + def get_number_of_3d_images(self) -> int: + """Return the number of 3D images currently held.""" + return len(self.img_3d) + + def save_3d_images( + self, + directory: Union[str, Path], + basename: str, + suffix: str = "mha", + ) -> None: + """Write each held 3D image to ``{directory}/{basename}_{i:03d}.{suffix}``. + + Args: + directory: Output directory; created if it does not exist. + basename: Filename stem used for every saved volume. + suffix: File extension (default: ``mha``). + """ + dir_path = Path(directory) + dir_path.mkdir(parents=True, exist_ok=True) + for i in range(self.get_number_of_3d_images()): + itk.imwrite( + self.img_3d[i], + str(dir_path / f"{basename}_{i:03d}.{suffix}"), + compression=True, + ) diff --git a/src/physiomotion4d/convert_nrrd_4d_to_3d.py b/src/physiomotion4d/convert_nrrd_4d_to_3d.py deleted file mode 100644 index 9ce75ae..0000000 --- a/src/physiomotion4d/convert_nrrd_4d_to_3d.py +++ /dev/null @@ -1,78 +0,0 @@ -import logging -from pathlib import Path -from typing import Any, Optional, Union - -import itk -import nrrd -import numpy as np - - -from physiomotion4d.physiomotion4d_base import PhysioMotion4DBase - - -class ConvertNRRD4DTo3D(PhysioMotion4DBase): - def __init__(self, log_level: int | str = logging.INFO) -> None: - """Initialize the NRRD 4D to 3D converter. - - Args: - log_level: Logging level (default: logging.INFO) - """ - super().__init__(class_name=self.__class__.__name__, log_level=log_level) - self.nrrd_4d: Optional[tuple[Any, dict[str, Any]]] = None - self.img_3d: list[Any] = [] - - def load_nrrd_3d(self, filenames: list[str]) -> None: - self.img_3d = [] - for filename in filenames: - img_3d = itk.imread(filename) - self.img_3d.append(img_3d) - - def load_nrrd_4d(self, filename: str) -> None: - # The nrrd sequence is a 4D file which is not supported by itk, babbel, or other readers. - # We must use the pynrrd reader. - self.nrrd_4d = nrrd.read(filename) - header = self.nrrd_4d[1] - - # The pynrrd reader returns a list of tuples, where the first element is the image data and the second element is the header. - - # Extract the origin, spacing, and direction from the header - origin = np.array(header["space origin"]) - spacing = np.array( - [abs(header["space directions"][x + 1][x]) for x in range(3)] - ) - direction = np.array([header["measurement frame"][x] for x in range(3)]) - if "right" in header["space"]: - direction[0][0] = -1 * direction[0][0] - if "anterior" in header["space"]: - direction[1][1] = -1 * direction[1][1] - if "inferior" in header["space"]: - direction[2][2] = -1 * direction[2][2] - - self.img_3d = [] - for t in range(self.nrrd_4d[0].shape[0]): - img4d_arr = np.array(self.nrrd_4d[0]) - - # The pynrrd reader returns the image in the order (t,x,y,z) - # We need to convert it to (z,y,x) for the itk writer - img3d_arr = img4d_arr[t, :, :, :].transpose(2, 1, 0) - - self.img_3d.append(itk.image_from_array(img3d_arr)) - self.img_3d[-1].SetOrigin(origin) - self.img_3d[-1].SetSpacing(spacing) - self.img_3d[-1].SetDirection(direction) - - def get_3d_image(self, index: int) -> Any: - return self.img_3d[index] - - def get_number_of_3d_images(self) -> int: - return len(self.img_3d) - - def save_3d_images(self, directory: Union[str, Path], basename: str) -> None: - dir_path = Path(directory) - dir_path.mkdir(parents=True, exist_ok=True) - for i in range(self.get_number_of_3d_images()): - itk.imwrite( - self.img_3d[i], - str(dir_path / f"{basename}_{i:03d}.mha"), - compression=True, - ) diff --git a/src/physiomotion4d/test_tools.py b/src/physiomotion4d/test_tools.py index 2240eb4..0889a55 100644 --- a/src/physiomotion4d/test_tools.py +++ b/src/physiomotion4d/test_tools.py @@ -506,7 +506,7 @@ def save_screenshot_openusd( def save_screenshot_image_slice( self, - image: Any, # itk.Image, axes X Y Z in RAS world space + image: Any, # itk.Image, axes X Y Z in LPS world space filename: str, *, axis: int = 0, @@ -528,7 +528,7 @@ def save_screenshot_image_slice( Saves to the configured result artifact directory. Args: - image: 3-D ``itk.Image`` in RAS world space, axes X Y Z. + image: 3-D ``itk.Image`` in LPS world space, axes X Y Z. filename: Output PNG filename, relative to the result artifact dir. axis: Numpy axis along which to slice (0=axial, 1=coronal, 2=sagittal). slice_fraction: Fractional position along ``axis`` in [0, 1]. diff --git a/src/physiomotion4d/vtk_to_usd/CLAUDE.md b/src/physiomotion4d/vtk_to_usd/CLAUDE.md index ce78ed2..225a1d4 100644 --- a/src/physiomotion4d/vtk_to_usd/CLAUDE.md +++ b/src/physiomotion4d/vtk_to_usd/CLAUDE.md @@ -44,10 +44,14 @@ single-file facade. Otherwise, prefer `convert_vtk_to_usd.py`. ## Coordinate System -RAS-to-Y-up conversion: `USD(x, y, z) = RAS(x, z, -y) * 0.001`. +LPS-to-USD-Y-up conversion: `USD(x, y, z) = LPS(x, z, -y) * 0.001`. -This conversion happens inside `usd_utils.ras_to_usd()` and -`ras_points_to_usd()`. It must not be applied more than once. +PhysioMotion4D keeps images and surfaces in ITK's native LPS frame; the +resulting USD frame is right-handed Y-up with USD +X = patient Left, +USD +Y = patient Superior, USD +Z = patient Anterior. + +This conversion happens inside `usd_utils.lps_to_usd()` and +`lps_points_to_usd()`. It must not be applied more than once. ## Testing Policy diff --git a/src/physiomotion4d/vtk_to_usd/__init__.py b/src/physiomotion4d/vtk_to_usd/__init__.py index 82b42c3..fbc701d 100644 --- a/src/physiomotion4d/vtk_to_usd/__init__.py +++ b/src/physiomotion4d/vtk_to_usd/__init__.py @@ -9,7 +9,7 @@ - Data containers: MeshData, ConversionSettings, MaterialData, etc. - VTK file readers (.vtk, .vtp, .vtu) - USD primitive writers: UsdMeshConverter, MaterialManager -- Coordinate helpers (RAS to Y-up) and mesh splitting utilities +- Coordinate helpers (LPS to USD Y-up) and mesh splitting utilities """ from .converter import convert_vtk_file @@ -33,9 +33,9 @@ add_framing_camera, compute_mesh_extent, create_primvar, - ras_normals_to_usd, - ras_points_to_usd, - ras_to_usd, + lps_normals_to_usd, + lps_points_to_usd, + lps_to_usd, sanitize_primvar_name, triangulate_face, ) @@ -63,9 +63,9 @@ "MaterialManager", "UsdMeshConverter", # Utilities - "ras_to_usd", - "ras_points_to_usd", - "ras_normals_to_usd", + "lps_to_usd", + "lps_points_to_usd", + "lps_normals_to_usd", "create_primvar", "sanitize_primvar_name", "triangulate_face", diff --git a/src/physiomotion4d/vtk_to_usd/usd_mesh_converter.py b/src/physiomotion4d/vtk_to_usd/usd_mesh_converter.py index 1f99916..f0c5d13 100644 --- a/src/physiomotion4d/vtk_to_usd/usd_mesh_converter.py +++ b/src/physiomotion4d/vtk_to_usd/usd_mesh_converter.py @@ -14,8 +14,8 @@ from .usd_utils import ( compute_mesh_extent, create_primvar, - ras_normals_to_usd, - ras_points_to_usd, + lps_normals_to_usd, + lps_points_to_usd, triangulate_face, ) @@ -78,7 +78,7 @@ def create_mesh( mesh = UsdGeom.Mesh.Define(self.stage, mesh_path) # Convert points to USD coordinates - usd_points = ras_points_to_usd(mesh_data.points) + usd_points = lps_points_to_usd(mesh_data.points) # Handle triangulation if requested face_counts = mesh_data.face_vertex_counts @@ -131,7 +131,7 @@ def create_mesh( # Handle normals if mesh_data.normals is not None: logger.debug("Adding normals to mesh") - usd_normals = ras_normals_to_usd(mesh_data.normals) + usd_normals = lps_normals_to_usd(mesh_data.normals) normals_attr = mesh.CreateNormalsAttr() normals_attr.SetMetadata("interpolation", UsdGeom.Tokens.vertex) if time_code is not None: @@ -371,7 +371,7 @@ def create_time_varying_mesh( mesh_data_sequence[1:], time_codes[1:], strict=False ): # Update points - usd_points = ras_points_to_usd(mesh_data.points) + usd_points = lps_points_to_usd(mesh_data.points) mesh.GetPointsAttr().Set(usd_points, time_code) # Update extent @@ -380,7 +380,7 @@ def create_time_varying_mesh( # Update normals if present if mesh_data.normals is not None: - usd_normals = ras_normals_to_usd(mesh_data.normals) + usd_normals = lps_normals_to_usd(mesh_data.normals) mesh.GetNormalsAttr().Set(usd_normals, time_code) # Update colors if present diff --git a/src/physiomotion4d/vtk_to_usd/usd_utils.py b/src/physiomotion4d/vtk_to_usd/usd_utils.py index 68f23c3..e79328f 100644 --- a/src/physiomotion4d/vtk_to_usd/usd_utils.py +++ b/src/physiomotion4d/vtk_to_usd/usd_utils.py @@ -17,23 +17,26 @@ logger = logging.getLogger(__name__) -def ras_to_usd(point: NDArray | tuple | list) -> Gf.Vec3f: - """Convert RAS (Right-Anterior-Superior) coordinates to USD's right-handed Y-up system. +def lps_to_usd(point: NDArray | tuple | list) -> Gf.Vec3f: + """Convert LPS (Left-Posterior-Superior) coordinates to USD's right-handed Y-up frame. - VTK/Medical imaging typically uses RAS coordinate system: - - R (Right): X-axis points to patient's right - - A (Anterior): Y-axis points to patient's front - - S (Superior): Z-axis points to patient's head + PhysioMotion4D keeps images and surfaces in ITK's native LPS world space. + ``itk.imread`` normalizes every supported input (DICOM, NIfTI, MHA, NRRD) + to LPS, and ``itk.vtk_image_from_image`` preserves that frame when + handing data to PyVista/VTK, so meshes extracted via ``contour_labels`` + or ``threshold`` arrive here in LPS. - USD uses right-handed Y-up: - - X: right - - Y: up - - Z: back (toward camera) + The USD frame produced by this conversion is right-handed Y-up with: - Conversion: USD(x, y, z) = RAS(x, z, -y) * 0.001 (mm → m) + - USD +X = patient Left (LPS +x) + - USD +Y = patient Superior (LPS +z) — "up" in Omniverse + - USD +Z = patient Anterior (−LPS +y) — toward the viewer when the + camera looks at the patient from the front + + Conversion: ``USD(x, y, z) = LPS(x, z, −y) * 0.001`` (mm → m) Args: - point: Point in RAS coordinates [x, y, z] in millimeters + point: Point in LPS coordinates [x, y, z] in millimeters Returns: Gf.Vec3f: Point in USD coordinates in meters @@ -52,10 +55,11 @@ def ras_to_usd(point: NDArray | tuple | list) -> Gf.Vec3f: ) -def ras_points_to_usd(points: NDArray) -> Vt.Vec3fArray: - """Convert array of RAS points (mm) to USD coordinates (m). +def lps_points_to_usd(points: NDArray) -> Vt.Vec3fArray: + """Convert array of LPS points (mm) to USD Y-up coordinates (m). - Applies axis swap RAS → Y-up and scales millimeters to meters (* 0.001). + Applies the LPS → Y-up axis swap defined in :func:`lps_to_usd` and scales + millimeters to meters (* 0.001). Args: points: Array of points with shape (N, 3) in millimeters @@ -66,7 +70,7 @@ def ras_points_to_usd(points: NDArray) -> Vt.Vec3fArray: if points.shape[1] != 3: raise ValueError(f"Points must have shape (N, 3), got {points.shape}") - # Vectorized: USD(x, y, z) = RAS(x, z, -y) * 0.001 (mm → m) + # Vectorized: USD(x, y, z) = LPS(x, z, -y) * 0.001 (mm → m) usd_points = np.empty(points.shape, dtype=np.float32) usd_points[:, 0] = points[:, 0] * 0.001 usd_points[:, 1] = points[:, 2] * 0.001 @@ -75,11 +79,11 @@ def ras_points_to_usd(points: NDArray) -> Vt.Vec3fArray: return Vt.Vec3fArray.FromNumpy(usd_points) -def ras_normals_to_usd(normals: NDArray) -> Vt.Vec3fArray: - """Convert array of RAS normals to USD Y-up coordinates. +def lps_normals_to_usd(normals: NDArray) -> Vt.Vec3fArray: + """Convert array of LPS normals to USD Y-up coordinates. - Applies only the axis swap — normals are unit direction vectors and must - not be scaled by the mm→m factor. + Applies only the axis swap from :func:`lps_to_usd` — normals are unit + direction vectors and must not be scaled by the mm→m factor. Args: normals: Array of normals with shape (N, 3) diff --git a/src/physiomotion4d/workflow_convert_heart_gated_ct_to_usd.py b/src/physiomotion4d/workflow_convert_image_to_usd.py similarity index 91% rename from src/physiomotion4d/workflow_convert_heart_gated_ct_to_usd.py rename to src/physiomotion4d/workflow_convert_image_to_usd.py index d424775..eb3f853 100644 --- a/src/physiomotion4d/workflow_convert_heart_gated_ct_to_usd.py +++ b/src/physiomotion4d/workflow_convert_image_to_usd.py @@ -1,8 +1,10 @@ """ -Heart-gated CT processor implementing the complete 4D CT to USD workflow. +Image-to-USD workflow implementing the complete 3D/4D image to USD pipeline. -This module implements the complete pipeline for processing 4D cardiac CT images -as demonstrated in the Heart-GatedCT experiment notebooks. +This module implements the complete pipeline for processing 3D or 4D medical +images (e.g. cardiac and respiratory gated CT studies) into dynamic USD +models. 4D image arrays follow the (X, Y, Z, T) axis convention used +throughout PhysioMotion4D. """ import logging @@ -15,7 +17,7 @@ from physiomotion4d import ConvertVTKToUSD from physiomotion4d.contour_tools import ContourTools -from physiomotion4d.convert_nrrd_4d_to_3d import ConvertNRRD4DTo3D +from physiomotion4d.convert_image_4d_to_3d import ConvertImage4DTo3D from physiomotion4d.physiomotion4d_base import PhysioMotion4DBase from physiomotion4d.register_images_ants import RegisterImagesANTs from physiomotion4d.register_images_base import RegisterImagesBase @@ -25,9 +27,9 @@ from physiomotion4d.usd_anatomy_tools import USDAnatomyTools -class WorkflowConvertHeartGatedCTToUSD(PhysioMotion4DBase): +class WorkflowConvertImageToUSD(PhysioMotion4DBase): """ - Complete workflow for Heart-gated CT images to dynamic USD models. + Complete workflow for converting 4D CT images to dynamic USD models. This class implements the full workflow from 4D CT images to painted USD files suitable for visualization in NVIDIA Omniverse. @@ -48,11 +50,16 @@ def __init__( save_labelmaps: bool = True, ): """ - Initialize the Heart-gated CT to USD workflow. + Initialize the image-to-USD workflow. Args: - input_filenames (List): List of paths to the 3D NRRD files containing cardiac CT data. - If there is only one file, it will be used as the 4D NRRD file. + input_filenames (List): One or more image sources for the time + series. A single entry may be a 4D image file (NRRD/NIfTI/MHA + in (X, Y, Z, T) order), a 3D image file, or a directory holding + a DICOM series (3D or 4D). Multiple entries are treated as a + pre-split list of 3D images, one per time point. All entries + are routed through :class:`ConvertImage4DTo3D` so any + ITK-readable format is accepted. contrast_enhanced (bool): Whether the study uses contrast enhancement output_directory (str): Directory path where output files will be stored project_name (str): Project name for USD file organization @@ -91,7 +98,7 @@ def __init__( os.makedirs(output_directory, exist_ok=True) # Initialize processing components - self.converter = ConvertNRRD4DTo3D(log_level=log_level) + self.converter = ConvertImage4DTo3D(log_level=log_level) self.segmenter = SegmentChestTotalSegmentator(log_level=log_level) self.segmenter.contrast_threshold = 500 @@ -181,7 +188,7 @@ def process(self) -> str: Returns: str: Path to the final dynamic anatomy USD file """ - self.log_section("Heart-gated CT Processing Pipeline") + self.log_section("Image-to-USD Processing Pipeline") # Load and convert data self._load_time_series() @@ -205,23 +212,27 @@ def _load_time_series(self) -> None: """Load and convert 4D data to time series images.""" self.log_info("Loading time series data...") + self._time_series_images = [] + self._num_time_points = 0 + if len(self.input_filenames) == 1: - self.converter.load_nrrd_4d(self.input_filenames[0]) + self.converter.load_image_4d(self.input_filenames[0]) self.converter.save_3d_images( self.output_directory, os.path.basename(self.input_filenames[0]), ) + self._num_time_points = self.converter.get_number_of_3d_images() + for i in range(self._num_time_points): + self._time_series_images.append(self.converter.get_3d_image(i)) else: - self.log_info("Loading %d 3D NRRD files", len(self.input_filenames)) - self.converter.load_nrrd_3d(self.input_filenames) + self.log_info("Loading %d 3D image files", len(self.input_filenames)) + self._time_series_images = [ + itk.imread(path) for path in self.input_filenames + ] + self._num_time_points = len(self._time_series_images) - self._num_time_points = self.converter.get_number_of_3d_images() if self._num_time_points <= 0: raise ValueError("No time-series images were produced from input data") - - # Load all time series images into memory - for i in range(self._num_time_points): - self._time_series_images.append(self.converter.get_3d_image(i)) if not self._time_series_images: raise ValueError("No time-series images were loaded from input data") diff --git a/statistics.md b/statistics.md index 13b6eb0..e1c3412 100644 --- a/statistics.md +++ b/statistics.md @@ -53,7 +53,7 @@ percent-cell markers so the same file can be executed end-to-end with | `convert_vtk_to_usd.py` | ~800 | High-level VTK -> USD converter | | `vtk_to_usd/` subpackage | 2,657 | Low-level VTK -> USD building blocks (9 files) | | `cli/` subpackage | 1,788 | CLI entry-point scripts (8 commands) | -| `workflow_convert_heart_gated_ct_to_usd.py` | ~540 | Heart CT to USD workflow | +| `workflow_convert_image_to_usd.py` | ~540 | 4D image to USD workflow | --- diff --git a/tests/GITHUB_WORKFLOWS.md b/tests/GITHUB_WORKFLOWS.md index c2fa0ab..3f3ba76 100644 --- a/tests/GITHUB_WORKFLOWS.md +++ b/tests/GITHUB_WORKFLOWS.md @@ -79,9 +79,9 @@ Tests that require downloading external data, executed in sequence with caching. 2. **Data Conversion Tests** ```bash - pytest tests/test_convert_nrrd_4d_to_3d.py -v --cov=src/physiomotion4d --cov-append --cov-report=xml + pytest tests/test_convert_image_4d_to_3d.py -v --cov=src/physiomotion4d --cov-append --cov-report=xml ``` - - Converts 4D NRRD to 3D time series + - Converts 4D image to 3D time series via ITK - Creates slice files - Depends on downloaded data diff --git a/tests/README.md b/tests/README.md index bcb1371..4bcc7b5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -15,7 +15,7 @@ This directory contains comprehensive test suites for the PhysioMotion4D package ### Data Pipeline Tests - **`test_download_heart_data.py`** - Automatic data download with fallback logic -- **`test_convert_nrrd_4d_to_3d.py`** - 4D NRRD to 3D time series conversion +- **`test_convert_image_4d_to_3d.py`** - 4D image to 3D time series conversion (ITK) ### Segmentation Tests (GPU Required) - **`test_segment_chest_total_segmentator.py`** - TotalSegmentator chest CT segmentation @@ -138,7 +138,7 @@ Tests are organized hierarchically - some tests depend on outputs from earlier t ``` test_download_heart_data ↓ -test_convert_nrrd_4d_to_3d +test_convert_image_4d_to_3d ↓ ↓ ↓ ├─→ test_register_images_ants ──→ test_transform_tools ↓ ├─→ test_register_images_icon diff --git a/tests/TESTING_GUIDE.md b/tests/TESTING_GUIDE.md index 3807935..3dfed24 100644 --- a/tests/TESTING_GUIDE.md +++ b/tests/TESTING_GUIDE.md @@ -43,7 +43,7 @@ pytest tests/ -m "not slow" -v ```bash # Data preparation tests (fast) pytest tests/test_download_heart_data.py -v -pytest tests/test_convert_nrrd_4d_to_3d.py -v +pytest tests/test_convert_image_4d_to_3d.py -v # Registration tests (slow, ~5-10 min each) pytest tests/test_register_images_ants.py -v @@ -74,7 +74,7 @@ Tests are organized hierarchically with fixtures providing reusable data: ``` test_download_heart_data ↓ -test_convert_nrrd_4d_to_3d +test_convert_image_4d_to_3d ↓ ↓ ↓ ├─→ test_register_images_ants ──→ test_transform_tools ↓ ├─→ test_register_images_icon diff --git a/tests/conftest.py b/tests/conftest.py index 4449bad..f95d05f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -15,7 +15,7 @@ import pytest from physiomotion4d.contour_tools import ContourTools -from physiomotion4d.convert_nrrd_4d_to_3d import ConvertNRRD4DTo3D +from physiomotion4d.convert_image_4d_to_3d import ConvertImage4DTo3D from physiomotion4d.data_download_tools import DataDownloadTools from physiomotion4d.register_images_ants import RegisterImagesANTs from physiomotion4d.register_images_greedy import RegisterImagesGreedy @@ -357,9 +357,9 @@ def test_images( slice_000 = data_dir / "slice_000.mha" slice_007 = data_dir / "slice_007.mha" if not slice_000.exists() or not slice_007.exists(): - print("\nConverting 4D NRRD to 3D time series...") - conv = ConvertNRRD4DTo3D() - conv.load_nrrd_4d(str(download_test_data)) + print("\nConverting 4D image to 3D time series...") + conv = ConvertImage4DTo3D() + conv.load_image_4d(str(download_test_data)) conv.save_3d_images(data_dir, "slice") else: print("\n3D slice files already exist") diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 057002a..b130298 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -10,7 +10,8 @@ CLI_MODULES = [ "physiomotion4d.cli.convert_ct_to_vtk", - "physiomotion4d.cli.convert_heart_gated_ct_to_usd", + "physiomotion4d.cli.convert_image_4d_to_3d", + "physiomotion4d.cli.convert_image_to_usd", "physiomotion4d.cli.convert_vtk_to_usd", "physiomotion4d.cli.create_statistical_model", "physiomotion4d.cli.fit_statistical_model_to_patient", diff --git a/tests/test_convert_nrrd_4d_to_3d.py b/tests/test_convert_image_4d_to_3d.py similarity index 62% rename from tests/test_convert_nrrd_4d_to_3d.py rename to tests/test_convert_image_4d_to_3d.py index d87ed14..b11e643 100644 --- a/tests/test_convert_nrrd_4d_to_3d.py +++ b/tests/test_convert_image_4d_to_3d.py @@ -1,6 +1,6 @@ #!/usr/bin/env python """ -Test for converting 4D NRRD to 3D time series. +Test for converting a 4D image to a 3D time series using ITK readers. This test depends on test_download_heart_data and replicates the functionality from cell 3 of the notebook Heart-GatedCT_To_USD/0-download_and_convert_4d_to_3d.ipynb. @@ -10,35 +10,32 @@ import pytest -from physiomotion4d.convert_nrrd_4d_to_3d import ConvertNRRD4DTo3D +from physiomotion4d.convert_image_4d_to_3d import ConvertImage4DTo3D @pytest.mark.requires_data -class TestConvertNRRD4DTo3D: - """Test suite for converting 4D NRRD to 3D time series.""" +class TestConvertImage4DTo3D: + """Test suite for converting a 4D image to a 3D time series.""" def test_convert_4d_to_3d( self, download_test_data: Path, test_directories: dict[str, Path], ) -> None: - """Test conversion of 4D NRRD to 3D time series.""" - output_dir = test_directories["output"] / "convert_nrrd_4d_to_3d" + """Test conversion of 4D image to 3D time series.""" + output_dir = test_directories["output"] / "convert_image_4d_to_3d" output_dir.mkdir(parents=True, exist_ok=True) input_4d_file = download_test_data - # Convert 4D to 3D time series - print("\nConverting 4D NRRD to 3D time series...") - conv = ConvertNRRD4DTo3D() - conv.load_nrrd_4d(str(input_4d_file)) + print("\nConverting 4D image to 3D time series...") + conv = ConvertImage4DTo3D() + conv.load_image_4d(str(input_4d_file)) conv.save_3d_images(output_dir, "slice") - # Verify that slice files were created slice_007 = output_dir / "slice_007.mha" assert slice_007.exists(), f"Expected slice file not created: {slice_007}" - # Count how many slice files were created slice_files = list(output_dir.glob("slice_*.mha")) print(f"Created {len(slice_files)} slice files") assert len(slice_files) > 0, "No slice files were created" @@ -49,54 +46,52 @@ def test_slice_files_created( test_directories: dict[str, Path], ) -> None: """Test that all expected slice files are present after conversion.""" - output_dir = test_directories["output"] / "convert_nrrd_4d_to_3d" + output_dir = test_directories["output"] / "convert_image_4d_to_3d" output_dir.mkdir(parents=True, exist_ok=True) - # Check that slice files exist + conv = ConvertImage4DTo3D() + conv.load_image_4d(str(download_test_data)) + conv.save_3d_images(output_dir, "slice") + slice_files = list(output_dir.glob("slice_*.mha")) assert len(slice_files) > 10, ( f"Expected more than 10 slice files, found {len(slice_files)}" ) - # Verify specific slice file exists (mid-stroke) slice_007 = output_dir / "slice_007.mha" assert slice_007.exists(), "Expected slice_007.mha not found" print(f"\nFound {len(slice_files)} slice files") - def test_load_nrrd_4d(self, download_test_data: Path) -> None: - """Test loading 4D NRRD file.""" + def test_load_image_4d(self, download_test_data: Path) -> None: + """Test loading a 4D image.""" input_4d_file = download_test_data - conv = ConvertNRRD4DTo3D() - conv.load_nrrd_4d(str(input_4d_file)) + conv = ConvertImage4DTo3D() + conv.load_image_4d(str(input_4d_file)) - # Verify that data was loaded - assert conv.nrrd_4d is not None, "4D NRRD data not loaded" assert conv.get_number_of_3d_images() > 0, "No time points found in 4D image" - print(f"\nLoaded 4D NRRD with {conv.get_number_of_3d_images()} time points") + print(f"\nLoaded 4D image with {conv.get_number_of_3d_images()} time points") def test_save_3d_images( self, download_test_data: Path, test_directories: dict[str, Path], ) -> None: - """Test saving 3D images from 4D NRRD.""" - output_dir = test_directories["output"] / "convert_nrrd_4d_to_3d" + """Test saving 3D images from a 4D source.""" + output_dir = test_directories["output"] / "convert_image_4d_to_3d" output_dir.mkdir(parents=True, exist_ok=True) input_4d_file = download_test_data - conv = ConvertNRRD4DTo3D() - conv.load_nrrd_4d(str(input_4d_file)) + conv = ConvertImage4DTo3D() + conv.load_image_4d(str(input_4d_file)) num_time_points = conv.get_number_of_3d_images() - # Save to a test subdirectory conv.save_3d_images(output_dir, "test_slice") - # Verify files were created test_slice_files = list(output_dir.glob("test_slice_*.mha")) assert len(test_slice_files) > 0, "No test slice files were created" assert len(test_slice_files) == num_time_points, ( @@ -105,7 +100,6 @@ def test_save_3d_images( print(f"\nSaved {len(test_slice_files)} 3D images") - # Clean up test files for test_file in test_slice_files: test_file.unlink() diff --git a/tests/test_register_images_ants.py b/tests/test_register_images_ants.py index 5f95755..67084e9 100644 --- a/tests/test_register_images_ants.py +++ b/tests/test_register_images_ants.py @@ -2,7 +2,7 @@ """ Test for ANTs-based image registration. -This test depends on test_convert_nrrd_4d_to_3d and uses the converted +This test depends on test_convert_image_4d_to_3d and uses the converted 3D CT images to test ANTs registration functionality. """ diff --git a/tests/test_register_images_icon.py b/tests/test_register_images_icon.py index aeb63e2..1be05b6 100644 --- a/tests/test_register_images_icon.py +++ b/tests/test_register_images_icon.py @@ -2,7 +2,7 @@ """ Test for ICON-based image registration. -This test depends on test_convert_nrrd_4d_to_3d and uses the converted +This test depends on test_convert_image_4d_to_3d and uses the converted 3D CT images to test ICON registration functionality. Note: ICON requires CUDA-enabled GPU. """ diff --git a/tests/test_segment_chest_total_segmentator.py b/tests/test_segment_chest_total_segmentator.py index 765c2fc..15d3242 100644 --- a/tests/test_segment_chest_total_segmentator.py +++ b/tests/test_segment_chest_total_segmentator.py @@ -2,7 +2,7 @@ """ Test for chest CT segmentation using TotalSegmentator. -This test depends on test_convert_nrrd_4d_to_3d and tests segmentation +This test depends on test_convert_image_4d_to_3d and tests segmentation functionality on two time points from the converted 3D data. """ diff --git a/tutorials/README.md b/tutorials/README.md index 56c950f..bd24303 100644 --- a/tutorials/README.md +++ b/tutorials/README.md @@ -13,7 +13,7 @@ dataset licensing, and expected directory layout. | # | Script | Primary API | Dataset | |---|--------|-------------|---------| -| 1 | [tutorial_01_heart_gated_ct_to_usd.py](tutorial_01_heart_gated_ct_to_usd.py) | `WorkflowConvertHeartGatedCTToUSD` | Slicer-Heart-CT (prepare first) | +| 1 | [tutorial_01_heart_gated_ct_to_usd.py](tutorial_01_heart_gated_ct_to_usd.py) | `WorkflowConvertImageToUSD` | Slicer-Heart-CT (prepare first) | | 2 | [tutorial_02_ct_to_vtk.py](tutorial_02_ct_to_vtk.py) | `WorkflowConvertCTToVTK` | Slicer-Heart-CT (prepare first) | | 3 | [tutorial_03_create_statistical_model.py](tutorial_03_create_statistical_model.py) | `WorkflowCreateStatisticalModel` | KCL-Heart-Model (manual) | | 4 | [tutorial_04_fit_statistical_model_to_patient.py](tutorial_04_fit_statistical_model_to_patient.py) | `WorkflowFitStatisticalModelToPatient` | KCL-Heart-Model plus Tutorial 3 output | diff --git a/tutorials/tutorial_01_heart_gated_ct_to_usd.py b/tutorials/tutorial_01_heart_gated_ct_to_usd.py index ebabe03..b732789 100644 --- a/tutorials/tutorial_01_heart_gated_ct_to_usd.py +++ b/tutorials/tutorial_01_heart_gated_ct_to_usd.py @@ -29,7 +29,7 @@ Strengths --------- -- Single call (``WorkflowConvertHeartGatedCTToUSD.process()``) runs the full pipeline. +- Single call (``WorkflowConvertImageToUSD.process()``) runs the full pipeline. - Supports both GPU-accelerated ICON registration and CPU-capable ANTs registration. - Automatically detects contrast enhancement and adjusts segmentation thresholds. - Output is Omniverse-ready with anatomical materials (USDAnatomyTools). @@ -44,7 +44,7 @@ Classes Used ------------ -- WorkflowConvertHeartGatedCTToUSD (workflow_convert_heart_gated_ct_to_usd.py): +- WorkflowConvertImageToUSD (workflow_convert_image_to_usd.py): Orchestrates the full pipeline: 4D NRRD -> segmentation -> registration -> contour extraction -> USD export. - SegmentChestTotalSegmentator (segment_chest_total_segmentator.py): @@ -75,11 +75,11 @@ import itk from physiomotion4d.test_tools import TestTools -from physiomotion4d.workflow_convert_heart_gated_ct_to_usd import ( - WorkflowConvertHeartGatedCTToUSD, +from physiomotion4d.workflow_convert_image_to_usd import ( + WorkflowConvertImageToUSD, ) -# nnUNetv2 (used by TotalSegmentator inside WorkflowConvertHeartGatedCTToUSD) +# nnUNetv2 (used by TotalSegmentator inside WorkflowConvertImageToUSD) # 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 workflow.process() again and @@ -128,7 +128,7 @@ # %% # Workflow initialization - workflow = WorkflowConvertHeartGatedCTToUSD( + workflow = WorkflowConvertImageToUSD( input_filenames=input_filenames, contrast_enhanced=True, output_directory=str(output_dir),