Files
AnyImage/tests/test_mesh.py
nan 6c8358f92f Refactor image processing and cutout functionalities
- Updated the naming convention for upscaled images to include underscores and file extensions.
- Renamed properties related to cutout pixel size to point spacing for clarity.
- Adjusted alpha region calculations to use a half-alpha threshold for better accuracy.
- Enhanced the handling of foreground mesh masks to consider half-alpha values.
- Introduced new functions for unbounded selections and cutout images, ensuring proper handling of source and selection images.
- Improved test coverage for cutout functionalities, including bounds mapping and alpha preservation.
- Updated tests to reflect changes in naming conventions and functionality adjustments.
2026-08-27 12:01:47 +08:00

367 lines
12 KiB
Python

import builtins
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
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_cutout_crop_bounds_map_to_the_source_plane():
source = SimpleNamespace(
data=SimpleNamespace(size=(200, 100)),
empty_display_size=2.0,
empty_image_offset=(-0.5, -0.5),
)
bounds = mesh._crop_plane_bounds(
source,
(50, 25, 150, 75),
(200, 100),
)
np.testing.assert_allclose(bounds, (-0.5, 0.5, -0.25, 0.25))
def test_depth_surface_cutout_uses_half_alpha_boundary():
alpha = np.zeros((12, 16), dtype=np.float32)
alpha[2:10, 2:14] = 0.49
alpha[4:8, 5:11] = 0.51
regions, size = mesh._cutout_regions(alpha, 64, threshold=0.5)
contour = np.asarray(regions[0][0])
assert size == (16, 12)
np.testing.assert_allclose(contour.min(axis=0), (5.0, 4.0))
np.testing.assert_allclose(contour.max(axis=0), (11.0, 8.0))
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_point_spacing_uses_the_complete_source_plane():
source = SimpleNamespace(matrix_world=object())
with (
patch.object(mesh, "plane_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object(
mesh,
"_world_plane_metric",
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
spacing = mesh._cutout_point_spacing(source, 200)
assert spacing == 0.02
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),
0.5,
((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),
0.5,
((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),
0.025,
((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