diff --git a/docs/API_MAP.md b/docs/API_MAP.md index d434243..dec4930 100644 --- a/docs/API_MAP.md +++ b/docs/API_MAP.md @@ -779,8 +779,8 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## src/physiomotion4d/workflow_convert_image_to_usd.py - **class WorkflowConvertImageToUSD** (line 42): 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, segmentation_method='ChestTotalSegmentator', registration_method='ICON', log_level=logging.INFO, save_registered_images=True, save_registration_transforms=True, save_labelmaps=True)` (line 61): Initialize the image-to-USD workflow. - - `def process(self)` (line 235): Execute the complete workflow from 4D CT to dynamic USD models. + - `def __init__(self, input_filenames, contrast_enhanced, output_directory, project_name, reference_image_filename=None, number_of_registration_iterations=1, segmentation_method='ChestTotalSegmentator', registration_method='ICON', times_per_second=24.0, log_level=logging.INFO, save_registered_images=True, save_registration_transforms=True, save_labelmaps=True)` (line 61): Initialize the image-to-USD workflow. + - `def process(self)` (line 239): Execute the complete workflow from 4D CT to dynamic USD models. ## src/physiomotion4d/workflow_convert_image_to_vtk.py @@ -874,7 +874,9 @@ _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 25): Each CLI module exits successfully for --help. +- `def test_cli_help(module_name, monkeypatch, capsys)` (line 27): Each CLI module exits successfully for --help. +- `def test_convert_image_to_usd_help_includes_fps(monkeypatch, capsys)` (line 44): Image-to-USD CLI exposes playback FPS for animated USD output. +- `def test_convert_image_to_usd_cli_passes_fps(monkeypatch, tmp_path)` (line 60): Image-to-USD CLI forwards --fps as times_per_second. ## tests/test_contour_tools.py @@ -1168,6 +1170,10 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def test_normals_remain_unit_length(self, tmp_path)` (line 384): Normal vectors must not be scaled. - `def test_stage_meters_per_unit(self, tmp_path)` (line 404): Stage metersPerUnit metadata must be 1.0. +## tests/test_workflow_convert_image_to_usd.py + +- `def test_create_usd_files_passes_times_per_second(monkeypatch, tmp_path)` (line 14): Workflow forwards FPS to VTK-to-USD for shape (X, Y, Z, T) outputs. + ## tests/test_workflow_fit_statistical_model_to_patient.py - `def test_auto_generate_mask_accumulates_multilabel_models(monkeypatch)` (line 19): Multi-model masks accumulate label IDs instead of overwriting prior labels. diff --git a/docs/cli_scripts/byod_tutorials.rst b/docs/cli_scripts/byod_tutorials.rst index ec43132..87b560b 100644 --- a/docs/cli_scripts/byod_tutorials.rst +++ b/docs/cli_scripts/byod_tutorials.rst @@ -106,10 +106,10 @@ inside ``--output-dir``. ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Pass a 4D DICOM directory, a single 4D image file, or an explicit list of 3D -image files to produce an animated USD scene. The image-to-USD CLI does not -provide an ``--fps`` option. Use ``--reference-image`` only when you need to -provide a separate fixed image for registration; otherwise the workflow selects -its default reference frame internally. +image files to produce an animated USD scene. Use ``--fps`` when you need to +set the animated USD playback rate. Use ``--reference-image`` only when you +need to provide a separate fixed image for registration; otherwise the workflow +selects its default reference frame internally. **CLI:** @@ -128,6 +128,7 @@ its default reference frame internally. physiomotion4d-convert-image-to-usd \ phase_000.mha phase_001.mha phase_002.mha \ --output-dir ./results \ + --fps 30 \ --project-name heart_animated **Python API:** @@ -141,6 +142,7 @@ its default reference frame internally. contrast_enhanced=False, output_directory="./results", project_name="heart_animated", + times_per_second=30.0, ) workflow.process() diff --git a/src/physiomotion4d/cli/convert_image_to_usd.py b/src/physiomotion4d/cli/convert_image_to_usd.py index 3984682..61021da 100644 --- a/src/physiomotion4d/cli/convert_image_to_usd.py +++ b/src/physiomotion4d/cli/convert_image_to_usd.py @@ -32,6 +32,9 @@ def main() -> int: # Use the cardiac-only Simpleware segmentation backend %(prog)s input.nrrd --segmentation-method HeartSimpleware + + # Set animated USD playback to 30 frames per second + %(prog)s input.nrrd --fps 30 """, ) @@ -88,6 +91,13 @@ def main() -> int: default="ICON", help="Registration method to use: ANTS or ICON (default: ICON)", ) + parser.add_argument( + "--fps", + type=float, + default=24.0, + dest="times_per_second", + help="Frames per second for animated USD time series (default: 24)", + ) args = parser.parse_args() @@ -111,6 +121,7 @@ def main() -> int: number_of_registration_iterations=args.registration_iterations, segmentation_method=args.segmentation_method, registration_method=args.registration_method, + times_per_second=args.times_per_second, ) except Exception as e: print(f"Error initializing workflow: {e}") diff --git a/src/physiomotion4d/workflow_convert_image_to_usd.py b/src/physiomotion4d/workflow_convert_image_to_usd.py index 443972d..d4f828d 100644 --- a/src/physiomotion4d/workflow_convert_image_to_usd.py +++ b/src/physiomotion4d/workflow_convert_image_to_usd.py @@ -68,6 +68,7 @@ def __init__( number_of_registration_iterations: Optional[int] = 1, segmentation_method: str = "ChestTotalSegmentator", registration_method: str = "ICON", + times_per_second: float = 24.0, log_level: int | str = logging.INFO, save_registered_images: bool = True, save_registration_transforms: bool = True, @@ -95,6 +96,8 @@ def __init__( pulmonary/great-vessel branches trimmed to the cardiac region). registration_method (str): Registration method to use: ``'ANTS'`` or ``'ICON'`` (default: ``'ICON'``). + times_per_second: Frames per second for animated USD time series. + Defaults to 24.0, matching the underlying VTK-to-USD converter. log_level: Logging level (default: logging.INFO) save_registered_images: Write registered image intermediates to output_directory when True @@ -114,6 +117,7 @@ def __init__( self.save_registered_images = save_registered_images self.save_registration_transforms = save_registration_transforms self.save_labelmaps = save_labelmaps + self.times_per_second = times_per_second # Validate segmentation method if segmentation_method not in SEGMENTATION_METHODS: @@ -578,6 +582,7 @@ def _create_usd_files(self) -> None: self._transformed_contours[anatomy_type], self.segmenter.taxonomy.all_labels(), segmenter=self.segmenter, + times_per_second=self.times_per_second, log_level=self.log_level, ) usd_file = os.path.join( diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 5b49fd5..1744fba 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -3,7 +3,9 @@ from __future__ import annotations import importlib +from pathlib import Path import sys +from typing import Any import pytest @@ -37,3 +39,60 @@ def test_cli_help( captured = capsys.readouterr() assert exc_info.value.code == 0 assert "usage:" in captured.out.lower() + + +def test_convert_image_to_usd_help_includes_fps( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + """Image-to-USD CLI exposes playback FPS for animated USD output.""" + module = importlib.import_module("physiomotion4d.cli.convert_image_to_usd") + monkeypatch.setattr(sys, "argv", ["convert_image_to_usd", "--help"]) + + with pytest.raises(SystemExit) as exc_info: + module.main() + + captured = capsys.readouterr() + assert exc_info.value.code == 0 + assert "--fps" in captured.out + + +def test_convert_image_to_usd_cli_passes_fps( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + """Image-to-USD CLI forwards --fps as times_per_second.""" + import physiomotion4d + + module = importlib.import_module("physiomotion4d.cli.convert_image_to_usd") + input_file = tmp_path / "input.mha" + input_file.write_text("placeholder") + captured_kwargs: dict[str, Any] = {} + + class FakeWorkflowConvertImageToUSD: + def __init__(self, **kwargs: Any) -> None: + captured_kwargs.update(kwargs) + + def process(self) -> str: + return "output.usd" + + monkeypatch.setattr( + physiomotion4d, + "WorkflowConvertImageToUSD", + FakeWorkflowConvertImageToUSD, + ) + monkeypatch.setattr( + sys, + "argv", + [ + "convert_image_to_usd", + str(input_file), + "--output-dir", + str(tmp_path), + "--fps", + "30", + ], + ) + + assert module.main() == 0 + assert captured_kwargs["times_per_second"] == 30.0 diff --git a/tests/test_workflow_convert_image_to_usd.py b/tests/test_workflow_convert_image_to_usd.py new file mode 100644 index 0000000..b056478 --- /dev/null +++ b/tests/test_workflow_convert_image_to_usd.py @@ -0,0 +1,75 @@ +"""Tests for the image-to-USD workflow.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from physiomotion4d.physiomotion4d_base import PhysioMotion4DBase +from physiomotion4d.workflow_convert_image_to_usd import WorkflowConvertImageToUSD +import physiomotion4d.workflow_convert_image_to_usd as workflow_module + + +def test_create_usd_files_passes_times_per_second( + monkeypatch: Any, + tmp_path: Path, +) -> None: + """Workflow forwards FPS to VTK-to-USD for shape (X, Y, Z, T) outputs.""" + times_per_second_values: list[float] = [] + + class FakeStage: + def Export(self, _output_filename: str) -> None: + return None + + class FakeConvertVTKToUSD: + def __init__( + self, + _project_name: str, + _input_polydata: list[Any], + _mask_ids: dict[int, str], + *, + times_per_second: float, + **_kwargs: Any, + ) -> None: + times_per_second_values.append(times_per_second) + + def convert(self, _usd_file: str) -> FakeStage: + return FakeStage() + + class FakeUSDAnatomyTools: + def __init__(self, _stage: FakeStage) -> None: + return None + + def enhance_meshes(self, _segmenter: Any) -> None: + return None + + class FakeTaxonomy: + def all_labels(self) -> dict[int, str]: + return {1: "heart"} + + class FakeSegmenter: + taxonomy = FakeTaxonomy() + + monkeypatch.setattr(workflow_module, "ConvertVTKToUSD", FakeConvertVTKToUSD) + monkeypatch.setattr(workflow_module, "USDAnatomyTools", FakeUSDAnatomyTools) + + workflow = WorkflowConvertImageToUSD.__new__(WorkflowConvertImageToUSD) + PhysioMotion4DBase.__init__( + workflow, + class_name=WorkflowConvertImageToUSD.__name__, + log_level=logging.CRITICAL, + ) + workflow.project_name = "patient" + workflow.output_directory = str(tmp_path) + workflow.segmenter = FakeSegmenter() + workflow.times_per_second = 12.5 + workflow._transformed_contours = { + "all": [], + "dynamic": [], + "static": [], + } + + workflow._create_usd_files() + + assert times_per_second_values == [12.5, 12.5, 12.5]