diff --git a/src/cedalion/vis/blocks.py b/src/cedalion/vis/blocks.py index 3875676e..1863006a 100644 --- a/src/cedalion/vis/blocks.py +++ b/src/cedalion/vis/blocks.py @@ -163,6 +163,8 @@ def plot_surface( - Masha Iudina | mashayudi@gmail.com | 2024 """ + textured_path = False + if pick_landmarks and surface.units != cedalion.units.mm: raise NotImplementedError( "plot_surface(pick_landmarks=...) currently requires a surface " @@ -174,7 +176,22 @@ def plot_surface( if isinstance(surface, cdc.VTKSurface): mesh = surface.mesh elif isinstance(surface, cdc.TrimeshSurface): - mesh = cdc.VTKSurface.from_trimeshsurface(surface).mesh + # UV-mapped path: preserves texture at pixel resolution instead of + # collapsing to per-vertex color (which smears sub-vertex features on + # coarse meshes like Scaniverse scans). + tri_visual = surface.mesh.visual + tri_uv = getattr(tri_visual, "uv", None) + tri_img = getattr(tri_visual, "image", None) or getattr( + getattr(tri_visual, "material", None), "image", None + ) + if tri_uv is not None and tri_img is not None: + from cedalion.vtktutils import trimesh_to_pv_textured_polydata + mesh, texture = trimesh_to_pv_textured_polydata(surface.mesh) + if texture is not None and "texture" not in kwargs: + kwargs["texture"] = texture + textured_path = True + else: + mesh = cdc.VTKSurface.from_trimeshsurface(surface).mesh else: raise ValueError("unsupported mesh") @@ -223,9 +240,11 @@ def plot_surface( if "pickable" not in kwargs: kwargs["pickable"] = True if "smooth_shading" not in kwargs: - kwargs["smooth_shading"] = True + # smooth_shading + split_sharp_edges recompute normals and duplicate + # vertices along sharp edges, which mangles UVs on the textured path. + kwargs["smooth_shading"] = not textured_path if "split_sharp_edges" not in kwargs: - kwargs["split_sharp_edges"] = True + kwargs["split_sharp_edges"] = not textured_path if "feature_angle" not in kwargs: kwargs["feature_angle"] = 90 diff --git a/src/cedalion/vtktutils.py b/src/cedalion/vtktutils.py index ce0972d6..70da790c 100644 --- a/src/cedalion/vtktutils.py +++ b/src/cedalion/vtktutils.py @@ -54,6 +54,46 @@ def trimesh_to_vtk_polydata(mesh: trimesh.Trimesh): return vtk_mesh +def trimesh_to_pv_textured_polydata( + mesh: trimesh.Trimesh, +) -> tuple[pv.PolyData, "pv.Texture | None"]: + """Convert a textured trimesh to a UV-mapped PyVista PolyData. + + Preserves the mesh's UV coordinates as active texture coordinates and + returns the texture image as a ``pv.Texture`` so callers can UV-map the + texture image at pixel resolution instead of sampling it once per vertex + (as :func:`trimesh_to_vtk_polydata` does). This matters on coarse meshes + where per-vertex sampling smears sub-vertex texture features. + + Args: + mesh: The input trimesh. UVs are read from ``mesh.visual.uv`` and the + texture image from ``mesh.visual.image`` or + ``mesh.visual.material.image``. + + Returns: + Tuple of (PyVista PolyData with active texture coordinates set when + available, pv.Texture for the mesh's image or ``None`` if the mesh + has no usable UV+image combination). + """ + faces = np.hstack( + [np.full((len(mesh.faces), 1), 3, dtype=np.int64), mesh.faces] + ).ravel() + poly = pv.PolyData(np.asarray(mesh.vertices, dtype=np.float64), faces) + + visual = mesh.visual + uv = getattr(visual, "uv", None) + img = getattr(visual, "image", None) or getattr( + getattr(visual, "material", None), "image", None + ) + + if uv is None or img is None: + return poly, None + + poly.active_texture_coordinates = np.asarray(uv, dtype=np.float32) + texture = pv.Texture(np.asarray(img.convert("RGB"))) + return poly, texture + + def pyvista_polydata_to_trimesh(polydata: pv.PolyData) -> trimesh.Trimesh: """Convert a PyVista PolyData object to a Trimesh object. diff --git a/tests/test_vtktutils.py b/tests/test_vtktutils.py new file mode 100644 index 00000000..0c9e01dc --- /dev/null +++ b/tests/test_vtktutils.py @@ -0,0 +1,47 @@ +"""Tests for cedalion.vtktutils.""" + +import numpy as np +import pyvista as pv +import trimesh +from PIL import Image + +from cedalion.vtktutils import trimesh_to_pv_textured_polydata + + +def _quad_trimesh_with_uv() -> trimesh.Trimesh: + verts = np.array( + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]] + ) + faces = np.array([[0, 1, 2], [0, 2, 3]]) + uv = np.array( + [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]], dtype=np.float32 + ) + img = Image.new("RGB", (4, 4), (255, 0, 0)) + mesh = trimesh.Trimesh(vertices=verts, faces=faces, process=False) + mesh.visual = trimesh.visual.TextureVisuals(uv=uv, image=img) + return mesh + + +def test_trimesh_to_pv_textured_polydata_uses_uvs(): + mesh = _quad_trimesh_with_uv() + + poly, tex = trimesh_to_pv_textured_polydata(mesh) + + # UV-mapped path: active texture coordinates are set, no per-vertex + # RGB scalars. + assert isinstance(poly, pv.PolyData) + assert poly.active_texture_coordinates is not None + assert poly.active_texture_coordinates.shape == (4, 2) + assert poly.GetPointData().GetScalars() is None + assert isinstance(tex, pv.Texture) + + +def test_trimesh_to_pv_textured_polydata_no_texture_returns_none(): + verts = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) + faces = np.array([[0, 1, 2]]) + mesh = trimesh.Trimesh(vertices=verts, faces=faces, process=False) + + poly, tex = trimesh_to_pv_textured_polydata(mesh) + + assert isinstance(poly, pv.PolyData) + assert tex is None