From 0a7d093b695c0632e7243c768193af12ec5ab936 Mon Sep 17 00:00:00 2001 From: Stephen Aylward Date: Wed, 6 May 2026 11:58:39 -0400 Subject: [PATCH 1/3] ENH: Add low-level VTK-to-USD facade and clarify API boundary Add vtk_to_usd.convert_vtk_file() as the stable advanced file-conversion facade, while keeping ConvertVTKToUSD as the in-repo API for experiments, workflows, and tests. Refactor VTK-to-USD tests to validate behavior through ConvertVTKToUSD, replace experiment diagnostics with ConvertVTKToUSD.inspect_file(), and update docs/API map to remove stale converter APIs. --- README.md | 28 +- docs/API_MAP.md | 85 ++- docs/api/usd/index.rst | 20 +- docs/api/usd/polymesh.rst | 8 +- docs/api/usd/tetmesh.rst | 7 +- docs/developer/architecture.rst | 4 +- docs/developer/extending.rst | 14 +- docs/developer/usd_generation.rst | 579 ++---------------- docs/examples.rst | 29 +- docs/quickstart.rst | 18 +- .../convert_chop_alterra_valve_to_usd.py | 52 +- .../convert_chop_tpv25_valve_to_usd.py | 52 +- .../convert_vtk_to_usd_using_class.py | 69 +-- src/physiomotion4d/convert_vtk_to_usd.py | 74 ++- src/physiomotion4d/vtk_to_usd/CLAUDE.md | 75 ++- src/physiomotion4d/vtk_to_usd/README.md | 331 +++------- src/physiomotion4d/vtk_to_usd/__init__.py | 14 +- src/physiomotion4d/vtk_to_usd/converter.py | 79 +++ tests/test_vtk_to_usd_library.py | 505 +++------------ 19 files changed, 578 insertions(+), 1465 deletions(-) create mode 100644 src/physiomotion4d/vtk_to_usd/converter.py diff --git a/README.md b/README.md index f95c8b9..5546bed 100644 --- a/README.md +++ b/README.md @@ -127,8 +127,8 @@ print(f"PhysioMotion4D version: {physiomotion4d.__version__}") - `ContourTools`: Mesh extraction and contour manipulation - **USD Conversion**: VTK to USD conversion for Omniverse visualization - `ConvertVTKToUSD`: High-level converter for PyVista/VTK objects with colormap support - - `vtk_to_usd` module: File-based conversion library - - `VTKToUSDConverter`: Core converter with time-series support + - `vtk_to_usd` module: Advanced low-level file conversion library + - `convert_vtk_file()`: Single-file VTK/VTP/VTU to USD facade - `read_vtk_file()`: Read VTK/VTP/VTU files into MeshData - `ConversionSettings`: Configurable conversion parameters - `MaterialData`: USD material definitions @@ -307,7 +307,7 @@ forward_transform = results["forward_transform"] # Moving to fixed ### VTK to USD Conversion -PhysioMotion4D provides two APIs for converting VTK data to USD for NVIDIA Omniverse visualization: +PhysioMotion4D provides two APIs for converting VTK data to USD for NVIDIA Omniverse visualization. Repository workflows, experiments, and CLIs use `ConvertVTKToUSD`; `vtk_to_usd` is a public advanced layer for users who need low-level file conversion primitives. #### Option 1: High-Level ConvertVTKToUSD (for PyVista/VTK objects) @@ -336,11 +336,10 @@ converter.set_colormap( stage = converter.convert('cardiac_motion.usd') ``` -#### Option 2: File-Based vtk_to_usd Library +#### Option 2: Advanced File-Based vtk_to_usd Facade ```python from physiomotion4d.vtk_to_usd import ( - VTKToUSDConverter, ConversionSettings, MaterialData, convert_vtk_file, @@ -349,11 +348,11 @@ from physiomotion4d.vtk_to_usd import ( # Simple single-file conversion stage = convert_vtk_file('mesh.vtp', 'output.usd') -# Advanced: Custom settings and materials +# Advanced: custom settings and material settings = ConversionSettings( triangulate_meshes=True, compute_normals=True, - meters_per_unit=0.001, # mm to meters + meters_per_unit=1.0, # coordinates are authored in meters times_per_second=60.0, ) @@ -363,20 +362,19 @@ material = MaterialData( roughness=0.4, ) -converter = VTKToUSDConverter(settings) -stage = converter.convert_file('heart.vtp', 'heart.usd', material=material) - -# Time-series conversion -files = ['frame_000.vtp', 'frame_001.vtp', 'frame_002.vtp'] -time_codes = [0.0, 0.1, 0.2] -stage = converter.convert_sequence(files, 'animated.usd', time_codes=time_codes) +stage = convert_vtk_file( + 'heart.vtp', + 'heart.usd', + data_basename='Heart', + settings=settings, + material=material, +) ``` Features: - Automatic coordinate system conversion (RAS to Y-up) - Material system with UsdPreviewSurface - Preserves all VTK data arrays as USD primvars -- Time-series animation support - Supports VTP, VTK, and VTU file formats ### Logging and Debug Control diff --git a/docs/API_MAP.md b/docs/API_MAP.md index b1cd73c..33fdf46 100644 --- a/docs/API_MAP.md +++ b/docs/API_MAP.md @@ -15,8 +15,8 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## experiments/Convert_VTK_To_USD/convert_vtk_to_usd_using_class.py -- `def create_deformed_pv_mesh(base, time_step, num_steps=10)` (line 237): Return a sinusoidally scaled copy of base with a synthetic pressure field. -- `def verify_usd_file(usd_path)` (line 315): Verify USD file integrity. +- `def create_deformed_pv_mesh(base, time_step, num_steps=10)` (line 236): Return a sinusoidally scaled copy of base with a synthetic pressure field. +- `def verify_usd_file(usd_path)` (line 314): Verify USD file integrity. ## experiments/DisplacementField_To_USD/displacement_field_to_usd.py @@ -115,13 +115,14 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## src/physiomotion4d/convert_vtk_to_usd.py -- **class ConvertVTKToUSD** (line 38): Advanced VTK to USD converter with colormap and anatomical labeling support. - - `def __init__(self, data_basename, input_polydata, mask_ids=None, compute_normals=False, convert_to_surface=True, times_per_second=24.0, separate_by='none', solid_color=(0.8, 0.8, 0.8), log_level=logging.INFO)` (line 68): Initialize converter. - - `def from_files(cls, data_basename, vtk_files, *, extract_surface=True, separate_by='none', times_per_second=24.0, solid_color=(0.8, 0.8, 0.8), time_codes=None, static_merge=False, mask_ids=None, log_level=logging.INFO)` (line 139): Create a converter by loading VTK files from disk. - - `def supports_mesh_type(self, mesh)` (line 239): Check if mesh type is supported for conversion. - - `def list_available_arrays(self)` (line 267): List all point data arrays available across all time steps. - - `def set_colormap(self, color_by_array=None, colormap='plasma', intensity_range=None)` (line 313): Configure colormap for visualization. - - `def convert(self, output_usd_file, convert_to_surface=None, compute_normals=None)` (line 347): Convert VTK meshes to USD. +- **class ConvertVTKToUSD** (line 41): Advanced VTK to USD converter with colormap and anatomical labeling support. + - `def __init__(self, data_basename, input_polydata, mask_ids=None, compute_normals=False, convert_to_surface=True, times_per_second=24.0, separate_by='none', solid_color=(0.8, 0.8, 0.8), log_level=logging.INFO)` (line 71): Initialize converter. + - `def from_files(cls, data_basename, vtk_files, *, extract_surface=True, separate_by='none', times_per_second=24.0, solid_color=(0.8, 0.8, 0.8), time_codes=None, static_merge=False, mask_ids=None, log_level=logging.INFO)` (line 142): Create a converter by loading VTK files from disk. + - `def supports_mesh_type(self, mesh)` (line 240): Check if mesh type is supported for conversion. + - `def inspect_file(cls, vtk_file, *, extract_surface=True)` (line 269): Summarize a VTK file using the same low-level reader as conversion. + - `def list_available_arrays(self)` (line 335): List all point data arrays available across all time steps. + - `def set_colormap(self, color_by_array=None, colormap='plasma', intensity_range=None)` (line 381): Configure colormap for visualization. + - `def convert(self, output_usd_file, convert_to_surface=None, compute_normals=None)` (line 415): Convert VTK meshes to USD. ## src/physiomotion4d/image_tools.py @@ -343,6 +344,10 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def list_mesh_paths_under(self, stage_or_path, parent_path='/World/Meshes')` (line 1103): List paths of all mesh prims under a parent path. - `def repair_mesh_primvar_element_sizes(self, stage_or_path, mesh_path, *, time_code=None, save=True)` (line 1130): Repair missing/incorrect primvar elementSize metadata for a mesh. +## src/physiomotion4d/vtk_to_usd/converter.py + +- `def convert_vtk_file(vtk_file, output_usd_file, *, data_basename=None, mesh_name='Mesh', extract_surface=True, settings=None, material=None)` (line 14): Convert one VTK file to one USD stage. + ## src/physiomotion4d/vtk_to_usd/data_structures.py - **class DataType** (line 13): Data type enumeration for generic arrays. @@ -707,44 +712,30 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## tests/test_vtk_to_usd_library.py -- `def get_data_dir()` (line 34): Get the data directory path. -- `def check_kcl_heart_data()` (line 41): Check if KCL Heart Model data is available. -- `def check_valve4d_data()` (line 48): Check if CHOP Valve4D data is available. -- `def get_or_create_average_surface(test_directories)` (line 55): Get or create average_surface.vtp from average_mesh.vtk. -- `def kcl_average_surface(test_directories)` (line 101): Fixture providing the KCL average heart surface. -- **class TestGenericArray** (line 117): Test GenericArray data structure validation and reshaping. - - `def test_scalar_1d_array(self)` (line 120): Test that 1D scalar arrays (num_components=1) are kept as-is. - - `def test_flat_multicomponent_array_reshape(self)` (line 133): Test that flat 1D arrays with num_components>1 are reshaped to 2D. - - `def test_2d_array_valid(self)` (line 149): Test that 2D arrays with correct shape are accepted. - - `def test_flat_array_not_divisible_raises_error(self)` (line 162): Test that flat arrays with length not divisible by num_components raise error. - - `def test_2d_array_wrong_shape_raises_error(self)` (line 173): Test that 2D arrays with wrong shape raise error. - - `def test_3d_array_raises_error(self)` (line 184): Test that 3D arrays are rejected. - - `def test_flat_array_large_components(self)` (line 195): Test reshaping with large num_components (e.g., 9 for 3x3 tensors). -- **class TestFromFilesValidation** (line 210): Synthetic tests for ConvertVTKToUSD.from_files() — no real data required. - - `def test_time_codes_length_mismatch_raises(self, tmp_path)` (line 222): from_files() must reject time_codes whose length != len(vtk_files). - - `def test_time_codes_non_monotone_raises(self, tmp_path)` (line 232): from_files() must reject time_codes that decrease between frames. - - `def test_time_codes_equal_consecutive_is_valid(self, tmp_path)` (line 242): Equal consecutive time codes are non-decreasing and must not raise. - - `def test_from_files_populates_cached_mesh_data(self, tmp_path)` (line 256): from_files() with >1 frame must populate _cached_mesh_data. - - `def test_from_files_cache_reused_in_convert(self, tmp_path)` (line 269): _convert_unified() must not call _vtk_to_mesh_data() when cache is populated. - - `def test_from_files_single_file_no_cache(self, tmp_path)` (line 287): A single-file converter must not populate _cached_mesh_data. - - `def test_from_files_static_merge_no_cache(self, tmp_path)` (line 295): static_merge=True must not populate _cached_mesh_data. -- **class TestVTKReader** (line 307): Test VTK file reading capabilities. - - `def test_read_vtp_file(self, kcl_average_surface)` (line 310): Test reading VTP (PolyData) files. - - `def test_read_legacy_vtk_file(self)` (line 331): Test reading legacy VTK files. - - `def test_generic_arrays_preserved(self, kcl_average_surface)` (line 358): Test that generic data arrays are preserved during reading. -- **class TestVTKToUSDConversion** (line 382): Test VTK to USD conversion capabilities. - - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 385): Test converting a single VTK file to USD. - - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 417): Test conversion with a custom solid color material. - - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 455): Test that ConvertVTKToUSD applies correct default stage metadata. - - `def test_primvar_preservation(self, test_directories, kcl_average_surface)` (line 478): Test that VTK data arrays are preserved as USD primvars. -- **class TestTimeSeriesConversion** (line 514): Test time-series conversion capabilities. - - `def test_time_series_conversion(self, test_directories, kcl_average_surface)` (line 517): Test converting multiple VTK files as a time series. -- **class TestIntegration** (line 557): Integration tests combining multiple features. - - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 560): Test complete conversion workflow with all features. -- **class TestUnitScaling** (line 603): Verify that VTK mm coordinates are converted to USD meter coordinates. - - `def test_mm_to_m_point_scaling(self, tmp_path)` (line 606): Points written to USD must be 0.001× their original mm values. - - `def test_normals_remain_unit_length(self, tmp_path)` (line 638): Normal vectors must not be scaled — they should remain unit length. - - `def test_stage_meters_per_unit(self, tmp_path)` (line 664): Stage metersPerUnit metadata must be 1.0 (coordinates stored in meters). +- `def get_data_dir()` (line 24): Get the data directory path. +- `def check_kcl_heart_data()` (line 31): Check if KCL Heart Model data is available. +- `def check_valve4d_data()` (line 38): Check if CHOP Valve4D data is available. +- `def get_or_create_average_surface(test_directories)` (line 45): Get or create average_surface.vtp from average_mesh.vtk. +- `def kcl_average_surface(test_directories)` (line 72): Fixture providing the KCL average heart surface. +- **class TestFromFilesValidation** (line 85): Synthetic tests for ConvertVTKToUSD.from_files(). + - `def test_time_codes_length_mismatch_raises(self, tmp_path)` (line 88): from_files() must reject time_codes whose length != len(vtk_files). + - `def test_time_codes_non_monotone_raises(self, tmp_path)` (line 98): from_files() must reject time_codes that decrease between frames. + - `def test_time_codes_equal_consecutive_is_valid(self, tmp_path)` (line 108): Equal consecutive time codes are non-decreasing and must not raise. + - `def test_from_files_single_file_writes_static_mesh(self, tmp_path)` (line 120): A single-file converter writes a static mesh with no time range. + - `def test_from_files_static_merge_writes_separate_meshes(self, tmp_path)` (line 132): static_merge=True treats files as static objects, not time samples. +- **class TestSyntheticConversion** (line 151): Synthetic ConvertVTKToUSD tests that do not require downloaded data. + - `def test_file_primvar_preservation(self, tmp_path)` (line 154): Point arrays in a VTP file are preserved as USD primvars. + - `def test_time_series_conversion(self, tmp_path)` (line 173): Multiple VTP files write point time samples and stage time metadata. +- **class TestVTKToUSDConversion** (line 197): Test ConvertVTKToUSD on optional real VTK data. + - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 200): Test converting a single VTK file to USD. + - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 219): Test conversion with a custom solid color material. + - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 247): Test that ConvertVTKToUSD applies correct default stage metadata. +- **class TestIntegration** (line 264): Integration tests combining multiple features. + - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 267): Test complete conversion workflow with all features. +- **class TestUnitScaling** (line 290): Verify that VTK mm coordinates are converted to USD meter coordinates. + - `def test_mm_to_m_point_scaling(self, tmp_path)` (line 293): Points written to USD must be 0.001x their original mm values. + - `def test_normals_remain_unit_length(self, tmp_path)` (line 313): Normal vectors must not be scaled. + - `def test_stage_meters_per_unit(self, tmp_path)` (line 333): Stage metersPerUnit metadata must be 1.0. ## utils/claude_github_reviews.py diff --git a/docs/api/usd/index.rst b/docs/api/usd/index.rst index 94e8eef..3be096a 100644 --- a/docs/api/usd/index.rst +++ b/docs/api/usd/index.rst @@ -13,9 +13,7 @@ USD generation tools for creating animated 3D models from medical images: * **USD Tools**: Core USD file operations * **Anatomy Tools**: Anatomical structure handling -* **VTK Conversion**: Convert VTK meshes to USD -* **PolyMesh**: Surface mesh representation -* **TetMesh**: Tetrahedral mesh representation +* **VTK Conversion**: Convert VTK meshes to USD with :class:`ConvertVTKToUSD` Quick Links =========== @@ -24,8 +22,6 @@ Quick Links * :doc:`tools` - Core USD utilities * :doc:`anatomy_tools` - Anatomical structure tools * :doc:`vtk_conversion` - VTK to USD conversion - * :doc:`polymesh` - Surface mesh USD - * :doc:`tetmesh` - Tetrahedral mesh USD Module Documentation ==================== @@ -36,8 +32,6 @@ Module Documentation tools anatomy_tools vtk_conversion - polymesh - tetmesh Quick Start =========== @@ -49,16 +43,12 @@ Convert VTK to USD from physiomotion4d import ConvertVTKToUSD - converter = ConvertVTKToUSD( - output_file="animated_heart.usd", - colormap="rainbow", - verbose=True - ) - - converter.convert( + converter = ConvertVTKToUSD.from_files( + data_basename="Heart", vtk_files=["heart_phase_00.vtk", "heart_phase_01.vtk"], - time_points=[0.0, 0.1, 0.2] + time_codes=[0.0, 1.0], ) + stage = converter.convert("animated_heart.usd") Create Anatomical Scene ----------------------- diff --git a/docs/api/usd/polymesh.rst b/docs/api/usd/polymesh.rst index a5d861e..47b8bbe 100644 --- a/docs/api/usd/polymesh.rst +++ b/docs/api/usd/polymesh.rst @@ -1,15 +1,15 @@ ==================================== -PolyMesh USD Generation +Surface Mesh USD Generation ==================================== .. currentmodule:: physiomotion4d -Surface mesh (polygon mesh) representation in USD. +Surface VTK meshes are converted with :class:`ConvertVTKToUSD`. Class Reference =============== -.. autoclass:: ConvertVTKToUSDPolyMesh +.. autoclass:: ConvertVTKToUSD :members: :undoc-members: :show-inheritance: @@ -17,4 +17,4 @@ Class Reference .. rubric:: Navigation -:doc:`vtk_conversion` | :doc:`index` | :doc:`tetmesh` +:doc:`vtk_conversion` | :doc:`index` diff --git a/docs/api/usd/tetmesh.rst b/docs/api/usd/tetmesh.rst index c51b3a0..cf217c5 100644 --- a/docs/api/usd/tetmesh.rst +++ b/docs/api/usd/tetmesh.rst @@ -4,12 +4,13 @@ TetMesh USD Generation .. currentmodule:: physiomotion4d -Tetrahedral mesh representation in USD for volumetric models. +Volumetric VTK meshes are converted to USD surfaces with +:class:`ConvertVTKToUSD` using surface extraction. Class Reference =============== -.. autoclass:: ConvertVTKToUSDTetMesh +.. autoclass:: ConvertVTKToUSD :members: :undoc-members: :show-inheritance: @@ -17,4 +18,4 @@ Class Reference .. rubric:: Navigation -:doc:`polymesh` | :doc:`index` | :doc:`../utilities/index` +:doc:`vtk_conversion` | :doc:`index` | :doc:`../utilities/index` diff --git a/docs/developer/architecture.rst b/docs/developer/architecture.rst index 623cfb7..62ae6c5 100644 --- a/docs/developer/architecture.rst +++ b/docs/developer/architecture.rst @@ -103,9 +103,7 @@ Most PhysioMotion4D classes inherit from :class:`PhysioMotion4DBase`: │ │ └── RegisterImagesICON │ └── (Model registration classes) └── Conversion Classes - └── ConvertVTKToUSDBase - ├── ConvertVTKToUSDPolyMesh - └── ConvertVTKToUSDTetMesh + └── ConvertVTKToUSD The base class provides: diff --git a/docs/developer/extending.rst b/docs/developer/extending.rst index d2a10f7..574d4ee 100644 --- a/docs/developer/extending.rst +++ b/docs/developer/extending.rst @@ -54,7 +54,7 @@ Start with this template for new workflows: from physiomotion4d import PhysioMotion4DBase from physiomotion4d import SegmentChestTotalSegmentator from physiomotion4d import RegisterImagesICON - from physiomotion4d import ConvertVTKToUSDPolyMesh + from physiomotion4d import ConvertVTKToUSD class MyCustomWorkflow(PhysioMotion4DBase): """ @@ -95,7 +95,7 @@ Start with this template for new workflows: """Initialize processing components.""" self.segmentator = SegmentChestTotalSegmentator(verbose=self.verbose) self.registrator = RegisterImagesICON(device="cuda:0") - self.usd_converter = ConvertVTKToUSDPolyMesh(verbose=self.verbose) + self.usd_converter_class = ConvertVTKToUSD def process(self): """Execute complete workflow.""" @@ -189,13 +189,11 @@ Complete example of a custom workflow: # Convert to USD usd_file = f"{self.output_directory}/brain_vessels.usd" - converter = ConvertVTKToUSD() - converter.convert( - vtk_file=vessel_mesh, - usd_file=usd_file, - mesh_name="brain_vessels", - apply_materials=True + converter = ConvertVTKToUSD( + data_basename="brain_vessels", + input_polydata=[vessel_mesh], ) + converter.convert(usd_file) self.log(f"Brain vessel model created: {usd_file}", level="INFO") return usd_file diff --git a/docs/developer/usd_generation.rst b/docs/developer/usd_generation.rst index b7ffc41..8887849 100644 --- a/docs/developer/usd_generation.rst +++ b/docs/developer/usd_generation.rst @@ -1,567 +1,86 @@ -==================================== -USD Generation Development Guide -==================================== - -This guide covers developing with USD generation tools. - -For complete API documentation, see :doc:`../api/usd/index`. - -Overview -======== - -The USD generation module converts VTK meshes into Universal Scene Description (USD) format for visualization in NVIDIA Omniverse with anatomically-realistic materials and time-varying geometry. - -Overview -======== - -PhysioMotion4D provides comprehensive USD conversion capabilities: - -* **Base Converter**: Core USD generation functionality -* **PolyMesh Converter**: Surface mesh conversion -* **TetMesh Converter**: Volumetric tetrahedral mesh conversion -* **Anatomical Materials**: Organ-specific material painting -* **Time-Varying Geometry**: 4D animation support - -All converters inherit from :class:`ConvertVTKToUSDBase`. - -Base Converter Class -==================== - -ConvertVTKToUSDBase ---------------------- - -Abstract base class for USD conversion. - -.. autoclass:: physiomotion4d.ConvertVTKToUSDBase - :members: - :undoc-members: - :show-inheritance: - -**Key Methods**: - * ``convert(vtk_file, usd_file)``: Convert VTK to USD - * ``add_material(mesh, material_name)``: Apply material - * ``create_time_varying(vtk_files, usd_file)``: Create animated USD - * ``merge_usd_files(usd_files, output)``: Combine USD files - -USD Converters -============== - -Polygon Mesh Converter ----------------------- - -Convert surface meshes (VTK PolyData) to USD. - -.. autoclass:: physiomotion4d.ConvertVTKToUSDPolyMesh - :members: - :undoc-members: - :show-inheritance: - -**Features**: - * Surface mesh conversion - * Anatomical material painting - * Time-varying geometry - * Scalar data visualization with colormaps - -**Example Usage**: - -.. code-block:: python - - from physiomotion4d import ConvertVTKToUSDPolyMesh - - # Initialize converter - converter = ConvertVTKToUSDPolyMesh( - start_time=0, - end_time=1.0, - fps=30, - verbose=True - ) - - # Convert single mesh - converter.convert( - vtk_file="heart_mesh.vtk", - usd_file="heart.usd", - mesh_name="heart_lv", - apply_materials=True - ) - - # Convert 4D time series - vtk_files = [ - "heart_frame_00.vtk", - "heart_frame_01.vtk", - # ... more frames - ] - - converter.create_time_varying( - vtk_files=vtk_files, - usd_file="heart_animated.usd", - mesh_names=["heart_lv", "heart_rv", "aorta"] - ) - -**Material Painting**: - -.. code-block:: python - - # Automatic material from mesh name - converter.convert( - vtk_file="heart_lv.vtk", - usd_file="output.usd", - mesh_name="heart_lv", # Auto-detected as heart - apply_materials=True - ) - - # Custom material - converter.convert( - vtk_file="custom.vtk", - usd_file="output.usd", - material_name="cardiac_tissue", - material_color=[0.8, 0.2, 0.2], - material_opacity=0.9 - ) - -**Colormap Visualization**: - -.. code-block:: python +======================== +USD Generation +======================== - # Visualize scalar data with colormap - converter.convert_with_colormap( - vtk_file="heart_strain.vtk", - usd_file="strain_viz.usd", - scalar_array="Strain", - colormap="plasma", - value_range=[0, 0.2], - show_colorbar=True - ) +PhysioMotion4D uses :class:`physiomotion4d.ConvertVTKToUSD` as the +application-level API for VTK-to-USD conversion. Workflows, command-line tools, +and experiments should use this class instead of importing +``physiomotion4d.vtk_to_usd`` directly. -Tetrahedral Mesh Converter ---------------------------- +``physiomotion4d.vtk_to_usd`` remains a public advanced low-level package for +external users who need file readers, data containers, or direct USD writer +primitives. -Convert volumetric meshes (VTK UnstructuredGrid) to USD. +ConvertVTKToUSD +================ -.. autoclass:: physiomotion4d.ConvertVTKToUSDTetMesh +.. autoclass:: physiomotion4d.ConvertVTKToUSD :members: :undoc-members: :show-inheritance: -**Features**: - * Volumetric mesh conversion - * Internal structure visualization - * Cut-plane rendering - * Volume data representation - -**Example Usage**: +Single File +----------- .. code-block:: python - from physiomotion4d import ConvertVTKToUSDTetMesh - - # Initialize for tetrahedral meshes - converter = ConvertVTKToUSDTetMesh(verbose=True) - - # Convert volumetric mesh - converter.convert( - vtk_file="heart_volume.vtu", - usd_file="heart_volume.usd", - extract_surface=True, # Also create surface - internal_opacity=0.1 # Make volume semi-transparent - ) - - # Create cut-plane visualization - converter.convert_with_cutplane( - vtk_file="heart_volume.vtu", - usd_file="heart_cutplane.usd", - plane_normal=[1, 0, 0], - plane_position=0.5 - ) - -Anatomical Material System -=========================== + from physiomotion4d import ConvertVTKToUSD -Material Library ----------------- + stage = ConvertVTKToUSD.from_files( + data_basename='Heart', + vtk_files=['heart.vtp'], + extract_surface=True, + ).convert('heart.usd') -PhysioMotion4D includes a comprehensive anatomical material library: +Time Series +----------- .. code-block:: python - from physiomotion4d import USDAnatomyTools - - # Access material library - anatomy_tools = USDAnatomyTools() - - # Get available materials - materials = anatomy_tools.get_available_materials() - print(f"Available: {materials}") - - # Get material for structure - mat = anatomy_tools.get_material("heart_lv") - print(f"Heart LV: {mat}") + from physiomotion4d import ConvertVTKToUSD -**Material Categories**: - * **Cardiac**: Heart chambers, myocardium, valves - * **Vascular**: Arteries, veins, capillaries - * **Pulmonary**: Lungs, airways, alveoli - * **Skeletal**: Bones, cartilage - * **Soft Tissue**: Muscles, fat, connective tissue - * **Neural**: Brain, nerves - * **Visceral**: Liver, kidneys, spleen, etc. + stage = ConvertVTKToUSD.from_files( + 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, + ).convert('animated_heart.usd') -Custom Materials +In-Memory Meshes ---------------- -Define custom anatomical materials: - -.. code-block:: python - - from physiomotion4d import USDAnatomyTools - - anatomy_tools = USDAnatomyTools() - - # Define custom material - anatomy_tools.add_custom_material( - name="custom_cardiac_tissue", - base_color=[0.85, 0.2, 0.2], - metallic=0.0, - roughness=0.8, - opacity=0.95, - emission_color=[0.1, 0.0, 0.0], - emission_strength=0.2 - ) - - # Use custom material - converter.apply_material( - usd_stage=stage, - prim_path="/heart_lv", - material_name="custom_cardiac_tissue" - ) - -Material Properties -------------------- - -Anatomical materials support physically-based rendering: - -.. code-block:: python - - material_properties = { - 'base_color': [0.8, 0.2, 0.2], # RGB color - 'metallic': 0.0, # 0=non-metal, 1=metal - 'roughness': 0.7, # 0=smooth, 1=rough - 'opacity': 0.9, # 0=transparent, 1=opaque - 'ior': 1.4, # Index of refraction - 'emission_color': [0.0, 0.0, 0.0], # Emissive color - 'emission_strength': 0.0, # Emission intensity - 'normal_map': None, # Normal map texture - 'displacement': None # Displacement map - } - -Time-Varying Geometry -===================== - -Animation Creation ------------------- - -Create animated USD files from 4D VTK sequences: - .. code-block:: python from physiomotion4d import ConvertVTKToUSD - - # Initialize with timing - converter = ConvertVTKToUSD( - start_time=0.0, - end_time=2.0, # 2 second animation - fps=30, # 30 frames per second - loop=True # Loop animation - ) - - # Create 4D animation - vtk_4d_files = [f"frame_{i:03d}.vtk" for i in range(60)] - - converter.create_animation( - vtk_files=vtk_4d_files, - usd_file="animated_heart.usd", - mesh_names=["heart_lv", "heart_rv", "heart_myocardium"] - ) - -Time Sampling -------------- - -Control temporal resolution: - -.. code-block:: python + import pyvista as pv - # High temporal resolution + meshes = [pv.read(path) for path in vtk_files] converter = ConvertVTKToUSD( - start_time=0, - end_time=1.0, - fps=60 # Smooth animation - ) - - # Match source frame rate - num_frames = len(vtk_files) - cycle_duration = 1.0 # 1 second cardiac cycle - - converter = ConvertVTKToUSD( - start_time=0, - end_time=cycle_duration, - fps=num_frames / cycle_duration - ) - -Colormap Rendering -================== - -Scalar Data Visualization -------------------------- - -Visualize scalar data on meshes: - -.. code-block:: python - - from physiomotion4d import ConvertVTKToUSDPolyMesh - - converter = ConvertVTKToUSDPolyMesh() - - # Available colormaps - colormaps = [ - 'plasma', 'viridis', 'inferno', 'magma', - 'rainbow', 'jet', 'hot', 'cool', - 'gray', 'bone', 'copper' - ] - - # Apply colormap to scalar data - converter.convert_with_colormap( - vtk_file="heart_with_strain.vtk", - usd_file="strain_visualization.usd", - scalar_array="Strain", # Array name in VTK - colormap="plasma", - value_range=[0.0, 0.15], # Data range - show_colorbar=True, - colorbar_position="right" - ) - -Time-Varying Colormaps ----------------------- - -Animate colormaps over time: - -.. code-block:: python - - # Create time-varying colormap animation - vtk_files_with_data = [ - "heart_t0_strain.vtk", - "heart_t1_strain.vtk", - # ... more timesteps - ] - - converter.create_time_varying_colormap( - vtk_files=vtk_files_with_data, - usd_file="strain_animation.usd", - scalar_array="Strain", - colormap="viridis", - global_range=[0, 0.2], # Fixed range across time - fps=30 - ) - -USD File Management -=================== - -Merging USD Files ------------------ - -Combine multiple USD files: - -.. code-block:: python - - from physiomotion4d import USDTools - - usd_tools = USDTools() - - # Merge multiple anatomical structures - usd_tools.merge_usd_files( - input_files=[ - "heart.usd", - "lungs.usd", - "vessels.usd" - ], - output_file="complete_anatomy.usd", - preserve_hierarchy=True, - resolve_conflicts=True + data_basename='CardiacModel', + input_polydata=meshes, + compute_normals=True, ) + stage = converter.convert('cardiac_model.usd') -Scene Organization ------------------- - -Organize USD scene hierarchy: - -.. code-block:: python - - from pxr import Usd, UsdGeom - - # Create organized scene - stage = Usd.Stage.CreateNew("organized_scene.usd") - - # Create hierarchy - anatomy_root = UsdGeom.Xform.Define(stage, "/Anatomy") - cardiac = UsdGeom.Xform.Define(stage, "/Anatomy/Cardiac") - vessels = UsdGeom.Xform.Define(stage, "/Anatomy/Vessels") - - # Add meshes to hierarchy - converter.add_mesh_to_stage( - stage=stage, - vtk_file="heart_lv.vtk", - prim_path="/Anatomy/Cardiac/LeftVentricle" - ) - - stage.Save() - -Advanced Features -================= - -Level of Detail (LOD) ---------------------- - -Create multi-resolution meshes: - -.. code-block:: python - - class LODConverter(ConvertVTKToUSDPolyMesh): - """Converter with LOD support.""" - - def create_lod_mesh(self, vtk_file, usd_file, lod_levels): - """Create mesh with multiple LODs.""" - import pyvista as pv - - # Load high-res mesh - mesh = pv.read(vtk_file) - - # Create LOD variants - for lod, reduction in enumerate(lod_levels): - decimated = mesh.decimate(reduction) - self.convert( - vtk_mesh=decimated, - usd_file=usd_file, - variant_name=f"LOD_{lod}" - ) - -Instancing ----------- - -Efficiently handle repeated structures: +Colormaps +--------- .. code-block:: python - from physiomotion4d import USDTools - - usd_tools = USDTools() - - # Create instances of the same mesh - usd_tools.create_instances( - stage=stage, - source_prim="/Vessels/Artery", - instance_paths=[ - "/Anatomy/Artery_1", - "/Anatomy/Artery_2", - "/Anatomy/Artery_3" - ], - transforms=[ - transform_1, - transform_2, - transform_3 - ] + converter.set_colormap( + color_by_array='pressure', + colormap='viridis', + intensity_range=(0.0, 1.0), ) + stage = converter.convert('pressure.usd') -Performance Optimization -======================== - -Mesh Simplification -------------------- +Advanced Low-Level Facade +========================= -Reduce polygon count for performance: +Use the low-level facade only when the high-level class is not appropriate: .. code-block:: python - import pyvista as pv - - def optimize_mesh_for_usd(vtk_file, target_reduction=0.5): - """Optimize mesh for USD conversion.""" - mesh = pv.read(vtk_file) - - # Decimate - decimated = mesh.decimate(target_reduction) - - # Clean - cleaned = decimated.clean() - - # Smooth - smoothed = cleaned.smooth(n_iter=20) - - return smoothed - -Batch Conversion ----------------- - -Convert multiple files efficiently: - -.. code-block:: python - - from concurrent.futures import ThreadPoolExecutor - from pathlib import Path - - def batch_convert_to_usd(vtk_dir, usd_dir, num_workers=4): - """Convert multiple VTK files to USD in parallel.""" - converter = ConvertVTKToUSDPolyMesh() - - vtk_files = list(Path(vtk_dir).glob("*.vtk")) - - def convert_single(vtk_file): - usd_file = Path(usd_dir) / f"{vtk_file.stem}.usd" - converter.convert(str(vtk_file), str(usd_file)) - return usd_file - - with ThreadPoolExecutor(max_workers=num_workers) as executor: - results = list(executor.map(convert_single, vtk_files)) - - return results - -Best Practices -============== - -Material Assignment -------------------- - -* Use anatomically accurate colors and properties -* Maintain consistent naming conventions -* Apply appropriate transparency for visualization -* Use emission for highlighting structures - -Animation ---------- - -* Match frame rate to source data -* Use smooth temporal interpolation -* Ensure consistent topology across frames -* Optimize polygon count for real-time playback - -File Organization ------------------ - -.. code-block:: text - - # Recommended structure - output/ - ├── meshes/ - │ ├── frame_000.vtk - │ ├── frame_001.vtk - │ └── ... - ├── usd/ - │ ├── static_anatomy.usd - │ ├── dynamic_anatomy.usd - │ └── complete_scene.usd - └── textures/ - └── colormaps/ - -See Also -======== + from physiomotion4d.vtk_to_usd import convert_vtk_file -* :doc:`utilities` - USD and mesh utilities -* :doc:`workflows` - Using USD conversion in workflows -* :doc:`../cli_scripts/vtk_to_usd` - CLI for USD conversion + stage = convert_vtk_file('mesh.vtp', 'mesh.usd') diff --git a/docs/examples.rst b/docs/examples.rst index f8d4bbf..17d49d3 100644 --- a/docs/examples.rst +++ b/docs/examples.rst @@ -191,18 +191,19 @@ Convert VTK mesh sequence to animated USD: .. code-block:: python - from physiomotion4d import ConvertVTKToUSDPolyMesh + from physiomotion4d import ConvertVTKToUSD import glob # Get VTK files vtk_files = sorted(glob.glob("heart_frame_*.vtp")) - # Convert - converter = ConvertVTKToUSDPolyMesh() - converter.set_input_filenames(vtk_files) - converter.set_output_filename("heart_animation.usd") - converter.set_frame_rate(30) # FPS - converter.convert() + time_codes = [float(i) for i in range(len(vtk_files))] + stage = ConvertVTKToUSD.from_files( + data_basename="Heart", + vtk_files=vtk_files, + time_codes=time_codes, + times_per_second=30, + ).convert("heart_animation.usd") print("USD animation created: heart_animation.usd") @@ -522,7 +523,7 @@ Mix and match different components: SegmentChestTotalSegmentator, RegisterImagesICON, TransformTools, - ConvertVTKToUSDPolyMesh, + ConvertVTKToUSD, ContourTools ) import itk @@ -558,11 +559,13 @@ Mix and match different components: for i, mesh in enumerate(meshes): mesh.save(f"heart_{i:03d}.vtp") - # Convert to USD - converter = ConvertVTKToUSDPolyMesh() - converter.set_input_filenames([f"heart_{i:03d}.vtp" for i in range(10)]) - converter.set_output_filename("heart.usd") - converter.convert() + vtk_files = [f"heart_{i:03d}.vtp" for i in range(10)] + time_codes = [float(i) for i in range(len(vtk_files))] + stage = ConvertVTKToUSD.from_files( + data_basename="Heart", + vtk_files=vtk_files, + time_codes=time_codes, + ).convert("heart.usd") See Also ======== diff --git a/docs/quickstart.rst b/docs/quickstart.rst index 145e85c..0526594 100644 --- a/docs/quickstart.rst +++ b/docs/quickstart.rst @@ -168,20 +168,16 @@ Convert VTK time series to USD: .. code-block:: python - from physiomotion4d import ConvertVTKToUSDPolyMesh + from physiomotion4d import ConvertVTKToUSD - # Initialize converter - converter = ConvertVTKToUSDPolyMesh() - - # Set input VTK files (time series) vtk_files = [f"heart_frame_{i:03d}.vtp" for i in range(10)] - converter.set_input_filenames(vtk_files) - - # Set output USD file - converter.set_output_filename("heart_animation.usd") + time_codes = [float(i) for i in range(len(vtk_files))] - # Convert - converter.convert() + stage = ConvertVTKToUSD.from_files( + data_basename="Heart", + vtk_files=vtk_files, + time_codes=time_codes, + ).convert("heart_animation.usd") Sample Data =========== 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 491ed1e..8bbeb19 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 @@ -29,8 +29,6 @@ import re from pathlib import Path -import numpy as np - # Use as a test from physiomotion4d.notebook_utils import running_as_test @@ -39,10 +37,6 @@ from physiomotion4d import ConvertVTKToUSD -# cell_type_name_for_vertex_count and read_vtk_file are internal APIs used for diagnostics -from physiomotion4d.vtk_to_usd import cell_type_name_for_vertex_count -from physiomotion4d.vtk_to_usd.vtk_reader import read_vtk_file - # %% [markdown] # ## 1. Discover and Organize Time-Series Files @@ -95,42 +89,44 @@ # Examine the first time step to understand the data structure. # %% -# Debuggin +# Debugging first_file = alterra_series[0][1] -mesh_data = read_vtk_file(first_file, extract_surface=True) +mesh_info = ConvertVTKToUSD.inspect_file(first_file, extract_surface=True) print(f"\nFile: {first_file.name}") print("\nGeometry:") -print(f" Points: {len(mesh_data.points):,}") -print(f" Faces: {len(mesh_data.face_vertex_counts):,}") -print(f" Normals: {'Yes' if mesh_data.normals is not None else 'No'}") -print(f" Colors: {'Yes' if mesh_data.colors is not None else 'No'}") +print(f" Points: {mesh_info['points']:,}") +print(f" Faces: {mesh_info['faces']:,}") +print(f" Normals: {'Yes' if mesh_info['has_normals'] else 'No'}") +print(f" Colors: {'Yes' if mesh_info['has_colors'] else 'No'}") # Bounding box -bbox_min = np.min(mesh_data.points, axis=0) -bbox_max = np.max(mesh_data.points, axis=0) -bbox_size = bbox_max - bbox_min +bbox_min = mesh_info["bounds_min"] +bbox_max = mesh_info["bounds_max"] +bbox_size = mesh_info["bounds_size"] print("\nBounding Box:") print(f" Min: [{bbox_min[0]:.3f}, {bbox_min[1]:.3f}, {bbox_min[2]:.3f}]") print(f" Max: [{bbox_max[0]:.3f}, {bbox_max[1]:.3f}, {bbox_max[2]:.3f}]") print(f" Size: [{bbox_size[0]:.3f}, {bbox_size[1]:.3f}, {bbox_size[2]:.3f}]") -print(f"\nData Arrays ({len(mesh_data.generic_arrays)}):") -for i, array in enumerate(mesh_data.generic_arrays, 1): - print(f" {i}. {array.name}:") - print(f" - Type: {array.data_type.value}") - print(f" - Components: {array.num_components}") - print(f" - Interpolation: {array.interpolation}") - print(f" - Elements: {len(array.data):,}") - if array.data.size > 0: - print(f" - Range: [{np.min(array.data):.6f}, {np.max(array.data):.6f}]") +print(f"\nData Arrays ({len(mesh_info['arrays'])}):") +for i, array in enumerate(mesh_info["arrays"], 1): + print(f" {i}. {array['name']}:") + print(f" - Type: {array['data_type']}") + print(f" - Components: {array['num_components']}") + print(f" - Interpolation: {array['interpolation']}") + print(f" - Elements: {array['num_elements']:,}") + data_range = array["range"] + if data_range[0] is not None and data_range[1] is not None: + print(f" - Range: [{data_range[0]:.6f}, {data_range[1]:.6f}]") # Cell types (face vertex count = triangle, quad, etc.) -unique_counts, num_each = np.unique(mesh_data.face_vertex_counts, return_counts=True) print("\nCell types (faces by vertex count):") -for u, n in zip(unique_counts, num_each): - name = cell_type_name_for_vertex_count(int(u)) - print(f" {name} ({u} vertices): {n:,} faces") +for cell_type in mesh_info["cell_types"]: + print( + f" {cell_type['name']} ({cell_type['vertex_count']} vertices): " + f"{cell_type['num_faces']:,} faces" + ) # %% [markdown] # ## 3. Convert TPV25 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 4ecdb8b..64379a5 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 @@ -29,8 +29,6 @@ import re from pathlib import Path -import numpy as np - from physiomotion4d.notebook_utils import running_as_test # Import USDTools for post-processing colormap @@ -38,10 +36,6 @@ from physiomotion4d import ConvertVTKToUSD -# cell_type_name_for_vertex_count and read_vtk_file are internal APIs used for diagnostics -from physiomotion4d.vtk_to_usd import cell_type_name_for_vertex_count -from physiomotion4d.vtk_to_usd.vtk_reader import read_vtk_file - # %% [markdown] # ## 1. Discover and Organize Time-Series Files @@ -94,42 +88,44 @@ # Examine the first time step to understand the data structure. # %% -# Debuggin +# Debugging first_file = tpv25_series[0][1] -mesh_data = read_vtk_file(first_file, extract_surface=True) +mesh_info = ConvertVTKToUSD.inspect_file(first_file, extract_surface=True) print(f"\nFile: {first_file.name}") print("\nGeometry:") -print(f" Points: {len(mesh_data.points):,}") -print(f" Faces: {len(mesh_data.face_vertex_counts):,}") -print(f" Normals: {'Yes' if mesh_data.normals is not None else 'No'}") -print(f" Colors: {'Yes' if mesh_data.colors is not None else 'No'}") +print(f" Points: {mesh_info['points']:,}") +print(f" Faces: {mesh_info['faces']:,}") +print(f" Normals: {'Yes' if mesh_info['has_normals'] else 'No'}") +print(f" Colors: {'Yes' if mesh_info['has_colors'] else 'No'}") # Bounding box -bbox_min = np.min(mesh_data.points, axis=0) -bbox_max = np.max(mesh_data.points, axis=0) -bbox_size = bbox_max - bbox_min +bbox_min = mesh_info["bounds_min"] +bbox_max = mesh_info["bounds_max"] +bbox_size = mesh_info["bounds_size"] print("\nBounding Box:") print(f" Min: [{bbox_min[0]:.3f}, {bbox_min[1]:.3f}, {bbox_min[2]:.3f}]") print(f" Max: [{bbox_max[0]:.3f}, {bbox_max[1]:.3f}, {bbox_max[2]:.3f}]") print(f" Size: [{bbox_size[0]:.3f}, {bbox_size[1]:.3f}, {bbox_size[2]:.3f}]") -print(f"\nData Arrays ({len(mesh_data.generic_arrays)}):") -for i, array in enumerate(mesh_data.generic_arrays, 1): - print(f" {i}. {array.name}:") - print(f" - Type: {array.data_type.value}") - print(f" - Components: {array.num_components}") - print(f" - Interpolation: {array.interpolation}") - print(f" - Elements: {len(array.data):,}") - if array.data.size > 0: - print(f" - Range: [{np.min(array.data):.6f}, {np.max(array.data):.6f}]") +print(f"\nData Arrays ({len(mesh_info['arrays'])}):") +for i, array in enumerate(mesh_info["arrays"], 1): + print(f" {i}. {array['name']}:") + print(f" - Type: {array['data_type']}") + print(f" - Components: {array['num_components']}") + print(f" - Interpolation: {array['interpolation']}") + print(f" - Elements: {array['num_elements']:,}") + data_range = array["range"] + if data_range[0] is not None and data_range[1] is not None: + print(f" - Range: [{data_range[0]:.6f}, {data_range[1]:.6f}]") # Cell types (face vertex count = triangle, quad, etc.) -unique_counts, num_each = np.unique(mesh_data.face_vertex_counts, return_counts=True) print("\nCell types (faces by vertex count):") -for u, n in zip(unique_counts, num_each): - name = cell_type_name_for_vertex_count(int(u)) - print(f" {name} ({u} vertices): {n:,} faces") +for cell_type in mesh_info["cell_types"]: + print( + f" {cell_type['name']} ({cell_type['vertex_count']} vertices): " + f"{cell_type['num_faces']:,} faces" + ) # %% [markdown] # ## 3. Convert TPV25 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 c5862b8..cf6ce24 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 @@ -2,7 +2,7 @@ # %% [markdown] # # VTK to USD Converter Test Notebook # -# This notebook demonstrates the usage of the new `vtk_to_usd` library for converting VTK files to USD format. +# This notebook demonstrates ConvertVTKToUSD for converting VTK files to USD format. # # The library is based on the ParaViewConnector architecture from Omniverse but simplified for file-based conversion only. # @@ -32,9 +32,6 @@ from physiomotion4d import ContourTools, ConvertVTKToUSD -# read_vtk_file is internal API used here only for data inspection/diagnostics -from physiomotion4d.vtk_to_usd.vtk_reader import read_vtk_file - # Configure logging logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") @@ -86,49 +83,51 @@ # %% # Read and inspect the VTP file -mesh_data = read_vtk_file(vtp_file) +mesh_info = ConvertVTKToUSD.inspect_file(vtp_file) print("=" * 60) print("VTP File (average_surface.vtp) Contents:") print("=" * 60) print("\nGeometry:") -print(f" Points: {len(mesh_data.points)}") -print(f" Faces: {len(mesh_data.face_vertex_counts)}") -print(f" Normals: {'Yes' if mesh_data.normals is not None else 'No'}") -print(f" Colors: {'Yes' if mesh_data.colors is not None else 'No'}") - -print(f"\nData Arrays ({len(mesh_data.generic_arrays)}):") -for i, array in enumerate(mesh_data.generic_arrays, 1): - print(f" {i}. {array.name}:") - print(f" - Components: {array.num_components}") - print(f" - Type: {array.data_type.value}") - print(f" - Interpolation: {array.interpolation}") - print(f" - Shape: {array.data.shape}") - if array.data.size > 0: - print(f" - Range: [{np.min(array.data):.6f}, {np.max(array.data):.6f}]") +print(f" Points: {mesh_info['points']}") +print(f" Faces: {mesh_info['faces']}") +print(f" Normals: {'Yes' if mesh_info['has_normals'] else 'No'}") +print(f" Colors: {'Yes' if mesh_info['has_colors'] else 'No'}") + +print(f"\nData Arrays ({len(mesh_info['arrays'])}):") +for i, array in enumerate(mesh_info["arrays"], 1): + print(f" {i}. {array['name']}:") + print(f" - Components: {array['num_components']}") + print(f" - Type: {array['data_type']}") + print(f" - Interpolation: {array['interpolation']}") + print(f" - Shape: {array['shape']}") + data_range = array["range"] + if data_range[0] is not None and data_range[1] is not None: + print(f" - Range: [{data_range[0]:.6f}, {data_range[1]:.6f}]") # %% # Read and inspect the VTK file -mesh_data_vtk = read_vtk_file(vtk_file, extract_surface=True) +mesh_info_vtk = ConvertVTKToUSD.inspect_file(vtk_file, extract_surface=True) print("=" * 60) print("VTK File (average_mesh.vtk) Contents:") print("=" * 60) print("\nGeometry:") -print(f" Points: {len(mesh_data_vtk.points)}") -print(f" Faces: {len(mesh_data_vtk.face_vertex_counts)}") -print(f" Normals: {'Yes' if mesh_data_vtk.normals is not None else 'No'}") -print(f" Colors: {'Yes' if mesh_data_vtk.colors is not None else 'No'}") - -print(f"\nData Arrays ({len(mesh_data_vtk.generic_arrays)}):") -for i, array in enumerate(mesh_data_vtk.generic_arrays, 1): - print(f" {i}. {array.name}:") - print(f" - Components: {array.num_components}") - print(f" - Type: {array.data_type.value}") - print(f" - Interpolation: {array.interpolation}") - print(f" - Shape: {array.data.shape}") - if array.data.size > 0: - print(f" - Range: [{np.min(array.data):.6f}, {np.max(array.data):.6f}]") +print(f" Points: {mesh_info_vtk['points']}") +print(f" Faces: {mesh_info_vtk['faces']}") +print(f" Normals: {'Yes' if mesh_info_vtk['has_normals'] else 'No'}") +print(f" Colors: {'Yes' if mesh_info_vtk['has_colors'] else 'No'}") + +print(f"\nData Arrays ({len(mesh_info_vtk['arrays'])}):") +for i, array in enumerate(mesh_info_vtk["arrays"], 1): + print(f" {i}. {array['name']}:") + print(f" - Components: {array['num_components']}") + print(f" - Type: {array['data_type']}") + print(f" - Interpolation: {array['interpolation']}") + print(f" - Shape: {array['shape']}") + data_range = array["range"] + if data_range[0] is not None and data_range[1] is not None: + print(f" - Range: [{data_range[0]:.6f}, {data_range[1]:.6f}]") # %% [markdown] # ## 3. Advanced Conversion with Custom Settings @@ -367,7 +366,7 @@ def verify_usd_file(usd_path): # %% [markdown] # ## Conclusion # -# This notebook demonstrated the comprehensive features of the new `vtk_to_usd` library: +# This notebook demonstrated the comprehensive features of ConvertVTKToUSD: # # 1. **Simple Conversion**: One-line conversion of VTK files # 2. **Data Inspection**: Reading and analyzing VTK data arrays diff --git a/src/physiomotion4d/convert_vtk_to_usd.py b/src/physiomotion4d/convert_vtk_to_usd.py index bfe04bc..7e6cabb 100644 --- a/src/physiomotion4d/convert_vtk_to_usd.py +++ b/src/physiomotion4d/convert_vtk_to_usd.py @@ -14,7 +14,7 @@ import logging from collections.abc import Sequence from pathlib import Path -from typing import Any, Literal, Optional, cast +from typing import Any, Literal, Optional, Union, cast import numpy as np import pyvista as pv @@ -30,8 +30,11 @@ MaterialManager, MeshData, UsdMeshConverter, + cell_type_name_for_vertex_count, + read_vtk_file, split_mesh_data_by_cell_type, split_mesh_data_by_connectivity, + validate_time_series_topology, ) @@ -221,8 +224,6 @@ def from_files( # converted MeshData so _convert_unified() can reuse it without a second # round of _vtk_to_mesh_data() calls. if len(meshes) > 1 and not static_merge: - from .vtk_to_usd.vtk_reader import validate_time_series_topology - mesh_data_seq = [ instance._vtk_to_mesh_data(m, i) for i, m in enumerate(meshes) ] @@ -264,6 +265,73 @@ def supports_mesh_type(self, mesh: pv.DataSet | vtk.vtkDataSet) -> bool: ), ) + @classmethod + def inspect_file( + cls, + vtk_file: Union[Path, str], + *, + extract_surface: bool = True, + ) -> dict[str, Any]: + """Summarize a VTK file using the same low-level reader as conversion. + + This method is intended for experiments, workflows, and CLIs that need + diagnostics without importing the advanced vtk_to_usd subpackage + directly. + + Args: + vtk_file: Path to a VTK file (.vtk, .vtp, or .vtu). + extract_surface: If True, extract surfaces from volumetric meshes. + + Returns: + Dictionary containing geometry counts, bounds, data arrays, and + surface cell type counts. + """ + mesh_data = read_vtk_file(vtk_file, extract_surface=extract_surface) + bbox_min = np.min(mesh_data.points, axis=0) + bbox_max = np.max(mesh_data.points, axis=0) + + unique_counts, num_each = np.unique( + mesh_data.face_vertex_counts, return_counts=True + ) + + arrays = [] + for array in mesh_data.generic_arrays: + array_min = None + array_max = None + if array.data.size > 0: + array_min = float(np.min(array.data)) + array_max = float(np.max(array.data)) + arrays.append( + { + "name": array.name, + "data_type": array.data_type.value, + "num_components": array.num_components, + "interpolation": array.interpolation, + "num_elements": len(array.data), + "shape": tuple(array.data.shape), + "range": (array_min, array_max), + } + ) + + return { + "points": len(mesh_data.points), + "faces": len(mesh_data.face_vertex_counts), + "has_normals": mesh_data.normals is not None, + "has_colors": mesh_data.colors is not None, + "bounds_min": tuple(float(v) for v in bbox_min), + "bounds_max": tuple(float(v) for v in bbox_max), + "bounds_size": tuple(float(v) for v in bbox_max - bbox_min), + "arrays": arrays, + "cell_types": [ + { + "name": cell_type_name_for_vertex_count(int(count)), + "vertex_count": int(count), + "num_faces": int(total), + } + for count, total in zip(unique_counts, num_each, strict=False) + ], + } + def list_available_arrays(self) -> dict: """ List all point data arrays available across all time steps. diff --git a/src/physiomotion4d/vtk_to_usd/CLAUDE.md b/src/physiomotion4d/vtk_to_usd/CLAUDE.md index 3c59e8f..ce78ed2 100644 --- a/src/physiomotion4d/vtk_to_usd/CLAUDE.md +++ b/src/physiomotion4d/vtk_to_usd/CLAUDE.md @@ -1,61 +1,56 @@ -# CLAUDE.md — vtk_to_usd +# CLAUDE.md - vtk_to_usd -This subpackage is an **advanced internal library**. Understand this before touching it. +This subpackage is a public advanced low-level library. Understand this before +touching it. -## Preferred API +## Preferred In-Repo API -**Do not add calls to `vtk_to_usd` from outside this subpackage.** - -The correct entry point for all VTK→USD conversion in PhysioMotion4D is: +Do not add calls to `vtk_to_usd` from experiments, workflows, CLIs, or other +top-level PhysioMotion4D modules. The in-repository entry point for VTK-to-USD +conversion is: ```python from physiomotion4d.convert_vtk_to_usd import ConvertVTKToUSD ``` -`ConvertVTKToUSD` operates on in-memory PyVista objects, handles colormap overlays, -multi-label anatomy, and animated time series, and is the only API that external users -or other PhysioMotion4D modules should call. +External advanced users may import `physiomotion4d.vtk_to_usd` directly. + +## Role of This Subpackage -## Role of this subpackage +`vtk_to_usd/` is the workhorse used by `ConvertVTKToUSD`. It provides: -`vtk_to_usd/` is called internally by `ConvertVTKToUSD`. It provides: -- File-based VTK→USD conversion (reads `.vtk`, `.vtp`, `.vtu` from disk) +- `convert_vtk_file()` for single-file low-level conversion +- VTK readers for `.vtk`, `.vtp`, and `.vtu` - Low-level data structures: `MeshData`, `ConversionSettings`, `MaterialData` - USD primitive writing: normals, primvars, time samples, materials -Other PhysioMotion4D modules (workflow, segmentation, registration) should never -import directly from `physiomotion4d.vtk_to_usd`; they must go through -`ConvertVTKToUSD`. External library users may use the file-based API. - -## When to edit this subpackage - -Only edit code here when: -1. `ConvertVTKToUSD` cannot expose the needed behavior through its own API, **and** -2. The change is to the file-based conversion layer itself (readers, USD writers, data structures). +## When To Edit This Subpackage -Always check whether the fix belongs in `convert_vtk_to_usd.py` first. +Edit code here only when the change belongs in the file-based conversion layer +itself: readers, USD writers, data structures, coordinate transforms, or the +single-file facade. Otherwise, prefer `convert_vtk_to_usd.py`. -## Module responsibilities +## Module Responsibilities -| File | Responsibility | -|-----------------------|-----------------------------------------------------| -| `data_structures.py` | Data containers: `MeshData`, `MaterialData`, etc. | -| `vtk_reader.py` | Read `.vtk`, `.vtp`, `.vtu` files into `MeshData` | -| `usd_utils.py` | Coordinate conversion (RAS→Y-up), primvar helpers | -| `material_manager.py` | `UsdPreviewSurface` creation and binding | -| `usd_mesh_converter.py` | Write `MeshData` to a USD prim | -| `converter.py` | `VTKToUSDConverter` — high-level file-based API | +| File | Responsibility | +| --- | --- | +| `converter.py` | `convert_vtk_file()` single-file facade | +| `data_structures.py` | `MeshData`, `MaterialData`, etc. | +| `vtk_reader.py` | Read `.vtk`, `.vtp`, `.vtu` files into `MeshData` | +| `usd_utils.py` | Coordinate conversion and primvar helpers | +| `material_manager.py` | `UsdPreviewSurface` creation and binding | +| `usd_mesh_converter.py` | Write `MeshData` to a USD prim | +| `mesh_utils.py` | Mesh splitting helpers | -## Coordinate system +## Coordinate System -RAS→Y-up conversion: `USD(x, y, z) = RAS(x, z, -y)` +RAS-to-Y-up conversion: `USD(x, y, z) = RAS(x, z, -y) * 0.001`. -This conversion happens inside `usd_utils.ras_to_usd()` / `ras_points_to_usd()`. -It must not be applied more than once. If you add a code path that produces USD -geometry, verify the transform is applied exactly once. +This conversion happens inside `usd_utils.ras_to_usd()` and +`ras_points_to_usd()`. It must not be applied more than once. -## What not to do +## Testing Policy -- Do not expose new public symbols in `__init__.py` without a clear reason. -- Do not call `vtk_to_usd` internals from `workflow_*.py` or any other top-level module. -- Do not duplicate coordinate conversion logic outside `usd_utils.py`. +Tests should exercise this subpackage through `ConvertVTKToUSD`. Do not add +direct tests for `vtk_to_usd` internals unless the project explicitly changes +this policy. diff --git a/src/physiomotion4d/vtk_to_usd/README.md b/src/physiomotion4d/vtk_to_usd/README.md index 5bf2679..f192c42 100644 --- a/src/physiomotion4d/vtk_to_usd/README.md +++ b/src/physiomotion4d/vtk_to_usd/README.md @@ -1,314 +1,127 @@ -# VTK to USD Converter Library +# VTK to USD Advanced Library -A comprehensive Python library for converting VTK files (VTK, VTP, VTU) to USD (Universal Scene Description) format. Based on the architecture of NVIDIA's ParaViewConnector for Omniverse, but simplified for file-based conversion without ParaView or Qt dependencies. +`physiomotion4d.vtk_to_usd` is the public low-level VTK-to-USD conversion +layer used by `physiomotion4d.ConvertVTKToUSD`. -## Features - -### File Format Support -- **Legacy VTK** (`.vtk`): Binary and ASCII formats -- **XML PolyData** (`.vtp`): Surface meshes -- **XML UnstructuredGrid** (`.vtu`): Volumetric meshes (with surface extraction) +Repository workflows, experiments, and CLIs should use `ConvertVTKToUSD`. +Import this subpackage directly only when you need advanced file readers, data +containers, coordinate helpers, or USD writer primitives. -### Data Preservation -- **Geometry**: Points, faces, topology -- **Normals**: Automatic computation or preservation from source -- **Colors**: Vertex colors (RGB/RGBA) -- **Data Arrays**: All VTK point and cell data arrays converted to USD primvars -- **Time-Series**: Support for animated/time-varying data - -### USD Features -- **Materials**: UsdPreviewSurface with customizable properties -- **Primvars**: VTK data arrays → USD primvars with appropriate interpolation -- **Coordinate Systems**: Automatic conversion from RAS (medical imaging) to USD Y-up -- **Time Sampling**: Efficient time-varying attribute encoding +## Features -### Architecture +- File support: legacy VTK (`.vtk`), XML PolyData (`.vtp`), XML + UnstructuredGrid (`.vtu`) +- Geometry: points, faces, topology, normals, vertex colors +- Data arrays: VTK point and cell arrays as USD primvars +- Materials: `UsdPreviewSurface` materials +- Coordinates: RAS millimeter coordinates converted to USD Y-up meters -The library is organized into modular components inspired by ParaViewConnector: +## Modules -``` +```text vtk_to_usd/ -├── data_structures.py # Data containers (MeshData, MaterialData, etc.) -├── vtk_reader.py # VTK file readers (VTK, VTP, VTU) -├── usd_utils.py # USD utility functions (coordinate conversion, primvars) -├── material_manager.py # Material creation and binding -├── usd_mesh_converter.py # Mesh conversion to USD -├── converter.py # High-level API -└── __init__.py # Public exports -``` - -## Installation - -The library is part of the PhysioMotion4D package. Ensure you have the required dependencies: - -```bash -pip install vtk pxr numpy + converter.py # convert_vtk_file facade + data_structures.py # MeshData, MaterialData, ConversionSettings + vtk_reader.py # VTK file readers + usd_utils.py # coordinate conversion and primvar helpers + material_manager.py # UsdPreviewSurface creation and binding + usd_mesh_converter.py # MeshData to UsdGeom.Mesh writer + mesh_utils.py # splitting by connectivity or cell type ``` ## Quick Start -### Simple Conversion - ```python from physiomotion4d.vtk_to_usd import convert_vtk_file -# Convert a single file stage = convert_vtk_file('mesh.vtp', 'output.usd') ``` -### With Custom Settings +## Custom Settings ```python -from physiomotion4d.vtk_to_usd import VTKToUSDConverter, ConversionSettings, MaterialData +from physiomotion4d.vtk_to_usd import ( + ConversionSettings, + MaterialData, + convert_vtk_file, +) -# Configure conversion settings = ConversionSettings( triangulate_meshes=True, compute_normals=True, - preserve_point_arrays=True, - meters_per_unit=0.001, # mm to meters + meters_per_unit=1.0, + up_axis='Y', ) -# Define material material = MaterialData( - name="my_material", - diffuse_color=(0.8, 0.3, 0.3), + name='cardiac_tissue', + diffuse_color=(0.9, 0.3, 0.3), roughness=0.4, ) -# Convert -converter = VTKToUSDConverter(settings) -stage = converter.convert_file( - vtk_file='mesh.vtp', - output_usd='output.usd', +stage = convert_vtk_file( + 'heart.vtp', + 'heart.usd', + data_basename='Heart', + settings=settings, material=material, ) ``` -### Time-Series Data - -```python -from physiomotion4d.vtk_to_usd import VTKToUSDConverter +## Time Series -converter = VTKToUSDConverter() +Use the high-level package API for time series, colormaps, labels, and +application workflows: -# Convert sequence of VTK files -files = ['frame_0.vtp', 'frame_1.vtp', 'frame_2.vtp'] -time_codes = [0.0, 0.1, 0.2] # seconds +```python +from physiomotion4d import ConvertVTKToUSD -stage = converter.convert_sequence( - vtk_files=files, - output_usd='animated.usd', - time_codes=time_codes, -) +stage = ConvertVTKToUSD.from_files( + data_basename='AnimatedMesh', + vtk_files=['frame_0.vtp', 'frame_1.vtp', 'frame_2.vtp'], + time_codes=[0.0, 1.0, 2.0], +).convert('animated.usd') ``` -### Direct MeshData Conversion +## MeshData Inspection ```python -from physiomotion4d.vtk_to_usd import read_vtk_file, VTKToUSDConverter +from physiomotion4d.vtk_to_usd import read_vtk_file -# Read VTK file mesh_data = read_vtk_file('mesh.vtp') - -# Inspect data -print(f"Points: {len(mesh_data.points)}") -print(f"Faces: {len(mesh_data.face_vertex_counts)}") -print(f"Data arrays: {len(mesh_data.generic_arrays)}") - +print(len(mesh_data.points)) +print(len(mesh_data.face_vertex_counts)) for array in mesh_data.generic_arrays: - print(f" - {array.name}: {array.num_components} components, {array.data_type}") - -# Convert to USD -converter = VTKToUSDConverter() -stage = converter.convert_mesh_data(mesh_data, 'output.usd') -``` - -## API Reference - -### ConversionSettings - -Configuration for conversion process: - -```python -@dataclass -class ConversionSettings: - # Output settings - output_binary: bool = False # Binary or ASCII USD - meters_per_unit: float = 1.0 # Unit scale - up_axis: str = "Y" # "Y" or "Z" - - # Mesh processing - triangulate_meshes: bool = True # Convert all faces to triangles - compute_normals: bool = True # Compute normals if missing - preserve_point_arrays: bool = True # Keep point data arrays - preserve_cell_arrays: bool = True # Keep cell data arrays - - # Material settings - use_preview_surface: bool = True # Use UsdPreviewSurface - default_color: tuple = (0.8, 0.8, 0.8) - - # Time settings - times_per_second: float = 24.0 # FPS for animation - use_time_samples: bool = True # Use time sampling - - # Array prefixes - point_array_prefix: str = "vtk_point_" - cell_array_prefix: str = "vtk_cell_" -``` - -### MaterialData - -Material properties: - -```python -@dataclass -class MaterialData: - name: str = "default_material" - diffuse_color: tuple[float, float, float] = (0.8, 0.8, 0.8) - specular_color: tuple[float, float, float] = (0.0, 0.0, 0.0) - emissive_color: tuple[float, float, float] = (0.0, 0.0, 0.0) - opacity: float = 1.0 - roughness: float = 0.5 - metallic: float = 0.0 - ior: float = 1.5 - use_vertex_colors: bool = False -``` - -### MeshData - -Mesh geometry and data: - -```python -@dataclass -class MeshData: - points: NDArray # (N, 3) array - face_vertex_counts: NDArray # (F,) array - face_vertex_indices: NDArray # Flat array of indices - normals: Optional[NDArray] = None # (N, 3) or facevarying - uvs: Optional[NDArray] = None # (N, 2) texture coordinates - colors: Optional[NDArray] = None # (N, 3) or (N, 4) vertex colors - generic_arrays: list[GenericArray] = [] # Data arrays - material_id: str = "default_material" + print(array.name, array.num_components, array.data_type) ``` -### VTKToUSDConverter - -Main converter class: - -- `convert_file(vtk_file, output_usd, **kwargs)`: Convert single file -- `convert_sequence(vtk_files, output_usd, time_codes, **kwargs)`: Convert time series -- `convert_mesh_data(mesh_data, output_usd, **kwargs)`: Convert MeshData -- `convert_mesh_data_sequence(mesh_data_list, output_usd, **kwargs)`: Convert MeshData sequence - -## Data Array Handling - -VTK data arrays are automatically converted to USD primvars with appropriate types and interpolation: - -### Point Data → Vertex Primvars -- Interpolation: `vertex` -- Naming: `vtk_point_` -- Example: `vtk_point_pressure`, `vtk_point_temperature` - -### Cell Data → Uniform Primvars -- Interpolation: `uniform` (per-face) -- Naming: `vtk_cell_` -- Example: `vtk_cell_region_id` - -### Type Mapping - -| VTK Type | USD Type | Components | -| ------------ | ----------- | ---------- | -| Float/Double | FloatArray | 1 | -| Float/Double | Float2Array | 2 | -| Float/Double | Float3Array | 3 | -| Float/Double | Float4Array | 4 | -| Int/Long | IntArray | 1-4 | -| UInt | UIntArray | 1+ | -| UChar/Char | UCharArray | 1+ | - -## Coordinate System Conversion +## Public API -The library automatically converts from RAS (Right-Anterior-Superior) coordinate system used in medical imaging to USD's Y-up coordinate system: +- `convert_vtk_file()`: Convert one VTK file to one USD stage. +- `read_vtk_file()`: Read `.vtk`, `.vtp`, or `.vtu` into `MeshData`. +- `ConversionSettings`: Conversion settings for the low-level writer. +- `MaterialData`: USD material settings. +- `MeshData`, `GenericArray`, `DataType`: data containers used by the writer. +- `MaterialManager`, `UsdMeshConverter`: advanced USD authoring primitives. -**RAS (Medical Imaging):** -- X: Patient's right -- Y: Patient's anterior (front) -- Z: Patient's superior (head) +## Coordinate System -**USD Y-up:** -- X: Right -- Y: Up -- Z: Back (toward camera) +Input VTK coordinates are assumed to be RAS in millimeters: -**Conversion:** `USD(x, y, z) = RAS(x, z, -y)` +- X: patient right +- Y: patient anterior +- Z: patient superior -## Design Principles +USD output is Y-up in meters: -Based on ParaViewConnector but adapted for file-based conversion: - -1. **No Omniverse Dependencies**: Pure file-based USD output -2. **No ParaView/Qt**: Direct VTK API usage -3. **Modular Architecture**: Separate concerns (reading, conversion, materials) -4. **Data Preservation**: All VTK arrays preserved as primvars -5. **Standards Compliant**: Uses UsdPreviewSurface and standard USD schemas - -## Comparison with ParaViewConnector - -| Feature | ParaViewConnector | vtk_to_usd | -| ---------------- | ----------------------- | ----------------------- | -| **Input** | ParaView proxies | VTK files | -| **Output** | Omniverse/Files | Files only | -| **Dependencies** | ParaView, Qt, Omniverse | VTK, USD | -| **Use Case** | Interactive pipeline | Batch conversion | -| **Materials** | MDL + PreviewSurface | PreviewSurface | -| **Time Series** | Full clip system | Time-sampled attributes | -| **Volumes** | OpenVDB support | Surface extraction | - -## Examples - -See `experiments/convert_vtk_to_usd_lib/test_vtk_to_usd_converter.ipynb` for comprehensive examples including: - -1. Basic file conversion -2. Data array inspection -3. Custom materials and settings -4. Time-series animation -5. USD file verification - -## Testing - -Run the test notebook to verify the installation: - -```bash -cd experiments/convert_vtk_to_usd_lib -jupyter notebook test_vtk_to_usd_converter.ipynb +```text +USD(x, y, z) = RAS(x, z, -y) * 0.001 ``` -## Known Limitations - -1. **Volumetric Meshes**: VTU files are converted to surface meshes (via extract_surface) -2. **Complex Materials**: Only UsdPreviewSurface supported (no MDL) -3. **Topology Changes**: Time-varying topology requires separate prims per frame -4. **Large Datasets**: Memory-limited (entire mesh loaded at once) - -## Future Enhancements - -Potential improvements based on ParaViewConnector: - -- [ ] OpenVDB volume support -- [ ] Clip-based time management for varying topology -- [ ] MDL material support -- [ ] Texture coordinate generation -- [ ] Point cloud support (UsdGeomPoints) -- [ ] Curve/line support (UsdGeomBasisCurves) -- [ ] Streaming for large datasets - -## License - -Part of the PhysioMotion4D project. +The stage metadata is authored as `metersPerUnit=1.0` and `upAxis='Y'`. -## References +## Testing Policy -- ParaViewConnector: https://github.com/NVIDIA-Omniverse/ParaViewConnector -- USD Documentation: https://openusd.org/ -- VTK Documentation: https://vtk.org/ +Repository tests validate this library through `ConvertVTKToUSD`, the supported +application-level API. Avoid adding direct tests for `vtk_to_usd` internals unless +the project explicitly changes this policy. diff --git a/src/physiomotion4d/vtk_to_usd/__init__.py b/src/physiomotion4d/vtk_to_usd/__init__.py index a5b7f69..e2f88af 100644 --- a/src/physiomotion4d/vtk_to_usd/__init__.py +++ b/src/physiomotion4d/vtk_to_usd/__init__.py @@ -1,16 +1,18 @@ -"""Internal VTK-to-USD plumbing for PhysioMotion4D. +"""Public advanced VTK-to-USD conversion layer for PhysioMotion4D. -This subpackage is private. External code and all PhysioMotion4D modules must -use ConvertVTKToUSD from physiomotion4d.convert_vtk_to_usd; they must not -import from this package directly. +This subpackage is a stable low-level API for advanced external users. Code in +PhysioMotion4D experiments, workflows, and CLIs should use ConvertVTKToUSD from +physiomotion4d.convert_vtk_to_usd instead of importing this package directly. Provides: +- File facade: convert_vtk_file - Data containers: MeshData, ConversionSettings, MaterialData, etc. - VTK file readers (.vtk, .vtp, .vtu) - USD primitive writers: UsdMeshConverter, MaterialManager -- Coordinate helpers (RAS → Y-up) and mesh splitting utilities +- Coordinate helpers (RAS to Y-up) and mesh splitting utilities """ +from .converter import convert_vtk_file from .data_structures import ( ConversionSettings, DataType, @@ -46,6 +48,8 @@ ) __all__ = [ + # File facade + "convert_vtk_file", # Data structures "ConversionSettings", "DataType", diff --git a/src/physiomotion4d/vtk_to_usd/converter.py b/src/physiomotion4d/vtk_to_usd/converter.py new file mode 100644 index 0000000..acb3890 --- /dev/null +++ b/src/physiomotion4d/vtk_to_usd/converter.py @@ -0,0 +1,79 @@ +"""Public low-level file conversion facade for vtk_to_usd.""" + +from pathlib import Path +from typing import Optional, Union + +from pxr import Usd, UsdGeom + +from .data_structures import ConversionSettings, MaterialData +from .material_manager import MaterialManager +from .usd_mesh_converter import UsdMeshConverter +from .vtk_reader import read_vtk_file + + +def convert_vtk_file( + vtk_file: Union[str, Path], + output_usd_file: Union[str, Path], + *, + data_basename: Optional[str] = None, + mesh_name: str = "Mesh", + extract_surface: bool = True, + settings: Optional[ConversionSettings] = None, + material: Optional[MaterialData] = None, +) -> Usd.Stage: + """Convert one VTK file to one USD stage. + + This is the stable low-level facade for advanced users who want the + file-based vtk_to_usd conversion layer directly. In-repository workflows, + experiments, and CLIs should use :class:`physiomotion4d.ConvertVTKToUSD` + instead. + + Args: + vtk_file: Input VTK file path (.vtk, .vtp, or .vtu). + output_usd_file: Output USD file path. + data_basename: Root prim name under /World. Defaults to the input stem. + mesh_name: Mesh prim name under /World/{data_basename}. + extract_surface: If True, extract surfaces from volumetric VTK datasets. + settings: Optional conversion settings. Defaults to ConversionSettings(). + material: Optional material. Defaults to settings.default_color. + + Returns: + Created USD stage. + """ + input_path = Path(vtk_file) + output_path = Path(output_usd_file) + conversion_settings = settings or ConversionSettings() + root_name = data_basename or input_path.stem + + if output_path.exists(): + output_path.unlink() + + mesh_data = read_vtk_file(input_path, extract_surface=extract_surface) + + stage = Usd.Stage.CreateNew(str(output_path)) + UsdGeom.SetStageMetersPerUnit(stage, conversion_settings.meters_per_unit) + up_axis = ( + UsdGeom.Tokens.z + if conversion_settings.up_axis.upper() == "Z" + else UsdGeom.Tokens.y + ) + UsdGeom.SetStageUpAxis(stage, up_axis) + + world = UsdGeom.Xform.Define(stage, "/World") + stage.SetDefaultPrim(world.GetPrim()) + root_path = f"/World/{root_name}" + UsdGeom.Xform.Define(stage, root_path) + + material_mgr = MaterialManager(stage) + mat_data = material or MaterialData( + name=f"{mesh_name}_material", + diffuse_color=conversion_settings.default_color, + ) + material_mgr.get_or_create_material(mat_data) + mesh_data.material_id = mat_data.name + + mesh_converter = UsdMeshConverter(stage, conversion_settings, material_mgr) + mesh_converter.create_mesh(mesh_data, f"{root_path}/{mesh_name}") + + stage.Save() + return stage diff --git a/tests/test_vtk_to_usd_library.py b/tests/test_vtk_to_usd_library.py index c2172af..9861837 100644 --- a/tests/test_vtk_to_usd_library.py +++ b/tests/test_vtk_to_usd_library.py @@ -1,12 +1,10 @@ #!/usr/bin/env python """ -Tests for VTK-to-USD conversion via ConvertVTKToUSD. +Tests for VTK-to-USD conversion through ConvertVTKToUSD. -Covers: -- VTK file reading (VTP, VTK, VTU formats) via internal vtk_to_usd helpers -- ConvertVTKToUSD.from_files() — single file, time series, settings -- Material and primvar preservation -- Data structure validation (GenericArray, MeshData, etc.) +The low-level physiomotion4d.vtk_to_usd package is exercised only through +ConvertVTKToUSD here. It remains a public advanced API, but repository tests +should validate the supported application entry point. Note: Tests marked requires_data need manually downloaded data: - KCL-Heart-Model: data/KCL-Heart-Model/ @@ -14,7 +12,6 @@ """ from pathlib import Path -from unittest.mock import patch import numpy as np import pytest @@ -22,15 +19,8 @@ from pxr import UsdGeom, UsdShade from physiomotion4d import ConvertVTKToUSD -from physiomotion4d.vtk_to_usd import ( - DataType, - GenericArray, - MeshData, - read_vtk_file, -) -# Helper to get data paths def get_data_dir() -> Path: """Get the data directory path.""" tests_dir = Path(__file__).parent @@ -56,44 +46,25 @@ def get_or_create_average_surface(test_directories: dict[str, Path]) -> Path: """ Get or create average_surface.vtp from average_mesh.vtk. - This function extracts the surface from the volumetric mesh and caches it - in the test output directory for reuse across test runs. - Args: - test_directories: Dictionary with 'output' key pointing to test output directory + test_directories: Dictionary with 'output' key pointing to test output + directory. Returns: - Path to the average_surface.vtp file + Path to the average_surface.vtp file. """ output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) surface_file = output_dir / "average_surface.vtp" - - # If surface file already exists, return it if surface_file.exists(): - print(f"\nUsing cached surface file: {surface_file}") return surface_file - # Create surface from volumetric mesh data_dir = get_data_dir() / "KCL-Heart-Model" vtk_file = data_dir / "average_mesh.vtk" - - print(f"\nCreating surface from: {vtk_file}") - - # Load volumetric mesh vtk_mesh = pv.read(str(vtk_file)) - - # Extract surface surface = vtk_mesh.extract_surface(algorithm="dataset_surface") - - # Save to output directory surface.save(str(surface_file)) - - print(f"Created and saved surface: {surface_file}") - print(f" Points: {surface.n_points:,}") - print(f" Faces: {surface.n_faces_strict:,}") - return surface_file @@ -102,122 +73,17 @@ def kcl_average_surface(test_directories: dict[str, Path]) -> Path: """ Fixture providing the KCL average heart surface. - Generates average_surface.vtp from average_mesh.vtk if needed, - caching the result for subsequent test runs. - Returns: - Path to average_surface.vtp file + Path to average_surface.vtp file. """ if not check_kcl_heart_data(): - pytest.skip("KCL-Heart-Model data not available (must be manually downloaded)") + pytest.skip("KCL-Heart-Model data not available") return get_or_create_average_surface(test_directories) -class TestGenericArray: - """Test GenericArray data structure validation and reshaping.""" - - def test_scalar_1d_array(self) -> None: - """Test that 1D scalar arrays (num_components=1) are kept as-is.""" - data = np.array([1.0, 2.0, 3.0, 4.0]) - array = GenericArray( - name="test_scalar", - data=data, - num_components=1, - data_type=DataType.FLOAT, - ) - assert array.data.ndim == 1 - assert len(array.data) == 4 - np.testing.assert_array_equal(array.data, data) - - def test_flat_multicomponent_array_reshape(self) -> None: - """Test that flat 1D arrays with num_components>1 are reshaped to 2D.""" - # 12 values that should reshape to (4, 3) - data = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], dtype=float) - array = GenericArray( - name="test_vector", - data=data, - num_components=3, - data_type=DataType.FLOAT, - ) - assert array.data.ndim == 2 - assert array.data.shape == (4, 3) - # Verify data is preserved correctly - expected = data.reshape(-1, 3) - np.testing.assert_array_equal(array.data, expected) - - def test_2d_array_valid(self) -> None: - """Test that 2D arrays with correct shape are accepted.""" - data = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]], dtype=float) - array = GenericArray( - name="test_vector", - data=data, - num_components=3, - data_type=DataType.FLOAT, - ) - assert array.data.ndim == 2 - assert array.data.shape == (4, 3) - np.testing.assert_array_equal(array.data, data) - - def test_flat_array_not_divisible_raises_error(self) -> None: - """Test that flat arrays with length not divisible by num_components raise error.""" - data = np.array([1, 2, 3, 4, 5], dtype=float) # 5 values, not divisible by 3 - with pytest.raises(ValueError, match="not divisible by num_components"): - GenericArray( - name="test_invalid", - data=data, - num_components=3, - data_type=DataType.FLOAT, - ) - - def test_2d_array_wrong_shape_raises_error(self) -> None: - """Test that 2D arrays with wrong shape raise error.""" - data = np.array([[1, 2], [3, 4], [5, 6]], dtype=float) # Shape (3, 2) - with pytest.raises(ValueError, match="incompatible with num_components"): - GenericArray( - name="test_invalid", - data=data, - num_components=3, # Expects shape[1] == 3, got 2 - data_type=DataType.FLOAT, - ) - - def test_3d_array_raises_error(self) -> None: - """Test that 3D arrays are rejected.""" - data = np.ones((2, 3, 4), dtype=float) - with pytest.raises(ValueError, match="must be 1D or 2D"): - GenericArray( - name="test_invalid", - data=data, - num_components=3, - data_type=DataType.FLOAT, - ) - - def test_flat_array_large_components(self) -> None: - """Test reshaping with large num_components (e.g., 9 for 3x3 tensors).""" - # 18 values that should reshape to (2, 9) - data = np.arange(18, dtype=float) - array = GenericArray( - name="test_tensor", - data=data, - num_components=9, - data_type=DataType.FLOAT, - ) - assert array.data.ndim == 2 - assert array.data.shape == (2, 9) - np.testing.assert_array_equal(array.data, data.reshape(-1, 9)) - - class TestFromFilesValidation: - """Synthetic tests for ConvertVTKToUSD.from_files() — no real data required. - - Covers: - - Gap A: time_codes length and monotonicity validation - - Gap B: _cached_mesh_data population and reuse in _convert_unified() - """ - - # ------------------------------------------------------------------ - # Gap A — time_codes validation - # ------------------------------------------------------------------ + """Synthetic tests for ConvertVTKToUSD.from_files().""" def test_time_codes_length_mismatch_raises(self, tmp_path: Path) -> None: """from_files() must reject time_codes whose length != len(vtk_files).""" @@ -246,141 +112,90 @@ def test_time_codes_equal_consecutive_is_valid(self, tmp_path: Path) -> None: f1 = tmp_path / "f1.vtp" sphere.save(str(f0)) sphere.save(str(f1)) - converter = ConvertVTKToUSD.from_files("X", [f0, f1], time_codes=[1.0, 1.0]) - assert converter._time_codes == [1.0, 1.0] - - # ------------------------------------------------------------------ - # Gap B — topology cache population and reuse - # ------------------------------------------------------------------ - - def test_from_files_populates_cached_mesh_data(self, tmp_path: Path) -> None: - """from_files() with >1 frame must populate _cached_mesh_data.""" - plane = pv.Plane(i_resolution=2, j_resolution=2) - files = [] - for i in range(3): - p = tmp_path / f"p{i}.vtp" - plane.save(str(p)) - files.append(p) - converter = ConvertVTKToUSD.from_files("Plane", files) - assert converter._cached_mesh_data is not None - assert len(converter._cached_mesh_data) == 3 - assert all(isinstance(m, MeshData) for m in converter._cached_mesh_data) - - def test_from_files_cache_reused_in_convert(self, tmp_path: Path) -> None: - """_convert_unified() must not call _vtk_to_mesh_data() when cache is populated.""" - plane = pv.Plane(i_resolution=2, j_resolution=2) - files = [] - for i in range(3): - p = tmp_path / f"p{i}.vtp" - plane.save(str(p)) - files.append(p) - converter = ConvertVTKToUSD.from_files("Plane", files) - with patch.object( - converter, "_vtk_to_mesh_data", wraps=converter._vtk_to_mesh_data - ) as spy: - stage = converter.convert(str(tmp_path / "out.usd")) - assert spy.call_count == 0, ( - f"_vtk_to_mesh_data called {spy.call_count} time(s); cache should have been used" - ) - assert stage.GetPrimAtPath("/World/Plane/Mesh").IsValid() + stage = ConvertVTKToUSD.from_files( + "X", [f0, f1], time_codes=[1.0, 1.0] + ).convert(str(tmp_path / "out.usd")) + assert stage.GetPrimAtPath("/World/X/Mesh").IsValid() - def test_from_files_single_file_no_cache(self, tmp_path: Path) -> None: - """A single-file converter must not populate _cached_mesh_data.""" + def test_from_files_single_file_writes_static_mesh(self, tmp_path: Path) -> None: + """A single-file converter writes a static mesh with no time range.""" plane = pv.Plane(i_resolution=2, j_resolution=2) f0 = tmp_path / "p0.vtp" plane.save(str(f0)) - converter = ConvertVTKToUSD.from_files("P", [f0]) - assert converter._cached_mesh_data is None - def test_from_files_static_merge_no_cache(self, tmp_path: Path) -> None: - """static_merge=True must not populate _cached_mesh_data.""" + stage = ConvertVTKToUSD.from_files("P", [f0]).convert(str(tmp_path / "p.usd")) + + mesh = UsdGeom.Mesh(stage.GetPrimAtPath("/World/P/Mesh")) + assert len(mesh.GetPointsAttr().Get()) == plane.n_points + assert not stage.HasAuthoredTimeCodeRange() + + def test_from_files_static_merge_writes_separate_meshes( + self, tmp_path: Path + ) -> None: + """static_merge=True treats files as static objects, not time samples.""" plane = pv.Plane(i_resolution=2, j_resolution=2) f0 = tmp_path / "p0.vtp" f1 = tmp_path / "p1.vtp" plane.save(str(f0)) plane.save(str(f1)) - converter = ConvertVTKToUSD.from_files("P", [f0, f1], static_merge=True) - assert converter._cached_mesh_data is None - - -@pytest.mark.requires_data -class TestVTKReader: - """Test VTK file reading capabilities.""" - - def test_read_vtp_file(self, kcl_average_surface: Path) -> None: - """Test reading VTP (PolyData) files.""" - vtp_file = kcl_average_surface - - assert vtp_file.exists(), f"VTP file not found: {vtp_file}" - - # Read the file - mesh_data = read_vtk_file(vtp_file) - # Verify mesh data structure - assert mesh_data is not None - assert mesh_data.points is not None - assert len(mesh_data.points) > 0 - assert mesh_data.face_vertex_counts is not None - assert mesh_data.face_vertex_indices is not None - - print(f"\nRead VTP file: {vtp_file.name}") - print(f" Points: {len(mesh_data.points):,}") - print(f" Faces: {len(mesh_data.face_vertex_counts):,}") - print(f" Data arrays: {len(mesh_data.generic_arrays)}") - - def test_read_legacy_vtk_file(self) -> None: - """Test reading legacy VTK files.""" - if not check_kcl_heart_data(): - pytest.skip( - "KCL-Heart-Model data not available (must be manually downloaded)" - ) - - data_dir = get_data_dir() / "KCL-Heart-Model" - vtk_file = data_dir / "average_mesh.vtk" + stage = ConvertVTKToUSD.from_files("P", [f0, f1], static_merge=True).convert( + str(tmp_path / "static.usd") + ) - assert vtk_file.exists(), f"VTK file not found: {vtk_file}" + assert stage.GetPrimAtPath("/World/P/P_0").IsValid() + assert stage.GetPrimAtPath("/World/P/P_1").IsValid() + assert not stage.HasAuthoredTimeCodeRange() - # Read the file with surface extraction - mesh_data = read_vtk_file(vtk_file, extract_surface=True) - # Verify mesh data structure - assert mesh_data is not None - assert mesh_data.points is not None - assert len(mesh_data.points) > 0 - assert mesh_data.face_vertex_counts is not None - assert mesh_data.face_vertex_indices is not None +class TestSyntheticConversion: + """Synthetic ConvertVTKToUSD tests that do not require downloaded data.""" - print(f"\nRead legacy VTK file: {vtk_file.name}") - print(f" Points: {len(mesh_data.points):,}") - print(f" Faces: {len(mesh_data.face_vertex_counts):,}") - print(f" Data arrays: {len(mesh_data.generic_arrays)}") + def test_file_primvar_preservation(self, tmp_path: Path) -> None: + """Point arrays in a VTP file are preserved as USD primvars.""" + mesh = pv.Plane(i_resolution=2, j_resolution=2) + mesh.point_data["pressure"] = np.linspace( + 0.0, 1.0, mesh.n_points, dtype=np.float32 + ) + vtk_file = tmp_path / "plane.vtp" + mesh.save(str(vtk_file)) - def test_generic_arrays_preserved(self, kcl_average_surface: Path) -> None: - """Test that generic data arrays are preserved during reading.""" - vtp_file = kcl_average_surface + stage = ConvertVTKToUSD.from_files( + data_basename="Plane", + vtk_files=[vtk_file], + ).convert(str(tmp_path / "plane.usd")) - mesh_data = read_vtk_file(vtp_file) + mesh_prim = stage.GetPrimAtPath("/World/Plane/Mesh") + primvars_api = UsdGeom.PrimvarsAPI(mesh_prim) + primvar_names = [p.GetPrimvarName() for p in primvars_api.GetPrimvars()] + assert "vtk_point_pressure" in primvar_names - # Verify generic arrays - assert len(mesh_data.generic_arrays) > 0, "No data arrays found" + def test_time_series_conversion(self, tmp_path: Path) -> None: + """Multiple VTP files write point time samples and stage time metadata.""" + files = [] + for i in range(3): + mesh = pv.Plane(i_resolution=2, j_resolution=2) + mesh.points[:, 2] += float(i) + path = tmp_path / f"p{i}.vtp" + mesh.save(str(path)) + files.append(path) - # Check array structure - for array in mesh_data.generic_arrays: - assert array.name is not None - assert array.data is not None - assert array.num_components > 0 - assert array.interpolation in ["vertex", "uniform", "constant"] + time_codes = [0.0, 1.0, 2.0] + stage = ConvertVTKToUSD.from_files( + data_basename="Plane", + vtk_files=files, + time_codes=time_codes, + ).convert(str(tmp_path / "time.usd")) - print("\nGeneric arrays preserved:") - for array in mesh_data.generic_arrays: - print( - f" - {array.name}: {array.num_components} components, {len(array.data):,} values" - ) + mesh = UsdGeom.Mesh(stage.GetPrimAtPath("/World/Plane/Mesh")) + assert stage.GetStartTimeCode() == 0.0 + assert stage.GetEndTimeCode() == 2.0 + assert mesh.GetPointsAttr().GetTimeSamples() == time_codes @pytest.mark.requires_data class TestVTKToUSDConversion: - """Test VTK to USD conversion capabilities.""" + """Test ConvertVTKToUSD on optional real VTK data.""" def test_single_file_conversion( self, test_directories: dict[str, Path], kcl_average_surface: Path @@ -389,30 +204,17 @@ def test_single_file_conversion( output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) - vtp_file = kcl_average_surface output_usd = output_dir / "heart_surface.usd" - stage = ConvertVTKToUSD.from_files( data_basename="HeartSurface", - vtk_files=[vtp_file], + vtk_files=[kcl_average_surface], ).convert(str(output_usd)) assert output_usd.exists() - assert stage is not None - - # No split: mesh lives at /World/HeartSurface/Mesh mesh_prim = stage.GetPrimAtPath("/World/HeartSurface/Mesh") assert mesh_prim.IsValid() assert mesh_prim.IsA(UsdGeom.Mesh) - - mesh = UsdGeom.Mesh(mesh_prim) - points = mesh.GetPointsAttr().Get() - assert len(points) > 0 - - print("\nConverted single file to USD") - print(f" Input: {vtp_file.name}") - print(f" Output: {output_usd}") - print(f" Points: {len(points):,}") + assert len(UsdGeom.Mesh(mesh_prim).GetPointsAttr().Get()) > 0 def test_conversion_with_material( self, test_directories: dict[str, Path], kcl_average_surface: Path @@ -421,37 +223,27 @@ def test_conversion_with_material( output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) - vtp_file = kcl_average_surface output_usd = output_dir / "heart_with_material.usd" - stage = ConvertVTKToUSD.from_files( data_basename="HeartSurface", - vtk_files=[vtp_file], + vtk_files=[kcl_average_surface], solid_color=(0.9, 0.3, 0.3), ).convert(str(output_usd)) - # Material for the no-split case is named "Mesh_material" material_prim = stage.GetPrimAtPath("/World/Looks/Mesh_material") assert material_prim.IsValid() assert material_prim.IsA(UsdShade.Material) - # Verify material is bound to the mesh prim mesh_prim = stage.GetPrimAtPath("/World/HeartSurface/Mesh") binding_api = UsdShade.MaterialBindingAPI(mesh_prim) bound_material = binding_api.ComputeBoundMaterial()[0] assert bound_material.GetPrim().IsValid() - # Verify the shader's diffuseColor input carries the requested solid color shader_prim = stage.GetPrimAtPath("/World/Looks/Mesh_material/PreviewSurface") - assert shader_prim.IsValid() shader = UsdShade.Shader(shader_prim) diffuse_value = shader.GetInput("diffuseColor").Get() - assert diffuse_value is not None assert tuple(diffuse_value) == pytest.approx((0.9, 0.3, 0.3), abs=1e-5) - print("\nConverted with custom solid color material") - print(" Material path: /World/Looks/Mesh_material") - def test_conversion_settings( self, test_directories: dict[str, Path], kcl_average_surface: Path ) -> None: @@ -459,99 +251,14 @@ def test_conversion_settings( output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) - vtp_file = kcl_average_surface - output_usd = output_dir / "heart_custom_settings.usd" - stage = ConvertVTKToUSD.from_files( data_basename="Mesh", - vtk_files=[vtp_file], - ).convert(str(output_usd)) + vtk_files=[kcl_average_surface], + ).convert(str(output_dir / "heart_custom_settings.usd")) - # ConvertVTKToUSD always uses meters_per_unit=1.0 and Y-up assert UsdGeom.GetStageMetersPerUnit(stage) == 1.0 assert UsdGeom.GetStageUpAxis(stage) == UsdGeom.Tokens.y - print("\nVerified stage metadata defaults") - print(f" Meters per unit: {UsdGeom.GetStageMetersPerUnit(stage)}") - print(f" Up axis: {UsdGeom.GetStageUpAxis(stage)}") - - def test_primvar_preservation( - self, test_directories: dict[str, Path], kcl_average_surface: Path - ) -> None: - """Test that VTK data arrays are preserved as USD primvars.""" - output_dir = test_directories["output"] / "vtk_to_usd_library" - output_dir.mkdir(parents=True, exist_ok=True) - - vtp_file = kcl_average_surface - - # Read source to count arrays - mesh_data = read_vtk_file(vtp_file) - array_names = [arr.name for arr in mesh_data.generic_arrays] - - output_usd = output_dir / "heart_with_primvars.usd" - - stage = ConvertVTKToUSD.from_files( - data_basename="Mesh", - vtk_files=[vtp_file], - ).convert(str(output_usd)) - - # No split: mesh at /World/Mesh/Mesh - mesh_prim = stage.GetPrimAtPath("/World/Mesh/Mesh") - primvars_api = UsdGeom.PrimvarsAPI(mesh_prim) - primvars = primvars_api.GetPrimvars() - - primvar_names = [pv.GetPrimvarName() for pv in primvars] - assert len(primvar_names) > 0 - - print("\nPrimvars preserved:") - print(f" Source arrays: {len(array_names)}") - print(f" USD primvars: {len(primvar_names)}") - for name in primvar_names[:5]: - print(f" - {name}") - - -@pytest.mark.requires_data -class TestTimeSeriesConversion: - """Test time-series conversion capabilities.""" - - def test_time_series_conversion( - self, test_directories: dict[str, Path], kcl_average_surface: Path - ) -> None: - """Test converting multiple VTK files as a time series.""" - output_dir = test_directories["output"] / "vtk_to_usd_library" - output_dir.mkdir(parents=True, exist_ok=True) - - vtp_file = kcl_average_surface - - # Use the same file three times to simulate a time series - vtk_files = [vtp_file] * 3 - time_codes = [0.0, 1.0, 2.0] - - output_usd = output_dir / "heart_time_series.usd" - - stage = ConvertVTKToUSD.from_files( - data_basename="Mesh", - vtk_files=vtk_files, - time_codes=time_codes, - ).convert(str(output_usd)) - - assert stage.GetStartTimeCode() == 0.0 - assert stage.GetEndTimeCode() == 2.0 - - # No split: mesh at /World/Mesh/Mesh - mesh_prim = stage.GetPrimAtPath("/World/Mesh/Mesh") - mesh = UsdGeom.Mesh(mesh_prim) - time_samples = mesh.GetPointsAttr().GetTimeSamples() - assert len(time_samples) == 3 - assert time_samples == time_codes - - print("\nConverted time series") - print(f" Frames: {len(vtk_files)}") - print(f" Time codes: {time_codes}") - print( - f" Stage time range: {stage.GetStartTimeCode()} - {stage.GetEndTimeCode()}" - ) - @pytest.mark.slow class TestIntegration: @@ -564,48 +271,27 @@ def test_end_to_end_conversion( output_dir = test_directories["output"] / "vtk_to_usd_library" output_dir.mkdir(parents=True, exist_ok=True) - vtp_file = kcl_average_surface output_usd = output_dir / "heart_complete.usd" - stage = ConvertVTKToUSD.from_files( data_basename="CardiacModel", - vtk_files=[vtp_file], + vtk_files=[kcl_average_surface], solid_color=(0.85, 0.2, 0.2), times_per_second=24.0, ).convert(str(output_usd)) - assert output_usd.exists() - assert stage is not None - - # No split: mesh at /World/CardiacModel/Mesh mesh_prim = stage.GetPrimAtPath("/World/CardiacModel/Mesh") + assert output_usd.exists() assert mesh_prim.IsValid() - - mesh = UsdGeom.Mesh(mesh_prim) - points = mesh.GetPointsAttr().Get() - assert len(points) > 0 - - # Material is auto-named "Mesh_material" - material_prim = stage.GetPrimAtPath("/World/Looks/Mesh_material") - assert material_prim.IsValid() - - primvars_api = UsdGeom.PrimvarsAPI(mesh_prim) - primvars = primvars_api.GetPrimvars() - assert len(primvars) > 0 - - print("\nEnd-to-end conversion complete") - print(f" Output: {output_usd}") - print(f" Size: {output_usd.stat().st_size / 1024:.1f} KB") - print(f" Points: {len(points):,}") - print(f" Primvars: {len(primvars)}") + assert len(UsdGeom.Mesh(mesh_prim).GetPointsAttr().Get()) > 0 + assert stage.GetPrimAtPath("/World/Looks/Mesh_material").IsValid() + assert UsdGeom.PrimvarsAPI(mesh_prim).GetPrimvars() class TestUnitScaling: """Verify that VTK mm coordinates are converted to USD meter coordinates.""" def test_mm_to_m_point_scaling(self, tmp_path: Path) -> None: - """Points written to USD must be 0.001× their original mm values.""" - # Sphere with radius=100 mm — vertices should be near ±100 in VTK. + """Points written to USD must be 0.001x their original mm values.""" mesh = pv.Sphere(radius=100.0) output_usd = tmp_path / "sphere.usd" @@ -615,28 +301,17 @@ def test_mm_to_m_point_scaling(self, tmp_path: Path) -> None: ).convert(str(output_usd)) mesh_prim = stage.GetPrimAtPath("/World/Sphere/Mesh") - assert mesh_prim.IsValid(), "Mesh prim not found at expected path" - usd_mesh = UsdGeom.Mesh(mesh_prim) usd_points = usd_mesh.GetPointsAttr().Get() assert usd_points is not None and len(usd_points) > 0 coords = np.array(usd_points) max_coord = float(np.abs(coords).max()) - - # In meters a 100 mm sphere has vertices ≤ 0.1 m (plus floating-point headroom). - assert max_coord < 0.15, ( - f"Max coordinate {max_coord:.4f} is not in meters. " - "Expected < 0.15 m for a 100 mm radius sphere; " - "got a value that looks like millimeters." - ) - # Sanity-check it's not collapsed to near zero (e.g., double-scaling). - assert max_coord > 0.05, ( - f"Max coordinate {max_coord:.6f} is unexpectedly small." - ) + assert max_coord < 0.15 + assert max_coord > 0.05 def test_normals_remain_unit_length(self, tmp_path: Path) -> None: - """Normal vectors must not be scaled — they should remain unit length.""" + """Normal vectors must not be scaled.""" mesh = pv.Sphere(radius=100.0) mesh.compute_normals(inplace=True) output_usd = tmp_path / "sphere_normals.usd" @@ -647,22 +322,16 @@ def test_normals_remain_unit_length(self, tmp_path: Path) -> None: ).convert(str(output_usd)) mesh_prim = stage.GetPrimAtPath("/World/Sphere/Mesh") - usd_mesh = UsdGeom.Mesh(mesh_prim) - normals_attr = usd_mesh.GetNormalsAttr() - + normals_attr = UsdGeom.Mesh(mesh_prim).GetNormalsAttr() if normals_attr is None or normals_attr.Get() is None: pytest.skip("No normals on this mesh") normals = np.array(normals_attr.Get()) norms = np.linalg.norm(normals, axis=1) - # Every normal should be ≈ 1.0 (unit vector), not 0.001. - assert np.allclose(norms, 1.0, atol=1e-3), ( - f"Normals are not unit length after conversion. " - f"Mean norm: {norms.mean():.6f}, expected ≈ 1.0" - ) + assert np.allclose(norms, 1.0, atol=1e-3) def test_stage_meters_per_unit(self, tmp_path: Path) -> None: - """Stage metersPerUnit metadata must be 1.0 (coordinates stored in meters).""" + """Stage metersPerUnit metadata must be 1.0.""" mesh = pv.Sphere(radius=100.0) output_usd = tmp_path / "sphere_meta.usd" From 442fd5702ebb2cb6fb70e6ecd03b4a3692e74b80 Mon Sep 17 00:00:00 2001 From: Stephen Aylward Date: Wed, 6 May 2026 14:52:46 -0400 Subject: [PATCH 2/3] ENH: Clarify VTK-to-USD API boundary and add file facade Add vtk_to_usd.convert_vtk_file() as the stable advanced low-level single-file conversion facade while keeping ConvertVTKToUSD as the in-repo API for experiments, workflows, and tests. Add ConvertVTKToUSD.inspect_file() for public diagnostics, update experiments to use it, refactor tests to exercise conversion through ConvertVTKToUSD, and refresh docs/API map to remove stale converter APIs. Handle empty mesh inspection without raising. --- docs/API_MAP.md | 48 +++++++++++---------- src/physiomotion4d/convert_vtk_to_usd.py | 10 ++++- src/physiomotion4d/vtk_to_usd/vtk_reader.py | 14 +++++- tests/test_vtk_to_usd_library.py | 36 ++++++++++++++++ 4 files changed, 82 insertions(+), 26 deletions(-) diff --git a/docs/API_MAP.md b/docs/API_MAP.md index 33fdf46..8624fee 100644 --- a/docs/API_MAP.md +++ b/docs/API_MAP.md @@ -120,9 +120,9 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def from_files(cls, data_basename, vtk_files, *, extract_surface=True, separate_by='none', times_per_second=24.0, solid_color=(0.8, 0.8, 0.8), time_codes=None, static_merge=False, mask_ids=None, log_level=logging.INFO)` (line 142): Create a converter by loading VTK files from disk. - `def supports_mesh_type(self, mesh)` (line 240): Check if mesh type is supported for conversion. - `def inspect_file(cls, vtk_file, *, extract_surface=True)` (line 269): Summarize a VTK file using the same low-level reader as conversion. - - `def list_available_arrays(self)` (line 335): List all point data arrays available across all time steps. - - `def set_colormap(self, color_by_array=None, colormap='plasma', intensity_range=None)` (line 381): Configure colormap for visualization. - - `def convert(self, output_usd_file, convert_to_surface=None, compute_normals=None)` (line 415): Convert VTK meshes to USD. + - `def list_available_arrays(self)` (line 341): List all point data arrays available across all time steps. + - `def set_colormap(self, color_by_array=None, colormap='plasma', intensity_range=None)` (line 387): Configure colormap for visualization. + - `def convert(self, output_usd_file, convert_to_surface=None, compute_normals=None)` (line 421): Convert VTK meshes to USD. ## src/physiomotion4d/image_tools.py @@ -395,14 +395,14 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ ## src/physiomotion4d/vtk_to_usd/vtk_reader.py - **class VTKReader** (line 20): Base class for VTK file readers. -- **class PolyDataReader** (line 222): Reader for VTK PolyData files (.vtp). - - `def read(filename)` (line 226): Read a VTP file and return MeshData. -- **class LegacyVTKReader** (line 282): Reader for legacy VTK files (.vtk). - - `def read(filename, extract_surface=True)` (line 294): Read a legacy VTK file and return MeshData. -- **class UnstructuredGridReader** (line 455): Reader for VTK UnstructuredGrid files (.vtu). - - `def read(filename, extract_surface=True)` (line 459): Read a VTU file and return MeshData. -- `def read_vtk_file(filename, extract_surface=True)` (line 568): Auto-detect VTK file format and read appropriately. -- `def validate_time_series_topology(mesh_data_sequence, filenames=None)` (line 596): Validate topology consistency across a time series of meshes. +- **class PolyDataReader** (line 234): Reader for VTK PolyData files (.vtp). + - `def read(filename)` (line 238): Read a VTP file and return MeshData. +- **class LegacyVTKReader** (line 294): Reader for legacy VTK files (.vtk). + - `def read(filename, extract_surface=True)` (line 306): Read a legacy VTK file and return MeshData. +- **class UnstructuredGridReader** (line 467): Reader for VTK UnstructuredGrid files (.vtu). + - `def read(filename, extract_surface=True)` (line 471): Read a VTU file and return MeshData. +- `def read_vtk_file(filename, extract_surface=True)` (line 580): Auto-detect VTK file format and read appropriately. +- `def validate_time_series_topology(mesh_data_sequence, filenames=None)` (line 608): Validate topology consistency across a time series of meshes. ## src/physiomotion4d/workflow_convert_ct_to_vtk.py @@ -724,18 +724,20 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def test_from_files_single_file_writes_static_mesh(self, tmp_path)` (line 120): A single-file converter writes a static mesh with no time range. - `def test_from_files_static_merge_writes_separate_meshes(self, tmp_path)` (line 132): static_merge=True treats files as static objects, not time samples. - **class TestSyntheticConversion** (line 151): Synthetic ConvertVTKToUSD tests that do not require downloaded data. - - `def test_file_primvar_preservation(self, tmp_path)` (line 154): Point arrays in a VTP file are preserved as USD primvars. - - `def test_time_series_conversion(self, tmp_path)` (line 173): Multiple VTP files write point time samples and stage time metadata. -- **class TestVTKToUSDConversion** (line 197): Test ConvertVTKToUSD on optional real VTK data. - - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 200): Test converting a single VTK file to USD. - - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 219): Test conversion with a custom solid color material. - - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 247): Test that ConvertVTKToUSD applies correct default stage metadata. -- **class TestIntegration** (line 264): Integration tests combining multiple features. - - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 267): Test complete conversion workflow with all features. -- **class TestUnitScaling** (line 290): Verify that VTK mm coordinates are converted to USD meter coordinates. - - `def test_mm_to_m_point_scaling(self, tmp_path)` (line 293): Points written to USD must be 0.001x their original mm values. - - `def test_normals_remain_unit_length(self, tmp_path)` (line 313): Normal vectors must not be scaled. - - `def test_stage_meters_per_unit(self, tmp_path)` (line 333): Stage metersPerUnit metadata must be 1.0. + - `def test_inspect_file_reports_public_summary(self, tmp_path)` (line 154): inspect_file() reports geometry, bounds, arrays, and cell types. + - `def test_inspect_file_reports_empty_mesh(self, tmp_path)` (line 174): inspect_file() reports empty meshes without raising. + - `def test_file_primvar_preservation(self, tmp_path)` (line 190): Point arrays in a VTP file are preserved as USD primvars. + - `def test_time_series_conversion(self, tmp_path)` (line 209): Multiple VTP files write point time samples and stage time metadata. +- **class TestVTKToUSDConversion** (line 233): Test ConvertVTKToUSD on optional real VTK data. + - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 236): Test converting a single VTK file to USD. + - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 255): Test conversion with a custom solid color material. + - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 283): Test that ConvertVTKToUSD applies correct default stage metadata. +- **class TestIntegration** (line 300): Integration tests combining multiple features. + - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 303): Test complete conversion workflow with all features. +- **class TestUnitScaling** (line 326): Verify that VTK mm coordinates are converted to USD meter coordinates. + - `def test_mm_to_m_point_scaling(self, tmp_path)` (line 329): Points written to USD must be 0.001x their original mm values. + - `def test_normals_remain_unit_length(self, tmp_path)` (line 349): Normal vectors must not be scaled. + - `def test_stage_meters_per_unit(self, tmp_path)` (line 369): Stage metersPerUnit metadata must be 1.0. ## utils/claude_github_reviews.py diff --git a/src/physiomotion4d/convert_vtk_to_usd.py b/src/physiomotion4d/convert_vtk_to_usd.py index 7e6cabb..259c52c 100644 --- a/src/physiomotion4d/convert_vtk_to_usd.py +++ b/src/physiomotion4d/convert_vtk_to_usd.py @@ -287,8 +287,13 @@ def inspect_file( surface cell type counts. """ mesh_data = read_vtk_file(vtk_file, extract_surface=extract_surface) - bbox_min = np.min(mesh_data.points, axis=0) - bbox_max = np.max(mesh_data.points, axis=0) + is_empty = len(mesh_data.points) == 0 + if is_empty: + bbox_min = np.zeros(3, dtype=np.float64) + bbox_max = np.zeros(3, dtype=np.float64) + else: + bbox_min = np.min(mesh_data.points, axis=0) + bbox_max = np.max(mesh_data.points, axis=0) unique_counts, num_each = np.unique( mesh_data.face_vertex_counts, return_counts=True @@ -314,6 +319,7 @@ def inspect_file( ) return { + "is_empty": is_empty, "points": len(mesh_data.points), "faces": len(mesh_data.face_vertex_counts), "has_normals": mesh_data.normals is not None, diff --git a/src/physiomotion4d/vtk_to_usd/vtk_reader.py b/src/physiomotion4d/vtk_to_usd/vtk_reader.py index ead97db..dcc89e2 100644 --- a/src/physiomotion4d/vtk_to_usd/vtk_reader.py +++ b/src/physiomotion4d/vtk_to_usd/vtk_reader.py @@ -142,8 +142,20 @@ def _extract_geometry_from_polydata(polydata: vtk.vtkPolyData) -> tuple: """Extract points, face counts, and face indices from vtkPolyData.""" # Get points vtk_points = polydata.GetPoints() + if vtk_points is None: + return ( + np.empty((0, 3), dtype=np.float64), + np.empty((0,), dtype=np.int32), + np.empty((0,), dtype=np.int32), + ) num_points = vtk_points.GetNumberOfPoints() - points = np.array([vtk_points.GetPoint(i) for i in range(num_points)]) + if num_points == 0: + points = np.empty((0, 3), dtype=np.float64) + else: + points = np.array( + [vtk_points.GetPoint(i) for i in range(num_points)], + dtype=np.float64, + ) # Get cells (faces) polys = polydata.GetPolys() diff --git a/tests/test_vtk_to_usd_library.py b/tests/test_vtk_to_usd_library.py index 9861837..2c908fc 100644 --- a/tests/test_vtk_to_usd_library.py +++ b/tests/test_vtk_to_usd_library.py @@ -151,6 +151,42 @@ def test_from_files_static_merge_writes_separate_meshes( class TestSyntheticConversion: """Synthetic ConvertVTKToUSD tests that do not require downloaded data.""" + def test_inspect_file_reports_public_summary(self, tmp_path: Path) -> None: + """inspect_file() reports geometry, bounds, arrays, and cell types.""" + mesh = pv.Plane(i_resolution=2, j_resolution=2) + mesh.point_data["pressure"] = np.linspace( + 0.0, 1.0, mesh.n_points, dtype=np.float32 + ) + vtk_file = tmp_path / "inspect_plane.vtp" + mesh.save(str(vtk_file)) + + summary = ConvertVTKToUSD.inspect_file(vtk_file) + + assert summary["is_empty"] is False + assert summary["points"] == mesh.n_points + assert summary["faces"] > 0 + assert len(summary["bounds_min"]) == 3 + assert len(summary["bounds_max"]) == 3 + assert len(summary["bounds_size"]) == 3 + assert summary["cell_types"] + assert any(array["name"] == "pressure" for array in summary["arrays"]) + + def test_inspect_file_reports_empty_mesh(self, tmp_path: Path) -> None: + """inspect_file() reports empty meshes without raising.""" + vtk_file = tmp_path / "empty.vtp" + pv.PolyData().save(str(vtk_file)) + + summary = ConvertVTKToUSD.inspect_file(vtk_file) + + assert summary["is_empty"] is True + assert summary["points"] == 0 + assert summary["faces"] == 0 + assert summary["bounds_min"] == (0.0, 0.0, 0.0) + assert summary["bounds_max"] == (0.0, 0.0, 0.0) + assert summary["bounds_size"] == (0.0, 0.0, 0.0) + assert summary["arrays"] == [] + assert summary["cell_types"] == [] + def test_file_primvar_preservation(self, tmp_path: Path) -> None: """Point arrays in a VTP file are preserved as USD primvars.""" mesh = pv.Plane(i_resolution=2, j_resolution=2) From b0da21c06a6675815736ed61c026a48976d0bee4 Mon Sep 17 00:00:00 2001 From: Stephen Aylward Date: Wed, 6 May 2026 15:12:08 -0400 Subject: [PATCH 3/3] DOCS: consolidate VTK-to-USD API documentation Remove duplicate ConvertVTKToUSD API pages, clean stale navigation links, clarify VTK-to-USD scaling and primvar documentation, and remove an unused Valve4D test helper. --- README.md | 2 +- docs/API_MAP.md | 47 ++++++++++++------------- docs/api/index.rst | 2 -- docs/api/usd/polymesh.rst | 20 ----------- docs/api/usd/tetmesh.rst | 21 ----------- docs/api/usd/vtk_conversion.rst | 2 +- docs/api/utilities/index.rst | 2 +- src/physiomotion4d/vtk_to_usd/README.md | 4 ++- tests/test_vtk_to_usd_library.py | 7 ---- 9 files changed, 29 insertions(+), 78 deletions(-) delete mode 100644 docs/api/usd/polymesh.rst delete mode 100644 docs/api/usd/tetmesh.rst diff --git a/README.md b/README.md index 5546bed..09e5e3c 100644 --- a/README.md +++ b/README.md @@ -352,7 +352,7 @@ stage = convert_vtk_file('mesh.vtp', 'output.usd') settings = ConversionSettings( triangulate_meshes=True, compute_normals=True, - meters_per_unit=1.0, # coordinates are authored in meters + meters_per_unit=1.0, # USD stage units after built-in mm-to-m scaling times_per_second=60.0, ) diff --git a/docs/API_MAP.md b/docs/API_MAP.md index 8624fee..58bfad0 100644 --- a/docs/API_MAP.md +++ b/docs/API_MAP.md @@ -714,30 +714,29 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._ - `def get_data_dir()` (line 24): Get the data directory path. - `def check_kcl_heart_data()` (line 31): Check if KCL Heart Model data is available. -- `def check_valve4d_data()` (line 38): Check if CHOP Valve4D data is available. -- `def get_or_create_average_surface(test_directories)` (line 45): Get or create average_surface.vtp from average_mesh.vtk. -- `def kcl_average_surface(test_directories)` (line 72): Fixture providing the KCL average heart surface. -- **class TestFromFilesValidation** (line 85): Synthetic tests for ConvertVTKToUSD.from_files(). - - `def test_time_codes_length_mismatch_raises(self, tmp_path)` (line 88): from_files() must reject time_codes whose length != len(vtk_files). - - `def test_time_codes_non_monotone_raises(self, tmp_path)` (line 98): from_files() must reject time_codes that decrease between frames. - - `def test_time_codes_equal_consecutive_is_valid(self, tmp_path)` (line 108): Equal consecutive time codes are non-decreasing and must not raise. - - `def test_from_files_single_file_writes_static_mesh(self, tmp_path)` (line 120): A single-file converter writes a static mesh with no time range. - - `def test_from_files_static_merge_writes_separate_meshes(self, tmp_path)` (line 132): static_merge=True treats files as static objects, not time samples. -- **class TestSyntheticConversion** (line 151): Synthetic ConvertVTKToUSD tests that do not require downloaded data. - - `def test_inspect_file_reports_public_summary(self, tmp_path)` (line 154): inspect_file() reports geometry, bounds, arrays, and cell types. - - `def test_inspect_file_reports_empty_mesh(self, tmp_path)` (line 174): inspect_file() reports empty meshes without raising. - - `def test_file_primvar_preservation(self, tmp_path)` (line 190): Point arrays in a VTP file are preserved as USD primvars. - - `def test_time_series_conversion(self, tmp_path)` (line 209): Multiple VTP files write point time samples and stage time metadata. -- **class TestVTKToUSDConversion** (line 233): Test ConvertVTKToUSD on optional real VTK data. - - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 236): Test converting a single VTK file to USD. - - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 255): Test conversion with a custom solid color material. - - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 283): Test that ConvertVTKToUSD applies correct default stage metadata. -- **class TestIntegration** (line 300): Integration tests combining multiple features. - - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 303): Test complete conversion workflow with all features. -- **class TestUnitScaling** (line 326): Verify that VTK mm coordinates are converted to USD meter coordinates. - - `def test_mm_to_m_point_scaling(self, tmp_path)` (line 329): Points written to USD must be 0.001x their original mm values. - - `def test_normals_remain_unit_length(self, tmp_path)` (line 349): Normal vectors must not be scaled. - - `def test_stage_meters_per_unit(self, tmp_path)` (line 369): Stage metersPerUnit metadata must be 1.0. +- `def get_or_create_average_surface(test_directories)` (line 38): Get or create average_surface.vtp from average_mesh.vtk. +- `def kcl_average_surface(test_directories)` (line 65): Fixture providing the KCL average heart surface. +- **class TestFromFilesValidation** (line 78): Synthetic tests for ConvertVTKToUSD.from_files(). + - `def test_time_codes_length_mismatch_raises(self, tmp_path)` (line 81): from_files() must reject time_codes whose length != len(vtk_files). + - `def test_time_codes_non_monotone_raises(self, tmp_path)` (line 91): from_files() must reject time_codes that decrease between frames. + - `def test_time_codes_equal_consecutive_is_valid(self, tmp_path)` (line 101): Equal consecutive time codes are non-decreasing and must not raise. + - `def test_from_files_single_file_writes_static_mesh(self, tmp_path)` (line 113): A single-file converter writes a static mesh with no time range. + - `def test_from_files_static_merge_writes_separate_meshes(self, tmp_path)` (line 125): static_merge=True treats files as static objects, not time samples. +- **class TestSyntheticConversion** (line 144): Synthetic ConvertVTKToUSD tests that do not require downloaded data. + - `def test_inspect_file_reports_public_summary(self, tmp_path)` (line 147): inspect_file() reports geometry, bounds, arrays, and cell types. + - `def test_inspect_file_reports_empty_mesh(self, tmp_path)` (line 167): inspect_file() reports empty meshes without raising. + - `def test_file_primvar_preservation(self, tmp_path)` (line 183): Point arrays in a VTP file are preserved as USD primvars. + - `def test_time_series_conversion(self, tmp_path)` (line 202): Multiple VTP files write point time samples and stage time metadata. +- **class TestVTKToUSDConversion** (line 226): Test ConvertVTKToUSD on optional real VTK data. + - `def test_single_file_conversion(self, test_directories, kcl_average_surface)` (line 229): Test converting a single VTK file to USD. + - `def test_conversion_with_material(self, test_directories, kcl_average_surface)` (line 248): Test conversion with a custom solid color material. + - `def test_conversion_settings(self, test_directories, kcl_average_surface)` (line 276): Test that ConvertVTKToUSD applies correct default stage metadata. +- **class TestIntegration** (line 293): Integration tests combining multiple features. + - `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 296): Test complete conversion workflow with all features. +- **class TestUnitScaling** (line 319): Verify that VTK mm coordinates are converted to USD meter coordinates. + - `def test_mm_to_m_point_scaling(self, tmp_path)` (line 322): Points written to USD must be 0.001x their original mm values. + - `def test_normals_remain_unit_length(self, tmp_path)` (line 342): Normal vectors must not be scaled. + - `def test_stage_meters_per_unit(self, tmp_path)` (line 362): Stage metersPerUnit metadata must be 1.0. ## utils/claude_github_reviews.py diff --git a/docs/api/index.rst b/docs/api/index.rst index dd70f8e..00bbf18 100644 --- a/docs/api/index.rst +++ b/docs/api/index.rst @@ -49,8 +49,6 @@ This section provides detailed documentation for all PhysioMotion4D classes, fun usd/tools usd/anatomy_tools usd/vtk_conversion - usd/polymesh - usd/tetmesh .. toctree:: :maxdepth: 2 diff --git a/docs/api/usd/polymesh.rst b/docs/api/usd/polymesh.rst deleted file mode 100644 index 47b8bbe..0000000 --- a/docs/api/usd/polymesh.rst +++ /dev/null @@ -1,20 +0,0 @@ -==================================== -Surface Mesh USD Generation -==================================== - -.. currentmodule:: physiomotion4d - -Surface VTK meshes are converted with :class:`ConvertVTKToUSD`. - -Class Reference -=============== - -.. autoclass:: ConvertVTKToUSD - :members: - :undoc-members: - :show-inheritance: - :inherited-members: - -.. rubric:: Navigation - -:doc:`vtk_conversion` | :doc:`index` diff --git a/docs/api/usd/tetmesh.rst b/docs/api/usd/tetmesh.rst deleted file mode 100644 index cf217c5..0000000 --- a/docs/api/usd/tetmesh.rst +++ /dev/null @@ -1,21 +0,0 @@ -==================================== -TetMesh USD Generation -==================================== - -.. currentmodule:: physiomotion4d - -Volumetric VTK meshes are converted to USD surfaces with -:class:`ConvertVTKToUSD` using surface extraction. - -Class Reference -=============== - -.. autoclass:: ConvertVTKToUSD - :members: - :undoc-members: - :show-inheritance: - :inherited-members: - -.. rubric:: Navigation - -:doc:`vtk_conversion` | :doc:`index` | :doc:`../utilities/index` diff --git a/docs/api/usd/vtk_conversion.rst b/docs/api/usd/vtk_conversion.rst index f2ba3cf..d2d59f4 100644 --- a/docs/api/usd/vtk_conversion.rst +++ b/docs/api/usd/vtk_conversion.rst @@ -17,4 +17,4 @@ Class Reference .. rubric:: Navigation -:doc:`anatomy_tools` | :doc:`index` | :doc:`polymesh` +:doc:`anatomy_tools` | :doc:`index` diff --git a/docs/api/utilities/index.rst b/docs/api/utilities/index.rst index ca24cdf..481084b 100644 --- a/docs/api/utilities/index.rst +++ b/docs/api/utilities/index.rst @@ -44,4 +44,4 @@ See Also .. rubric:: Navigation -:doc:`../usd/tetmesh` | :doc:`../index` | :doc:`image_tools` +:doc:`../usd/vtk_conversion` | :doc:`../index` | :doc:`image_tools` diff --git a/src/physiomotion4d/vtk_to_usd/README.md b/src/physiomotion4d/vtk_to_usd/README.md index f192c42..ad9ff0f 100644 --- a/src/physiomotion4d/vtk_to_usd/README.md +++ b/src/physiomotion4d/vtk_to_usd/README.md @@ -12,7 +12,9 @@ containers, coordinate helpers, or USD writer primitives. - File support: legacy VTK (`.vtk`), XML PolyData (`.vtp`), XML UnstructuredGrid (`.vtu`) - Geometry: points, faces, topology, normals, vertex colors -- Data arrays: VTK point and cell arrays as USD primvars +- Data arrays: VTK point arrays as USD primvars; cell-array primvars are + limited by surface topology and controlled by + `ConversionSettings.preserve_cell_arrays` - Materials: `UsdPreviewSurface` materials - Coordinates: RAS millimeter coordinates converted to USD Y-up meters diff --git a/tests/test_vtk_to_usd_library.py b/tests/test_vtk_to_usd_library.py index 2c908fc..0c9268d 100644 --- a/tests/test_vtk_to_usd_library.py +++ b/tests/test_vtk_to_usd_library.py @@ -35,13 +35,6 @@ def check_kcl_heart_data() -> bool: return vtk_file.exists() -def check_valve4d_data() -> bool: - """Check if CHOP Valve4D data is available.""" - data_dir = get_data_dir() / "CHOP-Valve4D" - alterra_dir = data_dir / "Alterra" - return alterra_dir.exists() and any(alterra_dir.glob("*.vtk")) - - def get_or_create_average_surface(test_directories: dict[str, Path]) -> Path: """ Get or create average_surface.vtp from average_mesh.vtk.