feat: Implement depth surface strip cleanup and organize tool CLI

- Added a proposal for depth surface strip cleanup, detailing the rationale and changes to the depth plane and cutout nodes.
- Created specifications for the new cleanup requirements, including scenarios for strip removal and boundary edge detection.
- Developed tasks for implementing shared strip detection logic and integrating new interfaces for depth nodes.
- Established a new CLI structure for organizing node groups and model commands, replacing old entry points with a more intuitive command structure.
- Introduced a prototype for panorama surface generation, including requirements for distance reconstruction and geometry node surfaces.
- Added comprehensive tests for depth strip cleanup functionality, ensuring correct behavior for various mesh topologies and edge cases.
This commit is contained in:
2026-09-10 00:53:43 +08:00
parent 1622afa7a2
commit 27ca25224a
42 changed files with 1970 additions and 70 deletions

View File

@@ -0,0 +1,185 @@
"""Build or verify a standalone panorama Geometry Nodes demonstration offline."""
import argparse
from pathlib import Path
import sys
import bpy
import numpy as np
from mathutils import Vector
sys.path.insert(0, str(Path(__file__).resolve().parent))
from quad_surface import build_node_group
from validation import evaluated_points, socket_id, verify
def create_distance_image(name, path, output_path):
with np.load(path) as data:
distance, alpha = data["distance"], data["alpha"]
height, width = distance.shape
image = bpy.data.images.new(name, width=width, height=height, alpha=True, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
rgba = np.concatenate((np.repeat(distance[..., None], 3, -1), alpha[..., None]), -1)
image.pixels.foreach_set(rgba[::-1].astype(np.float32).ravel())
image.filepath_raw = str(output_path)
image.file_format = "OPEN_EXR"
image.save()
image.pack()
return image
def create_material(image):
material = bpy.data.materials.new("Panorama Color")
material.use_nodes = True
material.node_tree.nodes.clear()
nodes, links = material.node_tree.nodes, material.node_tree.links
uv = nodes.new("ShaderNodeUVMap")
uv.uv_map = "UVMap"
uv.location = (0, 0)
texture = nodes.new("ShaderNodeTexImage")
texture.image = image
texture.extension = "REPEAT"
texture.location = (200, 0)
emission = nodes.new("ShaderNodeEmission")
emission.location = (500, 0)
output = nodes.new("ShaderNodeOutputMaterial")
output.location = (700, 0)
links.new(uv.outputs[0], texture.inputs[0])
links.new(texture.outputs[0], emission.inputs[0])
links.new(emission.outputs[0], output.inputs[0])
return material
def build_scene(output):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = build_node_group()
color = bpy.data.images.load(str(output / "panorama.png"))
color.pack()
material = create_material(color)
objects = []
for name, filename, offset in (("Reference Room", "reference.npz", -4),
("MoGe2 Panorama", "prediction.npz", 4)):
if not (output / filename).exists():
continue
depth_path = output / ("reference_depth.exr" if filename == "reference.npz" else "depth.exr")
distance = create_distance_image(depth_path.name, output / filename, depth_path)
mesh = bpy.data.meshes.new("Panorama Host")
obj = bpy.data.objects.new(name, mesh)
bpy.context.collection.objects.link(obj)
obj.location.x = offset
obj.data.materials.append(material)
modifier = obj.modifiers.new("Panorama Surface", "NODES")
modifier.node_group = group
modifier[socket_id(group, "Distance")] = distance
modifier[socket_id(group, "Subdivide")] = 7
modifier[socket_id(group, "Depth Scale")] = 1.0
modifier[socket_id(group, "Validity Threshold")] = 0.9
modifier[socket_id(group, "Material")] = material
objects.append(obj)
for obj in objects:
obj.select_set(False)
objects[-1].select_set(True)
bpy.context.view_layer.objects.active = objects[-1]
scene = bpy.context.scene
scene.render.engine = "CYCLES"
scene.cycles.samples = 16
scene.render.resolution_x, scene.render.resolution_y = 1400, 750
scene.render.resolution_percentage = 100
scene.world = bpy.data.worlds.new("Trial World")
scene.world.color = (0.18, 0.18, 0.18)
scene.view_settings.view_transform = "Standard"
camera_data = bpy.data.cameras.new("Overview")
camera = bpy.data.objects.new("Overview", camera_data)
bpy.context.collection.objects.link(camera)
camera.location = (11, -17, 13)
camera.rotation_euler = (Vector((0, 0, 0)) - camera.location).to_track_quat("-Z", "Y").to_euler()
camera_data.type, camera_data.ortho_scale = "ORTHO", 20
if len(objects) == 1:
points, _ = evaluated_points(objects[0])
points += np.array(objects[0].location)
center = (points.min(0) + points.max(0)) / 2
extent = float(np.max(np.ptp(points, axis=0)))
camera.location = Vector(center) + Vector((0.6, -1, 1)) * extent
camera.rotation_euler = (Vector(center) - camera.location).to_track_quat("-Z", "Y").to_euler()
camera_data.ortho_scale = extent * 1.8
scene.camera = camera
capture_camera = None
if len(objects) == 1:
capture_data = bpy.data.cameras.new("Capture Center")
capture_data.lens = 22
capture_camera = bpy.data.objects.new("Capture Center", capture_data)
bpy.context.collection.objects.link(capture_camera)
capture_camera.location = objects[0].location
capture_camera.rotation_euler = Vector((-1, 0, 0.15)).to_track_quat("-Z", "Y").to_euler()
bpy.data.texts.new("Attribution").write(
(Path(__file__).parent / "ATTRIBUTION.md").read_text(encoding="utf-8"))
bpy.data.texts.new("试验说明").write(
"Subdivide 控制 Quad Sphere 细分,全有效面数为 6 × 4^Subdivide。\n"
"Depth Scale:0 为单位球面,1 为全景距离重建。\n"
"深度 EXR 的 Alpha 已包含 Mask × 源图 Alpha,低于 Validity Threshold 的区域会被剔除。\n"
"Quad Sphere 来自 O_Essentials,节点组和依赖已打包。\n"
)
for screen in bpy.data.screens:
for area in screen.areas:
if area.type == "VIEW_3D":
area.spaces.active.shading.type = "MATERIAL"
area.spaces.active.region_3d.view_distance = extent * 1.4 if len(objects) == 1 else 18
if len(objects) == 1:
area.spaces.active.region_3d.view_location = Vector(center)
area.spaces.active.region_3d.view_rotation = camera.rotation_euler.to_quaternion()
verify(output)
bpy.ops.wm.save_as_mainfile(filepath=str(output / "panorama_trial.blend"))
# The render uses a temporary inspection cut; the saved asset retains the full shell.
for obj in objects:
if len(objects) == 1:
continue
modifier = obj.modifiers.new("Inspection Cut", "NODES")
cut_group = bpy.data.node_groups.new(f"{obj.name} Inspection", "GeometryNodeTree")
cut_group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
cut_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = cut_group.nodes.new("NodeGroupInput")
position = cut_group.nodes.new("GeometryNodeInputPosition")
xyz = cut_group.nodes.new("ShaderNodeSeparateXYZ")
above = cut_group.nodes.new("ShaderNodeMath")
above.operation = "GREATER_THAN"
above.inputs[1].default_value = 0.4
front = cut_group.nodes.new("ShaderNodeMath")
front.operation = "LESS_THAN"
front.inputs[1].default_value = -0.7
either = cut_group.nodes.new("ShaderNodeMath")
either.operation = "MAXIMUM"
remove = cut_group.nodes.new("GeometryNodeDeleteGeometry")
remove.domain = "FACE"
result = cut_group.nodes.new("NodeGroupOutput")
for a, b in ((position.outputs[0], xyz.inputs[0]),
(xyz.outputs["Z"], above.inputs[0]), (xyz.outputs["Y"], front.inputs[0]),
(above.outputs[0], either.inputs[0]), (front.outputs[0], either.inputs[1]),
(source.outputs[0], remove.inputs["Geometry"]),
(either.outputs[0], remove.inputs["Selection"]),
(remove.outputs[0], result.inputs[0])):
cut_group.links.new(a, b)
modifier.node_group = cut_group
scene.render.filepath = str(output / "comparison.png")
bpy.ops.render.render(write_still=True)
if capture_camera is not None:
scene.camera = capture_camera
scene.render.filepath = str(output / "capture_view.png")
bpy.ops.render.render(write_still=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--verify", action="store_true")
args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
output = args.output.resolve()
if args.verify:
bpy.ops.wm.open_mainfile(filepath=str(output / "panorama_trial.blend"))
verify(output)
else:
build_scene(output)
if __name__ == "__main__":
main()