diff --git a/AGENTS.md b/AGENTS.md index be5f182..4732e41 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ - 说明性内容以中文为主,专有名词和约定俗成的表达可使用英文 - 修改代码、依赖或执行方式后, - 不保留旧路径、转发函数、兼容层或胶水代码 - - 仅过测试,不改同步改文档,不构建文档、产物 + - 更新并运行相关测试,但不主动同步说明文档、构建文档或打包扩展 - 优先选择依赖少、直接、易验证的实现 - 复用来自 `common` 的函数,避免重复造轮子 - 确保文件名/文件夹名和内容边界一致 @@ -20,7 +20,7 @@ - 沿真实调用链清理无用代码,不把仅有测试引用当作业务用途;查证动态调用、框架回调和注册入口后再删除。 - 精简测试,保留有效行为覆盖,使测试名称、归属与功能对应,并检查状态隔离。 - 同步整理 `tools/nodes`、`docs/internals` 和相关引用,使名称、说明与实际实现一致。本触发指令包含内部文档整理。 -- 保留已有的其他修改和有效功能,运行全量测试并确认正常退出,检查旧引用与最终差异,简要报告调整及验证结果;不构建文档或产物。 +- 保留已有的其他修改和有效功能,运行全量测试并确认正常退出,检查旧引用与最终差异,简要报告调整及验证结果;不构建文档或打包扩展。 ## 查询文档 @@ -47,10 +47,10 @@ uv run pytest uv run pack # 构建并验证 Blender 节点资产 -uv run build_nodes +uv run build-nodes # 准备、校验并同步 ONNX 模型到 R2 -uv run sync_models +uv run sync-models ``` ## 命名规范 @@ -87,5 +87,8 @@ uv run sync_models ## Blender 节点组规范 -- 节点组统一存入 `assets/O_AnyImage.blend`,运行时按名称加载(`load_node_group(group_name)`) -- 根据[节点资产](docs/internals/node-assets.md)说明构建节点组,禁止调用 `bpy.data.node_groups.new()` 新建 +- 节点组统一保存于 `src/anyimage/assets/O_AnyImage.blend`。 +- 插件运行时必须使用 `load_node_group(group_name)` 从资产加载,禁止调用 `bpy.data.node_groups.new()` 创建节点组。 +- `tools/nodes` 构建脚本和测试允许通过代码创建节点组。 +- 修改节点组的逻辑、接口、布局或共用构建函数后,必须运行 `uv run build-nodes`,无需另行确认;源码、测试与 `.blend` 资产均更新且验证通过,才算完成。 +- 具体构建规范与操作步骤见[节点资产](docs/internals/node-assets.md)。 diff --git a/docs/internals/development.md b/docs/internals/development.md index ca930ae..c8b5711 100644 --- a/docs/internals/development.md +++ b/docs/internals/development.md @@ -27,8 +27,8 @@ Blender Stub 共用 `blender_test_support.py`;Server 和实际 Blender 几何 | --- | --- | | `uv run pack` | 生成 Windows、macOS arm64、Linux 扩展包 | | `uv run pack --prepare` | 为本机同步扩展 wheel 与 Manifest | -| `uv run build_nodes` | 构建、验证节点资产并运行测试 | -| `uv run sync_models` | 准备、校验并上传模型声明的文件 | +| `uv run build-nodes` | 构建、验证节点资产并运行测试 | +| `uv run sync-models` | 准备、校验并上传模型声明的文件 | | `uv run docs -dev` | 本地预览文档 | | `uv run docs` | 通过 Mike 发布项目版本并更新 latest | diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index 8de5ee5..07da330 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -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` @@ -40,8 +40,8 @@ 保存后,`check.py` 在独立 Blender 进程中重新加载资产并做基本几何求值。接口、默认值和详细几何行为由 `tests/` 检查。 ```bash -uv run build_nodes --blender "/path/to/blender" -uv run build_nodes --skip-tests +uv run build-nodes --blender "/path/to/blender" +uv run build-nodes --skip-tests ``` 脚本按 `--blender`、环境变量 `BLENDER_BIN`、项目 `.env`、`PATH` 的顺序查找 Blender。无法自动找到时,在项目根目录的 `.env` 中配置: diff --git a/experiments/panorama_surface/.gitignore b/experiments/panorama_surface/.gitignore new file mode 100644 index 0000000..639c90f --- /dev/null +++ b/experiments/panorama_surface/.gitignore @@ -0,0 +1,3 @@ +/output/ +/analytic/ +/photo/ diff --git a/experiments/panorama_surface/ATTRIBUTION.md b/experiments/panorama_surface/ATTRIBUTION.md new file mode 100644 index 0000000..95b9e56 --- /dev/null +++ b/experiments/panorama_surface/ATTRIBUTION.md @@ -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)。试验独立实现这些计算。 diff --git a/experiments/panorama_surface/blender_scene.py b/experiments/panorama_surface/blender_scene.py new file mode 100644 index 0000000..8950fdf --- /dev/null +++ b/experiments/panorama_surface/blender_scene.py @@ -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() diff --git a/experiments/panorama_surface/geometry.py b/experiments/panorama_surface/geometry.py new file mode 100644 index 0000000..7f18dde --- /dev/null +++ b/experiments/panorama_surface/geometry.py @@ -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 diff --git a/experiments/panorama_surface/prototype.py b/experiments/panorama_surface/prototype.py new file mode 100644 index 0000000..9d2209e --- /dev/null +++ b/experiments/panorama_surface/prototype.py @@ -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() diff --git a/experiments/panorama_surface/quad_surface.py b/experiments/panorama_surface/quad_surface.py new file mode 100644 index 0000000..7e1daca --- /dev/null +++ b/experiments/panorama_surface/quad_surface.py @@ -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 diff --git a/experiments/panorama_surface/test_geometry.py b/experiments/panorama_surface/test_geometry.py new file mode 100644 index 0000000..6139925 --- /dev/null +++ b/experiments/panorama_surface/test_geometry.py @@ -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))) diff --git a/experiments/panorama_surface/validation.py b/experiments/panorama_surface/validation.py new file mode 100644 index 0000000..426304b --- /dev/null +++ b/experiments/panorama_surface/validation.py @@ -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) diff --git a/openspec/changes/clean-depth-surface-strip-faces/.openspec.yaml b/openspec/changes/clean-depth-surface-strip-faces/.openspec.yaml new file mode 100644 index 0000000..1ea7e36 --- /dev/null +++ b/openspec/changes/clean-depth-surface-strip-faces/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-09 diff --git a/openspec/changes/clean-depth-surface-strip-faces/design.md b/openspec/changes/clean-depth-surface-strip-faces/design.md new file mode 100644 index 0000000..25b3e82 --- /dev/null +++ b/openspec/changes/clean-depth-surface-strip-faces/design.md @@ -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 + +本提案完成后实施源码与测试,保留工作区其他修改,执行全量测试并确认正常退出。依照项目规范,此实施任务不构建文档或产物。正式节点资产重建发布需要单独安排;源码实施完成不代表用户安装的节点资产已更新。 diff --git a/openspec/changes/clean-depth-surface-strip-faces/proposal.md b/openspec/changes/clean-depth-surface-strip-faces/proposal.md new file mode 100644 index 0000000..0a78a5b --- /dev/null +++ b/openspec/changes/clean-depth-surface-strip-faces/proposal.md @@ -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 建网流程产生三角面,因此本次规则对这类输入不会删面;四边网格输入的实验效果不能作为三角网格的验收结果。条带判定依据拓扑,已被拆成条带的真实物体细节也可能被删除。 diff --git a/openspec/changes/clean-depth-surface-strip-faces/specs/depth-surface-strip-cleanup/spec.md b/openspec/changes/clean-depth-surface-strip-faces/specs/depth-surface-strip-cleanup/spec.md new file mode 100644 index 0000000..3816fba --- /dev/null +++ b/openspec/changes/clean-depth-surface-strip-faces/specs/depth-surface-strip-cleanup/spec.md @@ -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** 新位置的输入正常控制原拆边强度及默认条带清理 diff --git a/openspec/changes/clean-depth-surface-strip-faces/tasks.md b/openspec/changes/clean-depth-surface-strip-faces/tasks.md new file mode 100644 index 0000000..87a74c5 --- /dev/null +++ b/openspec/changes/clean-depth-surface-strip-faces/tasks.md @@ -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。 diff --git a/openspec/changes/organize-tool-cli/.openspec.yaml b/openspec/changes/organize-tool-cli/.openspec.yaml new file mode 100644 index 0000000..e8cda9e --- /dev/null +++ b/openspec/changes/organize-tool-cli/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-10 diff --git a/openspec/changes/organize-tool-cli/design.md b/openspec/changes/organize-tool-cli/design.md new file mode 100644 index 0000000..85863eb --- /dev/null +++ b/openspec/changes/organize-tool-cli/design.md @@ -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` 刷新本地脚本,运行相关测试、帮助命令及节点构建链验证。保留工作区既有修改。 diff --git a/openspec/changes/organize-tool-cli/proposal.md b/openspec/changes/organize-tool-cli/proposal.md new file mode 100644 index 0000000..4b2eb8e --- /dev/null +++ b/openspec/changes/organize-tool-cli/proposal.md @@ -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 规划文件。 diff --git a/openspec/changes/organize-tool-cli/specs/developer-tool-cli/spec.md b/openspec/changes/organize-tool-cli/specs/developer-tool-cli/spec.md new file mode 100644 index 0000000..ff43223 --- /dev/null +++ b/openspec/changes/organize-tool-cli/specs/developer-tool-cli/spec.md @@ -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** 命令非零退出,失败产物不替换正式目标 diff --git a/openspec/changes/organize-tool-cli/tasks.md b/openspec/changes/organize-tool-cli/tasks.md new file mode 100644 index 0000000..61b9970 --- /dev/null +++ b/openspec/changes/organize-tool-cli/tasks.md @@ -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 检查代码与测试中的旧入口引用、最终差异和测试结果,确认保留工作区既有修改,并简要报告命令迁移及验证结果。 diff --git a/openspec/changes/prototype-panorama-surface/.openspec.yaml b/openspec/changes/prototype-panorama-surface/.openspec.yaml new file mode 100644 index 0000000..1ea7e36 --- /dev/null +++ b/openspec/changes/prototype-panorama-surface/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-09 diff --git a/openspec/changes/prototype-panorama-surface/design.md b/openspec/changes/prototype-panorama-surface/design.md new file mode 100644 index 0000000..c438cad --- /dev/null +++ b/openspec/changes/prototype-panorama-surface/design.md @@ -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 结果只作为流程验证,几何正确性以解析真值验证。 +- 梯度融合没有绝对尺度约束 → 输出明确采用预测尺度锚定,记录相对误差。 +- 遮挡处连接可能产生长面 → 通过真实照片试验观察可见表面连接质量。 diff --git a/openspec/changes/prototype-panorama-surface/proposal.md b/openspec/changes/prototype-panorama-surface/proposal.md new file mode 100644 index 0000000..b8ece6d --- /dev/null +++ b/openspec/changes/prototype-panorama-surface/proposal.md @@ -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 环境,生产源码、依赖及原节点资产保持原状。 diff --git a/openspec/changes/prototype-panorama-surface/specs/panorama-surface-prototype/spec.md b/openspec/changes/prototype-panorama-surface/specs/panorama-surface-prototype/spec.md new file mode 100644 index 0000000..2946f7f --- /dev/null +++ b/openspec/changes/prototype-panorama-surface/specs/panorama-surface-prototype/spec.md @@ -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** 先去除天空背景,使用处理后的透明全景进行几何预测与深度导出 diff --git a/openspec/changes/prototype-panorama-surface/tasks.md b/openspec/changes/prototype-panorama-surface/tasks.md new file mode 100644 index 0000000..ab91af0 --- /dev/null +++ b/openspec/changes/prototype-panorama-surface/tasks.md @@ -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 乘积及天空剔除 diff --git a/pyproject.toml b/pyproject.toml index d2e32f3..2476b04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,8 +27,8 @@ dev = [ [project.scripts] pack = "tools.pack:main" -build_nodes = "tools.nodes.cli:main" -sync_models = "tools.models.cli:main" +node-group = "tools.nodes.cli:main" +model = "tools.models.cli:main" docs = "tools.docs:main" [build-system] diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 915e687..25181d3 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a1c2a02bca6bb55221308d257eee7c2966aa35e1dc25cc897a12600ba49ae5c -size 4583729 +oid sha256:c3e6c8cffd8c69efb3d4770feccd6d9a6d2c8bf23d838b45b4bf75473c21634d +size 6146627 diff --git a/tests/anyimage/operators/test_cutout_objects.py b/tests/anyimage/operators/test_cutout_objects.py index 6b8fe76..ce80b92 100644 --- a/tests/anyimage/operators/test_cutout_objects.py +++ b/tests/anyimage/operators/test_cutout_objects.py @@ -422,16 +422,17 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge(): 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) obj, modifier = _depth_surface_step_object() 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) obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() 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 diff --git a/tests/tools/models/test_cli.py b/tests/tools/models/test_cli.py index 02bd83e..cd768e5 100644 --- a/tests/tools/models/test_cli.py +++ b/tests/tools/models/test_cli.py @@ -3,6 +3,97 @@ import importlib.util import sys import unittest 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(): diff --git a/tests/tools/nodes/test_cli.py b/tests/tools/nodes/test_cli.py index cbf0544..8cb04c7 100644 --- a/tests/tools/nodes/test_cli.py +++ b/tests/tools/nodes/test_cli.py @@ -77,7 +77,7 @@ def test_launcher_runs_asset_build_and_validations_before_tests(monkeypatch, tmp calls = [] monkeypatch.setattr(launcher, "blender_path", lambda _: executable) monkeypatch.setattr(launcher, "run", calls.append) - monkeypatch.setattr("sys.argv", ["build_nodes"]) + monkeypatch.setattr("sys.argv", ["node-group", "build"]) 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 all(command[1:6] == ("--factory-startup", "--background", "--python-exit-code", "1", "--python-expr") for command in calls[:-1]) 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() diff --git a/tests/tools/nodes/test_cutouts.py b/tests/tools/nodes/test_cutouts.py index 628fdac..7f67610 100644 --- a/tests/tools/nodes/test_cutouts.py +++ b/tests/tools/nodes/test_cutouts.py @@ -5,6 +5,7 @@ import bpy import numpy as np import pytest 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 @@ -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"] expected = ["Cleanup Threshold"] 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"]: expected += ["Reference Depth"] expected += ["Smooth Weight"] @@ -52,7 +53,10 @@ def test_cutout_interfaces_and_thickness_modes(source): assert options == expected, (group.name, options) assert inputs["Smooth"].socket_type == "NodeSocketInt" and inputs["Smooth"].default_value == 0 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" 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)]) diff --git a/tests/tools/nodes/test_depth_relief_planes.py b/tests/tools/nodes/test_depth_relief_planes.py index d66f5d3..35d7558 100644 --- a/tests/tools/nodes/test_depth_relief_planes.py +++ b/tests/tools/nodes/test_depth_relief_planes.py @@ -188,13 +188,15 @@ def test_curved_shell_smooths_normals_without_shrinking_thickness(surface, norma def test_surface_interface_and_node_structure(surface): obj, _, _, inputs, _ = surface - assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Smooth", "Valid Only", - "Reference Depth", "Normal Smooth", "Smooth Weight", "Validity Threshold", "Split Threshold", "Uniform Scale", "Depth Image"] + expected = ["Geometry", "Subdivide", "Thickness", "Depth Scale", "Split Threshold", "Smooth", "Valid Only", + "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"].min_value == 0 assert inputs["Split Threshold"].max_value == 1 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"].parent.name == "Options" 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"] controls = ["Reference 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[1].interface_items] == ["Uniform Scale", "Depth Image"] assert all(panel.default_closed for panel in panels) diff --git a/tests/tools/nodes/test_depth_strip_cleanup.py b/tests/tools/nodes/test_depth_strip_cleanup.py new file mode 100644 index 0000000..ba6b59c --- /dev/null +++ b/tests/tools/nodes/test_depth_strip_cleanup.py @@ -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) diff --git a/tests/tools/nodes/test_depth_surface_split.py b/tests/tools/nodes/test_depth_surface_split.py index bd0dd75..ee2a8b7 100644 --- a/tests/tools/nodes/test_depth_surface_split.py +++ b/tests/tools/nodes/test_depth_surface_split.py @@ -147,11 +147,14 @@ def test_coincident_independent_sheets_do_not_weld_together(): @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) original, original_faces = evaluated(obj) set_value("Split Threshold", 1.0) coords, faces = evaluated(obj) + if resolution == 1: + assert len(coords) == 0 and not faces + return assert len(coords) == len(obj.data.vertices) assert len(faces) == len(obj.data.polygons) 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() +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(): obj, set_value = surface(step=6.0) + output_split_mesh(obj) mesh = bpy.data.meshes.new("Coincident face centers") mesh.from_pydata( [(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]) def test_linear_depth_slope_has_density_independent_selection(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"] pixels = np.array(image.pixels[:], np.float32).reshape(32, 64, 4) pixels[..., 2] = 1 + 4 * pixels[..., 0] diff --git a/tests/tools/test_commands.py b/tests/tools/test_commands.py index e8f73a5..30c07a8 100644 --- a/tests/tools/test_commands.py +++ b/tests/tools/test_commands.py @@ -1,6 +1,26 @@ from tests.support.paths import PROJECT_ROOT import tomllib 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): @@ -12,8 +32,8 @@ class ToolCommandsTests(unittest.TestCase): project["scripts"], { "pack": "tools.pack:main", - "build_nodes": "tools.nodes.cli:main", - "sync_models": "tools.models.cli:main", + "node-group": "tools.nodes.cli:main", + "model": "tools.models.cli:main", "docs": "tools.docs:main", }, ) diff --git a/tools/models/cli.py b/tools/models/cli.py index 0ca9de3..147d28c 100644 --- a/tools/models/cli.py +++ b/tools/models/cli.py @@ -7,6 +7,7 @@ import subprocess import sys from dataclasses import dataclass from pathlib import Path +from tempfile import TemporaryDirectory from urllib.request import Request, urlopen @@ -194,12 +195,19 @@ def prepare_model(model): build_model(model) return if model.key == "PISA_SR": - subprocess.run( - ["uv", "run", "--script", str(Path(__file__).with_name("pisa_sr_export.py"))], - cwd=PROJECT_ROOT, check=True, - ) - if not verify_file(model.destination, model.size, model.sha256): - raise RuntimeError("PiSA-SR export differs from the published catalog") + model.destination.parent.mkdir(parents=True, exist_ok=True) + with TemporaryDirectory(prefix=".pisa-sr-", dir=MODELS_DIR) as directory: + subprocess.run( + ["uv", "run", "--script", str(Path(__file__).with_name("pisa_sr_export.py")), + "--destination", directory], + cwd=PROJECT_ROOT, check=True, + ) + 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") + for path in Path(directory).iterdir(): + path.replace(model.destination.parent / path.name) return if not model.source_url: raise RuntimeError(f"No upstream source or builder for {model.key}") @@ -231,20 +239,23 @@ def upload_models(models=MODEL_FILES): def parse_args(): - parser = argparse.ArgumentParser() - parser.add_argument( - "--prepare-only", - action="store_true", - help="Prepare and verify models without uploading them", - ) - return parser.parse_args() + parser = argparse.ArgumentParser(description="Prepare models locally or sync them to R2.") + commands = parser.add_subparsers(dest="command") + commands.add_parser("prepare", help="Download or export models and verify them locally") + commands.add_parser("sync", help="Prepare and verify all models, then upload them to R2") + args = parser.parse_args() + if args.command is None: + parser.print_help() + return args def main(): args = parse_args() + if args.command is None: + return for model in MODEL_FILES: prepare_model(model) - if not args.prepare_only: + if args.command == "sync": upload_models() diff --git a/tools/nodes/arrangement/dimensions.json b/tools/nodes/arrangement/dimensions.json index e0ff9f8..0f84b54 100644 --- a/tools/nodes/arrangement/dimensions.json +++ b/tools/nodes/arrangement/dimensions.json @@ -18,7 +18,9 @@ "GeometryNodeBoundBox": 0, "GeometryNodeCaptureAttribute": 3, "GeometryNodeCornersOfEdge": 0, + "GeometryNodeCornersOfFace": 0, "GeometryNodeDeleteGeometry": 2, + "GeometryNodeEdgesOfCorner": 0, "GeometryNodeExtrudeMesh": 1, "GeometryNodeFaceOfCorner": 0, "GeometryNodeFieldAtIndex": 2, @@ -32,6 +34,7 @@ "GeometryNodeInputMeshEdgeNeighbors": 0, "GeometryNodeInputMeshEdgeVertices": 0, "GeometryNodeInputMeshFaceArea": 0, + "GeometryNodeInputMeshFaceNeighbors": 0, "GeometryNodeInputMeshIsland": 0, "GeometryNodeInputMeshVertexNeighbors": 0, "GeometryNodeInputNamedAttribute": 1, @@ -42,6 +45,7 @@ "GeometryNodeMergeByDistance": 1, "GeometryNodeMeshCube": 0, "GeometryNodeMeshGrid": 0, + "GeometryNodeOffsetCornerInFace": 0, "GeometryNodeRemoveAttribute": 1, "GeometryNodeRepeatInput": 0, "GeometryNodeRepeatOutput": 3, @@ -57,6 +61,7 @@ "GeometryNodeStoreNamedAttribute": 2, "GeometryNodeSwitch": 1, "GeometryNodeTransform": 1, + "GeometryNodeVertexOfCorner": 0, "NodeGroupInput": 0, "NodeGroupOutput": 0, "ShaderNodeClamp": 1, diff --git a/tools/nodes/cli.py b/tools/nodes/cli.py index f18de1d..c742f54 100644 --- a/tools/nodes/cli.py +++ b/tools/nodes/cli.py @@ -3,16 +3,13 @@ import os import shutil import subprocess import sys +import webbrowser from pathlib import Path -from tempfile import TemporaryDirectory +from tempfile import TemporaryDirectory, mkdtemp from zipfile import ZipFile PROJECT_ROOT = Path(__file__).resolve().parents[2] -BLENDER_MODULES = ( - "tools.nodes.build", - "tools.nodes.check", -) def project_env_value(name, path=None): @@ -57,22 +54,7 @@ def run(command): subprocess.run(command, cwd=PROJECT_ROOT, check=True) -def main(): - parser = argparse.ArgumentParser( - description="Build and validate O_AnyImage.blend.", - ) - parser.add_argument( - "--blender", - help="Blender executable; overrides BLENDER_BIN, .env, and PATH.", - ) - parser.add_argument( - "--skip-tests", - action="store_true", - help="Skip the Python test suite.", - ) - options = parser.parse_args() - executable = blender_path(options.blender) - +def run_blender_modules(executable, modules, arguments=()): with TemporaryDirectory(prefix="anyimage-node-dependencies-") as directory: for wheel in (PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("scipy-*.whl"): with ZipFile(wheel) as archive: @@ -83,16 +65,52 @@ def main(): f"import sys, runpy; sys.path[:0] = {paths!r}; " f"sys.path.insert(0, {directory!r} + '/cp' + str(sys.version_info.major) + str(sys.version_info.minor)); " ) - for module_name in BLENDER_MODULES: + for module_name in modules: run( ( executable, "--factory-startup", "--background", "--python-exit-code", "1", - "--python-expr", bootstrap + f"runpy.run_module({module_name!r}, run_name='__main__')", + "--python-expr", bootstrap + f"sys.argv = {[module_name, *arguments]!r}; " + + f"runpy.run_module({module_name!r}, run_name='__main__')", ) ) + + +def main(): + parser = argparse.ArgumentParser(description="Build, check, or preview node groups.") + commands = parser.add_subparsers(dest="command") + for name, help_text in ( + ("build", "Build assets, validate them, and run tests"), + ("check", "Validate saved assets and run tests"), + ("preview", "Preview node groups from current source"), + ): + command = commands.add_parser(name, help=help_text) + command.add_argument("--blender", help="Blender executable; overrides BLENDER_BIN, .env, and PATH") + if name == "preview": + command.add_argument("--output", type=Path, help="Output HTML path; defaults to a temporary directory") + command.add_argument("--no-open", action="store_true", help="Save without opening a browser") + command.add_argument("--fragment", action="store_true", help="Save an HTML fragment without opening it") + else: + command.add_argument("--skip-tests", action="store_true", help="Skip pytest, keeping asset validation") + options = parser.parse_args() + if options.command is None: + parser.print_help() + return + executable = blender_path(options.blender) + if options.command == "preview": + output = (options.output or Path(mkdtemp(prefix="anyimage-node-preview-")) / "nodes.html").resolve() + arguments = ["--output", str(output)] + if options.fragment: + arguments.append("--fragment") + run_blender_modules(executable, ("tools.nodes.preview.export",), arguments) + print(output) + if not options.no_open and not options.fragment: + webbrowser.open(output.as_uri()) + return + modules = ("tools.nodes.build", "tools.nodes.check") if options.command == "build" else ("tools.nodes.check",) + run_blender_modules(executable, modules) if not options.skip_tests: run((sys.executable, "-m", "pytest")) diff --git a/tools/nodes/common/depth_surface.py b/tools/nodes/common/depth_surface.py index 09374e2..75ab342 100644 --- a/tools/nodes/common/depth_surface.py +++ b/tools/nodes/common/depth_surface.py @@ -50,7 +50,125 @@ def _math(group, operation, a, b=None): return node.outputs[0] -def split_depth_surface(group, geometry, image, strength, uniform): +def _remove_strip_faces(group, geometry, *, triangles=False): + """Delete boundary strips from one unchanged topology snapshot.""" + nodes, links = group.nodes, group.links + + def equals(value, count): + compare = nodes.new("FunctionNodeCompare") + compare.data_type, compare.operation = "INT", "EQUAL" + links.new(value, compare.inputs[2]) + if isinstance(count, int): + compare.inputs[3].default_value = count + else: + links.new(count, compare.inputs[3]) + return compare.outputs["Result"] + + def combine(operation, a, b): + node = nodes.new("FunctionNodeIntegerMath") + node.operation = operation + links.new(a, node.inputs[0]) + links.new(b, node.inputs[1]) + return node.outputs[0] + + boundaries = [] + for offset in range(4): + index = nodes.new("GeometryNodeInputIndex") + corners = nodes.new("GeometryNodeCornersOfFace") + links.new(index.outputs[0], corners.inputs["Face Index"]) + corner = nodes.new("GeometryNodeOffsetCornerInFace") + corner.inputs["Offset"].default_value = offset + links.new(corners.outputs["Corner Index"], corner.inputs["Corner Index"]) + edge = nodes.new("GeometryNodeEdgesOfCorner") + links.new(corner.outputs["Corner Index"], edge.inputs["Corner Index"]) + sample = nodes.new("GeometryNodeFieldAtIndex") + sample.data_type, sample.domain = "INT", "EDGE" + links.new(edge.outputs["Next Edge Index"], sample.inputs["Index"]) + neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + links.new(neighbors.outputs["Face Count"], sample.inputs["Value"]) + boundaries.append(equals(sample.outputs["Value"], 1)) + opposite = combine( + "MAXIMUM", + combine("MULTIPLY", boundaries[0], boundaries[2]), + combine("MULTIPLY", boundaries[1], boundaries[3]), + ) + face = nodes.new("GeometryNodeInputMeshFaceNeighbors") + selected = combine("MULTIPLY", equals(face.outputs["Vertex Count"], 4), opposite) + if triangles: + def sample(value, index, domain): + node = nodes.new("GeometryNodeFieldAtIndex") + node.data_type, node.domain = "INT", domain + links.new(value, node.inputs["Value"]) + links.new(index, node.inputs["Index"]) + return node.outputs["Value"] + + def offset_corner(corner, offset): + node = nodes.new("GeometryNodeOffsetCornerInFace") + links.new(corner, node.inputs["Corner Index"]) + node.inputs["Offset"].default_value = offset + return node.outputs["Corner Index"] + + def vertex(corner): + node = nodes.new("GeometryNodeVertexOfCorner") + links.new(corner, node.inputs["Corner Index"]) + return node.outputs["Vertex Index"] + + def boundary(corner): + edge = nodes.new("GeometryNodeEdgesOfCorner") + links.new(corner, edge.inputs["Corner Index"]) + count = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + return equals(sample(count.outputs["Face Count"], edge.outputs["Next Edge Index"], "EDGE"), 1) + + # Evaluate each internal edge as a symmetric pair of triangles. + edge_index = nodes.new("GeometryNodeInputIndex") + sides = [] + for side in (0, 1): + corners = nodes.new("GeometryNodeCornersOfEdge") + links.new(edge_index.outputs[0], corners.inputs["Edge Index"]) + corners.inputs["Sort Index"].default_value = side + corner = corners.outputs["Corner Index"] + face_index = nodes.new("GeometryNodeFaceOfCorner") + links.new(corner, face_index.inputs["Corner Index"]) + face_size = nodes.new("GeometryNodeInputMeshFaceNeighbors") + triangle = equals(sample(face_size.outputs["Vertex Count"], face_index.outputs["Face Index"], "FACE"), 3) + end, tip = offset_corner(corner, 1), offset_corner(corner, 2) + sides.append((triangle, vertex(corner), vertex(tip), boundary(end), boundary(tip))) + a, b = sides + same_direction = equals(a[1], b[1]) + same_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[3]), combine("MULTIPLY", a[4], b[4])) + reverse_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[4]), combine("MULTIPLY", a[4], b[3])) + pair = combine("MAXIMUM", + combine("MULTIPLY", same_direction, reverse_order), + combine("MULTIPLY", equals(same_direction, 0), same_order)) + neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") + for condition in (equals(neighbors.outputs["Face Count"], 2), a[0], b[0], equals(equals(a[2], b[2]), 0)): + pair = combine("MULTIPLY", pair, condition) + + # Explicit edge-to-face sampling marks both members without averaging + # through neighboring vertices or iterating over newly exposed edges. + face_index = nodes.new("GeometryNodeInputIndex") + corners = nodes.new("GeometryNodeCornersOfFace") + links.new(face_index.outputs[0], corners.inputs["Face Index"]) + boundary_count, paired = None, None + for offset in range(3): + corner = offset_corner(corners.outputs["Corner Index"], offset) + edge = nodes.new("GeometryNodeEdgesOfCorner") + links.new(corner, edge.inputs["Corner Index"]) + match = sample(pair, edge.outputs["Next Edge Index"], "EDGE") + paired = match if paired is None else combine("MAXIMUM", paired, match) + rim = boundary(corner) + boundary_count = rim if boundary_count is None else combine("ADD", boundary_count, rim) + triangle_selected = combine("MULTIPLY", equals(corners.outputs["Total"], 3), + combine("MAXIMUM", paired, equals(boundary_count, 3))) + selected = combine("MAXIMUM", selected, triangle_selected) + delete = nodes.new("GeometryNodeDeleteGeometry") + delete.domain, delete.mode = "FACE", "ALL" + links.new(geometry, delete.inputs["Geometry"]) + links.new(selected, delete.inputs["Selection"]) + return delete.outputs["Geometry"] + + +def split_depth_surface(group, geometry, image, strength, uniform, *, triangles=False): """Keep each face's camera sample on its own corners before splitting.""" nodes, links = group.nodes, group.links uv = nodes.new("GeometryNodeInputNamedAttribute") @@ -136,7 +254,7 @@ def split_depth_surface(group, geometry, image, strength, uniform): choose.input_type = "GEOMETRY" links.new(enabled, choose.inputs["Switch"]) links.new(geometry, choose.inputs["False"]) - links.new(split.outputs["Mesh"], choose.inputs["True"]) + links.new(_remove_strip_faces(group, split.outputs["Mesh"], triangles=triangles), choose.inputs["True"]) return choose.outputs["Output"] diff --git a/tools/nodes/groups/image_depth_cutout.py b/tools/nodes/groups/image_depth_cutout.py index 7877209..64e33bd 100644 --- a/tools/nodes/groups/image_depth_cutout.py +++ b/tools/nodes/groups/image_depth_cutout.py @@ -436,7 +436,7 @@ def _build_depth_surface( nodes = group.nodes links = group.links geometry = split_depth_surface( - group, geometry, depth_image, split, uniform_scale + group, geometry, depth_image, split, uniform_scale, triangles=True ) original_camera = _position_field(group, depth_image) geometry, camera_position = store_surface_camera( @@ -517,12 +517,12 @@ def build_image_depth_cutout_group(): float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet' float_input(group, 'Uniform Thickness', 0.0, subtype='DISTANCE').description = 'Distance used only by Uniform thickness mode' float_input(group, 'Depth Scale', 1.0) + float_input(group, 'Split Threshold', 0.0, maximum=1, subtype='FACTOR') iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt') iterations.default_value = 0 iterations.min_value, iterations.max_value = (0, 20) options = group.interface.new_panel(name='Options', default_closed=True) float_input(group, 'Cleanup Threshold', 0.0, parent=options) - float_input(group, 'Split Threshold', 0.0, maximum=1, subtype='FACTOR', parent=options) float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options) float_input(group, 'Edge Round', 0.2, maximum=1, subtype='FACTOR', parent=options) float_input(group, 'Front Inflation', 1.0, maximum=32, parent=options) diff --git a/tools/nodes/groups/image_depth_plane.py b/tools/nodes/groups/image_depth_plane.py index 4b4da8d..1d34364 100644 --- a/tools/nodes/groups/image_depth_plane.py +++ b/tools/nodes/groups/image_depth_plane.py @@ -36,6 +36,7 @@ def build_image_depth_plane_group(image_plane_group): depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat") depth_scale.default_value = 1.0 depth_scale.min_value = 0.0 + float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR") iterations = _interface_socket(group, "Smooth", "INPUT", "NodeSocketInt") iterations.default_value = 0 iterations.min_value, iterations.max_value = 0, 20 @@ -65,7 +66,6 @@ def build_image_depth_plane_group(image_plane_group): validity_threshold.default_value = 0.9 validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0 validity_threshold.subtype = "FACTOR" - float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR", parent=options) data = group.interface.new_panel(name="Data", default_closed=True) uniform_scale = _interface_socket( group,