- Introduced a new `Boundary Padding` preference in the Cutout Tool with a default of 2 px and a configurable range of 1-4 px. - Updated the Cutout client to establish inward mapping from the final Mask, sampling in a fixed direction and processing independent material RGBA and Depth. - Enhanced the handling of RGBA and Depth during cutout operations, ensuring proper mapping and preservation of source data. - Added tests to validate the new boundary padding functionality and its integration with existing cutout processes. feat(geometry): remove overall mesh smoothing from geometry nodes - Removed the `Smooth` input from several geometry node groups to eliminate unnecessary overall mesh smoothing. - Retained `Smooth Weight` for depth nodes while removing it from Cutout and Relief Plane nodes. - Ensured that boundary, normal, and fill smoothing behaviors remain intact and consistent. - Updated tests and documentation to reflect the changes in node interfaces and behaviors. feat(normals): scale tangent normals with depth - Reused the existing `o_normal_reduction` attribute to adjust Tangent Normal strength based on the geometry's `Depth Scale`. - Ensured that Tangent Normal strength is consistent with the geometry depth, preserving full strength for materials that do not provide the attribute. - Updated tests to verify the correct behavior of normal strength adjustments in both Tangent and Object Space.
248 lines
10 KiB
Python
248 lines
10 KiB
Python
"""Evaluate panorama topology, validity, UVs and depth discontinuities."""
|
|
|
|
from collections import Counter
|
|
|
|
import bpy
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from anyimage.common.object import modifier_input_identifier, set_modifier_input
|
|
from tools.nodes.groups.image_depth_panorama import build_image_depth_panorama_group
|
|
|
|
|
|
def panorama(depth=3, alpha=1, subdivide=3):
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
group = build_image_depth_panorama_group()
|
|
image = bpy.data.images.new("Radial Depth", width=128, height=64, float_buffer=True)
|
|
image.colorspace_settings.name = "Non-Color"
|
|
image.alpha_mode = "CHANNEL_PACKED"
|
|
rgba = np.ones((64, 128, 4), np.float32)
|
|
rgba[..., :3] = np.broadcast_to(depth, (64, 128))[..., None]
|
|
rgba[..., 3] = alpha
|
|
image.pixels.foreach_set(rgba.ravel())
|
|
obj = bpy.data.objects.new("Panorama", bpy.data.meshes.new("Panorama"))
|
|
bpy.context.collection.objects.link(obj)
|
|
modifier = obj.modifiers.new("Panorama", "NODES")
|
|
modifier.node_group = group
|
|
|
|
def set_value(name, value):
|
|
set_modifier_input(modifier, modifier_input_identifier(group, name), value)
|
|
obj.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
|
|
for name, value in (("Depth Image", image), ("Subdivide", subdivide)):
|
|
set_value(name, value)
|
|
set_value("Boundary Smooth", 0)
|
|
set_value("Depth Split", 0)
|
|
return obj, set_value
|
|
|
|
|
|
def evaluated(obj):
|
|
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
mesh = result.to_mesh()
|
|
try:
|
|
points = np.array([v.co[:] for v in mesh.vertices]).reshape(-1, 3)
|
|
faces = [tuple(f.vertices) for f in mesh.polygons]
|
|
uv = np.array([v.uv[:] for v in mesh.uv_layers.active.data]) if mesh.uv_layers.active else np.empty((0, 2))
|
|
return points, faces, uv
|
|
finally:
|
|
result.to_mesh_clear()
|
|
|
|
|
|
@pytest.mark.parametrize("subdivide", [0, 4])
|
|
def test_quad_sphere_topology_and_radial_endpoints(subdivide):
|
|
obj, set_value = panorama(subdivide=subdivide)
|
|
inputs = [s for s in obj.modifiers[0].node_group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"]
|
|
assert [s.name for s in inputs if s.socket_type == "NodeSocketImage"] == ["Depth Image"]
|
|
for scale, radius in ((0, 1), (0.5, 2), (1, 3)):
|
|
set_value("Depth Scale", scale)
|
|
points, faces, uv = evaluated(obj)
|
|
assert len(faces) == 6 * 4 ** subdivide
|
|
assert all(len(face) == 4 for face in faces)
|
|
np.testing.assert_allclose(np.linalg.norm(points, axis=1), radius, atol=1e-5)
|
|
edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0])))
|
|
assert set(edges.values()) == {2}
|
|
assert len(points) - len(edges) + len(faces) == 2
|
|
assert np.isfinite(uv).all()
|
|
if subdivide > 0:
|
|
assert np.max(np.ptp(uv.reshape(-1, 4, 2)[..., 0], axis=1)) <= 0.5 + 1e-5
|
|
|
|
|
|
def test_depth_alpha_culls_faces_without_double_multiplication():
|
|
obj, set_value = panorama(alpha=0.5)
|
|
assert not len(evaluated(obj)[0])
|
|
set_value("Depth Mask", False)
|
|
assert len(evaluated(obj)[1]) == 384
|
|
set_value("Depth Mask", True)
|
|
set_value("Mask Threshold", 0.49)
|
|
assert len(evaluated(obj)[1]) == 384
|
|
|
|
|
|
def test_invalid_depth_does_not_bleed_into_alpha_boundary():
|
|
alpha = np.ones((64, 128))
|
|
alpha[:, 64:] = 0
|
|
depth = np.where(alpha, 3, 10000)
|
|
obj, _ = panorama(depth, alpha, subdivide=5)
|
|
points, _, _ = evaluated(obj)
|
|
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 3, atol=1e-5)
|
|
|
|
|
|
def test_linear_depth_reconstructs_flat_floor_without_sampling_rings():
|
|
z = -np.cos((np.arange(64) + 0.5) * np.pi / 64)
|
|
depth = np.broadcast_to((2 / np.maximum(-z, 0.1))[:, None], (64, 128))
|
|
obj, _ = panorama(depth, subdivide=6)
|
|
points, _, _ = evaluated(obj)
|
|
rays = points / np.linalg.norm(points, axis=1, keepdims=True)
|
|
floor = rays[:, 2] < -0.5
|
|
error = points[floor, 2] + 2
|
|
assert np.sqrt(np.mean(error ** 2)) < 0.002
|
|
assert np.max(abs(error)) < 0.004
|
|
|
|
|
|
@pytest.mark.parametrize("sky_alpha", [0, 1])
|
|
def test_invalid_distance_removes_faces_between_valid_and_invalid_regions(sky_alpha):
|
|
depth = np.full((64, 128), 3.0)
|
|
depth[:, 64:] = 1e-7
|
|
alpha = np.ones((64, 128))
|
|
alpha[:, 64:] = sky_alpha
|
|
obj, set_value = panorama(depth, alpha, subdivide=4)
|
|
points, original_faces, _ = evaluated(obj)
|
|
assert np.linalg.norm(points, axis=1).min() > 1
|
|
assert np.linalg.norm(points, axis=1).max() < 3.01
|
|
set_value("Invalid Distance", 250)
|
|
assert evaluated(obj)[1] == original_faces
|
|
set_value("Depth Mask", False)
|
|
for background in (100, 250):
|
|
set_value("Invalid Distance", background)
|
|
points, faces, _ = evaluated(obj)
|
|
radii = np.linalg.norm(points, axis=1)
|
|
assert np.any(np.isclose(radii, background))
|
|
assert np.any(np.isclose(radii, 3))
|
|
assert len(original_faces) < len(faces) < 6 * 4 ** 4
|
|
assert all(np.all(radii[list(face)] > 10) or np.all(radii[list(face)] < 10) for face in faces)
|
|
set_value("Depth Scale", 0)
|
|
np.testing.assert_allclose(np.linalg.norm(evaluated(obj)[0], axis=1), 1, atol=1e-5)
|
|
|
|
|
|
def test_controls_and_material_slot_inheritance():
|
|
obj, set_value = panorama()
|
|
group = obj.modifiers[0].node_group
|
|
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
|
|
assert inputs["Mask Threshold"].parent.name == "Options"
|
|
assert inputs["Invalid Distance"].parent.name == "Options"
|
|
assert inputs["Invalid Distance"].default_value == 100
|
|
assert inputs["Invalid Distance"].subtype == "DISTANCE"
|
|
assert inputs["Smooth Weight"].parent.name == "Options"
|
|
assert inputs["Depth Mask"].parent == inputs["Subdivide"].parent
|
|
assert inputs["Depth Mask"].default_value
|
|
obj.data.from_pydata([(0, 0, 0)], [], [])
|
|
material = bpy.data.materials.new("Slot Material")
|
|
obj.data.materials.append(material)
|
|
for name in ("First Slot", "Replacement Slot"):
|
|
current = bpy.data.materials.new(name)
|
|
obj.data.materials[0] = current
|
|
obj.update_tag(refresh={"DATA"})
|
|
bpy.context.view_layer.update()
|
|
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
mesh = result.to_mesh()
|
|
try:
|
|
assert mesh.materials[0].original == current
|
|
assert all(f.material_index == 0 and f.use_smooth for f in mesh.polygons)
|
|
finally:
|
|
result.to_mesh_clear()
|
|
|
|
|
|
def test_material_uv_matches_radial_sampling_and_faces_point_inward():
|
|
obj, _ = panorama(subdivide=4)
|
|
result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
|
mesh = result.to_mesh()
|
|
try:
|
|
assert all(np.dot(face.normal, face.center) < 0 for face in mesh.polygons)
|
|
assert any(loop.uv.x < 0 for loop in mesh.uv_layers.active.data)
|
|
for loop in mesh.loops:
|
|
ray = np.array(mesh.vertices[loop.vertex_index].co)
|
|
ray /= np.linalg.norm(ray)
|
|
u, v = mesh.uv_layers.active.data[loop.index].uv
|
|
if abs(ray[2]) < 0.999999:
|
|
expected_u = np.arctan2(ray[1], ray[0]) / (2 * np.pi)
|
|
assert abs((u - expected_u + 0.5) % 1 - 0.5) < 1e-5
|
|
assert abs(v - (1 - np.arccos(np.clip(ray[2], -1, 1)) / np.pi)) < 1e-5
|
|
finally:
|
|
result.to_mesh_clear()
|
|
|
|
|
|
def test_split_strength_and_distance_scale_invariance():
|
|
depth = np.ones((64, 128))
|
|
depth[:, 64:] = 7
|
|
signatures = []
|
|
for scale in (0.01, 1, 100):
|
|
obj, set_value = panorama(depth * scale)
|
|
counts = []
|
|
for split in (0, 0.1, 0.5, 1):
|
|
set_value("Depth Split", split)
|
|
points, faces, uv = evaluated(obj)
|
|
assert np.isfinite(points).all() and np.isfinite(uv).all()
|
|
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
|
|
counts.append(sum(n == 1 for n in edges.values()))
|
|
assert counts[0] == 0 and counts[-1] > 0
|
|
assert counts == sorted(counts)
|
|
signatures.append(counts)
|
|
assert signatures[0] == signatures[1] == signatures[2]
|
|
|
|
|
|
def test_continuous_spherical_slope_does_not_split_at_seam_or_poles():
|
|
u, v = np.meshgrid((np.arange(128) + 0.5) / 128, (np.arange(64) + 0.5) / 64)
|
|
depth = 3 + 0.3 * np.cos(2 * np.pi * u) * np.sin(np.pi * v)
|
|
obj, set_value = panorama(depth)
|
|
original = evaluated(obj)
|
|
set_value("Depth Split", 1)
|
|
set_value("Boundary Smooth", 12)
|
|
points, faces, uv = evaluated(obj)
|
|
np.testing.assert_allclose(points, original[0], atol=1e-6)
|
|
assert faces == original[1]
|
|
np.testing.assert_allclose(uv, original[2])
|
|
|
|
|
|
def test_split_corners_keep_their_rays_and_own_face_distance():
|
|
from scipy.spatial import cKDTree
|
|
|
|
depth = np.ones((64, 128))
|
|
depth[:, 64:] = 7
|
|
obj, set_value = panorama(depth)
|
|
original, _, _ = evaluated(obj)
|
|
original_radii = np.linalg.norm(original, axis=1)
|
|
original_rays = original / original_radii[:, None]
|
|
set_value("Depth Split", 1)
|
|
points, _, uv = evaluated(obj)
|
|
radii = np.linalg.norm(points, axis=1)
|
|
distance, indices = cKDTree(original_rays).query(points / radii[:, None])
|
|
assert distance.max() < 1e-5
|
|
counts = Counter(indices)
|
|
uncut = np.array([counts[index] == 1 for index in indices])
|
|
np.testing.assert_allclose(radii[uncut], original_radii[indices[uncut]], atol=1e-5)
|
|
assert any(np.ptp(radii[indices == index]) > 4 for index, count in counts.items() if count > 1)
|
|
assert np.max(np.ptp(uv.reshape(-1, 4, 2)[..., 0], axis=1)) <= 0.5 + 1e-5
|
|
|
|
|
|
def test_zero_split_bypasses_single_snapshot_strip_cleanup(monkeypatch):
|
|
from tools.nodes.common import depth_surface
|
|
|
|
depth = np.broadcast_to(1 + 6 * (np.arange(128) % 16 < 8), (64, 128))
|
|
with monkeypatch.context() as patch:
|
|
patch.setattr(depth_surface, "_remove_strip_faces", lambda group, geometry, **kw: geometry)
|
|
obj, set_value = panorama(depth)
|
|
set_value("Depth Split", 1)
|
|
_, faces, _ = evaluated(obj)
|
|
edges = Counter(tuple(sorted((a, b))) for f in faces for a, b in zip(f, (*f[1:], f[0])))
|
|
strips = 0
|
|
for face in faces:
|
|
boundaries = [edges[tuple(sorted((a, b)))] == 1 for a, b in zip(face, (*face[1:], face[0]))]
|
|
strips += (boundaries[0] and boundaries[2]) or (boundaries[1] and boundaries[3])
|
|
assert strips > 0
|
|
obj, set_value = panorama(depth)
|
|
original_faces = evaluated(obj)[1]
|
|
set_value("Depth Split", 1)
|
|
assert len(evaluated(obj)[1]) == len(faces) - strips
|
|
set_value("Depth Split", 0)
|
|
assert evaluated(obj)[1] == original_faces
|