Render UVs: Texture aspect and fit modes

This example compares planar texture fitting when the mesh and UV axes are aligned, then repeats the comparison after rotating the mesh while leaving the UV axes fixed in the camera frame. This separates texture aspect effects from the effect of projecting an oblique target onto fixed UV axes.

from pathlib import Path

import numpy as np
import riley
from scipy.spatial.transform import Rotation

import pyvale.data as dataset
import pyvale.dataio as io
from pyvale import render
from pyvale.examples.renderuvs.tools import render_uv_example

1. Load the packaged three dimensional calibration plate

data_dir = dataset.riley_stereocal_case_path()
simulation = io.MeshLoader(
    load_dir=data_dir,
    coords_file="coords.csv",
    connect_files="connect.csv",
    load_opts=io.SimLoadOpts(coord_header=None),
).load_mesh()

base_mesh = render.meshes3d_from_simdata(
    simulation,
    {"connect": riley.ConnectConvention(
        riley.EElemType.TRI3, riley.EConnectAxis.ROW, 0,
        riley.ENodeOrder.RILEY,
    )},
)["connect"]

texture = riley.load_texture_mono_u8(dataset.riley_cal_target_texture_path())

2. Define the three aspect preserving fit modes

CONTAIN preserves the complete projected target while respecting the texture aspect ratio. FIT_U spans the texture width and derives V from the same scale; FIT_V spans its height and derives U. The latter two can therefore extend outside the unit texture square when the projection and texture have different aspect ratios.

fit_modes = (
    ("contain", render.EUVFit.CONTAIN),
    ("fit_u", render.EUVFit.FIT_U),
    ("fit_v", render.EUVFit.FIT_V),
)

3. Build aligned and rotated target cases

The aligned plate lies in the camera XY plane, so its physical axes and the planar UV axes coincide. The rotated plate is oblique, but UV projection is still performed in world XY (the camera frame), making the axis mismatch visible in the calibration grid.

aligned_mesh = base_mesh
rotated_mesh = render.mesh_rotate(
    base_mesh,
    Rotation.from_euler("xyz", (0.0, 24.0, 8.0), degrees=True),
    pivot=render.mesh_center(base_mesh),
)

4. Render both three mode comparisons

output_dir = Path.cwd() / "pyvale-output" / "renderuvs_ex1b_uv_texture_aspect"

for orientation_name, mesh in (
    ("aligned", aligned_mesh),
    ("rotated", rotated_mesh),
):
    camera = render.Camera(
        pixels_num=np.array((1792, 1120)),
        pixels_size=np.array((5.5e-6, 5.5e-6)),
        pos_world=np.zeros(3),
        rot_world=Rotation.identity(),
        roi_cent_world=render.mesh_center(mesh),
        focal_length=35.0e-3,
        subsample=4,
    )
    camera = render.cam_frame_mesh(
        camera,
        mesh,
        fov_scale=render.cam_coverage_to_fov_scale(0.90),
    )

    for fit_name, fit_mode in fit_modes:
        uvs = render.uv_project_planar(
            mesh.coords,
            texture_shape=texture.shape[-2:],
            fit=fit_mode,
        )

        textured_mesh = render.Mesh3D(
            element_type=mesh.element_type,
            coords=mesh.coords,
            connectivity=mesh.connectivity,
            shader=riley.TextureShader(uvs=uvs, texture=texture),
        )

        render_uv_example(
            textured_mesh,
            camera,
            output_dir / orientation_name / fit_name,
        )

5. Compare with a physically specified calibration dot pitch

Unlike the automatic fit modes, this mapping fixes the experimental scale at 1.25 mm per dot pitch using the measured 177.1 texture pixels per pitch. Its oblique XY projection slightly exceeds the source texture, so the default SATURATE policy clips those UVs and emits an explicit warning.

texture_px_per_leng = render.uv_calc_texture_px_per_leng(
    texture_px_per_feature=177.1,
    feature_leng=1.25e-3,
)

physical_mapping = render.uv_map_planar_scaled(
    rotated_mesh.coords,
    texture[0],
    texture_px_per_leng,
)

physical_mesh = render.Mesh3D(
    element_type=rotated_mesh.element_type,
    coords=rotated_mesh.coords,
    connectivity=rotated_mesh.connectivity,
    shader=riley.TextureShader(
        uvs=physical_mapping.uvs,
        texture=physical_mapping.texture[None, :, :],
    ),
)

physical_camera = render.Camera(
    pixels_num=np.array((1792, 1120)),
    pixels_size=np.array((5.5e-6, 5.5e-6)),
    pos_world=np.zeros(3),
    rot_world=Rotation.identity(),
    roi_cent_world=render.mesh_center(rotated_mesh),
    focal_length=35.0e-3,
    subsample=4,
)

physical_camera = render.cam_frame_mesh(
    physical_camera,
    rotated_mesh,
    fov_scale=render.cam_coverage_to_fov_scale(0.90),
)

render_uv_example(
    physical_mesh,
    physical_camera,
    output_dir / "physical_pitch",
)

image_leng_per_px = render.cam_calc_leng_per_px(physical_camera)

image_px_per_feature_pitch = render.uv_calc_image_px_per_feature(
    1.25e-3,
    image_leng_per_px,
)

print(
    "Physical pitch mapping predicts "
    f"{image_px_per_feature_pitch:.2f} image px/dot pitch at the ROI"
)

print(f"Rendered UV fit variants to {output_dir}")

For the aligned target, contain, fit U, and fit V are shown from left to right. The target and UV axes coincide, so this row isolates the behaviour of the three aspect preserving fit policies.

Contain, fit U, and fit V mappings on an axis aligned plate

The same contain, fit U, and fit V order is repeated after rotating the physical target. The UV projection axes remain fixed in the camera frame, so the grid now exposes the mismatch between target and UV axes.

Contain, fit U, and fit V mappings on an oblique plate

Finally, the physically scaled mapping fixes the dot pitch instead of fitting the source image to either projected extent.

Physically pitched calibration texture on an oblique plate

Gallery generated by Sphinx-Gallery