Files
AnyImage/tests/anyimage/operators/test_cutout_objects.py
nan 0aeb61f818 Refactor tests and remove unused imports
- Cleaned up test files by removing unnecessary imports and unused parameters.
- Simplified test functions by eliminating redundant parameterization and consolidating logic.
- Updated test cases to directly use defined values instead of parameterized inputs where applicable.
- Enhanced readability and maintainability of the test code across various test files.
2026-09-17 10:02:05 +08:00

513 lines
21 KiB
Python

from unittest.mock import patch
import bpy
import numpy as np
import pytest
from mathutils import Vector
from anyimage.common import depth as depth_data
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 shape as cutout_shape
from anyimage.operators.cutout_tool.boundary_padding import (
image_rgba_buffer,
pad_cutout_images,
)
from anyimage.server.geometry.depth_texture import (
write_depth_texture,
)
from anyimage.server.models.geometry import GeometryFrame
from tests.support.image_objects import _create_cutout_shape, _depth_image, _depth_metadata, _evaluated_positions, _group_node
def test_depth_result_image_loads_camera_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))
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 > depth_data.DEPTH_ALPHA_THRESHOLD,
)
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)),
)
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.y) < 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, 1.0, 0.0))
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
@pytest.mark.parametrize("gesture", ("LASSO", "POLYLINE"))
def test_depth_surface_object_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))
result = _create_cutout_shape(
source,
"DEPTH_SOLID",
0.25,
depth_image=depth_image,
depth_metadata=depth_metadata,
gesture=gesture,
)
assert all(abs(vertex.co.y) < 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,
)
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)
scale_id = object_data.modifier_input_identifier(
modifier.node_group,
"Uniform Scale",
)
reference_id = object_data.modifier_input_identifier(
modifier.node_group,
"Reference Depth",
)
# The Depth texture holds a constant unit camera depth.
assert modifier_value(modifier, reference_id) == pytest.approx(
modifier_value(modifier, scale_id)
)
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Boundary Smooth"),
0,
)
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
baseline_mesh = evaluated.to_mesh()
try:
baseline_y = np.asarray([vertex.co.y for vertex in baseline_mesh.vertices])
baseline_min = baseline_y.min()
baseline_max = baseline_y.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
y = np.asarray([vertex.co.y for vertex in evaluated_mesh.vertices])
assert np.isclose(y.min(), baseline_min, atol=1e-5)
assert np.isclose(y.max(), baseline_max + 1.0, atol=1e-5)
finally:
evaluated.to_mesh_clear()
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)),
)
modifier = result.modifiers[0]
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(modifier.node_group, "Thickness"),
0.0,
)
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[:, 2]) > 1.5
assert np.ptp(positions[:, 1]) < 1e-4
@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))
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.y) < 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", "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 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, "Direction"), (0, 1, 0))
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)
@pytest.mark.parametrize("float_buffer", (False, True))
def test_boundary_padding_preserves_independent_blender_image_interpretation(float_buffer):
bpy.ops.wm.read_factory_settings(use_empty=True)
source = bpy.data.images.new(
"Padding Source", width=9, height=9, alpha=True, float_buffer=float_buffer
)
padded = bpy.data.images.new(
"Padding Color", width=9, height=9, alpha=True, float_buffer=float_buffer
)
source.alpha_mode = padded.alpha_mode = "CHANNEL_PACKED"
values = np.zeros((9, 9, 4), dtype=np.float32)
values[2:7, 2:7] = (0.25, 0.5, 0.75, 1.0)
source.pixels.foreach_set(np.flipud(values).ravel())
padded.pixels.foreach_set(np.flipud(values).ravel())
original = image_rgba_buffer(source)
pad_cutout_images(padded, None, None, values[..., 3], 0.5, 2.0)
np.testing.assert_array_equal(image_rgba_buffer(source), original)
assert image_rgba_buffer(padded)[1, 4, 3] == pytest.approx(1.0)
assert padded.is_float is float_buffer
assert padded.alpha_mode == "CHANNEL_PACKED"
assert padded.packed_file is not None
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,
boundary_padding=0.0,
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"]
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.y) < 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