Skip to content

Commit 54d1d5e

Browse files
aylwardclaude
andauthored
fix: scale VTK mm coordinates to meters when exporting to OpenUSD (#45)
* fix: scale VTK mm coordinates to meters when exporting to OpenUSD VTK/medical imaging uses millimeters; OpenUSD stages declaring metersPerUnit=1.0 require meter-scale geometry. Previously ras_points_to_usd() performed only a RAS→Y-up axis swap, writing raw mm values into USD while the stage metadata claimed they were meters — making a 100 mm structure appear as 100 meters in viewers. Changes: - ras_to_usd() and ras_points_to_usd(): apply * 0.001 (mm → m) during the axis-swap so all point coordinates in USD are in meters. - ras_normals_to_usd(): decoupled from ras_points_to_usd(); performs axis swap only with no unit scaling (normals are unit direction vectors). - usd_tools.py merge_usd_files / merge_usd_files_flattened: fix inconsistent metersPerUnit from 0.01 (centimeters) to 1.0 (meters) to match converter output. - save_usd_file_arrangement: update grid spacing from 400.0 mm to 0.4 m. - Add TestUnitScaling tests verifying point scaling, normal length preservation, and stage metersPerUnit metadata. Breaking change: all generated USD files will have coordinates 1000× smaller than before (meter scale instead of millimeter scale). https://claude.ai/code/session_01L8zowmsyVqXNkZaQivxgc8 * FIX:Fix USD meter scaling and default geometry samples Author default values for time-sampled mesh points, extents, and normals so Omniverse/default-time readers can load single-frame and mixed static/animated USD content correctly. Allocate float arrays during RAS-to-USD coordinate conversion to avoid truncating meter-scaled integer inputs, and update USD merge documentation to match metersPerUnit=1.0. * DOC: Minor doc update * COMP: Eliminate copying large array that isn't needed. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7523ff3 commit 54d1d5e

5 files changed

Lines changed: 136 additions & 34 deletions

File tree

docs/API_MAP.md

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -373,19 +373,19 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._
373373
- **class UsdMeshConverter** (line 25): Converts MeshData to UsdGeomMesh with full feature support.
374374
- `def __init__(self, stage, settings, material_mgr)` (line 36): Initialize mesh converter.
375375
- `def create_mesh(self, mesh_data, mesh_path, time_code=None, bind_material=True)` (line 53): Create a UsdGeomMesh from MeshData.
376-
- `def create_time_varying_mesh(self, mesh_data_sequence, mesh_path, time_codes, bind_material=True)` (line 282): Create a mesh with time-varying attributes.
376+
- `def create_time_varying_mesh(self, mesh_data_sequence, mesh_path, time_codes, bind_material=True)` (line 288): Create a mesh with time-varying attributes.
377377

378378
## src/physiomotion4d/vtk_to_usd/usd_utils.py
379379

380380
- `def ras_to_usd(point)` (line 18): Convert RAS (Right-Anterior-Superior) coordinates to USD's right-handed Y-up system.
381-
- `def ras_points_to_usd(points)` (line 45): Convert array of RAS points to USD coordinates.
382-
- `def ras_normals_to_usd(normals)` (line 67): Convert array of RAS normals to USD coordinates.
383-
- `def numpy_to_vt_array(array, data_type)` (line 81): Convert numpy array to appropriate VtArray type.
384-
- `def get_sdf_value_type(data_type, num_components)` (line 153): Get appropriate SDF value type for primvar creation.
385-
- `def sanitize_primvar_name(name)` (line 200): Sanitize a name to be USD-compliant.
386-
- `def create_primvar(geom, array, array_name_prefix='', time_code=None)` (line 235): Create a USD primvar from a GenericArray.
387-
- `def triangulate_face(face_counts, face_indices)` (line 349): Triangulate polygonal faces.
388-
- `def compute_mesh_extent(points)` (line 389): Compute bounding box extent for a mesh.
381+
- `def ras_points_to_usd(points)` (line 53): Convert array of RAS points (mm) to USD coordinates (m).
382+
- `def ras_normals_to_usd(normals)` (line 76): Convert array of RAS normals to USD Y-up coordinates.
383+
- `def numpy_to_vt_array(array, data_type)` (line 99): Convert numpy array to appropriate VtArray type.
384+
- `def get_sdf_value_type(data_type, num_components)` (line 171): Get appropriate SDF value type for primvar creation.
385+
- `def sanitize_primvar_name(name)` (line 218): Sanitize a name to be USD-compliant.
386+
- `def create_primvar(geom, array, array_name_prefix='', time_code=None)` (line 253): Create a USD primvar from a GenericArray.
387+
- `def triangulate_face(face_counts, face_indices)` (line 367): Triangulate polygonal faces.
388+
- `def compute_mesh_extent(points)` (line 407): Compute bounding box extent for a mesh.
389389

390390
## src/physiomotion4d/vtk_to_usd/vtk_reader.py
391391

@@ -741,6 +741,10 @@ _Re-run `py utils/generate_api_map.py` whenever public APIs change._
741741
- `def test_time_series_conversion(self, test_directories, kcl_average_surface)` (line 517): Test converting multiple VTK files as a time series.
742742
- **class TestIntegration** (line 557): Integration tests combining multiple features.
743743
- `def test_end_to_end_conversion(self, test_directories, kcl_average_surface)` (line 560): Test complete conversion workflow with all features.
744+
- **class TestUnitScaling** (line 603): Verify that VTK mm coordinates are converted to USD meter coordinates.
745+
- `def test_mm_to_m_point_scaling(self, tmp_path)` (line 606): Points written to USD must be 0.001× their original mm values.
746+
- `def test_normals_remain_unit_length(self, tmp_path)` (line 638): Normal vectors must not be scaled — they should remain unit length.
747+
- `def test_stage_meters_per_unit(self, tmp_path)` (line 664): Stage metersPerUnit metadata must be 1.0 (coordinates stored in meters).
744748

745749
## utils/claude_github_reviews.py
746750

src/physiomotion4d/usd_tools.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,8 @@ def save_usd_file_arrangement(
170170
n_rows = int(np.floor(np.sqrt(n_objects)))
171171
n_cols = int(np.ceil(n_objects / n_rows))
172172
self.log_info("Grid layout: %d rows x %d cols", n_rows, n_cols)
173-
x_spacing = 400.0
174-
y_spacing = 400.0
173+
x_spacing = 0.4
174+
y_spacing = 0.4
175175
x_offset = -x_spacing * (n_cols - 1) / 2
176176
y_offset = -y_spacing * (n_rows - 1) / 2
177177

@@ -273,8 +273,8 @@ def merge_usd_files(
273273
coordinate systems
274274
275275
Note:
276-
The merged file uses meters as the base unit (0.01 scale factor)
277-
and Y-up axis orientation, which are standard for Omniverse.
276+
The merged file stores coordinates in meters (metersPerUnit=1.0)
277+
with upAxis="Y", which are standard for Omniverse.
278278
Time-varying data (animations) are preserved across all time samples.
279279
280280
Example:
@@ -285,7 +285,7 @@ def merge_usd_files(
285285
"""
286286
# Create new stage with meters as units (standard USD configuration)
287287
stage = Usd.Stage.CreateNew(output_filename)
288-
stage.SetMetadata("metersPerUnit", 0.01)
288+
stage.SetMetadata("metersPerUnit", 1.0)
289289
stage.SetMetadata("upAxis", "Y")
290290

291291
# Define root prim for organization
@@ -478,7 +478,7 @@ def merge_usd_files_flattened(
478478
temp_stage = Usd.Stage.CreateInMemory()
479479

480480
# Set standard metadata (meters and Y-up for Omniverse)
481-
temp_stage.SetMetadata("metersPerUnit", 0.01)
481+
temp_stage.SetMetadata("metersPerUnit", 1.0)
482482
temp_stage.SetMetadata("upAxis", "Y")
483483

484484
# Define root prim for organization

src/physiomotion4d/vtk_to_usd/usd_mesh_converter.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ def create_mesh(
9797
# Set points (time-varying if time_code provided)
9898
points_attr = mesh.CreatePointsAttr()
9999
if time_code is not None:
100+
if points_attr.Get() is None:
101+
points_attr.Set(usd_points)
100102
points_attr.Set(usd_points, time_code)
101103
else:
102104
points_attr.Set(usd_points)
@@ -105,6 +107,8 @@ def create_mesh(
105107
extent = compute_mesh_extent(usd_points)
106108
extent_attr = mesh.CreateExtentAttr()
107109
if time_code is not None:
110+
if extent_attr.Get() is None:
111+
extent_attr.Set(extent)
108112
extent_attr.Set(extent, time_code)
109113
else:
110114
extent_attr.Set(extent)
@@ -120,6 +124,8 @@ def create_mesh(
120124
normals_attr = mesh.CreateNormalsAttr()
121125
normals_attr.SetMetadata("interpolation", UsdGeom.Tokens.vertex)
122126
if time_code is not None:
127+
if normals_attr.Get() is None:
128+
normals_attr.Set(usd_normals)
123129
normals_attr.Set(usd_normals, time_code)
124130
else:
125131
normals_attr.Set(usd_normals)

src/physiomotion4d/vtk_to_usd/usd_utils.py

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,54 +28,72 @@ def ras_to_usd(point: NDArray | tuple | list) -> Gf.Vec3f:
2828
- Y: up
2929
- Z: back (toward camera)
3030
31-
Conversion: USD(x, y, z) = RAS(x, z, -y)
31+
Conversion: USD(x, y, z) = RAS(x, z, -y) * 0.001 (mm → m)
3232
3333
Args:
34-
point: Point in RAS coordinates [x, y, z]
34+
point: Point in RAS coordinates [x, y, z] in millimeters
3535
3636
Returns:
37-
Gf.Vec3f: Point in USD coordinates
37+
Gf.Vec3f: Point in USD coordinates in meters
3838
"""
3939
if isinstance(point, (tuple, list)):
40-
return Gf.Vec3f(float(point[0]), float(point[2]), float(-point[1]))
40+
return Gf.Vec3f(
41+
float(point[0]) * 0.001,
42+
float(point[2]) * 0.001,
43+
float(-point[1]) * 0.001,
44+
)
4145
else:
42-
return Gf.Vec3f(float(point[0]), float(point[2]), float(-point[1]))
46+
return Gf.Vec3f(
47+
float(point[0]) * 0.001,
48+
float(point[2]) * 0.001,
49+
float(-point[1]) * 0.001,
50+
)
4351

4452

4553
def ras_points_to_usd(points: NDArray) -> Vt.Vec3fArray:
46-
"""Convert array of RAS points to USD coordinates.
54+
"""Convert array of RAS points (mm) to USD coordinates (m).
55+
56+
Applies axis swap RAS → Y-up and scales millimeters to meters (* 0.001).
4757
4858
Args:
49-
points: Array of points with shape (N, 3)
59+
points: Array of points with shape (N, 3) in millimeters
5060
5161
Returns:
52-
Vt.Vec3fArray: Points in USD coordinates
62+
Vt.Vec3fArray: Points in USD Y-up coordinates in meters
5363
"""
5464
if points.shape[1] != 3:
5565
raise ValueError(f"Points must have shape (N, 3), got {points.shape}")
5666

57-
# Vectorized conversion: USD(x, y, z) = RAS(x, z, -y)
58-
usd_points = np.empty_like(points)
59-
usd_points[:, 0] = points[:, 0] # X stays the same
60-
usd_points[:, 1] = points[:, 2] # Y = Z
61-
usd_points[:, 2] = -points[:, 1] # Z = -Y
67+
# Vectorized: USD(x, y, z) = RAS(x, z, -y) * 0.001 (mm → m)
68+
usd_points = np.empty(points.shape, dtype=np.float32)
69+
usd_points[:, 0] = points[:, 0] * 0.001
70+
usd_points[:, 1] = points[:, 2] * 0.001
71+
usd_points[:, 2] = -points[:, 1] * 0.001
6272

63-
# Convert to USD Vec3fArray
64-
return Vt.Vec3fArray.FromNumpy(usd_points.astype(np.float32))
73+
return Vt.Vec3fArray.FromNumpy(usd_points)
6574

6675

6776
def ras_normals_to_usd(normals: NDArray) -> Vt.Vec3fArray:
68-
"""Convert array of RAS normals to USD coordinates.
77+
"""Convert array of RAS normals to USD Y-up coordinates.
6978
70-
Same transformation as points since normals are vectors.
79+
Applies only the axis swap — normals are unit direction vectors and must
80+
not be scaled by the mm→m factor.
7181
7282
Args:
7383
normals: Array of normals with shape (N, 3)
7484
7585
Returns:
76-
Vt.Vec3fArray: Normals in USD coordinates
86+
Vt.Vec3fArray: Normals in USD Y-up coordinates (unit length preserved)
7787
"""
78-
return ras_points_to_usd(normals)
88+
if normals.shape[1] != 3:
89+
raise ValueError(f"Normals must have shape (N, 3), got {normals.shape}")
90+
91+
usd_normals = np.empty(normals.shape, dtype=np.float32)
92+
usd_normals[:, 0] = normals[:, 0]
93+
usd_normals[:, 1] = normals[:, 2]
94+
usd_normals[:, 2] = -normals[:, 1]
95+
96+
return Vt.Vec3fArray.FromNumpy(usd_normals)
7997

8098

8199
def numpy_to_vt_array(array: NDArray, data_type: DataType) -> Any:

tests/test_vtk_to_usd_library.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -598,3 +598,77 @@ def test_end_to_end_conversion(
598598
print(f" Size: {output_usd.stat().st_size / 1024:.1f} KB")
599599
print(f" Points: {len(points):,}")
600600
print(f" Primvars: {len(primvars)}")
601+
602+
603+
class TestUnitScaling:
604+
"""Verify that VTK mm coordinates are converted to USD meter coordinates."""
605+
606+
def test_mm_to_m_point_scaling(self, tmp_path: Path) -> None:
607+
"""Points written to USD must be 0.001× their original mm values."""
608+
# Sphere with radius=100 mm — vertices should be near ±100 in VTK.
609+
mesh = pv.Sphere(radius=100.0)
610+
output_usd = tmp_path / "sphere.usd"
611+
612+
stage = ConvertVTKToUSD(
613+
data_basename="Sphere",
614+
input_polydata=[mesh],
615+
).convert(str(output_usd))
616+
617+
mesh_prim = stage.GetPrimAtPath("/World/Sphere/Mesh")
618+
assert mesh_prim.IsValid(), "Mesh prim not found at expected path"
619+
620+
usd_mesh = UsdGeom.Mesh(mesh_prim)
621+
usd_points = usd_mesh.GetPointsAttr().Get()
622+
assert usd_points is not None and len(usd_points) > 0
623+
624+
coords = np.array(usd_points)
625+
max_coord = float(np.abs(coords).max())
626+
627+
# In meters a 100 mm sphere has vertices ≤ 0.1 m (plus floating-point headroom).
628+
assert max_coord < 0.15, (
629+
f"Max coordinate {max_coord:.4f} is not in meters. "
630+
"Expected < 0.15 m for a 100 mm radius sphere; "
631+
"got a value that looks like millimeters."
632+
)
633+
# Sanity-check it's not collapsed to near zero (e.g., double-scaling).
634+
assert max_coord > 0.05, (
635+
f"Max coordinate {max_coord:.6f} is unexpectedly small."
636+
)
637+
638+
def test_normals_remain_unit_length(self, tmp_path: Path) -> None:
639+
"""Normal vectors must not be scaled — they should remain unit length."""
640+
mesh = pv.Sphere(radius=100.0)
641+
mesh.compute_normals(inplace=True)
642+
output_usd = tmp_path / "sphere_normals.usd"
643+
644+
stage = ConvertVTKToUSD(
645+
data_basename="Sphere",
646+
input_polydata=[mesh],
647+
).convert(str(output_usd))
648+
649+
mesh_prim = stage.GetPrimAtPath("/World/Sphere/Mesh")
650+
usd_mesh = UsdGeom.Mesh(mesh_prim)
651+
normals_attr = usd_mesh.GetNormalsAttr()
652+
653+
if normals_attr is None or normals_attr.Get() is None:
654+
pytest.skip("No normals on this mesh")
655+
656+
normals = np.array(normals_attr.Get())
657+
norms = np.linalg.norm(normals, axis=1)
658+
# Every normal should be ≈ 1.0 (unit vector), not 0.001.
659+
assert np.allclose(norms, 1.0, atol=1e-3), (
660+
f"Normals are not unit length after conversion. "
661+
f"Mean norm: {norms.mean():.6f}, expected ≈ 1.0"
662+
)
663+
664+
def test_stage_meters_per_unit(self, tmp_path: Path) -> None:
665+
"""Stage metersPerUnit metadata must be 1.0 (coordinates stored in meters)."""
666+
mesh = pv.Sphere(radius=100.0)
667+
output_usd = tmp_path / "sphere_meta.usd"
668+
669+
stage = ConvertVTKToUSD(
670+
data_basename="Sphere",
671+
input_polydata=[mesh],
672+
).convert(str(output_usd))
673+
674+
assert UsdGeom.GetStageMetersPerUnit(stage) == 1.0

0 commit comments

Comments
 (0)