Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions src/cedalion/vis/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand All @@ -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")

Expand Down Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions src/cedalion/vtktutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
47 changes: 47 additions & 0 deletions tests/test_vtktutils.py
Original file line number Diff line number Diff line change
@@ -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
Loading