- 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.
412 lines
16 KiB
Python
412 lines
16 KiB
Python
"""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
|