diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..66331e1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Convert line endings to LF. +* text=auto diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ad7799c..273e87a 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,4 +1,4 @@ # These owners will be the default owners for everything in # the repo. Unless a later match takes precedence, # Following people will be requested for review when someone opens a pull request. -* @TechyDaniel @Olaf-Wolf3D +* @Olaf-Wolf3D diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fe121f0..1b9550b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,13 +22,13 @@ repos: # Code style and formatting - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.0.285 + rev: v0.4.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 24.4.2 hooks: - id: black args: [ @@ -42,7 +42,7 @@ repos: args: ["--verbose", "2"] # override the .docstr.yaml to see less output - repo: https://github.com/pre-commit/mirrors-mypy - rev: 'v1.5.1' + rev: 'v1.10.0' hooks: - id: mypy additional_dependencies: [numpy, pytest] diff --git a/README.md b/README.md index c0a151f..6f89e46 100644 --- a/README.md +++ b/README.md @@ -6,19 +6,55 @@ A collection of tools for dealing with mesh related data. You'll find setup instruction of this project in the [CONTRIBUTING.md](https://github.com/readyplayerme/meshops/blob/main/CONTRIBUTING.md) file. -## Position Map +## Features + +### Position Map + +```python +from readyplayerme.meshops.draw.image import get_position_map +``` Position maps encode locations on the surface of a mesh as color values in an image using the UV layout. Since 8-bit colors are positive values only and capped at 255, the linear transformation of the positions into the color space is lossy and not invertible, meaning that the original positions cannot be recovered from the color values. However, these maps can be utilized as control signals for various tasks such as texture synthesis and for shader effects. -## UV Seams Transitioning +### Object Space Normal Maps + +```python +from readyplayerme.meshops.draw.image import get_obj_space_normal_map +``` + +Object space normal maps encode the surface normals of a mesh as color values in an image using the UV layout. +Similar to position maps, the conversion from normals to colors is lossy. +They also can be used as control signals for various tasks such as texture synthesis. + +### Any Vertex Attribute to Image + +```python +from readyplayerme.meshops.draw.image import get_vertex_attribute_image +``` + +This function allows you to convert any vertex attribute of a mesh that can be represented as a color to an image. + +### UV Island Mask + +```python +from readyplayerme.meshops.draw.image import create_mask +``` + +This function creates a black and white mask image from the mesh's UV layout. + +### UV Seams Transitioning + +```python +from readyplayerme.meshops.draw.image import blend_uv_seams +``` UV seams are splits in a triangle mesh that, however, is supposed to represent a continuous surface across these splits. These splits are necessary to allow the mesh to flatten out cleanly in UV space and have as little distortion in the texture projection as possible. The UV seams are splits between UV islands for which the projection onto the mesh should appear as seamless as possible. -### Goal +#### Goal - identify UV splits in a mesh - mitigate the mismatch between image content when transitioning over a UV split diff --git a/pyproject.toml b/pyproject.toml index 6f7ae0e..c6314b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,9 @@ test = [ "pyinstrument", ] lint = [ - "black>=23.3.0", - "mypy>=1.5.1", - "ruff>=0.0.285", + "black==24.4.2", + "mypy==1.10.0", + "ruff==0.4.3", ] dev = [ "readyplayerme.meshops[test, lint]", @@ -73,7 +73,7 @@ post-install-commands = [ ] [tool.hatch.envs.default.scripts] -install-precommit = "pre-commit install -t pre-commit -t commit-msg -t pre-push" +install-precommit = "pre-commit install --overwrite -t pre-commit -t commit-msg -t pre-push" test = "pytest {args:tests}" test-cov = "coverage run -m pytest {args:tests}" cov-report = [ @@ -87,7 +87,7 @@ cov = [ ] typing = "mypy --install-types --non-interactive {args:src/readyplayerme/meshops tests}" style = [ - "ruff {args:.}", + "ruff check {args:.}", "black --check --diff {args:.}", ] fmt = [ @@ -124,13 +124,16 @@ python = ["3.10", "3.11"] #addopts="-n auto" [tool.black] -target-versions = ["py310", "py311"] +target-version = ["py310", "py311"] line-length = 120 skip-string-normalization = false [tool.ruff] target-version = "py311" line-length = 120 +builtins = ["_"] + +[tool.ruff.lint] select = [ "A", "ANN", @@ -178,7 +181,6 @@ ignore = [ # Don't require documentation for every function parameter and magic methods. "D417", "D102", "D105", "D107", "D100" ] -builtins = ["_"] unfixable = [ # Don't touch unused imports "F401", @@ -208,32 +210,32 @@ exclude = [ "venv", ] -[tool.ruff.isort] +[tool.ruff.lint.isort] known-first-party = ["readyplayerme"] -[tool.ruff.flake8-tidy-imports] +[tool.ruff.lint.flake8-tidy-imports] ban-relative-imports = "all" -[tool.ruff.flake8-quotes] +[tool.ruff.lint.flake8-quotes] docstring-quotes = "double" -[tool.ruff.flake8-annotations] +[tool.ruff.lint.flake8-annotations] mypy-init-return = true allow-star-arg-any = true ignore-fully-untyped = true suppress-none-returning = true -[tool.ruff.flake8-unused-arguments] +[tool.ruff.lint.flake8-unused-arguments] ignore-variadic-names = true -[tool.ruff.pycodestyle] +[tool.ruff.lint.pycodestyle] ignore-overlong-task-comments = true -[tool.ruff.pydocstyle] +[tool.ruff.lint.pydocstyle] convention = "pep257" ignore-decorators = ["typing.overload"] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] # Tests can use magic values, assertions, and relative imports "tests/**/*" = ["PLR2004", "S101", "TID252"] diff --git a/src/readyplayerme/meshops/__about__.py b/src/readyplayerme/meshops/__about__.py index d6212c5..fbddaae 100644 --- a/src/readyplayerme/meshops/__about__.py +++ b/src/readyplayerme/meshops/__about__.py @@ -1,4 +1,4 @@ # SPDX-FileCopyrightText: 2023-present Ready Player Me # # SPDX-License-Identifier: MIT -__version__ = "0.0.1" +__version__ = "0.1.0" diff --git a/src/readyplayerme/meshops/draw/__init__.py b/src/readyplayerme/meshops/draw/__init__.py index c620195..684c23f 100644 --- a/src/readyplayerme/meshops/draw/__init__.py +++ b/src/readyplayerme/meshops/draw/__init__.py @@ -1,3 +1,4 @@ """Module for drawing operations on images.""" + from readyplayerme.meshops.draw.color import * # noqa: F403 from readyplayerme.meshops.draw.image import * # noqa: F403 diff --git a/src/readyplayerme/meshops/draw/color.py b/src/readyplayerme/meshops/draw/color.py index c3ce270..8598928 100644 --- a/src/readyplayerme/meshops/draw/color.py +++ b/src/readyplayerme/meshops/draw/color.py @@ -1,5 +1,10 @@ """Functions to deal with colors and color modes.""" + +from typing import Any + import numpy as np +import numpy.typing as npt +import skimage from readyplayerme.meshops.types import Color, ColorMode, Image, IndexGroups @@ -33,19 +38,19 @@ def get_image_color_mode(image: Image) -> ColorMode: raise ValueError(error_msg) -def get_color_array_color_mode(color_array: Color) -> ColorMode: +def get_color_mode(color: Color) -> ColorMode: """ Determine the color mode of a color array. - :param color_array: An array representing colors. + :param color: An array representing colors. :return ColorMode: Enum indicating the color mode of the color array (GRAYSCALE, RGB, or RGBA). """ try: - n_channels = color_array.shape[-1] + n_channels = color.shape[-1] except IndexError as error: error_msg = "Color has invalid shape: zero dimensions." raise ValueError(error_msg) from error - match color_array.ndim, n_channels: + match color.ndim, n_channels: case 1, _: return ColorMode.GRAYSCALE case 2, 1: @@ -117,3 +122,48 @@ def interpolate_values(start: Color, end: Color, num_steps: int) -> Color: return start + t * (end - start) else: return start[None, :] + t[:, None] * (end - start) + + +def attribute_to_color(attribute: npt.NDArray[Any], *, normalize_per_channel: bool = False) -> Color: + """Convert an attribute to color values. + + If necessary, remap to [0, 255] range as uint8. + + :param attribute: The attribute to turn into color values. + :param normalize_per_channel: Whether to remap each channel separately or across all channels. + Only if the attribute is not already uint8. + :return: The attribute as uint8 color values. + """ + attribute = np.nan_to_num(attribute) + if attribute.ndim == 0: + msg = "Attribute has 0 dimensions. Must at least be a scalar (1 dimension)." + raise ValueError(msg) + if attribute.size > 1: # Do not squeeze a scalar, as it will get 0 dimensions. + attribute = np.squeeze(attribute) + # If the attribute has 2 values, like UV coordinates, add a third value to make it RGB. + dims_2d = 2 + if attribute.ndim == dims_2d and attribute.shape[1] == dims_2d: + attribute = np.pad(attribute, ((0, 0), (0, 1)), mode="constant", constant_values=0) + # Now check if we're actually dealing with colors. + try: + _ = get_color_mode(attribute) + except ValueError: + raise + # Normalize the attribute. + if attribute.dtype != np.uint8: + axis = 0 if normalize_per_channel else None + # If the attribute range is below 1, keep 1 as the maximum. Also avoids division by 0. + attribute_range = np.maximum(np.absolute(attribute).max(axis=axis, keepdims=True), 1) + colors = attribute / attribute_range + # If the minimum is less than 0, shift the array into the [0, 1] range. + column_mask = colors.min(axis=axis) < 0 + if column_mask.any(): + if axis is None: + colors = (colors + 1) * 0.5 + elif axis == 0: + colors[:, column_mask] = (colors[:, column_mask] + 1) * 0.5 + colors = skimage.util.img_as_ubyte(colors) + else: + colors = attribute + + return colors diff --git a/src/readyplayerme/meshops/draw/image.py b/src/readyplayerme/meshops/draw/image.py index d22ded0..0e45001 100644 --- a/src/readyplayerme/meshops/draw/image.py +++ b/src/readyplayerme/meshops/draw/image.py @@ -1,4 +1,5 @@ """Module for dealing with image manipulation.""" + from collections.abc import Callable import numpy as np @@ -8,8 +9,9 @@ from readyplayerme.meshops import mesh as mops from readyplayerme.meshops.draw.color import ( + attribute_to_color, blend_colors, - get_color_array_color_mode, + get_color_mode, get_image_color_mode, interpolate_values, ) @@ -31,7 +33,7 @@ def create_nan_image(width: int, height: int, mode: ColorMode = ColorMode.RGB) - msg = "Width and height must be positive integers" raise ValueError(msg) - shape = (height, width) if mode == ColorMode.GRAYSCALE else (height, width, mode.value) + shape = tuple(filter(bool, (height, width, mode.value))) return np.full(shape, np.nan, dtype=np.float32) except ValueError as error: msg = "Failed to create NaN image" @@ -178,7 +180,7 @@ def draw_lines( :param interpolate_func: Function to interpolate colors. :return: Image with interpolated lines. """ - if get_color_array_color_mode(colors) == ColorMode.GRAYSCALE: + if get_color_mode(colors) == ColorMode.GRAYSCALE: colors = colors[:, np.newaxis] for edge in edges: try: @@ -245,7 +247,7 @@ def rasterize( # Check if the image and colors are compatible (both grayscale or both color) image_mode = get_image_color_mode(image) - colors_mode = get_color_array_color_mode(colors) + colors_mode = get_color_mode(colors) if image_mode != colors_mode: msg = "Color mode of 'image' and 'colors' must match (both grayscale or both color)." raise ValueError(msg) @@ -286,7 +288,11 @@ def blend_uv_seams(mesh: mops.Mesh, image: Image) -> Image: boundary_vertices = mops.get_boundary_vertices(mesh.edges) seam_vertices = mops.get_overlapping_vertices(mesh.vertices, boundary_vertices) # Sample colors from all UV coordinates and blend only colors of overlapping vertices. - pixel_coords = mops.uv_to_image_coords(mesh.uv_coords, image.shape[0], image.shape[1]) + try: + pixel_coords = mops.uv_to_image_coords(mesh.uv_coords, image.shape[0], image.shape[1]) + except TypeError as error: + msg = f"UV coordinates are invalid: {mesh.uv_coords}" + raise ValueError(msg) from error vertex_colors = image[pixel_coords[:, 1], pixel_coords[:, 0]] mixed_colors = blend_colors(vertex_colors, seam_vertices) @@ -310,7 +316,71 @@ def blend_uv_seams(mesh: mops.Mesh, image: Image) -> Image: return blend_images(image, raster_image, blurred_mask).astype(np.uint8) -def get_position_map(width: int, height: int, mesh: mops.Mesh, padding: int = 4) -> Image: +def get_vertex_attribute_image( + width: int, + height: int, + faces: mops.Faces, + uvs: mops.UVs, + attribute: Color, + padding: int = 4, + *, + normalize_per_channel: bool = False, +) -> Image: + """Turn a vertex attribute into an image using a uv layout. + + If the attribute is not already a uint8, it's normalized and then mapped to the 8-bit range [0, 255]. + + :param width: Width of the image in pixels. + :param height: Height of the image in pixels. + :param faces: The faces of the mesh containing vertex indices. + :param uvs: The UV coordinates for the vertices of the faces. + :param attribute: The attribute to convert to an image. + :param padding: Padding in pixels to add around the UV shells. Default 4. + :param normalize_per_channel: Whether to normalize each channel separately or across all channels. + Only if the attribute is not already uint8. Default False. + :return: The vertex attribute as an 8-bit image. + """ + # Sanity checks. + try: + num_uvs = len(uvs) + except TypeError as error: + msg = f"UV coordinates are invalid: {uvs}." + raise ValueError(msg) from error + if (num_vertices := faces.ravel().max() + 1) > num_uvs: + msg = f"UV coordinates are invalid: Too few UV coordinates. Expected {num_vertices}, got {num_uvs}." + raise ValueError(msg) + if len(attribute) != num_uvs: + msg = f"Attribute length does not match UV coordinates length: {len(attribute)} != {num_uvs}." + raise ValueError(msg) + try: + colors = attribute_to_color(attribute, normalize_per_channel=normalize_per_channel) + attr_color_mode = get_color_mode(colors) + except ValueError as error: + msg = f"Attribute shape is unsupported for image conversion: {attribute.shape}" + raise ValueError(msg) from error + + # Create an image from the attribute. + image_coords = mops.uv_to_image_coords(uvs, width, height) + attribute_img = create_nan_image(width, height, attr_color_mode) + edges = mops.faces_to_edges(faces) + attribute_img = rasterize(attribute_img, edges, image_coords, colors, inplace=True) + # Constrain colored pixels to the UV shells. + triangle_coords = mops.get_faces_image_coords(faces, uvs, width, height) + mask = create_mask(width, height, triangle_coords).astype(bool) + if attr_color_mode in (ColorMode.RGB, ColorMode.RGBA): + mask = mask[:, :, np.newaxis].repeat(attr_color_mode, axis=2) + attribute_img *= mask + # Add padding around the UV shells. + if padding > 0: + padded = maximum_filter(attribute_img, size=padding, axes=[0, 1]) + attribute_img[~mask] = padded[~mask] + + return attribute_img.astype(np.uint8) + + +def get_position_map( + width: int, height: int, mesh: mops.Mesh, padding: int = 4, *, center: bool = True, uniform_scale: bool = True +) -> Image: """Get a position map from the given mesh. The positions are normalized and then mapped to the 8-bit range [0, 255]. @@ -319,25 +389,44 @@ def get_position_map(width: int, height: int, mesh: mops.Mesh, padding: int = 4) :param height: Height of the position map in pixels. :param mesh: The mesh to use for creating the position map. :param padding: Padding in pixels to add around the UV shells. Default 4. + :param center: Whether to compute the positions from the center of the mesh. Default True. + :param uniform_scale: Whether to scale the positions uniformly along each axis or independently for each axis. + Default True. :return: The position map as uint8. """ - positions = mesh.vertices - # Since 8-bit images only store positive numbers up to 255, first normalize the positions to the range [0, 1]. - positions = positions - positions.min(axis=0) - positions = positions / positions.max(axis=0) - # Map the normalized positions to the 8-bit color format. - positions = (positions * 255).astype(np.uint8) - # Create an image from the positions. - image_coords = mops.uv_to_image_coords(mesh.uv_coords, width, height) - triangle_coords = mops.get_faces_image_coords(mesh.faces, mesh.uv_coords, width, height) - pos_map = create_nan_image(width, height, ColorMode.RGB) - pos_map = rasterize(pos_map, mesh.edges, image_coords, positions, inplace=True) - # Constrain colored pixels to the UV shells. - mask = create_mask(width, height, triangle_coords).astype(bool)[:, :, np.newaxis].repeat(3, axis=2) - pos_map *= mask - # Add padding around the UV shells. - if padding > 0: - padded = maximum_filter(pos_map, size=padding, axes=[0, 1]) - pos_map[~mask] = padded[~mask] - - return pos_map.astype(np.uint8) + if center: + vertices = mesh.vertices - mesh.vertices.mean(axis=0) + else: + vertices = mesh.vertices + if uniform_scale: + vertices /= vertices.ptp() + else: + vertices /= vertices.ptp(axis=0) + return get_vertex_attribute_image( + width, + height, + mesh.faces, + mesh.uv_coords, + vertices, + padding=padding, + normalize_per_channel=not uniform_scale, # Not really needed, because the positions are already normalized. + ) + + +def get_obj_space_normal_map(width: int, height: int, mesh: mops.Mesh, padding: int = 4) -> Image: + """Get an object space normal map from the given mesh. + + The normals are mapped to the 8-bit integer range [0, 255] to be represented as colors. + + :param width: Width of the normal map in pixels. + :param height: Height of the normal map in pixels. + :param mesh: The mesh to use for creating the normal map. + :param padding: Padding in pixels to add around the UV shells. Default 4. + :return: The object space normal map as uint8. + """ + if mesh.normals is None: + msg = "Mesh does not have vertex normals." + raise ValueError(msg) + return get_vertex_attribute_image( + width, height, mesh.faces, mesh.uv_coords, mesh.normals, padding=padding, normalize_per_channel=False + ) diff --git a/src/readyplayerme/meshops/mesh.py b/src/readyplayerme/meshops/mesh.py index aacddd2..7861d08 100644 --- a/src/readyplayerme/meshops/mesh.py +++ b/src/readyplayerme/meshops/mesh.py @@ -1,4 +1,5 @@ """Functions to handle mesh data and read it from file.""" + from collections.abc import Callable from dataclasses import dataclass from pathlib import Path @@ -8,7 +9,7 @@ from scipy.spatial import cKDTree from readyplayerme.meshops.material import Material -from readyplayerme.meshops.types import Edges, Faces, IndexGroups, Indices, PixelCoord, UVs, Vertices +from readyplayerme.meshops.types import Edges, Faces, IndexGroups, Indices, Normals, PixelCoord, UVs, Vertices @dataclass() @@ -22,6 +23,7 @@ class Mesh: edges: Edges faces: Faces uv_coords: UVs | None = None + normals: Normals | None = None material: Material | None = None @@ -51,6 +53,7 @@ def read_gltf(filename: str | Path) -> Mesh: """ try: loaded = trimesh.load(filename, process=False, force="mesh") + assert isinstance(loaded, trimesh.Trimesh), "Loaded object is not a Trimesh." # noqa: S101 # For type checker. except ValueError as error: msg = f"Error loading {filename}: {error}" raise OSError(msg) from error @@ -63,15 +66,17 @@ def read_gltf(filename: str | Path) -> Mesh: uvs = loaded.visual.to_texture().uv # Sets the shape of UVs, but values are all 0.5. else: uvs = None + normals = loaded.vertex_normals try: material = Material.from_trimesh_material(loaded.visual.material) except AttributeError: material = None return Mesh( vertices=np.array(loaded.vertices), - uv_coords=uvs, faces=np.array(loaded.faces), edges=loaded.edges, + uv_coords=uvs, + normals=normals, material=material, ) @@ -134,6 +139,16 @@ def get_overlapping_vertices( return grouped_indices +def faces_to_edges(faces: Faces) -> Edges: + """Return edges of the faces. + + :param faces: Faces to convert to edge representation. + :return: Edges contained in the faces. + """ + # Split faces into first 2 columns, last 2 columns and first + last column. + return np.hstack((faces[:, :2], faces[:, 1:], np.roll(faces, 1, axis=1)[:, :2])).reshape(faces.size, 2) + + def uv_to_image_coords( uvs: UVs, width: int, diff --git a/src/readyplayerme/meshops/types.py b/src/readyplayerme/meshops/types.py index 0125efd..ead840c 100644 --- a/src/readyplayerme/meshops/types.py +++ b/src/readyplayerme/meshops/types.py @@ -1,5 +1,6 @@ """Custom types for meshops.""" -from enum import Enum + +from enum import IntEnum from typing import TypeAlias import numpy as np @@ -8,13 +9,14 @@ # trimesh uses int64 and float64 for its arrays. Indices: TypeAlias = npt.NDArray[np.uint32] | npt.NDArray[np.uint64] # Shape (i,) Vertices: TypeAlias = npt.NDArray[np.float32] | npt.NDArray[np.float64] # Shape (v, 3) +Normals: TypeAlias = npt.NDArray[np.float64] # Shape (n, 3) 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,) -class ColorMode(Enum): +class ColorMode(IntEnum): """Color modes for images.""" GRAYSCALE = 0 diff --git a/tests/conftest.py b/tests/conftest.py index 2c4e54e..88cd8c2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,5 @@ """Pytest fixtures for the whole repo.""" + import re from pathlib import Path diff --git a/tests/readyplayerme/meshops/conftest.py b/tests/readyplayerme/meshops/conftest.py index e47156b..eae7171 100644 --- a/tests/readyplayerme/meshops/conftest.py +++ b/tests/readyplayerme/meshops/conftest.py @@ -1,4 +1,5 @@ """Pytest fixtures for meshops tests.""" + import pytest import skimage as ski diff --git a/tests/readyplayerme/meshops/unit/conftest.py b/tests/readyplayerme/meshops/unit/conftest.py index a216dbb..c7a4ec6 100644 --- a/tests/readyplayerme/meshops/unit/conftest.py +++ b/tests/readyplayerme/meshops/unit/conftest.py @@ -1,4 +1,5 @@ """Pytest fixtures for meshops unit tests.""" + import numpy as np import pytest @@ -98,7 +99,22 @@ def mock_mesh(): [0.869261205, 0.525972605], ] ) - return Mesh(vertices=vertices, edges=edges, faces=faces, uv_coords=uv) + normals = np.array( + [ + [-0.70710678, 0.0, 0.70710678], + [-0.57735027, 0.57735027, 0.57735027], + [-0.70710678, 0.0, -0.70710678], + [-0.57735027, 0.57735027, -0.57735027], + [0.57611046, 0.0, 0.81737185], + [0.57735027, 0.57735027, 0.57735027], + [0.0, 0.0, -1.0], + [0.57735027, 0.57735027, -0.57735027], + [0.70710678, 0.0, 0.70710678], + [0.0, 0.0, -1.0], + [1.0, 0.0, 0.0], + ] + ) + return Mesh(vertices=vertices, edges=edges, faces=faces, uv_coords=uv, normals=normals) @pytest.fixture diff --git a/tests/readyplayerme/meshops/unit/test_colors.py b/tests/readyplayerme/meshops/unit/test_colors.py index 1136bf4..da09352 100644 --- a/tests/readyplayerme/meshops/unit/test_colors.py +++ b/tests/readyplayerme/meshops/unit/test_colors.py @@ -15,6 +15,7 @@ # RGBA image (3D array with shape (h, w, 4)) (np.random.rand(10, 10, 4).astype(np.float32), ColorMode.RGBA), ], + ids=["Grayscale", "RGB", "RGBA"], ) def test_get_image_color_mode(image, expected_mode): """Test the get_image_color_mode function with valid inputs.""" @@ -31,6 +32,7 @@ def test_get_image_color_mode(image, expected_mode): # Invalid image: 4D array np.random.rand(10, 10, 10, 3).astype(np.float32), ], + ids=["Invalid channel count", "1D array", "4D array"], ) def test_get_image_color_mode_should_fail(image): """Test the get_image_color_mode function with invalid inputs.""" @@ -50,10 +52,11 @@ def test_get_image_color_mode_should_fail(image): # RGBA color array (2D array with shape (n, 4)) (np.array([[255, 0, 0, 255], [0, 255, 0, 255], [0, 0, 255, 255]], dtype=np.uint8), ColorMode.RGBA), ], + ids=["Grayscale 1D", "Grayscale 2D", "RGB", "RGBA"], ) -def test_get_color_array_color_mode(color_array, expected_mode): - """Test the get_color_array_color_mode function with valid inputs.""" - assert draw.get_color_array_color_mode(color_array) == expected_mode +def test_get_color_mode(color_array, expected_mode): + """Test the get_color_mode function with valid inputs.""" + assert draw.get_color_mode(color_array) == expected_mode @pytest.mark.parametrize( @@ -66,11 +69,12 @@ def test_get_color_array_color_mode(color_array, expected_mode): # Invalid color array: 0-dimensional array np.array(128, dtype=np.uint8), ], + ids=["Invalid channel count", "3D array", "0-dimensional array"], ) -def test_get_color_array_color_mode_should_fail(color_array): - """Test the get_color_array_color_mode function with invalid inputs.""" +def test_get_color_mode_should_fail(color_array): + """Test the get_color_mode function with invalid inputs.""" with pytest.raises(ValueError): - draw.get_color_array_color_mode(color_array) + draw.get_color_mode(color_array) @pytest.mark.parametrize( @@ -101,6 +105,14 @@ def test_get_color_array_color_mode_should_fail(color_array): # Case with empty colors and groups (np.array([], dtype=np.uint8), [], np.array([], dtype=np.uint8)), ], + ids=[ + "Simple groups and distinct colors", + "Single group", + "Groups of 1 element", + "Empty colors array", + "Empty groups", + "Empty colors and groups", + ], ) def test_blend_colors(colors, index_groups, expected): """Test the blend_colors function.""" @@ -116,6 +128,7 @@ def test_blend_colors(colors, index_groups, expected): # Case with negative index (np.array([[255, 0, 0], [0, 255, 0]]), [[-3, 1]]), ], + ids=["Out-of-bounds indices", "Negative index"], ) def test_blend_colors_should_fail(colors, index_groups): """Test error handling in blend_colors function.""" @@ -161,6 +174,14 @@ def test_blend_colors_should_fail(colors, index_groups): # Interpolation with two steps (should return start and end colors only) (np.array([0, 255, 0]), np.array([0, 0, 255]), 2, np.array([[0, 255, 0], [0, 0, 255]])), ], + ids=[ + "Basic interpolation from red to blue", + "Interpolation from RGBA red to RGBA blue (with alpha channel)", + "Interpolation in grayscale", + "Interpolation with more steps", + "Interpolation with a single step", + "Interpolation with two steps", + ], ) def test_interpolate_values(start_color, end_color, num_steps, expected_output): """Test the vectorized_interpolate function with various input scenarios.""" @@ -178,8 +199,131 @@ def test_interpolate_values(start_color, end_color, num_steps, expected_output): # Invalid type for start or end color ("255, 0, 0", np.array([0, 0, 255]), 3, AttributeError), ], + ids=["Mismatched shapes", "Invalid number of steps (less than 1)", "Invalid type for start or end color"], ) def test_interpolate_values_should_fail(start_color, end_color, num_steps, expected_exception): """Test the vectorized_interpolate function with invalid input scenarios.""" with pytest.raises(expected_exception): draw.interpolate_values(start_color, end_color, num_steps) + + +@pytest.mark.parametrize( + "attribute, per_channel_normalization, expected", + [ + # single scalar attribute. + (np.array([-5]), False, np.array([0], dtype=np.uint8)), + # uint8 scalar attribute. + ( + np.array([128, 64, 192], dtype=np.uint8), + False, + np.array([128, 64, 192], dtype=np.uint8), + ), + # uint8 "1.5D" attribute. + ( + np.array([[128], [64], [192]], dtype=np.uint8), + False, + np.array([128, 64, 192], dtype=np.uint8), + ), + # uint8 2D attribute. + ( + np.array([[128, 192], [64, 32]], dtype=np.uint8), + False, + np.array([[128, 192, 0], [64, 32, 0]], dtype=np.uint8), + ), + # uint8 RGBA attribute. + ( + np.array([[128, 64, 192, 32], [100, 150, 200, 50]], dtype=np.uint8), + False, + np.array([[128, 64, 192, 32], [100, 150, 200, 50]], dtype=np.uint8), + ), + # int32 RGB attribute and per_channel_normalization=True + ( + np.array([[200, 0, 500], [-200, 0, 250], [100, 0, 0]], dtype=np.int32), + True, + np.array([[255, 0, 255], [0, 0, 128], [191, 0, 0]], dtype=np.uint8), + ), + # int32 RGB attribute and per_channel_normalization=False + ( + np.array([[200, 0, 500], [-200, 0, 250], [100, 0, 0]], dtype=np.int32), + False, + np.array([[178, 128, 255], [76, 128, 191], [153, 128, 128]], dtype=np.uint8), + ), + # float32 attribute and per_channel_normalization=True + ( + np.array([[-2.0, 0.0, 0.0, 0.5], [0.0, 1.0, 0.0, 0.5], [0.0, 0.0, 1.0, 0.5]]), + True, + np.array([[0, 0, 0, 128], [128, 255, 0, 128], [128, 0, 255, 128]], dtype=np.uint8), + ), + # float32 attribute and per_channel_normalization=False + ( + np.array([[-2.0, 0.0, 0.0, 0.5], [0.0, 1.0, 0.0, 0.5], [0.0, 0.0, 1.0, 0.5]]), + False, + np.array([[0, 128, 128, 159], [128, 191, 128, 159], [128, 128, 191, 159]], dtype=np.uint8), + ), + # 3D with 1 channel -> squeezed to (2, 3) + ( + np.array([[[1], [2]], [[3], [4]]], dtype=np.uint8), + False, + np.array([[1, 2, 0], [3, 4, 0]], dtype=np.uint8), + ), + # 1 NAN value + ( + np.array([[np.nan, 0, 0], [0, 1, 0], [0, 0, 1]]), + True, + np.array([[0, 0, 0], [0, 255, 0], [0, 0, 255]], dtype=np.uint8), + ), + # All NAN values + ( + np.array([[np.nan, np.nan, np.nan], [np.nan, np.nan, np.nan], [np.nan, np.nan, np.nan]]), + True, + np.array([[0, 0, 0], [0, 0, 0], [0, 0, 0]], dtype=np.uint8), + ), + # infinities + ( + np.array([[np.inf, 0, 0], [0, -np.inf, 0], [0, 0, np.inf]]), + True, + np.array([[255, 128, 0], [0, 0, 0], [0, 128, 255]], dtype=np.uint8), + ), + ], + ids=[ + "single scalar", + "uint8 scalars", + "uint8 1.5D", + "uint8 2D", + "uint8 RGBA", + "int32 RGB, normalize per channel", + "int32 RGB, normalize globally", + "float32 RGBA per channel", + "float32 RGBA global", + "3D with 1 channel", + "1 NAN value", + "all NAN values", + "infinities", + ], +) +def test_attribute_to_color(attribute, per_channel_normalization, expected): + """Test the attribute_to_color function with valid inputs.""" + result = draw.attribute_to_color(attribute, normalize_per_channel=per_channel_normalization) + assert result.dtype == np.uint8 + assert len(result) == len(attribute) # The number of colors should match the number of attributes. + np.testing.assert_array_equal(result, expected) + + +@pytest.mark.parametrize( + "attribute", + [ + # Empty attribute + np.array([]), + # 0-dimensional attribute + np.array(5), + # 2D with 5 channels + np.array([[1, 2, 3, 4, 5], [6, 7, 8, 9, 10]]), + # 3D with 2 channels + np.array([[[1, 2], [3, 4]], [[5, 6], [7, 8]]]), + ], + ids=["empty", "0-dimensional attribute", "2D with 5 channels", "3D with 2 channels"], +) +def test_attribute_to_color_should_fail(attribute): + """Test the attribute_to_color function with invalid inputs.""" + with pytest.raises(ValueError): + draw.attribute_to_color(attribute) diff --git a/tests/readyplayerme/meshops/unit/test_image.py b/tests/readyplayerme/meshops/unit/test_image.py index 7589e56..a6599e9 100644 --- a/tests/readyplayerme/meshops/unit/test_image.py +++ b/tests/readyplayerme/meshops/unit/test_image.py @@ -8,7 +8,7 @@ from readyplayerme.meshops import draw from readyplayerme.meshops.mesh import Mesh -from readyplayerme.meshops.types import Color, ColorMode, Edges, Image, PixelCoord +from readyplayerme.meshops.types import Color, ColorMode, Edges, Faces, Image, PixelCoord, UVs @pytest.mark.parametrize( @@ -57,6 +57,14 @@ np.full((2, 2, 3), 0.5), # Expected output: gray RGB image ), ], + ids=[ + "Basic blending with a uniform mask", + "Blending with different mask values", + "Mask with all zeros (full image_1)", + "Mask with all ones (full image_2)", + "Non uniform mask values:", + "Full RGB Image", + ], ) def test_blend_images(image_1: Image, image_2: Image, mask: Image, expected_output: Image): """Test the blend_images function with various input scenarios.""" @@ -84,6 +92,7 @@ def test_blend_images(image_1: Image, image_2: Image, mask: Image, expected_outp ValueError, ), ], + ids=["Shape mismatch", "Invalid input type", "Empty mask"], ) def test_blend_images_should_fail(image_1: Image, image_2: Image, mask: Image, expected_exception: Image): """Test the blend_images function with invalid input scenarios.""" @@ -113,6 +122,17 @@ def test_blend_images_should_fail(image_1: Image, image_2: Image, mask: Image, e # NaNs at both ends (np.array([np.nan, 2, np.nan]), np.array([2, 2, 2])), ], + ids=[ + "All NaNs", + "No NaNs", + "Single Element", + "Single NaN", + "Interpolation with NaNs in the middle", + "Interpolation with multiple NaNs", + "NaN at the beginning", + "NaN at the end", + "NaNs at both ends", + ], ) def test_interpolate_segment(input_segment: Image, expected_output: Image): """Test the interpolate_segment function with various input scenarios.""" @@ -123,7 +143,7 @@ def test_interpolate_segment(input_segment: Image, expected_output: Image): @pytest.mark.parametrize( "image, edges, image_coords, colors, interpolate_func, expected_output", [ - # Empty Edges with Mocked Data + # Empty Edges ( np.zeros((5, 5, 3), dtype=np.uint8), np.array([]), @@ -132,7 +152,7 @@ def test_interpolate_segment(input_segment: Image, expected_output: Image): lambda color0, color1, steps: np.array([[100, 100, 100]] * steps, dtype=np.uint8), # noqa: ARG005 np.zeros((5, 5, 3), dtype=np.uint8), ), - # Test with RGBA image + # RGBA image ( np.zeros((5, 5, 4), dtype=np.uint8), # RGBA image array np.array([[0, 1]]), # Edge from point 0 to point 1 @@ -150,7 +170,7 @@ def test_interpolate_segment(input_segment: Image, expected_output: Image): dtype=np.uint8, ), ), - # Test with grayscale image, grayscale 2D colors (2,1) + # Grayscale image, grayscale 2D colors (2,1) ( np.zeros((5, 5), dtype=np.uint8), # Grayscale image array np.array([[0, 1]]), # Edge from point 0 to point 1 @@ -168,7 +188,7 @@ def test_interpolate_segment(input_segment: Image, expected_output: Image): dtype=np.uint8, ), ), - # Test with grayscale image, grayscale 1D colors (2,) + # Grayscale image, grayscale 1D colors (2,) ( np.zeros((5, 5), dtype=np.uint8), # Grayscale image array np.array([[0, 1]]), # Edge from point 0 to point 1 @@ -186,7 +206,7 @@ def test_interpolate_segment(input_segment: Image, expected_output: Image): dtype=np.uint8, ), ), - # Non-Existent Edge Points with Mocked Data + # Non-Existent Edge Points ( np.zeros((5, 5, 3), dtype=np.uint8), np.array([[0, 2]]), # Edge from point 0 to point 2 @@ -204,7 +224,7 @@ def test_interpolate_segment(input_segment: Image, expected_output: Image): dtype=np.uint8, ), ), - # Zero Length Lines with Mocked Data + # Zero Length Lines ( np.zeros((5, 5, 3), dtype=np.uint8), np.array([[0, 0]]), # Start and end points are the same @@ -223,6 +243,14 @@ def test_interpolate_segment(input_segment: Image, expected_output: Image): ), ), ], + ids=[ + "Empty Edges", + "RGBA image", + "Grayscale image, grayscale 2D colors (2,1)", + "Grayscale image, grayscale 1D colors (2,)", + "Non-Existent Edge Points", + "Zero Length Lines", + ], ) def test_draw_lines( image: Image, @@ -274,6 +302,20 @@ def test_draw_lines( # All NaN Arrays (np.array([[np.nan, np.nan], [np.nan, np.nan]]), np.array([[np.nan, np.nan], [np.nan, np.nan]])), ], + ids=[ + "Interpolate NaNs in-between valid values", + "Horizontal interpolation with multiple columns", + "Multiple NaNs in-between", + "No NaNs in-between", + "NaNs only at the edges should remain as NaNs", + "Single NaN in-between", + "All NaNs except edges", + "Single-Row Array with NaN in-between", + "Empty Arrays", + "Single Element Arrays", + "Arrays with No NaN Values", + "All NaN Arrays", + ], ) def test_lerp_nans_horizontally(input_array: Image, expected_output: Image): """Test vectorized_lerp_nans_vertically function with various input scenarios.""" @@ -293,8 +335,9 @@ def test_lerp_nans_horizontally(input_array: Image, expected_output: Image): ), # Edge cases (np.array([[np.nan], [2], [3]]), np.array([[2], [2], [3]])), - # Multiple columns with nan edges + # Multiple columns with nan edges Grayscale (np.array([[1], [2], [np.nan]]), np.array([[1], [2], [2]])), + # Multiple columns with nan edges RGB ( np.array([[1, np.nan, 3], [4, 5, np.nan], [np.nan, 7, 5]]), np.array( @@ -322,6 +365,21 @@ def test_lerp_nans_horizontally(input_array: Image, expected_output: Image): # All NaN Arrays (np.array([[np.nan, np.nan], [np.nan, np.nan]]), np.array([[np.nan, np.nan], [np.nan, np.nan]])), ], + ids=[ + "Basic vertical interpolation single columns", + "Basic vertical interpolation multiple columns", + "Edge cases", + "Multiple columns with nan edges Grayscale", + "Multiple columns with nan edges RGB", + "Multiple consecutive NaNs", + "No NaNs", + "All NaNs", + "Single-column Array", + "Empty Arrays", + "Single Element Arrays", + "Arrays with No NaN Values", + "All NaN Arrays", + ], ) def test_lerp_nans_vertically(input_array: Image, expected_output: Image): """Test vectorized_lerp_nans_horizontally function with various input scenarios.""" @@ -336,6 +394,7 @@ def test_lerp_nans_vertically(input_array: Image, expected_output: Image): (100, 100, ColorMode.RGBA), # RGBA (100, 100, ColorMode.GRAYSCALE), # Grayscale ], + ids=["RGB", "RGBA", "Grayscale"], ) def test_create_nan_image(width: int, height: int, mode: ColorMode): """Test the create_nan_image function with valid inputs.""" @@ -354,6 +413,7 @@ def test_create_nan_image(width: int, height: int, mode: ColorMode): (100.5, 100, TypeError), # Float width (100, 100.5, TypeError), # Float height ], + ids=["Zero width", "Zero height", "Negative width", "Negative height", "Float width", "Float height"], ) def test_create_nan_image_should_fail(width: int, height: int, error: type[Exception]): """Test the create_nan_image function with invalid inputs.""" @@ -364,8 +424,10 @@ def test_create_nan_image_should_fail(width: int, height: int, error: type[Excep @pytest.mark.parametrize( "input_array, expected_output", [ - # No NaNs or Infinities - (np.array([[100, 150], [200, 250]], dtype=np.float32), np.array([[100, 150], [200, 250]], dtype=np.float32)), + # No NaNs or Infinities, uint8 + (np.array([[100, 150], [200, 250]], dtype=np.uint8), np.array([[100, 150], [200, 250]], dtype=np.uint8)), + # No NaNs or Infinities, float + (np.array([[1.0, 0.0], [0.5, 0.25]], dtype=np.float32), np.array([[1.0, 0.0], [0.5, 0.25]], dtype=np.float32)), # Contains NaNs (np.array([[np.nan, 150], [200, np.nan]], dtype=np.float32), np.array([[0, 150], [200, 0]], dtype=np.float32)), # Contains Positive and Negative Infinities @@ -378,9 +440,23 @@ def test_create_nan_image_should_fail(width: int, height: int, error: type[Excep np.array([[np.nan, -np.inf], [np.inf, np.nan]], dtype=np.float32), np.array([[0, 0], [255, 0]], dtype=np.float32), ), + # Mix of finite, NaNs and Infinities + ( + np.array([[np.nan, 150, -np.inf], [np.inf, 75, np.nan]], dtype=np.float32), + np.array([[0, 150, 0], [255, 75, 0]], dtype=np.float32), + ), # Values Exceeding the Range [0, 255] (np.array([[300, -100], [500, 600]], dtype=np.float32), np.array([[255, 0], [255, 255]], dtype=np.float32)), ], + ids=[ + "Finites only, uint8", + "Finites only, float", + "NaNs", + "Positive and Negative Infinities", + "Mix of NaNs and Infinities", + "Mix of finite, NaNs and Infinities", + "Values Exceeding the Range [0, 255]", + ], ) def test_clip_image(input_array: Image, expected_output: Image): """Test the clean_image function with various input scenarios.""" @@ -427,6 +503,7 @@ def test_clip_image(input_array: Image, expected_output: Image): ), ) ], + ids=["Basic Functionality"], ) def test_create_mask(width: int, height: int, coords: npt.NDArray[np.int16], expected_output: Image): """Test the create_mask function with valid inputs.""" @@ -469,6 +546,7 @@ def test_create_mask(width: int, height: int, coords: npt.NDArray[np.int16], exp np.array([[[255, 0, 0]]], dtype=np.float32), ), ], + ids=["Basic Functionality", "No Edges", "Single Pixel Image"], ) def test_rasterize( image: Image, @@ -549,6 +627,13 @@ def test_rasterize( ValueError, # Expecting a ValueError due to mismatched color modes ), ], + ids=[ + "Out of Bounds Edges", + "Zero Dimensions", + "Mismatched Array Sizes", + "Mismatched Color Modes (Grayscale+RGB)", + "Mismatched Color Modes (RGB+Grayscale)", + ], ) def test_rasterize_should_fail( image: Image, @@ -694,16 +779,142 @@ def test_rasterize_should_fail( ), ), ], + ids=["Grayscale image", "RGB image", "RGBA image"], indirect=["mock_mesh"], ) def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): """Test the blend_uv_seams function with valid inputs.""" - if image.ndim > 2: # Debug: Skip RGB & RGBA tests to see if at least grayscale works. - pytest.skip("Skipping test of RGB & RGBA for debugging purposes.") output = draw.blend_uv_seams(mock_mesh, image) np.testing.assert_array_equal(output, expected_output) +def test_blend_uv_seams_should_fail(mock_mesh: Mesh): + """Test the blend_uv_seams function fails when mesh has no UV coordinates.""" + mock_mesh.uv_coords = None + image = np.zeros((8, 8), dtype=np.uint8) + with pytest.raises(ValueError, match="^(UV coordinates are invalid:).*"): + draw.blend_uv_seams(mock_mesh, image) + + +@pytest.mark.parametrize( + "width, height, faces, uvs, attribute, padding, normalize_per_channel, expected_output", + [ + # TODO: Add more tests: UV, 0-dim, normalize_per_channel + # Scalar value attribute. + ( + 4, + 4, + np.array([[0, 1, 2]]), + np.array([[0, 0], [1, 0], [0, 1]]), + np.array([0.0, 0.5, 1.0]), + 0, + False, + np.array( + [[255, 0, 0, 0], [170, 212, 0, 0], [85, 127, 170, 0], [0, 42, 85, 128]], + dtype=np.uint8, + ), + ), + # RGB color attribute. + ( + 4, + 4, + np.array([[0, 1, 2]]), + np.array([[0, 0], [1, 0], [0, 1]]), + np.array([[255, 0, 0], [0, 255, 0], [0, 0, 255]]), + 0, + False, + np.array( + [ + [[0, 0, 255], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + [[85, 0, 170], [0, 85, 170], [0, 0, 0], [0, 0, 0]], + [[170, 0, 85], [85, 85, 85], [0, 170, 85], [0, 0, 0]], + [[255, 0, 0], [170, 85, 0], [85, 170, 0], [0, 255, 0]], + ], + dtype=np.uint8, + ), + ), + # RGBA float attribute. + ( + 4, + 2, + np.array([[0, 1, 2]]), + np.array([[0, 0], [1, 0], [0, 1]]), + np.array([[-2.0, 0.0, 0.0, 0.5], [0.0, 1.0, 0.0, 0.5], [0.0, 0.0, 1.0, 0.5]]), + 0, + False, + np.array( + [ + [[128, 128, 191, 159], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], + [[0, 128, 128, 159], [42, 149, 128, 159], [128, 170, 149, 159], [128, 191, 128, 159]], + ], + dtype=np.uint8, + ), + ), + ], + ids=["Scalar value attribute", "RGB color attribute", "RGBA float attribute"], +) +def test_get_vertex_attribute_image( + width: int, + height: int, + faces: Faces, + uvs: UVs, + attribute: Color, + padding: int, + normalize_per_channel: bool, # noqa: FBT001 + expected_output: Image, +): + """Test the get_vertex_attribute_image function with valid inputs.""" + image = draw.get_vertex_attribute_image( + width, height, faces, uvs, attribute, padding, normalize_per_channel=normalize_per_channel + ) + np.testing.assert_array_equal(image, expected_output) + assert image.shape[:2] == ( + height, + width, + ), f"Image shape {image.shape} does not match expected shape {(height, width)}." + assert image.dtype == np.uint8, f"Image dtype {image.dtype} does not match expected data type uint8." + + +@pytest.mark.parametrize( + "faces, uvs, attribute, error_message", + [ + # No UVs. + ( + np.array([[0, 1, 2]]), + None, + np.array([[255, 0, 0], [0, 255, 0], [0, 0, 255]]), + "^(UV coordinates are invalid:).*", + ), + # Mismatched UVs length. + ( + np.array([[0, 1, 2]]), + np.array([[0, 0], [1, 0]]), + np.array([[255, 0, 0], [0, 255, 0], [0, 0, 255]]), + "^(UV coordinates are invalid: Too few UV coordinates.).*", + ), + # Mismatched attribute length. + ( + np.array([[0, 1, 2]]), + np.array([[0, 0], [1, 0], [0, 1]]), + np.array([[255, 0, 0], [0, 255, 0]]), + "^(Attribute length does not match UV coordinates length:).*", + ), + # Unsupported color mode. + ( + np.array([[0, 1, 2]]), + np.array([[0, 0], [1, 0], [0, 1]]), + np.array([[0, 1, 2, 3, 4], [5, 6, 7, 8, 9], [10, 11, 12, 13, 14]]), + "^(Attribute shape is unsupported for image conversion).*", + ), + ], + ids=["No UVs", "Mismatched UVs length", "Mismatched attribute length", "Unsupported color mode"], +) +def test_get_vertex_attribute_image_should_fail(faces: Faces, uvs: UVs, attribute: Color, error_message: str): + """Test the get_vertex_attribute_image function fails when provided data is incompatible.""" + with pytest.raises(ValueError, match=error_message): + draw.get_vertex_attribute_image(8, 8, faces, uvs, attribute) + + @pytest.mark.parametrize( "mock_mesh, padding, expected_output", [ @@ -713,68 +924,68 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): 0, np.array( [ - [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [255, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [167, 74, 78], [0, 0, 0], [0, 0, 0]], [ - [0, 0, 0], - [51, 25, 0], - [102, 50, 0], - [153, 76, 0], - [204, 101, 0], - [255, 127, 0], + [59, 74, 78], + [80, 84, 78], + [102, 95, 78], + [123, 106, 78], + [145, 117, 78], + [167, 128, 78], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 51], - [0, 85, 85], - [0, 170, 0], - [63, 180, 0], - [127, 191, 0], + [59, 74, 99], + [59, 109, 114], + [59, 145, 78], + [86, 149, 78], + [113, 154, 78], [0, 0, 0], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 102], - [0, 170, 170], - [0, 212, 85], - [0, 255, 0], - [255, 255, 0], - [255, 191, 0], - [255, 127, 0], + [59, 74, 121], + [59, 145, 150], + [59, 163, 114], + [59, 181, 78], + [167, 181, 78], + [167, 154, 78], + [167, 128, 78], [0, 0, 0], ], [ - [0, 0, 153], - [0, 127, 204], - [0, 255, 255], - [127, 191, 255], - [255, 191, 127], - [255, 127, 127], + [59, 74, 142], + [59, 127, 164], + [59, 181, 186], + [113, 154, 186], + [167, 154, 132], + [167, 128, 132], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 204], - [0, 127, 255], - [127, 63, 255], - [191, 95, 255], - [255, 127, 255], - [255, 63, 127], + [59, 74, 164], + [59, 127, 186], + [113, 101, 186], + [140, 114, 186], + [167, 128, 186], + [167, 101, 132], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 255], - [63, 31, 255], - [127, 63, 255], - [191, 63, 255], - [255, 63, 255], + [59, 74, 186], + [86, 87, 186], + [113, 101, 186], + [140, 101, 186], + [167, 101, 186], [0, 0, 0], [0, 0, 0], [0, 0, 0], ], - [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [255, 0, 255], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [167, 74, 186], [0, 0, 0], [0, 0, 0], [0, 0, 0]], ], dtype=np.uint8, ), @@ -785,68 +996,68 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): -4, np.array( [ - [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [255, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [167, 74, 78], [0, 0, 0], [0, 0, 0]], [ - [0, 0, 0], - [51, 25, 0], - [102, 50, 0], - [153, 76, 0], - [204, 101, 0], - [255, 127, 0], + [59, 74, 78], + [80, 84, 78], + [102, 95, 78], + [123, 106, 78], + [145, 117, 78], + [167, 128, 78], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 51], - [0, 85, 85], - [0, 170, 0], - [63, 180, 0], - [127, 191, 0], + [59, 74, 99], + [59, 109, 114], + [59, 145, 78], + [86, 149, 78], + [113, 154, 78], [0, 0, 0], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 102], - [0, 170, 170], - [0, 212, 85], - [0, 255, 0], - [255, 255, 0], - [255, 191, 0], - [255, 127, 0], + [59, 74, 121], + [59, 145, 150], + [59, 163, 114], + [59, 181, 78], + [167, 181, 78], + [167, 154, 78], + [167, 128, 78], [0, 0, 0], ], [ - [0, 0, 153], - [0, 127, 204], - [0, 255, 255], - [127, 191, 255], - [255, 191, 127], - [255, 127, 127], + [59, 74, 142], + [59, 127, 164], + [59, 181, 186], + [113, 154, 186], + [167, 154, 132], + [167, 128, 132], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 204], - [0, 127, 255], - [127, 63, 255], - [191, 95, 255], - [255, 127, 255], - [255, 63, 127], + [59, 74, 164], + [59, 127, 186], + [113, 101, 186], + [140, 114, 186], + [167, 128, 186], + [167, 101, 132], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 255], - [63, 31, 255], - [127, 63, 255], - [191, 63, 255], - [255, 63, 255], + [59, 74, 186], + [86, 87, 186], + [113, 101, 186], + [140, 101, 186], + [167, 101, 186], [0, 0, 0], [0, 0, 0], [0, 0, 0], ], - [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [255, 0, 255], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [167, 74, 186], [0, 0, 0], [0, 0, 0], [0, 0, 0]], ], dtype=np.uint8, ), @@ -858,90 +1069,92 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): np.array( [ [ - [51, 25, 0], - [102, 50, 0], - [153, 76, 0], - [204, 101, 0], - [255, 127, 0], - [255, 0, 0], - [255, 127, 0], - [255, 127, 0], - ], - [ - [0, 0, 0], - [51, 25, 0], - [102, 50, 0], - [153, 76, 0], - [204, 101, 0], - [255, 127, 0], - [255, 191, 0], - [255, 127, 0], - ], - [ - [0, 0, 51], - [0, 85, 85], - [0, 170, 0], - [63, 180, 0], - [127, 191, 0], - [255, 255, 0], - [255, 255, 0], - [255, 191, 0], - ], - [ - [0, 0, 102], - [0, 170, 170], - [0, 212, 85], - [0, 255, 0], - [255, 255, 0], - [255, 191, 0], - [255, 127, 0], - [255, 191, 127], - ], - [ - [0, 0, 153], - [0, 127, 204], - [0, 255, 255], - [127, 191, 255], - [255, 191, 127], - [255, 127, 127], - [255, 255, 255], - [255, 191, 127], - ], - [ - [0, 0, 204], - [0, 127, 255], - [127, 63, 255], - [191, 95, 255], - [255, 127, 255], - [255, 63, 127], - [255, 255, 255], - [255, 191, 127], - ], - [ - [0, 0, 255], - [63, 31, 255], - [127, 63, 255], - [191, 63, 255], - [255, 63, 255], - [255, 191, 255], - [255, 191, 255], - [255, 127, 127], - ], - [ - [63, 127, 255], - [127, 127, 255], - [191, 127, 255], - [255, 127, 255], - [255, 0, 255], - [255, 127, 255], - [255, 127, 255], - [255, 63, 127], + [80, 84, 78], + [102, 95, 78], + [123, 106, 78], + [145, 117, 78], + [167, 128, 78], + [167, 74, 78], + [167, 128, 78], + [167, 128, 78], + ], + [ + [59, 74, 78], + [80, 84, 78], + [102, 95, 78], + [123, 106, 78], + [145, 117, 78], + [167, 128, 78], + [167, 154, 78], + [167, 128, 78], + ], + [ + [59, 74, 99], + [59, 109, 114], + [59, 145, 78], + [86, 149, 78], + [113, 154, 78], + [167, 181, 78], + [167, 181, 78], + [167, 154, 78], + ], + [ + [59, 74, 121], + [59, 145, 150], + [59, 163, 114], + [59, 181, 78], + [167, 181, 78], + [167, 154, 78], + [167, 128, 78], + [167, 154, 132], + ], + [ + [59, 74, 142], + [59, 127, 164], + [59, 181, 186], + [113, 154, 186], + [167, 154, 132], + [167, 128, 132], + [167, 181, 186], + [167, 154, 132], + ], + [ + [59, 74, 164], + [59, 127, 186], + [113, 101, 186], + [140, 114, 186], + [167, 128, 186], + [167, 101, 132], + [167, 181, 186], + [167, 154, 132], + ], + [ + [59, 74, 186], + [86, 87, 186], + [113, 101, 186], + [140, 101, 186], + [167, 101, 186], + [167, 154, 186], + [167, 154, 186], + [167, 128, 132], + ], + [ + [86, 127, 186], + [113, 127, 186], + [140, 127, 186], + [167, 128, 186], + [167, 74, 186], + [167, 128, 186], + [167, 128, 186], + [167, 101, 132], ], ], dtype=np.uint8, ), ), + # TODO: tests for center and uniform_scale ], + ids=["No Padding", "Negative Padding", "Positive Padding"], indirect=["mock_mesh"], ) def test_get_position_map(mock_mesh: Mesh, padding: int, expected_output: Image): @@ -949,3 +1162,284 @@ def test_get_position_map(mock_mesh: Mesh, padding: int, expected_output: Image) width = height = 8 output = draw.get_position_map(width, height, mock_mesh, padding=padding) np.testing.assert_array_equal(output, expected_output) + + +def test_get_position_map_should_fail(mock_mesh: Mesh): + """Test the get_position_map function fails when mesh has no UV coordinates.""" + mock_mesh.uv_coords = None + width = height = 8 + with pytest.raises(ValueError, match="^(UV coordinates are invalid:).*"): + draw.get_position_map(width, height, mock_mesh) + + +@pytest.mark.parametrize( + "mock_mesh, padding, expected_output", + [ + # No Padding + ( + "mock_mesh", + 0, + np.array( + [ + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [128, 128, 0], [0, 0, 0], [0, 0, 0]], + [ + [37, 128, 37], + [55, 128, 29], + [73, 128, 22], + [91, 128, 14], + [109, 128, 7], + [128, 128, 0], + [0, 0, 0], + [0, 0, 0], + ], + [ + [37, 128, 73], + [42, 152, 91], + [48, 176, 48], + [69, 170, 37], + [91, 164, 27], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + [ + [37, 128, 109], + [48, 176, 146], + [51, 188, 100], + [54, 201, 54], + [201, 201, 54], + [228, 164, 91], + [255, 128, 128], + [0, 0, 0], + ], + [ + [37, 128, 145], + [45, 164, 173], + [54, 201, 201], + [136, 164, 209], + [209, 164, 136], + [236, 128, 173], + [0, 0, 0], + [0, 0, 0], + ], + [ + [37, 128, 181], + [45, 164, 209], + [127, 128, 218], + [172, 128, 218], + [218, 128, 218], + [228, 128, 180], + [0, 0, 0], + [0, 0, 0], + ], + [ + [37, 128, 218], + [82, 128, 218], + [127, 128, 218], + [168, 128, 221], + [209, 128, 225], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [201, 128, 232], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + ], + dtype=np.uint8, + ), + ), + # Negative Padding + ( + "mock_mesh", + -4, + np.array( + [ + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [128, 128, 0], [0, 0, 0], [0, 0, 0]], + [ + [37, 128, 37], + [55, 128, 29], + [73, 128, 22], + [91, 128, 14], + [109, 128, 7], + [128, 128, 0], + [0, 0, 0], + [0, 0, 0], + ], + [ + [37, 128, 73], + [42, 152, 91], + [48, 176, 48], + [69, 170, 37], + [91, 164, 27], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + [ + [37, 128, 109], + [48, 176, 146], + [51, 188, 100], + [54, 201, 54], + [201, 201, 54], + [228, 164, 91], + [255, 128, 128], + [0, 0, 0], + ], + [ + [37, 128, 145], + [45, 164, 173], + [54, 201, 201], + [136, 164, 209], + [209, 164, 136], + [236, 128, 173], + [0, 0, 0], + [0, 0, 0], + ], + [ + [37, 128, 181], + [45, 164, 209], + [127, 128, 218], + [172, 128, 218], + [218, 128, 218], + [228, 128, 180], + [0, 0, 0], + [0, 0, 0], + ], + [ + [37, 128, 218], + [82, 128, 218], + [127, 128, 218], + [168, 128, 221], + [209, 128, 225], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [201, 128, 232], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + ], + dtype=np.uint8, + ), + ), + # Positive Padding + ( + "mock_mesh", + 4, + np.array( + [ + [ + [55, 128, 37], + [73, 128, 37], + [91, 128, 37], + [109, 128, 29], + [128, 128, 22], + [128, 128, 0], + [128, 128, 7], + [128, 128, 0], + ], + [ + [37, 128, 37], + [55, 128, 29], + [73, 128, 22], + [91, 128, 14], + [109, 128, 7], + [128, 128, 0], + [128, 164, 27], + [128, 128, 0], + ], + [ + [37, 128, 73], + [42, 152, 91], + [48, 176, 48], + [69, 170, 37], + [91, 164, 27], + [255, 201, 128], + [255, 201, 128], + [255, 164, 128], + ], + [ + [37, 128, 109], + [48, 176, 146], + [51, 188, 100], + [54, 201, 54], + [201, 201, 54], + [228, 164, 91], + [255, 128, 128], + [255, 164, 173], + ], + [ + [37, 128, 145], + [45, 164, 173], + [54, 201, 201], + [136, 164, 209], + [209, 164, 136], + [236, 128, 173], + [255, 201, 218], + [255, 164, 180], + ], + [ + [37, 128, 181], + [45, 164, 209], + [127, 128, 218], + [172, 128, 218], + [218, 128, 218], + [228, 128, 180], + [255, 201, 225], + [255, 164, 180], + ], + [ + [37, 128, 218], + [82, 128, 218], + [127, 128, 218], + [168, 128, 221], + [209, 128, 225], + [236, 164, 232], + [236, 164, 232], + [236, 128, 180], + ], + [ + [82, 164, 218], + [127, 164, 218], + [172, 164, 221], + [218, 164, 232], + [201, 128, 232], + [228, 128, 232], + [228, 128, 232], + [228, 128, 180], + ], + ], + dtype=np.uint8, + ), + ), + ], + ids=["No Padding", "Negative Padding", "Positive Padding"], + indirect=["mock_mesh"], +) +def test_get_obj_space_normal_map(mock_mesh: Mesh, padding: int, expected_output: Image): + """Test the get_obj_space_normal_map function with valid inputs.""" + width = height = 8 + output = draw.get_obj_space_normal_map(width, height, mock_mesh, padding=padding) + np.testing.assert_array_equal(output, expected_output) + + +@pytest.mark.parametrize( + "mock_mesh, missing, error_pattern", + [ + ( + "mock_mesh", + "uv_coords", + "^(UV coordinates are invalid:).*", + ), + ( + "mock_mesh", + "normals", + "Mesh does not have vertex normals.", + ), + ], + ids=["No UVs", "No Normals"], + indirect=["mock_mesh"], +) +def test_get_obj_space_normal_map_should_fail(mock_mesh: Mesh, missing: str, error_pattern: str): + """Test the get_obj_space_normal_map function fails when mesh has no vertex normals.""" + setattr(mock_mesh, missing, None) + width = height = 8 + with pytest.raises(ValueError, match=error_pattern): + draw.get_obj_space_normal_map(width, height, mock_mesh) diff --git a/tests/readyplayerme/meshops/unit/test_mesh.py b/tests/readyplayerme/meshops/unit/test_mesh.py index cbe5a87..343e5b4 100644 --- a/tests/readyplayerme/meshops/unit/test_mesh.py +++ b/tests/readyplayerme/meshops/unit/test_mesh.py @@ -1,4 +1,5 @@ """Unit tests for the mesh module.""" + import types from collections.abc import Callable from pathlib import Path @@ -17,6 +18,7 @@ class TestReadMesh: @pytest.mark.parametrize( "filepath, expected", [("test.glb", mesh.read_gltf), (Path("test.glb"), mesh.read_gltf), ("test.gltf", mesh.read_gltf)], + ids=["str .glb", "Path .glb", "str .gltf"], ) def test_get_mesh_reader_glb(self, filepath: str | Path, expected: Callable[[str | Path], mesh.Mesh]): """Test the get_mesh_reader function with a .glb file path.""" @@ -24,7 +26,11 @@ def test_get_mesh_reader_glb(self, filepath: str | Path, expected: Callable[[str assert callable(reader), "The mesh reader should be a callable function." 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"]) + @pytest.mark.parametrize( + "filepath", + ["test", "test.obj", Path("test.stl"), "test.fbx", "test.abc", "test.ply"], + ids=["no extension", ".obj", ".stl", ".fbx", ".abc", ".ply"], + ) 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): @@ -92,6 +98,12 @@ def test_get_boundary_vertices(mock_mesh: mesh.Mesh): # Overlapping vertices, but empty indices given. (np.array([[1.0, 1.0, 1.0], [1.0, 1.0, 1.0]]), np.array([], dtype=np.int32), 0.1, []), ], + ids=[ + "mock_mesh", + "close positions with imprecision", + "overlapping vertices, None indices", + "overlapping vertices, empty indices", + ], ) def test_get_overlapping_vertices( vertices: Vertices, indices: Indices, precision: float, expected: IndexGroups, request: pytest.FixtureRequest @@ -105,7 +117,7 @@ def test_get_overlapping_vertices( assert len(grouped_vertices) == len(expected), "Number of groups doesn't match expected" for group, exp_group in zip(grouped_vertices, expected, strict=False): - assert np.array_equal(group, exp_group), f"Grouped vertices {group} do not match expected {exp_group}" + np.testing.assert_array_equal(group, exp_group, f"Grouped vertices {group} do not match expected {exp_group}") @pytest.mark.parametrize( @@ -116,6 +128,7 @@ def test_get_overlapping_vertices( # Case with index out of bounds (negative index) np.array([0, -1], dtype=np.int32), # Using int32 to allow negative values ], + ids=["index out of bounds (>max)", "index out of bounds ( no image space coordinates + (np.array([[0.5, 0.5], [0.25, 0.75]]), 50, 50, np.array([], dtype=np.uint8), np.empty((0, 2))), # 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) @@ -152,20 +174,33 @@ def test_get_overlapping_vertices_should_fail(indices): # 0 px image (np.array([[0.5, 0.5], [0.25, 0.75]]), 0, 0, np.array([0]), np.array([[0, 0]])), ], + ids=[ + "with indices", + "without indices (None)", + "near 0 and 1 UVs", + "empty indices []", + "out of range UV", + "wrapped UV", + "non square UV", + "1px image", + "0px image", + ], ) 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.""" + """Test that the uv_to_image_coords 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." + np.testing.assert_array_equal(image_space_coords, expected, "Image space coordinates do not match expected values.") @pytest.mark.parametrize( "uvs, width, height, indices", [ + # Too many 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])), ], + ids=["too many indices", "no UV coord"], ) 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.""" @@ -207,6 +242,7 @@ def test_get_faces_image_coords(mock_mesh): # No faces (np.array([[]]), np.array([[0.5, 0.5], [0.25, 0.75], [0.0, 0.0]]), 1, 1), ], + ids=["too few UV coords", "no UV coords", "no faces"], ) def test_get_faces_image_coords_should_fail(faces: Faces, uvs: UVs, width: int, height: int): """Test the get_faces_image_coords function raises expected exceptions."""