Files
AnyImage/tests/anyimage/operators/test_cutout_objects.py
nan ab282e4fdf feat: Implement depth cutout symmetry and associated functionalities
- Introduced a new proposal for depth cutout symmetry, allowing for the reuse of depth replacement and geometry operations.
- Added requirements for maintaining default thickness values for cutout nodes and ensuring proper initialization during object creation.
- Enhanced symmetry node to handle existing geometry, including alignment, mirroring, and boundary filling.
- Implemented fill smoothing to control the geometry of generated walls while preserving original surfaces.
- Created tests to validate the functionality of the new symmetry features against baseline geometry.
- Updated existing assets and node groups to support the new symmetry capabilities without introducing additional dependencies.
2026-09-14 14:58:48 +08:00

851 lines
34 KiB
Python

from types import SimpleNamespace
from unittest.mock import patch
import bpy
import numpy as np
import pytest
from mathutils import Matrix, Vector
from anyimage.common import depth as depth_data
from anyimage.common import color_image as colors
from tests.support.color_image import color_image as prepared_color_image
from anyimage.common import image as image_data
from anyimage.common import material as material_data
from anyimage.common import object as object_data
from anyimage.common.selection import ImageEditWarning, SelectionPath, rasterize_selection_path
from anyimage.operators.cutout_tool import geometry as cutout_geometry
from anyimage.operators.cutout_tool import operators as cutout_main
from anyimage.operators.cutout_tool import object as cutout_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from anyimage.server.geometry.depth_texture import (
write_depth_texture,
)
from anyimage.server.models.geometry import GeometryFrame
from tests.support.image_objects import _add_cutout_modifier, _create_cutout_shape, _depth_image, _depth_metadata, _depth_surface_step_object, _evaluated_positions, _evaluated_topology, _group_node
@pytest.mark.parametrize("shape", ("FLAT", "SOLID"))
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
def test_cutout_depth_axis_is_adjusted_only_in_modifier(shape, gesture):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Axis Source", width=32, height=16, alpha=True)
source = bpy.data.objects.new("Axis Source", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
source.matrix_world = (
Matrix.Translation((2.0, -3.0, 1.0))
@ Matrix.Rotation(0.35, 4, "Y")
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
)
bpy.context.collection.objects.link(source)
bpy.context.view_layer.update()
positive_x = _create_cutout_shape(
source,
shape,
0.1,
gesture=gesture,
)
negative_z = _create_cutout_shape(
source,
shape,
0.1,
gesture=gesture,
)
for result in (positive_x, negative_z):
modifier = result.modifiers[0]
mode = object_data.modifier_input_identifier(modifier.node_group, "Mode")
assert modifier_value(modifier, mode) == (1 if gesture == "POLYLINE" else 0)
thickness = object_data.modifier_input_identifier(
modifier.node_group, "Thickness", subtype="DISTANCE",
)
assert modifier_value(modifier, thickness) == (0.5 if shape == "SOLID" and gesture == "POLYLINE" else 0.0)
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
assert np.allclose(local[:, 2], 0.0, atol=1e-8)
assert np.ptp(local[:, 0]) > 0.0
assert np.ptp(local[:, 1]) > 0.0
assert all(polygon.normal.z > 0.99 for polygon in result.data.polygons)
assert np.allclose(positive_x.matrix_world, source.matrix_world)
modifier = positive_x.modifiers[0]
axis = object_data.modifier_input_identifier(modifier.node_group, "Depth Axis")
assert modifier_value(modifier, axis) == 0
object_data.set_modifier_input(modifier, axis, 1)
positive_x.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
positive_x.matrix_world @= Matrix(cutout_shape.CUTOUT_Z_TO_X).inverted()
def world_positions(obj):
local = _evaluated_positions(obj)
return np.asarray([tuple(obj.matrix_world @ Vector(point)) for point in local])
expected = np.asarray(sorted(map(tuple, world_positions(positive_x))))
actual = np.asarray(sorted(map(tuple, world_positions(negative_z))))
from scipy.optimize import linear_sum_assignment
distances = np.linalg.norm(actual[:, None] - expected[None], axis=2)
rows, columns = linear_sum_assignment(distances)
assert distances[rows, columns].max() < 2e-6
assert np.allclose(negative_z.matrix_world, source.matrix_world)
def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Source.png", width=2, height=2)
source = bpy.data.objects.new("Source", None)
source.data = source_image
points = np.arange(12, dtype=np.float32).reshape((2, 2, 3))
validity = np.asarray(((1.0, 0.0), (0.5, 1.0)), dtype=np.float32)
frame = GeometryFrame(
depth=points[..., 2].copy(),
validity=validity,
intrinsics=np.eye(3, dtype=np.float32),
points=points,
)
path = tmp_path / "depth.exr"
alpha = np.asarray(((0.8, 1.0), (0.75, 0.0)), dtype=np.float32)
write_depth_texture(frame, path, alpha=alpha)
metadata = _depth_metadata(np.eye(3), (2, 2), 1.0)
image = depth_data.load_depth_result_image(path, source, metadata)
packed = np.empty(16, dtype=np.float32)
image.pixels.foreach_get(packed)
packed = np.flipud(packed.reshape((2, 2, 4)))
assert np.array_equal(packed[..., :3], points)
assert np.array_equal(packed[..., 3], alpha * validity)
camera_depth, valid = depth_data.camera_depth_values(image)
np.testing.assert_array_equal(camera_depth, points[..., 2])
np.testing.assert_array_equal(valid, alpha * validity > 0.5)
assert image.alpha_mode == "CHANNEL_PACKED"
assert image.name == "Source_depth.exr"
def test_depth_texture_keeps_points_outside_positive_validity(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Source.png", width=2, height=1)
source = bpy.data.objects.new("Source", None)
source.data = source_image
frame = GeometryFrame(
depth=np.asarray(((2.0, 7.0),), dtype=np.float32),
points=np.asarray((((-1.0, 3.0, 2.0), (4.0, -2.0, 7.0)),), dtype=np.float32),
validity=np.asarray(((1.0, 0.0),), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
)
path = tmp_path / "depth.exr"
write_depth_texture(frame, path, alpha=np.ones_like(frame.validity))
image = depth_data.load_depth_result_image(
path,
source,
_depth_metadata(np.eye(3), (2, 1), 2.0),
)
packed = np.empty(8, dtype=np.float32)
image.pixels.foreach_get(packed)
packed = packed.reshape((1, 2, 4))
assert np.array_equal(packed[..., :3], frame.points)
assert np.array_equal(packed[..., 3], frame.validity)
def test_balloon_build_starts_from_the_unified_single_sheet():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Balloon Sheet", width=64, height=64, alpha=True)
source = bpy.data.objects.new("Balloon Sheet", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
result = _create_cutout_shape(
source,
"SOLID",
0.1,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert len(result.data.vertices) > 4
assert len(result.data.polygons) > 1
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
profile_attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME]
assert profile_attribute.data_type == "FLOAT"
assert profile_attribute.domain == "POINT"
profile = np.empty(len(result.data.vertices), dtype=np.float32)
profile_attribute.data.foreach_get("value", profile)
assert profile.min() == 0.0 and profile.max() > 0.0
material_tree = result.data.materials[0].node_tree
assert not any(node.bl_idname == "ShaderNodeAttribute" for node in material_tree.nodes)
image_layer = _group_node(material_data.material_node_group(), material_tree)
assert not image_layer.inputs["Normal Scale"].is_linked
assert image_layer.inputs["Normal Scale"].default_value == 1.0
assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["SOLID"]
inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
def test_teddy_preference_writes_the_shared_balloon_attribute():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Teddy Sheet", width=64, height=64, alpha=True)
source = bpy.data.objects.new("Teddy Sheet", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
with patch.object(
cutout_geometry,
"addon_preferences",
return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"),
):
result = _create_cutout_shape(
source,
"FLAT",
0.1,
)
attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME]
profile = np.empty(len(result.data.vertices), dtype=np.float32)
attribute.data.foreach_get("value", profile)
assert profile.min() == 0.0 and profile.max() > 0.0
assert not any(
node.bl_idname == "ShaderNodeAttribute"
for node in result.data.materials[0].node_tree.nodes
)
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
def test_depth_surface_protected_smooth_evaluates(gesture):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Surface", width=64, height=64)
source = bpy.data.objects.new("Surface", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
size = 8
depth = np.ones((size, size), dtype=np.float32)
x, y = np.meshgrid(np.arange(size), np.arange(size))
intrinsics = np.asarray(
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
points = np.empty((size, size, 3), dtype=np.float32)
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
points[..., 2] = depth
valid = np.ones((size, size), dtype=bool)
depth_image = _depth_image(points, valid)
depth_metadata = _depth_metadata(intrinsics, (size, size), 1.0)
result = _create_cutout_shape(
source,
"DEPTH_SOLID",
0.25,
depth_image=depth_image,
depth_metadata=depth_metadata,
gesture=gesture,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
modifier = result.modifiers[0]
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["DEPTH_SOLID"]
layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree)
assert layer.inputs["Object Space"].default_value is True
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Mode")) == (1 if gesture == "POLYLINE" else 0)
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE")) == pytest.approx(0.2)
assert modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE")) == 1.0
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
0.0,
)
expected_cleanup = cutout_geometry.cutout_cleanup_value(
0.25,
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")),
expected_cleanup,
)
assert np.isclose(modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")), 1.0)
assert (
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth"))
== 0
)
for name, value in (("Mode", 1), ("Front Inflation", 0.0)):
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value)
reference_id = object_data.modifier_input_identifier(
modifier.node_group,
"Reference Depth",
)
assert modifier_value(modifier, reference_id) == 8.0
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"),
0.0,
)
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
baseline_mesh = evaluated.to_mesh()
try:
baseline_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices])
baseline_min = baseline_z.min()
baseline_max = baseline_z.max()
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
1.0,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
evaluated_mesh = evaluated.to_mesh()
try:
assert len(evaluated_mesh.polygons) > 0
z = np.asarray([vertex.co.z for vertex in evaluated_mesh.vertices])
assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5)
assert np.isclose(z.max(), baseline_max, atol=1e-5)
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Smooth"),
4,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"),
1.0,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
internal_mesh = evaluated.to_mesh()
try:
internal_positions = np.asarray(
[tuple(vertex.co) for vertex in internal_mesh.vertices]
)
finally:
evaluated.to_mesh_clear()
assert internal_positions.ndim == 2
assert internal_positions.shape[1] == 3
assert np.isfinite(internal_positions).all()
def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Surface", width=64, height=64)
source = bpy.data.objects.new("Surface", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
size = 8
depth = np.ones((size, size), dtype=np.float32)
x, y = np.meshgrid(np.arange(size), np.arange(size))
intrinsics = np.asarray(
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
points = np.empty((size, size, 3), dtype=np.float32)
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
points[..., 2] = depth
validity = np.ones((size, size), dtype=bool)
validity[[0, -1], :] = False
validity[:, [0, -1]] = False
result = _create_cutout_shape(
source,
"DEPTH_SOLID",
0.1,
depth_image=_depth_image(points, validity),
depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0),
)
modifier = result.modifiers[0]
for name, value in (("Thickness", 0.0), ("Smooth", 0)):
object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
positions = _evaluated_positions(result)
assert np.isfinite(positions).all()
assert np.ptp(positions[:, 0]) > 1.5
assert np.ptp(positions[:, 1]) > 1.5
assert np.ptp(positions[:, 2]) < 1e-4
def test_depth_surface_removes_boundary_ears_without_loose_geometry():
bpy.ops.wm.read_factory_settings(use_empty=True)
obj, modifier = _depth_surface_step_object()
split_id = object_data.modifier_input_identifier(modifier.node_group, "Depth Split")
# Both outer ears have three boundary vertices, including the raised corner.
for value in (0., .5, 1.):
object_data.set_modifier_input(modifier, split_id, value)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
assert (vertices, faces) == (7, 6)
assert not loose_edges and not loose_points
def test_cutout_thickness_keeps_one_connected_shell():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
data = bpy.data.meshes.new("Connected Cutout")
data.from_pydata(vertices, (), faces)
obj = bpy.data.objects.new("Connected Cutout", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "FLAT")
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"),
0.0,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
1.0,
)
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.vertices) == 18
assert len(result.polygons) == 24
z = np.asarray([vertex.co.z for vertex in result.vertices])
assert np.isclose(z.min(), -1.0, atol=1e-6)
assert np.isclose(z.max(), 0.0, atol=1e-6)
front_faces = [
polygon for polygon in result.polygons if polygon.normal.z > 0.9
]
back_faces = [polygon for polygon in result.polygons if polygon.normal.z < -0.9]
side_faces = [
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
]
assert len(front_faces) == 8
assert len(back_faces) == 8
assert len(side_faces) == 8
center = Vector((1.0, 1.0, 0.0))
for polygon in side_faces:
radial = polygon.center - center
assert polygon.normal.dot(radial) > 0.0
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"),
2.4,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.vertices) == 34
assert len(result.polygons) == 40
side_faces = [
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
]
assert len(side_faces) == 24
for polygon in side_faces:
coordinates = [result.vertices[index].co for index in polygon.vertices]
edge_lengths = [
(coordinates[(index + 1) % len(coordinates)] - coordinate).length
for index, coordinate in enumerate(coordinates)
]
assert max(edge_lengths) / min(edge_lengths) <= 1.25 + 1e-6
finally:
evaluated.to_mesh_clear()
def test_balloon_uses_injected_profile_at_default_height():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
data = bpy.data.meshes.new("Balloon Profile")
data.from_pydata(vertices, (), faces)
cutout_object._assign_float_point_attribute(
data,
cutout_shape.BALLOON_ATTRIBUTE_NAME,
(0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0),
)
obj = bpy.data.objects.new("Balloon Profile", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "SOLID")
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"),
0.0,
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
1.0,
)
bpy.context.view_layer.update()
positions = _evaluated_positions(obj)
assert np.isclose(positions[:, 2].min(), -1.0, atol=1e-6)
assert np.isclose(positions[:, 2].max(), 1.0, atol=1e-6)
assert np.isclose(
positions[:, 2].min(),
-positions[:, 2].max(),
atol=1e-5,
)
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
baseline_face_count = len(result.polygons)
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
2.0,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert len(result.polygons) == baseline_face_count
z = np.asarray([vertex.co.z for vertex in result.vertices])
assert np.isclose(z.min(), -2.0, atol=1e-6)
assert np.isclose(z.max(), 2.0, atol=1e-6)
finally:
evaluated.to_mesh_clear()
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
0.0,
)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
zero_positions = _evaluated_positions(obj)
assert len(zero_positions) == len(vertices)
assert np.allclose(zero_positions[:, 2], 0.0, atol=1e-6)
def test_cutout_cleanup_uses_absolute_area_with_many_small_islands():
bpy.ops.wm.read_factory_settings(use_empty=True)
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
faces = []
for y in range(2):
for x in range(2):
a = y * 3 + x
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
for island in range(12):
start = len(vertices)
x = 5.0 + island * 0.1
vertices.extend(((x, 5.0, 0.0), (x + 0.01, 5.0, 0.0), (x, 5.01, 0.0)))
faces.append((start, start + 1, start + 2))
data = bpy.data.meshes.new("Cleanup Threshold")
data.from_pydata(vertices, (), faces)
obj = bpy.data.objects.new("Cleanup Threshold", data)
bpy.context.collection.objects.link(obj)
modifier = _add_cutout_modifier(obj, "FLAT")
cleanup_id = object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")
object_data.set_modifier_input(modifier, cleanup_id, 0.0)
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 45
object_data.set_modifier_input(modifier, cleanup_id, 0.1)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
assert len(_evaluated_positions(obj)) == 9
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
def test_depth_symmetry_uses_metric_plane_inputs(gesture):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Depth Symmetry", width=100, height=100, alpha=True)
source = bpy.data.objects.new("Depth Symmetry", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
crop_bounds = (50, 20, 90, 80)
local_bounds = cutout_geometry.crop_plane_bounds(
source,
crop_bounds,
(100, 100),
)
y, x = np.indices((61, 41), dtype=np.float32)
normalized_x = x / 40.0 * 2.0 - 1.0
normalized_y = y / 60.0 * 2.0 - 1.0
points = np.zeros((61, 41, 3), dtype=np.float32)
points[..., 0] = normalized_x
points[..., 1] = normalized_y
points[..., 2] = 2.0 + normalized_x * 0.15 - normalized_y * 0.1
points[27:34, 17:24, 2] -= 0.25
intrinsics = np.asarray(
((50.0, 0.0, 20.0), (0.0, 50.0, 30.0), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
valid = np.ones((61, 41), dtype=bool)
depth_image = _depth_image(points, valid)
depth_metadata = _depth_metadata(intrinsics, (41, 61), 2.0)
selection_values = np.ones((60, 40), dtype=np.float32)
selection_values[:, :2] = 0.0
result = _create_cutout_shape(
source,
"DEPTH_SYMMETRY",
0.1,
depth_image=depth_image,
depth_metadata=depth_metadata,
bounds=crop_bounds,
selection_values=selection_values,
gesture=gesture,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
assert {obj.name for obj in bpy.context.scene.objects} == {
source.name,
result.name,
}
assert len(result.modifiers) == 2
modifier, symmetry = result.modifiers
assert modifier.node_group.name == "O Image Depth Cutout"
assert symmetry.node_group.name == "O Image Cutout Symmetry"
layer = _group_node(material_data.material_node_group(), result.data.materials[0].node_tree)
assert layer.inputs["Object Space"].default_value is True
assert not modifier.show_group_selector and not symmetry.show_group_selector
def value(modifier, name, subtype=None):
return modifier_value(modifier, object_data.modifier_input_identifier(
modifier.node_group, name, subtype=subtype))
assert value(modifier, "Mode") == (1 if gesture == "POLYLINE" else 0)
for subtype in ("NONE", "DISTANCE"):
assert value(modifier, "Thickness", subtype) == 0
initialized = {"Mode", "Cleanup Threshold", "Depth Scale", "Uniform Scale", "Reference Depth", "Depth Image"}
for socket in modifier.node_group.interface.items_tree:
if (socket.item_type == "SOCKET" and socket.in_out == "INPUT"
and socket.name not in initialized
and socket.socket_type in {"NodeSocketFloat", "NodeSocketInt"}):
assert modifier_value(modifier, socket.identifier) == pytest.approx(socket.default_value)
assert value(modifier, "Depth Axis") == 0
assert isinstance(value(modifier, "Depth Image"), bpy.types.Image)
assert value(modifier, "Uniform Scale") > 0
assert value(modifier, "Reference Depth") > 0
assert not np.allclose(value(symmetry, "Symmetry Direction"), (0, 0, 1))
crop_center = Vector(((local_bounds[0] + local_bounds[1]) / 2,
(local_bounds[2] + local_bounds[3]) / 2, 0))
np.testing.assert_allclose(result.matrix_world.translation, source.matrix_world @ crop_center, atol=1e-5)
from scipy.spatial import cKDTree
positions = _evaluated_positions(result)
assert len(positions) and np.isfinite(positions).all()
assert cKDTree(positions).query(positions * (1, 1, -1))[0].max() < 1e-5
# Both modes start from the same front, including after changing the menu.
object_data.set_modifier_input(modifier,
object_data.modifier_input_identifier(modifier.node_group, "Mode"),
0 if gesture == "POLYLINE" else 1)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
changed = _evaluated_positions(result)
np.testing.assert_array_equal(changed, positions)
def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Bounds Source", width=5, height=4, alpha=True)
rgba = np.arange(4 * 5 * 4, dtype=np.float32).reshape((4, 5, 4)) / 100.0
rgba[:, :, 3] = np.asarray(
(
(0.0, 0.1, 0.2, 0.3, 0.4),
(0.5, 0.6, 0.7, 0.8, 0.9),
(1.0, 0.9, 0.8, 0.7, 0.6),
(0.5, 0.4, 0.3, 0.2, 0.1),
),
dtype=np.float32,
)
path = colors.prepare_material_color_input(image, (1, 1, 5, 4), rgba)
result = None
try:
result = colors.load_material_color_image(path)
loaded = image_data.image_rgba(result)
np.testing.assert_allclose(loaded, rgba[1:4, 1:5], atol=1.0 / 255.0)
assert result.packed_file is not None
colors.cleanup_material_color_input(path)
assert not path.exists()
assert result.packed_file is not None
finally:
colors.cleanup_material_color_input(path)
if result is not None:
bpy.data.images.remove(result, do_unlink=True)
def test_local_cutout_selection_matches_uint8_tenth_alpha_boundary():
top_down = np.ones((4, 4, 4), dtype=np.float32)
top_down[1, 1, 3] = 25.0 / 255.0
top_down[1, 2, 3] = 26.0 / 255.0
selection_mask = rasterize_selection_path(
(4, 4),
SelectionPath(
points=((0, 0), (4, 0), (4, 4), (0, 4)),
),
)
selection = cutout_main._cutout_content_values(
top_down[:, :, 3],
selection_mask.values,
)
assert selection[1, 1] <= cutout_geometry.CUTOUT_ALPHA_THRESHOLD
assert selection[1, 2] > 0.1
def test_cutout_content_merge_keeps_the_geometric_selection_unchanged():
selection = np.asarray(((0.25, 1.0), (0.5, 0.75)), dtype=np.float32)
original = selection.copy()
alpha = np.asarray(((1.0, 0.5), (0.0, 0.25)), dtype=np.float32)
content = cutout_main._cutout_content_values(
alpha, selection, alpha_threshold=0.5
)
np.testing.assert_array_equal(selection, original)
np.testing.assert_allclose(content, alpha * original)
with pytest.raises(ImageEditWarning, match="no visible image pixels"):
cutout_main._cutout_content_values(np.zeros((2, 2)), selection)
def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Local Cutout", width=64, height=48, alpha=True)
source_rgba = np.ones((48, 64, 4), dtype=np.float32)
source_rgba[:, :, 3] = np.linspace(0.25, 1.0, 48, dtype=np.float32)[:, None]
image.pixels.foreach_set(np.flipud(source_rgba).ravel())
source = bpy.data.objects.new("Local Cutout", None)
source.empty_display_type = "IMAGE"
source.data = image
bpy.context.collection.objects.link(source)
source.select_set(True)
bpy.context.view_layer.objects.active = source
selection_path = SelectionPath(
points=((-8, 8), (72, 8), (72, 40), (-8, 40)),
)
source_bounds = image_data.image_empty_bounds(source)
with patch.object(
image_data,
"image_rgba",
wraps=image_data.image_rgba,
) as read_rgba:
rgba = image_data.image_rgba(image)
selection_mask = rasterize_selection_path(
tuple(image.size),
selection_path,
antialias=True,
)
left, top, right, bottom = selection_mask.bounds
content_values = cutout_main._cutout_content_values(
rgba[top:bottom, left:right, 3],
selection_mask.values,
alpha_threshold=0.5,
)
color_image = prepared_color_image(
source.data,
selection_mask.bounds,
rgba,
)
cutout_main.create_cutout_shape(
bpy.context,
source,
"FLAT",
0.1,
content_values,
selection_mask.bounds,
alpha_threshold=0.5,
color_image=color_image,
)
read_rgba.assert_called_once_with(image)
result = bpy.context.view_layer.objects.active
assert result is not source
assert result.type == "MESH"
assert result["anyimage_mesh_shape"] == "FLAT"
modifier = result.modifiers[0]
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["FLAT"]
expected_cleanup = cutout_geometry.cutout_cleanup_value(
0.1,
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")),
expected_cleanup,
)
assert (
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"))
== 1.0
)
assert (
modifier_value(modifier, object_data.modifier_input_identifier(modifier.node_group, "Smooth"))
== 0
)
assert len(result.data.vertices) > 4
assert len(result.data.polygons) > 1
assert all(polygon.use_smooth for polygon in result.data.polygons)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
source_inverse = source.matrix_world.inverted()
source_x = [
(source_inverse @ (result.matrix_world @ vertex.co)).x
for vertex in result.data.vertices
]
assert min(source_x) >= source_bounds[0]
assert max(source_x) <= source_bounds[1]
assert bpy.data.objects.get(source.name) is source
assert result.data.materials[0].node_tree is not None
color_texture = next(
node
for node in result.data.materials[0].node_tree.nodes
if node.bl_idname == "ShaderNodeTexImage"
)
assert color_texture.image is not image
assert tuple(color_texture.image.size) == (64, 36)
copied = np.empty(36 * 64 * 4, dtype=np.float32)
color_texture.image.pixels.foreach_get(copied)
copied = copied.reshape((36, 64, 4))
assert np.allclose(copied[:, :, :3], 1.0)
left, top, right, bottom = selection_mask.bounds
assert np.allclose(
copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3])
)
def modifier_value(modifier, identifier):
"""Read modifier values through the runtime's socket API."""
inputs = object_data._modifier_input_slots(modifier)
if inputs is None:
return modifier[identifier]
slot = getattr(inputs, identifier)
prop = slot.bl_rna.properties["value"]
if prop.type == "ENUM":
return list(prop.enum_items.keys()).index(slot.value)
return slot.value