Skip to content

Commit

Permalink
feat: Implement blend_colors function for color blending (#6)
Browse files Browse the repository at this point in the history
* feat(meshops): Implement blend_colors function, given index groups

Colors at given indices in the same group are blended into having the same color.

Task: TECHART-325

---------

Co-authored-by: Olaf Haag <[email protected]>
  • Loading branch information
TechyDaniel and Olaf Haag authored Nov 25, 2023
1 parent df10367 commit 2c36655
Show file tree
Hide file tree
Showing 3 changed files with 87 additions and 4 deletions.
34 changes: 33 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, PixelCoord, UVs, Vertices
from readyplayerme.meshops.types import Color, Edges, IndexGroups, Indices, Mesh, PixelCoord, UVs, Vertices


def read_mesh(filename: str | Path) -> Mesh:
Expand Down Expand Up @@ -128,3 +128,35 @@ def uv_to_image_coords(
img_coords[:, 1] = ((1 - wrapped_uvs[:, 1]) * (height - 0.5)).astype(np.uint16)

return img_coords


def blend_colors(colors: Color, index_groups: IndexGroups) -> Color:
"""Blend colors according to the given grouped indices.
Colors at indices in the same group are blended into having the same color.
:param index_groups: Groups of indices. Indices must be within the bounds of the colors array.
:param vertex_colors: Colors.
:return: Colors with new blended colors at given indices.
"""
if not len(colors):
return np.empty_like(colors)

blended_colors = np.copy(colors)
if not index_groups:
return blended_colors

# Check that the indices are within the bounds of the colors array,
# so we don't start any operations that would later fail.
try:
colors[np.hstack(index_groups)]
except IndexError as error:
msg = f"Index in index groups is out of bounds for colors: {error}"
raise IndexError(msg) from error

# Blending process
for group in index_groups:
if len(group): # A mean-operation with an empty group would return nan values.
blended_colors[group] = np.mean(colors[group], axis=0)

return blended_colors
1 change: 1 addition & 0 deletions src/readyplayerme/meshops/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
Edges: TypeAlias = npt.NDArray[np.int32] | npt.NDArray[np.int64] # Shape (e, 2)
Faces: TypeAlias = npt.NDArray[np.int32] | npt.NDArray[np.int64] # Shape (f, 3)
IndexGroups: TypeAlias = list[npt.NDArray[np.uint32]]
Color: TypeAlias = npt.NDArray[np.uint8] # Shape RGBA: (c, 4) | RGB: (c, 3) | Grayscale: (c,)

UVs: TypeAlias = npt.NDArray[np.float32] | npt.NDArray[np.float64] # Shape (i, 2)
PixelCoord: TypeAlias = npt.NDArray[np.uint16] # Shape (i, 2)
Expand Down
56 changes: 53 additions & 3 deletions tests/readyplayerme/meshops/unit/test_mesh.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def test_get_mesh_reader_glb(self, filepath: str | Path, expected: Callable[[str
assert reader == expected, "The reader function for .glTF files should be read_gltf."

@pytest.mark.parametrize("filepath", ["test", "test.obj", Path("test.stl"), "test.fbx", "test.abc", "test.ply"])
def test_get_mesh_reader_unsupported(self, filepath: str | Path):
def test_get_mesh_reader_should_fail(self, filepath: str | Path):
"""Test the get_mesh_reader function with an unsupported file format."""
with pytest.raises(NotImplementedError):
mesh.get_mesh_reader(filepath)
Expand Down Expand Up @@ -114,7 +114,7 @@ def test_get_overlapping_vertices(
np.array([0, -1], dtype=np.int32), # Using int32 to allow negative values
],
)
def test_get_overlapping_vertices_error_handling(indices):
def test_get_overlapping_vertices_should_fail(indices):
"""Test that get_overlapping_vertices function raises an exception for out of bounds indices."""
vertices = np.array([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]])
with pytest.raises(IndexError):
Expand Down Expand Up @@ -164,7 +164,57 @@ def test_uv_to_image_coords(uvs: UVs, width: int, height: int, indices: Indices,
(np.array([]), 1, 1, np.array([0, 1, 2])),
],
)
def test_uv_to_image_coords_exceptions(uvs: UVs, width: int, height: int, indices: Indices):
def test_uv_to_image_coords_should_fail(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)


@pytest.mark.parametrize(
"colors, index_groups, expected",
[
# Case with simple groups and distinct colors
(
np.array([[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]]),
[[0, 1], [2, 3]],
np.array([[127, 127, 0], [127, 127, 0], [127, 127, 127], [127, 127, 127]]),
),
# Case with a single group
(
np.array([[100, 100, 100], [200, 200, 200]]),
[[0, 1]],
np.array([[150, 150, 150], [150, 150, 150]]),
),
# Case with groups of 1 element
(
np.array([[100, 0, 0], [0, 100, 0], [0, 0, 100]]),
[[0], [1], [2]],
np.array([[100, 0, 0], [0, 100, 0], [0, 0, 100]]),
),
# Case with empty colors array
(np.array([], dtype=np.uint8), [[0, 1]], np.array([])),
# Case with empty groups
(np.array([[255, 0, 0], [0, 255, 0]]), [], np.array([[255, 0, 0], [0, 255, 0]])),
# Case with empty colors and groups
(np.array([], dtype=np.uint8), [], np.array([], dtype=np.uint8)),
],
)
def test_blend_colors(colors, index_groups, expected):
"""Test the blend_colors function."""
blended_colors = mesh.blend_colors(colors, index_groups)
np.testing.assert_array_equal(blended_colors, expected)


@pytest.mark.parametrize(
"colors, index_groups",
[
# Case with out-of-bounds indices
(np.array([[255, 0, 0], [0, 255, 0]]), [[0, 2]]),
# Case with negative index
(np.array([[255, 0, 0], [0, 255, 0]]), [[-3, 1]]),
],
)
def test_blend_colors_should_fail(colors, index_groups):
"""Test error handling in blend_colors function."""
with pytest.raises(IndexError):
mesh.blend_colors(colors, index_groups)

0 comments on commit 2c36655

Please sign in to comment.