feat: Implement weight cutout boundary smoothing for image depth cutout

- Added a new proposal for boundary smoothing in the weight cutout process, differentiating between Split edges and Outline smoothing requirements.
- Introduced a new capability `cutout-boundary-smoothing` to control smoothing strength and default values.
- Updated specifications to define the behavior of boundary-specific smoothing strength and default settings.
- Created tasks to verify the implementation of boundary behavior and asset updates.
- Developed comprehensive tests for depth symmetry and normal scaling to ensure expected behavior and performance.
- Introduced new attributes for normal mapping to facilitate persistent face attributes sharing between depth symmetry and image materials.
- Built a new node group for O Image Depth Symmetry, integrating the new smoothing capabilities and ensuring compatibility with existing workflows.
This commit is contained in:
2026-09-14 12:44:20 +08:00
parent 09778c4b48
commit dc000e50b1
53 changed files with 1350 additions and 364 deletions

View File

@@ -5,9 +5,7 @@ import numpy as np
from anyimage.common import image as image_data
from anyimage.common import material as material_data
from anyimage.operators.cutout_tool import object as cutout_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from tests.support.image_objects import _create_cutout_shape, _group_node, _inputs
from tests.support.image_objects import _create_cutout_shape, _group_node
def test_image_layer_switches_between_tangent_and_object_space_normal():
@@ -201,28 +199,3 @@ def test_shared_conversion_images_use_blender_duplicate_suffix():
assert first is not second
assert first.name == "Poster_color.png"
assert second.name == "Poster_color.png.001"
def test_balloon_normal_scale_uses_existing_image_layer_input():
bpy.ops.wm.read_factory_settings(use_empty=True)
group = material_data.material_node_group()
original_inputs = _inputs(group)
image_material = bpy.data.materials.new("Balloon")
image_material.use_nodes = True
image_material.node_tree.nodes.clear()
image_layer = image_material.node_tree.nodes.new("ShaderNodeGroup")
image_layer.node_tree = group
cutout_object._connect_cutout_normal_scale(image_material)
assert _inputs(group) == original_inputs
attribute = next(
node
for node in image_material.node_tree.nodes
if node != image_layer
)
assert attribute.bl_idname == "ShaderNodeAttribute"
assert attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME
link = next(iter(image_material.node_tree.links))
assert link.from_socket == attribute.outputs["Fac"]
assert link.to_socket == image_layer.inputs["Normal Scale"]

View File

@@ -28,7 +28,7 @@ def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain():
assert np.corrcoef(selected_plane, along)[0, 1] > 0.99
def test_depth_balloon_plane_calibration_matches_cutout_axes():
def test_depth_symmetry_plane_calibration_matches_cutout_axes():
vertices = []
uv = []
camera_depth = np.empty((3, 3), dtype=np.float32)
@@ -40,7 +40,7 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes():
uv.append((column * 0.5, 1.0 - row * 0.5))
camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y
reference, direction = depth_calibration.depth_balloon_projection_parameters(
reference, direction = depth_calibration.depth_symmetry_projection_parameters(
vertices,
uv,
camera_depth,
@@ -58,7 +58,7 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes():
assert np.allclose(direction, expected_direction, atol=1e-6)
def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile():
def test_depth_symmetry_reference_uses_the_shared_upper_depth_percentile():
size = 10
row, column = np.indices((size, size))
camera_depth = np.full((size, size), 2.0, dtype=np.float32)
@@ -70,7 +70,7 @@ def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile():
vertices.append((float(x), float(size - 1 - y), 0.0))
uv.append((x / (size - 1), 1.0 - y / (size - 1)))
reference, direction = depth_calibration.depth_balloon_projection_parameters(
reference, direction = depth_calibration.depth_symmetry_projection_parameters(
vertices,
uv,
camera_depth,

View File

@@ -10,7 +10,7 @@ from anyimage.operators.cutout_tool.balloon import poisson_balloon_profile
@pytest.mark.parametrize("enabled", [False, True])
def test_depth_presets_always_generate_the_correct_normal_space(enabled):
assert normal_mode_for_shape("DEPTH_SOLID", enabled) == "OBJECT"
assert normal_mode_for_shape("DEPTH_BALLOON", enabled) == "TANGENT"
assert normal_mode_for_shape("DEPTH_SYMMETRY", enabled) == "TANGENT"
for shape in ("FLAT", "SOLID"):
assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE")

View File

@@ -133,7 +133,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
assert [call[2].shape for call in window_manager.pie.calls] == [
"FLAT",
"SOLID",
"DEPTH_BALLOON",
"DEPTH_SYMMETRY",
"DEPTH_SOLID",
]
assert all(call[2].generate_normal for call in window_manager.pie.calls)
@@ -155,10 +155,10 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
def test_cutout_normal_space_is_derived_from_shape():
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", False) == "OBJECT"
assert cutout_main.normal_mode_for_shape("DEPTH_BALLOON", False) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", False) == "TANGENT"
assert cutout_main.normal_mode_for_shape("FLAT", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("SOLID", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_BALLOON", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SYMMETRY", True) == "TANGENT"
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", True) == "OBJECT"

View File

@@ -175,22 +175,10 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
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
assert not any(node.bl_idname == "ShaderNodeAttribute" for node in material_tree.nodes)
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 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)
@@ -219,7 +207,7 @@ def test_teddy_preference_writes_the_shared_balloon_attribute():
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(
assert not any(
node.bl_idname == "ShaderNodeAttribute"
for node in result.data.materials[0].node_tree.nodes
)
@@ -585,10 +573,11 @@ def test_cutout_cleanup_uses_absolute_area_with_many_small_islands():
assert len(_evaluated_positions(obj)) == 9
def test_depth_balloon_uses_metric_plane_inputs():
@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 Balloon", width=100, height=100, alpha=True)
source = bpy.data.objects.new("Depth Balloon", None)
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
@@ -619,12 +608,13 @@ def test_depth_balloon_uses_metric_plane_inputs():
selection_values[:, :2] = 0.0
result = _create_cutout_shape(
source,
"DEPTH_BALLOON",
"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)
@@ -634,6 +624,9 @@ def test_depth_balloon_uses_metric_plane_inputs():
result.name,
}
modifier = result.modifiers[0]
mode_id = object_data.modifier_input_identifier(modifier.node_group, "Mode")
assert modifier_value(modifier, mode_id) == 1
object_data.set_modifier_input(modifier, mode_id, 0)
depth_image_id = object_data.modifier_input_identifier(
modifier.node_group,
"Depth Image",