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

@@ -7,7 +7,7 @@
- 说明性内容以中文为主,专有名词和约定俗成的表达可使用英文 - 说明性内容以中文为主,专有名词和约定俗成的表达可使用英文
- 修改代码、依赖或执行方式后, - 修改代码、依赖或执行方式后,
- 不保留旧路径、转发函数、兼容层或胶水代码 - 不保留旧路径、转发函数、兼容层或胶水代码
- 仅过测试,不改同步改文档,不构建文档、产物 - 更新并运行相关测试,但不主动同步说明文档、构建文档或打包扩展
- 优先选择依赖少、直接、易验证的实现 - 优先选择依赖少、直接、易验证的实现
- 复用来自 `common` 的函数,避免重复造轮子 - 复用来自 `common` 的函数,避免重复造轮子
- 确保文件名/文件夹名和内容边界一致 - 确保文件名/文件夹名和内容边界一致
@@ -20,7 +20,7 @@
- 沿真实调用链清理无用代码,不把仅有测试引用当作业务用途;查证动态调用、框架回调和注册入口后再删除。 - 沿真实调用链清理无用代码,不把仅有测试引用当作业务用途;查证动态调用、框架回调和注册入口后再删除。
- 精简测试,保留有效行为覆盖,使测试名称、归属与功能对应,并检查状态隔离。 - 精简测试,保留有效行为覆盖,使测试名称、归属与功能对应,并检查状态隔离。
- 同步整理 `tools/nodes`、`docs/internals` 和相关引用,使名称、说明与实际实现一致。本触发指令包含内部文档整理。 - 同步整理 `tools/nodes`、`docs/internals` 和相关引用,使名称、说明与实际实现一致。本触发指令包含内部文档整理。
- 保留已有的其他修改和有效功能,运行全量测试并确认正常退出,检查旧引用与最终差异,简要报告调整及验证结果;不构建文档或产物。 - 保留已有的其他修改和有效功能,运行全量测试并确认正常退出,检查旧引用与最终差异,简要报告调整及验证结果;不构建文档或打包扩展。
## 查询文档 ## 查询文档
@@ -47,10 +47,10 @@ uv run pytest
uv run pack uv run pack
# 构建并验证 Blender 节点资产 # 构建并验证 Blender 节点资产
uv run build_nodes uv run build-nodes
# 准备、校验并同步 ONNX 模型到 R2 # 准备、校验并同步 ONNX 模型到 R2
uv run sync_models uv run sync-models
``` ```
## 命名规范 ## 命名规范
@@ -87,5 +87,8 @@ uv run sync_models
## Blender 节点组规范 ## Blender 节点组规范
- 节点组统一存入 `assets/O_AnyImage.blend`,运行时按名称加载(`load_node_group(group_name)`) - 节点组统一保存于 `src/anyimage/assets/O_AnyImage.blend`。
- 根据[节点资产](docs/internals/node-assets.md)说明构建节点组,禁止调用 `bpy.data.node_groups.new()` 新建 - 插件运行时必须使用 `load_node_group(group_name)` 从资产加载,禁止调用 `bpy.data.node_groups.new()` 创建节点组。
- `tools/nodes` 构建脚本和测试允许通过代码创建节点组。
- 修改节点组的逻辑、接口、布局或共用构建函数后,必须运行 `uv run build-nodes`,无需另行确认;源码、测试与 `.blend` 资产均更新且验证通过,才算完成。
- 具体构建规范与操作步骤见[节点资产](docs/internals/node-assets.md)。

View File

@@ -27,8 +27,8 @@ Blender Stub 共用 `blender_test_support.py`;Server 和实际 Blender 几何
| --- | --- | | --- | --- |
| `uv run pack` | 生成 Windows、macOS arm64、Linux 扩展包 | | `uv run pack` | 生成 Windows、macOS arm64、Linux 扩展包 |
| `uv run pack --prepare` | 为本机同步扩展 wheel 与 Manifest | | `uv run pack --prepare` | 为本机同步扩展 wheel 与 Manifest |
| `uv run build_nodes` | 构建、验证节点资产并运行测试 | | `uv run build-nodes` | 构建、验证节点资产并运行测试 |
| `uv run sync_models` | 准备、校验并上传模型声明的文件 | | `uv run sync-models` | 准备、校验并上传模型声明的文件 |
| `uv run docs -dev` | 本地预览文档 | | `uv run docs -dev` | 本地预览文档 |
| `uv run docs` | 通过 Mike 发布项目版本并更新 latest | | `uv run docs` | 通过 Mike 发布项目版本并更新 latest |

View File

@@ -2,7 +2,7 @@
## 构建节点资产 ## 构建节点资产
生产节点组由 `uv run build_nodes` 构建到 `src/anyimage/assets/O_AnyImage.blend` 生产节点组由 `uv run build-nodes` 构建到 `src/anyimage/assets/O_AnyImage.blend`
节点组文件在 `tools/nodes/groups` 节点组文件在 `tools/nodes/groups`
@@ -40,8 +40,8 @@
保存后,`check.py` 在独立 Blender 进程中重新加载资产并做基本几何求值。接口、默认值和详细几何行为由 `tests/` 检查。 保存后,`check.py` 在独立 Blender 进程中重新加载资产并做基本几何求值。接口、默认值和详细几何行为由 `tests/` 检查。
```bash ```bash
uv run build_nodes --blender "/path/to/blender" uv run build-nodes --blender "/path/to/blender"
uv run build_nodes --skip-tests uv run build-nodes --skip-tests
``` ```
脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置: 脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置:

View File

@@ -0,0 +1,3 @@
/output/
/analytic/
/photo/

View File

@@ -0,0 +1,5 @@
# 素材来源
真实照片试验使用 DerMische 的 [Braunschweig Sankt-Ägidien Panorama 02](https://commons.wikimedia.org/wiki/File:Braunschweig_Sankt-%C3%84gidien_Panorama_02.jpg),许可为 [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)。使用 Wikimedia 的 1280×640 缩略图作为输入,并通过 MoGe2 估计距离、重投影和生成网格。照片及相应衍生预览、带照片纹理的网格遵循该许可,作者不为本试验背书。
多视图投影与梯度域距离融合参考 [microsoft/MoGe 的 panorama.py](https://github.com/microsoft/MoGe/blob/main/moge/utils/panorama.py);固定视角几何恢复参考 [MoGe v2](https://github.com/microsoft/MoGe/blob/main/moge/model/v2.py)。试验独立实现这些计算。

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()

View File

@@ -0,0 +1,147 @@
"""Spherical projection and gradient-domain distance fusion for an isolated trial."""
import numpy as np
from scipy.ndimage import map_coordinates
from scipy.sparse import coo_matrix, diags
from scipy.sparse.linalg import spsolve
def panorama_directions(width, height):
u, v = np.meshgrid((np.arange(width) + 0.5) / width,
(np.arange(height) + 0.5) / height)
return uv_directions(u, v)
def uv_directions(u, v):
theta, phi = 2 * np.pi * u, np.pi * v
return np.stack((np.sin(phi) * np.cos(theta),
np.sin(phi) * np.sin(theta), np.cos(phi)), axis=-1)
def camera_bases():
ratio = (1 + np.sqrt(5)) / 2
vertices = []
for a in (-1, 1):
for b in (-ratio, ratio):
vertices.extend(((0, a, b), (a, b, 0), (b, 0, a)))
bases = []
for forward in np.asarray(vertices):
forward = forward / np.linalg.norm(forward)
right = np.cross(forward, (0, 0, 1))
right /= np.linalg.norm(right)
down = np.cross(forward, right)
bases.append(np.stack((right, down, forward)))
return bases
def view_rays(size):
x, y = np.meshgrid(2 * (np.arange(size) + 0.5) / size - 1,
2 * (np.arange(size) + 0.5) / size - 1)
return np.stack((x, y, np.ones_like(x)), axis=-1)
def sample_panorama(image, directions):
directions = directions / np.linalg.norm(directions, axis=-1, keepdims=True)
u = np.arctan2(directions[..., 1], directions[..., 0]) / (2 * np.pi) % 1
v = np.arccos(np.clip(directions[..., 2], -1, 1)) / np.pi
height, width = image.shape[:2]
padded = np.concatenate((image[:, -1:], image, image[:, :1]), axis=1)
coords = (np.clip(v * height - 0.5, 0, height - 1), u * width + 0.5)
if image.ndim == 2:
return map_coordinates(padded, coords, order=1, mode="nearest")
return np.stack([map_coordinates(padded[..., c], coords, order=1,
mode="nearest") for c in range(image.shape[2])], -1)
def room_reference(directions):
"""Trace a furnished rectangular room from a camera 1.6 m above its floor."""
directions = directions / np.linalg.norm(directions, axis=-1, keepdims=True)
bounds = np.where(directions >= 0, (3.0, 4.0, 1.4), (-3.0, -4.0, -1.6))
hits = bounds / np.where(np.abs(directions) > 1e-12, directions, 1e-12)
hits = np.where(hits > 0, hits, np.inf)
surface = np.argmin(hits, axis=-1)
distance = np.min(hits, axis=-1)
point = directions * distance[..., None]
colors = np.array(((0.68, 0.76, 0.80), (0.81, 0.73, 0.61), (0.9, 0.88, 0.82)))
color = colors[surface].copy()
floor = (surface == 2) & (directions[..., 2] < 0)
tiles = (np.floor(point[..., 0] * 2) + np.floor(point[..., 1] * 2)) % 2
color[floor] = np.stack((0.40 + 0.08 * tiles, 0.27 + 0.06 * tiles,
0.15 + 0.04 * tiles), -1)[floor]
lines = (np.abs(np.sin(point[..., 0] * np.pi * 2)) < 0.04) | (
np.abs(np.sin(point[..., 1] * np.pi * 2)) < 0.04)
color[floor & lines] *= 0.6
window = (surface == 1) & (directions[..., 1] > 0) & (
np.abs(point[..., 0] + 0.4) < 1.05) & (np.abs(point[..., 2] - 0.25) < 0.7)
color[window] = (0.48, 0.72, 0.88)
frame = window & ((np.abs(point[..., 0] + 0.4) < 0.045) |
(np.abs(point[..., 2] - 0.25) < 0.04))
color[frame] = 0.93
for lower, upper, base_color in (
((0.8, 1.0, -1.6), (2.0, 2.2, -0.6), (0.28, 0.43, 0.34)),
((-2.8, -1.8, -1.6), (-2.1, 0.5, 0.7), (0.43, 0.26, 0.15)),
):
safe = np.where(np.abs(directions) > 1e-12, directions, 1e-12)
a, b = np.asarray(lower) / safe, np.asarray(upper) / safe
near, far = np.max(np.minimum(a, b), axis=-1), np.min(np.maximum(a, b), axis=-1)
visible = (near > 0) & (near < far) & (near < distance)
distance[visible] = near[visible]
color[visible] = base_color
color *= (0.75 + 0.25 * np.clip(directions[..., 2] + 0.6, 0, 1))[..., None]
return distance, color
def warp_distances(distances, masks, bases, width, height):
directions = panorama_directions(width, height)
logs, validities = [], []
for distance, mask, basis in zip(distances, masks, bases):
rays = directions @ basis.T
projected = rays[..., :2] / np.maximum(rays[..., 2:], 1e-8)
valid = (rays[..., 2] > 0) & (np.abs(projected) < 0.98).all(axis=-1)
size = distance.shape[0]
coords = ((projected[..., 1] + 1) * size / 2 - 0.5,
(projected[..., 0] + 1) * size / 2 - 0.5)
valid &= map_coordinates(mask.astype(float), coords, order=0, mode="nearest") > 0.5
logs.append(map_coordinates(np.log(np.maximum(distance, 1e-6)), coords,
order=1, mode="nearest"))
validities.append(valid)
return np.asarray(logs), np.asarray(validities)
def merge_distances(distances, masks, bases, width, height):
"""Fuse log-distance derivatives with a cyclic longitude and a scale anchor.
Algorithm reference: microsoft/MoGe, moge/utils/panorama.py.
This independent implementation solves normal equations on a small trial grid.
"""
logs, valid = warp_distances(distances, masks, bases, width, height)
count = width * height
index = np.arange(count).reshape(height, width)
start = np.concatenate((index.ravel(), index[:-1].ravel()))
end = np.concatenate((np.roll(index, -1, axis=1).ravel(), index[1:].ravel()))
rows = np.arange(len(start))
gradient = coo_matrix((np.concatenate((-np.ones(len(start)), np.ones(len(start)))),
(np.tile(rows, 2), np.concatenate((start, end)))),
shape=(len(start), count)).tocsr()
laplacian = -(gradient.T @ gradient).tocsr()
edge_valid = valid.reshape(12, -1)[:, start] & valid.reshape(12, -1)[:, end]
grad_samples = logs.reshape(12, -1)[:, end] - logs.reshape(12, -1)[:, start]
grad_target = np.sum(grad_samples * edge_valid, axis=0) / np.maximum(edge_valid.sum(0), 1)
lap_valid = valid.copy()
lap_valid &= np.roll(valid, 1, axis=2) & np.roll(valid, -1, axis=2)
lap_valid[:, 1:] &= valid[:, :-1]
lap_valid[:, :-1] &= valid[:, 1:]
lap_valid = lap_valid.reshape(12, -1)
lap_samples = np.asarray((laplacian @ logs.reshape(12, -1).T).T)
lap_target = np.sum(lap_samples * lap_valid, axis=0) / np.maximum(lap_valid.sum(0), 1)
g = gradient[edge_valid.any(0)]
l = laplacian[lap_valid.any(0)]
coverage = valid.any(0)
if not coverage.any():
raise ValueError("No valid panorama predictions")
baseline = np.sum(logs * valid, axis=0) / np.maximum(valid.sum(0), 1)
matrix = g.T @ g + l.T @ l + diags(np.full(count, 1e-7))
target = g.T @ grad_target[edge_valid.any(0)] + l.T @ lap_target[lap_valid.any(0)]
solution = spsolve(matrix.tocsc(), target + 1e-7 * baseline.ravel()).reshape(height, width)
solution += np.median(baseline[coverage] - solution[coverage])
return np.exp(solution).astype(np.float32), coverage

View File

@@ -0,0 +1,144 @@
"""Run MoGe2 panorama inference without importing or changing the add-on."""
import argparse
import json
from pathlib import Path
import sys
import time
import numpy as np
from PIL import Image
from scipy.optimize import least_squares
from geometry import (camera_bases, merge_distances, panorama_directions,
room_reference, sample_panorama, view_rays)
def fixed_fov_distance(points, mask, metric_scale):
"""Recover camera Z shift using the known 90-degree perspective rays."""
size = points.shape[0]
rays = view_rays(size)
select = mask > 0.5
select &= np.isfinite(points).all(-1)
if select.sum() < 16:
return np.ones((size, size), dtype=np.float32), np.zeros((size, size), dtype=bool)
stride = max(1, size // 64)
sampled = select[::stride, ::stride]
p = points[::stride, ::stride][sampled].astype(float)
q = rays[::stride, ::stride, :2][sampled]
initial = np.sum(q * (p[:, :2] - q * p[:, 2:])) / max(np.sum(q * q), 1e-8)
lower = -float(points[..., 2][select].min()) + 1e-5
fitted = least_squares(lambda shift: (p[:, :2] / (p[:, 2:] + shift[0]) - q).ravel(),
[max(initial, lower + 1e-4)], bounds=([lower], [np.inf]))
depth = (points[..., 2] + fitted.x[0]) * float(metric_scale)
select &= (depth > 0) & np.isfinite(depth)
distance = np.maximum(depth, 1e-6) * np.linalg.norm(rays, axis=-1)
return distance.astype(np.float32), select
def depth_alpha(image, validity):
"""Combine the model mask with nearest-resized source alpha once at export."""
height, width = validity.shape
if image.shape[-1] == 4:
source = Image.fromarray(image[..., 3].astype(np.float32))
alpha = np.asarray(source.resize((width, height), Image.Resampling.NEAREST))
else:
alpha = np.ones((height, width), dtype=np.float32)
return np.asarray(validity, dtype=np.float32) * alpha
def remove_background(image):
"""Use the project's existing BEN2 implementation for the trial input."""
root = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(root / "src/anyimage/server/models"))
from onnx_ben2 import create_session, infer
session = create_session(root / "models/BEN2-ONNX", "cpu")
return infer(session, image, release_memory=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--image", type=Path)
parser.add_argument("--model", type=Path, default=Path("models/moge-2-vits-normal-onnx/model.onnx"))
parser.add_argument("--output", type=Path, default=Path(__file__).parent / "output")
parser.add_argument("--size", type=int, default=256)
parser.add_argument("--width", type=int, default=256)
parser.add_argument("--reference-only", action="store_true")
parser.add_argument("--remove-background", action="store_true")
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
width, height = args.width, args.width // 2
directions = panorama_directions(width, height)
truth, _ = room_reference(directions)
if args.image:
with Image.open(args.image) as source:
source = source.convert("RGBA")
if args.remove_background:
foreground = remove_background(source)
original_alpha = np.asarray(source.getchannel("A"), dtype=np.float32) / 255
alpha = np.asarray(foreground.getchannel("A"), dtype=np.float32) * original_alpha
source.putalpha(Image.fromarray(alpha.astype(np.uint8)))
image = np.asarray(source, dtype=np.float32) / 255
if image.shape[1] != 2 * image.shape[0]:
raise ValueError("Expected a full 2:1 equirectangular panorama")
else:
_, image = room_reference(panorama_directions(1024, 512))
np.savez_compressed(args.output / "reference.npz", distance=truth.astype(np.float32),
alpha=np.ones_like(truth, dtype=np.float32))
Image.fromarray(np.uint8(np.clip(image, 0, 1) * 255)).save(args.output / "panorama.png")
bases = camera_bases()
distances, masks, panels = [], [], []
start = time.perf_counter()
if not args.reference_only:
import onnxruntime as ort
options = ort.SessionOptions()
options.intra_op_num_threads = 8
session = ort.InferenceSession(str(args.model), options, providers=["CPUExecutionProvider"])
scale_name = next(item.name for item in session.get_outputs()
if item.name in {"scale", "metric_scale"})
for index, basis in enumerate(bases):
rays = view_rays(args.size) @ basis
view = sample_panorama(image, rays).astype(np.float32)
source_valid = np.ones(view.shape[:2], dtype=bool)
if view.shape[-1] == 4:
source_valid = view[..., 3] > 0
view = view[..., :3] * view[..., 3:]
panels.append(np.uint8(np.clip(view, 0, 1) * 255))
if args.reference_only:
distance, _ = room_reference(rays)
mask = np.ones_like(distance, dtype=bool)
else:
prediction = session.run(["points", "mask", scale_name],
{"image": view.transpose(2, 0, 1)[None],
"num_tokens": np.array(1200, dtype=np.int64)})
distance, mask = fixed_fov_distance(prediction[0][0], prediction[1][0] * source_valid,
prediction[2].reshape(-1)[0])
mask &= source_valid
distances.append(distance)
masks.append(mask)
print(f"View {index + 1}/12: valid={mask.mean():.3f}, elapsed={time.perf_counter()-start:.1f}s", flush=True)
Image.fromarray(np.concatenate([np.concatenate(panels[i:i + 4], axis=1)
for i in range(0, 12, 4)], axis=0)).save(args.output / "views.png")
np.savez_compressed(args.output / "views.npz", distances=distances, masks=masks)
distance, validity = merge_distances(distances, masks, bases, width, height)
combined_alpha = depth_alpha(image, validity)
np.savez_compressed(args.output / "prediction.npz", distance=distance, alpha=combined_alpha)
stats = {"mode": "analytic" if args.reference_only else args.model.parent.name + "-cpu",
"input": str(args.image) if args.image else "synthetic room",
"background_removal": "BEN2" if args.remove_background else None,
"size": [width, height], "valid_fraction": float(validity.mean()),
"depth_alpha_valid_fraction": float((combined_alpha >= 0.9).mean()),
"seconds": time.perf_counter() - start,
"distance_range": [float(distance.min()), float(distance.max())]}
if not args.image:
ratio = np.median(truth[validity] / distance[validity])
stats["reference_scale_alignment"] = float(ratio)
stats["aligned_mean_relative_error"] = float(np.mean(np.abs(distance[validity] * ratio / truth[validity] - 1)))
stats["aligned_seam_mean_relative_error"] = float(np.mean(np.abs(distance[:, [0, -1]] * ratio / truth[:, [0, -1]] - 1)))
(args.output / "inference.json").write_text(json.dumps(stats, indent=2), encoding="utf-8")
print(json.dumps(stats, indent=2), flush=True)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,151 @@
"""Build an offline Quad Sphere panorama asset with point-domain validity culling."""
from pathlib import Path
import sys
import bpy
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from tools.nodes.common.nodes import interface_socket, prepare_node_group
REFERENCE_LIBRARY = Path("D:/Projects/OmooNodes/subspaces/O_Essentials/O_Essentials_GeometryNodes.blend1")
def build_node_group():
with bpy.data.libraries.load(str(REFERENCE_LIBRARY), link=False) as (source, target):
if "O Quad Sphere" not in source.node_groups:
raise ValueError("Reference library has no O Quad Sphere")
target.node_groups = ["O Quad Sphere"]
group = bpy.data.node_groups.new("Panorama Surface Trial", "GeometryNodeTree")
group.is_modifier = True
for name, kind in (("Subdivide", "NodeSocketInt"), ("Depth Scale", "NodeSocketFloat"),
("Validity Threshold", "NodeSocketFloat"),
("Distance", "NodeSocketImage"), ("Material", "NodeSocketMaterial")):
socket = interface_socket(group, name, "INPUT", kind)
if name == "Subdivide":
socket.default_value, socket.min_value, socket.max_value = 7, 0, 8
elif name == "Depth Scale":
socket.default_value, socket.min_value, socket.max_value = 1.0, 0.0, 1.0
socket.subtype = "FACTOR"
elif name == "Validity Threshold":
socket.default_value, socket.min_value, socket.max_value = 0.9, 0.0, 1.0
socket.subtype = "FACTOR"
elif name == "Material":
socket.hide_in_modifier = True
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
links = group.links
def node(kind, x, y):
result = group.nodes.new(kind)
result.location = (x, y)
return result
def math(operation, a, b, x, y):
result = node("ShaderNodeMath", x, y)
result.operation = operation
for index, value in enumerate((a, b)):
if isinstance(value, (float, int)):
result.inputs[index].default_value = value
else:
links.new(value, result.inputs[index])
return result.outputs[0]
def store(geometry, name, field, domain, x, y):
result = node("GeometryNodeStoreNamedAttribute", x, y)
result.data_type, result.domain = "FLOAT_VECTOR", domain
result.inputs["Name"].default_value = name
links.new(geometry, result.inputs["Geometry"])
links.new(field, result.inputs["Value"])
return result.outputs["Geometry"]
def read(name, x, y):
result = node("GeometryNodeInputNamedAttribute", x, y)
result.data_type = "FLOAT_VECTOR"
result.inputs["Name"].default_value = name
return result.outputs["Attribute"]
inputs = node("NodeGroupInput", 0, 400)
segments = math("POWER", 2, inputs.outputs["Subdivide"], 0, 150)
segments = math("ADD", segments, 1, 180, 150)
sphere = node("GeometryNodeGroup", 400, 400)
sphere.node_tree = target.node_groups[0]
sphere.inputs["Radius"].default_value = 1
links.new(segments, sphere.inputs["Segments"])
position = node("GeometryNodeInputPosition", 400, 100)
geometry = store(sphere.outputs["Mesh"], "panorama_direction", position.outputs[0], "POINT", 620, 400)
xyz = node("ShaderNodeSeparateXYZ", 620, 100)
links.new(read("panorama_direction", 400, -80), xyz.inputs[0])
angle = math("ARCTAN2", xyz.outputs["Y"], xyz.outputs["X"], 800, 100)
longitude = math("DIVIDE", angle, 6.283185307179586, 980, 100)
longitude = math("FRACT", math("ADD", longitude, 1, 1160, 100), 0, 1340, 100)
latitude = math("ARCCOSINE", xyz.outputs["Z"], 0, 800, -100)
latitude = math("SUBTRACT", 1, math("DIVIDE", latitude, 3.141592653589793, 980, -100), 1160, -100)
uv = node("ShaderNodeCombineXYZ", 1540, 100)
links.new(longitude, uv.inputs["X"])
links.new(latitude, uv.inputs["Y"])
geometry = store(geometry, "panorama_sample_uv", uv.outputs[0], "POINT", 1540, 400)
# Each face chooses its own longitude branch; pole corners use the face longitude.
face_position = node("GeometryNodeFieldOnDomain", 800, -400)
face_position.data_type, face_position.domain = "FLOAT_VECTOR", "FACE"
links.new(position.outputs[0], face_position.inputs["Value"])
face_xyz = node("ShaderNodeSeparateXYZ", 980, -400)
links.new(face_position.outputs[0], face_xyz.inputs[0])
face_angle = math("ARCTAN2", face_xyz.outputs["Y"], face_xyz.outputs["X"], 1160, -400)
face_u = math("DIVIDE", face_angle, 6.283185307179586, 1340, -400)
correction = math("ROUND", math("SUBTRACT", face_u, longitude, 1540, -300), 0, 1720, -300)
corner_u = math("ADD", longitude, correction, 1900, -300)
pole = math("GREATER_THAN", math("ABSOLUTE", xyz.outputs["Z"], 0, 1540, -520), 0.999999, 1720, -520)
choose_u = node("GeometryNodeSwitch", 2080, -300)
choose_u.input_type = "FLOAT"
links.new(pole, choose_u.inputs["Switch"])
links.new(corner_u, choose_u.inputs["False"])
links.new(face_u, choose_u.inputs["True"])
corner_uv = node("ShaderNodeCombineXYZ", 2300, -300)
links.new(choose_u.outputs[0], corner_uv.inputs["X"])
links.new(latitude, corner_uv.inputs["Y"])
geometry = store(geometry, "UVMap", corner_uv.outputs[0], "CORNER", 2300, 400)
samples = node("NodeGroupInput", 2520, -150)
sample_uv = read("panorama_sample_uv", 2520, -400)
texture = node("GeometryNodeImageTexture", 2740, 0)
texture.interpolation, texture.extension = "Closest", "EXTEND"
links.new(samples.outputs["Distance"], texture.inputs["Image"])
links.new(sample_uv, texture.inputs["Vector"])
depth = node("ShaderNodeSeparateXYZ", 2960, 0)
links.new(texture.outputs["Color"], depth.inputs[0])
blend = node("ShaderNodeMapRange", 3180, 0)
blend.clamp = True
blend.inputs["To Min"].default_value = 1
links.new(inputs.outputs["Depth Scale"], blend.inputs["Value"])
links.new(depth.outputs["X"], blend.inputs["To Max"])
radial = node("ShaderNodeVectorMath", 3400, 0)
radial.operation = "SCALE"
links.new(read("panorama_direction", 3180, -260), radial.inputs[0])
links.new(blend.outputs["Result"], radial.inputs[3])
deform = node("GeometryNodeSetPosition", 3620, 400)
links.new(geometry, deform.inputs["Geometry"])
links.new(radial.outputs[0], deform.inputs["Position"])
channels = node("FunctionNodeSeparateColor", 2960, -430)
channels.mode = "RGB"
links.new(texture.outputs["Color"], channels.inputs["Color"])
invalid = node("FunctionNodeCompare", 3400, -500)
invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN"
links.new(channels.outputs["Alpha"], invalid.inputs["A"])
links.new(inputs.outputs["Validity Threshold"], invalid.inputs["B"])
point_invalid = node("GeometryNodeFieldOnDomain", 3620, -500)
point_invalid.data_type, point_invalid.domain = "BOOLEAN", "POINT"
links.new(invalid.outputs["Result"], point_invalid.inputs["Value"])
delete = node("GeometryNodeDeleteGeometry", 3840, 400)
delete.domain, delete.mode = "FACE", "ALL"
links.new(deform.outputs["Geometry"], delete.inputs["Geometry"])
links.new(point_invalid.outputs[0], delete.inputs["Selection"])
material = node("GeometryNodeSetMaterial", 4060, 400)
links.new(delete.outputs["Geometry"], material.inputs["Geometry"])
links.new(inputs.outputs["Material"], material.inputs["Material"])
output = node("NodeGroupOutput", 4280, 400)
links.new(material.outputs["Geometry"], output.inputs[0])
prepare_node_group(group)
return group

View File

@@ -0,0 +1,93 @@
"""Behavior checks with independent spherical and room geometry references."""
import numpy as np
from geometry import (camera_bases, merge_distances, panorama_directions,
room_reference, sample_panorama, view_rays)
from prototype import fixed_fov_distance
def test_fixed_fov_recovers_shift_and_metric_distance():
rays = view_rays(64)
depth = 2 + 0.3 * rays[..., 0]
points = rays * depth[..., None]
points[..., 2] -= 0.7
distance, mask = fixed_fov_distance(points, np.ones((64, 64)), 1.5)
np.testing.assert_allclose(distance, depth * np.linalg.norm(rays, axis=-1) * 1.5, rtol=1e-5)
assert mask.all()
def test_longitude_sampling_wraps_without_a_seam():
directions = panorama_directions(256, 128)
image = 2 + directions[..., 0]
rays = np.array(((1, 1e-8, 0), (1, -1e-8, 0)))
sampled = sample_panorama(image, rays)
np.testing.assert_allclose(sampled[0], sampled[1], atol=1e-6)
np.testing.assert_allclose(sampled, 3, atol=0.001)
def test_fusion_removes_per_view_scale_offsets():
bases = camera_bases()
scales = np.exp(np.linspace(-0.4, 0.4, 12))
distances = []
for scale, basis in zip(scales, bases):
rays = view_rays(128) @ basis
rays /= np.linalg.norm(rays, axis=-1, keepdims=True)
distances.append((3 + 0.4 * rays[..., 0] + 0.2 * rays[..., 2]) * scale)
merged, mask = merge_distances(distances, np.ones((12, 128, 128), bool), bases, 64, 32)
rays = panorama_directions(64, 32)
expected = 3 + 0.4 * rays[..., 0] + 0.2 * rays[..., 2]
merged *= np.median(expected / merged)
assert mask.all()
assert np.mean(np.abs(merged / expected - 1)) < 0.002
assert np.max(np.abs(merged[:, [0, -1]] / expected[:, [0, -1]] - 1)) < 0.005
def test_room_distance_lands_on_floor():
rays = np.array(((0.0, 0.0, -1.0), (0.2, -0.3, -1.0)))
rays /= np.linalg.norm(rays, axis=-1, keepdims=True)
distance, _ = room_reference(rays)
np.testing.assert_allclose((rays * distance[:, None])[:, 2], -1.6)
def test_sky_view_returns_invalid_samples():
distance, mask = fixed_fov_distance(np.ones((32, 32, 3)), np.zeros((32, 32)), 1)
assert not mask.any()
assert np.isfinite(distance).all()
def test_room_projection_fusion_preserves_geometry():
bases = camera_bases()
distances = [room_reference(view_rays(256) @ basis)[0] for basis in bases]
merged, mask = merge_distances(distances, np.ones((12, 256, 256), bool), bases, 128, 64)
expected, _ = room_reference(panorama_directions(128, 64))
assert mask.all()
assert np.mean(np.abs(merged / expected - 1)) < 0.005
def test_input_alpha_survives_panorama_export(tmp_path, monkeypatch):
from PIL import Image
from prototype import main
rgba = np.full((16, 32, 4), 255, dtype=np.uint8)
rgba[:, :16, 3] = 0
source = tmp_path / "source.png"
destination = tmp_path / "result"
Image.fromarray(rgba).save(source)
monkeypatch.setattr("sys.argv", ["prototype", "--image", str(source), "--output", str(destination),
"--reference-only", "--size", "32", "--width", "32"])
main()
with Image.open(destination / "panorama.png") as exported:
np.testing.assert_array_equal(np.asarray(exported)[..., 3], rgba[..., 3])
with np.load(destination / "prediction.npz") as data:
assert np.all(data["alpha"][:, :16] == 0)
assert np.any(data["alpha"][:, 16:] > 0)
def test_depth_alpha_combines_mask_and_source_alpha_once():
from prototype import depth_alpha
image = np.ones((2, 4, 4), dtype=np.float32)
image[..., 3] = (0, 0.5, 0.75, 1)
mask = np.full((2, 4), 0.5, dtype=np.float32)
np.testing.assert_allclose(depth_alpha(image, mask), np.tile((0, 0.25, 0.375, 0.5), (2, 1)))

View File

@@ -0,0 +1,147 @@
"""Verify saved panorama assets using synthetic validity and depth inputs."""
import json
import bpy
import numpy as np
def socket_id(group, name):
return next(item.identifier for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == name)
def evaluated_mesh(obj):
obj.update_tag()
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
return evaluated, evaluated.to_mesh()
def evaluated_points(obj):
evaluated, mesh = evaluated_mesh(obj)
points = np.empty(len(mesh.vertices) * 3, dtype=np.float32)
mesh.vertices.foreach_get("co", points)
counts = (len(mesh.vertices), len(mesh.edges), len(mesh.polygons))
evaluated.to_mesh_clear()
return points.reshape(-1, 3), counts
def test_image(name, alpha=1):
image = bpy.data.images.new(name, width=16, height=8, alpha=True, float_buffer=True)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
pixels = np.full((8, 16, 4), 3, dtype=np.float32)
pixels[..., 3] = alpha
image.pixels.foreach_set(pixels.ravel())
image.update()
return image
def verify(output):
full, empty, partial = test_image("Full"), test_image("Empty", 0), test_image("Combined Alpha", 0.25)
half = test_image("Half")
pixels = np.full((8, 16, 4), 3, dtype=np.float32)
pixels[..., 3] = 1
pixels[:, :8, 3] = 0
half.pixels.foreach_set(pixels.ravel())
half.update()
results = {}
try:
for obj in list(bpy.data.objects):
if obj.type != "MESH" or not obj.modifiers:
continue
modifier = obj.modifiers[0]
group = modifier.node_group
image_inputs = [item.name for item in group.interface.items_tree if item.item_type == "SOCKET"
and item.in_out == "INPUT" and item.socket_type == "NodeSocketImage"]
assert image_inputs == ["Distance"]
names = ("Subdivide", "Depth Scale", "Validity Threshold", "Distance")
keys = {name: socket_id(group, name) for name in names}
original = {name: modifier[key] for name, key in keys.items()}
def set_inputs(**values):
for name, value in values.items():
modifier[keys[name.replace("_", " ")]] = value
try:
set_inputs(Distance=full, Depth_Scale=0, Validity_Threshold=0.9)
face_counts = []
for level in (0, 2, 4):
set_inputs(Subdivide=level)
evaluated, mesh = evaluated_mesh(obj)
try:
assert len(mesh.polygons) == 6 * 4 ** level
assert all(len(face.vertices) == 4 for face in mesh.polygons)
assert len(mesh.vertices) - len(mesh.edges) + len(mesh.polygons) == 2
use = np.zeros(len(mesh.edges), dtype=int)
for loop in mesh.loops:
use[loop.edge_index] += 1
assert np.all(use == 2)
points = np.array([vertex.co[:] for vertex in mesh.vertices])
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 1, atol=1e-5)
if level >= 2:
uv = mesh.attributes["UVMap"].data
assert all(np.ptp([uv[i].vector.x for i in face.loop_indices]) < 0.51
for face in mesh.polygons), "UV seam crosses the full image"
face_counts.append(len(mesh.polygons))
finally:
evaluated.to_mesh_clear()
sphere, _ = evaluated_points(obj)
set_inputs(Depth_Scale=1)
surface, _ = evaluated_points(obj)
np.testing.assert_allclose(surface, sphere * 3, atol=1e-5)
set_inputs(Depth_Scale=0.5)
middle, _ = evaluated_points(obj)
np.testing.assert_allclose(middle, sphere * 2, atol=1e-5)
set_inputs(Distance=empty)
assert evaluated_points(obj)[1][2] == 0
set_inputs(Distance=half)
retained = evaluated_points(obj)[1][2]
assert 0 < retained < face_counts[-1]
set_inputs(Distance=partial, Validity_Threshold=0.3)
assert evaluated_points(obj)[1][2] == 0, "Mask times alpha must be below threshold"
set_inputs(Validity_Threshold=0.2)
assert evaluated_points(obj)[1][2] == face_counts[-1]
finally:
for name, value in original.items():
modifier[keys[name]] = value
obj.update_tag()
points, counts = evaluated_points(obj)
assert np.isfinite(points).all()
assert counts[2] > 0
evaluated, mesh = evaluated_mesh(obj)
try:
directions = np.empty((counts[0], 3), dtype=np.float32)
uv = np.empty_like(directions)
mesh.attributes["panorama_direction"].data.foreach_get("vector", directions.ravel())
mesh.attributes["panorama_sample_uv"].data.foreach_get("vector", uv.ravel())
expected_u = np.arctan2(directions[:, 1], directions[:, 0]) / (2 * np.pi) % 1
delta_u = np.abs(uv[:, 0] - expected_u)
assert np.max(np.minimum(delta_u, 1 - delta_u)) < 1e-6
np.testing.assert_allclose(uv[:, 1], 1 - np.arccos(np.clip(directions[:, 2], -1, 1)) / np.pi, atol=1e-6)
image = original["Distance"]
width, height = image.size
pixels = np.array(image.pixels[:]).reshape(height, width, 4)
data_path = output / ("reference.npz" if obj.name == "Reference Room" else "prediction.npz")
with np.load(data_path) as data:
np.testing.assert_allclose(pixels[::-1, :, 3], data["alpha"], atol=0.0005)
x = np.clip((uv[:, 0] * width).astype(int), 0, width - 1)
y = np.clip((uv[:, 1] * height).astype(int), 0, height - 1)
radius = 1 + original["Depth Scale"] * (pixels[y, x, 0] - 1)
np.testing.assert_allclose(points, directions * radius[:, None], rtol=2e-6, atol=2e-5)
finally:
evaluated.to_mesh_clear()
results[obj.name] = {"subdivide_levels": [0, 2, 4], "quad_face_counts": face_counts,
"closed_quad_sphere": True, "uv_seam_verified": True,
"depth_scale_verified": True, "mask_alpha_verified": True,
"image_sampling_verified": True,
"single_depth_image_verified": True,
"packed_depth_alpha_verified": True,
"vertices": counts[0], "faces": counts[2]}
assert results
finally:
for image in (full, empty, partial, half):
bpy.data.images.remove(image)
(output / "blender_validation.json").write_text(json.dumps(results, indent=2), encoding="utf-8")
print(json.dumps(results, indent=2), flush=True)

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-09

View File

@@ -0,0 +1,64 @@
## Context
两个节点组共用 `split_depth_surface`,在变形前读取相邻面深度并拆边,随后固定相机坐标、投影和加厚。实验直接复制原资产,只插入单次 Delete Geometry;关闭实验开关时求值的顶点与面与原组完全一致。
实验四边网格结果:山景从 80,449 面删去 21,306 面,蜘蛛图 Depth Cutout 从 37,206 面删去 2,249 面。蜘蛛图三角网格的 74,412 面全部保留。两个实验均为 MoGe-2 Small、level 0、Split Threshold 0.5;面数取决于实验网格,正式测试验证拓扑性质。实验深度采用无损 EXR 打包,保存重开结果一致。
## Goals
在两个 Depth 节点组中共用确定性的单次条带删面逻辑,将 Split 控制放在主输入区 Smooth 前,并保留剩余表面的 UV、独立切口身份和加厚连接。
## Decisions
### 1. 在拆边分支内执行一次删除
```mermaid
flowchart TD
A[当前基础几何及已有预处理] --> B{Split Threshold 大于 0}
B -->|是| C[原有面深度采样与 Split Edges]
C --> D[四边面按顺序读取四条边的 Face Count]
D --> E[任一组对边均为边界边]
E --> F[Delete Geometry:Face / All,单次删除]
F --> G[固定剩余顶点的相机坐标]
B -->|否| G
G --> H[深度投影]
H --> I[现有厚度与 Smooth 流程]
```
共用实现放在 `tools/nodes/common/depth_surface.py`,将删除节点接在 Split Edges 输出与现有启用 Switch 的 True 输入之间。删除选择在该时刻的完整拆后几何上求值一次。
### 2. 按面角顺序识别边界对边
在 FACE 域用 Index → Corners of Face 获取当前面的一个起始角和面角数量。通过 Offset Corner in Face 的偏移 0、1、2、3,分别用 Edges of Corner 的 Next Edge Index 取得同方向的四条边;Evaluate at Index 在 EDGE 域读取 Mesh Edge Neighbors 的 Face Count。
令 `b_i = (FaceCount(edge_i) == 1)`,选择条件为:
```text
corner_count == 4 AND ((b0 AND b2) OR (b1 AND b3))
```
该结果与起始角和面绕序无关。一张面宽条带、三条边界边的条带末端、孤立四边面被选中;具有一条边界边或两条相邻边界边的普通面保留。边界计数严格等于 1,零邻面和非流形边不作为边界边参与匹配。
### 3. 正式接口只有 Split 控制
条带清理随 Split 正值分支固定启用。两个节点组仅有一个 `Split Threshold`,其 parent 为主输入区,顺序紧邻 `Smooth` 之前。维持 `FACTOR`、范围 0–1、默认 0 及现有阈值映射。
移动接口时检查构建顺序、局部 Group Input、修改器赋值与测试中的标识符或顺序依赖。组名保持正式名称;正式接口不引入实验开关。节点使用默认名称、局部连线和未使用输出隐藏规则。
### 4. 按输入拓扑验收
四边面按上述条件处理;三角面和其他多边面通过面角数量门控保留。Depth Cutout 的当前三角化输入仍可使用原 Split,清理阶段自然不选面。保持真实建网流程,不为匹配清理规则增加三角面合并或重拓扑。
### 5. 更新覆盖约束并验证厚度
将现有“Split 保留所有面”的测试改为“保留所有未命中条带条件的面”,并保留独立的纯拆边与零值旁路检查。厚度依据删面后几何建立边界;正厚度测试检查剩余区域闭合、切口区域独立和属性生命周期。
## Risks / Trade-offs
- 规则识别一张面宽的拓扑结构;真实窄结构、已被原 Split 过度拆分的远山也可能命中。不能据此承诺语义上的前景或远山完整保留。
- 两张面宽的条带及三角条带不受本规则处理,剩余边界仍受原网格分辨率限制。
- 单次求值避免清理不断向完整区域推进。首次删除后新暴露的候选面留待后续用户改变输入时重新求值,不能在同一次求值中反复删除。
## Delivery
本提案完成后实施源码与测试,保留工作区其他修改,执行全量测试并确认正常退出。依照项目规范,此实施任务不构建文档或产物。正式节点资产重建发布需要单独安排;源码实施完成不代表用户安装的节点资产已更新。

View File

@@ -0,0 +1,27 @@
## Why
Depth Plane 与 Depth Cutout 的 Split Edges 会在深度过渡区域产生一张面宽的条带和孤立四边面。拆边后检查四边面的两组对边,可以识别这些结构,同时保留只有相邻两条边为边界的普通表面角落。独立节点组实验已验证该判定及单次删除行为。
## What Changes
- `O Image Depth Plane` 与 `O Image Depth Cutout` 在启用 Split 的分支中,拆边后默认执行一次条带删面。
- 仅选择至少一组对边均为边界边的四边面;边界边的 Face Count 必须等于 1。
- 正式接口不增加实验中的 `Remove Strip Faces` 输入。`Split Threshold = 0` 时旁路拆边和条带删面。
- 两组的 `Split Threshold` 从 `Options` 移到主输入区,紧邻并位于 `Smooth` 前;名称、默认值、范围和阈值映射保持现状。
- 共用节点构建逻辑,并更新针对面数保留、接口顺序和厚度连接的行为测试。
## Capabilities
### New Capabilities
- `depth-surface-strip-cleanup`:拆边后默认进行单次四边面对边界边筛选删面,以及两个节点组的 Split 输入位置。
### Modified Capabilities
无。当前 `openspec/specs/` 为空;本提案依据当前源码及实验结果定义增量行为。
## Impact
涉及 `tools/nodes/common/depth_surface.py`、两个 Depth 节点组构建模块和对应测试。运行时继续加载正式命名的节点资产。实现阶段只修改源码与测试并执行测试;正式 `.blend` 的重建发布另行安排。
三角面及其他非四边面保持原行为。当前 Cutout 建网流程产生三角面,因此本次规则对这类输入不会删面;四边网格输入的实验效果不能作为三角网格的验收结果。条带判定依据拓扑,已被拆成条带的真实物体细节也可能被删除。

View File

@@ -0,0 +1,69 @@
## ADDED Requirements
### Requirement: 拆边后默认执行单次条带清理
`O Image Depth Plane` 与 `O Image Depth Cutout` SHALL 在 Split 正值分支内、Split Edges 之后且投影和厚度生成之前,通过几何节点执行一次 Face / All 删除。两个节点组 SHALL 共用该逻辑,正式接口 SHALL NOT 暴露 `Remove Strip Faces` 开关。
#### Scenario: 开启 Split
- **WHEN** Split Threshold 大于 0
- **THEN** 系统先按当前规则拆边,再根据拆后拓扑执行一次条带删面
#### Scenario: 关闭 Split
- **WHEN** Split Threshold 为 0
- **THEN** 系统旁路拆边及新增条带删面,保持当前零值分支的几何与投影行为
#### Scenario: 单次删除
- **WHEN** 首次删除使另一张面新出现一组边界对边
- **THEN** 该面不会因新边界在本次求值中被继续删除
### Requirement: 四边面边界对边判定
系统 SHALL 仅选择四边面,并按面角顺序取得四条边。若至少一组对边的 Face Count 均等于 1,该面 SHALL 被选中;其余面 SHALL 被保留。
#### Scenario: 条带及孤立四边面
- **WHEN** 四边面具有两条相对的边界边,或具有三条、四条边界边
- **THEN** 该面被删除
#### Scenario: 普通边缘及角落
- **WHEN** 四边面没有边界边、只有一条边界边,或仅有两条相邻边界边
- **THEN** 该面保留
#### Scenario: 面角起点与绕序
- **WHEN** 同一四边面的面角起点改变或绕序反转
- **THEN** 选择结果不变
#### Scenario: 非四边面
- **WHEN** 输入面为三角面或其他非四边面
- **THEN** 新增清理规则不删除该面
#### Scenario: 非流形边
- **WHEN** 一条边的 Face Count 不等于 1
- **THEN** 该边不能作为匹配条件中的边界边
### Requirement: 清理结果接入原投影和厚度流程
系统 SHALL 在剩余几何上固定相机坐标并执行现有投影及加厚,保留 UV 和用户属性,保持切口区域独立。清理选择 SHALL 只依据拆后拓扑,不依赖 Thickness 或后续 Smooth。
#### Scenario: 剩余区域加厚
- **WHEN** 清理后的定向正确的流形片状区域设置正厚度
- **THEN** 每个保留区域的正背面与侧壁保持完整连接,区域之间保持独立
#### Scenario: 改变显示形状参数
- **WHEN** 基础几何、深度输入与 Split Threshold 不变,仅修改 Thickness 或 Smooth
- **THEN** 被条带清理选中的基础面集合不变
#### Scenario: 属性与空结果
- **WHEN** 清理保留部分面或删除全部面
- **THEN** 求值正常完成且坐标有限;有剩余几何时 UV 与用户属性可用,临时属性依当前流程清理
### Requirement: Split Threshold 位于 Smooth 前
两个节点组 SHALL 将唯一的 `Split Threshold` 输入放在主输入区,紧邻并位于 `Smooth` 之前。该输入 SHALL 保持原名称、FACTOR 类型、0–1 范围、默认 0 和当前阈值映射。
#### Scenario: 检查接口
- **WHEN** 检查任一节点组或其修改器输入
- **THEN** Split Threshold 位于 Smooth 前且不属于 Options,Options 内没有重复输入,接口没有 Remove Strip Faces
#### Scenario: 参数读取
- **WHEN** Operator 为修改器赋值或用户调整 Split Threshold
- **THEN** 新位置的输入正常控制原拆边强度及默认条带清理

View File

@@ -0,0 +1,16 @@
## 1. 共用条带判定
- [x] 1.1 在 `tools/nodes/common/depth_surface.py` 实现四边面对边界边判定,在 Split Edges 后、启用分支 Switch 前接入单次 Face / All 删除;正式逻辑固定启用,无额外开关。
- [x] 1.2 验证孤立四边面、一面宽条带、普通边缘与角落、面角起点和绕序、三角面及其他多边面、非流形边;加入首次删除后出现新候选面的用例以确认仅执行一遍。
## 2. 两组接口与行为接入
- [x] 2.1 将 `O Image Depth Plane` 和 `O Image Depth Cutout` 的 Split Threshold 移到主输入区 Smooth 紧前,保留输入语义,整理局部节点布局。
- [x] 2.2 更新接口顺序、Options 归属、唯一输入、无实验开关及相关修改器赋值测试。
- [x] 2.3 更新原有面数保留断言,分别验证两组的四边面清理、三角面保留和 Split 为零时原行为;确认原梯度选择与比例映射不变。
## 3. 几何与集成验证
- [x] 3.1 验证剩余表面的 UV、用户属性、临时属性清理、空结果、正厚度闭合以及 Smooth 后区域独立;确认厚度与 Smooth 不改变基础删面集合。
- [x] 3.2 使用真实 Blender 求值验证两组节点;分别记录四边网格与当前 Cutout 三角网格的结果,不能用四边实验替代正式三角输入验收。
- [x] 3.3 运行 `uv run pytest` 全量测试并确认正常退出,检查最终差异和旧接口断言,保留其他已有修改;不构建文档或产物,并在交付说明中明确正式节点资产尚待另行重建发布。验证结果:940 passed、2 xfailed,退出码 0。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-10

View File

@@ -0,0 +1,44 @@
## Context
参见 proposal.md。现有节点入口依次调用 Blender 构建、资产校验及 pytest,预览模块已有 HTML 导出能力。模型入口统一准备模型并按选项上传,包含现成文件下载、普通 ONNX 转换及 PiSA-SR 独立环境转换。
## Goals
命令入口负责参数解析与流程调度,业务模块负责产物;复用现有 Blender 启动、模型准备与校验实现。
## Decisions
- 在现有两个 cli.py 使用 argparse 子命令,脚本入口为 `node-group` 和 `model`。Python 包继续使用表示业务集合的 `tools/nodes`、`tools/models`。
- `node-group build` 保持构建、资产校验、全量 pytest 的顺序;`check` 复用后两步。两者支持 `--blender` 和现有 `--skip-tests`,后者只跳过 pytest。任一步失败立即以非零状态退出。
- `preview` 复用 Blender 启动流程及现有预览导出器,默认写入系统临时目录的独立 HTML,输出绝对路径,再由父进程打开浏览器。支持 `--blender`、`--output PATH`、`--no-open`、`--fragment`;fragment 模式仅输出片段且不自动打开。文件保留供查看,资产文件保持原样。
- 模型子命令为 `prepare` 和 `sync`。`prepare` 替代 `--prepare-only`,统一下载现成 ONNX 或准备源权重并导出,包含 PiSA-SR;按既有目录输出,复用已校验的缓存及独立转换环境。`sync` 复用全部准备成功后上传的流程。
- PiSA-SR 在模型目录下的临时目录导出整组文件,全部通过目录清单校验后再更新正式产物,临时目录随流程退出清理。
- 使用延迟导入,使帮助与参数错误可以在未加载 Blender、Torch 和导出环境时返回。无动作时显示帮助并成功退出,非法动作或参数非零退出。
```mermaid
flowchart TD
A[解析命令] --> B[node-group]
A --> C[model]
B --> D[build 构建资产]
D --> E[校验资产]
B --> F[check]
F --> E
E --> G[pytest]
B --> H[preview 内存构建与排列]
H --> I[HTML 预览]
C --> J[prepare 下载或导出并校验全部模型]
C --> K[sync]
K --> J
J --> L{动作是 sync}
L -->|是| M[上传 R2]
```
## Risks / Trade-offs
- CLI 改名影响本地脚本调用 → 删除旧入口并验证新的安装脚本映射,实施时报告命令迁移。
- Blender 参数与预览参数共用进程命令行 → 显式传递业务参数,测试参数隔离与失败传播。
- 模型导出成本较高 → 保留大小及 SHA-256 校验后的缓存复用;自动测试使用隔离文件和替代执行器验证路由。
## Migration Plan
更新入口及子命令后执行 `uv sync` 刷新本地脚本,运行相关测试、帮助命令及节点构建链验证。保留工作区既有修改。

View File

@@ -0,0 +1,24 @@
## Why
节点组和模型工具需要统一为「对象 + 动作」命令,让构建、检查、预览和模型准备的职责直观可见,并能独立执行。
## What Changes
- **BREAKING**:将 `build-nodes` 替换为 `node-group build`,将 `sync-models` 替换为 `model sync`,删除旧入口。
- 提供 `node-group check`,检查已有资产并运行测试;提供 `node-group preview`,生成当前源码的 HTML 预览并打开浏览器。
- 提供 `model prepare`,按模型来源下载现成文件或准备源权重并导出,统一校验全部模型。
- 使用统一的小写子命令;未提供动作时显示帮助。
## Capabilities
### New Capabilities
- `developer-tool-cli`:节点组和模型命令的路由、执行边界、参数及失败行为。
### Modified Capabilities
无。
## Impact
涉及 `pyproject.toml` 的脚本入口、`tools/nodes`、`tools/models` 和相关命令测试。使用现有 argparse、Blender 与模型转换依赖。实施范围为命令代码与测试;本次产出为 OpenSpec 规划文件。

View File

@@ -0,0 +1,81 @@
## Purpose
为 AnyImage 开发者提供按业务对象组织的命令接口,明确节点组构建、检查与预览,以及模型准备与同步的执行边界,将模型下载和导出统一归入准备流程,让每个动作的产物、失败状态和副作用均可验证。
## ADDED Requirements
### Requirement: Grouped command entry points
系统 SHALL 提供 `uv run node-group` 和 `uv run model` 入口,使用小写子命令,并移除旧脚本入口 `build-nodes`、`sync-models`。
#### Scenario: Display help
- **WHEN** 用户未指定子命令或传入 `--help`
- **THEN** 命令显示帮助并成功退出,不启动 Blender、模型准备或上传
#### Scenario: Invalid arguments
- **WHEN** 用户传入未知子命令或参数
- **THEN** 命令显示参数错误并非零退出,业务动作不执行
### Requirement: Build and check node groups
`node-group build` SHALL 依次构建资产、校验保存的资产、运行全量 pytest;`node-group check` SHALL 仅执行后两步。两者 SHALL 支持 `--blender` 选择 Blender,支持 `--skip-tests` 仅跳过 pytest。
#### Scenario: Build assets
- **WHEN** 用户运行 `uv run node-group build`
- **THEN** 更新 O_AnyImage.blend,并在资产校验和全量测试成功后成功退出
#### Scenario: Check existing assets
- **WHEN** 用户运行 `uv run node-group check`
- **THEN** 检查已有资产并运行全量测试,资产文件内容保持原样
#### Scenario: Skip Python tests
- **WHEN** build 或 check 指定 `--skip-tests`
- **THEN** 仍执行资产校验,跳过 pytest
#### Scenario: Stop after failure
- **WHEN** 构建、校验或测试任一步失败
- **THEN** 命令非零退出且不执行后续步骤
### Requirement: Preview node groups
`node-group preview` SHALL 从当前源码在内存中构建并排列几何节点组,生成 HTML,输出绝对路径并默认打开浏览器,保持资产文件内容原样。默认文件 SHALL 保存于系统临时目录并在命令退出后可供查看。命令 SHALL 支持 `--blender`、`--output PATH`、`--no-open` 和 `--fragment`。
#### Scenario: Preview current source
- **WHEN** 用户运行 `uv run node-group preview`
- **THEN** 生成当前源码的可查看 HTML 并尝试打开浏览器
#### Scenario: Save without opening
- **WHEN** 用户指定 `--output PATH --no-open`
- **THEN** HTML 写入指定路径且不启动浏览器
#### Scenario: Export fragment
- **WHEN** 用户指定 `--fragment`
- **THEN** 输出 HTML 片段及其路径,不自动打开浏览器
### Requirement: Prepare and sync models
模型命令 SHALL 提供 `prepare` 和 `sync` 两个子命令。`model prepare` SHALL 准备并校验目录中全部模型,复用有效缓存,按模型来源下载现成 ONNX 或准备源权重并导出(包含 PiSA-SR);`model sync` SHALL 在全部准备成功后上传 R2。`prepare` SHALL 不上传且不要求上传工具可用。
#### Scenario: Prepare locally
- **WHEN** 用户运行 `uv run model prepare`
- **THEN** 全部模型在本地通过既有大小和 SHA-256 校验,不执行上传
#### Scenario: Sync prepared models
- **WHEN** 用户运行 `uv run model sync` 且全部准备成功
- **THEN** 上传全部模型到既定 R2 位置
#### Scenario: Preparation fails
- **WHEN** 任一模型准备或校验失败
- **THEN** 命令非零退出,sync 不开始上传
#### Scenario: Download and export models
- **WHEN** 用户运行 `uv run model prepare` 且本地缺少有效模型
- **THEN** 根据模型来源下载现成文件或准备源权重并导出,产物保存到既有本地目标并通过校验
#### Scenario: Reuse valid cache
- **WHEN** 本地模型已通过大小和 SHA-256 校验
- **THEN** prepare 复用该产物,跳过对应下载和导出
#### Scenario: Export fails
- **WHEN** 源权重准备、转换或产物校验失败
- **THEN** 命令非零退出,失败产物不替换正式目标

View File

@@ -0,0 +1,19 @@
## 1. 命令入口
- [x] 1.1 将脚本入口替换为 `node-group`、`model`,增加子命令解析并删除旧入口及 `--prepare-only`;更新入口测试,验证帮助、无动作、非法参数均不执行业务操作。
## 2. 节点组命令
- [x] 2.1 实现 build 与 check 的公共 Blender 调度,支持 `--blender`、`--skip-tests`;通过命令测试验证顺序、check 不构建和失败即停止。
- [x] 2.2 接入 preview,支持默认临时 HTML、`--output`、`--no-open`、`--fragment`,由父进程打开浏览器;验证参数传递、输出内容、片段模式和资产内容保持原样。
## 3. 模型命令
- [x] 3.1 实现 prepare 与 sync 两个模型子命令,prepare 统一下载和导出,sync 复用准备后上传逻辑;通过隔离测试验证缓存命中、下载或转换选择、校验失败阻断上传,以及 prepare 不依赖上传工具。
- [x] 3.2 验证 prepare 覆盖现成模型下载、普通模型转换与 PiSA-SR 导出,测试源权重准备、产物校验、失败临时文件清理和正式目标保护。
## 4. 集成验证
- [x] 4.1 执行 `uv sync` 后检查两组命令及全部子命令的 `--help`,运行相关工具测试,确认安装入口与源码一致。
- [x] 4.2 执行 `uv run node-group build` 验证真实 Blender 构建、资产校验和全量 pytest 正常退出;运行 `node-group check` 和 `node-group preview --no-open`,校验资产哈希保持原样及预览文件可查看。
- [x] 4.3 检查代码与测试中的旧入口引用、最终差异和测试结果,确认保留工作区既有修改,并简要报告命令迁移及验证结果。

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-09

View File

@@ -0,0 +1,36 @@
## Context
本地具备 MoGe2 ONNX 模型和 Blender 4.5。试验以独立脚本运行,使用合成房间的解析距离作为几何真值。
## Goals / Non-Goals
目标是验证完整全景的距离融合、几何节点径向变形与接缝。试验范围限定为 `experiments/panorama_surface` 和本变更目录。
## Decisions
```mermaid
flowchart TD
A[解析房间或用户全景] --> B[12 个重叠的 90 度视图]
B --> C[MoGe2 ONNX 与固定视角后处理]
C --> D[周期边界的对数距离融合]
D --> E[浮点距离及有效性]
E --> F[独立 Blender 节点资产]
F --> G[Quad Sphere 细分与径向定位]
G --> I[Mask 与 Alpha 剔除]
I --> H[重新加载与数值验证]
```
- 融合参考官方梯度域算法,使用 SciPy 稀疏求解;通过全局对数距离偏移恢复输入预测的整体尺度。
- Quad Sphere 从用户指定的 O_Essentials_GeometryNodes.blend1 离线载入并打包进试验文件,Segments 为 2^Subdivide + 1。
- 球面方向生成采样 UV;材质 UV 在面角域处理经度接缝及极点。
- 深度导出将 Mask × 源图 Alpha 写入 depth.exr 的 Alpha;几何节点只读取深度图片。
- Depth Scale 在单位球与预测距离间插值;深度 EXR 的 Alpha 低于阈值时,通过点域布尔字段删除面。
- 真实照片先使用 BEN2 去除天空,透明区域在视图几何恢复及融合阶段排除;最后按源图 Alpha 写入深度有效性。
- 节点组通过独立离线脚本构建到试验 `.blend`;验证阶段重新加载该文件。
- 合成真值与 AI 输出分别检查,区分投影实现误差和模型预测误差。
## Risks / Trade-offs
- 合成图与真实照片分布不同 → AI 结果只作为流程验证,几何正确性以解析真值验证。
- 梯度融合没有绝对尺度约束 → 输出明确采用预测尺度锚定,记录相对误差。
- 遮挡处连接可能产生长面 → 通过真实照片试验观察可见表面连接质量。

View File

@@ -0,0 +1,21 @@
## Why
验证 MoGe 多视图距离融合与 Blender 几何节点能否组成全景表面生成流程,为后续产品方案提供实测依据。
## What Changes
- 在 `experiments/panorama_surface` 实现独立试验,使用可计算真实距离的合成全景验证投影、融合与网格。
- 使用本地 MoGe2 ONNX 模型完成一次多视图推理,输出距离图和独立 Blender 示例。
- 检查可调细分 Quad Sphere、全景 UV、Mask 与 Alpha 剔除及 Blender 重新加载后的求值。
## Capabilities
### New Capabilities
- `panorama-surface-prototype`: 独立的全景距离融合和几何节点验证。
### Modified Capabilities
## Impact
新增试验目录和本提案目录。试验复用当前 Python、NumPy、SciPy、ONNX Runtime 与 Blender 环境,生产源码、依赖及原节点资产保持原状。

View File

@@ -0,0 +1,33 @@
## ADDED Requirements
### Requirement: Isolated panorama experiment
试验 SHALL 将代码和输出保存在独立试验目录,使用已有模型及运行环境。
#### Scenario: Run prototype
- **WHEN** 执行全景原型
- **THEN** 生成距离数据、独立 Blender 文件与验证结果,原有源码及节点资产保持原状
### Requirement: Periodic distance reconstruction
试验 SHALL 对完整全景使用周期经度映射、多视图距离融合及已知 90 度相机视角。
#### Scenario: Synthetic reference
- **WHEN** 使用解析球面或房间距离验证
- **THEN** 投影与融合产生有限正距离,并记录与真值的误差
### Requirement: Geometry node surface
试验 SHALL 使用可调 Subdivide 的 Quad Sphere,按全景距离沿球面射线定位顶点。Depth Scale 为 0 时保持单位球,1 时恢复预测距离。深度导出 SHALL 将 Mask × 源图 Alpha 写入 depth.exr 的 Alpha;几何节点 SHALL 只接收一张深度图片,并按该 Alpha 与 Validity Threshold 的比较结果剔除无效面。
#### Scenario: Reload Blender asset
- **WHEN** 在新的 Blender 进程加载试验文件
- **THEN** 全有效输入下网格闭合且仅有四边面,面数为 6 × 4^Subdivide,Depth Scale 0 与 1 分别得到单位球和预测表面
#### Scenario: Invalid image samples
- **WHEN** 输入包含无效 Mask 或透明图片 Alpha
- **THEN** 按 O Image Depth Plane 的点域布尔选择与面域删除方式剔除不合格区域
#### Scenario: Sky removal before geometry prediction
- **WHEN** 生成真实全景照片示例
- **THEN** 先去除天空背景,使用处理后的透明全景进行几何预测与深度导出

View File

@@ -0,0 +1,22 @@
## 1. 距离原型
- [x] 1.1 实现解析全景、12 视图投影与固定视角 MoGe2 推理
- [x] 1.2 实现周期对数距离融合并用解析真值测试
## 2. Blender 验证
- [x] 2.1 构建独立节点示例,提供距离变形、比例和断层控制
- [x] 2.2 在独立 Blender 进程重新加载并验证拓扑、位置与比例
- [x] 2.3 运行本地模型,检查结果并交付示例与验证数据
## 3. Quad Sphere 调整
- [x] 3.1 复用参考 Quad Sphere,支持动态细分与全景 UV
- [x] 3.2 使用 Depth Scale 控制球面变形,按 Mask 与 Alpha 剔除无效面
- [x] 3.3 验证细分面数、全四边面、UV、Alpha 与 Mask 并更新两个试验文件
## 4. 深度 Alpha 数据约定
- [x] 4.1 将 Mask × 源图 Alpha 写入 depth.exr,几何节点仅保留深度图片输入
- [x] 4.2 真实照片先去天空,再重新推理、融合并生成示例
- [x] 4.3 验证单图片输入、Alpha 乘积及天空剔除

View File

@@ -27,8 +27,8 @@ dev = [
[project.scripts] [project.scripts]
pack = "tools.pack:main" pack = "tools.pack:main"
build_nodes = "tools.nodes.cli:main" node-group = "tools.nodes.cli:main"
sync_models = "tools.models.cli:main" model = "tools.models.cli:main"
docs = "tools.docs:main" docs = "tools.docs:main"
[build-system] [build-system]

Binary file not shown.

View File

@@ -422,16 +422,17 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
assert np.ptp(positions[:, 2]) < 1e-4 assert np.ptp(positions[:, 2]) < 1e-4
def test_depth_surface_split_preserves_original_faces(): def test_depth_surface_split_removes_isolated_triangle_without_loose_geometry():
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
obj, modifier = _depth_surface_step_object() obj, modifier = _depth_surface_step_object()
split_id = object_data.modifier_input_identifier(modifier.node_group, "Split Threshold") split_id = object_data.modifier_input_identifier(modifier.node_group, "Split Threshold")
for value in (0., .5, 1.): # The raised corner becomes an isolated triangle after separation.
for value, expected in ((0., (9, 8)), (.5, (8, 7)), (1., (8, 7))):
object_data.set_modifier_input(modifier, split_id, value) object_data.set_modifier_input(modifier, split_id, value)
obj.update_tag(refresh={"DATA"}) obj.update_tag(refresh={"DATA"})
bpy.context.view_layer.update() bpy.context.view_layer.update()
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj) vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
assert vertices >= 9 and faces == 8 assert (vertices, faces) == expected
assert not loose_edges and not loose_points assert not loose_edges and not loose_points

View File

@@ -3,6 +3,97 @@ import importlib.util
import sys import sys
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
from dataclasses import replace
from pathlib import Path
import pytest
@pytest.mark.parametrize("command", ["prepare", "sync"])
def test_model_commands_prepare_all_before_upload(command):
module = load_sync_models()
events = []
with (
patch("sys.argv", ["model", command]),
patch.object(module, "prepare_model", side_effect=lambda model: events.append(model)),
patch.object(module, "upload_models", side_effect=lambda: events.append("upload")),
):
module.main()
assert events == [*module.MODEL_FILES, *(["upload"] if command == "sync" else [])]
def test_prepare_failure_prevents_upload():
module = load_sync_models()
with (
patch("sys.argv", ["model", "sync"]),
patch.object(module, "prepare_model", side_effect=RuntimeError("invalid model")),
patch.object(module, "upload_models") as upload,
):
with pytest.raises(RuntimeError, match="invalid model"):
module.main()
upload.assert_not_called()
def test_valid_model_cache_skips_all_preparation():
module = load_sync_models()
with (
patch.object(module, "verify_file", return_value=True),
patch.object(module, "build_model") as build,
patch.object(module, "download_file") as download,
patch.object(module.subprocess, "run") as run,
):
for model in module.MODEL_FILES:
module.prepare_model(model)
build.assert_not_called()
download.assert_not_called()
run.assert_not_called()
@pytest.mark.parametrize("valid", [True, False])
def test_pisa_export_validates_all_outputs_before_replacing(tmp_path, valid):
module = load_sync_models()
digest = module.hashlib.sha256(b"new").hexdigest()
models = tuple(module.ModelFile("PISA_SR", "pisa-sr", name, 3, digest) for name in ("a.onnx", "b.onnx"))
def export(command, **kwargs):
destination = Path(command[-1])
(destination / "a.onnx").write_bytes(b"new")
(destination / "b.onnx").write_bytes(b"new" if valid else b"bad")
with patch.object(module, "MODELS_DIR", tmp_path), patch.object(module, "MODEL_FILES", models), patch.object(module.subprocess, "run", side_effect=export):
models[1].destination.parent.mkdir()
models[1].destination.write_bytes(b"old")
if valid:
module.prepare_model(models[0])
assert all(model.destination.read_bytes() == b"new" for model in models)
else:
with pytest.raises(RuntimeError, match="SHA-256"):
module.prepare_model(models[0])
assert not models[0].destination.exists()
assert models[1].destination.read_bytes() == b"old"
assert not list(tmp_path.glob(".pisa-sr-*"))
def test_local_export_failure_cleans_temporary_output(tmp_path):
module = load_sync_models()
model = replace(next(model for model in module.MODEL_FILES if model.build), folder="test")
def export(exporter, source, destination):
destination.write_bytes(b"partial")
raise RuntimeError("export failed")
with (
patch.object(module, "MODELS_DIR", tmp_path),
patch.object(module, "PROJECT_ROOT", tmp_path),
patch.object(module, "prepare_source", return_value=tmp_path / "weights.pth"),
patch("tools.models.exports.export_model", side_effect=export),
):
model.destination.parent.mkdir()
model.destination.write_bytes(b"original")
with pytest.raises(RuntimeError, match="export failed"):
module.build_model(model)
assert model.destination.read_bytes() == b"original"
assert not model.destination.with_name(model.filename + ".part").exists()
def load_sync_models(): def load_sync_models():

View File

@@ -77,7 +77,7 @@ def test_launcher_runs_asset_build_and_validations_before_tests(monkeypatch, tmp
calls = [] calls = []
monkeypatch.setattr(launcher, "blender_path", lambda _: executable) monkeypatch.setattr(launcher, "blender_path", lambda _: executable)
monkeypatch.setattr(launcher, "run", calls.append) monkeypatch.setattr(launcher, "run", calls.append)
monkeypatch.setattr("sys.argv", ["build_nodes"]) monkeypatch.setattr("sys.argv", ["node-group", "build"])
launcher.main() launcher.main()
@@ -86,3 +86,53 @@ def test_launcher_runs_asset_build_and_validations_before_tests(monkeypatch, tmp
assert "runpy.run_module('tools.nodes.check', run_name='__main__')" in calls[1][-1] assert "runpy.run_module('tools.nodes.check', run_name='__main__')" in calls[1][-1]
assert all(command[1:6] == ("--factory-startup", "--background", "--python-exit-code", "1", "--python-expr") for command in calls[:-1]) assert all(command[1:6] == ("--factory-startup", "--background", "--python-exit-code", "1", "--python-expr") for command in calls[:-1])
assert calls[-1][1:] == ("-m", "pytest") assert calls[-1][1:] == ("-m", "pytest")
@pytest.mark.parametrize("skip_tests", [False, True])
def test_check_validates_without_building(monkeypatch, skip_tests):
launcher = load_launcher()
calls = []
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
monkeypatch.setattr(launcher, "run", calls.append)
monkeypatch.setattr("sys.argv", ["node-group", "check", *(["--skip-tests"] if skip_tests else [])])
launcher.main()
assert len(calls) == (1 if skip_tests else 2)
assert "tools.nodes.check" in calls[0][-1]
assert "tools.nodes.build" not in calls[0][-1]
def test_build_stops_after_blender_failure(monkeypatch):
launcher = load_launcher()
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
monkeypatch.setattr("sys.argv", ["node-group", "build"])
with patch.object(launcher, "run", side_effect=RuntimeError("failed")) as run:
with pytest.raises(RuntimeError, match="failed"):
launcher.main()
assert run.call_count == 1
@pytest.mark.parametrize("arguments,opens", [([], True), (["--no-open"], False), (["--fragment"], False)])
def test_preview_passes_isolated_arguments_and_opens_result(monkeypatch, tmp_path, arguments, opens):
launcher = load_launcher()
output = tmp_path / "node groups.html"
calls = []
monkeypatch.setattr(launcher, "blender_path", lambda _: "blender")
monkeypatch.setattr(launcher, "run", calls.append)
monkeypatch.setattr("sys.argv", ["node-group", "preview", "--output", str(output), *arguments])
with patch.object(launcher.webbrowser, "open") as open_browser:
launcher.main()
assert len(calls) == 1
expression = calls[0][-1]
import ast
assignment = next(
node for node in ast.parse(expression).body
if isinstance(node, ast.Assign) and ast.unparse(node.targets[0]) == "sys.argv"
)
assert ast.literal_eval(assignment.value) == [
"tools.nodes.preview.export", "--output", str(output),
*(["--fragment"] if "--fragment" in arguments else []),
]
if opens:
open_browser.assert_called_once_with(output.as_uri())
else:
open_browser.assert_not_called()

View File

@@ -5,6 +5,7 @@ import bpy
import numpy as np import numpy as np
import pytest import pytest
from tests.support.nodes import CUTOUT_BUILDERS from tests.support.nodes import CUTOUT_BUILDERS
from tools.nodes.arrangement.flow import origin_socket
from anyimage.operators.cutout_tool.object import load_cutout_node_group from anyimage.operators.cutout_tool.object import load_cutout_node_group
@@ -43,7 +44,7 @@ def test_cutout_interfaces_and_thickness_modes(source):
options = [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"] options = [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"]
expected = ["Cleanup Threshold"] expected = ["Cleanup Threshold"]
if group == groups["DEPTH_SOLID"]: if group == groups["DEPTH_SOLID"]:
expected += ["Split Threshold", "Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"] expected += ["Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth"]
elif group == groups["DEPTH_BALLOON"]: elif group == groups["DEPTH_BALLOON"]:
expected += ["Reference Depth"] expected += ["Reference Depth"]
expected += ["Smooth Weight"] expected += ["Smooth Weight"]
@@ -52,7 +53,10 @@ def test_cutout_interfaces_and_thickness_modes(source):
assert options == expected, (group.name, options) assert options == expected, (group.name, options)
assert inputs["Smooth"].socket_type == "NodeSocketInt" and inputs["Smooth"].default_value == 0 assert inputs["Smooth"].socket_type == "NodeSocketInt" and inputs["Smooth"].default_value == 0
assert inputs["Smooth Weight"].default_value == 1 assert inputs["Smooth Weight"].default_value == 1
incoming = [link for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"] incoming = [link for link in group.links
if link.to_node.bl_idname != "NodeReroute"
and origin_socket(link.from_socket).node.bl_idname == "NodeGroupInput"
and origin_socket(link.from_socket).name == "Geometry"]
assert len(incoming) == 1 and incoming[0].to_node.bl_idname == "GeometryNodeDeleteGeometry" assert len(incoming) == 1 and incoming[0].to_node.bl_idname == "GeometryNodeDeleteGeometry"
mesh = bpy.data.meshes.new("Profile") mesh = bpy.data.meshes.new("Profile")
mesh.from_pydata([(-1,-1,0), (1,-1,0), (1,1,0), (-1,1,0), (0,0,0)], [], [(0,1,4), (1,2,4), (2,3,4), (3,0,4)]) mesh.from_pydata([(-1,-1,0), (1,-1,0), (1,1,0), (-1,1,0), (0,0,0)], [], [(0,1,4), (1,2,4), (2,3,4), (3,0,4)])

View File

@@ -188,13 +188,15 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma
def test_surface_interface_and_node_structure(surface): def test_surface_interface_and_node_structure(surface):
obj, _, _, inputs, _ = surface obj, _, _, inputs, _ = surface
assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Smooth", "Valid Only", expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only",
"Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Split Threshold", "Uniform Scale", "Depth Image"] "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Uniform Scale", "Depth Image"]
assert list(inputs) == expected
assert inputs["Split Threshold"].default_value == 0 assert inputs["Split Threshold"].default_value == 0
assert inputs["Split Threshold"].min_value == 0 assert inputs["Split Threshold"].min_value == 0
assert inputs["Split Threshold"].max_value == 1 assert inputs["Split Threshold"].max_value == 1
assert inputs["Split Threshold"].subtype == "FACTOR" assert inputs["Split Threshold"].subtype == "FACTOR"
assert inputs["Split Threshold"].parent.name == "Options" 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.9)
assert inputs["Validity Threshold"].parent.name == "Options" assert inputs["Validity Threshold"].parent.name == "Options"
assert inputs["Validity Threshold"].subtype == "FACTOR" assert inputs["Validity Threshold"].subtype == "FACTOR"
@@ -341,7 +343,7 @@ def test_plane_interface_panels(source, kind):
assert [item.name for item in panels] == ["Options", "Data"] assert [item.name for item in panels] == ["Options", "Data"]
controls = ["Reference Depth"] controls = ["Reference Depth"]
if kind == "DEPTH": if kind == "DEPTH":
controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold", "Split Threshold"] controls += ["Normal Smooth", "Smooth Weight", "Validity Threshold"]
assert [item.name for item in panels[0].interface_items] == controls assert [item.name for item in panels[0].interface_items] == controls
assert [item.name for item in panels[1].interface_items] == ["Uniform Scale", "Depth Image"] assert [item.name for item in panels[1].interface_items] == ["Uniform Scale", "Depth Image"]
assert all(panel.default_closed for panel in panels) assert all(panel.default_closed for panel in panels)

View File

@@ -0,0 +1,222 @@
"""Evaluate opposite-boundary cleanup on explicit mesh topology."""
import bpy
import numpy as np
import pytest
from tools.nodes.common.depth_surface import _remove_strip_faces
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
from tools.nodes.groups.image_plane import build_image_plane_group
def cleaned_faces(vertices, faces, *, triangles=False):
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = bpy.data.meshes.new("Topology")
mesh.from_pydata(vertices, [], faces)
ids = mesh.attributes.new("source_face", "INT", "FACE")
ids.data.foreach_set("value", np.arange(len(faces), dtype=np.int32))
uv = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
uv.data[loop.index].uv = mesh.vertices[loop.vertex_index].co[:2]
obj = bpy.data.objects.new("Topology", mesh)
bpy.context.collection.objects.link(obj)
group = bpy.data.node_groups.new("Strip cleanup test", "GeometryNodeTree")
group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
source = group.nodes.new("NodeGroupInput")
result = _remove_strip_faces(group, source.outputs["Geometry"], triangles=triangles)
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
obj.modifiers.new("Cleanup", "NODES").node_group = group
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
try:
assert all(np.isfinite(vertex.co).all() for vertex in result.vertices)
if not result.polygons:
assert not result.vertices and not result.edges
return set()
assert "UVMap" in result.attributes
for loop in result.loops:
np.testing.assert_allclose(result.uv_layers["UVMap"].data[loop.index].uv,
result.vertices[loop.vertex_index].co[:2])
return {value.value for value in result.attributes["source_face"].data}
finally:
evaluated.to_mesh_clear()
@pytest.mark.parametrize("cells, expected", [
([(0, 0)], set()),
([(x, 0) for x in range(5)], set()),
([(x, y) for y in range(3) for x in range(3)], set(range(9))),
# The center becomes isolated only after its three arms disappear.
([(0, 0), (1, 0), (2, 0), (1, 1)], {1}),
])
@pytest.mark.parametrize("offset, reverse", [(0, False), (1, False), (2, True), (3, True)])
def test_quad_strips_and_corners_are_classified_once(cells, expected, offset, reverse):
vertices, indices, faces = [], {}, []
for x, y in cells:
face = []
for point in [(x, y, 0), (x + 1, y, 0), (x + 1, y + 1, 0), (x, y + 1, 0)]:
if point not in indices:
indices[point] = len(vertices)
vertices.append(point)
face.append(indices[point])
face = face[offset:] + face[:offset]
faces.append(face[::-1] if reverse else face)
assert cleaned_faces(vertices, faces) == expected
@pytest.mark.parametrize("count", [3, 5, 6])
def test_non_quad_faces_are_preserved(count):
angles = np.linspace(0, 2 * np.pi, count, endpoint=False)
vertices = [(np.cos(a), np.sin(a), 0) for a in angles]
assert cleaned_faces(vertices, [tuple(range(count))]) == {0}
def test_nonmanifold_edge_is_not_a_boundary():
vertices = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0),
(2, 0, 0), (1, 2, 0), (0, 2, 1), (-1, 0, 0)]
faces = [(0, 1, 2, 3), (2, 1, 4), (3, 2, 5), (2, 3, 6), (0, 3, 7)]
assert cleaned_faces(vertices, faces) == set(range(5))
@pytest.mark.parametrize("width, height, expected", [(1, 1, 0), (5, 1, 0), (2, 2, 8), (3, 3, 18)])
@pytest.mark.parametrize("reverse", [False, True])
def test_triangle_pairs_remove_strips_and_preserve_surface_corners(width, height, expected, reverse):
vertices = [(x, y, 0) for y in range(height + 1) for x in range(width + 1)]
faces = []
for y in range(height):
for x in range(width):
a = y * (width + 1) + x
b, c, d = a + 1, a + width + 2, a + width + 1
faces.extend([(a, b, c), (a, c, d)])
# Reverse alternate faces to test matching by vertex identity, not winding.
if reverse:
faces = [face[::-1] if i % 2 else face[1:] + face[:1] for i, face in enumerate(faces)]
result = cleaned_faces(vertices, faces, triangles=True)
assert len(result) == expected
def test_isolated_triangle_is_removed_only_for_cutout():
vertices = [(0, 0, 0), (1, 0, 0), (0, 1, 0)]
assert cleaned_faces(vertices, [(0, 1, 2)], triangles=True) == set()
assert cleaned_faces(vertices, [(0, 1, 2)]) == {0}
def test_triangle_pair_requires_a_manifold_shared_edge():
vertices = [(0, 0, 0), (1, 0, 0), (.5, 1, 0), (.5, -1, 0), (.5, 0, 1)]
faces = [(0, 1, 2), (1, 0, 3), (0, 1, 4)]
assert cleaned_faces(vertices, faces, triangles=True) == {0, 1, 2}
def test_triangle_pair_requires_four_distinct_vertices():
vertices = [(0, 0, 0), (1, 0, 0), (0, 1, 0)]
assert cleaned_faces(vertices, [(0, 1, 2), (2, 1, 0)], triangles=True) == {0, 1}
def test_triangle_cleanup_marks_both_faces_without_recursive_peeling():
vertices = [(0, 0, 0), (2, 0, 0), (1, 2, 0), (1, -2, 0),
(3, 2, 0), (-1, 2, 0), (-1, -1, 0)]
faces = [(0, 1, 2), (1, 0, 3), (2, 1, 4), (0, 2, 5), (3, 0, 6)]
assert cleaned_faces(vertices, faces, triangles=True) == {0, 2, 3}
@pytest.mark.parametrize("kind", ["plane", "cutout"])
def test_split_input_precedes_smooth_without_cleanup_switch(kind):
bpy.ops.wm.read_factory_settings(use_empty=True)
group = (build_image_depth_plane_group(build_image_plane_group()) if kind == "plane"
else build_image_depth_cutout_group())
inputs = [s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"]
names = [s.name for s in inputs]
assert names.count("Split Threshold") == 1
assert names[names.index("Smooth") - 1] == "Split Threshold"
split = inputs[names.index("Split Threshold")]
assert split.parent == inputs[names.index("Smooth")].parent
assert split.parent.name != "Options"
assert (split.default_value, split.min_value, split.max_value, split.subtype) == (0, 0, 1, "FACTOR")
assert "Remove Strip Faces" not in names
@pytest.mark.parametrize("kind", ["plane", "cutout"])
def test_strips_are_removed_before_projection_and_closed_thickness(kind):
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
from tests.support.planes import create_surface
if kind == "plane":
obj, set_value, _, _, image = create_surface("definition", "DEPTH")
set_value("Subdivide", 1)
else:
obj, set_value = surface(step=0.0)
image = bpy.data.images["Camera"]
width, height = image.size
pixels = np.array(image.pixels[:], np.float32).reshape(height, width, 4)
pixels[..., 2] = 1
pixels[:, width // 4:3 * width // 4, 2] = 21
image.pixels.foreach_set(pixels.ravel())
image.update()
set_value("Depth Scale", 1.0)
assert len(evaluated(obj)[1]) == 8
set_value("Split Threshold", 0.5)
vertices, faces = evaluated(obj)
assert len(faces) == 4 and len(vertices) == 9
assert component_count(faces) == 1
set_value("Thickness", 0.2)
before_smooth, shell = evaluated(obj)
assert len(shell) == 16
assert_closed(shell)
set_value("Smooth", 2)
after_smooth, smooth_shell = evaluated(obj)
assert smooth_shell == shell
assert np.isfinite(after_smooth).all()
assert_closed(smooth_shell)
assert component_count(smooth_shell) == 1
set_value("Thickness", 0.0)
assert len(evaluated(obj)[1]) == 4
@pytest.mark.parametrize("kind", ["plane", "cutout"])
def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind):
from tests.support.depth_surface import surface, evaluated
from tests.support.planes import create_surface
if kind == "plane":
obj, set_value, _, _, _ = create_surface("definition", "DEPTH")
set_value("Subdivide", 0)
else:
obj, set_value = surface(step=0.0, resolution=1)
original, faces = evaluated(obj)
assert len(faces) == 2
set_value("Split Threshold", 0.001)
assert not evaluated(obj)[1]
set_value("Thickness", 0.2)
assert not evaluated(obj)[1]
set_value("Thickness", 0.0)
set_value("Split Threshold", 0.0)
restored, restored_faces = evaluated(obj)
assert restored_faces == faces
np.testing.assert_array_equal(restored, original)
def test_cutout_cleans_triangle_strips_only_when_split_is_enabled():
import bmesh
from tests.support.depth_surface import surface, evaluated
obj, set_value = surface(step=0.0, resolution=1)
mesh = bmesh.new()
mesh.from_mesh(obj.data)
bmesh.ops.triangulate(mesh, faces=list(mesh.faces))
mesh.to_mesh(obj.data)
mesh.free()
set_value("Split Threshold", 0.0)
original, faces = evaluated(obj)
assert len(faces) == 4
set_value("Split Threshold", 0.001)
assert not evaluated(obj)[1]
set_value("Thickness", 0.2)
assert not evaluated(obj)[1]
set_value("Thickness", 0.0)
set_value("Split Threshold", 0.0)
restored, restored_faces = evaluated(obj)
assert restored_faces == faces
np.testing.assert_array_equal(restored, original)

View File

@@ -147,11 +147,14 @@ def test_coincident_independent_sheets_do_not_weld_together():
@pytest.mark.parametrize("resolution", [1, 2, 4]) @pytest.mark.parametrize("resolution", [1, 2, 4])
def test_planar_depth_has_no_split_at_any_density(resolution): def test_planar_depth_preserves_blocks_and_cleans_single_face_width(resolution):
obj, set_value = surface(step=0.0, resolution=resolution) obj, set_value = surface(step=0.0, resolution=resolution)
original, original_faces = evaluated(obj) original, original_faces = evaluated(obj)
set_value("Split Threshold", 1.0) set_value("Split Threshold", 1.0)
coords, faces = evaluated(obj) coords, faces = evaluated(obj)
if resolution == 1:
assert len(coords) == 0 and not faces
return
assert len(coords) == len(obj.data.vertices) assert len(coords) == len(obj.data.vertices)
assert len(faces) == len(obj.data.polygons) assert len(faces) == len(obj.data.polygons)
np.testing.assert_allclose(coords, original, atol=1e-6) np.testing.assert_allclose(coords, original, atol=1e-6)
@@ -219,8 +222,17 @@ def test_temporary_attributes_are_removed_and_user_attributes_survive(thickness,
result.to_mesh_clear() result.to_mesh_clear()
def output_split_mesh(obj):
"""Inspect separation before boundary strip cleanup."""
group = obj.modifiers[0].node_group
split = next(node for node in group.nodes if node.bl_idname == "GeometryNodeSplitEdges")
output = next(node for node in group.nodes if node.bl_idname == "NodeGroupOutput")
group.links.new(split.outputs["Mesh"], output.inputs["Geometry"])
def test_zero_center_distance_does_not_split_or_produce_nan(): def test_zero_center_distance_does_not_split_or_produce_nan():
obj, set_value = surface(step=6.0) obj, set_value = surface(step=6.0)
output_split_mesh(obj)
mesh = bpy.data.meshes.new("Coincident face centers") mesh = bpy.data.meshes.new("Coincident face centers")
mesh.from_pydata( mesh.from_pydata(
[(0, 0, 0), (1, 0, 0), (0.5, 1, 0), (0.5, 1, 0)], [], [(0, 1, 2), (1, 0, 3)] [(0, 0, 0), (1, 0, 0), (0.5, 1, 0), (0.5, 1, 0)], [], [(0, 1, 2), (1, 0, 3)]
@@ -258,6 +270,12 @@ def test_triangle_corner_weights_do_not_open_sloped_side_walls():
@pytest.mark.parametrize("resolution", [1, 2, 4]) @pytest.mark.parametrize("resolution", [1, 2, 4])
def test_linear_depth_slope_has_density_independent_selection(resolution): def test_linear_depth_slope_has_density_independent_selection(resolution):
obj, set_value = surface(step=0.0, resolution=resolution) obj, set_value = surface(step=0.0, resolution=resolution)
output_split_mesh(obj)
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.triangulate(bm, faces=list(bm.faces))
bm.to_mesh(obj.data)
bm.free()
image = bpy.data.images["Camera"] image = bpy.data.images["Camera"]
pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4) pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4)
pixels[..., 2] = 1 + 4 * pixels[..., 0] pixels[..., 2] = 1 + 4 * pixels[..., 0]

View File

@@ -1,6 +1,26 @@
from tests.support.paths import PROJECT_ROOT from tests.support.paths import PROJECT_ROOT
import tomllib import tomllib
import unittest import unittest
import importlib
from unittest.mock import patch
import pytest
@pytest.mark.parametrize("module_name", ["tools.nodes.cli", "tools.models.cli"])
@pytest.mark.parametrize("arguments,code", [([], None), (["--help"], 0), (["unknown"], 2), (["--bad-option"], 2)])
def test_help_and_invalid_arguments_do_not_start_work(module_name, arguments, code, capsys):
module = importlib.import_module(module_name)
with patch("sys.argv", ["tool", *arguments]), patch.object(module.subprocess, "run") as run:
if code is None:
module.main()
else:
with pytest.raises(SystemExit) as error:
module.main()
assert error.value.code == code
run.assert_not_called()
captured = capsys.readouterr()
assert "usage:" in captured.out + captured.err
class ToolCommandsTests(unittest.TestCase): class ToolCommandsTests(unittest.TestCase):
@@ -12,8 +32,8 @@ class ToolCommandsTests(unittest.TestCase):
project["scripts"], project["scripts"],
{ {
"pack": "tools.pack:main", "pack": "tools.pack:main",
"build_nodes": "tools.nodes.cli:main", "node-group": "tools.nodes.cli:main",
"sync_models": "tools.models.cli:main", "model": "tools.models.cli:main",
"docs": "tools.docs:main", "docs": "tools.docs:main",
}, },
) )

View File

@@ -7,6 +7,7 @@ import subprocess
import sys import sys
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
@@ -194,12 +195,19 @@ def prepare_model(model):
build_model(model) build_model(model)
return return
if model.key == "PISA_SR": if model.key == "PISA_SR":
model.destination.parent.mkdir(parents=True, exist_ok=True)
with TemporaryDirectory(prefix=".pisa-sr-", dir=MODELS_DIR) as directory:
subprocess.run( subprocess.run(
["uv", "run", "--script", str(Path(__file__).with_name("pisa_sr_export.py"))], ["uv", "run", "--script", str(Path(__file__).with_name("pisa_sr_export.py")),
"--destination", directory],
cwd=PROJECT_ROOT, check=True, cwd=PROJECT_ROOT, check=True,
) )
if not verify_file(model.destination, model.size, model.sha256): models = [item for item in MODEL_FILES if item.key == model.key]
for item in models:
if not verify_file(Path(directory) / item.filename, item.size, item.sha256):
raise RuntimeError("PiSA-SR export differs from the published catalog") raise RuntimeError("PiSA-SR export differs from the published catalog")
for path in Path(directory).iterdir():
path.replace(model.destination.parent / path.name)
return return
if not model.source_url: if not model.source_url:
raise RuntimeError(f"No upstream source or builder for {model.key}") raise RuntimeError(f"No upstream source or builder for {model.key}")
@@ -231,20 +239,23 @@ def upload_models(models=MODEL_FILES):
def parse_args(): def parse_args():
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser(description="Prepare models locally or sync them to R2.")
parser.add_argument( commands = parser.add_subparsers(dest="command")
"--prepare-only", commands.add_parser("prepare", help="Download or export models and verify them locally")
action="store_true", commands.add_parser("sync", help="Prepare and verify all models, then upload them to R2")
help="Prepare and verify models without uploading them", args = parser.parse_args()
) if args.command is None:
return parser.parse_args() parser.print_help()
return args
def main(): def main():
args = parse_args() args = parse_args()
if args.command is None:
return
for model in MODEL_FILES: for model in MODEL_FILES:
prepare_model(model) prepare_model(model)
if not args.prepare_only: if args.command == "sync":
upload_models() upload_models()

View File

@@ -18,7 +18,9 @@
"GeometryNodeBoundBox": 0, "GeometryNodeBoundBox": 0,
"GeometryNodeCaptureAttribute": 3, "GeometryNodeCaptureAttribute": 3,
"GeometryNodeCornersOfEdge": 0, "GeometryNodeCornersOfEdge": 0,
"GeometryNodeCornersOfFace": 0,
"GeometryNodeDeleteGeometry": 2, "GeometryNodeDeleteGeometry": 2,
"GeometryNodeEdgesOfCorner": 0,
"GeometryNodeExtrudeMesh": 1, "GeometryNodeExtrudeMesh": 1,
"GeometryNodeFaceOfCorner": 0, "GeometryNodeFaceOfCorner": 0,
"GeometryNodeFieldAtIndex": 2, "GeometryNodeFieldAtIndex": 2,
@@ -32,6 +34,7 @@
"GeometryNodeInputMeshEdgeNeighbors": 0, "GeometryNodeInputMeshEdgeNeighbors": 0,
"GeometryNodeInputMeshEdgeVertices": 0, "GeometryNodeInputMeshEdgeVertices": 0,
"GeometryNodeInputMeshFaceArea": 0, "GeometryNodeInputMeshFaceArea": 0,
"GeometryNodeInputMeshFaceNeighbors": 0,
"GeometryNodeInputMeshIsland": 0, "GeometryNodeInputMeshIsland": 0,
"GeometryNodeInputMeshVertexNeighbors": 0, "GeometryNodeInputMeshVertexNeighbors": 0,
"GeometryNodeInputNamedAttribute": 1, "GeometryNodeInputNamedAttribute": 1,
@@ -42,6 +45,7 @@
"GeometryNodeMergeByDistance": 1, "GeometryNodeMergeByDistance": 1,
"GeometryNodeMeshCube": 0, "GeometryNodeMeshCube": 0,
"GeometryNodeMeshGrid": 0, "GeometryNodeMeshGrid": 0,
"GeometryNodeOffsetCornerInFace": 0,
"GeometryNodeRemoveAttribute": 1, "GeometryNodeRemoveAttribute": 1,
"GeometryNodeRepeatInput": 0, "GeometryNodeRepeatInput": 0,
"GeometryNodeRepeatOutput": 3, "GeometryNodeRepeatOutput": 3,
@@ -57,6 +61,7 @@
"GeometryNodeStoreNamedAttribute": 2, "GeometryNodeStoreNamedAttribute": 2,
"GeometryNodeSwitch": 1, "GeometryNodeSwitch": 1,
"GeometryNodeTransform": 1, "GeometryNodeTransform": 1,
"GeometryNodeVertexOfCorner": 0,
"NodeGroupInput": 0, "NodeGroupInput": 0,
"NodeGroupOutput": 0, "NodeGroupOutput": 0,
"ShaderNodeClamp": 1, "ShaderNodeClamp": 1,

View File

@@ -3,16 +3,13 @@ import os
import shutil import shutil
import subprocess import subprocess
import sys import sys
import webbrowser
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory, mkdtemp
from zipfile import ZipFile from zipfile import ZipFile
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
BLENDER_MODULES = (
"tools.nodes.build",
"tools.nodes.check",
)
def project_env_value(name, path=None): def project_env_value(name, path=None):
@@ -57,22 +54,7 @@ def run(command):
subprocess.run(command, cwd=PROJECT_ROOT, check=True) subprocess.run(command, cwd=PROJECT_ROOT, check=True)
def main(): def run_blender_modules(executable, modules, arguments=()):
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)
with TemporaryDirectory(prefix="anyimage-node-dependencies-") as directory: with TemporaryDirectory(prefix="anyimage-node-dependencies-") as directory:
for wheel in (PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("scipy-*.whl"): for wheel in (PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("scipy-*.whl"):
with ZipFile(wheel) as archive: with ZipFile(wheel) as archive:
@@ -83,16 +65,52 @@ def main():
f"import sys, runpy; sys.path[:0] = {paths!r}; " 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)); " 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( run(
( (
executable, executable,
"--factory-startup", "--factory-startup",
"--background", "--background",
"--python-exit-code", "1", "--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: if not options.skip_tests:
run((sys.executable, "-m", "pytest")) run((sys.executable, "-m", "pytest"))

View File

@@ -50,7 +50,125 @@ def _math(group, operation, a, b=None):
return node.outputs[0] 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.""" """Keep each face's camera sample on its own corners before splitting."""
nodes, links = group.nodes, group.links nodes, links = group.nodes, group.links
uv = nodes.new("GeometryNodeInputNamedAttribute") uv = nodes.new("GeometryNodeInputNamedAttribute")
@@ -136,7 +254,7 @@ def split_depth_surface(group, geometry, image, strength, uniform):
choose.input_type = "GEOMETRY" choose.input_type = "GEOMETRY"
links.new(enabled, choose.inputs["Switch"]) links.new(enabled, choose.inputs["Switch"])
links.new(geometry, choose.inputs["False"]) 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"] return choose.outputs["Output"]

View File

@@ -436,7 +436,7 @@ def _build_depth_surface(
nodes = group.nodes nodes = group.nodes
links = group.links links = group.links
geometry = split_depth_surface( 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) original_camera = _position_field(group, depth_image)
geometry, camera_position = store_surface_camera( 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, '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, 'Uniform Thickness', 0.0, subtype='DISTANCE').description = 'Distance used only by Uniform thickness mode'
float_input(group, 'Depth Scale', 1.0) 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 = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')
iterations.default_value = 0 iterations.default_value = 0
iterations.min_value, iterations.max_value = (0, 20) iterations.min_value, iterations.max_value = (0, 20)
options = group.interface.new_panel(name='Options', default_closed=True) options = group.interface.new_panel(name='Options', default_closed=True)
float_input(group, 'Cleanup Threshold', 0.0, parent=options) 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, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
float_input(group, 'Edge Round', 0.2, maximum=1, subtype='FACTOR', 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) float_input(group, 'Front Inflation', 1.0, maximum=32, parent=options)

View File

@@ -36,6 +36,7 @@ def build_image_depth_plane_group(image_plane_group):
depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat") depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat")
depth_scale.default_value = 1.0 depth_scale.default_value = 1.0
depth_scale.min_value = 0.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 = _interface_socket(group, "Smooth", "INPUT", "NodeSocketInt")
iterations.default_value = 0 iterations.default_value = 0
iterations.min_value, iterations.max_value = 0, 20 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.default_value = 0.9
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0 validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
validity_threshold.subtype = "FACTOR" 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) data = group.interface.new_panel(name="Data", default_closed=True)
uniform_scale = _interface_socket( uniform_scale = _interface_socket(
group, group,