From 78946151095e4b0fdbaa6f384ef88eee1017b46b Mon Sep 17 00:00:00 2001 From: Stephen Aylward Date: Fri, 10 Jul 2026 09:30:14 -0400 Subject: [PATCH 1/3] ENH: Refactored VTK-to-USD class, workflow, and tutorial. Decouple VTK-to-USD workflow from file I/O; rename times_per_second to frames_per_second - WorkflowConvertVTKToUSD now takes in-memory PyVista/VTK meshes (input_meshes) with explicit usd_project_name/output_directory and optional time_codes/static_merge, replacing file-path input and regex-based time-series filename discovery. - Renamed times_per_second -> frames_per_second across ConvertVTKToUSD, ConversionSettings, CLI --fps, and workflows for clarity. - Renamed run() -> process() to match WorkflowConvertImageToUSD's convention. - download_data CLI no longer defaults to Slicer-Heart-CT; an explicit dataset argument is now required (prints help otherwise). - Updated docs, tutorials, experiments, and tests accordingly. --- docs/api/workflows.rst | 9 +- docs/architecture.rst | 6 +- docs/cli_scripts/byod_tutorials.rst | 34 ++-- docs/cli_scripts/download_data.rst | 14 +- docs/cli_scripts/vtk_to_usd.rst | 11 +- docs/developer/usd_generation.rst | 2 +- docs/tutorials.rst | 18 +- .../convert_chop_alterra_valve_to_usd.py | 4 +- .../convert_chop_heart_vtk_to_usd.py | 32 ++-- .../convert_chop_tpv25_valve_to_usd.py | 4 +- ...3-transform_dynamic_and_static_contours.py | 4 +- src/physiotwin4d/cli/convert_image_to_usd.py | 4 +- src/physiotwin4d/cli/convert_vtk_to_usd.py | 30 ++- src/physiotwin4d/cli/download_data.py | 9 +- src/physiotwin4d/convert_vtk_to_usd.py | 65 ++++--- src/physiotwin4d/data_download_tools.py | 6 + .../vtk_to_usd/data_structures.py | 2 +- .../workflow_convert_image_to_usd.py | 8 +- .../workflow_convert_vtk_to_usd.py | 172 ++++++++---------- tests/test_cli_smoke.py | 4 +- tests/test_download_data_cli.py | 18 +- tests/test_vtk_to_usd_library.py | 2 +- tutorials/tutorial_05_vtk_to_usd.py | 11 +- 23 files changed, 236 insertions(+), 233 deletions(-) diff --git a/docs/api/workflows.rst b/docs/api/workflows.rst index 50dcdaf..477b7b0 100644 --- a/docs/api/workflows.rst +++ b/docs/api/workflows.rst @@ -107,16 +107,19 @@ VTK to USD .. code-block:: python + import pyvista as pv from physiotwin4d import WorkflowConvertVTKToUSD + input_meshes = [pv.read("heart_000.vtp"), pv.read("heart_001.vtp")] workflow = WorkflowConvertVTKToUSD( - vtk_files=["heart_000.vtp", "heart_001.vtp"], - output_usd="heart.usd", + input_meshes=input_meshes, + usd_project_name="heart", + output_directory="./output", appearance="anatomy", anatomy_type="heart", ) - output_path = workflow.run() + output_path = workflow.process() Statistical Shape Modeling ========================== diff --git a/docs/architecture.rst b/docs/architecture.rst index fd791c2..0dd4c5a 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -84,9 +84,9 @@ Primary Workflows reference. ``WorkflowConvertVTKToUSD`` - Converts VTK files to animated USD scenes through the supported workflow - wrapper. The lower-level :mod:`physiotwin4d.vtk_to_usd` package exposes - advanced file conversion primitives. + Converts in-memory PyVista/VTK meshes to animated USD scenes through the + supported workflow wrapper. The lower-level :mod:`physiotwin4d.vtk_to_usd` + package exposes advanced file conversion primitives. AI Surrogate Workflows (PhysicsNeMo) ===================================== diff --git a/docs/cli_scripts/byod_tutorials.rst b/docs/cli_scripts/byod_tutorials.rst index 60df479..45f75f1 100644 --- a/docs/cli_scripts/byod_tutorials.rst +++ b/docs/cli_scripts/byod_tutorials.rst @@ -142,7 +142,7 @@ selects its default reference frame internally. segmentation_method=pt4d.SegmentChestTotalSegmentator(), output_directory="./results", project_name="heart_animated", - times_per_second=30.0, + frames_per_second=30.0, ) workflow.process() @@ -179,24 +179,27 @@ Pass a single ``.vtp`` file. Use ``--appearance`` to control material style and .. code-block:: python + import pyvista as pv import physiotwin4d as pt4d workflow = pt4d.WorkflowConvertVTKToUSD( - vtk_files=["heart.vtp"], - output_usd="heart.usd", + input_meshes=[pv.read("heart.vtp")], + usd_project_name="heart", + output_directory=".", appearance="anatomy", anatomy_type="heart", ) - workflow.run() + workflow.process() 4D - Mesh Time Series ~~~~~~~~~~~~~~~~~~~~~ -Pass per-frame VTK files. The default workflow treats multiple files as a time -series when their names match ``.t.vtk``, ``.t.vtp``, or -``.t.vtu``. The VTK-to-USD CLI supports ``--fps`` to control playback -rate. For scalar colormaps, combine ``--primvar``, ``--cmap``, and -``--intensity-range``. +Pass per-frame VTK files in time order; the workflow treats them as an +ordered time series by list order (no filename pattern required). Pass +``--static-merge`` instead when the files are unrelated static meshes to +merge into one scene rather than animation frames. The VTK-to-USD CLI +supports ``--fps`` to control playback rate. For scalar colormaps, combine +``--primvar``, ``--cmap``, and ``--intensity-range``. **CLI:** @@ -220,18 +223,21 @@ rate. For scalar colormaps, combine ``--primvar``, ``--cmap``, and .. code-block:: python + import pyvista as pv import physiotwin4d as pt4d + input_meshes = [pv.read(f) for f in ("stress.t0.vtk", "stress.t1.vtk", "stress.t2.vtk")] workflow = pt4d.WorkflowConvertVTKToUSD( - vtk_files=["stress.t0.vtk", "stress.t1.vtk", "stress.t2.vtk"], - output_usd="stress_animation.usd", - times_per_second=30, + input_meshes=input_meshes, + usd_project_name="stress_animation", + output_directory=".", + frames_per_second=30, appearance="colormap", colormap_primvar="vtk_point_stress_c0", colormap_name="viridis", colormap_intensity_range=(0, 500), ) - workflow.run() + workflow.process() **Lower-level in-memory conversion with ConvertVTKToUSD:** @@ -249,7 +255,7 @@ lower-level :class:`physiotwin4d.ConvertVTKToUSD` class directly: converter = pt4d.ConvertVTKToUSD( data_basename="HeartAnimation", input_polydata=meshes, - times_per_second=30, + frames_per_second=30, ) converter.convert("output.usd") diff --git a/docs/cli_scripts/download_data.rst b/docs/cli_scripts/download_data.rst index 2426eac..e759ca9 100644 --- a/docs/cli_scripts/download_data.rst +++ b/docs/cli_scripts/download_data.rst @@ -28,18 +28,14 @@ Supported Datasets Basic Usage =========== -Download the default dataset into the default location: +Download a dataset into its default location: .. code-block:: bash - physiotwin4d-download-data + physiotwin4d-download-data Slicer-Heart-CT -This is equivalent to: - -.. code-block:: bash - - physiotwin4d-download-data Slicer-Heart-CT \ - --directory data/Slicer-Heart-CT +Running the command with no arguments prints usage/help instead of +downloading anything. Options ======= @@ -50,7 +46,7 @@ Options ``data_name`` Dataset to download. One of ``Slicer-Heart-CT``, ``KCL-Heart-Model``, or - ``CHOP-Valve4D``. + ``CHOP-Valve4D``. Required — omitting it prints help and exits. ``--directory`` Directory where the dataset is stored. Defaults to ``data/``. diff --git a/docs/cli_scripts/vtk_to_usd.rst b/docs/cli_scripts/vtk_to_usd.rst index 58f8164..4bdea06 100644 --- a/docs/cli_scripts/vtk_to_usd.rst +++ b/docs/cli_scripts/vtk_to_usd.rst @@ -3,8 +3,9 @@ VTK to USD Conversion ===================== The ``physiotwin4d-convert-vtk-to-usd`` command converts VTK, VTP, or VTU -mesh files to USD for Omniverse visualization. Multiple input files are treated -as a time series. +mesh files to USD for Omniverse visualization. Multiple input files are +treated as an ordered time series by default; pass ``--static-merge`` to +instead merge unrelated static meshes into one scene with no time samples. Basic Usage =========== @@ -70,8 +71,10 @@ one mesh, or ``--by-cell-type`` to split by cell type. Python API ========== -Use :class:`physiotwin4d.WorkflowConvertVTKToUSD` for the workflow API and -:class:`physiotwin4d.ConvertVTKToUSD` for direct in-memory conversion. +Use :class:`physiotwin4d.WorkflowConvertVTKToUSD` for the workflow API +(splitting and appearance built in) and :class:`physiotwin4d.ConvertVTKToUSD` +for lower-level control (e.g. anatomical label splitting via ``mask_ids``). +Both take in-memory PyVista/VTK meshes. Related Pages ============= diff --git a/docs/developer/usd_generation.rst b/docs/developer/usd_generation.rst index b92d453..2503378 100644 --- a/docs/developer/usd_generation.rst +++ b/docs/developer/usd_generation.rst @@ -41,7 +41,7 @@ Time Series data_basename='Heart', vtk_files=['heart_t0.vtp', 'heart_t1.vtp', 'heart_t2.vtp'], time_codes=[0.0, 1.0, 2.0], - times_per_second=24.0, + frames_per_second=24.0, ).convert('animated_heart.usd') In-Memory Meshes diff --git a/docs/tutorials.rst b/docs/tutorials.rst index cb5ed45..c4b493e 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -346,24 +346,26 @@ Preview Output (placeholder — a real capture lands in a follow-up PR) Inner API usage - The supported workflow wrapper converts one or more VTK files into an - animated, materially-painted USD stage: + The supported workflow wrapper converts one or more in-memory PyVista/VTK + meshes into an animated, materially-painted USD stage: .. code-block:: python + mesh = pv.read(str(vtk_file)) workflow = WorkflowConvertVTKToUSD( - vtk_files=[vtk_file], - output_usd=output_usd, + input_meshes=[mesh], + usd_project_name="surfaces", + output_directory=output_dir, appearance="anatomy", anatomy_type="heart", separate_by_connectivity=True, ) - usd_file = workflow.run() + usd_file = workflow.process() For callers who need more control than the workflow wrapper offers (e.g. - converting in-memory PyVista/VTK objects instead of files, or applying a - colormap), use :class:`physiotwin4d.ConvertVTKToUSD` directly — it is the - supported public entry point for VTK-to-USD conversion. + applying a colormap or per-label anatomical splitting), use + :class:`physiotwin4d.ConvertVTKToUSD` directly — it is the supported + public entry point for VTK-to-USD conversion. Run .. code-block:: bash diff --git a/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py b/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py index f130131..dd033fc 100644 --- a/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py +++ b/experiments/Convert_VTK_To_USD/convert_chop_alterra_valve_to_usd.py @@ -65,7 +65,7 @@ # Conversion parameters separate_by = "connectivity" # Essential for alterra vtk file -times_per_second = 60.0 +frames_per_second = 60.0 solid_color = (0.5, 0.5, 0.5) # %% @@ -153,7 +153,7 @@ vtk_files=alterra_files, extract_surface=True, separate_by=separate_by, - times_per_second=times_per_second, + frames_per_second=frames_per_second, solid_color=solid_color, time_codes=alterra_times, ) diff --git a/experiments/Convert_VTK_To_USD/convert_chop_heart_vtk_to_usd.py b/experiments/Convert_VTK_To_USD/convert_chop_heart_vtk_to_usd.py index c3ae1ee..5bf0b4a 100644 --- a/experiments/Convert_VTK_To_USD/convert_chop_heart_vtk_to_usd.py +++ b/experiments/Convert_VTK_To_USD/convert_chop_heart_vtk_to_usd.py @@ -2,6 +2,7 @@ # %% from pathlib import Path +import pyvista as pv from physiotwin4d.workflow_convert_vtk_to_usd import WorkflowConvertVTKToUSD @@ -25,33 +26,30 @@ output_dir.mkdir(parents=True, exist_ok=True) -all_files = [] +all_meshes = [] for vtkname, usdname in zip(vtknames, usdnames): - out_usd = Path.absolute(output_dir / f"RVOT28-Dias-{usdname}.usd") - if out_usd.exists(): - out_usd.unlink() + mesh = pv.read(str(input_dir / f"{vtkname}.vtk")) + all_meshes.append(mesh) - in_name = input_dir / f"{vtkname}.vtk" - all_files.append(in_name) - - converter = WorkflowConvertVTKToUSD( - vtk_files=[in_name], - output_usd=out_usd, + workflow = WorkflowConvertVTKToUSD( + input_meshes=[mesh], + usd_project_name=f"RVOT28-Dias-{usdname}", + output_directory=output_dir, separate_by_connectivity=False, separate_by_cell_type=False, - mesh_name=f"RVOT28Dias_{usdname}", appearance="anatomy", anatomy_type="heart", ) - converter.run() + workflow.process() -converter = WorkflowConvertVTKToUSD( - vtk_files=all_files, - output_usd=Path.absolute(output_dir / Path("RVOT28-Dias-WholeHeart.usd")), +workflow = WorkflowConvertVTKToUSD( + input_meshes=all_meshes, + usd_project_name="RVOT28-Dias-WholeHeart", + output_directory=output_dir, separate_by_connectivity=False, separate_by_cell_type=False, - mesh_name="RVOT28Dias_WholeHeart", + static_merge=True, appearance="anatomy", anatomy_type="heart", ) -converter.run() +workflow.process() diff --git a/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py b/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py index 2c32d79..5b006ae 100644 --- a/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py +++ b/experiments/Convert_VTK_To_USD/convert_chop_tpv25_valve_to_usd.py @@ -63,7 +63,7 @@ # Conversion parameters separate_by = "connectivity" # Essential for tpv25 vtk file -times_per_second = 60.0 +frames_per_second = 60.0 solid_color = (0.5, 0.5, 0.5) # %% @@ -150,7 +150,7 @@ vtk_files=tpv25_files, extract_surface=True, separate_by=separate_by, - times_per_second=times_per_second, + frames_per_second=frames_per_second, solid_color=solid_color, time_codes=tpv25_times, ) diff --git a/experiments/Heart-GatedCT_To_USD/3-transform_dynamic_and_static_contours.py b/experiments/Heart-GatedCT_To_USD/3-transform_dynamic_and_static_contours.py index 8dcdbf0..0e7a660 100644 --- a/experiments/Heart-GatedCT_To_USD/3-transform_dynamic_and_static_contours.py +++ b/experiments/Heart-GatedCT_To_USD/3-transform_dynamic_and_static_contours.py @@ -63,7 +63,7 @@ def convert_contours(base_name, output_dir, project_name, compute_normals=False) polydata = [pv.read(f) for f in files] # For cardiac gated CT data with 21 time points (0-20), each frame = 1 second - # so we use times_per_second=1.0 instead of the default 24.0. + # so we use frames_per_second=1.0 instead of the default 24.0. # Forwarding `seg` groups labels by anatomy type under # /World/{project_name}/{type}/{label_name}. converter = ConvertVTKToUSD( @@ -72,7 +72,7 @@ def convert_contours(base_name, output_dir, project_name, compute_normals=False) all_mask_ids, segmenter=seg, compute_normals=compute_normals, - times_per_second=1.0, + frames_per_second=1.0, ) stage = converter.convert( os.path.join(output_dir, f"{project_name}.{base_name}.usd"), diff --git a/src/physiotwin4d/cli/convert_image_to_usd.py b/src/physiotwin4d/cli/convert_image_to_usd.py index 0ac5f15..309ce33 100644 --- a/src/physiotwin4d/cli/convert_image_to_usd.py +++ b/src/physiotwin4d/cli/convert_image_to_usd.py @@ -102,7 +102,7 @@ def main() -> int: "--fps", type=float, default=24.0, - dest="times_per_second", + dest="frames_per_second", help="Frames per second for animated USD time series (default: 24)", ) @@ -159,7 +159,7 @@ def main() -> int: output_directory=args.output_dir, segmentation_method=segmentation_method, registration_method=registration_method, - times_per_second=args.times_per_second, + frames_per_second=args.frames_per_second, ) except Exception as e: print(f"Error initializing workflow: {e}") diff --git a/src/physiotwin4d/cli/convert_vtk_to_usd.py b/src/physiotwin4d/cli/convert_vtk_to_usd.py index 0ef7bdf..1ddddb1 100644 --- a/src/physiotwin4d/cli/convert_vtk_to_usd.py +++ b/src/physiotwin4d/cli/convert_vtk_to_usd.py @@ -10,6 +10,9 @@ import argparse import os import sys +from pathlib import Path + +import pyvista as pv from ..usd_anatomy_tools import DEFAULT_RENDER_PARAMS @@ -49,7 +52,8 @@ def main() -> int: parser.add_argument( "vtk_files", nargs="+", - help="One or more VTK files (.vtk, .vtp, .vtu). Multiple files form a time series.", + help="One or more VTK files (.vtk, .vtp, .vtu). Multiple files form an " + "ordered time series unless --static-merge is set.", ) parser.add_argument( "-o", @@ -76,15 +80,16 @@ def main() -> int: help="Do not split; output a single mesh (clears --by-connectivity and --by-cell-type)", ) parser.add_argument( - "--mesh-name", - default="Mesh", - help="Base mesh name (default: Mesh)", + "--static-merge", + action="store_true", + help="Treat multiple input files as separate static objects in one scene " + "(no time samples) instead of an ordered time series.", ) parser.add_argument( "--fps", type=float, default=60.0, - dest="times_per_second", + dest="frames_per_second", help="Frames per second for time series (default: 60)", ) parser.add_argument( @@ -192,17 +197,22 @@ def main() -> int: print(f"Error: Input file not found: {p}") return 1 + output_path = Path(args.output_usd) + try: from .. import WorkflowConvertVTKToUSD + input_meshes = [pv.read(str(p)) for p in args.vtk_files] + workflow = WorkflowConvertVTKToUSD( - vtk_files=args.vtk_files, - output_usd=args.output_usd, + input_meshes=input_meshes, + usd_project_name=output_path.stem, + output_directory=output_path.parent, separate_by_connectivity=separate_by_connectivity, separate_by_cell_type=separate_by_cell_type, - mesh_name=args.mesh_name, - times_per_second=args.times_per_second, + frames_per_second=args.frames_per_second, extract_surface=args.extract_surface, + static_merge=args.static_merge, appearance=args.appearance, solid_color=solid_color, anatomy_type=args.anatomy_type, @@ -215,7 +225,7 @@ def main() -> int: return 1 try: - out_path = workflow.run() + out_path = workflow.process() print("\nConversion completed successfully.") print(f"Output: {out_path}") return 0 diff --git a/src/physiotwin4d/cli/download_data.py b/src/physiotwin4d/cli/download_data.py index 116d74c..990e4ec 100644 --- a/src/physiotwin4d/cli/download_data.py +++ b/src/physiotwin4d/cli/download_data.py @@ -22,7 +22,6 @@ def main(argv: Optional[list[str]] = None) -> int: formatter_class=argparse.RawDescriptionHelpFormatter, epilog=f""" Examples: - %(prog)s %(prog)s {SLICER_HEART_CT} --directory data/Slicer-Heart-CT %(prog)s {KCL_HEART_MODEL} --directory data/KCL-Heart-Model %(prog)s {CHOP_VALVE4D} --directory data/CHOP-Valve4D @@ -32,8 +31,8 @@ def main(argv: Optional[list[str]] = None) -> int: "data_name", nargs="?", choices=[SLICER_HEART_CT, KCL_HEART_MODEL, CHOP_VALVE4D], - default=SLICER_HEART_CT, - help=f"Dataset to download (default: {SLICER_HEART_CT})", + default=None, + help="Dataset to download", ) parser.add_argument( "--directory", @@ -42,6 +41,10 @@ def main(argv: Optional[list[str]] = None) -> int: ) args = parser.parse_args(argv) + if args.data_name is None: + parser.print_help() + return 1 + directory = args.directory or f"data/{args.data_name}" output_dir = Path(directory) diff --git a/src/physiotwin4d/convert_vtk_to_usd.py b/src/physiotwin4d/convert_vtk_to_usd.py index d6f0bc9..8b53cac 100644 --- a/src/physiotwin4d/convert_vtk_to_usd.py +++ b/src/physiotwin4d/convert_vtk_to_usd.py @@ -79,9 +79,11 @@ def __init__( mask_ids: Optional[dict[int, str]] = None, compute_normals: bool = False, convert_to_surface: bool = True, - times_per_second: float = 24.0, + frames_per_second: float = 24.0, separate_by: Literal["none", "connectivity", "cell_type"] = "none", solid_color: tuple[float, float, float] = (0.8, 0.8, 0.8), + static_merge: bool = False, + time_codes: Optional[list[float]] = None, segmenter: Optional[SegmentAnatomyBase] = None, log_level: int | str = logging.INFO, ) -> None: @@ -90,17 +92,24 @@ def __init__( Args: data_basename: Base name for USD data (used in prim paths) - input_polydata: Sequence of PyVista/VTK meshes (one per time step) + input_polydata: Sequence of PyVista/VTK meshes (one per time step, or + one per static object when static_merge is True) mask_ids: Optional mapping of label IDs to anatomical region names. If provided, meshes will be split by labeled regions. compute_normals: Whether to compute vertex normals convert_to_surface: If True, extract surface from volumetric meshes - times_per_second: Time codes per second (default 24.0). + frames_per_second: Time codes per second (default 24.0). For medical imaging time series where each frame = 1 second, use 1.0. separate_by: How to split the mesh into sub-prims. 'none' keeps the mesh as-is, 'connectivity' splits by connected component, 'cell_type' splits by face vertex count. solid_color: Default RGB diffuse color in [0, 1] used when no colormap is set. + static_merge: If True, treat each mesh in input_polydata as a separate + object in a single static scene (no time samples) instead + of a time series. + time_codes: Explicit time codes aligned to input_polydata, used when + static_merge is False. If None, uses sequential integers + [0, 1, 2, ...]. segmenter: Optional SegmentAnatomyBase instance used to classify each mask_ids label into an anatomy group (heart / lung / bone / major_vessels / contrast / soft_tissue / other) so labeled @@ -108,6 +117,10 @@ def __init__( and materials under ``/World/Looks/{type}/{label}_material``. When None, labels are grouped under a single ``Anatomy`` Xform. log_level: Logging level + + Raises: + ValueError: If time_codes is not None and its length does not match + input_polydata, or its values are not non-decreasing. """ super().__init__(class_name=self.__class__.__name__, log_level=log_level) @@ -125,9 +138,21 @@ def __init__( self.colormap: str = "plasma" self.intensity_range: Optional[tuple[float, float]] = None - # Set by from_files() for file-based construction - self._is_static_merge: bool = False - self._time_codes: Optional[list[float]] = None + if time_codes is not None and len(time_codes) != len(self.input_polydata): + raise ValueError( + f"time_codes length ({len(time_codes)}) must match " + f"input_polydata length ({len(self.input_polydata)})" + ) + if time_codes is not None and len(time_codes) > 1: + if any( + time_codes[i] > time_codes[i + 1] for i in range(len(time_codes) - 1) + ): + raise ValueError( + "time_codes must be in non-decreasing order; " + "got values that decrease between consecutive frames" + ) + self._is_static_merge: bool = static_merge + self._time_codes: Optional[list[float]] = time_codes # Pre-converted MeshData for each time step; populated by from_files() so # _convert_unified() can reuse the topology-validation work instead of # calling _vtk_to_mesh_data() a second time. @@ -141,7 +166,7 @@ def __init__( preserve_cell_arrays=True, meters_per_unit=1.0, up_axis="Y", - times_per_second=times_per_second, + frames_per_second=frames_per_second, ) self.logger.info( @@ -158,7 +183,7 @@ def from_files( *, extract_surface: bool = True, separate_by: Literal["none", "connectivity", "cell_type"] = "none", - times_per_second: float = 24.0, + frames_per_second: float = 24.0, solid_color: tuple[float, float, float] = (0.8, 0.8, 0.8), time_codes: Optional[list[float]] = None, static_merge: bool = False, @@ -177,7 +202,7 @@ def from_files( vtk_files: Paths to VTK files; one file = one time step (or one static mesh). extract_surface: If True, extract surface from UnstructuredGrid (.vtu) meshes. separate_by: How to split each mesh into sub-prims. - times_per_second: FPS for time-varying animation. + frames_per_second: FPS for time-varying animation. solid_color: Default RGB diffuse color in [0, 1]. time_codes: Explicit time codes aligned to vtk_files. If None, uses sequential integers [0, 1, 2, ...]. @@ -195,20 +220,6 @@ def from_files( if not file_list: raise ValueError("vtk_files must not be empty") - if time_codes is not None and len(time_codes) != len(file_list): - raise ValueError( - f"time_codes length ({len(time_codes)}) must match " - f"vtk_files length ({len(file_list)})" - ) - if time_codes is not None and len(time_codes) > 1: - if any( - time_codes[i] > time_codes[i + 1] for i in range(len(time_codes) - 1) - ): - raise ValueError( - "time_codes must be in non-decreasing order; " - "got values that decrease between consecutive frames" - ) - meshes: list[pv.DataSet | vtk.vtkDataSet] = [] for path in file_list: mesh = pv.read(str(path)) @@ -228,13 +239,13 @@ def from_files( mask_ids=mask_ids, separate_by=separate_by, convert_to_surface=extract_surface, - times_per_second=times_per_second, + frames_per_second=frames_per_second, solid_color=solid_color, + static_merge=static_merge, + time_codes=resolved_time_codes, segmenter=segmenter, log_level=log_level, ) - instance._is_static_merge = static_merge - instance._time_codes = resolved_time_codes # Validate topology consistency for multi-frame time series and cache the # converted MeshData so _convert_unified() can reuse it without a second @@ -596,7 +607,7 @@ def convert( ] stage.SetStartTimeCode(time_codes[0]) stage.SetEndTimeCode(time_codes[-1]) - stage.SetTimeCodesPerSecond(self.settings.times_per_second) + stage.SetTimeCodesPerSecond(self.settings.frames_per_second) # Initialize managers material_mgr = MaterialManager(stage) diff --git a/src/physiotwin4d/data_download_tools.py b/src/physiotwin4d/data_download_tools.py index 511c1c5..0f0c58b 100644 --- a/src/physiotwin4d/data_download_tools.py +++ b/src/physiotwin4d/data_download_tools.py @@ -72,6 +72,7 @@ def DownloadSlicerHeartCTData(dirname: Union[str, Path]) -> Path: # noqa: N802 f"Downloaded file is empty: {DataDownloadTools.SLICER_HEART_CT_URL}" ) tmp_file.replace(data_file) + print(f"Downloaded {DataDownloadTools.SLICER_HEART_CT_FILENAME}") except BaseException: tmp_handle.close() if tmp_file.exists(): @@ -122,6 +123,9 @@ def DownloadKCLHeartModelData(dirname: Union[str, Path]) -> Path: # noqa: N802 DataDownloadTools._DownloadAndExtractTarMember( url, member_name=f"{index:02d}.vtk", target_file=target_file ) + print( + f"Downloaded {index:02d}.vtk ({index}/{DataDownloadTools.KCL_HEART_MODEL_MESH_COUNT})" + ) average_file = data_dir / "average_mesh.vtk" if not (average_file.exists() and average_file.stat().st_size > 0): @@ -130,6 +134,7 @@ def DownloadKCLHeartModelData(dirname: Union[str, Path]) -> Path: # noqa: N802 member_name="average.vtk", target_file=average_file, ) + print("Downloaded average_mesh.vtk") return data_dir @@ -207,6 +212,7 @@ def DownloadCHOPValve4DData(dirname: Union[str, Path]) -> Path: # noqa: N802 continue url = DataDownloadTools.CHOP_VALVE4D_RELEASE_URL + asset_name DataDownloadTools._DownloadAndExtractZip(url, target_dir) + print(f"Downloaded {subdir_name} ({asset_name})") return data_dir @staticmethod diff --git a/src/physiotwin4d/vtk_to_usd/data_structures.py b/src/physiotwin4d/vtk_to_usd/data_structures.py index 4206a04..21d3517 100644 --- a/src/physiotwin4d/vtk_to_usd/data_structures.py +++ b/src/physiotwin4d/vtk_to_usd/data_structures.py @@ -161,7 +161,7 @@ class ConversionSettings: default_color: tuple[float, float, float] = (0.8, 0.8, 0.8) # Time settings - times_per_second: float = 24.0 + frames_per_second: float = 24.0 use_time_samples: bool = True # Array prefixes diff --git a/src/physiotwin4d/workflow_convert_image_to_usd.py b/src/physiotwin4d/workflow_convert_image_to_usd.py index 35bdbb5..d578f97 100644 --- a/src/physiotwin4d/workflow_convert_image_to_usd.py +++ b/src/physiotwin4d/workflow_convert_image_to_usd.py @@ -54,7 +54,7 @@ def __init__( registration_method: Optional[RegisterImagesBase] = None, dynamic_labelmap_ids: Optional[list[int]] = None, mask_dilation_radius: int = 10, - times_per_second: float = 24.0, + frames_per_second: float = 24.0, log_level: int | str = logging.INFO, save_assets: bool = True, ) -> None: @@ -80,7 +80,7 @@ def __init__( dynamic/static split; everything registers as "all"). mask_dilation_radius (int): Dilation radius, in voxels, applied to the dynamic/static registration masks. Defaults to 10. - times_per_second: Frames per second for animated USD time series. + frames_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_assets: Write registered images, transforms, and labelmaps @@ -98,7 +98,7 @@ def __init__( self.usd_project_name = usd_project_name self.dynamic_labelmap_ids = dynamic_labelmap_ids if dynamic_labelmap_ids else [] self.output_directory = output_directory - self.times_per_second = times_per_second + self.frames_per_second = frames_per_second self.save_assets = save_assets self.registration_results: list[ @@ -433,7 +433,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, + frames_per_second=self.frames_per_second, log_level=self.log_level, ) usd_file = os.path.join( diff --git a/src/physiotwin4d/workflow_convert_vtk_to_usd.py b/src/physiotwin4d/workflow_convert_vtk_to_usd.py index 89fd6c9..6d8286a 100644 --- a/src/physiotwin4d/workflow_convert_vtk_to_usd.py +++ b/src/physiotwin4d/workflow_convert_vtk_to_usd.py @@ -2,15 +2,17 @@ VTK to USD conversion workflow and batch runner. Implements the pipeline from the Convert_VTK_To_USD experiment notebooks: -load one or more VTK files, optionally split by connectivity or cell type, +take one or more meshes, optionally split by connectivity or cell type, convert to USD, then apply a chosen appearance (solid color, anatomic material, or colormap from a primvar with auto or specified intensity range). """ import logging -import re from pathlib import Path -from typing import Literal +from typing import Literal, Optional, Sequence, Union + +import pyvista as pv +import vtk from .convert_vtk_to_usd import ConvertVTKToUSD from .physiotwin4d_base import PhysioTwin4DBase @@ -22,43 +24,52 @@ class WorkflowConvertVTKToUSD(PhysioTwin4DBase): """ - Workflow to convert one or more VTK files to USD with configurable + Workflow to convert one or more meshes to USD with configurable splitting and appearance (solid color, anatomic material, or colormap). """ def __init__( self, - vtk_files: list[str | Path], - output_usd: str | Path, + input_meshes: Sequence[Union[pv.DataSet, vtk.vtkDataSet]], + usd_project_name: str, + output_directory: Union[str, Path], *, separate_by_connectivity: bool = True, separate_by_cell_type: bool = False, - mesh_name: str = "Mesh", - times_per_second: float = 60.0, + frames_per_second: float = 60.0, extract_surface: bool = True, - time_series_pattern: str = r"\.t(\d+)\.(vtk|vtp|vtu)$", + static_merge: bool = False, + time_codes: Optional[list[float]] = None, appearance: AppearanceKind = "solid", solid_color: tuple[float, float, float] = (0.8, 0.8, 0.8), anatomy_type: str = "heart", - colormap_primvar: str | None = None, + colormap_primvar: Optional[str] = None, colormap_name: str = "viridis", - colormap_intensity_range: tuple[float, float] | None = None, + colormap_intensity_range: Optional[tuple[float, float]] = None, log_level: int | str = logging.INFO, ): """ Initialize the VTK-to-USD workflow. Args: - vtk_files: List of paths to VTK files (.vtk, .vtp, .vtu). One file = single frame; - multiple files = time series (ordered by time_series_pattern). - output_usd: Path to output USD file. + input_meshes: One or more PyVista/VTK meshes. A single mesh, or + static_merge=True, produces a static scene; multiple meshes + with static_merge=False (default) are treated as ordered + time-series frames, in list order. + usd_project_name: Project name; used as the root USD prim name + (/World/{usd_project_name}) and the output filename + ({usd_project_name}.usd). + output_directory: Directory where the output USD file is written. separate_by_connectivity: If True, split mesh into separate objects by connectivity. separate_by_cell_type: If True, split mesh by cell type (triangle/quad/...). Cannot be True when separate_by_connectivity is True. - mesh_name: Base name for the mesh (or first mesh when not splitting). - times_per_second: FPS for time-varying data. - extract_surface: For .vtu, extract surface before conversion. - time_series_pattern: Regex to extract time index from filenames (one group). + frames_per_second: FPS for time-varying data. + extract_surface: For volumetric meshes, extract surface before conversion. + static_merge: If True, input_meshes is not a time series - each mesh is + written as a separate static object with no time samples (see + ConvertVTKToUSD). + time_codes: Explicit time codes aligned to input_meshes, used when + static_merge is False. If None, uses sequential integers [0, 1, 2, ...]. appearance: "solid" | "anatomy" | "colormap". solid_color: RGB in [0,1] when appearance == "solid". anatomy_type: Anatomy material name when appearance == "anatomy" @@ -70,14 +81,15 @@ def __init__( log_level: Logging level. """ super().__init__(class_name=self.__class__.__name__, log_level=log_level) - self.vtk_files = [Path(f) for f in vtk_files] - self.output_usd = Path(output_usd) + self.input_meshes = list(input_meshes) + self.usd_project_name = usd_project_name + self.output_directory = Path(output_directory) self.separate_by_connectivity = separate_by_connectivity self.separate_by_cell_type = separate_by_cell_type - self.mesh_name = mesh_name - self.times_per_second = times_per_second + self.frames_per_second = frames_per_second self.extract_surface = extract_surface - self.time_series_pattern = time_series_pattern + self.static_merge = static_merge + self.time_codes = time_codes self.appearance = appearance self.solid_color = solid_color self.anatomy_type = anatomy_type @@ -90,74 +102,34 @@ def __init__( "separate_by_connectivity and separate_by_cell_type cannot both be True" ) - def discover_time_series( - self, - paths: list[Path], - pattern: str = r"\.t(\d+)\.(vtk|vtp|vtu)$", - ) -> tuple[list[tuple[int, Path]], bool]: - """Discover and sort time-series VTK files by extracted time index. - - Args: - paths: List of paths to VTK files - pattern: Regex with one group for time step number (default matches .t123.vtk) - - Returns: - (time_series, pattern_matched): Sorted list of (time_step, path) tuples, and - a flag True only when every path matched the pattern (true time series). - If any path does not match, time_series is [(0, p) for p in paths] and - pattern_matched is False (static merge). + def process(self) -> str: """ - regex = re.compile(pattern, re.IGNORECASE) - invalid_series = False - parsed: list[tuple[int, Path]] = [] - for p in paths: - match = regex.search(p.name) - if match: - parsed.append((int(match.group(1)), Path(p))) - else: - parsed.append((-1, Path(p))) - invalid_series = True - # Only treat as time series when all paths match; otherwise static merge - if invalid_series: - self.log_warning("Not a time series: %s", paths) - time_series = [(0, p) for _, p in parsed] - return time_series, False - time_series = [(t, p) for t, p in parsed] - time_series.sort(key=lambda x: (x[0], str(x[1]))) - return time_series, True - - def run(self) -> str: - """ - Run the full workflow: convert VTK to USD, then apply the chosen appearance. + Run the full workflow: convert meshes to USD, then apply the chosen appearance. Returns: Path to the created USD file (str). """ self.log_section("VTK to USD conversion workflow") - if not self.vtk_files: - raise ValueError("vtk_files must not be empty") + if not self.input_meshes: + raise ValueError("input_meshes must not be empty") - # Discover time series - time_series, pattern_matched = self.discover_time_series( - self.vtk_files, pattern=self.time_series_pattern + n_frames = len(self.input_meshes) + time_codes = ( + None + if self.static_merge + else self.time_codes or [float(i) for i in range(n_frames)] ) - time_steps = [t for t, _ in time_series] - time_codes = [float(t) for t in time_steps] - paths_ordered = [p for _, p in time_series] - n_frames = len(paths_ordered) - - # Multiple files but no pattern match: treat as static scene (no time samples) - is_static_merge = n_frames > 1 and not pattern_matched - - self.log_info("Input: %d file(s), time steps: %s", n_frames, time_steps[:5]) - if n_frames > 5: - self.log_info(" ... and %d more", n_frames - 5) - if is_static_merge: + + self.output_directory.mkdir(parents=True, exist_ok=True) + output_usd = self.output_directory / f"{self.usd_project_name}.usd" + + self.log_info("Input: %d mesh(es)", n_frames) + if self.static_merge: self.log_info( - "Filenames do not match time-series pattern; outputting static scene (no time samples)" + "static_merge=True; outputting static scene (no time samples)" ) - self.log_info("Output: %s", self.output_usd) + self.log_info("Output: %s", output_usd) separate_by: Literal["none", "connectivity", "cell_type"] = ( "connectivity" @@ -167,30 +139,32 @@ def run(self) -> str: else "none" ) - converter = ConvertVTKToUSD.from_files( - data_basename=self.mesh_name, - vtk_files=paths_ordered, - extract_surface=self.extract_surface, + converter = ConvertVTKToUSD( + data_basename=self.usd_project_name, + input_polydata=self.input_meshes, + convert_to_surface=self.extract_surface, separate_by=separate_by, - times_per_second=self.times_per_second, + frames_per_second=self.frames_per_second, solid_color=self.solid_color, - time_codes=time_codes if not is_static_merge else None, - static_merge=is_static_merge, + static_merge=self.static_merge, + time_codes=time_codes, log_level=self.log_level, ) - stage = converter.convert(str(self.output_usd)) + stage = converter.convert(str(output_usd)) - # Post-process: apply chosen appearance to all meshes under /World/{mesh_name} + # Post-process: apply chosen appearance to all meshes under /World/{usd_project_name} usd_tools = USDTools(log_level=self.log_level) mesh_paths = usd_tools.list_mesh_paths_under( - str(self.output_usd), parent_path=f"/World/{self.mesh_name}" + str(output_usd), parent_path=f"/World/{self.usd_project_name}" ) if not mesh_paths: - self.log_warning("No mesh prims found under /World/%s", self.mesh_name) - return str(self.output_usd) + self.log_warning( + "No mesh prims found under /World/%s", self.usd_project_name + ) + return str(output_usd) # Static merge has no time samples; pass None so only default time is used - appearance_time_codes = None if is_static_merge else time_codes + appearance_time_codes = None if self.static_merge else time_codes self.log_info( "Applying appearance '%s' to %d mesh(es)", self.appearance, len(mesh_paths) @@ -199,7 +173,7 @@ def run(self) -> str: if self.appearance == "solid": for mesh_path in mesh_paths: usd_tools.set_solid_display_color( - str(self.output_usd), + str(output_usd), mesh_path, self.solid_color, time_codes=appearance_time_codes, @@ -218,9 +192,7 @@ def run(self) -> str: primvar = self.colormap_primvar for mesh_path in mesh_paths: if primvar is None: - primvars = usd_tools.list_mesh_primvars( - str(self.output_usd), mesh_path - ) + primvars = usd_tools.list_mesh_primvars(str(output_usd), mesh_path) primvar = usd_tools.pick_color_primvar(primvars) if primvar is None: self.log_warning( @@ -232,7 +204,7 @@ def run(self) -> str: "Applying colormap to %s from primvar %s", mesh_path, primvar ) usd_tools.apply_colormap_from_primvar( - str(self.output_usd), + str(output_usd), mesh_path, primvar, cmap=self.colormap_name, @@ -243,5 +215,5 @@ def run(self) -> str: if self.colormap_primvar is None: primvar = None # next mesh: auto-pick again - self.log_info("Workflow complete: %s", self.output_usd) - return str(self.output_usd) + self.log_info("Workflow complete: %s", output_usd) + return str(output_usd) diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index ce9f145..4bc8760 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -61,7 +61,7 @@ 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.""" + """Image-to-USD CLI forwards --fps as frames_per_second.""" import physiotwin4d module = importlib.import_module("physiotwin4d.cli.convert_image_to_usd") @@ -110,4 +110,4 @@ def process(self) -> str: assert module.main() == 0 assert captured_kwargs["time_series_images"] == [fake_image] assert captured_kwargs["reference_image"] is fake_image - assert captured_kwargs["times_per_second"] == 30.0 + assert captured_kwargs["frames_per_second"] == 30.0 diff --git a/tests/test_download_data_cli.py b/tests/test_download_data_cli.py index d6d6333..98de495 100644 --- a/tests/test_download_data_cli.py +++ b/tests/test_download_data_cli.py @@ -11,24 +11,14 @@ from physiotwin4d.data_download_tools import DataDownloadTools -def test_download_data_cli_uses_default_dataset_and_directory( - monkeypatch: pytest.MonkeyPatch, +def test_download_data_cli_with_no_args_prints_help( capsys: pytest.CaptureFixture[str], ) -> None: - """Default CLI arguments route Slicer-Heart-CT to data/Slicer-Heart-CT.""" - calls: list[Path] = [] - - def fake_download(dirname: Union[str, Path]) -> Path: - calls.append(Path(dirname)) - return Path(dirname) / DataDownloadTools.SLICER_HEART_CT_FILENAME - - monkeypatch.setattr(DataDownloadTools, "DownloadSlicerHeartCTData", fake_download) - + """No positional argument prints usage/help instead of downloading anything.""" result = download_data.main([]) - assert result == 0 - assert calls == [Path("data/Slicer-Heart-CT")] - assert "Downloaded Slicer-Heart-CT" in capsys.readouterr().out + assert result == 1 + assert "usage:" in capsys.readouterr().out def test_download_data_cli_uses_requested_directory( diff --git a/tests/test_vtk_to_usd_library.py b/tests/test_vtk_to_usd_library.py index ac0ab9a..ff30a3d 100644 --- a/tests/test_vtk_to_usd_library.py +++ b/tests/test_vtk_to_usd_library.py @@ -353,7 +353,7 @@ def test_end_to_end_conversion( data_basename="CardiacModel", vtk_files=[kcl_average_surface], solid_color=(0.85, 0.2, 0.2), - times_per_second=24.0, + frames_per_second=24.0, ).convert(str(output_usd)) mesh_prim = stage.GetPrimAtPath("/World/CardiacModel/Mesh") diff --git a/tutorials/tutorial_05_vtk_to_usd.py b/tutorials/tutorial_05_vtk_to_usd.py index 70a3b9e..5b5f94f 100644 --- a/tutorials/tutorial_05_vtk_to_usd.py +++ b/tutorials/tutorial_05_vtk_to_usd.py @@ -20,6 +20,8 @@ from pathlib import Path from typing import Optional +import pyvista as pv + from physiotwin4d.test_tools import TestTools from physiotwin4d.workflow_convert_vtk_to_usd import WorkflowConvertVTKToUSD @@ -69,10 +71,11 @@ # %% # Workflow initialization - output_usd = output_dir / "surfaces.usd" + mesh = pv.read(str(vtk_file)) workflow = WorkflowConvertVTKToUSD( - vtk_files=[vtk_file], - output_usd=output_usd, + input_meshes=[mesh], + usd_project_name="surfaces", + output_directory=output_dir, appearance="anatomy", anatomy_type="heart", separate_by_connectivity=True, @@ -81,7 +84,7 @@ # %% # Workflow execution - usd_file = workflow.run() + usd_file = workflow.process() # %% # Result saving From a483f89bb189aaf9d6e1a277fb643f5ba7c31333 Mon Sep 17 00:00:00 2001 From: Stephen Aylward Date: Fri, 10 Jul 2026 10:13:54 -0400 Subject: [PATCH 2/3] ENH: Code Rabbit --- docs/architecture.rst | 7 ++++--- src/physiotwin4d/cli/convert_vtk_to_usd.py | 5 ++++- src/physiotwin4d/convert_vtk_to_usd.py | 14 +++++++------- src/physiotwin4d/data_download_tools.py | 15 ++++++++++----- src/physiotwin4d/workflow_convert_vtk_to_usd.py | 11 ++++++----- 5 files changed, 31 insertions(+), 21 deletions(-) diff --git a/docs/architecture.rst b/docs/architecture.rst index 0dd4c5a..1ccf967 100644 --- a/docs/architecture.rst +++ b/docs/architecture.rst @@ -84,9 +84,10 @@ Primary Workflows reference. ``WorkflowConvertVTKToUSD`` - Converts in-memory PyVista/VTK meshes to animated USD scenes through the - supported workflow wrapper. The lower-level :mod:`physiotwin4d.vtk_to_usd` - package exposes advanced file conversion primitives. + Converts in-memory PyVista/VTK meshes to static or animated USD scenes + through the supported workflow wrapper. The lower-level + :mod:`physiotwin4d.vtk_to_usd` package exposes advanced file conversion + primitives. AI Surrogate Workflows (PhysicsNeMo) ===================================== diff --git a/src/physiotwin4d/cli/convert_vtk_to_usd.py b/src/physiotwin4d/cli/convert_vtk_to_usd.py index 1ddddb1..8f59f9e 100644 --- a/src/physiotwin4d/cli/convert_vtk_to_usd.py +++ b/src/physiotwin4d/cli/convert_vtk_to_usd.py @@ -198,6 +198,9 @@ def main() -> int: return 1 output_path = Path(args.output_usd) + if output_path.suffix.lower() != ".usd": + print(f"Error: Output file must have a .usd extension, got: {output_path}") + return 1 try: from .. import WorkflowConvertVTKToUSD @@ -220,7 +223,7 @@ def main() -> int: colormap_name=args.colormap_name, colormap_intensity_range=intensity_range, ) - except ValueError as e: + except (ValueError, OSError, TypeError) as e: print(f"Error: {e}") return 1 diff --git a/src/physiotwin4d/convert_vtk_to_usd.py b/src/physiotwin4d/convert_vtk_to_usd.py index 8b53cac..42390d7 100644 --- a/src/physiotwin4d/convert_vtk_to_usd.py +++ b/src/physiotwin4d/convert_vtk_to_usd.py @@ -138,13 +138,13 @@ def __init__( self.colormap: str = "plasma" self.intensity_range: Optional[tuple[float, float]] = None - if time_codes is not None and len(time_codes) != len(self.input_polydata): - raise ValueError( - f"time_codes length ({len(time_codes)}) must match " - f"input_polydata length ({len(self.input_polydata)})" - ) - if time_codes is not None and len(time_codes) > 1: - if any( + if not static_merge and time_codes is not None: + if len(time_codes) != len(self.input_polydata): + raise ValueError( + f"time_codes length ({len(time_codes)}) must match " + f"input_polydata length ({len(self.input_polydata)})" + ) + if len(time_codes) > 1 and any( time_codes[i] > time_codes[i + 1] for i in range(len(time_codes) - 1) ): raise ValueError( diff --git a/src/physiotwin4d/data_download_tools.py b/src/physiotwin4d/data_download_tools.py index 0f0c58b..8e699a4 100644 --- a/src/physiotwin4d/data_download_tools.py +++ b/src/physiotwin4d/data_download_tools.py @@ -9,6 +9,7 @@ from __future__ import annotations +import logging import shutil import tarfile import tempfile @@ -18,6 +19,7 @@ from typing import Union _DOWNLOAD_TIMEOUT_SECONDS = 60.0 +_logger = logging.getLogger(__name__) class DataDownloadTools: @@ -72,7 +74,7 @@ def DownloadSlicerHeartCTData(dirname: Union[str, Path]) -> Path: # noqa: N802 f"Downloaded file is empty: {DataDownloadTools.SLICER_HEART_CT_URL}" ) tmp_file.replace(data_file) - print(f"Downloaded {DataDownloadTools.SLICER_HEART_CT_FILENAME}") + _logger.info("Downloaded %s", DataDownloadTools.SLICER_HEART_CT_FILENAME) except BaseException: tmp_handle.close() if tmp_file.exists(): @@ -123,8 +125,11 @@ def DownloadKCLHeartModelData(dirname: Union[str, Path]) -> Path: # noqa: N802 DataDownloadTools._DownloadAndExtractTarMember( url, member_name=f"{index:02d}.vtk", target_file=target_file ) - print( - f"Downloaded {index:02d}.vtk ({index}/{DataDownloadTools.KCL_HEART_MODEL_MESH_COUNT})" + _logger.info( + "Downloaded %02d.vtk (%d/%d)", + index, + index, + DataDownloadTools.KCL_HEART_MODEL_MESH_COUNT, ) average_file = data_dir / "average_mesh.vtk" @@ -134,7 +139,7 @@ def DownloadKCLHeartModelData(dirname: Union[str, Path]) -> Path: # noqa: N802 member_name="average.vtk", target_file=average_file, ) - print("Downloaded average_mesh.vtk") + _logger.info("Downloaded average_mesh.vtk") return data_dir @@ -212,7 +217,7 @@ def DownloadCHOPValve4DData(dirname: Union[str, Path]) -> Path: # noqa: N802 continue url = DataDownloadTools.CHOP_VALVE4D_RELEASE_URL + asset_name DataDownloadTools._DownloadAndExtractZip(url, target_dir) - print(f"Downloaded {subdir_name} ({asset_name})") + _logger.info("Downloaded %s (%s)", subdir_name, asset_name) return data_dir @staticmethod diff --git a/src/physiotwin4d/workflow_convert_vtk_to_usd.py b/src/physiotwin4d/workflow_convert_vtk_to_usd.py index 6d8286a..06bc7d0 100644 --- a/src/physiotwin4d/workflow_convert_vtk_to_usd.py +++ b/src/physiotwin4d/workflow_convert_vtk_to_usd.py @@ -115,11 +115,12 @@ def process(self) -> str: raise ValueError("input_meshes must not be empty") n_frames = len(self.input_meshes) - time_codes = ( - None - if self.static_merge - else self.time_codes or [float(i) for i in range(n_frames)] - ) + if self.static_merge: + time_codes = None + elif self.time_codes is None: + time_codes = [float(i) for i in range(n_frames)] + else: + time_codes = self.time_codes self.output_directory.mkdir(parents=True, exist_ok=True) output_usd = self.output_directory / f"{self.usd_project_name}.usd" From f24f22096b454ff6e225246b36046b8b928f2067 Mon Sep 17 00:00:00 2001 From: Stephen Aylward Date: Fri, 10 Jul 2026 12:24:04 -0400 Subject: [PATCH 3/3] ENH: Produce usd, usda, or usdc files. --- docs/tutorials.rst | 2 ++ src/physiotwin4d/cli/convert_vtk_to_usd.py | 9 +++++--- src/physiotwin4d/convert_vtk_to_usd.py | 22 ++++++++++++++++--- .../workflow_convert_vtk_to_usd.py | 18 ++++++++++----- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/docs/tutorials.rst b/docs/tutorials.rst index c4b493e..df036f0 100644 --- a/docs/tutorials.rst +++ b/docs/tutorials.rst @@ -351,6 +351,8 @@ Inner API usage .. code-block:: python + import pyvista as pv + mesh = pv.read(str(vtk_file)) workflow = WorkflowConvertVTKToUSD( input_meshes=[mesh], diff --git a/src/physiotwin4d/cli/convert_vtk_to_usd.py b/src/physiotwin4d/cli/convert_vtk_to_usd.py index 8f59f9e..daef1a9 100644 --- a/src/physiotwin4d/cli/convert_vtk_to_usd.py +++ b/src/physiotwin4d/cli/convert_vtk_to_usd.py @@ -198,8 +198,11 @@ def main() -> int: return 1 output_path = Path(args.output_usd) - if output_path.suffix.lower() != ".usd": - print(f"Error: Output file must have a .usd extension, got: {output_path}") + if output_path.suffix.lower() not in {".usd", ".usda", ".usdc"}: + print( + "Error: Output file must have a .usd, .usda, or .usdc extension, " + f"got: {output_path}" + ) return 1 try: @@ -209,7 +212,7 @@ def main() -> int: workflow = WorkflowConvertVTKToUSD( input_meshes=input_meshes, - usd_project_name=output_path.stem, + usd_project_name=output_path.name, output_directory=output_path.parent, separate_by_connectivity=separate_by_connectivity, separate_by_cell_type=separate_by_cell_type, diff --git a/src/physiotwin4d/convert_vtk_to_usd.py b/src/physiotwin4d/convert_vtk_to_usd.py index 42390d7..ff35ba9 100644 --- a/src/physiotwin4d/convert_vtk_to_usd.py +++ b/src/physiotwin4d/convert_vtk_to_usd.py @@ -41,6 +41,20 @@ validate_time_series_topology, ) +_USD_EXTENSIONS = {".usd", ".usda", ".usdc"} + + +def _split_usd_extension(name: str) -> tuple[str, str]: + """Split a trailing USD extension off ``name``. + + Returns ``(name_without_extension, extension)``. ``extension`` is ``".usd"`` + when ``name`` has no recognized USD extension (``.usd``, ``.usda``, ``.usdc``). + """ + suffix = Path(name).suffix + if suffix.lower() in _USD_EXTENSIONS: + return name[: -len(suffix)], suffix + return name, ".usd" + class ConvertVTKToUSD(PhysioTwin4DBase): """ @@ -91,7 +105,8 @@ def __init__( Initialize converter. Args: - data_basename: Base name for USD data (used in prim paths) + data_basename: Base name for USD data (used in prim paths). A + trailing USD extension (.usd, .usda, .usdc) is stripped. input_polydata: Sequence of PyVista/VTK meshes (one per time step, or one per static object when static_merge is True) mask_ids: Optional mapping of label IDs to anatomical region names. @@ -124,7 +139,7 @@ def __init__( """ super().__init__(class_name=self.__class__.__name__, log_level=log_level) - self.data_basename = data_basename + self.data_basename, _ = _split_usd_extension(data_basename) self.input_polydata = list(input_polydata) self.mask_ids = mask_ids self.compute_normals = compute_normals @@ -198,7 +213,8 @@ def from_files( For a static scene with multiple disconnected meshes, set static_merge=True. Args: - data_basename: Base name for USD prim paths. + data_basename: Base name for USD prim paths. A trailing USD + extension (.usd, .usda, .usdc) is stripped. vtk_files: Paths to VTK files; one file = one time step (or one static mesh). extract_surface: If True, extract surface from UnstructuredGrid (.vtu) meshes. separate_by: How to split each mesh into sub-prims. diff --git a/src/physiotwin4d/workflow_convert_vtk_to_usd.py b/src/physiotwin4d/workflow_convert_vtk_to_usd.py index 06bc7d0..a5314fb 100644 --- a/src/physiotwin4d/workflow_convert_vtk_to_usd.py +++ b/src/physiotwin4d/workflow_convert_vtk_to_usd.py @@ -14,7 +14,7 @@ import pyvista as pv import vtk -from .convert_vtk_to_usd import ConvertVTKToUSD +from .convert_vtk_to_usd import ConvertVTKToUSD, _split_usd_extension from .physiotwin4d_base import PhysioTwin4DBase from .usd_anatomy_tools import USDAnatomyTools from .usd_tools import USDTools @@ -47,7 +47,7 @@ def __init__( colormap_name: str = "viridis", colormap_intensity_range: Optional[tuple[float, float]] = None, log_level: int | str = logging.INFO, - ): + ) -> None: """ Initialize the VTK-to-USD workflow. @@ -57,8 +57,10 @@ def __init__( with static_merge=False (default) are treated as ordered time-series frames, in list order. usd_project_name: Project name; used as the root USD prim name - (/World/{usd_project_name}) and the output filename - ({usd_project_name}.usd). + (/World/{usd_project_name}) and the output filename. A + trailing USD extension (.usd, .usda, .usdc) is stripped from + the prim name but preserved for the output filename; if + omitted, ".usd" is used. output_directory: Directory where the output USD file is written. separate_by_connectivity: If True, split mesh into separate objects by connectivity. separate_by_cell_type: If True, split mesh by cell type (triangle/quad/...). @@ -82,7 +84,9 @@ def __init__( """ super().__init__(class_name=self.__class__.__name__, log_level=log_level) self.input_meshes = list(input_meshes) - self.usd_project_name = usd_project_name + self.usd_project_name, self._usd_extension = _split_usd_extension( + usd_project_name + ) self.output_directory = Path(output_directory) self.separate_by_connectivity = separate_by_connectivity self.separate_by_cell_type = separate_by_cell_type @@ -123,7 +127,9 @@ def process(self) -> str: time_codes = self.time_codes self.output_directory.mkdir(parents=True, exist_ok=True) - output_usd = self.output_directory / f"{self.usd_project_name}.usd" + output_usd = ( + self.output_directory / f"{self.usd_project_name}{self._usd_extension}" + ) self.log_info("Input: %d mesh(es)", n_frames) if self.static_merge: