feat(cutout_tool): Implement cutout region to shape functionality

- Added interaction helpers for cutout region validation and shape menu interaction.
- Created main operator for converting image regions into cutout shapes, including AI artifact generation.
- Introduced object creation logic for cutout shapes, handling depth and normal textures.
- Defined cutout shape identifiers and node groups for various shape types.
- Implemented depth texture writing and prediction artifact generation for MoGe-2.
- Developed server jobs for generating cutout artifacts, depth planes, and refining image regions.
- Added region refinement logic to enhance gesture masks using BEN2.
This commit is contained in:
2026-08-31 11:36:27 +08:00
parent 08309f719d
commit 87d7721128
80 changed files with 4823 additions and 4297 deletions

View File

@@ -1,12 +1,10 @@
"""Build the four Cutout Geometry Nodes groups stored in O_AnyImage.blend."""
import math
import bpy
from node_graph import expand_smooth
from anyimage.operators.cutout_tool.shape_types import (
from anyimage.operators.cutout_tool.shape import (
BALLOON_ATTRIBUTE_NAME,
CUTOUT_NODE_GROUP_NAME,
CUTOUT_NODE_GROUP_NAMES,
@@ -51,9 +49,7 @@ def _localize_group_inputs(group, controls):
for link in ordered:
if (
not clusters
or link.to_node.location.x
- clusters[-1][-1].to_node.location.x
> 900.0
or link.to_node.location.x - clusters[-1][-1].to_node.location.x > 900.0
):
clusters.append([])
clusters[-1].append(link)
@@ -356,9 +352,7 @@ def _set_segmented_layer_x(
flip_side.location = (x + 2850.0, y + 350.0)
links.new(positioned_side, flip_side.inputs["Mesh"])
back = _offset_geometry_x(
group, geometry, back_x, (x + 2450.0, y - 400.0)
)
back = _offset_geometry_x(group, geometry, back_x, (x + 2450.0, y - 400.0))
flip_back = nodes.new("GeometryNodeFlipFaces")
flip_back.location = (x + 2850.0, y - 400.0)
links.new(back, flip_back.inputs["Mesh"])
@@ -503,17 +497,13 @@ def _build_cutout(group, geometry, thickness):
thickness,
(250.0, 300.0),
)
return _remove_zero_thickness(
group, geometry, shaped, thickness, (3750.0, 300.0)
)
return _remove_zero_thickness(group, geometry, shaped, thickness, (3750.0, 300.0))
def _build_balloon(group, geometry, thickness):
nodes = group.nodes
links = group.links
profile, _profile_factor = _profile_field(
group, geometry, (-200.0, 0.0)
)
profile, _profile_factor = _profile_field(group, geometry, (-200.0, 0.0))
front = _math(nodes, "MULTIPLY", (2400.0, 500.0))
front.inputs[1].default_value = -1.0
links.new(profile, front.inputs[0])
@@ -532,9 +522,7 @@ def _build_balloon(group, geometry, thickness):
back.outputs[0],
(2850.0, 650.0),
)
return _remove_zero_thickness(
group, geometry, shaped, thickness, (4400.0, 650.0)
)
return _remove_zero_thickness(group, geometry, shaped, thickness, (4400.0, 650.0))
def _build_depth_balloon(
@@ -550,9 +538,7 @@ def _build_depth_balloon(
):
nodes = group.nodes
links = group.links
profile, profile_factor = _profile_field(
group, geometry, (-200.0, 250.0)
)
profile, profile_factor = _profile_field(group, geometry, (-200.0, 250.0))
camera_position = _position_field(group, depth_image, (200.0, -250.0))
separate_camera = nodes.new("ShaderNodeSeparateXYZ")
separate_camera.location = (650.0, -250.0)
@@ -615,9 +601,7 @@ def _build_depth_balloon(
front_only = _set_geometry_x(
group, geometry, front_mix.outputs["Result"], (3500.0, 100.0)
)
return _remove_zero_thickness(
group, front_only, shaped, thickness, (4850.0, 600.0)
)
return _remove_zero_thickness(group, front_only, shaped, thickness, (4850.0, 600.0))
def _build_depth_surface(
@@ -709,9 +693,7 @@ def build_node_group(shape):
"the source sheet"
)
else:
thickness = _float_input(
group, "Thickness", 0.0, 0.0, None, subtype="DISTANCE"
)
thickness = _float_input(group, "Thickness", 0.0, 0.0, None, subtype="DISTANCE")
thickness.description = "Final thickness in scene distance"
depth_image = None
@@ -766,17 +748,15 @@ def build_node_group(shape):
if shape in {"DEPTH_BALLOON", "DEPTH_SURFACE"}:
data = group.interface.new_panel(name="Data", default_closed=True)
uniform_scale = _float_input(
group, "Uniform Scale", 1.0, 0.0, parent=data
)
uniform_scale = _float_input(group, "Uniform Scale", 1.0, 0.0, parent=data)
uniform_scale.description = (
"Internal uniform conversion from MoGe meters to local distance"
)
uniform_scale.hide_in_modifier = True
depth_image = _socket(
group, "Depth Image", "INPUT", "NodeSocketImage", data
depth_image = _socket(group, "Depth Image", "INPUT", "NodeSocketImage", data)
depth_image.description = (
f"Internally generated {CUTOUT_SHAPE_LABELS[shape]} data"
)
depth_image.description = f"Internally generated {CUTOUT_SHAPE_LABELS[shape]} data"
depth_image.hide_in_modifier = True
_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
@@ -795,7 +775,7 @@ def build_node_group(shape):
},
)
geometry = controls.outputs["Geometry"]
if shape == "CUTOUT":
if shape == "SURFACE":
geometry = _build_cutout(group, geometry, controls.outputs["Thickness"])
output_location = (4050.0, 300.0)
elif shape == "BALLOON":
@@ -833,7 +813,7 @@ def build_node_group(shape):
_shade_output(group, geometry, output_location)
group.color_tag = "GEOMETRY"
group.description = {
"CUTOUT": "Symmetrically thicken a Surface sheet.",
"SURFACE": "Symmetrically thicken a Surface sheet.",
"BALLOON": "Inflate a Surface sheet from its boundary profile.",
"DEPTH_BALLOON": (
"Mix Balloon profile and metric image depth around a fitted plane."
@@ -866,9 +846,7 @@ def build_cutout_modifier_group(shape_groups):
"Mirror the complete Depth Balloon front surface across the local YZ plane"
)
iterations = _socket(
group, "Smooth Iterations", "INPUT", "NodeSocketInt"
)
iterations = _socket(group, "Smooth Iterations", "INPUT", "NodeSocketInt")
iterations.default_value = 0
iterations.min_value = 0
iterations.max_value = 20
@@ -882,9 +860,7 @@ def build_cutout_modifier_group(shape_groups):
None,
parent=options,
)
cleanup.description = (
"Delete mesh islands below this absolute local-area threshold"
)
cleanup.description = "Delete mesh islands below this absolute local-area threshold"
smooth = _float_input(
group,
"Smooth",
@@ -915,18 +891,13 @@ def build_cutout_modifier_group(shape_groups):
)
depth_direction.description = "Normal direction of the fitted metric depth plane"
data = group.interface.new_panel(name="Data", default_closed=True)
uniform_scale = _float_input(
group, "Uniform Scale", 1.0, 0.0, parent=data
)
uniform_scale = _float_input(group, "Uniform Scale", 1.0, 0.0, parent=data)
uniform_scale.description = (
"Internal uniform conversion from MoGe meters to local distance"
)
uniform_scale.hide_in_modifier = True
depth_image = _socket(
group, "Depth Image", "INPUT", "NodeSocketImage", data
)
depth_image = _socket(group, "Depth Image", "INPUT", "NodeSocketImage", data)
depth_image.description = "Internally generated Cutout depth data"
depth_image.hide_in_modifier = True
_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
@@ -947,7 +918,7 @@ def build_cutout_modifier_group(shape_groups):
)
branch_y = {
"CUTOUT": 1150.0,
"SURFACE": 1150.0,
"BALLOON": 550.0,
"DEPTH_BALLOON": -50.0,
"DEPTH_SURFACE": -650.0,

View File

@@ -1,9 +1,8 @@
from pathlib import Path
import sys
from pathlib import Path
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
@@ -14,7 +13,7 @@ sys.path.insert(
from anyimage.operators.common.mesh_result import (
load_asset_node_group,
)
from anyimage.operators.cutout_tool.shape_types import (
from anyimage.operators.cutout_tool.shape import (
CUTOUT_NODE_GROUP_NAME,
CUTOUT_NODE_GROUP_NAMES,
)
@@ -34,8 +33,7 @@ def main():
shadeless = load_asset_node_group("O Shadeless")
cutout = load_asset_node_group(CUTOUT_NODE_GROUP_NAME)
cutout_groups = {
name: bpy.data.node_groups[name]
for name in CUTOUT_NODE_GROUP_NAMES.values()
name: bpy.data.node_groups[name] for name in CUTOUT_NODE_GROUP_NAMES.values()
}
assert mesh_plane.name == "O Mesh Plane"

View File

@@ -1,9 +1,8 @@
from pathlib import Path
import sys
from pathlib import Path
import bpy
PROJECT_ROOT = Path(__file__).parents[2]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
sys.path.insert(
@@ -11,12 +10,12 @@ sys.path.insert(
str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))),
)
from anyimage.operators.cutout_tool import node_assets as cutout_nodes
from anyimage.operators.cutout_tool import shape_types as shapes
from anyimage.operators.common.mesh_result import (
modifier_input_identifier,
set_modifier_input,
)
from anyimage.operators.cutout_tool import object as cutout_objects
from anyimage.operators.cutout_tool import shape as shapes
def evaluated_mesh(obj):
@@ -34,11 +33,7 @@ def input_names(group):
def grid_object(name):
vertices = [
(0.0, float(y), float(z))
for z in range(3)
for y in range(3)
]
vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)]
faces = []
for z in range(2):
for y in range(2):
@@ -56,7 +51,7 @@ def grid_object(name):
def add_modifier(obj, shape, thickness):
group = cutout_nodes.cutout_node_group(shape)
group = cutout_objects.load_cutout_node_group()
modifier = obj.modifiers.new(group.name, "NODES")
modifier.node_group = group
for name, value in (
@@ -76,9 +71,9 @@ def add_modifier(obj, shape, thickness):
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
wrapper = cutout_nodes.cutout_node_group("CUTOUT")
wrapper = cutout_objects.load_cutout_node_group()
assert all(
cutout_nodes.cutout_node_group(shape) == wrapper
cutout_objects.load_cutout_node_group() == wrapper
for shape in shapes.CUTOUT_SHAPES
)
assert wrapper.name == shapes.CUTOUT_NODE_GROUP_NAME
@@ -90,8 +85,7 @@ def main():
assert all(not group.is_modifier for group in groups.values())
menu_switch = next(
node for node in wrapper.nodes
if node.bl_idname == "GeometryNodeMenuSwitch"
node for node in wrapper.nodes if node.bl_idname == "GeometryNodeMenuSwitch"
)
assert [item.name for item in menu_switch.enum_items] == list(
shapes.CUTOUT_SHAPE_LABELS.values()
@@ -131,7 +125,7 @@ def main():
"Depth Image",
]
expected_inputs = {
"CUTOUT": ["Geometry", "Thickness"],
"SURFACE": ["Geometry", "Thickness"],
"BALLOON": ["Geometry", "Thickness"],
"DEPTH_BALLOON": [
"Geometry",
@@ -162,9 +156,7 @@ def main():
if link.to_node.bl_idname != "GeometryNodeGroup":
continue
shape = next(
shape
for shape, group in groups.items()
if group == link.to_node.node_tree
shape for shape, group in groups.items() if group == link.to_node.node_tree
)
dependencies.setdefault(link.from_socket.name, set()).add(shape)
assert dependencies == {
@@ -178,41 +170,30 @@ def main():
}
repeat_counts = {
shape: sum(
node.bl_idname == "GeometryNodeRepeatInput"
for node in group.nodes
)
shape: sum(node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes)
for shape, group in groups.items()
}
assert repeat_counts == {
"CUTOUT": 1,
"SURFACE": 1,
"BALLOON": 0,
"DEPTH_BALLOON": 0,
"DEPTH_SURFACE": 1,
}
assert sum(
node.bl_idname == "GeometryNodeRepeatInput"
for node in wrapper.nodes
) == 1
assert sum(
node.bl_idname == "GeometryNodeInputMeshIsland"
for node in wrapper.nodes
) == 1
assert (
sum(node.bl_idname == "GeometryNodeRepeatInput" for node in wrapper.nodes) == 1
)
assert (
sum(node.bl_idname == "GeometryNodeInputMeshIsland" for node in wrapper.nodes)
== 1
)
assert not any(
node.bl_idname == "GeometryNodeAttributeStatistic"
for node in wrapper.nodes
node.bl_idname == "GeometryNodeAttributeStatistic" for node in wrapper.nodes
)
assert all(
not any(
node.bl_idname == "GeometryNodeInputMeshIsland"
for node in group.nodes
)
not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes)
for group in groups.values()
)
assert any(
node.bl_idname == "GeometryNodeBlurAttribute"
for node in wrapper.nodes
)
assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in wrapper.nodes)
smooth = next(
item
for item in wrapper.interface.items_tree
@@ -244,23 +225,19 @@ def main():
assert iterations.min_value == 0
assert getattr(iterations.parent, "name", "") == ""
assert all(
not any(
node.bl_idname == "GeometryNodeBlurAttribute"
for node in group.nodes
)
not any(node.bl_idname == "GeometryNodeBlurAttribute" for node in group.nodes)
for group in groups.values()
)
for group in (wrapper, *groups.values()):
assert all(not node.label for node in group.nodes)
assert all(
link.from_node.location.x <= link.to_node.location.x
for link in group.links
link.from_node.location.x <= link.to_node.location.x for link in group.links
)
group_nodes = list(group.nodes)
assert len({tuple(node.location) for node in group_nodes}) == len(group_nodes)
for index, node in enumerate(group_nodes):
for other in group_nodes[index + 1:]:
for other in group_nodes[index + 1 :]:
assert not (
abs(node.location.x - other.location.x) < 150.0
and abs(node.location.y - other.location.y) < 100.0
@@ -277,14 +254,13 @@ def main():
if item.item_type == "SOCKET" and item.in_out == "INPUT":
assert item.structure_type == "SINGLE"
surface = grid_object("Surface Validation")
add_modifier(surface, "CUTOUT", 2.4)
add_modifier(surface, "SURFACE", 2.4)
evaluated, result = evaluated_mesh(surface)
try:
assert len(result.vertices) == 34
assert len(result.polygons) == 40
side_faces = [
polygon for polygon in result.polygons
if abs(polygon.normal.x) < 0.1
polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1
]
assert len(side_faces) == 24
finally:
@@ -415,14 +391,12 @@ def main():
try:
coordinates = [tuple(vertex.co) for vertex in result.vertices]
assert all(
all(abs(actual - expected) < 1e-5 for actual, expected in zip(point, target))
all(
abs(actual - expected) < 1e-5 for actual, expected in zip(point, target)
)
for point, target in zip(
coordinates,
(
(y * 0.25, y * 0.5, z * 0.5)
for z in range(3)
for y in range(3)
),
((y * 0.25, y * 0.5, z * 0.5) for z in range(3) for y in range(3)),
)
)
finally: