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:
2026-09-10 14:42:17 +08:00
parent 27ca25224a
commit 489003c2cc
136 changed files with 5758 additions and 1083 deletions

View File

@@ -30,6 +30,8 @@
"GeometryNodeFlipFaces": 0,
"GeometryNodeGroup": 1,
"GeometryNodeImageTexture": 2,
"GeometryNodeImageInfo": 0,
"GeometryNodeSetMaterial": 0,
"GeometryNodeInputIndex": 0,
"GeometryNodeInputMeshEdgeNeighbors": 0,
"GeometryNodeInputMeshEdgeVertices": 0,

View File

@@ -13,6 +13,7 @@ from .groups.image_depth_balloon import build_image_depth_balloon_group
from .groups.image_layer import build_image_layer_group
from .groups.image_depth_layer import build_image_depth_layer_group
from .groups.shadeless import build_shadeless_group
from .groups.image_depth_panorama import build_image_depth_panorama_group
OUTPUT_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyImage.blend"
@@ -22,6 +23,7 @@ def build_node_groups():
"""Build each asset once in dependency order."""
image_plane = build_image_plane_group()
return [
build_image_depth_panorama_group(),
image_plane,
build_image_depth_plane_group(image_plane),
build_image_relief_plane_group(image_plane),

View File

@@ -7,6 +7,7 @@ import bpy
ASSET_PATH = Path(__file__).resolve().parents[2] / "src/anyimage/assets/O_AnyImage.blend"
GEOMETRY_NAMES = {
"O Image Depth Panorama",
"O Image Plane", "O Image Depth Plane", "O Image Relief Plane",
"O Image Cutout", "O Image Depth Cutout", "O Image Depth Balloon",
}
@@ -17,7 +18,7 @@ def check_asset_library(asset_path):
"""Check library completeness and finite, nonempty evaluated geometry."""
with bpy.data.libraries.load(str(asset_path), link=True) as (source, target):
assert set(source.node_groups) == GEOMETRY_NAMES | SHADER_NAMES
target.node_groups = list(source.node_groups)
target.node_groups = sorted(GEOMETRY_NAMES | SHADER_NAMES)
depth_image = bpy.data.images.new("Asset Check Depth", width=8, height=8, float_buffer=True)
depth_image.colorspace_settings.name = "Non-Color"
depth_image.pixels.foreach_set([

View File

@@ -168,8 +168,8 @@ def _remove_strip_faces(group, geometry, *, triangles=False):
return delete.outputs["Geometry"]
def split_depth_surface(group, geometry, image, strength, uniform, *, triangles=False):
"""Keep each face's camera sample on its own corners before splitting."""
def sample_face_camera(group, image):
"""Sample camera XYZ at each planar face's UV center."""
nodes, links = group.nodes, group.links
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
@@ -183,9 +183,17 @@ def split_depth_surface(group, geometry, image, strength, uniform, *, triangles=
texture.extension = "EXTEND"
links.new(image, texture.inputs["Image"])
links.new(face_uv.outputs["Value"], texture.inputs["Vector"])
position = nodes.new("GeometryNodeInputPosition")
faces = _store(group, geometry, "face_center", position.outputs[0])
faces = _store(group, faces, "face_camera", texture.outputs["Color"])
return texture.outputs["Color"]
def split_depth_surface(group, geometry, face_camera, strength, uniform, *,
face_center=None, triangles=False):
"""Split adjacent face samples using their Z depth and base center spacing."""
nodes, links = group.nodes, group.links
if face_center is None:
face_center = nodes.new("GeometryNodeInputPosition").outputs[0]
faces = _store(group, geometry, "face_center", face_center)
faces = _store(group, faces, "face_camera", face_camera)
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(_read(group, "face_camera"), axes.inputs[0])
center_field = _read(group, "face_center")

View File

@@ -0,0 +1,22 @@
"""Carry the input mesh's material slots onto generated geometry."""
def inherit_material_slots(group, geometry, source):
nodes, links = group.nodes, group.links
capture = nodes.new("GeometryNodeCaptureAttribute")
capture.domain = "POINT"
capture.capture_items.clear()
capture.capture_items.new("BOOLEAN", "Source")
capture.inputs["Source"].default_value = True
links.new(source, capture.inputs["Geometry"])
join = nodes.new("GeometryNodeJoinGeometry")
links.new(geometry, join.inputs["Geometry"])
links.new(capture.outputs["Geometry"], join.inputs["Geometry"])
material = nodes.new("GeometryNodeSetMaterialIndex")
material.inputs["Material Index"].default_value = 0
links.new(join.outputs["Geometry"], material.inputs["Geometry"])
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain = "POINT"
links.new(material.outputs["Geometry"], delete.inputs["Geometry"])
links.new(capture.outputs["Source"], delete.inputs["Selection"])
return delete.outputs["Geometry"]

View File

@@ -5,7 +5,7 @@ from anyimage.operators.cutout_tool.shape import CUTOUT_Z_TO_X, NORMAL_SCALE_ATT
from ..common.nodes import interface_socket, group_input, math_node, float_input, menu_switch, read_float_attribute, store_float_attribute, prepare_node_group
from ..common.smoothing import expand_smooth
from ..common.cutout import _island_cleanup, _position_field, _remove_zero_thickness, _shade_output
from ..common.depth_surface import project_depth_surface, store_surface_camera, remove_surface_attributes, split_depth_surface, thicken_depth_surface
from ..common.depth_surface import project_depth_surface, store_surface_camera, remove_surface_attributes, split_depth_surface, sample_face_camera, thicken_depth_surface
from ..common.smoothing import edge_boundary_field
@@ -436,7 +436,7 @@ def _build_depth_surface(
nodes = group.nodes
links = group.links
geometry = split_depth_surface(
group, geometry, depth_image, split, uniform_scale, triangles=True
group, geometry, sample_face_camera(group, depth_image), split, uniform_scale, triangles=True
)
original_camera = _position_field(group, depth_image)
geometry, camera_position = store_surface_camera(

View File

@@ -0,0 +1,195 @@
"""Build a quad panorama with periodic sampling and radial depth separation."""
import math
import bpy
from ..common.depth_surface import (
_math, remove_surface_attributes, split_depth_surface, store_surface_camera,
)
from ..common.nodes import float_input, group_input, interface_socket, prepare_node_group
from ..common.material import inherit_material_slots
from ..common.smoothing import expand_smooth
def _quad_sphere(group, segments):
nodes, links = group.nodes, group.links
cube = nodes.new("GeometryNodeMeshCube")
cube.inputs["Size"].default_value = (2, 2, 2)
for axis in "XYZ":
links.new(segments, cube.inputs[f"Vertices {axis}"])
xyz = nodes.new("ShaderNodeSeparateXYZ")
links.new(nodes.new("GeometryNodeInputPosition").outputs[0], xyz.inputs[0])
squares = {axis: _math(group, "MULTIPLY", xyz.outputs[axis], xyz.outputs[axis]) for axis in "XYZ"}
combine = nodes.new("ShaderNodeCombineXYZ")
for axis, a, b in (("X", "Y", "Z"), ("Y", "Z", "X"), ("Z", "X", "Y")):
half = _math(group, "DIVIDE", _math(group, "ADD", squares[a], squares[b]), 2)
third = _math(group, "DIVIDE", _math(group, "MULTIPLY", squares[a], squares[b]), 3)
root = _math(group, "SQRT", _math(group, "ADD", _math(group, "SUBTRACT", 1, half), third))
links.new(_math(group, "MULTIPLY", xyz.outputs[axis], root), combine.inputs[axis])
position = nodes.new("GeometryNodeSetPosition")
links.new(cube.outputs["Mesh"], position.inputs["Geometry"])
links.new(combine.outputs[0], position.inputs["Position"])
return position.outputs["Geometry"]
def build_image_depth_panorama_group():
group = bpy.data.node_groups.new("O Image Depth Panorama", "GeometryNodeTree")
group.is_modifier = group.use_fake_user = True
interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.default_value, subdivide.min_value, subdivide.max_value = 7, 0, 8
float_input(group, "Depth Scale", 1, maximum=1, subtype="FACTOR")
float_input(group, "Split Threshold", 0, maximum=1, subtype="FACTOR")
smooth = interface_socket(group, "Smooth", "INPUT", "NodeSocketInt")
smooth.default_value, smooth.min_value, smooth.max_value = 0, 0, 20
interface_socket(group, "Valid Only", "INPUT", "NodeSocketBool").default_value = True
options = group.interface.new_panel("Options", default_closed=True)
float_input(group, "Smooth Weight", 1, maximum=1, subtype="FACTOR", parent=options)
float_input(group, "Validity Threshold", 0.9, maximum=1, subtype="FACTOR", parent=options)
float_input(group, "Invalid Distance", 100, minimum=1e-5, subtype="DISTANCE", parent=options)
data = group.interface.new_panel("Data", default_closed=True)
interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True
float_input(group, "Reference Distance", 1, minimum=1e-6, subtype="DISTANCE", parent=data).hide_in_modifier = True
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes, links = group.nodes, group.links
def read(name):
node = nodes.new("GeometryNodeInputNamedAttribute")
node.data_type = "FLOAT_VECTOR"
node.inputs["Name"].default_value = name
return node.outputs["Attribute"]
def store(geometry, name, value, domain):
node = nodes.new("GeometryNodeStoreNamedAttribute")
node.data_type, node.domain = ("FLOAT2" if name == "UVMap" else "FLOAT_VECTOR"), domain
node.inputs["Name"].default_value = name
links.new(geometry, node.inputs["Geometry"])
links.new(value, node.inputs["Value"])
return node.outputs["Geometry"]
def uv(direction):
normalize = nodes.new("ShaderNodeVectorMath")
normalize.operation = "NORMALIZE"
links.new(direction, normalize.inputs[0])
xyz = nodes.new("ShaderNodeSeparateXYZ")
links.new(normalize.outputs[0], xyz.inputs[0])
longitude = _math(group, "DIVIDE", _math(group, "ARCTAN2", xyz.outputs["Y"], xyz.outputs["X"]), 2 * math.pi)
z = _math(group, "MAXIMUM", -1, _math(group, "MINIMUM", 1, xyz.outputs["Z"]))
latitude = _math(group, "SUBTRACT", 1, _math(group, "DIVIDE", _math(group, "ARCCOSINE", z), math.pi))
return longitude, latitude, xyz.outputs["Z"]
def combine(u, v):
node = nodes.new("ShaderNodeCombineXYZ")
links.new(u, node.inputs["X"])
links.new(v, node.inputs["Y"])
return node.outputs[0]
def texture(coordinates, interpolation):
inputs = group_input(nodes, {"Depth Image"})
info = nodes.new("GeometryNodeImageInfo")
links.new(inputs.outputs["Depth Image"], info.inputs["Image"])
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(coordinates, axes.inputs[0])
half_pixel = _math(group, "DIVIDE", 0.5, info.outputs["Height"])
v = _math(group, "MAXIMUM", half_pixel,
_math(group, "MINIMUM", axes.outputs["Y"], _math(group, "SUBTRACT", 1, half_pixel)))
node = nodes.new("GeometryNodeImageTexture")
node.interpolation, node.extension = interpolation, "REPEAT"
links.new(inputs.outputs["Depth Image"], node.inputs["Image"])
links.new(combine(axes.outputs["X"], v), node.inputs["Vector"])
return node.outputs["Color"]
def depth(coordinates):
linear, nearest = texture(coordinates, "Linear"), texture(coordinates, "Closest")
alphas = []
for color in (linear, nearest):
separate = nodes.new("FunctionNodeSeparateColor")
links.new(color, separate.inputs["Color"])
alphas.append(separate.outputs["Alpha"])
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "RGBA"
links.new(_math(group, "LESS_THAN", alphas[0],
_math(group, "SUBTRACT", alphas[1], 1e-6)), choose.inputs["Switch"])
links.new(linear, choose.inputs["False"])
links.new(nearest, choose.inputs["True"])
return choose.outputs[0], alphas[1]
inputs = group_input(nodes, {"Subdivide"})
sphere = _quad_sphere(group, _math(group, "ADD", _math(group, "POWER", 2, inputs.outputs["Subdivide"]), 1))
position = nodes.new("GeometryNodeInputPosition").outputs[0]
geometry = store(sphere, "o_panorama_direction", position, "POINT")
longitude, latitude, z = uv(read("o_panorama_direction"))
geometry = store(geometry, "o_panorama_uv", combine(longitude, latitude), "POINT")
face = nodes.new("GeometryNodeFieldOnDomain")
face.data_type, face.domain = "FLOAT_VECTOR", "FACE"
links.new(read("o_panorama_direction"), face.inputs["Value"])
face_u, face_v, _ = uv(face.outputs["Value"])
correction = _math(group, "ROUND", _math(group, "SUBTRACT", face_u, longitude))
choose = nodes.new("GeometryNodeSwitch")
choose.input_type = "FLOAT"
links.new(_math(group, "GREATER_THAN", _math(group, "ABSOLUTE", z), 0.999999), choose.inputs["Switch"])
links.new(_math(group, "ADD", longitude, correction), choose.inputs["False"])
links.new(face_u, choose.inputs["True"])
geometry = store(geometry, "UVMap", combine(choose.outputs[0], latitude), "CORNER")
pixel, _ = depth(read("o_panorama_uv"))
geometry = store(geometry, "o_panorama_distance", pixel, "POINT")
nearest = nodes.new("FunctionNodeSeparateColor")
links.new(texture(read("o_panorama_uv"), "Closest"), nearest.inputs["Color"])
controls = group_input(nodes, {"Validity Threshold"})
invalid = _math(group, "MAXIMUM",
_math(group, "LESS_THAN", nearest.outputs["Alpha"], controls.outputs["Validity Threshold"]),
_math(group, "LESS_THAN", nearest.outputs["Red"], 1e-5))
geometry = store(geometry, "o_panorama_invalid", invalid, "POINT")
face_invalid = read("o_panorama_invalid")
any_invalid = _math(group, "GREATER_THAN", face_invalid, 0)
mixed = _math(group, "MULTIPLY", any_invalid,
_math(group, "LESS_THAN", face_invalid, 1))
selection = nodes.new("GeometryNodeSwitch")
selection.input_type = "BOOLEAN"
links.new(group_input(nodes, {"Valid Only"}).outputs["Valid Only"], selection.inputs["Switch"])
links.new(mixed, selection.inputs["False"])
links.new(any_invalid, selection.inputs["True"])
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain, delete.mode = "FACE", "ALL"
links.new(geometry, delete.inputs["Geometry"])
links.new(selection.outputs[0], delete.inputs["Selection"])
controls = group_input(nodes, {"Split Threshold", "Reference Distance"})
strength = controls.outputs["Split Threshold"]
geometry = split_depth_surface(group, delete.outputs[0], depth(combine(face_u, face_v))[0], strength,
_math(group, "DIVIDE", 1, controls.outputs["Reference Distance"]),
face_center=face.outputs["Value"])
geometry, distance = store_surface_camera(group, geometry, read("o_panorama_distance"), strength)
axes = nodes.new("ShaderNodeSeparateXYZ")
links.new(distance, axes.inputs[0])
distance = nodes.new("GeometryNodeSwitch")
distance.input_type = "FLOAT"
links.new(read("o_panorama_invalid"), distance.inputs["Switch"])
links.new(axes.outputs["X"], distance.inputs["False"])
links.new(group_input(nodes, {"Invalid Distance"}).outputs["Invalid Distance"], distance.inputs["True"])
controls = group_input(nodes, {"Depth Scale"})
radius = _math(group, "ADD", 1, _math(group, "MULTIPLY", controls.outputs["Depth Scale"], _math(group, "SUBTRACT", distance.outputs[0], 1)))
radial = nodes.new("ShaderNodeVectorMath")
radial.operation = "SCALE"
links.new(read("o_panorama_direction"), radial.inputs[0])
links.new(radius, radial.inputs[3])
deform = nodes.new("GeometryNodeSetPosition")
links.new(geometry, deform.inputs["Geometry"])
links.new(radial.outputs[0], deform.inputs["Position"])
geometry = remove_surface_attributes(group, deform.outputs[0])
remove = nodes.new("GeometryNodeRemoveAttribute")
remove.pattern_mode = "WILDCARD"
remove.inputs["Name"].default_value = "o_panorama_*"
links.new(geometry, remove.inputs["Geometry"])
geometry = expand_smooth(group, remove.outputs[0], iterations_name="Smooth", weight_name="Smooth Weight")
flip = nodes.new("GeometryNodeFlipFaces")
links.new(geometry, flip.inputs["Mesh"])
smooth = nodes.new("GeometryNodeSetShadeSmooth")
smooth.domain = "FACE"
smooth.inputs["Shade Smooth"].default_value = True
links.new(flip.outputs["Mesh"], smooth.inputs["Geometry"])
geometry = inherit_material_slots(group, smooth.outputs[0], group_input(nodes, {"Geometry"}).outputs["Geometry"])
output = nodes.new("NodeGroupOutput")
links.new(geometry, output.inputs["Geometry"])
prepare_node_group(group)
return group

View File

@@ -7,7 +7,7 @@ from ..common.nodes import prepare_node_group, float_input
from ..common.smoothing import expand_smooth
from ..common.depth_surface import (
project_depth_surface, thicken_depth_surface, remove_surface_attributes,
split_depth_surface, store_surface_camera,
split_depth_surface, sample_face_camera, store_surface_camera,
)
DEPTH_PLANE_NODE_GROUP_NAME = "O Image Depth Plane"
@@ -63,7 +63,7 @@ def build_image_depth_plane_group(image_plane_group):
smooth_weight.min_value, smooth_weight.max_value = 0.0, 1.0
smooth_weight.subtype = "FACTOR"
validity_threshold = _interface_socket(group, "Validity Threshold", "INPUT", "NodeSocketFloat", parent=options)
validity_threshold.default_value = 0.9
validity_threshold.default_value = 0.1
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
validity_threshold.subtype = "FACTOR"
data = group.interface.new_panel(name="Data", default_closed=True)
@@ -111,13 +111,10 @@ def build_image_depth_plane_group(image_plane_group):
invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN"
links.new(threshold_input.outputs["Validity Threshold"], invalid.inputs["B"])
links.new(channels.outputs["Alpha"], invalid.inputs["A"])
points = nodes.new("GeometryNodeFieldOnDomain")
points.data_type, points.domain = "BOOLEAN", "POINT"
links.new(invalid.outputs["Result"], points.inputs["Value"])
delete = nodes.new("GeometryNodeDeleteGeometry")
delete.domain, delete.mode = "FACE", "ALL"
links.new(plane.outputs["Geometry"], delete.inputs["Geometry"])
links.new(points.outputs["Value"], delete.inputs["Selection"])
links.new(invalid.outputs["Result"], delete.inputs["Selection"])
cull_controls = _group_input(nodes, {"Valid Only"})
cull = nodes.new("GeometryNodeSwitch")
cull.input_type = "GEOMETRY"
@@ -126,7 +123,7 @@ def build_image_depth_plane_group(image_plane_group):
links.new(plane.outputs["Geometry"], cull.inputs["False"])
split_input = _group_input(nodes, {"Split Threshold"})
geometry = split_depth_surface(
group, cull.outputs["Output"], texture_input.outputs["Depth Image"],
group, cull.outputs["Output"], sample_face_camera(group, texture_input.outputs["Depth Image"]),
split_input.outputs["Split Threshold"], scale_input.outputs["Uniform Scale"],
)
geometry, camera = store_surface_camera(

View File

@@ -3,6 +3,7 @@
import bpy
from ..common.nodes import prepare_node_group
from ..common.material import inherit_material_slots
IMAGE_PLANE_GROUP_NAME = "O Image Plane"
@@ -218,23 +219,8 @@ def build_image_plane_group():
links.new(place_grid.outputs["Geometry"], geometry_switch.inputs["False"])
links.new(place_cube.outputs["Geometry"], geometry_switch.inputs["True"])
capture_source = nodes.new("GeometryNodeCaptureAttribute")
capture_source.domain = "POINT"
capture_source.capture_items.clear()
capture_source.capture_items.new("BOOLEAN", "Source")
capture_source.inputs["Source"].default_value = True
links.new(geometry_input.outputs["Geometry"], capture_source.inputs["Geometry"])
join_material = nodes.new("GeometryNodeJoinGeometry")
links.new(geometry_switch.outputs["Output"], join_material.inputs["Geometry"])
links.new(capture_source.outputs["Geometry"], join_material.inputs["Geometry"])
set_material_index = nodes.new("GeometryNodeSetMaterialIndex")
set_material_index.inputs["Material Index"].default_value = 0
links.new(join_material.outputs["Geometry"], set_material_index.inputs["Geometry"])
delete_source = nodes.new("GeometryNodeDeleteGeometry")
delete_source.domain = "POINT"
links.new(set_material_index.outputs["Geometry"], delete_source.inputs["Geometry"])
links.new(capture_source.outputs["Source"], delete_source.inputs["Selection"])
links.new(delete_source.outputs["Geometry"], output.inputs["Geometry"])
geometry = inherit_material_slots(group, geometry_switch.outputs["Output"], geometry_input.outputs["Geometry"])
links.new(geometry, output.inputs["Geometry"])
group.color_tag = "GEOMETRY"
group.description = (

View File

@@ -14,6 +14,9 @@ def build_shadeless_group():
group.interface.new_socket(
name="Color", in_out="INPUT", socket_type="NodeSocketColor"
)
alpha = group.interface.new_socket(name="Alpha", in_out="INPUT", socket_type="NodeSocketFloat")
alpha.default_value, alpha.min_value, alpha.max_value = 1.0, 0.0, 1.0
alpha.subtype = "FACTOR"
nodes = group.nodes
links = group.links
group_input = nodes.new("NodeGroupInput")
@@ -67,7 +70,15 @@ def build_shadeless_group():
links.new(shader_factor.outputs[0], mix.inputs["Fac"])
links.new(diffuse.outputs["BSDF"], mix.inputs[1])
links.new(emission.outputs["Emission"], mix.inputs[2])
links.new(mix.outputs["Shader"], group_output.inputs["Shader"])
transparent = nodes.new("ShaderNodeBsdfTransparent")
transparent.location = (550.0, -200.0)
alpha_mix = nodes.new("ShaderNodeMixShader")
alpha_mix.location = (850.0, 100.0)
group_output.location = (1100.0, 100.0)
links.new(group_input.outputs["Alpha"], alpha_mix.inputs["Fac"])
links.new(transparent.outputs["BSDF"], alpha_mix.inputs[1])
links.new(mix.outputs["Shader"], alpha_mix.inputs[2])
links.new(alpha_mix.outputs["Shader"], group_output.inputs["Shader"])
group.color_tag = "SHADER"
group.description = (
"Display color without scene lighting while preserving shadows "