Add new tests and functionality for image processing and geometry handling
- Introduced color_image.py for preparing standalone color images for tests. - Added hdr_image.py to create HDR textures for testing. - Implemented test_image_depth_panorama.py to evaluate panorama topology, UVs, and depth discontinuities. - Created test_shadeless.py to validate the opacity and HDR color endpoints of the Shadeless group. - Developed moge3_export.py for exporting dynamic MoGe-3 backbone and ONNX graphs. - Added material.py to inherit material slots onto generated geometry. - Built image_depth_panorama.py to construct a quad panorama with depth separation and sampling.
This commit is contained in:
@@ -197,7 +197,7 @@ def test_surface_interface_and_node_structure(surface):
|
||||
assert inputs["Split Threshold"].subtype == "FACTOR"
|
||||
assert inputs["Split Threshold"].parent == inputs["Smooth"].parent
|
||||
assert inputs["Split Threshold"].parent.name != "Options"
|
||||
assert inputs["Validity Threshold"].default_value == pytest.approx(0.9)
|
||||
assert inputs["Validity Threshold"].default_value == pytest.approx(0.1)
|
||||
assert inputs["Validity Threshold"].parent.name == "Options"
|
||||
assert inputs["Validity Threshold"].subtype == "FACTOR"
|
||||
assert inputs["Valid Only"].default_value is True
|
||||
@@ -234,7 +234,7 @@ def test_relief_interface_and_node_structure(relief):
|
||||
|
||||
@pytest.mark.parametrize("threshold", [0.0, 0.4, 0.9, 1.0])
|
||||
@pytest.mark.parametrize("pattern", ["valid", "invalid", "partial", "half", "stripe"])
|
||||
def test_valid_only_recomputes_per_point_and_preserves_boundary_faces(surface, pattern, threshold):
|
||||
def test_valid_only_samples_face_centers(surface, pattern, threshold):
|
||||
obj, set_value, _, inputs, _ = surface
|
||||
set_value("Validity Threshold", threshold)
|
||||
depth_image = obj.modifiers[0][inputs["Depth Image"].identifier]
|
||||
@@ -256,17 +256,15 @@ def test_valid_only_recomputes_per_point_and_preserves_boundary_faces(surface, p
|
||||
set_value("Subdivide", subdivisions)
|
||||
set_value("Valid Only", False)
|
||||
base = evaluated(obj)
|
||||
u = (base[0][:, 0] + 2) / 4
|
||||
u = np.array([(base[0][list(face), 0].mean() + 2) / 4 for face in base[1]])
|
||||
sampled = np.interp(u * 16 - 0.5, np.arange(16), alpha)
|
||||
expected = [face for face in base[1] if any(sampled[index] >= threshold for index in face)]
|
||||
expected = [face for face, alpha_value in zip(base[1], sampled) if alpha_value >= threshold]
|
||||
set_value("Valid Only", True)
|
||||
culled = evaluated(obj)
|
||||
assert len(culled[1]) == len(expected)
|
||||
expected_points = {tuple(base[0][index]) for face in expected for index in face}
|
||||
assert {tuple(point) for point in culled[0]} == expected_points
|
||||
assert np.isfinite(culled[2]).all()
|
||||
if pattern == "half" and subdivisions >= 2 and threshold == 1.0:
|
||||
assert 0 <= min(point[0] for point in culled[0]) < 0.125
|
||||
set_value("Valid Only", False)
|
||||
np.testing.assert_allclose(evaluated(obj)[0], base[0])
|
||||
|
||||
@@ -293,9 +291,7 @@ def test_validity_graph_matches_reference_domains_and_connections(surface):
|
||||
group = obj.modifiers[0].node_group
|
||||
delete = next(node for node in group.nodes if node.bl_idname == "GeometryNodeDeleteGeometry")
|
||||
assert (delete.domain, delete.mode) == ("FACE", "ALL")
|
||||
points = origin_socket(delete.inputs["Selection"].links[0].from_socket).node
|
||||
assert (points.bl_idname, points.data_type, points.domain) == ("GeometryNodeFieldOnDomain", "BOOLEAN", "POINT")
|
||||
compare = origin_socket(points.inputs["Value"].links[0].from_socket).node
|
||||
compare = origin_socket(delete.inputs["Selection"].links[0].from_socket).node
|
||||
assert (compare.bl_idname, compare.data_type, compare.operation) == ("FunctionNodeCompare", "FLOAT", "LESS_THAN")
|
||||
assert origin_socket(compare.inputs["B"].links[0].from_socket).name == "Validity Threshold"
|
||||
alpha = origin_socket(compare.inputs["A"].links[0].from_socket)
|
||||
|
||||
259
tests/tools/nodes/test_image_depth_panorama.py
Normal file
259
tests/tools/nodes/test_image_depth_panorama.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""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
|
||||
from tests.support.nodes import validate_node_structure
|
||||
|
||||
|
||||
def panorama(depth=3, alpha=1, subdivide=3, reference=3):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = build_image_depth_panorama_group()
|
||||
assert group.name == "O Image Depth Panorama"
|
||||
validate_node_structure(group)
|
||||
assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes)
|
||||
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), ("Reference Distance", reference)):
|
||||
set_value(name, value)
|
||||
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))
|
||||
assert not any(a.name.startswith(("o_depth_", "o_panorama_")) for a in mesh.attributes)
|
||||
return points, faces, uv
|
||||
finally:
|
||||
result.to_mesh_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("subdivide", [0, 2, 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("Valid Only", False)
|
||||
assert len(evaluated(obj)[1]) == 384
|
||||
set_value("Valid Only", True)
|
||||
set_value("Validity 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("Valid Only", 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_smoothing_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["Validity 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["Valid Only"].parent == inputs["Subdivide"].parent
|
||||
assert inputs["Valid Only"].default_value
|
||||
assert "Material" not in inputs
|
||||
obj.data.from_pydata([(0, 0, 0)], [], [])
|
||||
material = bpy.data.materials.new("Slot Material")
|
||||
obj.data.materials.append(material)
|
||||
set_value("Smooth", 0)
|
||||
original = evaluated(obj)
|
||||
set_value("Smooth", 2)
|
||||
smoothed = evaluated(obj)
|
||||
assert smoothed[1] == original[1]
|
||||
assert np.max(abs(smoothed[0] - original[0])) > 1e-4
|
||||
np.testing.assert_array_equal(smoothed[2], original[2])
|
||||
set_value("Smooth Weight", 0)
|
||||
np.testing.assert_allclose(evaluated(obj)[0], original[0], atol=1e-6)
|
||||
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, reference=3 * scale)
|
||||
counts = []
|
||||
for split in (0, 0.1, 0.5, 1):
|
||||
set_value("Split Threshold", 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("Split Threshold", 1)
|
||||
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("Split Threshold", 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("Split Threshold", 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("Split Threshold", 1)
|
||||
assert len(evaluated(obj)[1]) == len(faces) - strips
|
||||
set_value("Split Threshold", 0)
|
||||
assert evaluated(obj)[1] == original_faces
|
||||
@@ -18,6 +18,6 @@ def test_saved_asset_inventory():
|
||||
if bpy.app.version >= (5, 0, 0):
|
||||
assert node_group.library is not None
|
||||
if name in {"O Image Depth Layer", "O Shadeless"}:
|
||||
expected = ["Color", "Alpha", "Depth", "Bump Scale"] if name == "O Image Depth Layer" else ["Color"]
|
||||
expected = ["Color", "Alpha", "Depth", "Bump Scale"] if name == "O Image Depth Layer" else ["Color", "Alpha"]
|
||||
assert [item.name for item in node_group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT"] == expected
|
||||
|
||||
@@ -22,7 +22,7 @@ class NodeAssetsTest(unittest.TestCase):
|
||||
for group in target.node_groups
|
||||
if group.bl_idname == "GeometryNodeTree"
|
||||
]
|
||||
self.assertEqual(len(groups), 6)
|
||||
self.assertEqual(len(groups), 7)
|
||||
for group in groups:
|
||||
validate_node_structure(group)
|
||||
positions = {node.name: tuple(node.location) for node in group.nodes
|
||||
|
||||
52
tests/tools/nodes/test_shadeless.py
Normal file
52
tests/tools/nodes/test_shadeless.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Render the bundled Shadeless group's opacity and HDR color endpoints."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
from anyimage.common.node import load_node_group
|
||||
|
||||
|
||||
def test_shadeless_alpha_is_mixed_inside_the_group(tmp_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = load_node_group("O Shadeless")
|
||||
inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"}
|
||||
assert list(inputs) == ["Color", "Alpha"] and inputs["Alpha"].default_value == 1
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "CYCLES"
|
||||
scene.cycles.device = "CPU"
|
||||
scene.cycles.samples = 8
|
||||
scene.cycles.use_denoising = False
|
||||
scene.render.film_transparent = True
|
||||
scene.render.resolution_x, scene.render.resolution_y = 96, 32
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "OPEN_EXR"
|
||||
scene.render.image_settings.color_mode = "RGBA"
|
||||
scene.render.image_settings.color_depth = "32"
|
||||
scene.render.filepath = str(tmp_path / "shadeless.exr")
|
||||
bpy.ops.object.camera_add(location=(0, 0, 5))
|
||||
scene.camera = bpy.context.object
|
||||
scene.camera.data.type = "ORTHO"
|
||||
scene.camera.data.ortho_scale = 3
|
||||
for index, alpha in enumerate((0, 0.5, 1)):
|
||||
bpy.ops.mesh.primitive_plane_add(size=1, location=(index - 1, 0, 0))
|
||||
material = bpy.data.materials.new(f"Alpha {alpha}")
|
||||
material.use_nodes = True
|
||||
material.node_tree.nodes.clear()
|
||||
shader = material.node_tree.nodes.new("ShaderNodeGroup")
|
||||
shader.node_tree = group
|
||||
shader.inputs["Color"].default_value = (4, 2, 0.5, 1)
|
||||
shader.inputs["Alpha"].default_value = alpha
|
||||
output = material.node_tree.nodes.new("ShaderNodeOutputMaterial")
|
||||
material.node_tree.links.new(shader.outputs[0], output.inputs["Surface"])
|
||||
bpy.context.object.data.materials.append(material)
|
||||
bpy.ops.render.render(write_still=True)
|
||||
image = bpy.data.images.load(scene.render.filepath, check_existing=False)
|
||||
try:
|
||||
pixels = np.array(image.pixels[:]).reshape(32, 96, 4)
|
||||
for index, alpha in enumerate((0, 0.5, 1)):
|
||||
patch = pixels[8:24, index * 32 + 8:index * 32 + 24]
|
||||
np.testing.assert_allclose(patch[..., 3].mean(), alpha, atol=0.035)
|
||||
np.testing.assert_allclose(pixels[16, 80, :3], (4, 2, 0.5), atol=1e-4)
|
||||
finally:
|
||||
bpy.data.images.remove(image)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
Reference in New Issue
Block a user