feat: Implement depth surface strip cleanup and organize tool CLI
- Added a proposal for depth surface strip cleanup, detailing the rationale and changes to the depth plane and cutout nodes. - Created specifications for the new cleanup requirements, including scenarios for strip removal and boundary edge detection. - Developed tasks for implementing shared strip detection logic and integrating new interfaces for depth nodes. - Established a new CLI structure for organizing node groups and model commands, replacing old entry points with a more intuitive command structure. - Introduced a prototype for panorama surface generation, including requirements for distance reconstruction and geometry node surfaces. - Added comprehensive tests for depth strip cleanup functionality, ensuring correct behavior for various mesh topologies and edge cases.
This commit is contained in:
15
AGENTS.md
15
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)。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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` 中配置:
|
||||
|
||||
3
experiments/panorama_surface/.gitignore
vendored
Normal file
3
experiments/panorama_surface/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/output/
|
||||
/analytic/
|
||||
/photo/
|
||||
5
experiments/panorama_surface/ATTRIBUTION.md
Normal file
5
experiments/panorama_surface/ATTRIBUTION.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# 素材来源
|
||||
|
||||
真实照片试验使用 DerMische 的 [Braunschweig Sankt-Ägidien Panorama 02](https://commons.wikimedia.org/wiki/File:Braunschweig_Sankt-%C3%84gidien_Panorama_02.jpg),许可为 [CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/)。使用 Wikimedia 的 1280×640 缩略图作为输入,并通过 MoGe2 估计距离、重投影和生成网格。照片及相应衍生预览、带照片纹理的网格遵循该许可,作者不为本试验背书。
|
||||
|
||||
多视图投影与梯度域距离融合参考 [microsoft/MoGe 的 panorama.py](https://github.com/microsoft/MoGe/blob/main/moge/utils/panorama.py);固定视角几何恢复参考 [MoGe v2](https://github.com/microsoft/MoGe/blob/main/moge/model/v2.py)。试验独立实现这些计算。
|
||||
185
experiments/panorama_surface/blender_scene.py
Normal file
185
experiments/panorama_surface/blender_scene.py
Normal file
@@ -0,0 +1,185 @@
|
||||
"""Build or verify a standalone panorama Geometry Nodes demonstration offline."""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from quad_surface import build_node_group
|
||||
from validation import evaluated_points, socket_id, verify
|
||||
|
||||
|
||||
def create_distance_image(name, path, output_path):
|
||||
with np.load(path) as data:
|
||||
distance, alpha = data["distance"], data["alpha"]
|
||||
height, width = distance.shape
|
||||
image = bpy.data.images.new(name, width=width, height=height, alpha=True, float_buffer=True)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image.alpha_mode = "CHANNEL_PACKED"
|
||||
rgba = np.concatenate((np.repeat(distance[..., None], 3, -1), alpha[..., None]), -1)
|
||||
image.pixels.foreach_set(rgba[::-1].astype(np.float32).ravel())
|
||||
image.filepath_raw = str(output_path)
|
||||
image.file_format = "OPEN_EXR"
|
||||
image.save()
|
||||
image.pack()
|
||||
return image
|
||||
|
||||
|
||||
def create_material(image):
|
||||
material = bpy.data.materials.new("Panorama Color")
|
||||
material.use_nodes = True
|
||||
material.node_tree.nodes.clear()
|
||||
nodes, links = material.node_tree.nodes, material.node_tree.links
|
||||
uv = nodes.new("ShaderNodeUVMap")
|
||||
uv.uv_map = "UVMap"
|
||||
uv.location = (0, 0)
|
||||
texture = nodes.new("ShaderNodeTexImage")
|
||||
texture.image = image
|
||||
texture.extension = "REPEAT"
|
||||
texture.location = (200, 0)
|
||||
emission = nodes.new("ShaderNodeEmission")
|
||||
emission.location = (500, 0)
|
||||
output = nodes.new("ShaderNodeOutputMaterial")
|
||||
output.location = (700, 0)
|
||||
links.new(uv.outputs[0], texture.inputs[0])
|
||||
links.new(texture.outputs[0], emission.inputs[0])
|
||||
links.new(emission.outputs[0], output.inputs[0])
|
||||
return material
|
||||
|
||||
|
||||
def build_scene(output):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = build_node_group()
|
||||
color = bpy.data.images.load(str(output / "panorama.png"))
|
||||
color.pack()
|
||||
material = create_material(color)
|
||||
objects = []
|
||||
for name, filename, offset in (("Reference Room", "reference.npz", -4),
|
||||
("MoGe2 Panorama", "prediction.npz", 4)):
|
||||
if not (output / filename).exists():
|
||||
continue
|
||||
depth_path = output / ("reference_depth.exr" if filename == "reference.npz" else "depth.exr")
|
||||
distance = create_distance_image(depth_path.name, output / filename, depth_path)
|
||||
mesh = bpy.data.meshes.new("Panorama Host")
|
||||
obj = bpy.data.objects.new(name, mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
obj.location.x = offset
|
||||
obj.data.materials.append(material)
|
||||
modifier = obj.modifiers.new("Panorama Surface", "NODES")
|
||||
modifier.node_group = group
|
||||
modifier[socket_id(group, "Distance")] = distance
|
||||
modifier[socket_id(group, "Subdivide")] = 7
|
||||
modifier[socket_id(group, "Depth Scale")] = 1.0
|
||||
modifier[socket_id(group, "Validity Threshold")] = 0.9
|
||||
modifier[socket_id(group, "Material")] = material
|
||||
objects.append(obj)
|
||||
for obj in objects:
|
||||
obj.select_set(False)
|
||||
objects[-1].select_set(True)
|
||||
bpy.context.view_layer.objects.active = objects[-1]
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "CYCLES"
|
||||
scene.cycles.samples = 16
|
||||
scene.render.resolution_x, scene.render.resolution_y = 1400, 750
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.world = bpy.data.worlds.new("Trial World")
|
||||
scene.world.color = (0.18, 0.18, 0.18)
|
||||
scene.view_settings.view_transform = "Standard"
|
||||
camera_data = bpy.data.cameras.new("Overview")
|
||||
camera = bpy.data.objects.new("Overview", camera_data)
|
||||
bpy.context.collection.objects.link(camera)
|
||||
camera.location = (11, -17, 13)
|
||||
camera.rotation_euler = (Vector((0, 0, 0)) - camera.location).to_track_quat("-Z", "Y").to_euler()
|
||||
camera_data.type, camera_data.ortho_scale = "ORTHO", 20
|
||||
if len(objects) == 1:
|
||||
points, _ = evaluated_points(objects[0])
|
||||
points += np.array(objects[0].location)
|
||||
center = (points.min(0) + points.max(0)) / 2
|
||||
extent = float(np.max(np.ptp(points, axis=0)))
|
||||
camera.location = Vector(center) + Vector((0.6, -1, 1)) * extent
|
||||
camera.rotation_euler = (Vector(center) - camera.location).to_track_quat("-Z", "Y").to_euler()
|
||||
camera_data.ortho_scale = extent * 1.8
|
||||
scene.camera = camera
|
||||
capture_camera = None
|
||||
if len(objects) == 1:
|
||||
capture_data = bpy.data.cameras.new("Capture Center")
|
||||
capture_data.lens = 22
|
||||
capture_camera = bpy.data.objects.new("Capture Center", capture_data)
|
||||
bpy.context.collection.objects.link(capture_camera)
|
||||
capture_camera.location = objects[0].location
|
||||
capture_camera.rotation_euler = Vector((-1, 0, 0.15)).to_track_quat("-Z", "Y").to_euler()
|
||||
bpy.data.texts.new("Attribution").write(
|
||||
(Path(__file__).parent / "ATTRIBUTION.md").read_text(encoding="utf-8"))
|
||||
bpy.data.texts.new("试验说明").write(
|
||||
"Subdivide 控制 Quad Sphere 细分,全有效面数为 6 × 4^Subdivide。\n"
|
||||
"Depth Scale:0 为单位球面,1 为全景距离重建。\n"
|
||||
"深度 EXR 的 Alpha 已包含 Mask × 源图 Alpha,低于 Validity Threshold 的区域会被剔除。\n"
|
||||
"Quad Sphere 来自 O_Essentials,节点组和依赖已打包。\n"
|
||||
)
|
||||
for screen in bpy.data.screens:
|
||||
for area in screen.areas:
|
||||
if area.type == "VIEW_3D":
|
||||
area.spaces.active.shading.type = "MATERIAL"
|
||||
area.spaces.active.region_3d.view_distance = extent * 1.4 if len(objects) == 1 else 18
|
||||
if len(objects) == 1:
|
||||
area.spaces.active.region_3d.view_location = Vector(center)
|
||||
area.spaces.active.region_3d.view_rotation = camera.rotation_euler.to_quaternion()
|
||||
verify(output)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(output / "panorama_trial.blend"))
|
||||
# The render uses a temporary inspection cut; the saved asset retains the full shell.
|
||||
for obj in objects:
|
||||
if len(objects) == 1:
|
||||
continue
|
||||
modifier = obj.modifiers.new("Inspection Cut", "NODES")
|
||||
cut_group = bpy.data.node_groups.new(f"{obj.name} Inspection", "GeometryNodeTree")
|
||||
cut_group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
|
||||
cut_group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = cut_group.nodes.new("NodeGroupInput")
|
||||
position = cut_group.nodes.new("GeometryNodeInputPosition")
|
||||
xyz = cut_group.nodes.new("ShaderNodeSeparateXYZ")
|
||||
above = cut_group.nodes.new("ShaderNodeMath")
|
||||
above.operation = "GREATER_THAN"
|
||||
above.inputs[1].default_value = 0.4
|
||||
front = cut_group.nodes.new("ShaderNodeMath")
|
||||
front.operation = "LESS_THAN"
|
||||
front.inputs[1].default_value = -0.7
|
||||
either = cut_group.nodes.new("ShaderNodeMath")
|
||||
either.operation = "MAXIMUM"
|
||||
remove = cut_group.nodes.new("GeometryNodeDeleteGeometry")
|
||||
remove.domain = "FACE"
|
||||
result = cut_group.nodes.new("NodeGroupOutput")
|
||||
for a, b in ((position.outputs[0], xyz.inputs[0]),
|
||||
(xyz.outputs["Z"], above.inputs[0]), (xyz.outputs["Y"], front.inputs[0]),
|
||||
(above.outputs[0], either.inputs[0]), (front.outputs[0], either.inputs[1]),
|
||||
(source.outputs[0], remove.inputs["Geometry"]),
|
||||
(either.outputs[0], remove.inputs["Selection"]),
|
||||
(remove.outputs[0], result.inputs[0])):
|
||||
cut_group.links.new(a, b)
|
||||
modifier.node_group = cut_group
|
||||
scene.render.filepath = str(output / "comparison.png")
|
||||
bpy.ops.render.render(write_still=True)
|
||||
if capture_camera is not None:
|
||||
scene.camera = capture_camera
|
||||
scene.render.filepath = str(output / "capture_view.png")
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--verify", action="store_true")
|
||||
args = parser.parse_args(sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
|
||||
output = args.output.resolve()
|
||||
if args.verify:
|
||||
bpy.ops.wm.open_mainfile(filepath=str(output / "panorama_trial.blend"))
|
||||
verify(output)
|
||||
else:
|
||||
build_scene(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
147
experiments/panorama_surface/geometry.py
Normal file
147
experiments/panorama_surface/geometry.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Spherical projection and gradient-domain distance fusion for an isolated trial."""
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import map_coordinates
|
||||
from scipy.sparse import coo_matrix, diags
|
||||
from scipy.sparse.linalg import spsolve
|
||||
|
||||
|
||||
def panorama_directions(width, height):
|
||||
u, v = np.meshgrid((np.arange(width) + 0.5) / width,
|
||||
(np.arange(height) + 0.5) / height)
|
||||
return uv_directions(u, v)
|
||||
|
||||
|
||||
def uv_directions(u, v):
|
||||
theta, phi = 2 * np.pi * u, np.pi * v
|
||||
return np.stack((np.sin(phi) * np.cos(theta),
|
||||
np.sin(phi) * np.sin(theta), np.cos(phi)), axis=-1)
|
||||
|
||||
|
||||
def camera_bases():
|
||||
ratio = (1 + np.sqrt(5)) / 2
|
||||
vertices = []
|
||||
for a in (-1, 1):
|
||||
for b in (-ratio, ratio):
|
||||
vertices.extend(((0, a, b), (a, b, 0), (b, 0, a)))
|
||||
bases = []
|
||||
for forward in np.asarray(vertices):
|
||||
forward = forward / np.linalg.norm(forward)
|
||||
right = np.cross(forward, (0, 0, 1))
|
||||
right /= np.linalg.norm(right)
|
||||
down = np.cross(forward, right)
|
||||
bases.append(np.stack((right, down, forward)))
|
||||
return bases
|
||||
|
||||
|
||||
def view_rays(size):
|
||||
x, y = np.meshgrid(2 * (np.arange(size) + 0.5) / size - 1,
|
||||
2 * (np.arange(size) + 0.5) / size - 1)
|
||||
return np.stack((x, y, np.ones_like(x)), axis=-1)
|
||||
|
||||
|
||||
def sample_panorama(image, directions):
|
||||
directions = directions / np.linalg.norm(directions, axis=-1, keepdims=True)
|
||||
u = np.arctan2(directions[..., 1], directions[..., 0]) / (2 * np.pi) % 1
|
||||
v = np.arccos(np.clip(directions[..., 2], -1, 1)) / np.pi
|
||||
height, width = image.shape[:2]
|
||||
padded = np.concatenate((image[:, -1:], image, image[:, :1]), axis=1)
|
||||
coords = (np.clip(v * height - 0.5, 0, height - 1), u * width + 0.5)
|
||||
if image.ndim == 2:
|
||||
return map_coordinates(padded, coords, order=1, mode="nearest")
|
||||
return np.stack([map_coordinates(padded[..., c], coords, order=1,
|
||||
mode="nearest") for c in range(image.shape[2])], -1)
|
||||
|
||||
|
||||
def room_reference(directions):
|
||||
"""Trace a furnished rectangular room from a camera 1.6 m above its floor."""
|
||||
directions = directions / np.linalg.norm(directions, axis=-1, keepdims=True)
|
||||
bounds = np.where(directions >= 0, (3.0, 4.0, 1.4), (-3.0, -4.0, -1.6))
|
||||
hits = bounds / np.where(np.abs(directions) > 1e-12, directions, 1e-12)
|
||||
hits = np.where(hits > 0, hits, np.inf)
|
||||
surface = np.argmin(hits, axis=-1)
|
||||
distance = np.min(hits, axis=-1)
|
||||
point = directions * distance[..., None]
|
||||
colors = np.array(((0.68, 0.76, 0.80), (0.81, 0.73, 0.61), (0.9, 0.88, 0.82)))
|
||||
color = colors[surface].copy()
|
||||
floor = (surface == 2) & (directions[..., 2] < 0)
|
||||
tiles = (np.floor(point[..., 0] * 2) + np.floor(point[..., 1] * 2)) % 2
|
||||
color[floor] = np.stack((0.40 + 0.08 * tiles, 0.27 + 0.06 * tiles,
|
||||
0.15 + 0.04 * tiles), -1)[floor]
|
||||
lines = (np.abs(np.sin(point[..., 0] * np.pi * 2)) < 0.04) | (
|
||||
np.abs(np.sin(point[..., 1] * np.pi * 2)) < 0.04)
|
||||
color[floor & lines] *= 0.6
|
||||
window = (surface == 1) & (directions[..., 1] > 0) & (
|
||||
np.abs(point[..., 0] + 0.4) < 1.05) & (np.abs(point[..., 2] - 0.25) < 0.7)
|
||||
color[window] = (0.48, 0.72, 0.88)
|
||||
frame = window & ((np.abs(point[..., 0] + 0.4) < 0.045) |
|
||||
(np.abs(point[..., 2] - 0.25) < 0.04))
|
||||
color[frame] = 0.93
|
||||
for lower, upper, base_color in (
|
||||
((0.8, 1.0, -1.6), (2.0, 2.2, -0.6), (0.28, 0.43, 0.34)),
|
||||
((-2.8, -1.8, -1.6), (-2.1, 0.5, 0.7), (0.43, 0.26, 0.15)),
|
||||
):
|
||||
safe = np.where(np.abs(directions) > 1e-12, directions, 1e-12)
|
||||
a, b = np.asarray(lower) / safe, np.asarray(upper) / safe
|
||||
near, far = np.max(np.minimum(a, b), axis=-1), np.min(np.maximum(a, b), axis=-1)
|
||||
visible = (near > 0) & (near < far) & (near < distance)
|
||||
distance[visible] = near[visible]
|
||||
color[visible] = base_color
|
||||
color *= (0.75 + 0.25 * np.clip(directions[..., 2] + 0.6, 0, 1))[..., None]
|
||||
return distance, color
|
||||
|
||||
|
||||
def warp_distances(distances, masks, bases, width, height):
|
||||
directions = panorama_directions(width, height)
|
||||
logs, validities = [], []
|
||||
for distance, mask, basis in zip(distances, masks, bases):
|
||||
rays = directions @ basis.T
|
||||
projected = rays[..., :2] / np.maximum(rays[..., 2:], 1e-8)
|
||||
valid = (rays[..., 2] > 0) & (np.abs(projected) < 0.98).all(axis=-1)
|
||||
size = distance.shape[0]
|
||||
coords = ((projected[..., 1] + 1) * size / 2 - 0.5,
|
||||
(projected[..., 0] + 1) * size / 2 - 0.5)
|
||||
valid &= map_coordinates(mask.astype(float), coords, order=0, mode="nearest") > 0.5
|
||||
logs.append(map_coordinates(np.log(np.maximum(distance, 1e-6)), coords,
|
||||
order=1, mode="nearest"))
|
||||
validities.append(valid)
|
||||
return np.asarray(logs), np.asarray(validities)
|
||||
|
||||
|
||||
def merge_distances(distances, masks, bases, width, height):
|
||||
"""Fuse log-distance derivatives with a cyclic longitude and a scale anchor.
|
||||
|
||||
Algorithm reference: microsoft/MoGe, moge/utils/panorama.py.
|
||||
This independent implementation solves normal equations on a small trial grid.
|
||||
"""
|
||||
logs, valid = warp_distances(distances, masks, bases, width, height)
|
||||
count = width * height
|
||||
index = np.arange(count).reshape(height, width)
|
||||
start = np.concatenate((index.ravel(), index[:-1].ravel()))
|
||||
end = np.concatenate((np.roll(index, -1, axis=1).ravel(), index[1:].ravel()))
|
||||
rows = np.arange(len(start))
|
||||
gradient = coo_matrix((np.concatenate((-np.ones(len(start)), np.ones(len(start)))),
|
||||
(np.tile(rows, 2), np.concatenate((start, end)))),
|
||||
shape=(len(start), count)).tocsr()
|
||||
laplacian = -(gradient.T @ gradient).tocsr()
|
||||
edge_valid = valid.reshape(12, -1)[:, start] & valid.reshape(12, -1)[:, end]
|
||||
grad_samples = logs.reshape(12, -1)[:, end] - logs.reshape(12, -1)[:, start]
|
||||
grad_target = np.sum(grad_samples * edge_valid, axis=0) / np.maximum(edge_valid.sum(0), 1)
|
||||
lap_valid = valid.copy()
|
||||
lap_valid &= np.roll(valid, 1, axis=2) & np.roll(valid, -1, axis=2)
|
||||
lap_valid[:, 1:] &= valid[:, :-1]
|
||||
lap_valid[:, :-1] &= valid[:, 1:]
|
||||
lap_valid = lap_valid.reshape(12, -1)
|
||||
lap_samples = np.asarray((laplacian @ logs.reshape(12, -1).T).T)
|
||||
lap_target = np.sum(lap_samples * lap_valid, axis=0) / np.maximum(lap_valid.sum(0), 1)
|
||||
g = gradient[edge_valid.any(0)]
|
||||
l = laplacian[lap_valid.any(0)]
|
||||
coverage = valid.any(0)
|
||||
if not coverage.any():
|
||||
raise ValueError("No valid panorama predictions")
|
||||
baseline = np.sum(logs * valid, axis=0) / np.maximum(valid.sum(0), 1)
|
||||
matrix = g.T @ g + l.T @ l + diags(np.full(count, 1e-7))
|
||||
target = g.T @ grad_target[edge_valid.any(0)] + l.T @ lap_target[lap_valid.any(0)]
|
||||
solution = spsolve(matrix.tocsc(), target + 1e-7 * baseline.ravel()).reshape(height, width)
|
||||
solution += np.median(baseline[coverage] - solution[coverage])
|
||||
return np.exp(solution).astype(np.float32), coverage
|
||||
144
experiments/panorama_surface/prototype.py
Normal file
144
experiments/panorama_surface/prototype.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Run MoGe2 panorama inference without importing or changing the add-on."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy.optimize import least_squares
|
||||
|
||||
from geometry import (camera_bases, merge_distances, panorama_directions,
|
||||
room_reference, sample_panorama, view_rays)
|
||||
|
||||
|
||||
def fixed_fov_distance(points, mask, metric_scale):
|
||||
"""Recover camera Z shift using the known 90-degree perspective rays."""
|
||||
size = points.shape[0]
|
||||
rays = view_rays(size)
|
||||
select = mask > 0.5
|
||||
select &= np.isfinite(points).all(-1)
|
||||
if select.sum() < 16:
|
||||
return np.ones((size, size), dtype=np.float32), np.zeros((size, size), dtype=bool)
|
||||
stride = max(1, size // 64)
|
||||
sampled = select[::stride, ::stride]
|
||||
p = points[::stride, ::stride][sampled].astype(float)
|
||||
q = rays[::stride, ::stride, :2][sampled]
|
||||
initial = np.sum(q * (p[:, :2] - q * p[:, 2:])) / max(np.sum(q * q), 1e-8)
|
||||
lower = -float(points[..., 2][select].min()) + 1e-5
|
||||
fitted = least_squares(lambda shift: (p[:, :2] / (p[:, 2:] + shift[0]) - q).ravel(),
|
||||
[max(initial, lower + 1e-4)], bounds=([lower], [np.inf]))
|
||||
depth = (points[..., 2] + fitted.x[0]) * float(metric_scale)
|
||||
select &= (depth > 0) & np.isfinite(depth)
|
||||
distance = np.maximum(depth, 1e-6) * np.linalg.norm(rays, axis=-1)
|
||||
return distance.astype(np.float32), select
|
||||
|
||||
|
||||
def depth_alpha(image, validity):
|
||||
"""Combine the model mask with nearest-resized source alpha once at export."""
|
||||
height, width = validity.shape
|
||||
if image.shape[-1] == 4:
|
||||
source = Image.fromarray(image[..., 3].astype(np.float32))
|
||||
alpha = np.asarray(source.resize((width, height), Image.Resampling.NEAREST))
|
||||
else:
|
||||
alpha = np.ones((height, width), dtype=np.float32)
|
||||
return np.asarray(validity, dtype=np.float32) * alpha
|
||||
|
||||
|
||||
def remove_background(image):
|
||||
"""Use the project's existing BEN2 implementation for the trial input."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(root / "src/anyimage/server/models"))
|
||||
from onnx_ben2 import create_session, infer
|
||||
|
||||
session = create_session(root / "models/BEN2-ONNX", "cpu")
|
||||
return infer(session, image, release_memory=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--image", type=Path)
|
||||
parser.add_argument("--model", type=Path, default=Path("models/moge-2-vits-normal-onnx/model.onnx"))
|
||||
parser.add_argument("--output", type=Path, default=Path(__file__).parent / "output")
|
||||
parser.add_argument("--size", type=int, default=256)
|
||||
parser.add_argument("--width", type=int, default=256)
|
||||
parser.add_argument("--reference-only", action="store_true")
|
||||
parser.add_argument("--remove-background", action="store_true")
|
||||
args = parser.parse_args()
|
||||
args.output.mkdir(parents=True, exist_ok=True)
|
||||
width, height = args.width, args.width // 2
|
||||
directions = panorama_directions(width, height)
|
||||
truth, _ = room_reference(directions)
|
||||
if args.image:
|
||||
with Image.open(args.image) as source:
|
||||
source = source.convert("RGBA")
|
||||
if args.remove_background:
|
||||
foreground = remove_background(source)
|
||||
original_alpha = np.asarray(source.getchannel("A"), dtype=np.float32) / 255
|
||||
alpha = np.asarray(foreground.getchannel("A"), dtype=np.float32) * original_alpha
|
||||
source.putalpha(Image.fromarray(alpha.astype(np.uint8)))
|
||||
image = np.asarray(source, dtype=np.float32) / 255
|
||||
if image.shape[1] != 2 * image.shape[0]:
|
||||
raise ValueError("Expected a full 2:1 equirectangular panorama")
|
||||
else:
|
||||
_, image = room_reference(panorama_directions(1024, 512))
|
||||
np.savez_compressed(args.output / "reference.npz", distance=truth.astype(np.float32),
|
||||
alpha=np.ones_like(truth, dtype=np.float32))
|
||||
Image.fromarray(np.uint8(np.clip(image, 0, 1) * 255)).save(args.output / "panorama.png")
|
||||
bases = camera_bases()
|
||||
distances, masks, panels = [], [], []
|
||||
start = time.perf_counter()
|
||||
if not args.reference_only:
|
||||
import onnxruntime as ort
|
||||
options = ort.SessionOptions()
|
||||
options.intra_op_num_threads = 8
|
||||
session = ort.InferenceSession(str(args.model), options, providers=["CPUExecutionProvider"])
|
||||
scale_name = next(item.name for item in session.get_outputs()
|
||||
if item.name in {"scale", "metric_scale"})
|
||||
for index, basis in enumerate(bases):
|
||||
rays = view_rays(args.size) @ basis
|
||||
view = sample_panorama(image, rays).astype(np.float32)
|
||||
source_valid = np.ones(view.shape[:2], dtype=bool)
|
||||
if view.shape[-1] == 4:
|
||||
source_valid = view[..., 3] > 0
|
||||
view = view[..., :3] * view[..., 3:]
|
||||
panels.append(np.uint8(np.clip(view, 0, 1) * 255))
|
||||
if args.reference_only:
|
||||
distance, _ = room_reference(rays)
|
||||
mask = np.ones_like(distance, dtype=bool)
|
||||
else:
|
||||
prediction = session.run(["points", "mask", scale_name],
|
||||
{"image": view.transpose(2, 0, 1)[None],
|
||||
"num_tokens": np.array(1200, dtype=np.int64)})
|
||||
distance, mask = fixed_fov_distance(prediction[0][0], prediction[1][0] * source_valid,
|
||||
prediction[2].reshape(-1)[0])
|
||||
mask &= source_valid
|
||||
distances.append(distance)
|
||||
masks.append(mask)
|
||||
print(f"View {index + 1}/12: valid={mask.mean():.3f}, elapsed={time.perf_counter()-start:.1f}s", flush=True)
|
||||
Image.fromarray(np.concatenate([np.concatenate(panels[i:i + 4], axis=1)
|
||||
for i in range(0, 12, 4)], axis=0)).save(args.output / "views.png")
|
||||
np.savez_compressed(args.output / "views.npz", distances=distances, masks=masks)
|
||||
distance, validity = merge_distances(distances, masks, bases, width, height)
|
||||
combined_alpha = depth_alpha(image, validity)
|
||||
np.savez_compressed(args.output / "prediction.npz", distance=distance, alpha=combined_alpha)
|
||||
stats = {"mode": "analytic" if args.reference_only else args.model.parent.name + "-cpu",
|
||||
"input": str(args.image) if args.image else "synthetic room",
|
||||
"background_removal": "BEN2" if args.remove_background else None,
|
||||
"size": [width, height], "valid_fraction": float(validity.mean()),
|
||||
"depth_alpha_valid_fraction": float((combined_alpha >= 0.9).mean()),
|
||||
"seconds": time.perf_counter() - start,
|
||||
"distance_range": [float(distance.min()), float(distance.max())]}
|
||||
if not args.image:
|
||||
ratio = np.median(truth[validity] / distance[validity])
|
||||
stats["reference_scale_alignment"] = float(ratio)
|
||||
stats["aligned_mean_relative_error"] = float(np.mean(np.abs(distance[validity] * ratio / truth[validity] - 1)))
|
||||
stats["aligned_seam_mean_relative_error"] = float(np.mean(np.abs(distance[:, [0, -1]] * ratio / truth[:, [0, -1]] - 1)))
|
||||
(args.output / "inference.json").write_text(json.dumps(stats, indent=2), encoding="utf-8")
|
||||
print(json.dumps(stats, indent=2), flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
151
experiments/panorama_surface/quad_surface.py
Normal file
151
experiments/panorama_surface/quad_surface.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Build an offline Quad Sphere panorama asset with point-domain validity culling."""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
from tools.nodes.common.nodes import interface_socket, prepare_node_group
|
||||
|
||||
|
||||
REFERENCE_LIBRARY = Path("D:/Projects/OmooNodes/subspaces/O_Essentials/O_Essentials_GeometryNodes.blend1")
|
||||
|
||||
|
||||
def build_node_group():
|
||||
with bpy.data.libraries.load(str(REFERENCE_LIBRARY), link=False) as (source, target):
|
||||
if "O Quad Sphere" not in source.node_groups:
|
||||
raise ValueError("Reference library has no O Quad Sphere")
|
||||
target.node_groups = ["O Quad Sphere"]
|
||||
group = bpy.data.node_groups.new("Panorama Surface Trial", "GeometryNodeTree")
|
||||
group.is_modifier = True
|
||||
for name, kind in (("Subdivide", "NodeSocketInt"), ("Depth Scale", "NodeSocketFloat"),
|
||||
("Validity Threshold", "NodeSocketFloat"),
|
||||
("Distance", "NodeSocketImage"), ("Material", "NodeSocketMaterial")):
|
||||
socket = interface_socket(group, name, "INPUT", kind)
|
||||
if name == "Subdivide":
|
||||
socket.default_value, socket.min_value, socket.max_value = 7, 0, 8
|
||||
elif name == "Depth Scale":
|
||||
socket.default_value, socket.min_value, socket.max_value = 1.0, 0.0, 1.0
|
||||
socket.subtype = "FACTOR"
|
||||
elif name == "Validity Threshold":
|
||||
socket.default_value, socket.min_value, socket.max_value = 0.9, 0.0, 1.0
|
||||
socket.subtype = "FACTOR"
|
||||
elif name == "Material":
|
||||
socket.hide_in_modifier = True
|
||||
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
|
||||
links = group.links
|
||||
|
||||
def node(kind, x, y):
|
||||
result = group.nodes.new(kind)
|
||||
result.location = (x, y)
|
||||
return result
|
||||
|
||||
def math(operation, a, b, x, y):
|
||||
result = node("ShaderNodeMath", x, y)
|
||||
result.operation = operation
|
||||
for index, value in enumerate((a, b)):
|
||||
if isinstance(value, (float, int)):
|
||||
result.inputs[index].default_value = value
|
||||
else:
|
||||
links.new(value, result.inputs[index])
|
||||
return result.outputs[0]
|
||||
|
||||
def store(geometry, name, field, domain, x, y):
|
||||
result = node("GeometryNodeStoreNamedAttribute", x, y)
|
||||
result.data_type, result.domain = "FLOAT_VECTOR", domain
|
||||
result.inputs["Name"].default_value = name
|
||||
links.new(geometry, result.inputs["Geometry"])
|
||||
links.new(field, result.inputs["Value"])
|
||||
return result.outputs["Geometry"]
|
||||
|
||||
def read(name, x, y):
|
||||
result = node("GeometryNodeInputNamedAttribute", x, y)
|
||||
result.data_type = "FLOAT_VECTOR"
|
||||
result.inputs["Name"].default_value = name
|
||||
return result.outputs["Attribute"]
|
||||
|
||||
inputs = node("NodeGroupInput", 0, 400)
|
||||
segments = math("POWER", 2, inputs.outputs["Subdivide"], 0, 150)
|
||||
segments = math("ADD", segments, 1, 180, 150)
|
||||
sphere = node("GeometryNodeGroup", 400, 400)
|
||||
sphere.node_tree = target.node_groups[0]
|
||||
sphere.inputs["Radius"].default_value = 1
|
||||
links.new(segments, sphere.inputs["Segments"])
|
||||
position = node("GeometryNodeInputPosition", 400, 100)
|
||||
geometry = store(sphere.outputs["Mesh"], "panorama_direction", position.outputs[0], "POINT", 620, 400)
|
||||
xyz = node("ShaderNodeSeparateXYZ", 620, 100)
|
||||
links.new(read("panorama_direction", 400, -80), xyz.inputs[0])
|
||||
angle = math("ARCTAN2", xyz.outputs["Y"], xyz.outputs["X"], 800, 100)
|
||||
longitude = math("DIVIDE", angle, 6.283185307179586, 980, 100)
|
||||
longitude = math("FRACT", math("ADD", longitude, 1, 1160, 100), 0, 1340, 100)
|
||||
latitude = math("ARCCOSINE", xyz.outputs["Z"], 0, 800, -100)
|
||||
latitude = math("SUBTRACT", 1, math("DIVIDE", latitude, 3.141592653589793, 980, -100), 1160, -100)
|
||||
uv = node("ShaderNodeCombineXYZ", 1540, 100)
|
||||
links.new(longitude, uv.inputs["X"])
|
||||
links.new(latitude, uv.inputs["Y"])
|
||||
geometry = store(geometry, "panorama_sample_uv", uv.outputs[0], "POINT", 1540, 400)
|
||||
|
||||
# Each face chooses its own longitude branch; pole corners use the face longitude.
|
||||
face_position = node("GeometryNodeFieldOnDomain", 800, -400)
|
||||
face_position.data_type, face_position.domain = "FLOAT_VECTOR", "FACE"
|
||||
links.new(position.outputs[0], face_position.inputs["Value"])
|
||||
face_xyz = node("ShaderNodeSeparateXYZ", 980, -400)
|
||||
links.new(face_position.outputs[0], face_xyz.inputs[0])
|
||||
face_angle = math("ARCTAN2", face_xyz.outputs["Y"], face_xyz.outputs["X"], 1160, -400)
|
||||
face_u = math("DIVIDE", face_angle, 6.283185307179586, 1340, -400)
|
||||
correction = math("ROUND", math("SUBTRACT", face_u, longitude, 1540, -300), 0, 1720, -300)
|
||||
corner_u = math("ADD", longitude, correction, 1900, -300)
|
||||
pole = math("GREATER_THAN", math("ABSOLUTE", xyz.outputs["Z"], 0, 1540, -520), 0.999999, 1720, -520)
|
||||
choose_u = node("GeometryNodeSwitch", 2080, -300)
|
||||
choose_u.input_type = "FLOAT"
|
||||
links.new(pole, choose_u.inputs["Switch"])
|
||||
links.new(corner_u, choose_u.inputs["False"])
|
||||
links.new(face_u, choose_u.inputs["True"])
|
||||
corner_uv = node("ShaderNodeCombineXYZ", 2300, -300)
|
||||
links.new(choose_u.outputs[0], corner_uv.inputs["X"])
|
||||
links.new(latitude, corner_uv.inputs["Y"])
|
||||
geometry = store(geometry, "UVMap", corner_uv.outputs[0], "CORNER", 2300, 400)
|
||||
|
||||
samples = node("NodeGroupInput", 2520, -150)
|
||||
sample_uv = read("panorama_sample_uv", 2520, -400)
|
||||
texture = node("GeometryNodeImageTexture", 2740, 0)
|
||||
texture.interpolation, texture.extension = "Closest", "EXTEND"
|
||||
links.new(samples.outputs["Distance"], texture.inputs["Image"])
|
||||
links.new(sample_uv, texture.inputs["Vector"])
|
||||
depth = node("ShaderNodeSeparateXYZ", 2960, 0)
|
||||
links.new(texture.outputs["Color"], depth.inputs[0])
|
||||
blend = node("ShaderNodeMapRange", 3180, 0)
|
||||
blend.clamp = True
|
||||
blend.inputs["To Min"].default_value = 1
|
||||
links.new(inputs.outputs["Depth Scale"], blend.inputs["Value"])
|
||||
links.new(depth.outputs["X"], blend.inputs["To Max"])
|
||||
radial = node("ShaderNodeVectorMath", 3400, 0)
|
||||
radial.operation = "SCALE"
|
||||
links.new(read("panorama_direction", 3180, -260), radial.inputs[0])
|
||||
links.new(blend.outputs["Result"], radial.inputs[3])
|
||||
deform = node("GeometryNodeSetPosition", 3620, 400)
|
||||
links.new(geometry, deform.inputs["Geometry"])
|
||||
links.new(radial.outputs[0], deform.inputs["Position"])
|
||||
|
||||
channels = node("FunctionNodeSeparateColor", 2960, -430)
|
||||
channels.mode = "RGB"
|
||||
links.new(texture.outputs["Color"], channels.inputs["Color"])
|
||||
invalid = node("FunctionNodeCompare", 3400, -500)
|
||||
invalid.data_type, invalid.operation = "FLOAT", "LESS_THAN"
|
||||
links.new(channels.outputs["Alpha"], invalid.inputs["A"])
|
||||
links.new(inputs.outputs["Validity Threshold"], invalid.inputs["B"])
|
||||
point_invalid = node("GeometryNodeFieldOnDomain", 3620, -500)
|
||||
point_invalid.data_type, point_invalid.domain = "BOOLEAN", "POINT"
|
||||
links.new(invalid.outputs["Result"], point_invalid.inputs["Value"])
|
||||
delete = node("GeometryNodeDeleteGeometry", 3840, 400)
|
||||
delete.domain, delete.mode = "FACE", "ALL"
|
||||
links.new(deform.outputs["Geometry"], delete.inputs["Geometry"])
|
||||
links.new(point_invalid.outputs[0], delete.inputs["Selection"])
|
||||
material = node("GeometryNodeSetMaterial", 4060, 400)
|
||||
links.new(delete.outputs["Geometry"], material.inputs["Geometry"])
|
||||
links.new(inputs.outputs["Material"], material.inputs["Material"])
|
||||
output = node("NodeGroupOutput", 4280, 400)
|
||||
links.new(material.outputs["Geometry"], output.inputs[0])
|
||||
prepare_node_group(group)
|
||||
return group
|
||||
93
experiments/panorama_surface/test_geometry.py
Normal file
93
experiments/panorama_surface/test_geometry.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Behavior checks with independent spherical and room geometry references."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
from geometry import (camera_bases, merge_distances, panorama_directions,
|
||||
room_reference, sample_panorama, view_rays)
|
||||
from prototype import fixed_fov_distance
|
||||
|
||||
|
||||
def test_fixed_fov_recovers_shift_and_metric_distance():
|
||||
rays = view_rays(64)
|
||||
depth = 2 + 0.3 * rays[..., 0]
|
||||
points = rays * depth[..., None]
|
||||
points[..., 2] -= 0.7
|
||||
distance, mask = fixed_fov_distance(points, np.ones((64, 64)), 1.5)
|
||||
np.testing.assert_allclose(distance, depth * np.linalg.norm(rays, axis=-1) * 1.5, rtol=1e-5)
|
||||
assert mask.all()
|
||||
|
||||
|
||||
def test_longitude_sampling_wraps_without_a_seam():
|
||||
directions = panorama_directions(256, 128)
|
||||
image = 2 + directions[..., 0]
|
||||
rays = np.array(((1, 1e-8, 0), (1, -1e-8, 0)))
|
||||
sampled = sample_panorama(image, rays)
|
||||
np.testing.assert_allclose(sampled[0], sampled[1], atol=1e-6)
|
||||
np.testing.assert_allclose(sampled, 3, atol=0.001)
|
||||
|
||||
|
||||
def test_fusion_removes_per_view_scale_offsets():
|
||||
bases = camera_bases()
|
||||
scales = np.exp(np.linspace(-0.4, 0.4, 12))
|
||||
distances = []
|
||||
for scale, basis in zip(scales, bases):
|
||||
rays = view_rays(128) @ basis
|
||||
rays /= np.linalg.norm(rays, axis=-1, keepdims=True)
|
||||
distances.append((3 + 0.4 * rays[..., 0] + 0.2 * rays[..., 2]) * scale)
|
||||
merged, mask = merge_distances(distances, np.ones((12, 128, 128), bool), bases, 64, 32)
|
||||
rays = panorama_directions(64, 32)
|
||||
expected = 3 + 0.4 * rays[..., 0] + 0.2 * rays[..., 2]
|
||||
merged *= np.median(expected / merged)
|
||||
assert mask.all()
|
||||
assert np.mean(np.abs(merged / expected - 1)) < 0.002
|
||||
assert np.max(np.abs(merged[:, [0, -1]] / expected[:, [0, -1]] - 1)) < 0.005
|
||||
|
||||
|
||||
def test_room_distance_lands_on_floor():
|
||||
rays = np.array(((0.0, 0.0, -1.0), (0.2, -0.3, -1.0)))
|
||||
rays /= np.linalg.norm(rays, axis=-1, keepdims=True)
|
||||
distance, _ = room_reference(rays)
|
||||
np.testing.assert_allclose((rays * distance[:, None])[:, 2], -1.6)
|
||||
|
||||
|
||||
def test_sky_view_returns_invalid_samples():
|
||||
distance, mask = fixed_fov_distance(np.ones((32, 32, 3)), np.zeros((32, 32)), 1)
|
||||
assert not mask.any()
|
||||
assert np.isfinite(distance).all()
|
||||
|
||||
|
||||
def test_room_projection_fusion_preserves_geometry():
|
||||
bases = camera_bases()
|
||||
distances = [room_reference(view_rays(256) @ basis)[0] for basis in bases]
|
||||
merged, mask = merge_distances(distances, np.ones((12, 256, 256), bool), bases, 128, 64)
|
||||
expected, _ = room_reference(panorama_directions(128, 64))
|
||||
assert mask.all()
|
||||
assert np.mean(np.abs(merged / expected - 1)) < 0.005
|
||||
|
||||
|
||||
def test_input_alpha_survives_panorama_export(tmp_path, monkeypatch):
|
||||
from PIL import Image
|
||||
from prototype import main
|
||||
|
||||
rgba = np.full((16, 32, 4), 255, dtype=np.uint8)
|
||||
rgba[:, :16, 3] = 0
|
||||
source = tmp_path / "source.png"
|
||||
destination = tmp_path / "result"
|
||||
Image.fromarray(rgba).save(source)
|
||||
monkeypatch.setattr("sys.argv", ["prototype", "--image", str(source), "--output", str(destination),
|
||||
"--reference-only", "--size", "32", "--width", "32"])
|
||||
main()
|
||||
with Image.open(destination / "panorama.png") as exported:
|
||||
np.testing.assert_array_equal(np.asarray(exported)[..., 3], rgba[..., 3])
|
||||
with np.load(destination / "prediction.npz") as data:
|
||||
assert np.all(data["alpha"][:, :16] == 0)
|
||||
assert np.any(data["alpha"][:, 16:] > 0)
|
||||
|
||||
|
||||
def test_depth_alpha_combines_mask_and_source_alpha_once():
|
||||
from prototype import depth_alpha
|
||||
|
||||
image = np.ones((2, 4, 4), dtype=np.float32)
|
||||
image[..., 3] = (0, 0.5, 0.75, 1)
|
||||
mask = np.full((2, 4), 0.5, dtype=np.float32)
|
||||
np.testing.assert_allclose(depth_alpha(image, mask), np.tile((0, 0.25, 0.375, 0.5), (2, 1)))
|
||||
147
experiments/panorama_surface/validation.py
Normal file
147
experiments/panorama_surface/validation.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Verify saved panorama assets using synthetic validity and depth inputs."""
|
||||
|
||||
import json
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
|
||||
def socket_id(group, name):
|
||||
return next(item.identifier for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.in_out == "INPUT" and item.name == name)
|
||||
|
||||
|
||||
def evaluated_mesh(obj):
|
||||
obj.update_tag()
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
return evaluated, evaluated.to_mesh()
|
||||
|
||||
|
||||
def evaluated_points(obj):
|
||||
evaluated, mesh = evaluated_mesh(obj)
|
||||
points = np.empty(len(mesh.vertices) * 3, dtype=np.float32)
|
||||
mesh.vertices.foreach_get("co", points)
|
||||
counts = (len(mesh.vertices), len(mesh.edges), len(mesh.polygons))
|
||||
evaluated.to_mesh_clear()
|
||||
return points.reshape(-1, 3), counts
|
||||
|
||||
|
||||
def test_image(name, alpha=1):
|
||||
image = bpy.data.images.new(name, width=16, height=8, alpha=True, float_buffer=True)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image.alpha_mode = "CHANNEL_PACKED"
|
||||
pixels = np.full((8, 16, 4), 3, dtype=np.float32)
|
||||
pixels[..., 3] = alpha
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
return image
|
||||
|
||||
|
||||
def verify(output):
|
||||
full, empty, partial = test_image("Full"), test_image("Empty", 0), test_image("Combined Alpha", 0.25)
|
||||
half = test_image("Half")
|
||||
pixels = np.full((8, 16, 4), 3, dtype=np.float32)
|
||||
pixels[..., 3] = 1
|
||||
pixels[:, :8, 3] = 0
|
||||
half.pixels.foreach_set(pixels.ravel())
|
||||
half.update()
|
||||
results = {}
|
||||
try:
|
||||
for obj in list(bpy.data.objects):
|
||||
if obj.type != "MESH" or not obj.modifiers:
|
||||
continue
|
||||
modifier = obj.modifiers[0]
|
||||
group = modifier.node_group
|
||||
image_inputs = [item.name for item in group.interface.items_tree if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT" and item.socket_type == "NodeSocketImage"]
|
||||
assert image_inputs == ["Distance"]
|
||||
names = ("Subdivide", "Depth Scale", "Validity Threshold", "Distance")
|
||||
keys = {name: socket_id(group, name) for name in names}
|
||||
original = {name: modifier[key] for name, key in keys.items()}
|
||||
|
||||
def set_inputs(**values):
|
||||
for name, value in values.items():
|
||||
modifier[keys[name.replace("_", " ")]] = value
|
||||
|
||||
try:
|
||||
set_inputs(Distance=full, Depth_Scale=0, Validity_Threshold=0.9)
|
||||
face_counts = []
|
||||
for level in (0, 2, 4):
|
||||
set_inputs(Subdivide=level)
|
||||
evaluated, mesh = evaluated_mesh(obj)
|
||||
try:
|
||||
assert len(mesh.polygons) == 6 * 4 ** level
|
||||
assert all(len(face.vertices) == 4 for face in mesh.polygons)
|
||||
assert len(mesh.vertices) - len(mesh.edges) + len(mesh.polygons) == 2
|
||||
use = np.zeros(len(mesh.edges), dtype=int)
|
||||
for loop in mesh.loops:
|
||||
use[loop.edge_index] += 1
|
||||
assert np.all(use == 2)
|
||||
points = np.array([vertex.co[:] for vertex in mesh.vertices])
|
||||
np.testing.assert_allclose(np.linalg.norm(points, axis=1), 1, atol=1e-5)
|
||||
if level >= 2:
|
||||
uv = mesh.attributes["UVMap"].data
|
||||
assert all(np.ptp([uv[i].vector.x for i in face.loop_indices]) < 0.51
|
||||
for face in mesh.polygons), "UV seam crosses the full image"
|
||||
face_counts.append(len(mesh.polygons))
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
sphere, _ = evaluated_points(obj)
|
||||
set_inputs(Depth_Scale=1)
|
||||
surface, _ = evaluated_points(obj)
|
||||
np.testing.assert_allclose(surface, sphere * 3, atol=1e-5)
|
||||
set_inputs(Depth_Scale=0.5)
|
||||
middle, _ = evaluated_points(obj)
|
||||
np.testing.assert_allclose(middle, sphere * 2, atol=1e-5)
|
||||
set_inputs(Distance=empty)
|
||||
assert evaluated_points(obj)[1][2] == 0
|
||||
set_inputs(Distance=half)
|
||||
retained = evaluated_points(obj)[1][2]
|
||||
assert 0 < retained < face_counts[-1]
|
||||
set_inputs(Distance=partial, Validity_Threshold=0.3)
|
||||
assert evaluated_points(obj)[1][2] == 0, "Mask times alpha must be below threshold"
|
||||
set_inputs(Validity_Threshold=0.2)
|
||||
assert evaluated_points(obj)[1][2] == face_counts[-1]
|
||||
finally:
|
||||
for name, value in original.items():
|
||||
modifier[keys[name]] = value
|
||||
obj.update_tag()
|
||||
points, counts = evaluated_points(obj)
|
||||
assert np.isfinite(points).all()
|
||||
assert counts[2] > 0
|
||||
evaluated, mesh = evaluated_mesh(obj)
|
||||
try:
|
||||
directions = np.empty((counts[0], 3), dtype=np.float32)
|
||||
uv = np.empty_like(directions)
|
||||
mesh.attributes["panorama_direction"].data.foreach_get("vector", directions.ravel())
|
||||
mesh.attributes["panorama_sample_uv"].data.foreach_get("vector", uv.ravel())
|
||||
expected_u = np.arctan2(directions[:, 1], directions[:, 0]) / (2 * np.pi) % 1
|
||||
delta_u = np.abs(uv[:, 0] - expected_u)
|
||||
assert np.max(np.minimum(delta_u, 1 - delta_u)) < 1e-6
|
||||
np.testing.assert_allclose(uv[:, 1], 1 - np.arccos(np.clip(directions[:, 2], -1, 1)) / np.pi, atol=1e-6)
|
||||
image = original["Distance"]
|
||||
width, height = image.size
|
||||
pixels = np.array(image.pixels[:]).reshape(height, width, 4)
|
||||
data_path = output / ("reference.npz" if obj.name == "Reference Room" else "prediction.npz")
|
||||
with np.load(data_path) as data:
|
||||
np.testing.assert_allclose(pixels[::-1, :, 3], data["alpha"], atol=0.0005)
|
||||
x = np.clip((uv[:, 0] * width).astype(int), 0, width - 1)
|
||||
y = np.clip((uv[:, 1] * height).astype(int), 0, height - 1)
|
||||
radius = 1 + original["Depth Scale"] * (pixels[y, x, 0] - 1)
|
||||
np.testing.assert_allclose(points, directions * radius[:, None], rtol=2e-6, atol=2e-5)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
results[obj.name] = {"subdivide_levels": [0, 2, 4], "quad_face_counts": face_counts,
|
||||
"closed_quad_sphere": True, "uv_seam_verified": True,
|
||||
"depth_scale_verified": True, "mask_alpha_verified": True,
|
||||
"image_sampling_verified": True,
|
||||
"single_depth_image_verified": True,
|
||||
"packed_depth_alpha_verified": True,
|
||||
"vertices": counts[0], "faces": counts[2]}
|
||||
assert results
|
||||
finally:
|
||||
for image in (full, empty, partial, half):
|
||||
bpy.data.images.remove(image)
|
||||
(output / "blender_validation.json").write_text(json.dumps(results, indent=2), encoding="utf-8")
|
||||
print(json.dumps(results, indent=2), flush=True)
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-09
|
||||
64
openspec/changes/clean-depth-surface-strip-faces/design.md
Normal file
64
openspec/changes/clean-depth-surface-strip-faces/design.md
Normal file
@@ -0,0 +1,64 @@
|
||||
## Context
|
||||
|
||||
两个节点组共用 `split_depth_surface`,在变形前读取相邻面深度并拆边,随后固定相机坐标、投影和加厚。实验直接复制原资产,只插入单次 Delete Geometry;关闭实验开关时求值的顶点与面与原组完全一致。
|
||||
|
||||
实验四边网格结果:山景从 80,449 面删去 21,306 面,蜘蛛图 Depth Cutout 从 37,206 面删去 2,249 面。蜘蛛图三角网格的 74,412 面全部保留。两个实验均为 MoGe-2 Small、level 0、Split Threshold 0.5;面数取决于实验网格,正式测试验证拓扑性质。实验深度采用无损 EXR 打包,保存重开结果一致。
|
||||
|
||||
## Goals
|
||||
|
||||
在两个 Depth 节点组中共用确定性的单次条带删面逻辑,将 Split 控制放在主输入区 Smooth 前,并保留剩余表面的 UV、独立切口身份和加厚连接。
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 在拆边分支内执行一次删除
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[当前基础几何及已有预处理] --> B{Split Threshold 大于 0}
|
||||
B -->|是| C[原有面深度采样与 Split Edges]
|
||||
C --> D[四边面按顺序读取四条边的 Face Count]
|
||||
D --> E[任一组对边均为边界边]
|
||||
E --> F[Delete Geometry:Face / All,单次删除]
|
||||
F --> G[固定剩余顶点的相机坐标]
|
||||
B -->|否| G
|
||||
G --> H[深度投影]
|
||||
H --> I[现有厚度与 Smooth 流程]
|
||||
```
|
||||
|
||||
共用实现放在 `tools/nodes/common/depth_surface.py`,将删除节点接在 Split Edges 输出与现有启用 Switch 的 True 输入之间。删除选择在该时刻的完整拆后几何上求值一次。
|
||||
|
||||
### 2. 按面角顺序识别边界对边
|
||||
|
||||
在 FACE 域用 Index → Corners of Face 获取当前面的一个起始角和面角数量。通过 Offset Corner in Face 的偏移 0、1、2、3,分别用 Edges of Corner 的 Next Edge Index 取得同方向的四条边;Evaluate at Index 在 EDGE 域读取 Mesh Edge Neighbors 的 Face Count。
|
||||
|
||||
令 `b_i = (FaceCount(edge_i) == 1)`,选择条件为:
|
||||
|
||||
```text
|
||||
corner_count == 4 AND ((b0 AND b2) OR (b1 AND b3))
|
||||
```
|
||||
|
||||
该结果与起始角和面绕序无关。一张面宽条带、三条边界边的条带末端、孤立四边面被选中;具有一条边界边或两条相邻边界边的普通面保留。边界计数严格等于 1,零邻面和非流形边不作为边界边参与匹配。
|
||||
|
||||
### 3. 正式接口只有 Split 控制
|
||||
|
||||
条带清理随 Split 正值分支固定启用。两个节点组仅有一个 `Split Threshold`,其 parent 为主输入区,顺序紧邻 `Smooth` 之前。维持 `FACTOR`、范围 0–1、默认 0 及现有阈值映射。
|
||||
|
||||
移动接口时检查构建顺序、局部 Group Input、修改器赋值与测试中的标识符或顺序依赖。组名保持正式名称;正式接口不引入实验开关。节点使用默认名称、局部连线和未使用输出隐藏规则。
|
||||
|
||||
### 4. 按输入拓扑验收
|
||||
|
||||
四边面按上述条件处理;三角面和其他多边面通过面角数量门控保留。Depth Cutout 的当前三角化输入仍可使用原 Split,清理阶段自然不选面。保持真实建网流程,不为匹配清理规则增加三角面合并或重拓扑。
|
||||
|
||||
### 5. 更新覆盖约束并验证厚度
|
||||
|
||||
将现有“Split 保留所有面”的测试改为“保留所有未命中条带条件的面”,并保留独立的纯拆边与零值旁路检查。厚度依据删面后几何建立边界;正厚度测试检查剩余区域闭合、切口区域独立和属性生命周期。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 规则识别一张面宽的拓扑结构;真实窄结构、已被原 Split 过度拆分的远山也可能命中。不能据此承诺语义上的前景或远山完整保留。
|
||||
- 两张面宽的条带及三角条带不受本规则处理,剩余边界仍受原网格分辨率限制。
|
||||
- 单次求值避免清理不断向完整区域推进。首次删除后新暴露的候选面留待后续用户改变输入时重新求值,不能在同一次求值中反复删除。
|
||||
|
||||
## Delivery
|
||||
|
||||
本提案完成后实施源码与测试,保留工作区其他修改,执行全量测试并确认正常退出。依照项目规范,此实施任务不构建文档或产物。正式节点资产重建发布需要单独安排;源码实施完成不代表用户安装的节点资产已更新。
|
||||
27
openspec/changes/clean-depth-surface-strip-faces/proposal.md
Normal file
27
openspec/changes/clean-depth-surface-strip-faces/proposal.md
Normal file
@@ -0,0 +1,27 @@
|
||||
## Why
|
||||
|
||||
Depth Plane 与 Depth Cutout 的 Split Edges 会在深度过渡区域产生一张面宽的条带和孤立四边面。拆边后检查四边面的两组对边,可以识别这些结构,同时保留只有相邻两条边为边界的普通表面角落。独立节点组实验已验证该判定及单次删除行为。
|
||||
|
||||
## What Changes
|
||||
|
||||
- `O Image Depth Plane` 与 `O Image Depth Cutout` 在启用 Split 的分支中,拆边后默认执行一次条带删面。
|
||||
- 仅选择至少一组对边均为边界边的四边面;边界边的 Face Count 必须等于 1。
|
||||
- 正式接口不增加实验中的 `Remove Strip Faces` 输入。`Split Threshold = 0` 时旁路拆边和条带删面。
|
||||
- 两组的 `Split Threshold` 从 `Options` 移到主输入区,紧邻并位于 `Smooth` 前;名称、默认值、范围和阈值映射保持现状。
|
||||
- 共用节点构建逻辑,并更新针对面数保留、接口顺序和厚度连接的行为测试。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `depth-surface-strip-cleanup`:拆边后默认进行单次四边面对边界边筛选删面,以及两个节点组的 Split 输入位置。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。当前 `openspec/specs/` 为空;本提案依据当前源码及实验结果定义增量行为。
|
||||
|
||||
## Impact
|
||||
|
||||
涉及 `tools/nodes/common/depth_surface.py`、两个 Depth 节点组构建模块和对应测试。运行时继续加载正式命名的节点资产。实现阶段只修改源码与测试并执行测试;正式 `.blend` 的重建发布另行安排。
|
||||
|
||||
三角面及其他非四边面保持原行为。当前 Cutout 建网流程产生三角面,因此本次规则对这类输入不会删面;四边网格输入的实验效果不能作为三角网格的验收结果。条带判定依据拓扑,已被拆成条带的真实物体细节也可能被删除。
|
||||
@@ -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** 新位置的输入正常控制原拆边强度及默认条带清理
|
||||
16
openspec/changes/clean-depth-surface-strip-faces/tasks.md
Normal file
16
openspec/changes/clean-depth-surface-strip-faces/tasks.md
Normal file
@@ -0,0 +1,16 @@
|
||||
## 1. 共用条带判定
|
||||
|
||||
- [x] 1.1 在 `tools/nodes/common/depth_surface.py` 实现四边面对边界边判定,在 Split Edges 后、启用分支 Switch 前接入单次 Face / All 删除;正式逻辑固定启用,无额外开关。
|
||||
- [x] 1.2 验证孤立四边面、一面宽条带、普通边缘与角落、面角起点和绕序、三角面及其他多边面、非流形边;加入首次删除后出现新候选面的用例以确认仅执行一遍。
|
||||
|
||||
## 2. 两组接口与行为接入
|
||||
|
||||
- [x] 2.1 将 `O Image Depth Plane` 和 `O Image Depth Cutout` 的 Split Threshold 移到主输入区 Smooth 紧前,保留输入语义,整理局部节点布局。
|
||||
- [x] 2.2 更新接口顺序、Options 归属、唯一输入、无实验开关及相关修改器赋值测试。
|
||||
- [x] 2.3 更新原有面数保留断言,分别验证两组的四边面清理、三角面保留和 Split 为零时原行为;确认原梯度选择与比例映射不变。
|
||||
|
||||
## 3. 几何与集成验证
|
||||
|
||||
- [x] 3.1 验证剩余表面的 UV、用户属性、临时属性清理、空结果、正厚度闭合以及 Smooth 后区域独立;确认厚度与 Smooth 不改变基础删面集合。
|
||||
- [x] 3.2 使用真实 Blender 求值验证两组节点;分别记录四边网格与当前 Cutout 三角网格的结果,不能用四边实验替代正式三角输入验收。
|
||||
- [x] 3.3 运行 `uv run pytest` 全量测试并确认正常退出,检查最终差异和旧接口断言,保留其他已有修改;不构建文档或产物,并在交付说明中明确正式节点资产尚待另行重建发布。验证结果:940 passed、2 xfailed,退出码 0。
|
||||
2
openspec/changes/organize-tool-cli/.openspec.yaml
Normal file
2
openspec/changes/organize-tool-cli/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-10
|
||||
44
openspec/changes/organize-tool-cli/design.md
Normal file
44
openspec/changes/organize-tool-cli/design.md
Normal file
@@ -0,0 +1,44 @@
|
||||
## Context
|
||||
|
||||
参见 proposal.md。现有节点入口依次调用 Blender 构建、资产校验及 pytest,预览模块已有 HTML 导出能力。模型入口统一准备模型并按选项上传,包含现成文件下载、普通 ONNX 转换及 PiSA-SR 独立环境转换。
|
||||
|
||||
## Goals
|
||||
|
||||
命令入口负责参数解析与流程调度,业务模块负责产物;复用现有 Blender 启动、模型准备与校验实现。
|
||||
|
||||
## Decisions
|
||||
|
||||
- 在现有两个 cli.py 使用 argparse 子命令,脚本入口为 `node-group` 和 `model`。Python 包继续使用表示业务集合的 `tools/nodes`、`tools/models`。
|
||||
- `node-group build` 保持构建、资产校验、全量 pytest 的顺序;`check` 复用后两步。两者支持 `--blender` 和现有 `--skip-tests`,后者只跳过 pytest。任一步失败立即以非零状态退出。
|
||||
- `preview` 复用 Blender 启动流程及现有预览导出器,默认写入系统临时目录的独立 HTML,输出绝对路径,再由父进程打开浏览器。支持 `--blender`、`--output PATH`、`--no-open`、`--fragment`;fragment 模式仅输出片段且不自动打开。文件保留供查看,资产文件保持原样。
|
||||
- 模型子命令为 `prepare` 和 `sync`。`prepare` 替代 `--prepare-only`,统一下载现成 ONNX 或准备源权重并导出,包含 PiSA-SR;按既有目录输出,复用已校验的缓存及独立转换环境。`sync` 复用全部准备成功后上传的流程。
|
||||
- PiSA-SR 在模型目录下的临时目录导出整组文件,全部通过目录清单校验后再更新正式产物,临时目录随流程退出清理。
|
||||
- 使用延迟导入,使帮助与参数错误可以在未加载 Blender、Torch 和导出环境时返回。无动作时显示帮助并成功退出,非法动作或参数非零退出。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[解析命令] --> B[node-group]
|
||||
A --> C[model]
|
||||
B --> D[build 构建资产]
|
||||
D --> E[校验资产]
|
||||
B --> F[check]
|
||||
F --> E
|
||||
E --> G[pytest]
|
||||
B --> H[preview 内存构建与排列]
|
||||
H --> I[HTML 预览]
|
||||
C --> J[prepare 下载或导出并校验全部模型]
|
||||
C --> K[sync]
|
||||
K --> J
|
||||
J --> L{动作是 sync}
|
||||
L -->|是| M[上传 R2]
|
||||
```
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- CLI 改名影响本地脚本调用 → 删除旧入口并验证新的安装脚本映射,实施时报告命令迁移。
|
||||
- Blender 参数与预览参数共用进程命令行 → 显式传递业务参数,测试参数隔离与失败传播。
|
||||
- 模型导出成本较高 → 保留大小及 SHA-256 校验后的缓存复用;自动测试使用隔离文件和替代执行器验证路由。
|
||||
|
||||
## Migration Plan
|
||||
|
||||
更新入口及子命令后执行 `uv sync` 刷新本地脚本,运行相关测试、帮助命令及节点构建链验证。保留工作区既有修改。
|
||||
24
openspec/changes/organize-tool-cli/proposal.md
Normal file
24
openspec/changes/organize-tool-cli/proposal.md
Normal file
@@ -0,0 +1,24 @@
|
||||
## Why
|
||||
|
||||
节点组和模型工具需要统一为「对象 + 动作」命令,让构建、检查、预览和模型准备的职责直观可见,并能独立执行。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**:将 `build-nodes` 替换为 `node-group build`,将 `sync-models` 替换为 `model sync`,删除旧入口。
|
||||
- 提供 `node-group check`,检查已有资产并运行测试;提供 `node-group preview`,生成当前源码的 HTML 预览并打开浏览器。
|
||||
- 提供 `model prepare`,按模型来源下载现成文件或准备源权重并导出,统一校验全部模型。
|
||||
- 使用统一的小写子命令;未提供动作时显示帮助。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `developer-tool-cli`:节点组和模型命令的路由、执行边界、参数及失败行为。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
无。
|
||||
|
||||
## Impact
|
||||
|
||||
涉及 `pyproject.toml` 的脚本入口、`tools/nodes`、`tools/models` 和相关命令测试。使用现有 argparse、Blender 与模型转换依赖。实施范围为命令代码与测试;本次产出为 OpenSpec 规划文件。
|
||||
@@ -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** 命令非零退出,失败产物不替换正式目标
|
||||
19
openspec/changes/organize-tool-cli/tasks.md
Normal file
19
openspec/changes/organize-tool-cli/tasks.md
Normal file
@@ -0,0 +1,19 @@
|
||||
## 1. 命令入口
|
||||
|
||||
- [x] 1.1 将脚本入口替换为 `node-group`、`model`,增加子命令解析并删除旧入口及 `--prepare-only`;更新入口测试,验证帮助、无动作、非法参数均不执行业务操作。
|
||||
|
||||
## 2. 节点组命令
|
||||
|
||||
- [x] 2.1 实现 build 与 check 的公共 Blender 调度,支持 `--blender`、`--skip-tests`;通过命令测试验证顺序、check 不构建和失败即停止。
|
||||
- [x] 2.2 接入 preview,支持默认临时 HTML、`--output`、`--no-open`、`--fragment`,由父进程打开浏览器;验证参数传递、输出内容、片段模式和资产内容保持原样。
|
||||
|
||||
## 3. 模型命令
|
||||
|
||||
- [x] 3.1 实现 prepare 与 sync 两个模型子命令,prepare 统一下载和导出,sync 复用准备后上传逻辑;通过隔离测试验证缓存命中、下载或转换选择、校验失败阻断上传,以及 prepare 不依赖上传工具。
|
||||
- [x] 3.2 验证 prepare 覆盖现成模型下载、普通模型转换与 PiSA-SR 导出,测试源权重准备、产物校验、失败临时文件清理和正式目标保护。
|
||||
|
||||
## 4. 集成验证
|
||||
|
||||
- [x] 4.1 执行 `uv sync` 后检查两组命令及全部子命令的 `--help`,运行相关工具测试,确认安装入口与源码一致。
|
||||
- [x] 4.2 执行 `uv run node-group build` 验证真实 Blender 构建、资产校验和全量 pytest 正常退出;运行 `node-group check` 和 `node-group preview --no-open`,校验资产哈希保持原样及预览文件可查看。
|
||||
- [x] 4.3 检查代码与测试中的旧入口引用、最终差异和测试结果,确认保留工作区既有修改,并简要报告命令迁移及验证结果。
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-09-09
|
||||
36
openspec/changes/prototype-panorama-surface/design.md
Normal file
36
openspec/changes/prototype-panorama-surface/design.md
Normal file
@@ -0,0 +1,36 @@
|
||||
## Context
|
||||
|
||||
本地具备 MoGe2 ONNX 模型和 Blender 4.5。试验以独立脚本运行,使用合成房间的解析距离作为几何真值。
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
目标是验证完整全景的距离融合、几何节点径向变形与接缝。试验范围限定为 `experiments/panorama_surface` 和本变更目录。
|
||||
|
||||
## Decisions
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[解析房间或用户全景] --> B[12 个重叠的 90 度视图]
|
||||
B --> C[MoGe2 ONNX 与固定视角后处理]
|
||||
C --> D[周期边界的对数距离融合]
|
||||
D --> E[浮点距离及有效性]
|
||||
E --> F[独立 Blender 节点资产]
|
||||
F --> G[Quad Sphere 细分与径向定位]
|
||||
G --> I[Mask 与 Alpha 剔除]
|
||||
I --> H[重新加载与数值验证]
|
||||
```
|
||||
|
||||
- 融合参考官方梯度域算法,使用 SciPy 稀疏求解;通过全局对数距离偏移恢复输入预测的整体尺度。
|
||||
- Quad Sphere 从用户指定的 O_Essentials_GeometryNodes.blend1 离线载入并打包进试验文件,Segments 为 2^Subdivide + 1。
|
||||
- 球面方向生成采样 UV;材质 UV 在面角域处理经度接缝及极点。
|
||||
- 深度导出将 Mask × 源图 Alpha 写入 depth.exr 的 Alpha;几何节点只读取深度图片。
|
||||
- Depth Scale 在单位球与预测距离间插值;深度 EXR 的 Alpha 低于阈值时,通过点域布尔字段删除面。
|
||||
- 真实照片先使用 BEN2 去除天空,透明区域在视图几何恢复及融合阶段排除;最后按源图 Alpha 写入深度有效性。
|
||||
- 节点组通过独立离线脚本构建到试验 `.blend`;验证阶段重新加载该文件。
|
||||
- 合成真值与 AI 输出分别检查,区分投影实现误差和模型预测误差。
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- 合成图与真实照片分布不同 → AI 结果只作为流程验证,几何正确性以解析真值验证。
|
||||
- 梯度融合没有绝对尺度约束 → 输出明确采用预测尺度锚定,记录相对误差。
|
||||
- 遮挡处连接可能产生长面 → 通过真实照片试验观察可见表面连接质量。
|
||||
21
openspec/changes/prototype-panorama-surface/proposal.md
Normal file
21
openspec/changes/prototype-panorama-surface/proposal.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## Why
|
||||
|
||||
验证 MoGe 多视图距离融合与 Blender 几何节点能否组成全景表面生成流程,为后续产品方案提供实测依据。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 在 `experiments/panorama_surface` 实现独立试验,使用可计算真实距离的合成全景验证投影、融合与网格。
|
||||
- 使用本地 MoGe2 ONNX 模型完成一次多视图推理,输出距离图和独立 Blender 示例。
|
||||
- 检查可调细分 Quad Sphere、全景 UV、Mask 与 Alpha 剔除及 Blender 重新加载后的求值。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `panorama-surface-prototype`: 独立的全景距离融合和几何节点验证。
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
## Impact
|
||||
|
||||
新增试验目录和本提案目录。试验复用当前 Python、NumPy、SciPy、ONNX Runtime 与 Blender 环境,生产源码、依赖及原节点资产保持原状。
|
||||
@@ -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** 先去除天空背景,使用处理后的透明全景进行几何预测与深度导出
|
||||
22
openspec/changes/prototype-panorama-surface/tasks.md
Normal file
22
openspec/changes/prototype-panorama-surface/tasks.md
Normal file
@@ -0,0 +1,22 @@
|
||||
## 1. 距离原型
|
||||
|
||||
- [x] 1.1 实现解析全景、12 视图投影与固定视角 MoGe2 推理
|
||||
- [x] 1.2 实现周期对数距离融合并用解析真值测试
|
||||
|
||||
## 2. Blender 验证
|
||||
|
||||
- [x] 2.1 构建独立节点示例,提供距离变形、比例和断层控制
|
||||
- [x] 2.2 在独立 Blender 进程重新加载并验证拓扑、位置与比例
|
||||
- [x] 2.3 运行本地模型,检查结果并交付示例与验证数据
|
||||
|
||||
## 3. Quad Sphere 调整
|
||||
|
||||
- [x] 3.1 复用参考 Quad Sphere,支持动态细分与全景 UV
|
||||
- [x] 3.2 使用 Depth Scale 控制球面变形,按 Mask 与 Alpha 剔除无效面
|
||||
- [x] 3.3 验证细分面数、全四边面、UV、Alpha 与 Mask 并更新两个试验文件
|
||||
|
||||
## 4. 深度 Alpha 数据约定
|
||||
|
||||
- [x] 4.1 将 Mask × 源图 Alpha 写入 depth.exr,几何节点仅保留深度图片输入
|
||||
- [x] 4.2 真实照片先去天空,再重新推理、融合并生成示例
|
||||
- [x] 4.3 验证单图片输入、Alpha 乘积及天空剔除
|
||||
@@ -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]
|
||||
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)])
|
||||
|
||||
@@ -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)
|
||||
|
||||
222
tests/tools/nodes/test_depth_strip_cleanup.py
Normal file
222
tests/tools/nodes/test_depth_strip_cleanup.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Evaluate opposite-boundary cleanup on explicit mesh topology."""
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from tools.nodes.common.depth_surface import _remove_strip_faces
|
||||
from tools.nodes.groups.image_depth_cutout import build_image_depth_cutout_group
|
||||
from tools.nodes.groups.image_depth_plane import build_image_depth_plane_group
|
||||
from tools.nodes.groups.image_plane import build_image_plane_group
|
||||
|
||||
|
||||
def cleaned_faces(vertices, faces, *, triangles=False):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
mesh = bpy.data.meshes.new("Topology")
|
||||
mesh.from_pydata(vertices, [], faces)
|
||||
ids = mesh.attributes.new("source_face", "INT", "FACE")
|
||||
ids.data.foreach_set("value", np.arange(len(faces), dtype=np.int32))
|
||||
uv = mesh.uv_layers.new(name="UVMap")
|
||||
for loop in mesh.loops:
|
||||
uv.data[loop.index].uv = mesh.vertices[loop.vertex_index].co[:2]
|
||||
obj = bpy.data.objects.new("Topology", mesh)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
group = bpy.data.node_groups.new("Strip cleanup test", "GeometryNodeTree")
|
||||
group.interface.new_socket(name="Geometry", in_out="INPUT", socket_type="NodeSocketGeometry")
|
||||
group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry")
|
||||
source = group.nodes.new("NodeGroupInput")
|
||||
result = _remove_strip_faces(group, source.outputs["Geometry"], triangles=triangles)
|
||||
group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"])
|
||||
obj.modifiers.new("Cleanup", "NODES").node_group = group
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
assert all(np.isfinite(vertex.co).all() for vertex in result.vertices)
|
||||
if not result.polygons:
|
||||
assert not result.vertices and not result.edges
|
||||
return set()
|
||||
assert "UVMap" in result.attributes
|
||||
for loop in result.loops:
|
||||
np.testing.assert_allclose(result.uv_layers["UVMap"].data[loop.index].uv,
|
||||
result.vertices[loop.vertex_index].co[:2])
|
||||
return {value.value for value in result.attributes["source_face"].data}
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("cells, expected", [
|
||||
([(0, 0)], set()),
|
||||
([(x, 0) for x in range(5)], set()),
|
||||
([(x, y) for y in range(3) for x in range(3)], set(range(9))),
|
||||
# The center becomes isolated only after its three arms disappear.
|
||||
([(0, 0), (1, 0), (2, 0), (1, 1)], {1}),
|
||||
])
|
||||
@pytest.mark.parametrize("offset, reverse", [(0, False), (1, False), (2, True), (3, True)])
|
||||
def test_quad_strips_and_corners_are_classified_once(cells, expected, offset, reverse):
|
||||
vertices, indices, faces = [], {}, []
|
||||
for x, y in cells:
|
||||
face = []
|
||||
for point in [(x, y, 0), (x + 1, y, 0), (x + 1, y + 1, 0), (x, y + 1, 0)]:
|
||||
if point not in indices:
|
||||
indices[point] = len(vertices)
|
||||
vertices.append(point)
|
||||
face.append(indices[point])
|
||||
face = face[offset:] + face[:offset]
|
||||
faces.append(face[::-1] if reverse else face)
|
||||
assert cleaned_faces(vertices, faces) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count", [3, 5, 6])
|
||||
def test_non_quad_faces_are_preserved(count):
|
||||
angles = np.linspace(0, 2 * np.pi, count, endpoint=False)
|
||||
vertices = [(np.cos(a), np.sin(a), 0) for a in angles]
|
||||
assert cleaned_faces(vertices, [tuple(range(count))]) == {0}
|
||||
|
||||
|
||||
def test_nonmanifold_edge_is_not_a_boundary():
|
||||
vertices = [(0, 0, 0), (1, 0, 0), (1, 1, 0), (0, 1, 0),
|
||||
(2, 0, 0), (1, 2, 0), (0, 2, 1), (-1, 0, 0)]
|
||||
faces = [(0, 1, 2, 3), (2, 1, 4), (3, 2, 5), (2, 3, 6), (0, 3, 7)]
|
||||
assert cleaned_faces(vertices, faces) == set(range(5))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("width, height, expected", [(1, 1, 0), (5, 1, 0), (2, 2, 8), (3, 3, 18)])
|
||||
@pytest.mark.parametrize("reverse", [False, True])
|
||||
def test_triangle_pairs_remove_strips_and_preserve_surface_corners(width, height, expected, reverse):
|
||||
vertices = [(x, y, 0) for y in range(height + 1) for x in range(width + 1)]
|
||||
faces = []
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
a = y * (width + 1) + x
|
||||
b, c, d = a + 1, a + width + 2, a + width + 1
|
||||
faces.extend([(a, b, c), (a, c, d)])
|
||||
# Reverse alternate faces to test matching by vertex identity, not winding.
|
||||
if reverse:
|
||||
faces = [face[::-1] if i % 2 else face[1:] + face[:1] for i, face in enumerate(faces)]
|
||||
result = cleaned_faces(vertices, faces, triangles=True)
|
||||
assert len(result) == expected
|
||||
|
||||
|
||||
def test_isolated_triangle_is_removed_only_for_cutout():
|
||||
vertices = [(0, 0, 0), (1, 0, 0), (0, 1, 0)]
|
||||
assert cleaned_faces(vertices, [(0, 1, 2)], triangles=True) == set()
|
||||
assert cleaned_faces(vertices, [(0, 1, 2)]) == {0}
|
||||
|
||||
|
||||
def test_triangle_pair_requires_a_manifold_shared_edge():
|
||||
vertices = [(0, 0, 0), (1, 0, 0), (.5, 1, 0), (.5, -1, 0), (.5, 0, 1)]
|
||||
faces = [(0, 1, 2), (1, 0, 3), (0, 1, 4)]
|
||||
assert cleaned_faces(vertices, faces, triangles=True) == {0, 1, 2}
|
||||
|
||||
|
||||
def test_triangle_pair_requires_four_distinct_vertices():
|
||||
vertices = [(0, 0, 0), (1, 0, 0), (0, 1, 0)]
|
||||
assert cleaned_faces(vertices, [(0, 1, 2), (2, 1, 0)], triangles=True) == {0, 1}
|
||||
|
||||
|
||||
def test_triangle_cleanup_marks_both_faces_without_recursive_peeling():
|
||||
vertices = [(0, 0, 0), (2, 0, 0), (1, 2, 0), (1, -2, 0),
|
||||
(3, 2, 0), (-1, 2, 0), (-1, -1, 0)]
|
||||
faces = [(0, 1, 2), (1, 0, 3), (2, 1, 4), (0, 2, 5), (3, 0, 6)]
|
||||
assert cleaned_faces(vertices, faces, triangles=True) == {0, 2, 3}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
def test_split_input_precedes_smooth_without_cleanup_switch(kind):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
group = (build_image_depth_plane_group(build_image_plane_group()) if kind == "plane"
|
||||
else build_image_depth_cutout_group())
|
||||
inputs = [s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"]
|
||||
names = [s.name for s in inputs]
|
||||
assert names.count("Split Threshold") == 1
|
||||
assert names[names.index("Smooth") - 1] == "Split Threshold"
|
||||
split = inputs[names.index("Split Threshold")]
|
||||
assert split.parent == inputs[names.index("Smooth")].parent
|
||||
assert split.parent.name != "Options"
|
||||
assert (split.default_value, split.min_value, split.max_value, split.subtype) == (0, 0, 1, "FACTOR")
|
||||
assert "Remove Strip Faces" not in names
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
def test_strips_are_removed_before_projection_and_closed_thickness(kind):
|
||||
from tests.support.depth_surface import surface, evaluated, assert_closed, component_count
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
if kind == "plane":
|
||||
obj, set_value, _, _, image = create_surface("definition", "DEPTH")
|
||||
set_value("Subdivide", 1)
|
||||
else:
|
||||
obj, set_value = surface(step=0.0)
|
||||
image = bpy.data.images["Camera"]
|
||||
width, height = image.size
|
||||
pixels = np.array(image.pixels[:], np.float32).reshape(height, width, 4)
|
||||
pixels[..., 2] = 1
|
||||
pixels[:, width // 4:3 * width // 4, 2] = 21
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.update()
|
||||
set_value("Depth Scale", 1.0)
|
||||
assert len(evaluated(obj)[1]) == 8
|
||||
set_value("Split Threshold", 0.5)
|
||||
vertices, faces = evaluated(obj)
|
||||
assert len(faces) == 4 and len(vertices) == 9
|
||||
assert component_count(faces) == 1
|
||||
set_value("Thickness", 0.2)
|
||||
before_smooth, shell = evaluated(obj)
|
||||
assert len(shell) == 16
|
||||
assert_closed(shell)
|
||||
set_value("Smooth", 2)
|
||||
after_smooth, smooth_shell = evaluated(obj)
|
||||
assert smooth_shell == shell
|
||||
assert np.isfinite(after_smooth).all()
|
||||
assert_closed(smooth_shell)
|
||||
assert component_count(smooth_shell) == 1
|
||||
set_value("Thickness", 0.0)
|
||||
assert len(evaluated(obj)[1]) == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["plane", "cutout"])
|
||||
def test_zero_split_bypasses_cleanup_and_positive_split_can_empty_mesh(kind):
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
from tests.support.planes import create_surface
|
||||
|
||||
if kind == "plane":
|
||||
obj, set_value, _, _, _ = create_surface("definition", "DEPTH")
|
||||
set_value("Subdivide", 0)
|
||||
else:
|
||||
obj, set_value = surface(step=0.0, resolution=1)
|
||||
original, faces = evaluated(obj)
|
||||
assert len(faces) == 2
|
||||
set_value("Split Threshold", 0.001)
|
||||
assert not evaluated(obj)[1]
|
||||
set_value("Thickness", 0.2)
|
||||
assert not evaluated(obj)[1]
|
||||
set_value("Thickness", 0.0)
|
||||
set_value("Split Threshold", 0.0)
|
||||
restored, restored_faces = evaluated(obj)
|
||||
assert restored_faces == faces
|
||||
np.testing.assert_array_equal(restored, original)
|
||||
|
||||
|
||||
def test_cutout_cleans_triangle_strips_only_when_split_is_enabled():
|
||||
import bmesh
|
||||
from tests.support.depth_surface import surface, evaluated
|
||||
|
||||
obj, set_value = surface(step=0.0, resolution=1)
|
||||
mesh = bmesh.new()
|
||||
mesh.from_mesh(obj.data)
|
||||
bmesh.ops.triangulate(mesh, faces=list(mesh.faces))
|
||||
mesh.to_mesh(obj.data)
|
||||
mesh.free()
|
||||
set_value("Split Threshold", 0.0)
|
||||
original, faces = evaluated(obj)
|
||||
assert len(faces) == 4
|
||||
set_value("Split Threshold", 0.001)
|
||||
assert not evaluated(obj)[1]
|
||||
set_value("Thickness", 0.2)
|
||||
assert not evaluated(obj)[1]
|
||||
set_value("Thickness", 0.0)
|
||||
set_value("Split Threshold", 0.0)
|
||||
restored, restored_faces = evaluated(obj)
|
||||
assert restored_faces == faces
|
||||
np.testing.assert_array_equal(restored, original)
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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":
|
||||
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"))],
|
||||
["uv", "run", "--script", str(Path(__file__).with_name("pisa_sr_export.py")),
|
||||
"--destination", directory],
|
||||
cwd=PROJECT_ROOT, check=True,
|
||||
)
|
||||
if not verify_file(model.destination, model.size, model.sha256):
|
||||
models = [item for item in MODEL_FILES if item.key == model.key]
|
||||
for item in models:
|
||||
if not verify_file(Path(directory) / item.filename, item.size, item.sha256):
|
||||
raise RuntimeError("PiSA-SR export differs from the published catalog")
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
"GeometryNodeBoundBox": 0,
|
||||
"GeometryNodeCaptureAttribute": 3,
|
||||
"GeometryNodeCornersOfEdge": 0,
|
||||
"GeometryNodeCornersOfFace": 0,
|
||||
"GeometryNodeDeleteGeometry": 2,
|
||||
"GeometryNodeEdgesOfCorner": 0,
|
||||
"GeometryNodeExtrudeMesh": 1,
|
||||
"GeometryNodeFaceOfCorner": 0,
|
||||
"GeometryNodeFieldAtIndex": 2,
|
||||
@@ -32,6 +34,7 @@
|
||||
"GeometryNodeInputMeshEdgeNeighbors": 0,
|
||||
"GeometryNodeInputMeshEdgeVertices": 0,
|
||||
"GeometryNodeInputMeshFaceArea": 0,
|
||||
"GeometryNodeInputMeshFaceNeighbors": 0,
|
||||
"GeometryNodeInputMeshIsland": 0,
|
||||
"GeometryNodeInputMeshVertexNeighbors": 0,
|
||||
"GeometryNodeInputNamedAttribute": 1,
|
||||
@@ -42,6 +45,7 @@
|
||||
"GeometryNodeMergeByDistance": 1,
|
||||
"GeometryNodeMeshCube": 0,
|
||||
"GeometryNodeMeshGrid": 0,
|
||||
"GeometryNodeOffsetCornerInFace": 0,
|
||||
"GeometryNodeRemoveAttribute": 1,
|
||||
"GeometryNodeRepeatInput": 0,
|
||||
"GeometryNodeRepeatOutput": 3,
|
||||
@@ -57,6 +61,7 @@
|
||||
"GeometryNodeStoreNamedAttribute": 2,
|
||||
"GeometryNodeSwitch": 1,
|
||||
"GeometryNodeTransform": 1,
|
||||
"GeometryNodeVertexOfCorner": 0,
|
||||
"NodeGroupInput": 0,
|
||||
"NodeGroupOutput": 0,
|
||||
"ShaderNodeClamp": 1,
|
||||
|
||||
@@ -3,16 +3,13 @@ import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from tempfile import TemporaryDirectory, mkdtemp
|
||||
from zipfile import ZipFile
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
BLENDER_MODULES = (
|
||||
"tools.nodes.build",
|
||||
"tools.nodes.check",
|
||||
)
|
||||
|
||||
|
||||
def project_env_value(name, path=None):
|
||||
@@ -57,22 +54,7 @@ def run(command):
|
||||
subprocess.run(command, cwd=PROJECT_ROOT, check=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Build and validate O_AnyImage.blend.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--blender",
|
||||
help="Blender executable; overrides BLENDER_BIN, .env, and PATH.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-tests",
|
||||
action="store_true",
|
||||
help="Skip the Python test suite.",
|
||||
)
|
||||
options = parser.parse_args()
|
||||
executable = blender_path(options.blender)
|
||||
|
||||
def run_blender_modules(executable, modules, arguments=()):
|
||||
with TemporaryDirectory(prefix="anyimage-node-dependencies-") as directory:
|
||||
for wheel in (PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("scipy-*.whl"):
|
||||
with ZipFile(wheel) as archive:
|
||||
@@ -83,16 +65,52 @@ def main():
|
||||
f"import sys, runpy; sys.path[:0] = {paths!r}; "
|
||||
f"sys.path.insert(0, {directory!r} + '/cp' + str(sys.version_info.major) + str(sys.version_info.minor)); "
|
||||
)
|
||||
for module_name in BLENDER_MODULES:
|
||||
for module_name in modules:
|
||||
run(
|
||||
(
|
||||
executable,
|
||||
"--factory-startup",
|
||||
"--background",
|
||||
"--python-exit-code", "1",
|
||||
"--python-expr", bootstrap + f"runpy.run_module({module_name!r}, run_name='__main__')",
|
||||
"--python-expr", bootstrap + f"sys.argv = {[module_name, *arguments]!r}; "
|
||||
+ f"runpy.run_module({module_name!r}, run_name='__main__')",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build, check, or preview node groups.")
|
||||
commands = parser.add_subparsers(dest="command")
|
||||
for name, help_text in (
|
||||
("build", "Build assets, validate them, and run tests"),
|
||||
("check", "Validate saved assets and run tests"),
|
||||
("preview", "Preview node groups from current source"),
|
||||
):
|
||||
command = commands.add_parser(name, help=help_text)
|
||||
command.add_argument("--blender", help="Blender executable; overrides BLENDER_BIN, .env, and PATH")
|
||||
if name == "preview":
|
||||
command.add_argument("--output", type=Path, help="Output HTML path; defaults to a temporary directory")
|
||||
command.add_argument("--no-open", action="store_true", help="Save without opening a browser")
|
||||
command.add_argument("--fragment", action="store_true", help="Save an HTML fragment without opening it")
|
||||
else:
|
||||
command.add_argument("--skip-tests", action="store_true", help="Skip pytest, keeping asset validation")
|
||||
options = parser.parse_args()
|
||||
if options.command is None:
|
||||
parser.print_help()
|
||||
return
|
||||
executable = blender_path(options.blender)
|
||||
if options.command == "preview":
|
||||
output = (options.output or Path(mkdtemp(prefix="anyimage-node-preview-")) / "nodes.html").resolve()
|
||||
arguments = ["--output", str(output)]
|
||||
if options.fragment:
|
||||
arguments.append("--fragment")
|
||||
run_blender_modules(executable, ("tools.nodes.preview.export",), arguments)
|
||||
print(output)
|
||||
if not options.no_open and not options.fragment:
|
||||
webbrowser.open(output.as_uri())
|
||||
return
|
||||
modules = ("tools.nodes.build", "tools.nodes.check") if options.command == "build" else ("tools.nodes.check",)
|
||||
run_blender_modules(executable, modules)
|
||||
if not options.skip_tests:
|
||||
run((sys.executable, "-m", "pytest"))
|
||||
|
||||
|
||||
@@ -50,7 +50,125 @@ def _math(group, operation, a, b=None):
|
||||
return node.outputs[0]
|
||||
|
||||
|
||||
def split_depth_surface(group, geometry, image, strength, uniform):
|
||||
def _remove_strip_faces(group, geometry, *, triangles=False):
|
||||
"""Delete boundary strips from one unchanged topology snapshot."""
|
||||
nodes, links = group.nodes, group.links
|
||||
|
||||
def equals(value, count):
|
||||
compare = nodes.new("FunctionNodeCompare")
|
||||
compare.data_type, compare.operation = "INT", "EQUAL"
|
||||
links.new(value, compare.inputs[2])
|
||||
if isinstance(count, int):
|
||||
compare.inputs[3].default_value = count
|
||||
else:
|
||||
links.new(count, compare.inputs[3])
|
||||
return compare.outputs["Result"]
|
||||
|
||||
def combine(operation, a, b):
|
||||
node = nodes.new("FunctionNodeIntegerMath")
|
||||
node.operation = operation
|
||||
links.new(a, node.inputs[0])
|
||||
links.new(b, node.inputs[1])
|
||||
return node.outputs[0]
|
||||
|
||||
boundaries = []
|
||||
for offset in range(4):
|
||||
index = nodes.new("GeometryNodeInputIndex")
|
||||
corners = nodes.new("GeometryNodeCornersOfFace")
|
||||
links.new(index.outputs[0], corners.inputs["Face Index"])
|
||||
corner = nodes.new("GeometryNodeOffsetCornerInFace")
|
||||
corner.inputs["Offset"].default_value = offset
|
||||
links.new(corners.outputs["Corner Index"], corner.inputs["Corner Index"])
|
||||
edge = nodes.new("GeometryNodeEdgesOfCorner")
|
||||
links.new(corner.outputs["Corner Index"], edge.inputs["Corner Index"])
|
||||
sample = nodes.new("GeometryNodeFieldAtIndex")
|
||||
sample.data_type, sample.domain = "INT", "EDGE"
|
||||
links.new(edge.outputs["Next Edge Index"], sample.inputs["Index"])
|
||||
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
links.new(neighbors.outputs["Face Count"], sample.inputs["Value"])
|
||||
boundaries.append(equals(sample.outputs["Value"], 1))
|
||||
opposite = combine(
|
||||
"MAXIMUM",
|
||||
combine("MULTIPLY", boundaries[0], boundaries[2]),
|
||||
combine("MULTIPLY", boundaries[1], boundaries[3]),
|
||||
)
|
||||
face = nodes.new("GeometryNodeInputMeshFaceNeighbors")
|
||||
selected = combine("MULTIPLY", equals(face.outputs["Vertex Count"], 4), opposite)
|
||||
if triangles:
|
||||
def sample(value, index, domain):
|
||||
node = nodes.new("GeometryNodeFieldAtIndex")
|
||||
node.data_type, node.domain = "INT", domain
|
||||
links.new(value, node.inputs["Value"])
|
||||
links.new(index, node.inputs["Index"])
|
||||
return node.outputs["Value"]
|
||||
|
||||
def offset_corner(corner, offset):
|
||||
node = nodes.new("GeometryNodeOffsetCornerInFace")
|
||||
links.new(corner, node.inputs["Corner Index"])
|
||||
node.inputs["Offset"].default_value = offset
|
||||
return node.outputs["Corner Index"]
|
||||
|
||||
def vertex(corner):
|
||||
node = nodes.new("GeometryNodeVertexOfCorner")
|
||||
links.new(corner, node.inputs["Corner Index"])
|
||||
return node.outputs["Vertex Index"]
|
||||
|
||||
def boundary(corner):
|
||||
edge = nodes.new("GeometryNodeEdgesOfCorner")
|
||||
links.new(corner, edge.inputs["Corner Index"])
|
||||
count = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
return equals(sample(count.outputs["Face Count"], edge.outputs["Next Edge Index"], "EDGE"), 1)
|
||||
|
||||
# Evaluate each internal edge as a symmetric pair of triangles.
|
||||
edge_index = nodes.new("GeometryNodeInputIndex")
|
||||
sides = []
|
||||
for side in (0, 1):
|
||||
corners = nodes.new("GeometryNodeCornersOfEdge")
|
||||
links.new(edge_index.outputs[0], corners.inputs["Edge Index"])
|
||||
corners.inputs["Sort Index"].default_value = side
|
||||
corner = corners.outputs["Corner Index"]
|
||||
face_index = nodes.new("GeometryNodeFaceOfCorner")
|
||||
links.new(corner, face_index.inputs["Corner Index"])
|
||||
face_size = nodes.new("GeometryNodeInputMeshFaceNeighbors")
|
||||
triangle = equals(sample(face_size.outputs["Vertex Count"], face_index.outputs["Face Index"], "FACE"), 3)
|
||||
end, tip = offset_corner(corner, 1), offset_corner(corner, 2)
|
||||
sides.append((triangle, vertex(corner), vertex(tip), boundary(end), boundary(tip)))
|
||||
a, b = sides
|
||||
same_direction = equals(a[1], b[1])
|
||||
same_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[3]), combine("MULTIPLY", a[4], b[4]))
|
||||
reverse_order = combine("MAXIMUM", combine("MULTIPLY", a[3], b[4]), combine("MULTIPLY", a[4], b[3]))
|
||||
pair = combine("MAXIMUM",
|
||||
combine("MULTIPLY", same_direction, reverse_order),
|
||||
combine("MULTIPLY", equals(same_direction, 0), same_order))
|
||||
neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
|
||||
for condition in (equals(neighbors.outputs["Face Count"], 2), a[0], b[0], equals(equals(a[2], b[2]), 0)):
|
||||
pair = combine("MULTIPLY", pair, condition)
|
||||
|
||||
# Explicit edge-to-face sampling marks both members without averaging
|
||||
# through neighboring vertices or iterating over newly exposed edges.
|
||||
face_index = nodes.new("GeometryNodeInputIndex")
|
||||
corners = nodes.new("GeometryNodeCornersOfFace")
|
||||
links.new(face_index.outputs[0], corners.inputs["Face Index"])
|
||||
boundary_count, paired = None, None
|
||||
for offset in range(3):
|
||||
corner = offset_corner(corners.outputs["Corner Index"], offset)
|
||||
edge = nodes.new("GeometryNodeEdgesOfCorner")
|
||||
links.new(corner, edge.inputs["Corner Index"])
|
||||
match = sample(pair, edge.outputs["Next Edge Index"], "EDGE")
|
||||
paired = match if paired is None else combine("MAXIMUM", paired, match)
|
||||
rim = boundary(corner)
|
||||
boundary_count = rim if boundary_count is None else combine("ADD", boundary_count, rim)
|
||||
triangle_selected = combine("MULTIPLY", equals(corners.outputs["Total"], 3),
|
||||
combine("MAXIMUM", paired, equals(boundary_count, 3)))
|
||||
selected = combine("MAXIMUM", selected, triangle_selected)
|
||||
delete = nodes.new("GeometryNodeDeleteGeometry")
|
||||
delete.domain, delete.mode = "FACE", "ALL"
|
||||
links.new(geometry, delete.inputs["Geometry"])
|
||||
links.new(selected, delete.inputs["Selection"])
|
||||
return delete.outputs["Geometry"]
|
||||
|
||||
|
||||
def split_depth_surface(group, geometry, image, strength, uniform, *, triangles=False):
|
||||
"""Keep each face's camera sample on its own corners before splitting."""
|
||||
nodes, links = group.nodes, group.links
|
||||
uv = nodes.new("GeometryNodeInputNamedAttribute")
|
||||
@@ -136,7 +254,7 @@ def split_depth_surface(group, geometry, image, strength, uniform):
|
||||
choose.input_type = "GEOMETRY"
|
||||
links.new(enabled, choose.inputs["Switch"])
|
||||
links.new(geometry, choose.inputs["False"])
|
||||
links.new(split.outputs["Mesh"], choose.inputs["True"])
|
||||
links.new(_remove_strip_faces(group, split.outputs["Mesh"], triangles=triangles), choose.inputs["True"])
|
||||
return choose.outputs["Output"]
|
||||
|
||||
|
||||
|
||||
@@ -436,7 +436,7 @@ def _build_depth_surface(
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
geometry = split_depth_surface(
|
||||
group, geometry, depth_image, split, uniform_scale
|
||||
group, geometry, depth_image, split, uniform_scale, triangles=True
|
||||
)
|
||||
original_camera = _position_field(group, depth_image)
|
||||
geometry, camera_position = store_surface_camera(
|
||||
@@ -517,12 +517,12 @@ def build_image_depth_cutout_group():
|
||||
float_input(group, 'Thickness', 1.0).description = 'Balloon profile multiplier; zero returns the sheet'
|
||||
float_input(group, 'Uniform Thickness', 0.0, subtype='DISTANCE').description = 'Distance used only by Uniform thickness mode'
|
||||
float_input(group, 'Depth Scale', 1.0)
|
||||
float_input(group, 'Split Threshold', 0.0, maximum=1, subtype='FACTOR')
|
||||
iterations = interface_socket(group, 'Smooth', 'INPUT', 'NodeSocketInt')
|
||||
iterations.default_value = 0
|
||||
iterations.min_value, iterations.max_value = (0, 20)
|
||||
options = group.interface.new_panel(name='Options', default_closed=True)
|
||||
float_input(group, 'Cleanup Threshold', 0.0, parent=options)
|
||||
float_input(group, 'Split Threshold', 0.0, maximum=1, subtype='FACTOR', parent=options)
|
||||
float_input(group, 'Reference Depth', 1.0, subtype='DISTANCE', parent=options)
|
||||
float_input(group, 'Edge Round', 0.2, maximum=1, subtype='FACTOR', parent=options)
|
||||
float_input(group, 'Front Inflation', 1.0, maximum=32, parent=options)
|
||||
|
||||
@@ -36,6 +36,7 @@ def build_image_depth_plane_group(image_plane_group):
|
||||
depth_scale = _interface_socket(group, "Depth Scale", "INPUT", "NodeSocketFloat")
|
||||
depth_scale.default_value = 1.0
|
||||
depth_scale.min_value = 0.0
|
||||
float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR")
|
||||
iterations = _interface_socket(group, "Smooth", "INPUT", "NodeSocketInt")
|
||||
iterations.default_value = 0
|
||||
iterations.min_value, iterations.max_value = 0, 20
|
||||
@@ -65,7 +66,6 @@ def build_image_depth_plane_group(image_plane_group):
|
||||
validity_threshold.default_value = 0.9
|
||||
validity_threshold.min_value, validity_threshold.max_value = 0.0, 1.0
|
||||
validity_threshold.subtype = "FACTOR"
|
||||
float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR", parent=options)
|
||||
data = group.interface.new_panel(name="Data", default_closed=True)
|
||||
uniform_scale = _interface_socket(
|
||||
group,
|
||||
|
||||
Reference in New Issue
Block a user