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:
@@ -18,7 +18,9 @@
|
||||
"GeometryNodeBoundBox": 0,
|
||||
"GeometryNodeCaptureAttribute": 3,
|
||||
"GeometryNodeCornersOfEdge": 0,
|
||||
"GeometryNodeCornersOfFace": 0,
|
||||
"GeometryNodeDeleteGeometry": 2,
|
||||
"GeometryNodeEdgesOfCorner": 0,
|
||||
"GeometryNodeExtrudeMesh": 1,
|
||||
"GeometryNodeFaceOfCorner": 0,
|
||||
"GeometryNodeFieldAtIndex": 2,
|
||||
@@ -32,6 +34,7 @@
|
||||
"GeometryNodeInputMeshEdgeNeighbors": 0,
|
||||
"GeometryNodeInputMeshEdgeVertices": 0,
|
||||
"GeometryNodeInputMeshFaceArea": 0,
|
||||
"GeometryNodeInputMeshFaceNeighbors": 0,
|
||||
"GeometryNodeInputMeshIsland": 0,
|
||||
"GeometryNodeInputMeshVertexNeighbors": 0,
|
||||
"GeometryNodeInputNamedAttribute": 1,
|
||||
@@ -42,6 +45,7 @@
|
||||
"GeometryNodeMergeByDistance": 1,
|
||||
"GeometryNodeMeshCube": 0,
|
||||
"GeometryNodeMeshGrid": 0,
|
||||
"GeometryNodeOffsetCornerInFace": 0,
|
||||
"GeometryNodeRemoveAttribute": 1,
|
||||
"GeometryNodeRepeatInput": 0,
|
||||
"GeometryNodeRepeatOutput": 3,
|
||||
@@ -57,6 +61,7 @@
|
||||
"GeometryNodeStoreNamedAttribute": 2,
|
||||
"GeometryNodeSwitch": 1,
|
||||
"GeometryNodeTransform": 1,
|
||||
"GeometryNodeVertexOfCorner": 0,
|
||||
"NodeGroupInput": 0,
|
||||
"NodeGroupOutput": 0,
|
||||
"ShaderNodeClamp": 1,
|
||||
|
||||
@@ -3,16 +3,13 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from tempfile import TemporaryDirectory, mkdtemp
|
||||
from zipfile import ZipFile
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
BLENDER_MODULES = (
|
||||
"tools.nodes.build",
|
||||
"tools.nodes.check",
|
||||
)
|
||||
|
||||
|
||||
def project_env_value(name, path=None):
|
||||
@@ -57,22 +54,7 @@ def run(command):
|
||||
subprocess.run(command, cwd=PROJECT_ROOT, check=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build and validate O_AnyImage.blend.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--blender",
|
||||
help="Blender executable; overrides BLENDER_BIN, .env, and PATH.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-tests",
|
||||
action="store_true",
|
||||
help="Skip the Python test suite.",
|
||||
)
|
||||
options = parser.parse_args()
|
||||
executable = blender_path(options.blender)
|
||||
|
||||
def run_blender_modules(executable, modules, arguments=()):
|
||||
with TemporaryDirectory(prefix="anyimage-node-dependencies-") as directory:
|
||||
for wheel in (PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("scipy-*.whl"):
|
||||
with ZipFile(wheel) as archive:
|
||||
@@ -83,16 +65,52 @@ def main():
|
||||
f"import sys, runpy; sys.path[:0] = {paths!r}; "
|
||||
f"sys.path.insert(0, {directory!r} + '/cp' + str(sys.version_info.major) + str(sys.version_info.minor)); "
|
||||
)
|
||||
for module_name in BLENDER_MODULES:
|
||||
for module_name in modules:
|
||||
run(
|
||||
(
|
||||
executable,
|
||||
"--factory-startup",
|
||||
"--background",
|
||||
"--python-exit-code", "1",
|
||||
"--python-expr", bootstrap + f"runpy.run_module({module_name!r}, run_name='__main__')",
|
||||
"--python-expr", bootstrap + f"sys.argv = {[module_name, *arguments]!r}; "
|
||||
+ f"runpy.run_module({module_name!r}, run_name='__main__')",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build, check, or preview node groups.")
|
||||
commands = parser.add_subparsers(dest="command")
|
||||
for name, help_text in (
|
||||
("build", "Build assets, validate them, and run tests"),
|
||||
("check", "Validate saved assets and run tests"),
|
||||
("preview", "Preview node groups from current source"),
|
||||
):
|
||||
command = commands.add_parser(name, help=help_text)
|
||||
command.add_argument("--blender", help="Blender executable; overrides BLENDER_BIN, .env, and PATH")
|
||||
if name == "preview":
|
||||
command.add_argument("--output", type=Path, help="Output HTML path; defaults to a temporary directory")
|
||||
command.add_argument("--no-open", action="store_true", help="Save without opening a browser")
|
||||
command.add_argument("--fragment", action="store_true", help="Save an HTML fragment without opening it")
|
||||
else:
|
||||
command.add_argument("--skip-tests", action="store_true", help="Skip pytest, keeping asset validation")
|
||||
options = parser.parse_args()
|
||||
if options.command is None:
|
||||
parser.print_help()
|
||||
return
|
||||
executable = blender_path(options.blender)
|
||||
if options.command == "preview":
|
||||
output = (options.output or Path(mkdtemp(prefix="anyimage-node-preview-")) / "nodes.html").resolve()
|
||||
arguments = ["--output", str(output)]
|
||||
if options.fragment:
|
||||
arguments.append("--fragment")
|
||||
run_blender_modules(executable, ("tools.nodes.preview.export",), arguments)
|
||||
print(output)
|
||||
if not options.no_open and not options.fragment:
|
||||
webbrowser.open(output.as_uri())
|
||||
return
|
||||
modules = ("tools.nodes.build", "tools.nodes.check") if options.command == "build" else ("tools.nodes.check",)
|
||||
run_blender_modules(executable, modules)
|
||||
if not options.skip_tests:
|
||||
run((sys.executable, "-m", "pytest"))
|
||||
|
||||
|
||||
@@ -50,7 +50,125 @@ def _math(group, operation, a, b=None):
|
||||
return node.outputs[0]
|
||||
|
||||
|
||||
def split_depth_surface(group, geometry, image, strength, uniform):
|
||||
def _remove_strip_faces(group, geometry, *, triangles=False):
|
||||
"""Delete boundary strips from one unchanged topology snapshot."""
|
||||
nodes, links = group.nodes, group.links
|
||||
|
||||
def equals(value, count):
|
||||
compare = nodes.new("FunctionNodeCompare")
|
||||
compare.data_type, compare.operation = "INT", "EQUAL"
|
||||
links.new(value, compare.inputs[2])
|
||||
if isinstance(count, int):
|
||||
compare.inputs[3].default_value = count
|
||||
else:
|
||||
links.new(count, compare.inputs[3])
|
||||
return compare.outputs["Result"]
|
||||
|
||||
def combine(operation, a, b):
|
||||
node = nodes.new("FunctionNodeIntegerMath")
|
||||
node.operation = operation
|
||||
links.new(a, node.inputs[0])
|
||||
links.new(b, node.inputs[1])
|
||||
return node.outputs[0]
|
||||
|
||||
boundaries = []
|
||||
for offset in range(4):
|
||||
index = nodes.new("GeometryNodeInputIndex")
|
||||
corners = nodes.new("GeometryNodeCornersOfFace")
|
||||
links.new(index.outputs[0], corners.inputs["Face Index"])
|
||||
corner = nodes.new("GeometryNodeOffsetCornerInFace")
|
||||
corner.inputs["Offset"].default_value = offset
|
||||
links.new(corners.outputs["Corner Index"], corner.inputs["Corner Index"])
|
||||
edge = nodes.new("GeometryNodeEdgesOfCorner")
|
||||
links.new(corner.outputs["Corner Index"], edge.inputs["Corner Index"])
|
||||
sample = nodes.new("GeometryNodeFieldAtIndex")
|
||||
sample.data_type, sample.domain = "INT", "EDGE"
|
||||
links.new(edge.outputs["Next Edge Index"], sample.inputs["Index"])
|
||||
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
links.new(neighbors.outputs["Face Count"], sample.inputs["Value"])
|
||||
boundaries.append(equals(sample.outputs["Value"], 1))
|
||||
opposite = combine(
|
||||
"MAXIMUM",
|
||||
combine("MULTIPLY", boundaries[0], boundaries[2]),
|
||||
combine("MULTIPLY", boundaries[1], boundaries[3]),
|
||||
)
|
||||
face = nodes.new("GeometryNodeInputMeshFaceNeighbors")
|
||||
selected = combine("MULTIPLY", equals(face.outputs["Vertex Count"], 4), opposite)
|
||||
if triangles:
|
||||
def sample(value, index, domain):
|
||||
node = nodes.new("GeometryNodeFieldAtIndex")
|
||||
node.data_type, node.domain = "INT", domain
|
||||
links.new(value, node.inputs["Value"])
|
||||
links.new(index, node.inputs["Index"])
|
||||
return node.outputs["Value"]
|
||||
|
||||
def offset_corner(corner, offset):
|
||||
node = nodes.new("GeometryNodeOffsetCornerInFace")
|
||||
links.new(corner, node.inputs["Corner Index"])
|
||||
node.inputs["Offset"].default_value = offset
|
||||
return node.outputs["Corner Index"]
|
||||
|
||||
def vertex(corner):
|
||||
node = nodes.new("GeometryNodeVertexOfCorner")
|
||||
links.new(corner, node.inputs["Corner Index"])
|
||||
return node.outputs["Vertex Index"]
|
||||
|
||||
def boundary(corner):
|
||||
edge = nodes.new("GeometryNodeEdgesOfCorner")
|
||||
links.new(corner, edge.inputs["Corner Index"])
|
||||
count = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
return equals(sample(count.outputs["Face Count"], edge.outputs["Next Edge Index"], "EDGE"), 1)
|
||||
|
||||
# Evaluate each internal edge as a symmetric pair of triangles.
|
||||
edge_index = nodes.new("GeometryNodeInputIndex")
|
||||
sides = []
|
||||
for side in (0, 1):
|
||||
corners = nodes.new("GeometryNodeCornersOfEdge")
|
||||
links.new(edge_index.outputs[0], corners.inputs["Edge Index"])
|
||||
corners.inputs["Sort Index"].default_value = side
|
||||
corner = corners.outputs["Corner Index"]
|
||||
face_index = nodes.new("GeometryNodeFaceOfCorner")
|
||||
links.new(corner, face_index.inputs["Corner Index"])
|
||||
face_size = nodes.new("GeometryNodeInputMeshFaceNeighbors")
|
||||
triangle = equals(sample(face_size.outputs["Vertex Count"], face_index.outputs["Face Index"], "FACE"), 3)
|
||||
end, tip = offset_corner(corner, 1), offset_corner(corner, 2)
|
||||
sides.append((triangle, vertex(corner), vertex(tip), boundary(end), boundary(tip)))
|
||||
a, b = sides
|
||||
same_direction = equals(a[1], b[1])
|
||||
same_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[3]), combine("MULTIPLY", a[4], b[4]))
|
||||
reverse_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[4]), combine("MULTIPLY", a[4], b[3]))
|
||||
pair = combine("MAXIMUM",
|
||||
combine("MULTIPLY", same_direction, reverse_order),
|
||||
combine("MULTIPLY", equals(same_direction, 0), same_order))
|
||||
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
for condition in (equals(neighbors.outputs["Face Count"], 2), a[0], b[0], equals(equals(a[2], b[2]), 0)):
|
||||
pair = combine("MULTIPLY", pair, condition)
|
||||
|
||||
# Explicit edge-to-face sampling marks both members without averaging
|
||||
# through neighboring vertices or iterating over newly exposed edges.
|
||||
face_index = nodes.new("GeometryNodeInputIndex")
|
||||
corners = nodes.new("GeometryNodeCornersOfFace")
|
||||
links.new(face_index.outputs[0], corners.inputs["Face Index"])
|
||||
boundary_count, paired = None, None
|
||||
for offset in range(3):
|
||||
corner = offset_corner(corners.outputs["Corner Index"], offset)
|
||||
edge = nodes.new("GeometryNodeEdgesOfCorner")
|
||||
links.new(corner, edge.inputs["Corner Index"])
|
||||
match = sample(pair, edge.outputs["Next Edge Index"], "EDGE")
|
||||
paired = match if paired is None else combine("MAXIMUM", paired, match)
|
||||
rim = boundary(corner)
|
||||
boundary_count = rim if boundary_count is None else combine("ADD", boundary_count, rim)
|
||||
triangle_selected = combine("MULTIPLY", equals(corners.outputs["Total"], 3),
|
||||
combine("MAXIMUM", paired, equals(boundary_count, 3)))
|
||||
selected = combine("MAXIMUM", selected, triangle_selected)
|
||||
delete = nodes.new("GeometryNodeDeleteGeometry")
|
||||
delete.domain, delete.mode = "FACE", "ALL"
|
||||
links.new(geometry, delete.inputs["Geometry"])
|
||||
links.new(selected, delete.inputs["Selection"])
|
||||
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."""
|
||||
nodes, links = group.nodes, group.links
|
||||
uv = nodes.new("GeometryNodeInputNamedAttribute")
|
||||
@@ -136,7 +254,7 @@ def split_depth_surface(group, geometry, image, strength, uniform):
|
||||
choose.input_type = "GEOMETRY"
|
||||
links.new(enabled, choose.inputs["Switch"])
|
||||
links.new(geometry, choose.inputs["False"])
|
||||
links.new(split.outputs["Mesh"], choose.inputs["True"])
|
||||
links.new(_remove_strip_faces(group, split.outputs["Mesh"], triangles=triangles), choose.inputs["True"])
|
||||
return choose.outputs["Output"]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
group, geometry, depth_image, split, uniform_scale, triangles=True
|
||||
)
|
||||
original_camera = _position_field(group, depth_image)
|
||||
geometry, camera_position = store_surface_camera(
|
||||
@@ -517,12 +517,12 @@ def build_image_depth_cutout_group():
|
||||
float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet'
|
||||
float_input(group, 'Uniform Thickness', 0.0, subtype='DISTANCE').description = 'Distance used only by Uniform thickness mode'
|
||||
float_input(group, 'Depth Scale', 1.0)
|
||||
float_input(group, 'Split Threshold', 0.0, maximum=1, subtype='FACTOR')
|
||||
iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')
|
||||
iterations.default_value = 0
|
||||
iterations.min_value, iterations.max_value = (0, 20)
|
||||
options = group.interface.new_panel(name='Options', default_closed=True)
|
||||
float_input(group, 'Cleanup Threshold', 0.0, parent=options)
|
||||
float_input(group, 'Split Threshold', 0.0, maximum=1, subtype='FACTOR', parent=options)
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
float_input(group, 'Edge Round', 0.2, maximum=1, subtype='FACTOR', parent=options)
|
||||
float_input(group, 'Front Inflation', 1.0, maximum=32, parent=options)
|
||||
|
||||
@@ -36,6 +36,7 @@ def build_image_depth_plane_group(image_plane_group):
|
||||
depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat")
|
||||
depth_scale.default_value = 1.0
|
||||
depth_scale.min_value = 0.0
|
||||
float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR")
|
||||
iterations = _interface_socket(group, "Smooth", "INPUT", "NodeSocketInt")
|
||||
iterations.default_value = 0
|
||||
iterations.min_value, iterations.max_value = 0, 20
|
||||
@@ -65,7 +66,6 @@ def build_image_depth_plane_group(image_plane_group):
|
||||
validity_threshold.default_value = 0.9
|
||||
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
|
||||
validity_threshold.subtype = "FACTOR"
|
||||
float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR", parent=options)
|
||||
data = group.interface.new_panel(name="Data", default_closed=True)
|
||||
uniform_scale = _interface_socket(
|
||||
group,
|
||||
|
||||
Reference in New Issue
Block a user