Files
AnyImage/tests/tools/nodes/test_boundary_smoothing.py
nan c62ec2cd48 Refactor depth surface handling and unify depth sampling rules
- Removed the `always_vertex` parameter from `build_surface_camera` to streamline depth sampling for cutouts and ensure consistent behavior across depth plane and panorama.
- Updated depth sampling logic to ensure that all free boundary points, including the original frame of the depth plane, use neighboring face depth instead of their own texture sampling.
- Adjusted the depth mask deletion process to operate on faces rather than points, eliminating the need for additional cleanup of dangling edges.
- Ensured that the cutout rules are consistently applied across all relevant nodes, removing exceptions and simplifying the logic.
- Updated tests to verify the new behavior, ensuring that cutout points follow the depth of adjacent faces and that the original frame maintains its depth sampling.
- Refactored geometry handling in multiple depth-related nodes to improve clarity and maintainability.
2026-09-16 16:30:56 +08:00

412 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Verify pinned-boundary smoothing stays within its fixed edge band."""
from collections import Counter
import bpy
import bmesh
import numpy as np
import pytest
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
from tools.nodes.common.boundary_smoothing import cut_boundary_influence
from tools.nodes.common.smoothing import pinned_smooth
def pinned_group(
iterations=1,
region=1.0,
weight=1.0,
movement=None,
pin_boundary=True,
pin_sharp=True,
):
group = bpy.data.node_groups.new("Pinned smooth", "GeometryNodeTree")
for direction in ("INPUT", "OUTPUT"):
group.interface.new_socket(
name="Geometry", in_out=direction, socket_type="NodeSocketGeometry"
)
smooth = group.interface.new_socket(
name="Smooth", in_out="INPUT", socket_type="NodeSocketInt"
)
smooth.default_value = iterations
source = group.nodes.new("NodeGroupInput")
control = group.nodes.new("NodeGroupInput")
region_value = group.nodes.new("ShaderNodeValue")
region_value.outputs[0].default_value = region
result = pinned_smooth(
group,
source.outputs["Geometry"],
control.outputs["Smooth"],
region_value.outputs[0],
weight=weight,
movement=movement,
pin_boundary=pin_boundary,
pin_sharp=pin_sharp,
)
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
return group
def pinned_result(points, faces, **options):
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("Pinned")
mesh.from_pydata(points, [], faces)
obj = bpy.data.objects.new("Pinned", mesh)
bpy.context.collection.objects.link(obj)
obj.modifiers.new("Pinned", "NODES").node_group = pinned_group(**options)
return evaluated(obj)[0]
def grid(size):
points = [(x, y, 0.0) for y in range(size) for x in range(size)]
faces = [
(
y * size + x,
y * size + x + 1,
(y + 1) * size + x + 1,
(y + 1) * size + x,
)
for y in range(size - 1)
for x in range(size - 1)
]
return points, faces
def diagonal_surface(triangles=False):
obj, set_value = surface(resolution=16)
if triangles:
mesh = bmesh.new()
mesh.from_mesh(obj.data)
bmesh.ops.triangulate(mesh, faces=list(mesh.faces))
mesh.to_mesh(obj.data)
mesh.free()
image = bpy.data.images["Camera"]
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
yy, xx = np.mgrid[:32, :64]
pixels[..., 2] = 1 + (xx > 18 + yy * 0.7) * 6
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Depth Split", 0.5)
set_value("Depth Scale", 0)
return obj, set_value
def boundary_neighbors(faces):
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
neighbors = {}
for (a, b), count in edges.items():
if count == 1:
neighbors.setdefault(a, []).append(b)
neighbors.setdefault(b, []).append(a)
return neighbors
def edge_band(faces, seeds, rings=2):
adjacency = {}
for face in faces:
for a, b in zip(face, (*face[1:], face[0])):
adjacency.setdefault(a, set()).add(b)
adjacency.setdefault(b, set()).add(a)
selected = set(seeds)
for _ in range(rings):
selected |= {neighbor for vertex in selected for neighbor in adjacency[vertex]}
return selected
@pytest.mark.parametrize(("outline_scale", "expected_outline"), [(0.1, 0.1), (1.0, 1.0)])
def test_split_gets_full_influence_and_outline_uses_configured_scale(outline_scale, expected_outline):
bpy.ops.wm.read_factory_settings(use_empty=True)
points, faces, split = [], [], []
for side in range(2):
offset = len(points)
for y in range(9):
for x in range(5):
points.append((side * 4 + x, y, 0))
split.append(x == (4 if side == 0 else 0))
faces.extend(
(
offset + y * 5 + x,
offset + y * 5 + x + 1,
offset + (y + 1) * 5 + x + 1,
offset + (y + 1) * 5 + x,
)
for y in range(8)
for x in range(4)
)
mesh = bpy.data.meshes.new("Boundary influence")
mesh.from_pydata(points, [], faces)
mesh.attributes.new("test_split", "BOOLEAN", "POINT").data.foreach_set("value", split)
obj = bpy.data.objects.new("Boundary influence", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Boundary influence", "GeometryNodeTree")
for direction in ("INPUT", "OUTPUT"):
group.interface.new_socket(name="Geometry", in_out=direction, socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
split_attribute = group.nodes.new("GeometryNodeInputNamedAttribute")
split_attribute.data_type = "BOOLEAN"
split_attribute.inputs["Name"].default_value = "test_split"
store = group.nodes.new("GeometryNodeStoreNamedAttribute")
store.data_type, store.domain = "FLOAT", "POINT"
store.inputs["Name"].default_value = "test_influence"
group.links.new(source.outputs["Geometry"], store.inputs["Geometry"])
group.links.new(
cut_boundary_influence(group, split_attribute.outputs["Attribute"], outline_scale=outline_scale),
store.inputs["Value"],
)
output = group.nodes.new("NodeGroupOutput")
group.links.new(store.outputs["Geometry"], output.inputs["Geometry"])
obj.modifiers.new("Boundary influence", "NODES").node_group = group
evaluated_obj = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated_obj.to_mesh()
try:
influence = np.array([value.value for value in result.attributes["test_influence"].data])
finally:
evaluated_obj.to_mesh_clear()
np.testing.assert_allclose(influence[4 * 5], expected_outline, atol=1e-6)
np.testing.assert_allclose(influence[4 * 5 + 4], 1.0, atol=1e-6)
np.testing.assert_allclose(influence[4], 1.0, atol=1e-6)
def test_cutout_boundary_smooth_defaults_to_four():
obj, _ = surface()
socket = next(
item for item in obj.modifiers[0].node_group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == "Boundary Smooth"
)
assert socket.default_value == 4
assert (socket.min_value, socket.max_value) == (0, 16)
@pytest.mark.parametrize("triangles", [False, True])
def test_smoothing_reduces_stairs_without_leaving_two_ring_band(triangles):
obj, set_value = diagonal_surface(triangles)
original, faces = evaluated(obj)
boundary = boundary_neighbors(faces)
outer = np.isclose(original[:, 0], 0) | np.isclose(original[:, 0], 2)
outer |= np.isclose(original[:, 2], 0) | np.isclose(original[:, 2], 1)
cut = {i for i in boundary if not outer[i]}
band = edge_band(faces, boundary)
movable = np.array([i in band for i in range(len(original))])
assert movable.any() and (~movable).any()
set_value("Boundary Smooth", 16)
smoothed, result_faces = evaluated(obj)
assert result_faces == faces
np.testing.assert_array_equal(smoothed[~movable], original[~movable])
assert np.max(np.linalg.norm(smoothed - original, axis=1)) > 0.005
nearby = [i for i in band if i not in boundary and not outer[i]]
assert np.max(np.linalg.norm(smoothed[nearby] - original[nearby], axis=1)) > 1e-4
before, after = [], []
for i in cut:
adjacent = boundary[i]
if len(adjacent) != 2:
continue
before.append(np.linalg.norm(original[adjacent].mean(axis=0) - original[i]))
after.append(np.linalg.norm(smoothed[adjacent].mean(axis=0) - smoothed[i]))
assert sum(after) < 0.8 * sum(before)
np.testing.assert_array_equal(smoothed[:, 1], original[:, 1])
set_value("Boundary Smooth", 0)
np.testing.assert_array_equal(evaluated(obj)[0], original)
def test_smoothing_without_split_moves_outline_only_within_boundary_band():
obj, set_value = diagonal_surface()
set_value("Depth Split", 0)
original, faces = evaluated(obj)
set_value("Boundary Smooth", 16)
result, result_faces = evaluated(obj)
boundary = boundary_neighbors(faces)
band = edge_band(faces, boundary)
outside = [i for i in range(len(original)) if i not in band]
assert np.max(np.linalg.norm(result[list(boundary)] - original[list(boundary)], axis=1)) > 1e-4
np.testing.assert_array_equal(result[outside], original[outside])
assert result_faces == faces
set_value("Boundary Smooth", 0)
np.testing.assert_array_equal(evaluated(obj)[0], original)
@pytest.mark.parametrize("mode", [0, 1])
def test_smoothed_cut_shells_stay_closed_and_separate(mode):
from anyimage.common.object import modifier_input_identifier, set_modifier_input
obj, set_value = diagonal_surface(triangles=True)
obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set(
"value", np.full(len(obj.data.vertices), 0.3, dtype=np.float32)
)
set_value("Depth Scale", 1)
set_value("Boundary Smooth", 4)
original, faces = evaluated(obj)
count = component_count(faces)
set_value("Mode", mode)
modifier = obj.modifiers[0]
identifier = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE" if mode == 0 else "DISTANCE")
set_modifier_input(modifier, identifier, 0.05)
obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
result, shell_faces = evaluated(obj)
assert np.isfinite(result).all()
assert_closed(shell_faces)
assert component_count(shell_faces) == count
def test_panorama_smoothing_preserves_interior_and_uvs():
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
yy, xx = np.mgrid[:64, :128]
depth = np.where(xx > 35 + yy * 0.6, 8.0, 2.0)
obj, set_value = panorama(depth, subdivide=4)
set_value("Depth Split", 0.5)
original, faces, uv = panorama_mesh(obj)
rim = boundary_neighbors(faces)
band = edge_band(faces, rim)
interior = [i for i in range(len(original)) if i not in band]
set_value("Boundary Smooth", 4)
result, result_faces, result_uv = panorama_mesh(obj)
assert np.isfinite(result).all()
assert result_faces == faces
np.testing.assert_array_equal(result[interior], original[interior])
np.testing.assert_array_equal(result_uv, uv)
assert np.max(np.linalg.norm(result-original, axis=1)) > 1e-4
def test_plane_edge_smoothing_preserves_uvs_and_closes_thickness():
from tests.support.planes import create_surface, evaluated as plane_mesh
obj, set_value, _, inputs, image = create_surface("DEPTH")
assert inputs["Boundary Smooth"].default_value == 4
assert inputs["Boundary Smooth"].parent.name == "Options"
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
yy, xx = np.mgrid[:8, :16]
pixels[..., 2] = 1 + (xx > 5 + yy * 0.7) * 6
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Subdivide", 5)
set_value("Depth Scale", 1)
set_value("Depth Split", 0.5)
original, faces, uv, *_ = plane_mesh(obj)
set_value("Boundary Smooth", 4)
result, result_faces, result_uv, *_ = plane_mesh(obj)
assert result_faces == faces
np.testing.assert_array_equal(result_uv, uv)
assert np.max(np.linalg.norm(result-original, axis=1)) > 1e-4
set_value("Thickness", 0.03)
assert_closed(plane_mesh(obj)[1])
def test_validity_cut_smoothing_preserves_original_outline_and_interior():
from tests.support.planes import create_surface, evaluated as plane_mesh
obj, set_value, _, _, image = create_surface("DEPTH")
set_value("Subdivide", 5)
set_value("Depth Split", 0)
set_value("Depth Mask", False)
whole = plane_mesh(obj)[0]
low, high = whole.min(axis=0), whole.max(axis=0)
pixels = np.array(image.pixels[:], np.float32).reshape(8, 16, 4)
yy, xx = np.mgrid[:8, :16]
pixels[..., 3] = xx < 5 + yy * 0.7
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Depth Mask", True)
before, faces, uv, *_ = plane_mesh(obj)
boundary = boundary_neighbors(faces)
outer = np.any(np.isclose(before[:, [0, 2]], low[[0, 2]]) | np.isclose(before[:, [0, 2]], high[[0, 2]]), axis=1)
cut = {i for i in boundary if not outer[i]}
band = edge_band(faces, cut)
fixed = np.array([i not in band or outer[i] for i in range(len(before))])
set_value("Boundary Smooth", 5)
after, result_faces, result_uv, *_ = plane_mesh(obj)
assert result_faces == faces
np.testing.assert_array_equal(result_uv, uv)
np.testing.assert_array_equal(after[fixed], before[fixed])
assert np.max(np.linalg.norm(after-before, axis=1)) > 1e-4
before_bends, after_bends = [], []
for i in cut:
if len(boundary[i]) == 2:
before_bends.append(np.linalg.norm(before[boundary[i]].mean(axis=0)-before[i]))
after_bends.append(np.linalg.norm(after[boundary[i]].mean(axis=0)-after[i]))
assert sum(after_bends) < sum(before_bends) * 0.8
set_value("Thickness", 0.03)
assert_closed(plane_mesh(obj)[1])
set_value("Thickness", 0)
set_value("Depth Mask", False)
np.testing.assert_array_equal(plane_mesh(obj)[0], whole)
def test_panorama_validity_cut_smoothing_stays_in_two_ring_band():
from tests.tools.nodes.test_image_depth_panorama import panorama, evaluated as panorama_mesh
yy, xx = np.mgrid[:64, :128]
alpha = ((xx-64)**2 + (yy-32)**2 > 15**2).astype(np.float32)
obj, set_value = panorama(np.full((64, 128), 3), alpha, subdivide=4)
set_value("Depth Split", 0)
before, faces, uv = panorama_mesh(obj)
band = edge_band(faces, boundary_neighbors(faces))
outside = [i for i in range(len(before)) if i not in band]
set_value("Boundary Smooth", 5)
after, result_faces, result_uv = panorama_mesh(obj)
assert result_faces == faces
np.testing.assert_array_equal(result_uv, uv)
np.testing.assert_array_equal(after[outside], before[outside])
assert np.max(np.linalg.norm(after-before, axis=1)) > 1e-4
def test_boundary_points_follow_the_boundary_strip_blur():
"""A boundary spike relaxes along the outline instead of being pulled inward."""
points, faces = grid(4)
points[1] = (1.0, -0.5, 0.0)
result = pinned_result(points, faces)
# 边界条带只连接边界邻居:自身权重 1,两个边界邻居权重 0.5。
np.testing.assert_allclose(result[1], (1.0, -0.25, 0.0), atol=1e-6)
def test_sharp_features_lower_the_smoothing_weight():
"""A crease inside the normal blur range reduces the pinned displacement."""
points, faces = grid(3)
points[4] = (1.0, -0.5, 0.0)
flat = pinned_result(points, faces)
creased = list(points)
creased[0] = (0.0, 0.0, 1.0)
creased = pinned_result(creased, faces)
# 折痕不在尖点的邻居集合内,两次运行的平滑目标相同,只有锐度系数不同。
flat_step = abs(flat[4][1] + 0.5)
creased_step = abs(creased[4][1] + 0.5)
assert flat_step > 1e-3
assert creased_step < flat_step * 0.99
def test_junction_band_uses_the_neighbourhood_average():
"""Without the boundary pin a boundary spike follows every connected neighbour."""
points, faces = grid(4)
points[1] = (1.0, -0.5, 0.0)
result = pinned_result(points, faces, pin_boundary=False)
# 自身权重 1 与三个邻居的平均:(-0.5 + 0 + 0 + 1) / 4。
np.testing.assert_allclose(result[1], (1.0, 0.125, 0.0), atol=1e-6)
def test_junction_band_keeps_the_sharpness_weight():
"""The sharpness weight stays active without the boundary pin."""
points, faces = grid(3)
points[4] = (1.0, -0.5, 0.0)
flat = pinned_result(points, faces, pin_boundary=False)
creased = list(points)
creased[0] = (0.0, 0.0, 1.0)
creased = pinned_result(creased, faces, pin_boundary=False)
flat_step = abs(flat[4][1] + 0.5)
creased_step = abs(creased[4][1] + 0.5)
assert flat_step > 1e-3
assert creased_step < flat_step * 0.99
def test_movement_mask_keeps_the_symmetry_plane():
"""Restricting the movement leaves the masked axis untouched."""
points, faces = grid(4)
# 网格落在 XZ 对称平面上,Y 是只允许被松开的对称轴。
points = [(x, z, y) for x, y, z in points]
points[5] = (2.0, 0.5, 1.0)
original = np.array(points)
masked = pinned_result(points, faces, movement=(1, 0, 1))
np.testing.assert_array_equal(masked[:, 1], original[:, 1])
assert np.max(np.abs(masked[:, 0] - original[:, 0])) > 1e-4
full = pinned_result(points, faces)
assert np.max(np.abs(full[:, 1] - original[:, 1])) > 1e-4