Exercise: Asset Validation and Testing#

OpenUSD is incredibly powerful and flexible. As you develop your data exchange implementation, you can test it against different test assets and also use validators like usdchecker to make sure that your implementation is authoring valid and compliant OpenUSD data. In this exercise, we will use usdchecker to find any issues with an asset output by our obj2usd converter and improve our code accordingly.

  1. Let’s get a fresh conversion from obj2usd to validate. Execute the script by running the following in the terminal.

Windows:

python .\data_exchange\obj2usd.py .\data_exchange\shapes.obj

Linux:

python ./data_exchange/obj2usd.py ./data_exchange/shapes.obj
  1. Now, let’s run usdchecker to see what issues we find with our asset.

Windows:

.\scripts\usdchecker.bat .\data_exchange\shapes.usda

Linux:

./scripts/usdchecker.sh ./data_exchange/shapes.usda

usdchecker reports three errors for our asset, all related to stage metadata:

Our asset is missing metadata specifying the upAxis and units type for linear units. We should fix these now in our converter, as this was an oversight in the extraction phase. The missing defaultPrim metadata is better handled during the transformation phase, but it’s good that usdchecker is flagging it for us.

  1. Open obj2usd.py.

  2. Let’s set the upAxis and metersPerUnit stage metadata to fix the errors we encountered. Copy and paste this code in extract() after we define stage.

1UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.y)
2# Assume linear units as meters.
3UsdGeom.SetStageMetersPerUnit(stage, UsdGeom.LinearUnits.meters)

The OBJ specification states that the Y axis is the upAxis in OBJ files, so we can map that directly to OpenUSD. For linear units, OBJ is unitless. For our converter, we’ll choose meters as a sensible default. Note that the API for this geometric stage metadata is found in UsdGeom, not Usd.Stage where you might first think to look for it.

Click to reveal our Python code up to this point.
  1import argparse
  2import logging
  3import math
  4from enum import Enum
  5from pathlib import Path
  6
  7import assimp_py
  8from pxr import Gf, Sdf, Tf, Usd, UsdGeom, UsdShade
  9
 10logger = logging.getLogger("obj2usd")
 11
 12
 13class UpAxis(Enum):
 14    Y = UsdGeom.Tokens.y
 15    Z = UsdGeom.Tokens.z
 16
 17    def __str__(self):
 18        return self.value
 19
 20# ADD CODE BELOW HERE
 21# vvvvvvvvvvvvvvvvvvv
 22
 23def extract(input_file: Path, output_file: Path) -> Usd.Stage:
 24    logger.info("Executing extraction phase...")
 25    process_flags = 0
 26    # Load the obj using Assimp 
 27    scene = assimp_py.ImportFile(str(input_file), process_flags)
 28    # Define the stage where the output will go 
 29    stage: Usd.Stage = Usd.Stage.CreateNew(str(output_file))
 30    UsdGeom.SetStageUpAxis(stage, UsdGeom.Tokens.y)
 31    # Assume linear units as meters.
 32    UsdGeom.SetStageMetersPerUnit(stage, UsdGeom.LinearUnits.meters)
 33
 34    for mesh in scene.meshes:
 35        # Replace any invalid characters with underscores.
 36        sanitized_mesh_name = Tf.MakeValidIdentifier(mesh.name)
 37        usd_mesh = UsdGeom.Mesh.Define(stage, f"/{sanitized_mesh_name}")
 38        # You can use the Vt APIs here instead of Python lists.
 39        # Especially keep this in mind for C++ implementations.
 40        face_vertex_counts = []
 41        face_vertex_indices = []
 42        for indices in mesh.indices:
 43            # Convert the indices to a flat list
 44            face_vertex_indices.extend(indices)
 45            # Append the number of vertices for each face
 46            face_vertex_counts.append(len(indices))
 47        
 48        usd_mesh.CreatePointsAttr(mesh.vertices)
 49        usd_mesh.CreateFaceVertexCountsAttr().Set(face_vertex_counts)
 50        usd_mesh.CreateFaceVertexIndicesAttr().Set(face_vertex_indices)
 51        # Treat the mesh as a polygonal mesh and not a subdivision surface.
 52        # Respect the normals or lack of normals from OBJ.
 53        usd_mesh.CreateSubdivisionSchemeAttr(UsdGeom.Tokens.none)
 54        if mesh.normals:
 55            usd_mesh.CreateNormalsAttr(mesh.normals)
 56        
 57        # Get the mesh's material by index
 58        # scene.materials is a dictionary consisting of assimp material properties
 59        mtl = scene.materials[mesh.material_index]
 60        if not mtl:
 61            continue
 62        sanitized_mat_name = Tf.MakeValidIdentifier(mtl["NAME"])
 63        material_path = Sdf.Path(f"/{sanitized_mat_name}")
 64        # Create the material prim
 65        material: UsdShade.Material = UsdShade.Material.Define(stage, material_path)
 66        # Create a UsdPreviewSurface Shader prim.
 67        shader: UsdShade.Shader = UsdShade.Shader.Define(stage, material_path.AppendChild("Shader"))
 68        shader.CreateIdAttr("UsdPreviewSurface")
 69        # Connect shader surface output as an output for the material graph.
 70        material.CreateSurfaceOutput().ConnectToSource(shader.ConnectableAPI(), UsdShade.Tokens.surface)
 71        # Get colors
 72        diffuse_color = mtl["COLOR_DIFFUSE"]
 73        emissive_color = mtl["COLOR_EMISSIVE"]
 74        specular_color = mtl["COLOR_SPECULAR"]
 75        # Convert specular shininess to roughness.
 76        roughness = 1 - math.sqrt(mtl["SHININESS"] / 1000.0)
 77
 78        shader.CreateInput("useSpecularWorkflow", Sdf.ValueTypeNames.Int).Set(1)
 79        shader.CreateInput("diffuseColor", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(diffuse_color))
 80        shader.CreateInput("emissiveColor", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(emissive_color))
 81        shader.CreateInput("specularColor", Sdf.ValueTypeNames.Color3f).Set(Gf.Vec3f(specular_color))
 82        shader.CreateInput("roughness", Sdf.ValueTypeNames.Float).Set(roughness)
 83        binding_api = UsdShade.MaterialBindingAPI.Apply(usd_mesh.GetPrim())
 84        binding_api.Bind(material)
 85
 86    return stage
 87
 88
 89def transform(stage: Usd.Stage, args: argparse.Namespace):
 90    logger.info("Executing transformation phase...")
 91
 92
 93def main(args: argparse.Namespace):
 94    # Extract the .obj
 95    stage: Usd.Stage = extract(args.input, args.output)
 96    # Transformations to be applied to the scene hierarchy
 97    transform(stage, args)
 98    # Save the Stage after editing
 99    stage.Save()
100
101# ^^^^^^^^^^^^^^^^^^^^
102# ADD CODE ABOVE HERE
103
104
105if __name__ == "__main__":
106    logging.basicConfig(level=logging.DEBUG)
107    parser = argparse.ArgumentParser(
108        "obj2usd", description="An OBJ to USD converter script."
109    )
110    parser.add_argument("input", help="Input OBJ file", type=Path)
111    parser.add_argument("-o", "--output", help="Specify an output USD file", type=Path)
112    export_opts = parser.add_argument_group("Export Options")
113    export_opts.add_argument(
114        "-u",
115        "--up-axis",
116        help="Specify the up axis for the exported USD stage.",
117        type=UpAxis,
118        choices=list(UpAxis),
119        default=UpAxis.Y,
120    )
121
122    args = parser.parse_args()
123    if args.output is None:
124        args.output = args.input.parent / f"{args.input.stem}.usda"
125
126    logger.info(f"Converting {args.input}...")
127    main(args)
128    logger.info(f"Converted results output as: {args.output}.")
129    logger.info(f"Done.")
  1. Run the script again to generate a fixed asset.

Windows:

python .\data_exchange\obj2usd.py .\data_exchange\shapes.obj

Linux:

python ./data_exchange/obj2usd.py ./data_exchange/shapes.obj
  1. Let’s see what usdchecker reports now. Run usdchecker.

Windows:

.\scripts\usdchecker.bat .\data_exchange\shapes.usda

Linux:

./scripts/usdchecker.sh ./data_exchange/shapes.usda

We are down to one error.

We successfully fixed both the upAxis and metersPerUnit errors in our asset by updating our obj2usd code. We’ll fix the defaultPrim issue in the next exercise when we dive into the transformation phase.