Add comprehensive tests for cutout and mesh functionalities
- Introduced `test_cutout.py` to validate cutout pie menu behavior, image lasso options, and mesh detail calculations. - Added `test_mesh.py` to ensure correct tangent normal rasterization, plane source topology, and depth surface evaluations. - Created `test_mesh_blender.py` to verify material node groups, tangent baking, and local cutout building from Blender images. - Implemented tests for handling alpha regions and ensuring geometry nodes maintain expected behavior.
This commit is contained in:
322
tests/test_mesh.py
Normal file
322
tests/test_mesh.py
Normal file
@@ -0,0 +1,322 @@
|
||||
import builtins
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
SOURCE_ROOT = Path(__file__).parents[1] / "src"
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from anyimage.operators import mesh
|
||||
from anyimage.operators.normal_map import rasterize_tangent_normal
|
||||
from anyimage.server.geometry.cutout import alpha_regions, image_alpha_regions
|
||||
|
||||
|
||||
def mesh_component_count(faces):
|
||||
adjacency = {}
|
||||
for face in faces:
|
||||
for vertex in face:
|
||||
adjacency.setdefault(int(vertex), set())
|
||||
for start, end in zip(face, np.roll(face, -1)):
|
||||
adjacency[int(start)].add(int(end))
|
||||
adjacency[int(end)].add(int(start))
|
||||
remaining = set(adjacency)
|
||||
count = 0
|
||||
while remaining:
|
||||
count += 1
|
||||
pending = [remaining.pop()]
|
||||
while pending:
|
||||
vertex = pending.pop()
|
||||
connected = adjacency[vertex] & remaining
|
||||
remaining.difference_update(connected)
|
||||
pending.extend(connected)
|
||||
return count
|
||||
|
||||
|
||||
def test_tangent_normal_pixels_use_the_final_surface_frame():
|
||||
object_normal = np.zeros((3, 3, 3), dtype=np.float32)
|
||||
object_normal[..., 2] = 1.0
|
||||
triangle_uv = np.asarray((((0.0, 0.0), (1.0, 0.0), (0.0, 1.0)),))
|
||||
triangle_normal = np.asarray((((1.0, 0.0, 0.0),) * 3,))
|
||||
triangle_tangent = np.asarray((((0.0, 1.0, 0.0),) * 3,))
|
||||
triangle_sign = np.ones((1, 3), dtype=np.float32)
|
||||
|
||||
tangent = rasterize_tangent_normal(
|
||||
object_normal,
|
||||
triangle_uv,
|
||||
triangle_normal,
|
||||
triangle_tangent,
|
||||
triangle_sign,
|
||||
)
|
||||
|
||||
assert np.allclose(tangent[0, 0], (0.0, 1.0, 0.0))
|
||||
assert not np.allclose(tangent[0, 0], object_normal[0, 0])
|
||||
assert np.allclose(tangent[2, 2], (0.0, 0.0, 1.0))
|
||||
|
||||
|
||||
def test_plane_source_topology_is_one_quad_at_every_detail_level():
|
||||
low = mesh._plane_domain((-2.0, 2.0, -1.0, 1.0), 1)
|
||||
high = mesh._plane_domain((-2.0, 2.0, -1.0, 1.0), 512)
|
||||
|
||||
assert low[0].shape == high[0].shape == (4, 3)
|
||||
assert low[1].shape == high[1].shape == (1, 4)
|
||||
|
||||
|
||||
def test_cutout_front_back_and_side_faces_point_outward():
|
||||
vertices = np.asarray(
|
||||
((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0)),
|
||||
dtype=np.float64,
|
||||
)
|
||||
faces = np.asarray(((0, 1, 2),), dtype=np.int32)
|
||||
uv = vertices[:, :2].astype(np.float32)
|
||||
boundary = np.ones(3, dtype=bool)
|
||||
layered = mesh._layered_geometry(
|
||||
(vertices, faces, uv, boundary, vertices[:, :2]),
|
||||
mesh.CUTOUT,
|
||||
)
|
||||
positioned = layered[0] + layered[3]
|
||||
output_faces = layered[1]
|
||||
|
||||
def face_normal(face):
|
||||
points = positioned[np.asarray(face[:3], dtype=np.int32)]
|
||||
return np.cross(points[1] - points[0], points[2] - points[0])
|
||||
|
||||
assert face_normal(output_faces[0])[2] > 0.0
|
||||
assert face_normal(output_faces[1])[2] < 0.0
|
||||
center = vertices[:, :2].mean(axis=0)
|
||||
for face in output_faces[2:]:
|
||||
normal = face_normal(face)
|
||||
side_center = positioned[np.asarray(face, dtype=np.int32), :2].mean(axis=0)
|
||||
assert np.dot(normal[:2], side_center - center) > 0.0
|
||||
|
||||
|
||||
def test_depth_surface_moves_both_sides_with_camera_projection_and_extrudes_back():
|
||||
domain = mesh._plane_domain((-1.0, 1.0, -1.0, 1.0), 4)
|
||||
field = np.zeros((2, 2, 3), dtype=np.float32)
|
||||
field[..., 2] = 1.0
|
||||
valid = np.ones((2, 2), dtype=bool)
|
||||
camera_depth = np.ones((2, 2), dtype=np.float32)
|
||||
|
||||
depth_surface = mesh._layered_geometry(
|
||||
domain, mesh.DEPTH_SURFACE, (field, valid, camera_depth)
|
||||
)
|
||||
base_count = len(domain[0])
|
||||
|
||||
assert np.allclose(depth_surface[4][:base_count, 2], 1.0)
|
||||
assert np.allclose(depth_surface[4][base_count:, 2], 1.0)
|
||||
assert np.allclose(depth_surface[3][:base_count], 0.0)
|
||||
assert np.allclose(depth_surface[3][base_count:, 2], -1.0)
|
||||
assert all(depth_surface[5][: len(domain[1])])
|
||||
assert not any(depth_surface[5][len(domain[1]) :])
|
||||
|
||||
|
||||
def test_depth_surface_records_depth_discontinuities_for_geometry_nodes():
|
||||
faces = np.asarray(((0, 1, 2), (1, 2, 3)), dtype=np.int32)
|
||||
depth = np.asarray((1.0, 1.01, 1.02, 2.0), dtype=np.float32)
|
||||
|
||||
discontinuity = mesh._depth_discontinuity_values(faces, depth)
|
||||
|
||||
assert discontinuity[0] < mesh.DEPTH_EDGE_THRESHOLD
|
||||
assert discontinuity[1] > mesh.DEPTH_EDGE_THRESHOLD
|
||||
|
||||
|
||||
def test_depth_surface_discontinuity_map_uses_local_three_by_three_range():
|
||||
depth = np.ones((5, 5), dtype=np.float32)
|
||||
depth[:, 3:] = 2.0
|
||||
valid = np.ones_like(depth, dtype=bool)
|
||||
|
||||
discontinuity = mesh._depth_discontinuity_map(depth, valid)
|
||||
|
||||
assert discontinuity[2, 0] < mesh.DEPTH_EDGE_THRESHOLD
|
||||
assert discontinuity[2, 2] > mesh.DEPTH_EDGE_THRESHOLD
|
||||
|
||||
|
||||
def test_cutout_regions_group_holes_with_their_connected_component():
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "cutout.png"
|
||||
pixels = np.zeros((120, 200, 4), dtype=np.uint8)
|
||||
pixels[20:100, 50:160] = (20, 40, 60, 255)
|
||||
pixels[45:75, 80:120] = 0
|
||||
pixels[5:15, 5:15] = (20, 40, 60, 255)
|
||||
Image.fromarray(pixels, mode="RGBA").save(path)
|
||||
|
||||
regions, size = image_alpha_regions(path, max_resolution=200)
|
||||
|
||||
assert size == (200, 120)
|
||||
assert sorted(len(region) for region in regions) == [1, 2]
|
||||
contours = [contour for region in regions for contour in region]
|
||||
bounds = [
|
||||
(*np.min(contour, axis=0), *np.max(contour, axis=0))
|
||||
for contour in contours
|
||||
]
|
||||
assert (50.0, 20.0, 160.0, 100.0) in bounds
|
||||
assert (80.0, 45.0, 120.0, 75.0) in bounds
|
||||
assert (5.0, 5.0, 15.0, 15.0) in bounds
|
||||
|
||||
|
||||
def test_alpha_regions_accepts_blender_float_alpha_with_a_hole():
|
||||
alpha = np.zeros((120, 200), dtype=np.float32)
|
||||
alpha[20:100, 50:160] = 1.0
|
||||
alpha[45:75, 80:120] = 0.0
|
||||
|
||||
regions, size = alpha_regions(alpha, max_resolution=200)
|
||||
|
||||
assert size == (200, 120)
|
||||
assert len(regions) == 1
|
||||
assert len(regions[0]) == 2
|
||||
|
||||
|
||||
def test_blender_cutout_alpha_pipeline_does_not_import_pillow():
|
||||
original_import = builtins.__import__
|
||||
|
||||
def without_pillow(name, *args, **kwargs):
|
||||
if name == "PIL" or name.startswith("PIL."):
|
||||
raise ModuleNotFoundError("Pillow is unavailable in Blender")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
selection = np.zeros((32, 48), dtype=bool)
|
||||
selection[4:28, 6:42] = True
|
||||
with patch("builtins.__import__", side_effect=without_pillow):
|
||||
antialiased = mesh._antialias_selection(selection)
|
||||
regions, size = alpha_regions(antialiased, max_resolution=24)
|
||||
|
||||
assert size == (48, 32)
|
||||
assert regions
|
||||
assert np.any((antialiased > 0.0) & (antialiased < 1.0))
|
||||
|
||||
def test_cutout_geometry_ignores_source_alpha_without_refine():
|
||||
alpha = np.zeros((80, 120), dtype=np.float32)
|
||||
alpha[10:70, 10:110] = 1.0
|
||||
alpha[30:50, 45:75] = 0.0
|
||||
polygon = ((10, 10), (110, 10), (110, 70), (10, 70))
|
||||
|
||||
regions, size = mesh._geometry_cutout_regions(alpha, polygon, 64)
|
||||
|
||||
assert size == (120, 80)
|
||||
assert len(regions) == 1
|
||||
assert len(regions[0]) == 1
|
||||
|
||||
|
||||
def test_cutout_geometry_uses_final_alpha_with_refine():
|
||||
alpha = np.zeros((80, 120), dtype=np.float32)
|
||||
alpha[10:70, 10:110] = 1.0
|
||||
alpha[30:50, 45:75] = 0.0
|
||||
polygon = ((10, 10), (110, 10), (110, 70), (10, 70))
|
||||
|
||||
regions, size = mesh._geometry_cutout_regions(
|
||||
alpha, polygon, 64, refine_selection=True
|
||||
)
|
||||
|
||||
assert size == (120, 80)
|
||||
assert len(regions) == 1
|
||||
assert len(regions[0]) == 2
|
||||
|
||||
def test_cutout_geometry_can_extend_beyond_the_source_image():
|
||||
alpha = np.zeros((60, 100), dtype=np.float32)
|
||||
alpha[10:50] = 1.0
|
||||
polygon = ((-20, 10), (120, 10), (120, 50), (-20, 50))
|
||||
|
||||
regions, size = mesh._geometry_cutout_regions(alpha, polygon, 64)
|
||||
|
||||
points = np.concatenate(
|
||||
[np.asarray(contour) for region in regions for contour in region]
|
||||
)
|
||||
assert size == (100, 60)
|
||||
assert points[:, 0].min() <= -19.0
|
||||
assert points[:, 0].max() >= 119.0
|
||||
|
||||
def test_refined_cutout_geometry_discards_out_of_frame_selection():
|
||||
alpha = np.zeros((60, 100), dtype=np.float32)
|
||||
alpha[10:50] = 1.0
|
||||
polygon = ((-20, 10), (120, 10), (120, 50), (-20, 50))
|
||||
|
||||
regions, size = mesh._geometry_cutout_regions(
|
||||
alpha, polygon, 64, refine_selection=True
|
||||
)
|
||||
|
||||
points = np.concatenate(
|
||||
[np.asarray(contour) for region in regions for contour in region]
|
||||
)
|
||||
assert size == (100, 60)
|
||||
assert points[:, 0].min() >= 0.0
|
||||
assert points[:, 0].max() <= 100.0
|
||||
|
||||
def test_blender_cutout_contour_resolution_tracks_mesh_detail():
|
||||
expected = ([(((0, 0), (1, 0), (0, 1)),)], (1, 1))
|
||||
alpha = np.ones((2, 2), dtype=np.float32)
|
||||
with patch.object(mesh, "alpha_regions", return_value=expected) as trace:
|
||||
assert mesh._cutout_regions(alpha, 32) == expected
|
||||
assert mesh._cutout_regions(alpha, 256) == expected
|
||||
|
||||
assert trace.call_args_list[0].kwargs["max_resolution"] == 512
|
||||
assert trace.call_args_list[1].kwargs["max_resolution"] == 1024
|
||||
|
||||
def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_region():
|
||||
regions = (
|
||||
(((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),
|
||||
)
|
||||
|
||||
domain = mesh._cutout_domain(
|
||||
regions,
|
||||
(4096, 4096),
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
4,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
assert len(domain[0]) >= 3
|
||||
assert len(domain[1]) >= 1
|
||||
|
||||
|
||||
def test_cutout_domain_builds_a_full_image_at_minimum_detail():
|
||||
regions = ((((0, 0), (4096, 0), (4096, 4096), (0, 4096)),),)
|
||||
|
||||
domain = mesh._cutout_domain(
|
||||
regions,
|
||||
(4096, 4096),
|
||||
(-1.0, 1.0, -1.0, 1.0),
|
||||
4,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
assert len(domain[0]) >= 4
|
||||
assert len(domain[1]) >= 2
|
||||
|
||||
def test_cutout_domain_keeps_overlapping_regions_disconnected():
|
||||
regions = (
|
||||
(
|
||||
((20, 0), (100, 0), (100, 80), (20, 80)),
|
||||
((50, 20), (50, 60), (70, 60), (70, 20)),
|
||||
),
|
||||
(
|
||||
((0, 20), (40, 20), (40, 50), (0, 50)),
|
||||
),
|
||||
)
|
||||
domain = mesh._cutout_domain(
|
||||
regions,
|
||||
(120, 80),
|
||||
(0.0, 1.0, 0.0, 1.0),
|
||||
40,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
centers = domain[2][domain[1]].mean(axis=1)
|
||||
in_hole = (
|
||||
(centers[:, 0] > 0.45)
|
||||
& (centers[:, 0] < 0.55)
|
||||
& (centers[:, 1] > 0.35)
|
||||
& (centers[:, 1] < 0.65)
|
||||
)
|
||||
in_left_region = (
|
||||
(centers[:, 0] > 0.02)
|
||||
& (centers[:, 0] < 0.12)
|
||||
& (centers[:, 1] > 0.4)
|
||||
& (centers[:, 1] < 0.75)
|
||||
)
|
||||
|
||||
assert not in_hole.any()
|
||||
assert in_left_region.any()
|
||||
assert mesh_component_count(domain[1]) == 2
|
||||
Reference in New Issue
Block a user