- Added a proposal for depth surface strip cleanup, detailing the rationale and changes to the depth plane and cutout nodes. - Created specifications for the new cleanup requirements, including scenarios for strip removal and boundary edge detection. - Developed tasks for implementing shared strip detection logic and integrating new interfaces for depth nodes. - Established a new CLI structure for organizing node groups and model commands, replacing old entry points with a more intuitive command structure. - Introduced a prototype for panorama surface generation, including requirements for distance reconstruction and geometry node surfaces. - Added comprehensive tests for depth strip cleanup functionality, ensuring correct behavior for various mesh topologies and edge cases.
972 lines
37 KiB
Python
972 lines
37 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 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, _inputs
|
|
|
|
|
|
def test_object_normal_image_follows_cutout_inward_axis():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new(
|
|
"Object Normal",
|
|
width=1,
|
|
height=1,
|
|
alpha=True,
|
|
float_buffer=True,
|
|
)
|
|
decoded = np.asarray((0.2, -0.4, 0.8), dtype=np.float32)
|
|
encoded = decoded * 0.5 + 0.5
|
|
image.pixels.foreach_set((*encoded, 0.3))
|
|
|
|
cutout_object.orient_object_normal_image(
|
|
image,
|
|
cutout_object.CutoutInwardAxis.NEGATIVE_Z,
|
|
)
|
|
|
|
actual = np.empty(4, dtype=np.float32)
|
|
image.pixels.foreach_get(actual)
|
|
assert np.allclose(actual[:3], encoded, atol=1e-6)
|
|
assert np.isclose(actual[3], 0.3)
|
|
|
|
cutout_object.orient_object_normal_image(
|
|
image,
|
|
cutout_object.CutoutInwardAxis.POSITIVE_X,
|
|
)
|
|
image.pixels.foreach_get(actual)
|
|
expected = np.asarray((-0.8, -0.2, -0.4), dtype=np.float32) * 0.5 + 0.5
|
|
assert np.allclose(actual[:3], expected, atol=1e-6)
|
|
assert np.isclose(actual[3], 0.3)
|
|
|
|
|
|
@pytest.mark.parametrize("shape", ("FLAT", "SOLID"))
|
|
def test_cutout_axes_produce_the_same_world_geometry(shape):
|
|
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)
|
|
|
|
positive_x = _create_cutout_shape(
|
|
source,
|
|
shape,
|
|
8,
|
|
inward_axis=cutout_object.CutoutInwardAxis.POSITIVE_X,
|
|
)
|
|
negative_z = _create_cutout_shape(
|
|
source,
|
|
shape,
|
|
8,
|
|
inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z,
|
|
)
|
|
|
|
for result in (positive_x, negative_z):
|
|
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)
|
|
|
|
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_cutout_presets_load_three_direct_modifier_assets():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
groups = {shape: cutout_object.load_cutout_node_group(shape) for shape in cutout_shape.CUTOUT_SHAPES}
|
|
assert groups["FLAT"] == groups["SOLID"]
|
|
assert len(set(groups.values())) == 3
|
|
for shape, group in groups.items():
|
|
assert group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape]
|
|
assert group.is_modifier
|
|
assert "Shape" not in _inputs(group)
|
|
assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes)
|
|
assert all(socket.is_linked or socket.hide for node in group.nodes for socket in node.outputs)
|
|
for shape in ("SOLID", "DEPTH_SOLID"):
|
|
group = groups[shape]
|
|
inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"}
|
|
assert next(s for s in group.interface.items_tree if s.name == "Thickness").default_value == 1
|
|
thickness = [item for item in group.interface.items_tree if item.name == "Thickness"]
|
|
assert [item.subtype for item in thickness] == ["NONE", "DISTANCE"]
|
|
assert inputs["Thickness"].subtype == "DISTANCE"
|
|
assert inputs["Mode"].socket_type == "NodeSocketMenu"
|
|
assert inputs["Mode"].default_value == "Balloon"
|
|
assert inputs["Smooth"].default_value == 0
|
|
assert inputs["Smooth Weight"].default_value == 1
|
|
assert "Edge Smooth" not in inputs
|
|
assert not any(node.bl_idname == "GeometryNodeRepeatInput" and node.inputs["Iterations"].default_value == 8 for node in group.nodes)
|
|
assert next(item for item in groups["DEPTH_BALLOON"].interface.items_tree if item.name == "Double Sided").default_value
|
|
|
|
|
|
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",
|
|
8,
|
|
)
|
|
|
|
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
|
|
normal_scale_attribute = next(
|
|
node
|
|
for node in material_tree.nodes
|
|
if node.bl_idname == "ShaderNodeAttribute"
|
|
)
|
|
assert normal_scale_attribute.attribute_type == "GEOMETRY"
|
|
assert normal_scale_attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME
|
|
image_layer = _group_node(material_data.material_node_group(), material_tree)
|
|
normal_scale_link = next(
|
|
link
|
|
for link in material_tree.links
|
|
if link.to_node == image_layer
|
|
and link.to_socket == image_layer.inputs["Normal Scale"]
|
|
)
|
|
assert normal_scale_link.from_node == normal_scale_attribute
|
|
assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"]
|
|
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",
|
|
8,
|
|
)
|
|
|
|
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 any(
|
|
node.bl_idname == "ShaderNodeAttribute"
|
|
for node in result.data.materials[0].node_tree.nodes
|
|
)
|
|
|
|
|
|
def test_depth_surface_protected_smooth_evaluates():
|
|
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",
|
|
8,
|
|
depth_image=depth_image,
|
|
depth_metadata=depth_metadata,
|
|
)
|
|
|
|
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"]
|
|
expected_cleanup = cutout_geometry.cutout_cleanup_value(
|
|
cutout_geometry.cutout_sampling_spacing(source, 8),
|
|
cutout_geometry.world_plane_metric(source.matrix_world)[0],
|
|
)
|
|
assert np.isclose(
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")],
|
|
expected_cleanup,
|
|
)
|
|
assert np.isclose(modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")], 1.0)
|
|
assert (
|
|
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[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",
|
|
8,
|
|
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_split_removes_isolated_triangle_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, "Split Threshold")
|
|
# The raised corner becomes an isolated triangle after separation.
|
|
for value, expected in ((0., (9, 8)), (.5, (8, 7)), (1., (8, 7))):
|
|
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) == expected
|
|
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(), -0.5, atol=1e-6)
|
|
assert np.isclose(z.max(), 0.5, 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
|
|
|
|
|
|
def test_depth_balloon_uses_metric_plane_inputs_without_controller():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
image = bpy.data.images.new("Depth Balloon", width=100, height=100, alpha=True)
|
|
source = bpy.data.objects.new("Depth Balloon", 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_BALLOON",
|
|
8,
|
|
depth_image=depth_image,
|
|
depth_metadata=depth_metadata,
|
|
bounds=crop_bounds,
|
|
selection_values=selection_values,
|
|
)
|
|
|
|
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 "o_balloon_depth" not in result.data.attributes
|
|
assert "o_depth_offset" not in result.data.attributes
|
|
assert not result.children
|
|
assert {obj.name for obj in bpy.context.scene.objects} == {
|
|
source.name,
|
|
result.name,
|
|
}
|
|
modifier = result.modifiers[0]
|
|
depth_image_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Depth Image",
|
|
)
|
|
assert isinstance(modifier[depth_image_id], bpy.types.Image)
|
|
expected_center = np.zeros(2, dtype=np.float64)
|
|
crop_center = Vector(
|
|
(
|
|
(local_bounds[0] + local_bounds[1]) * 0.5,
|
|
(local_bounds[2] + local_bounds[3]) * 0.5,
|
|
0.0,
|
|
)
|
|
)
|
|
expected_origin = source.matrix_world @ crop_center
|
|
assert np.allclose(
|
|
tuple(result.matrix_world.translation),
|
|
tuple(expected_origin),
|
|
atol=1e-5,
|
|
)
|
|
|
|
depth_reference_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Reference Depth",
|
|
)
|
|
assert not np.isclose(modifier[depth_reference_id], 8.0)
|
|
depth_direction_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Depth Direction",
|
|
)
|
|
direction = np.asarray(modifier[depth_direction_id], dtype=np.float64)
|
|
assert not np.allclose(direction, (0.0, 0.0, 1.0))
|
|
uniform_scale_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Uniform Scale",
|
|
)
|
|
assert modifier[uniform_scale_id] > 0.0
|
|
thickness_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Thickness",
|
|
)
|
|
smooth_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Smooth Weight",
|
|
)
|
|
assert modifier[smooth_id] == 1.0
|
|
object_data.set_modifier_input(modifier, smooth_id, 0.0)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
positions = _evaluated_positions(result)
|
|
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1)
|
|
nearest_center = center_distance <= center_distance.min() + 1e-6
|
|
depth_at_thickness_one = positions[nearest_center, 2].max()
|
|
object_data.set_modifier_input(modifier, thickness_id, 2.0)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
positions = _evaluated_positions(result)
|
|
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1)
|
|
nearest_center = center_distance <= center_distance.min() + 1e-6
|
|
depth_at_thickness_two = positions[nearest_center, 2].max()
|
|
assert np.isclose(
|
|
depth_at_thickness_one,
|
|
depth_at_thickness_two,
|
|
atol=1e-5,
|
|
)
|
|
object_data.set_modifier_input(modifier, thickness_id, 1.0)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
|
|
before_positions = _evaluated_positions(result)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
depth_reference_id,
|
|
modifier[depth_reference_id] + 0.2,
|
|
)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
after_positions = _evaluated_positions(result)
|
|
assert np.isclose(
|
|
(after_positions[:, 2] - before_positions[:, 2]).max(),
|
|
0.2,
|
|
atol=1e-5,
|
|
)
|
|
|
|
depth_scale_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Depth Scale",
|
|
)
|
|
object_data.set_modifier_input(modifier, depth_scale_id, 0.0)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
zero_scale_before = _evaluated_positions(result)
|
|
object_data.set_modifier_input(
|
|
modifier,
|
|
depth_reference_id,
|
|
modifier[depth_reference_id] + 0.15,
|
|
)
|
|
object_data.set_modifier_input(modifier, depth_direction_id, (0.2, 0.1, 1.0))
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
zero_scale_after = _evaluated_positions(result)
|
|
assert np.allclose(zero_scale_after, zero_scale_before, atol=1e-5)
|
|
object_data.set_modifier_input(modifier, depth_scale_id, 1.0)
|
|
|
|
double_sided_id = object_data.modifier_input_identifier(
|
|
modifier.node_group,
|
|
"Double Sided",
|
|
)
|
|
object_data.set_modifier_input(modifier, double_sided_id, True)
|
|
result.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
mirrored = _evaluated_positions(result)
|
|
layers = {}
|
|
for x, y, z in mirrored:
|
|
layers.setdefault((round(float(x), 6), round(float(y), 6)), []).append(z)
|
|
assert layers
|
|
for z_values in layers.values():
|
|
if len(z_values) == 1:
|
|
assert np.isclose(z_values[0], 0.0, atol=1e-5)
|
|
continue
|
|
assert np.isclose(
|
|
min(z_values) + max(z_values),
|
|
0.0,
|
|
atol=1e-5,
|
|
)
|
|
|
|
|
|
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 = image_data.prepare_image_bounds_input(image, rgba, (1, 1, 5, 4))
|
|
result = None
|
|
try:
|
|
result = image_data.load_packed_rgba_image(
|
|
path,
|
|
image,
|
|
"Bounds Result",
|
|
)
|
|
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
|
|
|
|
image_data.cleanup_image_input(path, True)
|
|
|
|
assert not path.exists()
|
|
assert result.packed_file is not None
|
|
finally:
|
|
image_data.cleanup_image_input(path, True)
|
|
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 = cutout_object.create_cropped_color_image(
|
|
source,
|
|
selection_mask.bounds,
|
|
rgba,
|
|
)
|
|
cutout_main.create_cutout_shape(
|
|
bpy.context,
|
|
source,
|
|
"FLAT",
|
|
16,
|
|
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(
|
|
cutout_geometry.cutout_sampling_spacing(source, 16),
|
|
cutout_geometry.world_plane_metric(source.matrix_world)[0],
|
|
)
|
|
assert np.isclose(
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")],
|
|
expected_cleanup,
|
|
)
|
|
assert (
|
|
modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")]
|
|
== 1.0
|
|
)
|
|
assert (
|
|
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])
|
|
)
|