From 65b16990ed1d2f5af1760ceaab9882b5ac508b04 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Tue, 9 Jan 2024 15:11:49 +0100 Subject: [PATCH 01/16] chore: auto LF line endings --- .gitattributes | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitattributes 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 From f009463d89ee1ecaee1d089d590dbc6ba9afd932 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Tue, 9 Jan 2024 15:15:43 +0100 Subject: [PATCH 02/16] feat: render vertex attributes as image (WiP) generalized solution to turn arbitrary color attributes into images using a UV layout --- README.md | 6 + src/readyplayerme/meshops/draw/image.py | 107 +++- src/readyplayerme/meshops/mesh.py | 17 +- src/readyplayerme/meshops/types.py | 1 + tests/readyplayerme/meshops/unit/conftest.py | 17 +- .../readyplayerme/meshops/unit/test_image.py | 475 ++++++++++++++++-- tests/readyplayerme/meshops/unit/test_mesh.py | 13 +- 7 files changed, 560 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index c0a151f..cf68026 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ Position maps encode locations on the surface of a mesh as color values in an im 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. +## Object Space Normal Maps + +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. + ## UV Seams Transitioning UV seams are splits in a triangle mesh that, however, is supposed to represent a continuous surface across these splits. diff --git a/src/readyplayerme/meshops/draw/image.py b/src/readyplayerme/meshops/draw/image.py index d22ded0..2e9a468 100644 --- a/src/readyplayerme/meshops/draw/image.py +++ b/src/readyplayerme/meshops/draw/image.py @@ -286,7 +286,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,6 +314,71 @@ def blend_uv_seams(mesh: mops.Mesh, image: Image) -> Image: return blend_images(image, raster_image, blurred_mask).astype(np.uint8) +def get_vertex_attribute_image( + width: int, + height: int, + faces: mops.Faces, + uvs: mops.UVs, + attribute: Color, + padding: int = 4, +) -> 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 padding: Padding in pixels to add around the UV shells. Default 4. + :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: + attr_color_mode = get_color_array_color_mode(attribute) + except ValueError as error: + msg = f"Attribute shape is unsupported for image conversion: {attribute.shape}" + raise ValueError(msg) from error + + if attribute.dtype != np.uint8: + # Map the normalized attribute to the 8-bit color format. + colors = np.nan_to_num( + (attribute - attribute.min(axis=0, keepdims=True)) / np.ptp(attribute, axis=0, keepdims=True) + ) # Fixme per channel opt, extract to function + colors = skimage.util.img_as_ubyte(colors) + else: + colors = attribute + + # 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.value, 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) -> Image: """Get a position map from the given mesh. @@ -321,23 +390,21 @@ def get_position_map(width: int, height: int, mesh: mops.Mesh, padding: int = 4) :param padding: Padding in pixels to add around the UV shells. Default 4. :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 get_vertex_attribute_image(width, height, mesh.faces, mesh.uv_coords, mesh.vertices, padding=padding) + + +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. - return pos_map.astype(np.uint8) + 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) diff --git a/src/readyplayerme/meshops/mesh.py b/src/readyplayerme/meshops/mesh.py index aacddd2..5fd87d5 100644 --- a/src/readyplayerme/meshops/mesh.py +++ b/src/readyplayerme/meshops/mesh.py @@ -8,7 +8,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 +22,7 @@ class Mesh: edges: Edges faces: Faces uv_coords: UVs | None = None + normals: Normals | None = None material: Material | None = None @@ -63,15 +64,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 +137,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..be7eecd 100644 --- a/src/readyplayerme/meshops/types.py +++ b/src/readyplayerme/meshops/types.py @@ -8,6 +8,7 @@ # 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]] diff --git a/tests/readyplayerme/meshops/unit/conftest.py b/tests/readyplayerme/meshops/unit/conftest.py index a216dbb..f24d0f1 100644 --- a/tests/readyplayerme/meshops/unit/conftest.py +++ b/tests/readyplayerme/meshops/unit/conftest.py @@ -98,7 +98,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_image.py b/tests/readyplayerme/meshops/unit/test_image.py index 7589e56..c5e10b6 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( @@ -704,6 +704,118 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: 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, expected_output", + [ + # 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, + 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, + 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, + np.array( + [ + [[255, 0, 255, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], + [[0, 0, 0, 0], [85, 85, 0, 0], [255, 170, 85, 0], [255, 255, 0, 0]], + ], + dtype=np.uint8, + ), + ), + ], +) +def test_get_vertex_attribute_image( + width: int, height: int, faces: Faces, uvs: UVs, attribute: Color, padding: int, 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) + 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], [0, 1], [0, 1]]), + "^(Attribute shape is unsupported for image conversion).*", + ), + ], +) +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", [ @@ -717,10 +829,10 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): [ [0, 0, 0], [51, 25, 0], - [102, 50, 0], + [102, 51, 0], [153, 76, 0], - [204, 101, 0], - [255, 127, 0], + [204, 102, 0], + [255, 128, 0], [0, 0, 0], [0, 0, 0], ], @@ -741,7 +853,7 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): [0, 255, 0], [255, 255, 0], [255, 191, 0], - [255, 127, 0], + [255, 128, 0], [0, 0, 0], ], [ @@ -750,26 +862,26 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): [0, 255, 255], [127, 191, 255], [255, 191, 127], - [255, 127, 127], + [255, 128, 127], [0, 0, 0], [0, 0, 0], ], [ [0, 0, 204], [0, 127, 255], - [127, 63, 255], - [191, 95, 255], - [255, 127, 255], - [255, 63, 127], + [127, 64, 255], + [191, 96, 255], + [255, 128, 255], + [255, 64, 127], [0, 0, 0], [0, 0, 0], ], [ [0, 0, 255], - [63, 31, 255], - [127, 63, 255], - [191, 63, 255], - [255, 63, 255], + [63, 32, 255], + [127, 64, 255], + [191, 64, 255], + [255, 64, 255], [0, 0, 0], [0, 0, 0], [0, 0, 0], @@ -789,10 +901,10 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): [ [0, 0, 0], [51, 25, 0], - [102, 50, 0], + [102, 51, 0], [153, 76, 0], - [204, 101, 0], - [255, 127, 0], + [204, 102, 0], + [255, 128, 0], [0, 0, 0], [0, 0, 0], ], @@ -813,7 +925,7 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): [0, 255, 0], [255, 255, 0], [255, 191, 0], - [255, 127, 0], + [255, 128, 0], [0, 0, 0], ], [ @@ -822,26 +934,26 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): [0, 255, 255], [127, 191, 255], [255, 191, 127], - [255, 127, 127], + [255, 128, 127], [0, 0, 0], [0, 0, 0], ], [ [0, 0, 204], [0, 127, 255], - [127, 63, 255], - [191, 95, 255], - [255, 127, 255], - [255, 63, 127], + [127, 64, 255], + [191, 96, 255], + [255, 128, 255], + [255, 64, 127], [0, 0, 0], [0, 0, 0], ], [ [0, 0, 255], - [63, 31, 255], - [127, 63, 255], - [191, 63, 255], - [255, 63, 255], + [63, 32, 255], + [127, 64, 255], + [191, 64, 255], + [255, 64, 255], [0, 0, 0], [0, 0, 0], [0, 0, 0], @@ -859,23 +971,23 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): [ [ [51, 25, 0], - [102, 50, 0], + [102, 51, 0], [153, 76, 0], - [204, 101, 0], - [255, 127, 0], + [204, 102, 0], + [255, 128, 0], [255, 0, 0], - [255, 127, 0], - [255, 127, 0], + [255, 128, 0], + [255, 128, 0], ], [ [0, 0, 0], [51, 25, 0], - [102, 50, 0], + [102, 51, 0], [153, 76, 0], - [204, 101, 0], - [255, 127, 0], + [204, 102, 0], + [255, 128, 0], [255, 191, 0], - [255, 127, 0], + [255, 128, 0], ], [ [0, 0, 51], @@ -894,7 +1006,7 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): [0, 255, 0], [255, 255, 0], [255, 191, 0], - [255, 127, 0], + [255, 128, 0], [255, 191, 127], ], [ @@ -903,39 +1015,39 @@ def test_blend_uv_seams(mock_mesh: Mesh, image: Image, expected_output: Image): [0, 255, 255], [127, 191, 255], [255, 191, 127], - [255, 127, 127], + [255, 128, 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], + [127, 64, 255], + [191, 96, 255], + [255, 128, 255], + [255, 64, 127], [255, 255, 255], [255, 191, 127], ], [ [0, 0, 255], - [63, 31, 255], - [127, 63, 255], - [191, 63, 255], - [255, 63, 255], + [63, 32, 255], + [127, 64, 255], + [191, 64, 255], + [255, 64, 255], [255, 191, 255], [255, 191, 255], - [255, 127, 127], + [255, 128, 127], ], [ [63, 127, 255], [127, 127, 255], [191, 127, 255], - [255, 127, 255], + [255, 128, 255], [255, 0, 255], - [255, 127, 255], - [255, 127, 255], - [255, 63, 127], + [255, 128, 255], + [255, 128, 255], + [255, 64, 127], ], ], dtype=np.uint8, @@ -949,3 +1061,264 @@ 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], [106, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 41], [21, 0, 32], [42, 0, 24], [63, 0, 16], [84, 0, 8], [106, 0, 0], [0, 0, 0], [0, 0, 0]], + [ + [0, 0, 80], + [6, 85, 101], + [12, 170, 53], + [37, 148, 41], + [62, 127, 29], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + [ + [0, 0, 120], + [12, 170, 161], + [15, 212, 110], + [19, 255, 59], + [192, 255, 59], + [223, 127, 99], + [255, 0, 140], + [0, 0, 0], + ], + [ + [0, 0, 160], + [9, 127, 190], + [19, 255, 221], + [115, 127, 230], + [201, 127, 149], + [233, 0, 190], + [0, 0, 0], + [0, 0, 0], + ], + [ + [0, 0, 200], + [9, 127, 230], + [105, 0, 240], + [158, 0, 240], + [211, 0, 240], + [223, 0, 197], + [0, 0, 0], + [0, 0, 0], + ], + [ + [0, 0, 240], + [52, 0, 240], + [105, 0, 240], + [153, 0, 243], + [201, 0, 247], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [192, 0, 255], [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], [106, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 41], [21, 0, 32], [42, 0, 24], [63, 0, 16], [84, 0, 8], [106, 0, 0], [0, 0, 0], [0, 0, 0]], + [ + [0, 0, 80], + [6, 85, 101], + [12, 170, 53], + [37, 148, 41], + [62, 127, 29], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + [ + [0, 0, 120], + [12, 170, 161], + [15, 212, 110], + [19, 255, 59], + [192, 255, 59], + [223, 127, 99], + [255, 0, 140], + [0, 0, 0], + ], + [ + [0, 0, 160], + [9, 127, 190], + [19, 255, 221], + [115, 127, 230], + [201, 127, 149], + [233, 0, 190], + [0, 0, 0], + [0, 0, 0], + ], + [ + [0, 0, 200], + [9, 127, 230], + [105, 0, 240], + [158, 0, 240], + [211, 0, 240], + [223, 0, 197], + [0, 0, 0], + [0, 0, 0], + ], + [ + [0, 0, 240], + [52, 0, 240], + [105, 0, 240], + [153, 0, 243], + [201, 0, 247], + [0, 0, 0], + [0, 0, 0], + [0, 0, 0], + ], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [192, 0, 255], [0, 0, 0], [0, 0, 0], [0, 0, 0]], + ], + dtype=np.uint8, + ), + ), + # Positive Padding + ( + "mock_mesh", + 4, + np.array( + [ + [ + [21, 0, 41], + [42, 0, 41], + [63, 0, 41], + [84, 0, 32], + [106, 0, 24], + [106, 0, 0], + [106, 0, 8], + [106, 0, 0], + ], + [ + [0, 0, 41], + [21, 0, 32], + [42, 0, 24], + [63, 0, 16], + [84, 0, 8], + [106, 0, 0], + [106, 127, 29], + [106, 0, 0], + ], + [ + [0, 0, 80], + [6, 85, 101], + [12, 170, 53], + [37, 148, 41], + [62, 127, 29], + [255, 255, 140], + [255, 255, 140], + [255, 127, 140], + ], + [ + [0, 0, 120], + [12, 170, 161], + [15, 212, 110], + [19, 255, 59], + [192, 255, 59], + [223, 127, 99], + [255, 0, 140], + [255, 127, 190], + ], + [ + [0, 0, 160], + [9, 127, 190], + [19, 255, 221], + [115, 127, 230], + [201, 127, 149], + [233, 0, 190], + [255, 255, 240], + [255, 127, 197], + ], + [ + [0, 0, 200], + [9, 127, 230], + [105, 0, 240], + [158, 0, 240], + [211, 0, 240], + [223, 0, 197], + [255, 255, 247], + [255, 127, 197], + ], + [ + [0, 0, 240], + [52, 0, 240], + [105, 0, 240], + [153, 0, 243], + [201, 0, 247], + [233, 127, 255], + [233, 127, 255], + [233, 0, 197], + ], + [ + [52, 127, 240], + [105, 127, 240], + [158, 127, 243], + [211, 127, 255], + [192, 0, 255], + [223, 0, 255], + [223, 0, 255], + [223, 0, 197], + ], + ], + dtype=np.uint8, + ), + ), + ], + 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.", + ), + ], + 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..6e99570 100644 --- a/tests/readyplayerme/meshops/unit/test_mesh.py +++ b/tests/readyplayerme/meshops/unit/test_mesh.py @@ -105,7 +105,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( @@ -124,6 +124,15 @@ def test_get_overlapping_vertices_should_fail(indices): mesh.get_overlapping_vertices(vertices, indices) +def test_faces_to_edges(mock_mesh: mesh.Mesh): + """Test the faces_to_edges function returns the expected edges.""" + edges = mesh.faces_to_edges(mock_mesh.faces) + + np.testing.assert_array_equal( + edges, mock_mesh.edges, "The edges returned by faces_to_edges do not match the expected edges." + ) + + @pytest.mark.parametrize( "uvs, width, height, indices, expected", [ @@ -156,7 +165,7 @@ def test_get_overlapping_vertices_should_fail(indices): 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." + np.testing.assert_array_equal(image_space_coords, expected, "Image space coordinates do not match expected values.") @pytest.mark.parametrize( From d9442d445c60c19539670d25b62cc9a0afdf27ec Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 31 Jan 2024 14:23:11 +0100 Subject: [PATCH 03/16] chore: update code owners --- .github/CODEOWNERS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From d9bf5cf1afe7c9a0dfb61c1e825e87a9b5a78a85 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 31 Jan 2024 16:43:32 +0100 Subject: [PATCH 04/16] style: update black & ruff add empty line after module doc-string. --- .pre-commit-config.yaml | 4 ++-- pyproject.toml | 8 ++++---- src/readyplayerme/meshops/draw/__init__.py | 1 + src/readyplayerme/meshops/draw/color.py | 1 + src/readyplayerme/meshops/draw/image.py | 1 + src/readyplayerme/meshops/mesh.py | 1 + src/readyplayerme/meshops/types.py | 1 + tests/conftest.py | 1 + tests/readyplayerme/meshops/conftest.py | 1 + tests/readyplayerme/meshops/unit/conftest.py | 1 + tests/readyplayerme/meshops/unit/test_mesh.py | 1 + 11 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fe121f0..0933afc 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.1.15 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 24.1.1 hooks: - id: black args: [ diff --git a/pyproject.toml b/pyproject.toml index 6f7ae0e..2801553 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,9 @@ test = [ "pyinstrument", ] lint = [ - "black>=23.3.0", + "black>=24.1.1", "mypy>=1.5.1", - "ruff>=0.0.285", + "ruff>=0.1.15", ] 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 = [ @@ -124,7 +124,7 @@ 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 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..cd27ffc 100644 --- a/src/readyplayerme/meshops/draw/color.py +++ b/src/readyplayerme/meshops/draw/color.py @@ -1,4 +1,5 @@ """Functions to deal with colors and color modes.""" + import numpy as np from readyplayerme.meshops.types import Color, ColorMode, Image, IndexGroups diff --git a/src/readyplayerme/meshops/draw/image.py b/src/readyplayerme/meshops/draw/image.py index 2e9a468..3e5c815 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 diff --git a/src/readyplayerme/meshops/mesh.py b/src/readyplayerme/meshops/mesh.py index 5fd87d5..a771cdc 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 diff --git a/src/readyplayerme/meshops/types.py b/src/readyplayerme/meshops/types.py index be7eecd..fbfab1c 100644 --- a/src/readyplayerme/meshops/types.py +++ b/src/readyplayerme/meshops/types.py @@ -1,4 +1,5 @@ """Custom types for meshops.""" + from enum import Enum from typing import TypeAlias 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 f24d0f1..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 diff --git a/tests/readyplayerme/meshops/unit/test_mesh.py b/tests/readyplayerme/meshops/unit/test_mesh.py index 6e99570..7b42874 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 From 19af8dedd4d2cb280d01ef7c5d50ebb4c512f5d3 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Sun, 11 Feb 2024 05:21:39 +0100 Subject: [PATCH 05/16] feat: attribute to color function --- src/readyplayerme/meshops/draw/color.py | 38 +++++++ src/readyplayerme/meshops/draw/image.py | 21 ++-- src/readyplayerme/meshops/types.py | 4 +- .../readyplayerme/meshops/unit/test_colors.py | 105 ++++++++++++++++++ 4 files changed, 154 insertions(+), 14 deletions(-) diff --git a/src/readyplayerme/meshops/draw/color.py b/src/readyplayerme/meshops/draw/color.py index cd27ffc..1f3737b 100644 --- a/src/readyplayerme/meshops/draw/color.py +++ b/src/readyplayerme/meshops/draw/color.py @@ -1,6 +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 @@ -118,3 +122,37 @@ 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 = True) -> Color: + """Convert an attribute to color values. + + If necessary, normalize it and convert it to uint8. + + :param attribute: The attribute to turn into color values. + :param normalize_per_channel: Whether to normalize each channel separately or across all channels. + Only if the attribute is not already uint8. + :return: The attribute as uint8 color values. + """ + 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. + dim2 = 2 + if attribute.ndim == dim2 and attribute.shape[1] == dim2: + attribute = np.pad(attribute, ((0, 0), (0, 1)), mode="constant", constant_values=0) + # A color should not have more than 4 channels, but we don't enforce it here. + # Normalize the attribute. + if attribute.dtype != np.uint8: + axis = 0 if normalize_per_channel else None + colors = np.nan_to_num( + (attribute - attribute.min(axis=axis, keepdims=True)) + / (np.ptp(attribute, axis=axis, keepdims=True) + 1e-7) # Small constant to avoid division by zero. + ) + 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 3e5c815..2d4320f 100644 --- a/src/readyplayerme/meshops/draw/image.py +++ b/src/readyplayerme/meshops/draw/image.py @@ -9,6 +9,7 @@ from readyplayerme.meshops import mesh as mops from readyplayerme.meshops.draw.color import ( + attribute_to_color, blend_colors, get_color_array_color_mode, get_image_color_mode, @@ -32,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" @@ -322,6 +323,8 @@ def get_vertex_attribute_image( uvs: mops.UVs, attribute: Color, padding: int = 4, + *, + normalize_per_channel: bool = True, ) -> Image: """Turn a vertex attribute into an image using a uv layout. @@ -332,6 +335,8 @@ def get_vertex_attribute_image( :param faces: The faces of the mesh containing vertex indices. :param uvs: The UV coordinates for the vertices of the faces. :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 True. :return: The vertex attribute as an 8-bit image. """ # Sanity checks. @@ -347,20 +352,12 @@ def get_vertex_attribute_image( msg = f"Attribute length does not match UV coordinates length: {len(attribute)} != {num_uvs}." raise ValueError(msg) try: - attr_color_mode = get_color_array_color_mode(attribute) + colors = attribute_to_color(attribute, normalize_per_channel=normalize_per_channel) + attr_color_mode = get_color_array_color_mode(colors) except ValueError as error: msg = f"Attribute shape is unsupported for image conversion: {attribute.shape}" raise ValueError(msg) from error - if attribute.dtype != np.uint8: - # Map the normalized attribute to the 8-bit color format. - colors = np.nan_to_num( - (attribute - attribute.min(axis=0, keepdims=True)) / np.ptp(attribute, axis=0, keepdims=True) - ) # Fixme per channel opt, extract to function - colors = skimage.util.img_as_ubyte(colors) - else: - colors = attribute - # 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) @@ -370,7 +367,7 @@ def get_vertex_attribute_image( 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.value, axis=2) + mask = mask[:, :, np.newaxis].repeat(attr_color_mode, axis=2) attribute_img *= mask # Add padding around the UV shells. if padding > 0: diff --git a/src/readyplayerme/meshops/types.py b/src/readyplayerme/meshops/types.py index fbfab1c..ead840c 100644 --- a/src/readyplayerme/meshops/types.py +++ b/src/readyplayerme/meshops/types.py @@ -1,6 +1,6 @@ """Custom types for meshops.""" -from enum import Enum +from enum import IntEnum from typing import TypeAlias import numpy as np @@ -16,7 +16,7 @@ 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/readyplayerme/meshops/unit/test_colors.py b/tests/readyplayerme/meshops/unit/test_colors.py index 1136bf4..88ada9b 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,6 +52,7 @@ 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.""" @@ -66,6 +69,7 @@ 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.""" @@ -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,92 @@ 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", + [ + # Test with single scalar attribute. + (np.array([-5]), False, np.array([0], dtype=np.uint8)), + # Test with uint8 scalar attribute. + ( + np.array([128, 64, 192], dtype=np.uint8), + False, + np.array([128, 64, 192], dtype=np.uint8), + ), + # Test with uint8 "1.5D" attribute. + ( + np.array([[128], [64], [192]], dtype=np.uint8), + False, + np.array([128, 64, 192], dtype=np.uint8), + ), + # Test with uint8 2D attribute. + ( + np.array([[128, 192], [64, 32]], dtype=np.uint8), + False, + np.array([[128, 192, 0], [64, 32, 0]], dtype=np.uint8), + ), + # Test with 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), + ), + # Test with 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, 127], [191, 0, 0]], dtype=np.uint8), + ), + # Test with 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, 0], [255, 255, 0, 0], [255, 0, 255, 0]], dtype=np.uint8), + ), + # Test with 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, 170, 170, 212], [170, 255, 170, 212], [170, 170, 255, 212]], dtype=np.uint8), + ), + ], + ids=[ + "single scalar", + "uint8 scalars", + "uint8 1.5D", + "uint8 2D", + "uint8 RGBA", + "int32 RGB", + "float32 RGBA per channel", + "float32 RGBA global", + ], +) +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", + [ + # Test with empty attribute + np.array([]), + # Test with 0-dimensional attribute + np.array(5), + ], + ids=["empty", "0-dimensional attribute"], +) +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) From 41f3cd700dcba3458dd5fa55cf46fcb111baab67 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Mon, 29 Apr 2024 23:47:27 +0200 Subject: [PATCH 06/16] test: add comments and ids For better readability in UI and making sense of paramtrization --- .../readyplayerme/meshops/unit/test_image.py | 97 +++++++++++++++++-- tests/readyplayerme/meshops/unit/test_mesh.py | 28 +++++- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/tests/readyplayerme/meshops/unit/test_image.py b/tests/readyplayerme/meshops/unit/test_image.py index c5e10b6..d7cb949 100644 --- a/tests/readyplayerme/meshops/unit/test_image.py +++ b/tests/readyplayerme/meshops/unit/test_image.py @@ -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.""" @@ -381,6 +441,13 @@ def test_create_nan_image_should_fail(width: int, height: int, error: type[Excep # 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=[ + "No NaNs or Infinities", + "Contains NaNs", + "Contains Positive and Negative Infinities", + "Mix of 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 +494,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 +537,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 +618,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,6 +770,7 @@ 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): @@ -715,6 +792,7 @@ def test_blend_uv_seams_should_fail(mock_mesh: Mesh): @pytest.mark.parametrize( "width, height, faces, uvs, attribute, padding, expected_output", [ + # TODO: Add more tests for different attribute dimensions (UV, 0-dim/5dim/5channels fail). see attribute to color # Scalar value attribute. ( 4, @@ -763,6 +841,7 @@ def test_blend_uv_seams_should_fail(mock_mesh: Mesh): ), ), ], + 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, expected_output: Image @@ -809,6 +888,7 @@ def test_get_vertex_attribute_image( "^(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.""" @@ -1054,6 +1134,7 @@ def test_get_vertex_attribute_image_should_fail(faces: Faces, uvs: UVs, attribut ), ), ], + ids=["No Padding", "Negative Padding", "Positive Padding"], indirect=["mock_mesh"], ) def test_get_position_map(mock_mesh: Mesh, padding: int, expected_output: Image): @@ -1291,6 +1372,7 @@ def test_get_position_map_should_fail(mock_mesh: Mesh): ), ), ], + 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): @@ -1314,6 +1396,7 @@ def test_get_obj_space_normal_map(mock_mesh: Mesh, padding: int, expected_output "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): diff --git a/tests/readyplayerme/meshops/unit/test_mesh.py b/tests/readyplayerme/meshops/unit/test_mesh.py index 7b42874..2fa1ad9 100644 --- a/tests/readyplayerme/meshops/unit/test_mesh.py +++ b/tests/readyplayerme/meshops/unit/test_mesh.py @@ -18,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.""" @@ -25,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): @@ -93,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 @@ -117,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 ( Date: Tue, 30 Apr 2024 11:54:25 +0200 Subject: [PATCH 07/16] refactor(color): rename function to get_color_mode Do not include parameter type in function name --- src/readyplayerme/meshops/draw/color.py | 8 ++++---- src/readyplayerme/meshops/draw/image.py | 8 ++++---- tests/readyplayerme/meshops/unit/test_colors.py | 12 ++++++------ 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/readyplayerme/meshops/draw/color.py b/src/readyplayerme/meshops/draw/color.py index 1f3737b..4bb71cb 100644 --- a/src/readyplayerme/meshops/draw/color.py +++ b/src/readyplayerme/meshops/draw/color.py @@ -38,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: diff --git a/src/readyplayerme/meshops/draw/image.py b/src/readyplayerme/meshops/draw/image.py index 2d4320f..8b206bc 100644 --- a/src/readyplayerme/meshops/draw/image.py +++ b/src/readyplayerme/meshops/draw/image.py @@ -11,7 +11,7 @@ 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, ) @@ -180,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: @@ -247,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) @@ -353,7 +353,7 @@ def get_vertex_attribute_image( raise ValueError(msg) try: colors = attribute_to_color(attribute, normalize_per_channel=normalize_per_channel) - attr_color_mode = get_color_array_color_mode(colors) + 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 diff --git a/tests/readyplayerme/meshops/unit/test_colors.py b/tests/readyplayerme/meshops/unit/test_colors.py index 88ada9b..5dfc6d3 100644 --- a/tests/readyplayerme/meshops/unit/test_colors.py +++ b/tests/readyplayerme/meshops/unit/test_colors.py @@ -54,9 +54,9 @@ def test_get_image_color_mode_should_fail(image): ], 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( @@ -71,10 +71,10 @@ def test_get_color_array_color_mode(color_array, expected_mode): ], 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( From dfc69e839f3cc4fe64639a21b863f573c1feb945 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 8 May 2024 12:13:38 +0200 Subject: [PATCH 08/16] fix(color): change method of attribute normalization Now most extreme value, or 1, is used to scale relative to 0 as a fixed value. --- src/readyplayerme/meshops/draw/color.py | 31 ++++++--- .../readyplayerme/meshops/unit/test_colors.py | 69 +++++++++++++++---- 2 files changed, 75 insertions(+), 25 deletions(-) diff --git a/src/readyplayerme/meshops/draw/color.py b/src/readyplayerme/meshops/draw/color.py index 4bb71cb..8598928 100644 --- a/src/readyplayerme/meshops/draw/color.py +++ b/src/readyplayerme/meshops/draw/color.py @@ -124,33 +124,44 @@ def interpolate_values(start: Color, end: Color, num_steps: int) -> Color: return start[None, :] + t[:, None] * (end - start) -def attribute_to_color(attribute: npt.NDArray[Any], *, normalize_per_channel: bool = True) -> Color: +def attribute_to_color(attribute: npt.NDArray[Any], *, normalize_per_channel: bool = False) -> Color: """Convert an attribute to color values. - If necessary, normalize it and convert it to uint8. + If necessary, remap to [0, 255] range as uint8. :param attribute: The attribute to turn into color values. - :param normalize_per_channel: Whether to normalize each channel separately or across all channels. + :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. - dim2 = 2 - if attribute.ndim == dim2 and attribute.shape[1] == dim2: + 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) - # A color should not have more than 4 channels, but we don't enforce it here. + # 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 - colors = np.nan_to_num( - (attribute - attribute.min(axis=axis, keepdims=True)) - / (np.ptp(attribute, axis=axis, keepdims=True) + 1e-7) # Small constant to avoid division by zero. - ) + # 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 diff --git a/tests/readyplayerme/meshops/unit/test_colors.py b/tests/readyplayerme/meshops/unit/test_colors.py index 5dfc6d3..da09352 100644 --- a/tests/readyplayerme/meshops/unit/test_colors.py +++ b/tests/readyplayerme/meshops/unit/test_colors.py @@ -210,49 +210,79 @@ def test_interpolate_values_should_fail(start_color, end_color, num_steps, expec @pytest.mark.parametrize( "attribute, per_channel_normalization, expected", [ - # Test with single scalar attribute. + # single scalar attribute. (np.array([-5]), False, np.array([0], dtype=np.uint8)), - # Test with uint8 scalar attribute. + # uint8 scalar attribute. ( np.array([128, 64, 192], dtype=np.uint8), False, np.array([128, 64, 192], dtype=np.uint8), ), - # Test with uint8 "1.5D" attribute. + # uint8 "1.5D" attribute. ( np.array([[128], [64], [192]], dtype=np.uint8), False, np.array([128, 64, 192], dtype=np.uint8), ), - # Test with uint8 2D attribute. + # uint8 2D attribute. ( np.array([[128, 192], [64, 32]], dtype=np.uint8), False, np.array([[128, 192, 0], [64, 32, 0]], dtype=np.uint8), ), - # Test with uint8 RGBA attribute. + # 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), ), - # Test with int32 RGB attribute and per_channel_normalization=True + # 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, 127], [191, 0, 0]], dtype=np.uint8), + np.array([[255, 0, 255], [0, 0, 128], [191, 0, 0]], dtype=np.uint8), ), - # Test with float32 attribute and per_channel_normalization=True + # 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, 0], [255, 255, 0, 0], [255, 0, 255, 0]], dtype=np.uint8), + np.array([[0, 0, 0, 128], [128, 255, 0, 128], [128, 0, 255, 128]], dtype=np.uint8), ), - # Test with float32 attribute and per_channel_normalization=False + # 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, 170, 170, 212], [170, 255, 170, 212], [170, 170, 255, 212]], dtype=np.uint8), + 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=[ @@ -261,9 +291,14 @@ def test_interpolate_values_should_fail(start_color, end_color, num_steps, expec "uint8 1.5D", "uint8 2D", "uint8 RGBA", - "int32 RGB", + "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): @@ -277,12 +312,16 @@ def test_attribute_to_color(attribute, per_channel_normalization, expected): @pytest.mark.parametrize( "attribute", [ - # Test with empty attribute + # Empty attribute np.array([]), - # Test with 0-dimensional attribute + # 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"], + 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.""" From 0cc9597c553ba062cd1ecbd6cb2220facdb042b2 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 8 May 2024 12:15:33 +0200 Subject: [PATCH 09/16] test(mesh): update comments for uv_to_image_coords --- tests/readyplayerme/meshops/unit/test_mesh.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/readyplayerme/meshops/unit/test_mesh.py b/tests/readyplayerme/meshops/unit/test_mesh.py index 2fa1ad9..343e5b4 100644 --- a/tests/readyplayerme/meshops/unit/test_mesh.py +++ b/tests/readyplayerme/meshops/unit/test_mesh.py @@ -153,7 +153,7 @@ def test_faces_to_edges(mock_mesh: mesh.Mesh): (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 + # Near 0 and 1 UVs ( np.array([[0.0001, 0.9999], [0.9999, 0.0001]]), 200, @@ -161,8 +161,8 @@ def test_faces_to_edges(mock_mesh: mesh.Mesh): 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 + # Empty indices- give me coordinates of no UVs -> 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) @@ -175,10 +175,10 @@ def test_faces_to_edges(mock_mesh: mesh.Mesh): (np.array([[0.5, 0.5], [0.25, 0.75]]), 0, 0, np.array([0]), np.array([[0, 0]])), ], ids=[ - "simple UV conversion", - "full range UV conversion", - "near 0 and 1 values", - "empty indices", + "with indices", + "without indices (None)", + "near 0 and 1 UVs", + "empty indices []", "out of range UV", "wrapped UV", "non square UV", @@ -187,7 +187,7 @@ def test_faces_to_edges(mock_mesh: mesh.Mesh): ], ) 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) np.testing.assert_array_equal(image_space_coords, expected, "Image space coordinates do not match expected values.") From 7c030f11857ec1e3668c7c86e8feff8bde344b78 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 8 May 2024 12:22:38 +0200 Subject: [PATCH 10/16] feat(image): add center and uniform_scale params to get_position_map center determines whether the positions are computed from the center of the mesh, while the uniform_scale determines whether to scale positions uniformly or independently along each axis. --- src/readyplayerme/meshops/draw/image.py | 34 +++++++++++++++++++++---- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/readyplayerme/meshops/draw/image.py b/src/readyplayerme/meshops/draw/image.py index 8b206bc..0e45001 100644 --- a/src/readyplayerme/meshops/draw/image.py +++ b/src/readyplayerme/meshops/draw/image.py @@ -324,7 +324,7 @@ def get_vertex_attribute_image( attribute: Color, padding: int = 4, *, - normalize_per_channel: bool = True, + normalize_per_channel: bool = False, ) -> Image: """Turn a vertex attribute into an image using a uv layout. @@ -334,9 +334,10 @@ def get_vertex_attribute_image( :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 True. + Only if the attribute is not already uint8. Default False. :return: The vertex attribute as an 8-bit image. """ # Sanity checks. @@ -377,7 +378,9 @@ def get_vertex_attribute_image( return attribute_img.astype(np.uint8) -def get_position_map(width: int, height: int, mesh: mops.Mesh, padding: int = 4) -> Image: +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]. @@ -386,9 +389,28 @@ 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. """ - return get_vertex_attribute_image(width, height, mesh.faces, mesh.uv_coords, mesh.vertices, padding=padding) + 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: @@ -405,4 +427,6 @@ def get_obj_space_normal_map(width: int, height: int, mesh: mops.Mesh, padding: 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) + return get_vertex_attribute_image( + width, height, mesh.faces, mesh.uv_coords, mesh.normals, padding=padding, normalize_per_channel=False + ) From a64836c9185b7e71c3dd891ba86b30c6f26c6643 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 8 May 2024 12:26:48 +0200 Subject: [PATCH 11/16] test(image): add new tests and fix tests tests were adjusted for new attribute normalization method --- .../readyplayerme/meshops/unit/test_image.py | 596 ++++++++++-------- 1 file changed, 318 insertions(+), 278 deletions(-) diff --git a/tests/readyplayerme/meshops/unit/test_image.py b/tests/readyplayerme/meshops/unit/test_image.py index d7cb949..59ffe78 100644 --- a/tests/readyplayerme/meshops/unit/test_image.py +++ b/tests/readyplayerme/meshops/unit/test_image.py @@ -424,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 @@ -438,14 +440,21 @@ 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=[ - "No NaNs or Infinities", - "Contains NaNs", - "Contains Positive and Negative Infinities", + "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]", ], ) @@ -790,9 +799,9 @@ def test_blend_uv_seams_should_fail(mock_mesh: Mesh): @pytest.mark.parametrize( - "width, height, faces, uvs, attribute, padding, expected_output", + "width, height, faces, uvs, attribute, padding, normalize_per_channel, expected_output", [ - # TODO: Add more tests for different attribute dimensions (UV, 0-dim/5dim/5channels fail). see attribute to color + # TODO: Add more tests: UV, 0-dim, normalize_per_channel # Scalar value attribute. ( 4, @@ -801,6 +810,7 @@ def test_blend_uv_seams_should_fail(mock_mesh: Mesh): 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, @@ -814,6 +824,7 @@ def test_blend_uv_seams_should_fail(mock_mesh: Mesh): 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]], @@ -832,10 +843,11 @@ def test_blend_uv_seams_should_fail(mock_mesh: Mesh): 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( [ - [[255, 0, 255, 0], [0, 0, 0, 0], [0, 0, 0, 0], [0, 0, 0, 0]], - [[0, 0, 0, 0], [85, 85, 0, 0], [255, 170, 85, 0], [255, 255, 0, 0]], + [[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, ), @@ -844,10 +856,19 @@ def test_blend_uv_seams_should_fail(mock_mesh: Mesh): 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, expected_output: 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) + 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, @@ -884,7 +905,7 @@ def test_get_vertex_attribute_image( ( np.array([[0, 1, 2]]), np.array([[0, 0], [1, 0], [0, 1]]), - np.array([[0, 1], [0, 1], [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).*", ), ], @@ -905,68 +926,68 @@ def test_get_vertex_attribute_image_should_fail(faces: Faces, uvs: UVs, attribut 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, 51, 0], - [153, 76, 0], - [204, 102, 0], - [255, 128, 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, 128, 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, 128, 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, 64, 255], - [191, 96, 255], - [255, 128, 255], - [255, 64, 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, 32, 255], - [127, 64, 255], - [191, 64, 255], - [255, 64, 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, ), @@ -977,68 +998,68 @@ def test_get_vertex_attribute_image_should_fail(faces: Faces, uvs: UVs, attribut -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, 51, 0], - [153, 76, 0], - [204, 102, 0], - [255, 128, 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, 128, 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, 128, 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, 64, 255], - [191, 96, 255], - [255, 128, 255], - [255, 64, 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, 32, 255], - [127, 64, 255], - [191, 64, 255], - [255, 64, 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, ), @@ -1050,89 +1071,90 @@ def test_get_vertex_attribute_image_should_fail(faces: Faces, uvs: UVs, attribut np.array( [ [ - [51, 25, 0], - [102, 51, 0], - [153, 76, 0], - [204, 102, 0], - [255, 128, 0], - [255, 0, 0], - [255, 128, 0], - [255, 128, 0], + [80, 84, 78], + [102, 95, 78], + [123, 106, 78], + [145, 117, 78], + [167, 128, 78], + [167, 74, 78], + [167, 128, 78], + [167, 128, 78], ], [ - [0, 0, 0], - [51, 25, 0], - [102, 51, 0], - [153, 76, 0], - [204, 102, 0], - [255, 128, 0], - [255, 191, 0], - [255, 128, 0], + [59, 74, 78], + [80, 84, 78], + [102, 95, 78], + [123, 106, 78], + [145, 117, 78], + [167, 128, 78], + [167, 154, 78], + [167, 128, 78], ], [ - [0, 0, 51], - [0, 85, 85], - [0, 170, 0], - [63, 180, 0], - [127, 191, 0], - [255, 255, 0], - [255, 255, 0], - [255, 191, 0], + [59, 74, 99], + [59, 109, 114], + [59, 145, 78], + [86, 149, 78], + [113, 154, 78], + [167, 181, 78], + [167, 181, 78], + [167, 154, 78], ], [ - [0, 0, 102], - [0, 170, 170], - [0, 212, 85], - [0, 255, 0], - [255, 255, 0], - [255, 191, 0], - [255, 128, 0], - [255, 191, 127], + [59, 74, 121], + [59, 145, 150], + [59, 163, 114], + [59, 181, 78], + [167, 181, 78], + [167, 154, 78], + [167, 128, 78], + [167, 154, 132], ], [ - [0, 0, 153], - [0, 127, 204], - [0, 255, 255], - [127, 191, 255], - [255, 191, 127], - [255, 128, 127], - [255, 255, 255], - [255, 191, 127], + [59, 74, 142], + [59, 127, 164], + [59, 181, 186], + [113, 154, 186], + [167, 154, 132], + [167, 128, 132], + [167, 181, 186], + [167, 154, 132], ], [ - [0, 0, 204], - [0, 127, 255], - [127, 64, 255], - [191, 96, 255], - [255, 128, 255], - [255, 64, 127], - [255, 255, 255], - [255, 191, 127], + [59, 74, 164], + [59, 127, 186], + [113, 101, 186], + [140, 114, 186], + [167, 128, 186], + [167, 101, 132], + [167, 181, 186], + [167, 154, 132], ], [ - [0, 0, 255], - [63, 32, 255], - [127, 64, 255], - [191, 64, 255], - [255, 64, 255], - [255, 191, 255], - [255, 191, 255], - [255, 128, 127], + [59, 74, 186], + [86, 87, 186], + [113, 101, 186], + [140, 101, 186], + [167, 101, 186], + [167, 154, 186], + [167, 154, 186], + [167, 128, 132], ], [ - [63, 127, 255], - [127, 127, 255], - [191, 127, 255], - [255, 128, 255], - [255, 0, 255], - [255, 128, 255], - [255, 128, 255], - [255, 64, 127], + [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"], @@ -1161,59 +1183,68 @@ def test_get_position_map_should_fail(mock_mesh: Mesh): 0, np.array( [ - [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [106, 0, 0], [0, 0, 0], [0, 0, 0]], - [[0, 0, 41], [21, 0, 32], [42, 0, 24], [63, 0, 16], [84, 0, 8], [106, 0, 0], [0, 0, 0], [0, 0, 0]], + [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [128, 128, 0], [0, 0, 0], [0, 0, 0]], [ - [0, 0, 80], - [6, 85, 101], - [12, 170, 53], - [37, 148, 41], - [62, 127, 29], + [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], ], [ - [0, 0, 120], - [12, 170, 161], - [15, 212, 110], - [19, 255, 59], - [192, 255, 59], - [223, 127, 99], - [255, 0, 140], + [37, 128, 109], + [48, 176, 146], + [51, 188, 100], + [54, 201, 54], + [201, 201, 54], + [228, 164, 91], + [255, 128, 128], [0, 0, 0], ], [ - [0, 0, 160], - [9, 127, 190], - [19, 255, 221], - [115, 127, 230], - [201, 127, 149], - [233, 0, 190], + [37, 128, 145], + [45, 164, 173], + [54, 201, 201], + [136, 164, 209], + [209, 164, 136], + [236, 128, 173], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 200], - [9, 127, 230], - [105, 0, 240], - [158, 0, 240], - [211, 0, 240], - [223, 0, 197], + [37, 128, 181], + [45, 164, 209], + [127, 128, 218], + [172, 128, 218], + [218, 128, 218], + [228, 128, 180], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 240], - [52, 0, 240], - [105, 0, 240], - [153, 0, 243], - [201, 0, 247], + [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], [192, 0, 255], [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, ), @@ -1224,59 +1255,68 @@ def test_get_position_map_should_fail(mock_mesh: Mesh): -4, np.array( [ - [[0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [0, 0, 0], [106, 0, 0], [0, 0, 0], [0, 0, 0]], - [[0, 0, 41], [21, 0, 32], [42, 0, 24], [63, 0, 16], [84, 0, 8], [106, 0, 0], [0, 0, 0], [0, 0, 0]], + [[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], + ], [ - [0, 0, 80], - [6, 85, 101], - [12, 170, 53], - [37, 148, 41], - [62, 127, 29], + [37, 128, 73], + [42, 152, 91], + [48, 176, 48], + [69, 170, 37], + [91, 164, 27], [0, 0, 0], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 120], - [12, 170, 161], - [15, 212, 110], - [19, 255, 59], - [192, 255, 59], - [223, 127, 99], - [255, 0, 140], + [37, 128, 109], + [48, 176, 146], + [51, 188, 100], + [54, 201, 54], + [201, 201, 54], + [228, 164, 91], + [255, 128, 128], [0, 0, 0], ], [ - [0, 0, 160], - [9, 127, 190], - [19, 255, 221], - [115, 127, 230], - [201, 127, 149], - [233, 0, 190], + [37, 128, 145], + [45, 164, 173], + [54, 201, 201], + [136, 164, 209], + [209, 164, 136], + [236, 128, 173], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 200], - [9, 127, 230], - [105, 0, 240], - [158, 0, 240], - [211, 0, 240], - [223, 0, 197], + [37, 128, 181], + [45, 164, 209], + [127, 128, 218], + [172, 128, 218], + [218, 128, 218], + [228, 128, 180], [0, 0, 0], [0, 0, 0], ], [ - [0, 0, 240], - [52, 0, 240], - [105, 0, 240], - [153, 0, 243], - [201, 0, 247], + [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], [192, 0, 255], [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, ), @@ -1288,84 +1328,84 @@ def test_get_position_map_should_fail(mock_mesh: Mesh): np.array( [ [ - [21, 0, 41], - [42, 0, 41], - [63, 0, 41], - [84, 0, 32], - [106, 0, 24], - [106, 0, 0], - [106, 0, 8], - [106, 0, 0], + [55, 128, 37], + [73, 128, 37], + [91, 128, 37], + [109, 128, 29], + [128, 128, 22], + [128, 128, 0], + [128, 128, 7], + [128, 128, 0], ], [ - [0, 0, 41], - [21, 0, 32], - [42, 0, 24], - [63, 0, 16], - [84, 0, 8], - [106, 0, 0], - [106, 127, 29], - [106, 0, 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], ], [ - [0, 0, 80], - [6, 85, 101], - [12, 170, 53], - [37, 148, 41], - [62, 127, 29], - [255, 255, 140], - [255, 255, 140], - [255, 127, 140], + [37, 128, 73], + [42, 152, 91], + [48, 176, 48], + [69, 170, 37], + [91, 164, 27], + [255, 201, 128], + [255, 201, 128], + [255, 164, 128], ], [ - [0, 0, 120], - [12, 170, 161], - [15, 212, 110], - [19, 255, 59], - [192, 255, 59], - [223, 127, 99], - [255, 0, 140], - [255, 127, 190], + [37, 128, 109], + [48, 176, 146], + [51, 188, 100], + [54, 201, 54], + [201, 201, 54], + [228, 164, 91], + [255, 128, 128], + [255, 164, 173], ], [ - [0, 0, 160], - [9, 127, 190], - [19, 255, 221], - [115, 127, 230], - [201, 127, 149], - [233, 0, 190], - [255, 255, 240], - [255, 127, 197], + [37, 128, 145], + [45, 164, 173], + [54, 201, 201], + [136, 164, 209], + [209, 164, 136], + [236, 128, 173], + [255, 201, 218], + [255, 164, 180], ], [ - [0, 0, 200], - [9, 127, 230], - [105, 0, 240], - [158, 0, 240], - [211, 0, 240], - [223, 0, 197], - [255, 255, 247], - [255, 127, 197], + [37, 128, 181], + [45, 164, 209], + [127, 128, 218], + [172, 128, 218], + [218, 128, 218], + [228, 128, 180], + [255, 201, 225], + [255, 164, 180], ], [ - [0, 0, 240], - [52, 0, 240], - [105, 0, 240], - [153, 0, 243], - [201, 0, 247], - [233, 127, 255], - [233, 127, 255], - [233, 0, 197], + [37, 128, 218], + [82, 128, 218], + [127, 128, 218], + [168, 128, 221], + [209, 128, 225], + [236, 164, 232], + [236, 164, 232], + [236, 128, 180], ], [ - [52, 127, 240], - [105, 127, 240], - [158, 127, 243], - [211, 127, 255], - [192, 0, 255], - [223, 0, 255], - [223, 0, 255], - [223, 0, 197], + [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, From 0cf6e3696b819a6cdba78b4eb7370a53784df329 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 8 May 2024 12:32:45 +0200 Subject: [PATCH 12/16] test(image): skip skipping blend_uv_seams tests This is to debug whether the recent changes now pass through github action, because it passes on windows locally --- tests/readyplayerme/meshops/unit/test_image.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/readyplayerme/meshops/unit/test_image.py b/tests/readyplayerme/meshops/unit/test_image.py index 59ffe78..a6599e9 100644 --- a/tests/readyplayerme/meshops/unit/test_image.py +++ b/tests/readyplayerme/meshops/unit/test_image.py @@ -784,8 +784,6 @@ def test_rasterize_should_fail( ) 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) From 63a95bd576ea4cf43ef2952f787d6714af6996e8 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 8 May 2024 14:37:05 +0200 Subject: [PATCH 13/16] style: add type hint to read_gltf's `loaded` try to appease mypy type checking gods --- src/readyplayerme/meshops/mesh.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/readyplayerme/meshops/mesh.py b/src/readyplayerme/meshops/mesh.py index a771cdc..bd3ad30 100644 --- a/src/readyplayerme/meshops/mesh.py +++ b/src/readyplayerme/meshops/mesh.py @@ -52,7 +52,7 @@ def read_gltf(filename: str | Path) -> Mesh: :return: The loaded mesh object. """ try: - loaded = trimesh.load(filename, process=False, force="mesh") + loaded: trimesh.Trimesh = trimesh.load(filename, process=False, force="mesh") except ValueError as error: msg = f"Error loading {filename}: {error}" raise OSError(msg) from error From 5a85c55edcbb474706acfbc75d17073d86d18b72 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 8 May 2024 15:51:49 +0200 Subject: [PATCH 14/16] build(lint): bump ruff, black, mypy versions --- .pre-commit-config.yaml | 6 +++--- pyproject.toml | 28 +++++++++++++++------------- 2 files changed, 18 insertions(+), 16 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0933afc..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.1.15 + rev: v0.4.3 hooks: - id: ruff args: [--fix, --exit-non-zero-on-fix] - repo: https://github.com/psf/black - rev: 24.1.1 + 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/pyproject.toml b/pyproject.toml index 2801553..c6314b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,9 +41,9 @@ test = [ "pyinstrument", ] lint = [ - "black>=24.1.1", - "mypy>=1.5.1", - "ruff>=0.1.15", + "black==24.4.2", + "mypy==1.10.0", + "ruff==0.4.3", ] dev = [ "readyplayerme.meshops[test, lint]", @@ -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 = [ @@ -131,6 +131,9 @@ 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"] From 00893ce4e408e27388687a00134fe95dfca02d8a Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 8 May 2024 15:59:35 +0200 Subject: [PATCH 15/16] style: add assertion to read_gltf's `loaded` type try to appease mypy type checking gods once again --- src/readyplayerme/meshops/mesh.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/readyplayerme/meshops/mesh.py b/src/readyplayerme/meshops/mesh.py index bd3ad30..7861d08 100644 --- a/src/readyplayerme/meshops/mesh.py +++ b/src/readyplayerme/meshops/mesh.py @@ -52,7 +52,8 @@ def read_gltf(filename: str | Path) -> Mesh: :return: The loaded mesh object. """ try: - loaded: trimesh.Trimesh = trimesh.load(filename, process=False, force="mesh") + 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 From 49414d8e86ba37a7aacfa3fa2eca891964655c34 Mon Sep 17 00:00:00 2001 From: Olaf Haag Date: Wed, 8 May 2024 16:48:28 +0200 Subject: [PATCH 16/16] docs: bump version and update readme --- README.md | 38 +++++++++++++++++++++++--- src/readyplayerme/meshops/__about__.py | 2 +- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cf68026..6f89e46 100644 --- a/README.md +++ b/README.md @@ -6,25 +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. -## Object Space Normal Maps +### 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. -## UV Seams Transitioning +### 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/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"