Skip to content

Commit

Permalink
feat: update uv_to_texture_space function (#5)
Browse files Browse the repository at this point in the history
* feat: Implement UV to texture space conversion

- Add function to convert UV coordinates to texture space pixel coordinates.
- Ensure correct handling of edge cases including wrapping of out-of-range UVs.
- Include comprehensive tests to validate functionality across various scenarios.

---------

Co-authored-by: Olaf Haag <[email protected]>
  • Loading branch information
TechyDaniel and Olaf-Wolf3D authored Nov 24, 2023
1 parent 19855ca commit df10367
Show file tree
Hide file tree
Showing 3 changed files with 90 additions and 2 deletions.
38 changes: 37 additions & 1 deletion src/readyplayerme/meshops/mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import trimesh
from scipy.spatial import cKDTree

from readyplayerme.meshops.types import Edges, IndexGroups, Indices, Mesh, Vertices
from readyplayerme.meshops.types import Edges, IndexGroups, Indices, Mesh, PixelCoord, UVs, Vertices


def read_mesh(filename: str | Path) -> Mesh:
Expand Down Expand Up @@ -92,3 +92,39 @@ def get_overlapping_vertices(
processed.update(neighbors)

return grouped_indices


def uv_to_image_coords(
uvs: UVs,
width: int,
height: int,
indices: Indices | None = None,
) -> PixelCoord:
"""Convert UV coordinates to image space coordinates.
:param uvs: UV coordinates.
:param indices: Optional subset of UV indices for which to retrieve pixel coordinates.
:param width: Width of the image in pixels.
:param height: Height of the image in pixels.
:return: Coordinates in image space given the input width and height.
"""
try:
selected_uvs = uvs if indices is None else uvs[indices]
except IndexError as error:
msg = f"Index {np.where(indices>=len(uvs))[0]} is out of bounds for UVs with shape {uvs.shape}." # type: ignore
raise IndexError(msg) from error
if not len(selected_uvs):
return np.empty((0, 2), dtype=np.uint16)

# Wrap UV coordinates within the range [0, 1].
wrapped_uvs = np.mod(selected_uvs, 1)

# With wrapping, we keep the max 1 as 1 and not transpose into the next space.
wrapped_uvs[selected_uvs == 1] = 1

# Convert UV coordinates to texture space (pixel coordinates)
img_coords = np.empty((len(selected_uvs), 2), dtype=np.uint16)
img_coords[:, 0] = (wrapped_uvs[:, 0] * (width - 0.5)).astype(np.uint16)
img_coords[:, 1] = ((1 - wrapped_uvs[:, 1]) * (height - 0.5)).astype(np.uint16)

return img_coords
3 changes: 3 additions & 0 deletions src/readyplayerme/meshops/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@
Faces: TypeAlias = npt.NDArray[np.int32] | npt.NDArray[np.int64] # Shape (f, 3)
IndexGroups: TypeAlias = list[npt.NDArray[np.uint32]]

UVs: TypeAlias = npt.NDArray[np.float32] | npt.NDArray[np.float64] # Shape (i, 2)
PixelCoord: TypeAlias = npt.NDArray[np.uint16] # Shape (i, 2)


class Mesh(Protocol):
"""Structural type for a mesh class.
Expand Down
51 changes: 50 additions & 1 deletion tests/readyplayerme/meshops/unit/test_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
import pytest

from readyplayerme.meshops import mesh
from readyplayerme.meshops.types import IndexGroups, Indices, Mesh, Vertices
from readyplayerme.meshops.types import IndexGroups, Indices, Mesh, PixelCoord, UVs, Vertices


class TestReadMesh:
Expand Down Expand Up @@ -119,3 +119,52 @@ def test_get_overlapping_vertices_error_handling(indices):
vertices = np.array([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]])
with pytest.raises(IndexError):
mesh.get_overlapping_vertices(vertices, indices)


@pytest.mark.parametrize(
"uvs, width, height, indices, expected",
[
# Simple UV conversion with specific indices
(np.array([[0.5, 0.5], [0.25, 0.75]]), 100, 100, np.array([0]), np.array([[49, 49]])),
# Full range UV conversion without specific indices
(np.array([[0.0, 0.0], [1.0, 1.0]]), 200, 200, None, np.array([[0, 199], [199, 0]])),
# Near 0 and 1 values
(
np.array([[0.0001, 0.9999], [0.9999, 0.0001]]),
200,
200,
np.array([0, 1]),
np.array([[0, 0], [199, 199]]),
),
# Empty indices
(np.array([[0.5, 0.5], [0.25, 0.75]]), 50, 50, np.array([], dtype=np.uint8), np.empty((0, 2))), # FixMe
# UV coordinates out of range (non square tex - negative values)
(np.array([[-0.5, 1.5], [1.0, -1.0]]), 10, 100, np.array([0, 1]), np.array([[4, 49], [9, 99]])),
# UV coordinates out of range (wrapped - negative values)
(np.array([[-0.25, 1.5], [-2.0, -1.0]]), 100, 100, np.array([0, 1]), np.array([[74, 49], [0, 99]])),
# UV coordinates for non square
(np.array([[0.5, 0.5], [0.25, 0.75]]), 124, 10024, np.array([0]), np.array([[61, 5011]])),
# 1px image
(np.array([[0.5, 0.5], [-1, 1], [0, 0]]), 1, 1, np.array([0, 1, 2]), np.array([[0, 0], [0, 0], [0, 0]])),
# 0 px image
(np.array([[0.5, 0.5], [0.25, 0.75]]), 0, 0, np.array([0]), np.array([[0, 0]])),
],
)
def test_uv_to_image_coords(uvs: UVs, width: int, height: int, indices: Indices, expected: PixelCoord):
"""Test the uv_to_texture_space function returns the correct texture space coordinates."""
image_space_coords = mesh.uv_to_image_coords(uvs, width, height, indices)
assert np.array_equal(image_space_coords, expected), "Image space coordinates do not match expected values."


@pytest.mark.parametrize(
"uvs, width, height, indices",
[
(np.array([[0.5, 0.5], [0.25, 0.75]]), 100, 100, np.array([0, 1, 2])),
# No UV coord
(np.array([]), 1, 1, np.array([0, 1, 2])),
],
)
def test_uv_to_image_coords_exceptions(uvs: UVs, width: int, height: int, indices: Indices):
"""Test the uv_to_image_space function raises expected exceptions."""
with pytest.raises(IndexError):
mesh.uv_to_image_coords(uvs, width, height, indices)

0 comments on commit df10367

Please sign in to comment.