- Removed `Smooth Weight` input from `O Image Depth Plane`, `O Image Depth Cutout`, and `O Image Depth Panorama` to streamline the interface and maintain consistent boundary smoothing behavior. - Adjusted the `Reference Depth` input to appear last in the options panel for better organization, reflecting its role as a calibration value. - Consolidated the `expand_smooth()` function to only accept necessary parameters, eliminating unused branches related to `Smooth Weight` and `pin_sharp`. - Updated related tests and node assets to ensure functionality remains intact and consistent with the new structure. - Introduced new specifications to document the changes and ensure compliance with the updated requirements.
249 lines
10 KiB
Python
249 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["Boundary Smooth"].parent.name == "Options"
|
|
assert "Smooth Weight" not in inputs
|
|
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
|