From 196b964efce30d8cd84e3136fe41bec3c24dceff Mon Sep 17 00:00:00 2001 From: nan Date: Tue, 8 Sep 2026 17:18:03 +0800 Subject: [PATCH] Add cutout shell and volume node assets for depth volume processing - Implemented `cutout_shell.py` to build front normals and shell positions for balloon geometry. - Created `cutout_volume.py` to establish smooth center fields and manage balloon surface attributes. - Introduced `pisa_sr_export.py` for exporting PiSA-SR components with dependencies and asset finalization. --- AGENTS.md | 4 +- docs/internals/node-assets.md | 60 +- .../.openspec.yaml | 2 + .../add-pisa-sr-two-times-upscale/design.md | 74 ++ .../add-pisa-sr-two-times-upscale/proposal.md | 27 + .../specs/two-times-upscale/spec.md | 74 ++ .../add-pisa-sr-two-times-upscale/tasks.md | 26 + .../validation.md | 32 + .../add-projected-depth-plane/.openspec.yaml | 2 + .../add-projected-depth-plane/design.md | 77 ++ .../add-projected-depth-plane/proposal.md | 30 + .../specs/cutout-lasso-entry/spec.md | 23 + .../specs/projected-depth-surface/spec.md | 85 ++ .../add-projected-depth-plane/tasks.md | 24 + .../reorganize-cutout-shapes/.openspec.yaml | 2 + .../reorganize-cutout-shapes/design.md | 89 +++ .../reorganize-cutout-shapes/proposal.md | 32 + .../specs/cutout-normal-policy/spec.md | 45 ++ .../specs/cutout-shape-presets/spec.md | 56 ++ .../specs/depth-cutout-volume/spec.md | 45 ++ .../changes/reorganize-cutout-shapes/tasks.md | 30 + .../reorganize-cutout-shapes/validation.md | 24 + src/anyimage/assets/O_AnyImage.blend | 4 +- src/anyimage/common/node.py | 6 +- src/anyimage/common/object.py | 4 +- .../operators/cutout_tool/fine_mesh.py | 5 + .../operators/cutout_tool/geometry.py | 40 + .../operators/cutout_tool/interaction.py | 6 +- src/anyimage/operators/cutout_tool/main.py | 10 +- src/anyimage/operators/cutout_tool/object.py | 27 +- src/anyimage/operators/cutout_tool/shape.py | 20 +- src/anyimage/operators/upscale.py | 4 +- src/anyimage/properties.py | 4 +- src/anyimage/server/model_catalog.py | 31 +- src/anyimage/server/model_manager.py | 32 + src/anyimage/server/models/onnx_pisa_sr.py | 153 ++++ src/anyimage/server/models/onnx_runtime.py | 7 + src/anyimage/server/models/upscale.py | 7 + tests/test_blender_addon.py | 14 +- tests/test_cutout_fine_mesh.py | 21 + tests/test_cutout_geometry.py | 4 +- tests/test_cutout_interaction.py | 23 +- tests/test_cutout_objects.py | 441 ++--------- tests/test_cutout_volume.py | 301 +++++++ tests/test_depth_surface_nodes.py | 123 ++- tests/test_model_catalog.py | 2 +- tests/test_model_onnx_runtime.py | 16 +- tests/test_model_pisa_sr.py | 144 ++++ tests/test_node_assets.py | 2 +- tests/test_server_jobs.py | 2 +- tests/test_upscale_undo.py | 50 ++ .../model_licenses/pisa-sr/LICENSE-APACHE.txt | 202 +++++ .../model_licenses/pisa-sr/LICENSE-MODEL.txt | 84 ++ tools/model_licenses/pisa-sr/NOTICE.txt | 21 + tools/node_assets/build.py | 12 +- tools/node_assets/build_cutout_modifiers.py | 252 ++++++ tools/node_assets/cutout_depth_surface.py | 58 +- .../cutout_depth_surface_layout.py | 170 ++-- tools/node_assets/cutout_inflation.py | 142 ++++ tools/node_assets/cutout_layout.py | 53 ++ tools/node_assets/cutout_rounding.py | 101 +++ tools/node_assets/cutout_shape_layouts.py | 141 ++++ .../{build_cutout.py => cutout_shapes.py} | 432 +---------- tools/node_assets/cutout_shell.py | 122 +++ tools/node_assets/cutout_volume.py | 88 +++ tools/node_assets/geometry_layouts.py | 221 ------ tools/node_assets/node_graph.py | 7 +- tools/node_assets/validate.py | 18 +- tools/node_assets/validate_cutout.py | 732 ++---------------- tools/pisa_sr_export.py | 198 +++++ tools/sync_models.py | 8 + 71 files changed, 3440 insertions(+), 1988 deletions(-) create mode 100644 openspec/changes/add-pisa-sr-two-times-upscale/.openspec.yaml create mode 100644 openspec/changes/add-pisa-sr-two-times-upscale/design.md create mode 100644 openspec/changes/add-pisa-sr-two-times-upscale/proposal.md create mode 100644 openspec/changes/add-pisa-sr-two-times-upscale/specs/two-times-upscale/spec.md create mode 100644 openspec/changes/add-pisa-sr-two-times-upscale/tasks.md create mode 100644 openspec/changes/add-pisa-sr-two-times-upscale/validation.md create mode 100644 openspec/changes/add-projected-depth-plane/.openspec.yaml create mode 100644 openspec/changes/add-projected-depth-plane/design.md create mode 100644 openspec/changes/add-projected-depth-plane/proposal.md create mode 100644 openspec/changes/add-projected-depth-plane/specs/cutout-lasso-entry/spec.md create mode 100644 openspec/changes/add-projected-depth-plane/specs/projected-depth-surface/spec.md create mode 100644 openspec/changes/add-projected-depth-plane/tasks.md create mode 100644 openspec/changes/reorganize-cutout-shapes/.openspec.yaml create mode 100644 openspec/changes/reorganize-cutout-shapes/design.md create mode 100644 openspec/changes/reorganize-cutout-shapes/proposal.md create mode 100644 openspec/changes/reorganize-cutout-shapes/specs/cutout-normal-policy/spec.md create mode 100644 openspec/changes/reorganize-cutout-shapes/specs/cutout-shape-presets/spec.md create mode 100644 openspec/changes/reorganize-cutout-shapes/specs/depth-cutout-volume/spec.md create mode 100644 openspec/changes/reorganize-cutout-shapes/tasks.md create mode 100644 openspec/changes/reorganize-cutout-shapes/validation.md create mode 100644 src/anyimage/server/models/onnx_pisa_sr.py create mode 100644 tests/test_cutout_volume.py create mode 100644 tests/test_model_pisa_sr.py create mode 100644 tests/test_upscale_undo.py create mode 100644 tools/model_licenses/pisa-sr/LICENSE-APACHE.txt create mode 100644 tools/model_licenses/pisa-sr/LICENSE-MODEL.txt create mode 100644 tools/model_licenses/pisa-sr/NOTICE.txt create mode 100644 tools/node_assets/build_cutout_modifiers.py create mode 100644 tools/node_assets/cutout_inflation.py create mode 100644 tools/node_assets/cutout_layout.py create mode 100644 tools/node_assets/cutout_rounding.py create mode 100644 tools/node_assets/cutout_shape_layouts.py rename tools/node_assets/{build_cutout.py => cutout_shapes.py} (62%) create mode 100644 tools/node_assets/cutout_shell.py create mode 100644 tools/node_assets/cutout_volume.py create mode 100644 tools/pisa_sr_export.py diff --git a/AGENTS.md b/AGENTS.md index c256de8..feec24c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,9 @@ uv run sync_models - Group Input 按区域重复放置,Named Attribute 等字段入口就近读取;隐藏所有未使用输出,避免从远处拉线 - 跨区域暂存值用 Store Named Attribute 写入 `o_*` 命名空间,按用途分配前缀并明确 FACE、CORNER、POINT 域和存取时机;末尾用一个 Wildcard 模式的 Remove Named Attribute 清理该前缀,例如 `o_depth_*`,保留用户属性和 UV - Capture Attribute 适合局部字段固定;若属性连线跨越多个步骤,改为命名属性,让计算分支独立展开 -- 支线确需从主轴向左回接时直接连接,不加 Reroute 绕线,允许这些连线交叉;前向旁路只在确实更清楚时使用少量 Reroute。节点本体仍不得重叠或被遮挡 +- 长距离前向几何引用使用少量 Reroute 分段引导;同源引用共用干线,在用途区域附近分叉,避免多条斜线横穿主轴和计算支线 +- Switch 的原始几何旁路用 Reroute 沿主链外侧绕行,接近 Switch 时再汇入;旁路下置时,将该段属性计算集中在主链上方,留出连续通道 +- 支线确需从主轴向左回接时直接连接,允许这些连线交叉;Reroute 用于明确路径与分叉,节点本体仍不得重叠或被遮挡 - 保留节点默认 `name` 和标题,不用 `label` 覆盖,不用 String 节点注解。Frame 仅用于确有包裹语义的区域 - 全接线的简单数学运算可折叠;需要阅读的常数、运算域和模式保持可见 - Repeat、Simulation、For Each Geometry Element 等成对节点,确保位置正确,没有遮挡其他节点 diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index 48741a3..22c43a6 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -33,47 +33,71 @@ uv run build_node --skip-tests | `O Image Depth Plane` | `build_depth_plane.py` | | `O Image Layer`、`O Image Depth Layer` | `build_material_layers.py` | | `O Shadeless` | `build_shadeless.py` | -| `O Image Cutout` | `build_cutout.py` | -| `O Image Surface`、`O Image Balloon` | `build_cutout.py` | -| `O Image Depth Balloon`、`O Image Depth Surface` | `build_cutout.py` | +| `O Image Cutout`、`O Image Depth Cutout`、`O Image Depth Balloon` | `build_cutout_modifiers.py` | 构建从空 Factory 文件生成全部节点组,关闭 `.blend1` 备份,并覆盖唯一发布资产。 -四个 Cutout Shape 子节点统一接收局部 XY 平面,并以 `-Z` 表示向图片内部的方向。`O Image Cutout` 在 Shape 分支与平滑之后处理 **Inward Axis**:默认 `-Z` 直接输出原生几何,`+X` 分支执行一次 Z 到 X 的轴转换;运行时对象矩阵使用逆变换补偿,保证两种模式落在相同世界空间位置。 +Depth Cutout 的布局由 `cutout_depth_surface_layout.py` 编排:Split 属性采样位于主链上方,原始几何旁路沿下方的 Reroute 通道汇入 Switch;长距离投影引用共用分段干线,并在 Uniform 厚度与零厚度输出附近分叉。几何统计支线就近放置在其使用区域。 -`O Image Depth Plane`、`O Image Cutout`、`O Image Depth Balloon` 与 `O Image Depth Surface` 统一使用 **Reference Depth** 作为采样深度的运算基准。 +三个 Cutout 节点组直接作为修改器使用,统一接收局部 XY 平面,以 `-Z` 表示向图片内部的方向。每组在塑形与平滑后处理 **Inward Axis**:默认 `-Z` 直接输出原生几何,`+X` 分支执行一次 Z 到 X 的轴转换;运行时对象矩阵使用逆变换补偿,保证两种模式落在相同世界空间位置。 -### Depth Surface 分离与厚度 +`O Image Depth Plane`、`O Image Depth Cutout` 与 `O Image Depth Balloon` 使用 **Reference Depth** 作为采样深度的运算基准。 -Options 中的 **Split** 范围为 `0–1`,默认 `0` 关闭分离,数值越大越容易分离。节点比较 `场景 Z 差 × Split > 原始面中心距离 × 2.5`;`0.5` 对应深度坡度阈值 `5`。场景 Z 差包含图片标定的 **Uniform Scale** 和 **Depth Scale**。 +### Cutout 预设与厚度 + +| 创建入口 | 修改器节点组 | 默认设置 | +| --- | --- | --- | +| Flat | O Image Cutout | Balloon,Thickness 0 | +| Solid(默认) | O Image Cutout | Balloon,Thickness 1 | +| Depth Solid | O Image Depth Cutout | Balloon,Thickness 1 | +| Depth Balloon | O Image Depth Balloon | Thickness 1,Double Sided 开启 | + +前两种节点组提供 **Mode**,默认显示 Balloon。两个独立输入均显示为 **Thickness**:第一个是 Balloon 的无量纲倍率,第二个是 Uniform 的距离,各自保存数值。普通 Cutout 在零厚度时输出平面,深度 Cutout 输出深度表面。全部基础网格包含 POINT 域的 `o_balloon`,Flat 可直接调整为 Solid。 + +深度 Cutout 的 Balloon 厚度由 `cutout_volume.py` 构造平滑中心,再由 `cutout_rounding.py` 处理圆润轮廓与凸起方向。Options 中位于 Front Inflation 前的 **Edge Round** 默认 0.2,正背面共用 `1 − smootherstep(0, max(o_balloon), o_balloon)` 衰减权重。正面向中心加 Balloon 的轮廓靠拢,背面按 Balloon 值补充饱满度。Options 中的 **Normal Smooth** 默认 50:在中心曲面上平滑并归一化法线,背面沿该方向凸起,正面按边缘权重过渡。两个边缘范围及法线影响固定为 1。 + +中心位置以投影正面 Z 减去 `o_balloon` 初始化,固定轮廓与切口,在内部通过一个 Blur Attribute 执行 256 次邻域平滑,边界 Weight 为 0。圆润处理前,深凹处保留至少 `0.05 × o_balloon` 的厚度。轮廓零厚度点按来源身份合并,暂存字段使用 POINT 域 `o_balloon_surface_*` 并在输出前清理。`cutout_shell.py` 让薄厚度的 Balloon 从正面沿法线向内生长,再按平滑过渡达到 Thickness 为 1 的圆润体积;大于 1 时继续沿用该体积公式。Uniform 沿正面法线偏移指定距离,同样受 Normal Smooth 控制。Thickness 为 0 时返回原深度表面。整体 Smooth 复用公共构建函数,三个 Cutout 节点组的 Smooth 默认 0,Smooth Weight 默认 1。 + +`cutout_inflation.py` 在邻域中拟合深度已有的结构性鼓起,Options 中 **Front Inflation** 默认 1。Balloon 令结构量为 k、Thickness 与 Front Inflation 的乘积为 a,正面增加 `o_balloon × (cbrt((k+0.25)³+(a+0.25)³−0.25³)−k−0.25)`,通过有理化形式求值以保持小厚度精度。已有 1 倍鼓起、两个参数均为 1 时,额外增加约 0.323 倍 Balloon。Uniform 直接增加 `Thickness × Front Inflation`,不受结构量或轮廓权重限制;Front Inflation 为 1 时,正面边缘和中心的增量均等于背面的厚度距离。两种模式厚度为 0 时仍返回原表面,单层侧壁直接连接正背面。 + +**Normal Smooth** 同时控制正面、背面与 Uniform 的法线;正面膨胀在基础法线平滑之后使用 5.12 倍迭代范围,默认 50 对应额外 256 次,保持大膨胀量下的连续方向。设置为 0 时两阶段均停止平滑。Front Inflation 为 0 时关闭正面额外鼓起。 + +Normal Map 勾选控制 Flat 与 Solid 的 TANGENT 法线;Depth Solid 固定生成 OBJECT 法线,Depth Balloon 固定生成 TANGENT 法线。材质读取 `o_normal_scale`:普通 Cutout 和 Depth Balloon 使用 POINT 域的轮廓适配权重;Depth Cutout 使用 FACE 域的正面标记,让背面与切口保留几何法线。该属性属于输出材质协议,随 UVMap 与 o_balloon 保留。 + +Options 依次排列 Cleanup Threshold、Split Threshold、Reference Depth、Edge Round、Front Inflation、Normal Smooth、Smooth Weight;各节点组仅显示适用项,Depth Balloon 的 Depth Direction 位于末尾。 + +### Depth Cutout 分离与厚度 + +Options 中的 **Split Threshold** 范围为 `0–1`,默认 `0` 关闭分离,数值越大越容易分离。节点比较 `原始 Z 差 × Uniform Scale × Split Threshold > 原始面中心距离 × 2.5`;`0.5` 对应深度坡度阈值 `5`。**Uniform Scale** 将原始深度标定到网格单位,**Depth Scale** 仅控制最终置换幅度。 ```mermaid flowchart TD - A[基础网格与深度图片] --> B[面中心采样与共享边判别] + A[基础网格与深度图片] --> D[共用 Cleanup 按原平面岛面积清理] + D --> B[面中心采样与共享边判别] B --> C[存入面角命名属性并 Split Edges] - C --> D[Cleanup 清理拆后平面区域] - D --> E[切口顶点沿原相机射线采用所属侧深度] + C --> E[切口顶点沿原相机射线采用所属侧深度] E --> J[固定顶点数据并投影] - J --> F[生成正背面与分段侧壁] - F --> G[按原顶点及厚度层身份连接] + J --> L[选择平滑 Balloon 或 Uniform 厚度] + L --> F[生成正背面与单层四边侧壁] + F --> G[按原顶点及正背面身份连接] G --> H[恢复场景坐标] H --> K[移除 o_depth_* 暂存属性] - K --> I[外层 Smooth 与方向变换] + K --> I[边缘与整体平滑及方向变换] ``` -Split 关闭时使用原顶点采样,Cleanup 仍处理原平面区域。Depth Surface 的 Cleanup 在分离后、投影和厚度前执行;其他 Shape 保持原清理流程。 +三个节点组在塑形前复用 Cleanup,按原平面岛的总面积清理小区域。Depth Cutout 随后执行 Split,保留分离产生的小片;Split 关闭时使用原顶点采样。 未关联选中切边的顶点使用原始 XYZ。切口顶点以 `原 XYZ × 所属侧 Z / 原 Z` 保持原相机射线,随后执行既有 Uniform Scale、Reference Depth 和 Depth Scale 投影。 -厚度前固定顶点数据,使正背面和侧壁使用相同位置。连接时以原顶点编号和厚度层构造临时整数坐标,合并同一身份的副本后恢复场景坐标,保持近距离分离区域各自独立。实现位于 `cutout_depth_surface.py`,由 `build_cutout.py` 构建进共享节点资产。 +厚度前固定顶点数据,使正背面和侧壁使用相同位置。连接时以原顶点编号和正背面身份构造临时整数坐标,合并同一身份的副本后恢复场景坐标,保持近距离分离区域各自独立。实现位于 `cutout_depth_surface.py`,由 `build_cutout_modifiers.py` 构建进共享节点资产。 -暂存值通过 Store Named Attribute 写入保留前缀 `o_depth_`:面中心与相机采样使用 FACE,所属侧相机采样与切口标记使用 CORNER,最终相机坐标、表面位置、源顶点编号与厚度分段数使用 POINT。计算区域就近使用 Named Attribute,末尾用一个 Wildcard 模式的 Remove Named Attribute 匹配 `o_depth_*`,输出保留 UVMap 与用户属性。 +暂存值通过 Store Named Attribute 写入保留前缀 `o_depth_`:面中心与相机采样使用 FACE,所属侧相机采样与切口标记使用 CORNER,最终相机坐标、表面位置与源顶点编号使用 POINT。计算区域就近使用 Named Attribute,末尾用一个 Wildcard 模式的 Remove Named Attribute 匹配 `o_depth_*`,输出保留 UVMap 与用户属性。 ### 几何节点布局 -全部七个几何节点组使用连续横排的几何主轴。主轴可适当宽松,支线内部紧凑,同一目的的计算聚在同一侧,内部间距明显小于与其他部分的距离;独立用途可在两侧错落展开。长算式横排,末端就近汇入主轴。Join Geometry、Menu Switch、Combine XYZ 的同级独立分支末端排成一列并对齐输出端,菜单分支按输入顺序上下排列,共享上游留在公共位置。必要的向左引用直接连接。Group Input 与 Named Attribute 就近放置,全接线的简单运算可折叠,常数和域设置保持可见。 +全部五个几何节点组使用连续横排的几何主轴。主轴可适当宽松,支线内部紧凑,同一目的的计算聚在同一侧,内部间距明显小于与其他部分的距离;独立用途可在两侧错落展开。长算式横排,末端就近汇入主轴。Join Geometry、Menu Switch、Combine XYZ 的同级独立分支末端排成一列并对齐输出端,菜单分支按输入顺序上下排列,共享上游留在公共位置。必要的向左引用直接连接。Group Input 与 Named Attribute 就近放置,全接线的简单运算可折叠,常数和域设置保持可见。 -`geometry_layouts.py` 组织平面、外层 Cutout 和三个 Shape 的布局,`cutout_depth_surface_layout.py` 组织 Depth Surface;`node_layout.py` 统一放置局部输入、隐藏未使用输出并检查节点边界。具体规则见 [AGENTS.md](../../AGENTS.md)。布局验收包含实际节点编辑器预览,以及重排前后的运算、默认值、实际连接和几何结果比较。 +`geometry_layouts.py` 组织平面资产布局,`cutout_shape_layouts.py` 和 `cutout_depth_surface_layout.py` 组织 Cutout 的局部计算阶段;`cutout_layout.py` 将阶段布局放入三个修改器,`node_layout.py` 验证节点边界与同级输入对齐。具体规则见 [AGENTS.md](../../AGENTS.md)。布局验收包含实际节点编辑器预览及几何求值。 ## 修改节点组 diff --git a/openspec/changes/add-pisa-sr-two-times-upscale/.openspec.yaml b/openspec/changes/add-pisa-sr-two-times-upscale/.openspec.yaml new file mode 100644 index 0000000..7a8e2be --- /dev/null +++ b/openspec/changes/add-pisa-sr-two-times-upscale/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/add-pisa-sr-two-times-upscale/design.md b/openspec/changes/add-pisa-sr-two-times-upscale/design.md new file mode 100644 index 0000000..59a3bd3 --- /dev/null +++ b/openspec/changes/add-pisa-sr-two-times-upscale/design.md @@ -0,0 +1,74 @@ +## Context + +当前 `onnx_upscale.py` 用 256 像素 tile 执行三个 4× 模型,`models/upscale.py` 负责输入尺寸限制和 alpha 回填,`ModelManager` 缓存单个 Upscale Session。Operator 的倍率和提示均为 4×。 + +本地实验位于 `build/upscale-explore/pisa-research/`。已验证固定 512×512 的 PiSA-SR FP16 ONNX 全流程在 DirectML 上出图,0.7 预设对应像素强度 1.0、语义强度 0.7、空提示词和 seed 42。两套 UNet 常驻时,512 输出约 1.57–2.37 秒;默认强度 1.0 的单 UNet 速度不代表 0.7。分块和跨设备部署尚待验证。 + +## Goals / Non-Goals + +**Goals:** 四个选项统一 2× 输出;复现已选定的 PiSA-SR 0.7 观感;通过已有 ONNX 运行时服务图片和逐帧任务。 + +**Non-Goals:** 本次范围限定为固定预设与既有 Upscale 操作,强度滑杆、提示词编辑和视频时序增强属于独立能力。 + +## Decisions + +### 1. 最终倍率与模型原生倍率分别定义 + +```mermaid +flowchart TD + A[读取并限制输入尺寸] --> B{模型} + B -->|现有三个模型| C[原生 4× 分块推理] + C --> D[Lanczos 缩至输入宽高的 2 倍] + B -->|PiSA-SR| E[Bicubic 放大至 2×] + E --> F[VAE 编码与固定噪声采样] + F --> G[像素与完整 UNet 预测] + G --> H[按语义强度 0.7 组合并解码] + H --> I[AdaIN 颜色校正与裁切] + D --> J[按最终尺寸回填原始 alpha] + I --> J + J --> K[保存结果并替换图片] +``` + +公共结果倍率为 2,旧 ONNX 图的 shape 校验仍为原生 4。先实现完整 4× 拼接后一次 Lanczos 缩小,作为明确的像素基线;避免先逐 tile 缩小引入新的滤波接缝。最终尺寸相对于 `limit_image()` 之后的输入,沿用 Operator 对输入长边的既有限制。 + +保留三个模型的 key、原生名称和默认选择,说明文字明确最终输出 2×。新增 key `PISA_SR`、显示名 `PiSA-SR`,说明固定细节强度 0.7;生产和 Debug 复用相同输出入口。 + +### 2. 固定 0.7 的定义与实验一致 + +使用同一采样 latent 计算 `pixel + 0.7 * (full - pixel)`,再按已验证的公式解码。空提示词 embedding 与 timestep 固定在资产中,seed 固定为 42;采样算法、精度和噪声布局通过测试锁定。保留实验中的 Bicubic 输入插值、VAE scaling、输出量化和 AdaIN 颜色校正。 + +该强度是在预测结果上插值。将 LoRA 权重乘以 0.7 或插值两套 UNet 权重,不能视为等价的单 UNet 优化;任何这样的优化必须另行通过效果对照。 + +### 3. PiSA-SR 使用独立适配器与多文件资产 + +新增 `server/models/onnx_pisa_sr.py` 承担 VAE、UNet、采样及分块。`ModelManager` 按模型类型创建单 Session 或 PiSA-SR 管线,并统一缓存 key、释放和状态展示。Session 创建和运行复用 `onnx_runtime.py`,补齐 DirectML 所需的 sequential execution 与禁用 memory pattern 配置。 + +资产以 encoder、decoder、pixel UNet、full UNet 为明确文件集合,通过既有 catalog 下载与完整性校验。实验包约 3.63 GB,发布清单必须使用最终导出文件的尺寸和 SHA256。导出工具固定 PiSA 源码 commit、基础模型 revision、LoRA 校验值和导出环境;训练依赖仅用于离线工具。实验目录仅作为参考,不作为运行时路径。 + +### 4. 分阶段执行限制模型常驻 + +优先采用按阶段处理全部 tile 的调度:编码并把 latent 存入主机内存,运行像素 UNet 保存预测,释放该 Session 后运行完整 UNet并组合,最后解码。GPU 上同一时刻只常驻一套 UNet;切换在整图阶段边界发生。大图中间数组可落入任务临时目录,结束或取消后清理。 + +此方案以降低显存峰值为优先,代价是阶段加载成本;实施时同时测冷启动、连续图片和多帧耗时。任务结束按实际 Session 最后使用点释放临时推理内存,资源错误使整个 PiSA 管线缓存失效,Clear Models 释放全部组件。 + +### 5. 固定尺寸 ONNX 通过重叠分块覆盖任意输入 + +以已验证的 512×512 输出 tile 为初始部署尺寸,采用重叠区域加权融合,边缘填充后裁回精确的 2× 尺寸。噪声按整图 latent 坐标生成并切片,重叠区域共享噪声;AdaIN 在拼接完成后按整图执行。单 tile 情况保持实验基线,多 tile 的重叠宽度通过接缝对照确定并固化为内部常量。 + +alpha 从受尺寸限制的源图直接 Lanczos 放大到最终 2×,独立于生成模型;透明 RGB 的处理沿用现有输入语义,并加入半透明边缘目视验收。多帧逐帧处理并复用固定噪声规则,支持取消;生成纹理的时序稳定性列入验收观察。 + +## Risks / Trade-offs + +- [分块改变生成纹理或形成接缝] → 对两张实验样图及跨 tile 的纹理、渐变、透明边缘做完整图与局部对照;接缝未通过时继续调整分块方案。 +- [分阶段 Session 加载拖慢连续任务] → 记录端到端冷暖耗时、峰值显存和主机内存;不将单 UNet 默认模式的数字作为 0.7 性能承诺。 +- [FP16 图在 CPU/CoreML 上兼容性未知] → 实测项目支持的设备路径;需要时导出匹配的精度资产,并在 catalog 中表达准确支持范围。不可运行的设备返回明确原因。 +- [新细节改变原始图案或引起逐帧闪动] → 保留已有模型选择,验收脸部、文字、纹理和短序列样例,界面说明生成型细节补充。 +- [多 GB 权重分发与依赖来源] → 发布前核对基础模型和 PiSA 权重的分发条款,登记固定来源及最终文件校验信息。 + +## Migration Plan + +先准备与验证 PiSA 资产及分块管线,再接入 catalog、模型管理器和界面,统一最终 2× 结果并运行测试。现有用户保存的模型 key 继续有效,后续执行采用 2×;已有图片产物无需迁移。回退时同步回退模型选项和推理入口,已下载资产可由已有缓存管理处理。 + +## Open Questions + +实施实验需确定分块重叠宽度、阶段加载的可接受耗时,以及 CPU/CoreML 的实际精度与支持范围。这些验证不改变 0.7 和最终 2× 的产品约定。 diff --git a/openspec/changes/add-pisa-sr-two-times-upscale/proposal.md b/openspec/changes/add-pisa-sr-two-times-upscale/proposal.md new file mode 100644 index 0000000..31343c7 --- /dev/null +++ b/openspec/changes/add-pisa-sr-two-times-upscale/proposal.md @@ -0,0 +1,27 @@ +## Why + +现有 Upscale 每次把宽高放大 4 倍,尺寸增长过快。PiSA-SR 在已测试的 0.7 细节强度下能够补充更丰富的纹理,适合作为新的生成型选项;所有模型统一每次输出 2×,方便逐步放大。 + +## What Changes + +- 新增 `PiSA-SR` 模型选项,固定 `lambda_pix=1.0`、`lambda_sem=0.7`,输出宽高各为输入的 2 倍。 +- **BREAKING**:现有 General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus 仍使用已有 4× 权重,完成推理后以 Lanczos 缩小至一半,最终输出 2×。 +- Upscale 的尺寸计算、界面说明、生产与调试任务统一采用最终 2× 语义;保留现有默认模型。 +- PiSA-SR 通过 ONNX Runtime 接入模型下载、设备选择、缓存、取消和资源释放流程,支持任意合法输入尺寸与透明度保留。 + +## Capabilities + +### New Capabilities + +- `two-times-upscale`: 四个模型的统一 2× 输出、PiSA-SR 固定细节预设及模型资源生命周期。 + +### Modified Capabilities + +无已发布的对应 capability。 + +## Impact + +- `server/model_catalog.py`、模型下载与导出工具:增加 PiSA-SR 多文件模型资产及校验信息。 +- `server/models/`、`server/model_manager.py`:PiSA-SR 推理、分块及多 Session 生命周期;旧模型结果缩小。 +- `operators/upscale.py`、模型选择与相关界面:模型选项和最终倍率。 +- 生产、调试、透明图片和多帧处理相关测试。运行时沿用 ONNX Runtime;PyTorch 等仅用于离线导出与数值验证。 diff --git a/openspec/changes/add-pisa-sr-two-times-upscale/specs/two-times-upscale/spec.md b/openspec/changes/add-pisa-sr-two-times-upscale/specs/two-times-upscale/spec.md new file mode 100644 index 0000000..f93127f --- /dev/null +++ b/openspec/changes/add-pisa-sr-two-times-upscale/specs/two-times-upscale/spec.md @@ -0,0 +1,74 @@ +## ADDED Requirements + +### Requirement: Unified two-times output + +系统 SHALL 对全部 Upscale 模型返回处理输入宽高各 2 倍的结果,并将该倍率用于界面说明和尺寸计算;处理输入为应用已有最大输入尺寸限制后的图像。 + +#### Scenario: Existing models produce two-times images +- **WHEN** 任一现有模型处理 W×H 输入 +- **THEN** 系统先得到原生 4W×4H 结果,再使用 Lanczos 缩小到 2W×2H +- **AND** 使用已有 4× 权重 + +#### Scenario: Repeated upscale +- **WHEN** 用户对一次 2× 结果再次执行 Upscale 且输入尺寸仍满足限制 +- **THEN** 新结果宽高各为本次输入的 2 倍,即最初输入的 4 倍 + +### Requirement: Fixed PiSA-SR preset + +系统 SHALL 提供 `PISA_SR` 模型选项,使用固定像素强度 1.0、语义强度 0.7、空提示词和 seed 42,通过 ONNX Runtime 生成 2× 结果。现有默认模型 SHALL 保持有效。 + +#### Scenario: Selected preset is executed +- **WHEN** 用户选择 PiSA-SR 并执行 Upscale +- **THEN** 模型对同一 latent 的预测按 `pixel + 0.7 * (full - pixel)` 组合 +- **AND** 输入插值、采样、解码与颜色校正遵循已验证的实验基线 + +#### Scenario: Repeatable inference +- **WHEN** 相同图像在相同运行环境重复执行 PiSA-SR +- **THEN** 系统使用相同噪声和处理规则,结果在该设备的数值容差内一致 + +### Requirement: Arbitrary dimensions and alpha preservation + +系统 SHALL 支持合法输入范围内的小图、非方形图、奇数尺寸和跨 tile 图像,输出精确 2× 尺寸;有 alpha 的生产输入 SHALL 保留由源 alpha 插值而来的透明度。 + +#### Scenario: Tiled PiSA-SR image +- **WHEN** 输入需要多个 PiSA-SR tile +- **THEN** 系统融合重叠区域并裁去填充,输出无多余边缘的 2W×2H 图像 +- **AND** 连续纹理与渐变测试图的 tile 边界不出现可辨识拼接线 + +#### Scenario: Transparent input +- **WHEN** 输入包含全透明和半透明像素 +- **THEN** 输出 alpha 等于源 alpha 直接 Lanczos 放大到最终尺寸的结果 +- **AND** RGB 生成过程不生成或覆盖 alpha + +### Requirement: Model readiness and resource lifecycle + +系统 SHALL 对 PiSA-SR 全部必需资产完成下载及校验后才判定就绪,按所选设备创建管线;其模型切换、取消、资源错误和显式清理 SHALL 覆盖全部组件。 + +#### Scenario: Incomplete model package +- **WHEN** PiSA-SR 任一必需文件缺失或校验失败 +- **THEN** 系统提示准备模型并阻止使用不完整管线进行推理 + +#### Scenario: Bounded UNet residency +- **WHEN** PiSA-SR 执行像素与完整预测阶段 +- **THEN** GPU 同时常驻的 UNet 至多一套,阶段间中间数据被保留以完成 0.7 组合 + +#### Scenario: Cancel or resource failure +- **WHEN** 推理被取消或发生资源错误 +- **THEN** 系统停止后续阶段并清理任务中间文件 +- **AND** 资源错误释放整个 PiSA-SR 管线并报告可理解的失败原因 + +#### Scenario: Unsupported device +- **WHEN** 所选设备无法执行已分发的 PiSA-SR 资产 +- **THEN** 系统报告明确的设备支持原因或执行已验证并明确呈现的回退路径 + +### Requirement: Consistent job behavior + +系统 SHALL 在生产与调试任务采用同一模型预设和最终倍率,并对多帧输入逐帧应用该约定。 + +#### Scenario: Multiple frames +- **WHEN** Upscale 处理多帧输入 +- **THEN** 每帧输出均为其处理输入的 2× 尺寸,帧顺序正确且阶段之间可以取消 + +#### Scenario: Debug matches production +- **WHEN** 相同 RGB 输入、模型和设备分别通过生产与 Debug 入口处理 +- **THEN** 两者的 RGB 处理规则和最终尺寸一致 diff --git a/openspec/changes/add-pisa-sr-two-times-upscale/tasks.md b/openspec/changes/add-pisa-sr-two-times-upscale/tasks.md new file mode 100644 index 0000000..c78851f --- /dev/null +++ b/openspec/changes/add-pisa-sr-two-times-upscale/tasks.md @@ -0,0 +1,26 @@ +## 1. PiSA-SR 资产与数值基线 + +- [x] 1.1 将实验的固定来源、revision、LoRA 校验信息与离线依赖纳入正式导出工具,核对分发条款并实现四个 ONNX 组件的可复现导出。 +- [x] 1.2 固化 0.7 预测组合、seed 42 采样、空提示词、插值、量化和 AdaIN 规则;对两张样图验证 PyTorch 与 ONNX 0.7 的最终 RGB 平均绝对误差不超过 1 个 8-bit 灰度级,并目视确认细节。 +- [x] 1.3 验证 DirectML、CUDA 及 CPU/CoreML 的实际支持路径,确定精度资产与设备支持信息,记录不可用路径的明确处理方式。 + +## 2. 分块与资源管理 + +- [x] 2.1 实现 PiSA-SR 独立 ONNX 适配器,复用共享运行时,补齐 DirectML Session 配置并覆盖其他模型配置回归。 +- [x] 2.2 实现编码、像素预测、完整预测、解码的分阶段调度,GPU 至多常驻一套 UNet;覆盖阶段切换、中间数据保存及临时文件清理。 +- [x] 2.3 实现固定尺寸重叠分块、共享坐标噪声、加权拼接与最终裁切;用单 tile 基线、奇数尺寸、小图、渐变和跨边界纹理确定重叠参数并确认无可辨识接缝。 +- [x] 2.4 接入模型管理器的管线缓存、切换、Clear Models、取消与 OOM 释放;验证完整任务各组件的最后使用点和多帧行为。 +- [x] 2.5 实测 0.7 的 512、1024 和多 tile 输出,记录加载时间、端到端冷暖耗时、连续帧耗时及峰值显存/主机内存,确认阶段调度消除双 UNet 常驻。 + +## 3. 模型选项与统一倍率 + +- [x] 3.1 将最终 PiSA-SR 文件及 SHA256、尺寸、来源加入 catalog 和模型同步工具,覆盖多文件下载、损坏校验与就绪判定。 +- [x] 3.2 将三个旧模型的完整原生 4× 输出以 Lanczos 缩至 2×,保留原生 shape 校验;测试结果与先完整 4× 再缩半的基线一致。 +- [x] 3.3 在公共 Upscale 入口按模型分派,统一输入限制及最终 alpha 回填,覆盖生产与 Debug 两种路径。 +- [x] 3.4 新增 PiSA-SR 选项和 0.7 说明,更新 Operator 最终倍率、尺寸计算及所有相关 4× 行为提示,保留现有模型 key 与默认选择。 + +## 4. 验收 + +- [x] 4.1 覆盖四模型最终尺寸、连续两次 2×、输入限制、透明度、帧顺序、取消与资源错误的必要回归测试。 +- [x] 4.2 在 Blender 中验证模型选择、准备模型、图片替换与撤销,对样图、文字、半透明边缘和短序列目视检查细节、接缝及闪动。 +- [x] 4.3 运行相关测试及 `uv run pytest`;依赖或 Manifest 如有变更,核对锁文件与打包测试。记录实际设备验证范围及未解决问题。 diff --git a/openspec/changes/add-pisa-sr-two-times-upscale/validation.md b/openspec/changes/add-pisa-sr-two-times-upscale/validation.md new file mode 100644 index 0000000..80389aa --- /dev/null +++ b/openspec/changes/add-pisa-sr-two-times-upscale/validation.md @@ -0,0 +1,32 @@ +## 实施结果 + +四个 Upscale 选项统一输出 2×。PiSA-SR 固定像素强度 1.0、语义强度 0.7;旧模型完成原生 4× 拼接后使用 Lanczos 缩半。默认模型与现有 key 保持有效。 + +PiSA-SR 使用 512 输出 tile、128 像素重叠。固定 seed 42 的 64×64 latent 噪声作为 noise.npy 分发,按整图坐标周期索引,重叠区域使用相同噪声。中间 latent 与像素预测存入任务临时目录,GPU 同时只加载一个组件;最终颜色校正在整图拼接后执行。 + +正式导出器从固定源码、基础权重和 LoRA 重新生成的四个 ONNX 文件与实验图逐文件 SHA256 相同。发布资产另外写入许可和修改元数据,并随包提供 Apache 2.0、CreativeML Open RAIL++-M 与 NOTICE。最终 8 个文件已上传 R2;远端大小核对零差异,应用实际下载函数完成 noise.npy 的下载与 SHA256 校验。 + +## 验证 + +- `uv run --no-sync pytest -q`:680 passed、2 xfailed、60 subtests passed。 +- 两张 0.7 样图,DirectML 与 PyTorch 最终成品平均绝对差异分别为 0.693、0.596 个 8-bit 灰度级;CPU 为 0.257、0.569。 +- DirectML 测试覆盖 1024×1024、1114×1518、1026×514 和 2×6 输出;完整主体、渐变的目视检查未见明显拼接线。 +- 三帧轻微平移、文字和半透明边缘通过生产入口验证;alpha 与源 alpha 直接 Lanczos 放大完全一致。细纹理随帧有变化,文字出现生成型模型的笔画变化。 +- Blender 4.5.3 实际 Python 接口验证四个模型枚举、Operator 注册、结果尺寸与图片替换;真实 Undo 测试恢复原图尺寸和像素。窗口菜单未通过鼠标操作,菜单与请求参数由接口测试覆盖。 + +## 速度与设备 + +RTX 5070 Ti、其他 GPU 应用同时运行。计时包含阶段加载、采样、拼接和颜色校正;不含模型下载。 + +| DirectML 输出 | 完整耗时 | +| --- | --- | +| 512 首次 | 5.24 秒 | +| 512 重复 | 4.67 秒 | +| 1024 | 5.55 秒 | +| 三帧逐帧 | 每帧 4.79–5.05 秒 | + +采样进程 RSS 峰值约 437 MiB;显卡总占用从约 7985 MiB 到峰值 11930 MiB,包含其他应用,不能视作本进程独占显存。阶段加载约占单张 512 耗时的大部分。 + +DirectML 和 CPU 完整出图。独立 CUDA ONNX Runtime 1.24.4 配合 torch 2.7.1 的 cuDNN 9.7.1 在本机 VAE 卷积上报 `No valid engine configs for ConvFwd`;该组合未通过 CUDA 验收,运行错误明确提示改选 DirectML 或 CPU,关闭 Session 的运行中隐式回退。CoreML 无可用测试设备,当前 PiSA-SR 明确提示选择 CPU。Windows 的既有生产路径使用 DirectML。 + +实验日志、输出图片和各阶段计时在 `build/upscale-explore/pisa-research/`。项目运行时依赖与 Manifest 未修改。 diff --git a/openspec/changes/add-projected-depth-plane/.openspec.yaml b/openspec/changes/add-projected-depth-plane/.openspec.yaml new file mode 100644 index 0000000..7a8e2be --- /dev/null +++ b/openspec/changes/add-projected-depth-plane/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-08 diff --git a/openspec/changes/add-projected-depth-plane/design.md b/openspec/changes/add-projected-depth-plane/design.md new file mode 100644 index 0000000..7b512d5 --- /dev/null +++ b/openspec/changes/add-projected-depth-plane/design.md @@ -0,0 +1,77 @@ +## Context + +普通 Plane 的 `create_mesh_plane_mesh()` 创建中心四边形和完整 UV,`O Mesh Plane` 负责矩形细分。现有 Depth Plane 使用固定底面的浮雕置换;Depth Cutout 的曲面分支使用相机 XYZ 投影。整图 Job 已通过通用 Artifact 层执行一次 MoGe-2 推理,该层支持 VECTOR Depth。 + +Cutout 的双击入口集中在 `SelectCutoutSelection.invoke()`、`full_image` Property 和 WorkspaceTool keymap。工作区中的 Cutout Shape 重组仍在进行,本提案以当前文件中的实际实现为基线。 + +## Goals / Non-Goals + +本次目标是建立独立的整图 Depth Surface 转换,并让 Cutout 通过套索提交区域。Depth Surface 的用户控制固定为 Subdivide、Thickness、Depth Scale、Reference Depth。 + +## Decisions + +### 入口与命名 + +采用 `ConvertToDepthSurface`、`anyimage.convert_to_depth_surface`、界面文字 **Convert to Depth Surface** 和资产名 `O Image Depth Surface`,表达输出是相机投影曲面。菜单中的顺序为 Plane、Depth Plane、Depth Surface,现有 Depth Plane 继续承担固定底面浮雕。 + +Cutout 直接使用共享拖动 keymap,删除双击整图绑定、`full_image` 声明和完整矩形 Selection 构造代码;`invoke()` 仅记录 Pie Event 后启动共享手势。清理因此失去用途的 import 和测试夹具。 + +### 整图生成主链 + +```mermaid +flowchart TD + Image[Image Empty] --> Convert[Convert to Depth Surface] + Convert --> Check[静态图片与 AI 环境检查] + Check --> Job[整图 Depth Job:VECTOR 模式] + Job --> Prediction[一次 MoGe-2 推理] + Prediction --> Files[完整 Color、vector-depth.exr、depth.json、tangent-normal.png] + Files --> Load[主线程加载并 Pack 深度与法线] + Load --> Mesh[共用矩形 Mesh、中心矩阵与材质] + Mesh --> Modifier[加载 O Image Depth Surface] + Modifier --> Result[替换源 Empty 并创建一个 Undo 步骤] +``` + +在现有整图 Job 上增加明确的深度产物选择,Depth Plane 使用 Z,Depth Surface 使用 VECTOR;两者均从一次预测生成 Tangent Normal。Blender 侧将转换类型随请求保存至响应阶段,据此选择节点组和结果标识。复用 `common` 的图片加载、深度尺度、材质与对象替换函数;Server 只引用自身目录内的共享 Artifact 层。 + +### 基础网格与节点接口 + +Depth Surface 使用 `create_mesh_plane_mesh()` 和 `build_mesh_plane_matrix()`,保持整张图片的矩形边界、中心原点、UV 和材质槽。节点内部调用 `O Mesh Plane`,以 Thickness 0 生成基础曲面;同一 Subdivide 下,它与普通 Plane 的零厚度网格具有相同顶点、面连接与 UV。Alpha 参与材质显示,模型 Validity 用于数据有效性处理,几何始终覆盖完整矩形。 + +| 输入 | 默认值 | 语义 | +| --- | --- | --- | +| Subdivide | 6,范围 0–10 | 沿用 O Mesh Plane 的细分 | +| Thickness | 0,最小 0 | 均匀曲面壳厚度,DISTANCE | +| Depth Scale | 1,最小 0 | 平面到相机曲面的缩放,可大于 1 | +| Reference Depth | Metadata reference_depth × Uniform Scale | 局部深度基准,DISTANCE,放在 Options | + +Geometry 为结构输入;Depth Image 与 Uniform Scale 为隐藏数据输入。所有修改器输入使用 SINGLE。 + +### 投影与厚度 + +沿用 Depth Cutout 的 VECTOR Depth 坐标约定:将采样相机 XYZ 乘以 Uniform Scale,翻转 Y 得到局部 XY,局部 Z 为 `(Reference Depth − scaled_camera_z) × Depth Scale`。局部 XY 从基础矩形位置按 Depth Scale 插值到缩放后的相机 XY;0 为原平面,1 为完整投影,大于 1 为外推。Reference Depth 使用与采样相同的局部单位。 + +```mermaid +flowchart TD + Base[O Mesh Plane 零厚度矩形细分] --> Projection[相机 XY 插值与相对 Z 深度] + Texture[VECTOR Depth × Uniform Scale] --> Projection + Projection --> Thickness{Thickness} + Thickness -- 0 --> Surface[单层曲面] + Thickness -- 大于 0 --> Shell[沿曲面法线生成均匀壳并连接外边界] + Surface --> Output[保留 UV 与材质,清理临时属性] + Shell --> Output +``` + +提取现有曲面分支中可复用的投影、均匀厚度节点构建函数,按业务职责集中在共用构建模块;新组只组合自身需要的步骤。零厚度保持单层矩形拓扑,正厚度保留投影正面、沿内侧扩展背面并连接四周。厚度方向和法线处理与现有 Uniform Depth Surface 一致。 + +### 验证边界 + +以合成相机 XYZ 纹理验证投影和坐标方向,用相同 Subdivide 的普通 Plane 对照基础拓扑与 UV;使用非方图、偏移 Image Empty、旋转缩放矩阵验证定位。检查四项可见参数、隐藏数据输入、SINGLE、材质槽和临时属性清理。 + +节点布局按实际 Blender 绘制尺寸检查总览和局部,几何主链连续、计算支线就近接入。实现阶段按项目总则只运行测试;资产构建、资产视觉验收和节点资产文档同步作为后续发布准备,当前提案不执行构建。 + +## Risks / Trade-offs + +- 相机纹理的 Y 方向或中心对齐错误会使结果镜像、偏移 → 使用带非中心主点的合成相机数据验证,并保持与现有 Depth Cutout 相同的投影公式。 +- 深度不连续处的矩形面会被拉伸 → 将连续矩形拓扑作为明确输出约定,用阶跃深度验证连接关系稳定。 +- 新增类型和共享 Job 分支可能影响现有转换 → 检查注册与逆序注销、Z/VECTOR 产物选择、单次推理及 Undo 回归。 +- 工作区同时调整 Cutout 节点 → 实施前核对当前构建函数和 Shape 设置,在现有修改基础上提取共用步骤。 diff --git a/openspec/changes/add-projected-depth-plane/proposal.md b/openspec/changes/add-projected-depth-plane/proposal.md new file mode 100644 index 0000000..ff1a02c --- /dev/null +++ b/openspec/changes/add-projected-depth-plane/proposal.md @@ -0,0 +1,30 @@ +## Why + +整图相机投影曲面需要与普通 Plane 一样稳定的矩形基础网格,以及少量直接的深度控制。将它作为独立转换入口,也让 Cutout 的交互集中在用户圈选区域。 + +## What Changes + +- **BREAKING**:完全删除 Cutout 双击整图生成入口,包括快捷键、`full_image` 属性和整图 Selection 构造分支。 +- 新增 **Convert to Depth Surface**,与 Convert to Plane、Convert to Depth Plane 并列,用完整图片生成相机投影深度曲面。 +- 新增 `O Image Depth Surface`:基础网格复用普通 Plane 的矩形、UV、中心原点和 `O Mesh Plane` 细分,只公开 `Subdivide`、`Thickness`、`Depth Scale`、`Reference Depth` 四项用户参数。 +- 相机投影沿用 Depth Cutout 的坐标与深度缩放语义;厚度采用均匀曲面壳,默认零厚度。 +- 整图转换复用现有 MoGe-2 单次推理、材质与对象替换流程。 + +## Capabilities + +### New Capabilities + +- `projected-depth-surface`: 整图 Depth Surface 转换、矩形基础网格、相机投影和精简节点接口。 +- `cutout-lasso-entry`: Cutout 通过有效套索进入 Shape 选择,彻底移除双击整图入口。 + +### Modified Capabilities + +无。当前 `openspec/specs/` 尚无已归档规范;本次独立规定新增及调整后的行为。 + +## Impact + +- `operators/cutout_tool/main.py` 及其交互测试。 +- `operators/convert_to_plane/`、`menu.py`、`__init__.py` 的转换入口、结果创建与类型注册。 +- `common/depth.py` 等共用深度数据处理;按需扩展整图 Job 的产物选择,保持后端模块边界。 +- `tools/node_assets/` 的构建和验证、新节点组及节点测试;实施阶段按项目约束执行测试,节点资产构建和产物更新单独安排。 +- 无新增第三方依赖;实现以当前工作区为基线,衔接正在进行的 Cutout Shape 调整。 diff --git a/openspec/changes/add-projected-depth-plane/specs/cutout-lasso-entry/spec.md b/openspec/changes/add-projected-depth-plane/specs/cutout-lasso-entry/spec.md new file mode 100644 index 0000000..b0789a4 --- /dev/null +++ b/openspec/changes/add-projected-depth-plane/specs/cutout-lasso-entry/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Cutout starts shape generation from a lasso +Cutout SHALL 在用户提交有效套索后打开 Shape Pie Menu,并将圈选路径传给 Shape 生成入口。 + +#### Scenario: Submit a lasso +- **WHEN** 用户在 active Image Empty 上完成有效 Cutout 套索 +- **THEN** 系统打开当前可用 Shape 菜单并传递该套索路径 + +### Requirement: Full-image double-click entry is removed +系统 MUST 删除 Cutout 的双击整图 keymap、`full_image` Property、整图 Selection 构造分支及专属调用代码。双击 MUST NOT 打开整图 Shape 菜单或提交整图生成任务。 + +#### Scenario: Double-click an image +- **WHEN** Cutout 已激活,用户在 Image Empty 上双击且未形成有效套索 +- **THEN** 不打开 Shape 菜单、不创建对象、不提交生成 Job + +#### Scenario: Double-click viewport space +- **WHEN** active Image Empty 存在,用户在图片外空白区域双击 +- **THEN** 不启动整图 Cutout,选择处理沿用共享图片手势规则 + +#### Scenario: Inspect registration +- **WHEN** 检查 Cutout 的 Operator Property 和 WorkspaceTool keymap +- **THEN** 不存在 `full_image` 或用于整图生成的 `DOUBLE_CLICK` 绑定 diff --git a/openspec/changes/add-projected-depth-plane/specs/projected-depth-surface/spec.md b/openspec/changes/add-projected-depth-plane/specs/projected-depth-surface/spec.md new file mode 100644 index 0000000..889c2dc --- /dev/null +++ b/openspec/changes/add-projected-depth-plane/specs/projected-depth-surface/spec.md @@ -0,0 +1,85 @@ +## ADDED Requirements + +### Requirement: Full-image depth surface has a conversion entry +系统 SHALL 提供 `anyimage.convert_to_depth_surface`,界面显示 Convert to Depth Surface,与 Plane、Depth Plane 并列;该入口 SHALL 从当前静态 Image Empty 创建完整图片的相机投影曲面。 + +#### Scenario: Convert a still image +- **WHEN** 当前 Image Empty 是静态图片且 AI 环境可用 +- **THEN** 执行一次完整图片转换,成功后结果沿用源对象名称并替换源 Empty +- **AND** 一次 Undo 恢复转换前状态 + +#### Scenario: Reject unsupported media +- **WHEN** 输入为多帧 Movie 或 Sequence +- **THEN** 提示仅支持静态图片,不提交推理任务 + +#### Scenario: AI setup is required +- **WHEN** AI 环境未就绪 +- **THEN** 沿用现有 AI 设置流程,源 Empty 保持可用 + +### Requirement: Base geometry matches Plane conversion +Depth Surface SHALL 使用普通 Convert to Plane 的基础 Mesh 创建、中心矩阵、UV 和 O Mesh Plane 细分。相同 Subdivide 下投影前零厚度几何 MUST 与普通 Plane 的零厚度几何一致。整图矩形拓扑 SHALL 覆盖透明区域及深度跳变区域。 + +#### Scenario: Compare planar meshes +- **WHEN** 对同一图片以相同 Subdivide 创建普通 Plane 和 Thickness 0、Depth Scale 0 的 Depth Surface +- **THEN** 两者顶点位置、面连接、UV 和材质索引一致 + +#### Scenario: Preserve placement +- **WHEN** 源 Image Empty 具有非中心显示偏移以及平移、旋转、缩放 +- **THEN** Depth Scale 0、Thickness 0 的结果四角与源显示矩形的世界空间四角一致 +- **AND** 对象原点位于显示矩形中心 + +#### Scenario: Preserve rectangular coverage +- **WHEN** 图片包含透明孔洞或纹理深度跳变 +- **THEN** 基础网格仍为完整矩形细分,Alpha 由材质表达 + +### Requirement: Surface controls are limited to four parameters +`O Image Depth Surface` SHALL 只公开 Subdivide、Thickness、Depth Scale、Reference Depth 四项用户控制。默认值 SHALL 分别为 6、0、1 和 Metadata reference_depth 乘以 Uniform Scale。Geometry SHALL 为结构输入,Depth Image 与 Uniform Scale SHALL 为隐藏数据输入。输入 MUST 使用 SINGLE;Thickness、Reference Depth MUST 使用 DISTANCE。 + +#### Scenario: Inspect a new modifier +- **WHEN** 创建 Depth Surface 修改器 +- **THEN** 用户看到规定的四项控制,Reference Depth 位于 Options +- **AND** 不出现 Cutout 的 Mode、Split、Balloon、Inflation、轮廓或轴向控制 + +### Requirement: Geometry follows camera projection +Depth Surface SHALL 按现有 Depth Cutout 的相机 XYZ 坐标约定投影。采样 XYZ 乘以 Uniform Scale 后,XY SHALL 以 Depth Scale 从基础平面插值至相机 X、负相机 Y;Z SHALL 为 `(Reference Depth − scaled_camera_z) × Depth Scale`。 + +#### Scenario: Full projection +- **WHEN** Thickness 为 0、Depth Scale 为 1,输入合成相机 XYZ 纹理 +- **THEN** 每个顶点的位置符合相机投影公式,包括 Y 方向与深度基准 + +#### Scenario: Adjust depth strength +- **WHEN** Depth Scale 分别为 0、0.5、1 和大于 1 的值 +- **THEN** XY 与 Z 按同一缩放语义连续变化,0 恢复基础平面,大于 1 正常外推 + +#### Scenario: Adjust reference depth +- **WHEN** Reference Depth 增加局部距离 d,Depth Scale 为 s +- **THEN** 曲面 Z 增加 d × s,投影 XY 保持不变 + +### Requirement: Thickness forms a uniform surface shell +Thickness 为 0 时系统 SHALL 输出单层曲面;Thickness 大于 0 时 SHALL 保留投影正面,按现有 Uniform Depth Surface 的法线厚度语义向内生成背面并连接矩形边界。 + +#### Scenario: Zero thickness +- **WHEN** Thickness 为 0 +- **THEN** 仅存在一层曲面,无重复背面或退化侧壁 + +#### Scenario: Positive thickness +- **WHEN** 对平坦深度输入设置正 Thickness +- **THEN** 正背面沿法线距离等于 Thickness,边界闭合且法线朝向一致 + +### Requirement: One prediction produces complete image artifacts +整图 Depth Surface SHALL 通过共用 Artifact 层执行一次 MoGe-2 推理,生成 VECTOR Depth、Metadata 与 Tangent Normal,并保留完整 Color。Blender SHALL 在主线程加载、Pack 并设置图片色彩空间,将 Normal 用于材质,将 VECTOR Depth 用于几何。 + +#### Scenario: Complete a generation job +- **WHEN** 整图 Depth Surface Job 成功 +- **THEN** 只执行一次推理,并返回 `vector_depth`、`depth_metadata`、`tangent_normal` 及完整颜色产物 + +#### Scenario: Preserve existing depth plane generation +- **WHEN** 用户执行现有 Convert to Depth Plane +- **THEN** 继续生成 Z Depth 与 Tangent Normal,并创建固定底面浮雕 + +### Requirement: Node assets preserve geometry data +新节点组 SHALL 从 `assets/O_AnyImage.blend` 按名称加载。节点求值 SHALL 保留 UV 与第 0 个材质槽,并清理自身临时属性;构建验证 SHALL 检查接口、实际几何与 Blender 中的节点布局。 + +#### Scenario: Evaluate the asset +- **WHEN** 加载并求值 `O Image Depth Surface` +- **THEN** 结果保留 UV、用户属性和材质,不残留自身 `o_depth_*` 临时属性 diff --git a/openspec/changes/add-projected-depth-plane/tasks.md b/openspec/changes/add-projected-depth-plane/tasks.md new file mode 100644 index 0000000..f9e08ad --- /dev/null +++ b/openspec/changes/add-projected-depth-plane/tasks.md @@ -0,0 +1,24 @@ +## 1. 删除 Cutout 双击整图入口 + +- [ ] 1.1 删除 Cutout 的 DOUBLE_CLICK 整图绑定、full_image Property、整图 Selection 分支及失去用途的 import,保留共享套索交互。 +- [ ] 1.2 调整交互和注册测试,验证图片内外双击均不打开 Shape 菜单或提交任务,有效套索继续生成 Shape。 + +## 2. 新增整图 Depth Surface 转换 + +- [ ] 2.1 增加 ConvertToDepthSurface、菜单入口和正逆序注册,验证静态图片、AI 环境及忙碌状态检查。 +- [ ] 2.2 扩展共用整图 Job 的 Z/VECTOR 产物选择及 Blender 响应分派,保持普通 Depth Plane 的 Z 路径;验证 VECTOR Depth 和 Tangent Normal 来自一次推理。 +- [ ] 2.3 复用 common 的图片、深度、材质及对象收尾函数创建 Depth Surface;共用 Plane 的基础 Mesh 和中心矩阵,初始化四项参数及隐藏深度数据。 +- [ ] 2.4 验证完整颜色、材质槽、深度与法线 Pack、源对象替换、失败清理和单步 Undo。 + +## 3. 构建定义与几何验证 + +- [ ] 3.1 按业务职责提取共用相机投影和均匀厚度构建步骤,新增 O Image Depth Surface 构建定义,并接入节点组构建和验证索引。 +- [ ] 3.2 以 O Mesh Plane 的零厚度输出生成完整矩形基础网格,设置 Subdivide、Thickness、Depth Scale、Reference Depth 以及隐藏数据接口,检查 SINGLE 与 socket subtype。 +- [ ] 3.3 增加合成纹理几何测试:同级 Plane 拓扑与 UV 对照、Depth Scale 0/0.5/1/>1、Reference Depth、相机 Y 方向和非中心主点。 +- [ ] 3.4 增加零厚度单层、正厚度闭合、透明区域完整矩形、深度跳变连接、材质和临时属性清理验证。 +- [ ] 3.5 编排新节点组布局与验证入口,覆盖未用输出隐藏、主链及支线间距;记录发布准备时所需的实际绘制尺寸视觉验收项目。 + +## 4. 回归与交付检查 + +- [ ] 4.1 运行相关转换、交互、注册、Server Job 和节点构建定义测试,修复回归;检查现有 Depth Plane 和圈选 Cutout 的行为。 +- [ ] 4.2 运行 uv run pytest,记录测试结果及需要 Blender 或已构建新资产才能完成的验收项。按项目总则,代码实施只运行测试,资产构建、二进制产物和节点资产文档同步留给发布准备。 diff --git a/openspec/changes/reorganize-cutout-shapes/.openspec.yaml b/openspec/changes/reorganize-cutout-shapes/.openspec.yaml new file mode 100644 index 0000000..2e24cfa --- /dev/null +++ b/openspec/changes/reorganize-cutout-shapes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-07 diff --git a/openspec/changes/reorganize-cutout-shapes/design.md b/openspec/changes/reorganize-cutout-shapes/design.md new file mode 100644 index 0000000..f3ff7b7 --- /dev/null +++ b/openspec/changes/reorganize-cutout-shapes/design.md @@ -0,0 +1,89 @@ +## Context + +当前 `shape.py` 将四种形态映射为四个子组,再由 `O Image Cutout` 用 Shape 菜单选择。菜单统一传入 Normal Map 勾选值;`normal_mode_for_shape()` 仅在勾选时为 Depth Surface 选择 OBJECT,其余形态使用 TANGENT。 + +`build/cutout_surface_balloon/surface-balloon-smooth-back.blend` 已验证五张输入、15 个参数组合;背面算法、控制测试与实际节点布局可作为实施参考。当前工作区也已修正前置 Cleanup 和不依赖 Depth Scale 的 Split,实施从这些修正继续。 + +## Goals / Non-Goals + +**Goals:** 四个明确的创建预设,三个独立修改器节点组,统一厚度语义,深度入口自动生成法线,保留已验证的几何质量。 + +**Non-Goals:** 本次范围限定在 Cutout 的创建、节点资产与材质连接;AI 模型、图像区域交互和其他工具沿用现有职责。 + +## Decisions + +### 创建预设与节点资产分离 + +| 入口 / 内部标识 | 节点组 | 初始设置 | +| --- | --- | --- | +| Flat / FLAT | O Image Cutout | Balloon,Thickness 0 | +| Solid / SOLID | O Image Cutout | Balloon,Thickness 1 | +| Depth Solid / DEPTH_SOLID | O Image Depth Cutout | Balloon,Thickness 1 | +| Depth Balloon / DEPTH_BALLOON | O Image Depth Balloon | Thickness 1,Double Sided 开启 | + +Solid 是新创建的默认预设,Pie 保持四个选择。Flat 和 Solid 使用同一基础网格属性与节点组,创建后通过厚度即可转换。预设名称用于菜单、对象命名与 Operator 协议;资产名称仅用于按名称加载。 + +三个节点组直接设置为修改器资产,各自输出最终几何。删除总 Shape 分支及 `O Image Surface`、`O Image Balloon`、`O Image Depth Surface` 旧资产。复用构建函数展开公共步骤,避免为了共享代码再建立总包装组。保留 `Inward Axis` 的几何与对象矩阵配对转换。 + +### 两种厚度方式 + +`O Image Cutout` 和 `O Image Depth Cutout` 使用 `Thickness Mode` 菜单,顺序为 Balloon、Uniform,默认 Balloon。Balloon 的 Thickness 是无量纲倍率,Uniform 的厚度是距离;输入显示与保存必须区分两种语义,距离 socket 使用 DISTANCE,倍率使用 NONE。具体 socket 组织在实现时按 Blender 修改器接口能力选择,验收必须确认用户能辨认单位,切换时不发生隐式单位换算。 + +普通 Cutout 的 Balloon 模式使用正背面 `±o_balloon × Thickness`;Uniform 使用当前 Surface 的等厚板片行为。两种方式在零厚度时均返回单层平面。全部预设都准备 `o_balloon`,即使以 Flat 创建也能直接增厚。 + +深度 Cutout 保留当前 Depth Surface 的采样、射线投影与 Split;Uniform 保留沿内向轴等厚的正背面。Balloon 采用实验中的平滑背面,两种方式零厚度均返回深度表面。 + +### 平滑背面进入正式构建 + +```mermaid +flowchart TD + A[基础网格和 o_balloon] --> B[Cleanup 按原平面岛面积清理] + B --> C[原始深度判断 Split] + C --> D[相机射线修正与深度投影] + D --> E[估计并平滑中心位置] + E --> F[生成正背面及切口侧壁] + F --> G[边缘平滑与整体 Smooth] + G --> H[清理暂存属性与轴向输出] +``` + +令 D 为投影后正面 Z,B 为 o_balloon。以 `C₀ = D − B` 初始化中心位置,固定原轮廓和 Split 切口上的值,仅对内部执行 256 次邻域平滑。Thickness 为 1 时目标背面为 `C − B`;一般厚度场为 `Thickness × max(D − C + B, 0.05 × B)`。比例下限用于限制深凹处反穿,原轮廓 B 为零时厚度仍归零,不引入 Rim Thickness。 + +复用原型中对轮廓耳三角形、内部零轮廓弦的补点方法,保证封闭轮廓不会产生零面积面。通过来源顶点和厚度层身份连接几何,Split 两侧保持独立;正面在边缘和整体平滑关闭时保持原始深度表面位置。 + +保留原型的 Edge Smooth 控制与抑制轮廓收缩的平滑过程,整体 Smooth 复用项目实现。临时属性使用 o_* 专用前缀并在输出前移除,保留 UVMap、o_balloon 和用户属性。默认参数及平滑成本用五张缓存输入复验。 + +### 每个节点组承担自己的公共处理 + +三个资产复用同一 Cleanup 构建函数,在塑形前按原平面岛总面积清理;UI 值继续乘 0.01 转为面积。Smooth 与轴向输出在各自塑形后执行。仅深度 Cutout 提供 Split;比较式继续为 `abs(原始相邻面 Z 差) × Uniform Scale × Split > 原始面中心距离 × 2.5`,Depth Scale 只参与后续显示位移。 + +### 法线策略在 Operator 层统一解析 + +| 入口 | 勾选关闭 | 勾选开启 | +| --- | --- | --- | +| Flat | NONE | TANGENT | +| Solid | NONE | TANGENT | +| Depth Solid | OBJECT | OBJECT | +| Depth Balloon | TANGENT | TANGENT | + +统一解析函数同时供 Job 构建和结果接收使用,保证直接调用 Operator 与 Pie 调用一致。深度入口的自动生成不会改写用户保存的勾选值。工具提示说明 Normal Map 仅控制 Flat / Solid;AI 未就绪时仍只提供两个本地入口,并忽略遗留的开启值。 + +复用现有法线结果加载、材质连接与轴向变换。Flat 与 Solid 共用节点后,法线的 Balloon 适配不得仅由创建时的预设名决定;按当前厚度方式和几何属性处理,验证 Flat 增厚后材质仍正确。Depth Solid 保留 OBJECT 法线的方向转换,Depth Balloon 保留 TANGENT 法线的适配。 + +Depth Solid 在 FACE 域保存正面法线权重 1,背面与侧壁为 0;材质读取该权重,使平滑背面使用几何法线。法线权重作为公开材质属性保留在输出网格中。 + +## Risks / Trade-offs + +- 平滑中心场会弱化内部深度细节 → 正面保留原深度,背面用已验证的轮廓约束;通过合成曲面与五张实图同时验收。 +- 迭代与闭合细分增加成本 → 使用固定初始迭代数,测量默认预设求值时间;字段就近存取,避免重复推理与重复求值。 +- 同名 `O Image Cutout` 的资产职责改变 → 加载路径按新结构校验,避免复用已加载的旧 Shape 包装组;旧 .blend 中嵌入的数据保持原样,新创建对象使用新资产。源码不保留旧标识转发或迁移兼容层。 +- 法线图随默认预设开启后的材质效果可能不同 → 分别验证两个深度入口的正背面着色及两个 Inward Axis。 + +## Migration Plan + +按标识和默认值、节点构建、运行时加载与材质、验证的顺序实施。发布资产只保留三个 Cutout 节点组;同步删除旧构建和验证分支。需要回退时整体恢复源码与对应资产,避免新旧接口混装。 + +未归档的 `gate-cutout-ai-options`、`replace-depth-surface-stretch-with-split` 和 `standardize-cutout-z-axis` 涉及本次边界。未来归档时以本提案的入口名称、直接修改器架构、前置 Cleanup 和原始深度 Split 要求为准,同时保留 AI 门控与轴向约定;不回退当前工作区已完成的修复。 + +## Open Questions + +无产品决策待确认。厚度输入的具体展示方式属于实施细节,须满足倍率与距离可辨认的验收要求。 diff --git a/openspec/changes/reorganize-cutout-shapes/proposal.md b/openspec/changes/reorganize-cutout-shapes/proposal.md new file mode 100644 index 0000000..176831a --- /dev/null +++ b/openspec/changes/reorganize-cutout-shapes/proposal.md @@ -0,0 +1,32 @@ +## Why + +Cutout 当前将四种形态封装在一个总节点组中,创建入口与几何算法一一绑定。已经验证的平滑 Balloon 背面适合成为深度实体的默认厚度,需要将四个创建预设与三个几何节点组分开,并明确法线生成规则。 + +## What Changes + +- 创建入口确定为 `Flat`、`Solid`、`Depth Solid`、`Depth Balloon`,默认形态为 Solid。 +- **BREAKING**:以 `O Image Cutout`、`O Image Depth Cutout`、`O Image Depth Balloon` 三个可直接用于修改器的节点组替代总 Shape 切换器与四个子组,移除旧节点名称和 Shape 菜单协议。 +- Flat 与 Solid 共用 `O Image Cutout`,分别以 Thickness 0 和 1 创建;普通与深度 Cutout 均提供 Balloon / Uniform 两种厚度方式,默认 Balloon。 +- Depth Solid 使用经过五张图验证的新背面算法,保留正面深度位置,背面按平滑中心位置与 o_balloon 构造;Uniform 保留等厚 Depth Surface 行为。 +- Depth Balloon 保留原塑形算法,默认开启 Double Sided。 +- 三个节点组各自提供适用的 Cleanup、Smooth 和轴向输出;深度 Cutout 保持 Cleanup 在 Split 前、Split 使用原始深度差的修正。 +- Normal Map 勾选仅控制 Flat 与 Solid;Depth Solid 与 Depth Balloon 始终生成并使用各自所需的法线图。 + +## Capabilities + +### New Capabilities + +- `cutout-shape-presets`: 四个入口、三个节点组、创建默认值与两种厚度方式。 +- `depth-cutout-volume`: 深度表面上的平滑 Balloon 背面、等厚模式、清理分离及闭合行为。 +- `cutout-normal-policy`: 按入口选择法线生成策略,保持 AI 可用性约束及材质连接正确。 + +### Modified Capabilities + +无。当前 `openspec/specs/` 尚无已归档规范;本提案记录完整目标要求,并在设计中说明与现有未归档变更的关系。 + +## Impact + +- `src/anyimage/operators/cutout_tool/` 的形态标识、菜单、节点加载、默认参数、法线策略和材质绑定,以及 Cutout 工具属性。 +- `tools/node_assets/` 的构建、布局与验证;`src/anyimage/assets/O_AnyImage.blend` 中的 Cutout 资产。 +- Cutout 交互、Job 参数、网格求值和节点资产测试,以及节点资产说明。 +- 原型位于 `build/cutout_surface_balloon/`,实施时将必要算法纳入正式源码。无需新增依赖。 diff --git a/openspec/changes/reorganize-cutout-shapes/specs/cutout-normal-policy/spec.md b/openspec/changes/reorganize-cutout-shapes/specs/cutout-normal-policy/spec.md new file mode 100644 index 0000000..bc75a00 --- /dev/null +++ b/openspec/changes/reorganize-cutout-shapes/specs/cutout-normal-policy/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: Normal Map 勾选仅控制本地形态 +Normal Map 勾选 SHALL 仅控制 Flat 和 Solid 的法线生成;两个 Depth 入口 SHALL 始终生成并使用法线图,且不改写用户保存的勾选值。工具说明 SHALL 明确该作用范围。 + +#### Scenario: Flat 与 Solid 关闭法线 +- **WHEN** AI 可用且勾选关闭,用户创建 Flat 或 Solid +- **THEN** 系统使用 NONE 法线模式 + +#### Scenario: Flat 与 Solid 开启法线 +- **WHEN** AI 可用且勾选开启,用户创建 Flat 或 Solid +- **THEN** 系统请求 TANGENT 法线并连接结果材质 + +#### Scenario: Depth Solid 自动法线 +- **WHEN** 用户创建 Depth Solid,无论勾选保存为真或假 +- **THEN** 系统请求 OBJECT 法线并正确加载、按输出轴向处理和连接材质 + +#### Scenario: Depth Balloon 自动法线 +- **WHEN** 用户创建 Depth Balloon,无论勾选保存为真或假 +- **THEN** 系统请求 TANGENT 法线并连接结果材质 + +### Requirement: 所有调用路径采用一致策略 +法线策略 SHALL 在 Operator 业务层统一解析,Job 参数与结果接收使用同一策略。 + +#### Scenario: 绕过 Pie 调用 +- **WHEN** 直接调用 Operator 创建任一 Depth 形态并传入 generate_normal 为 false +- **THEN** 法线仍按该深度入口生成和应用,与 Pie 调用结果一致 + +### Requirement: AI 可用性继续限制入口 +AI 未就绪时系统 SHALL 仅提供 Flat 与 Solid,忽略遗留的开启法线选项;AI 就绪时 SHALL 提供四个入口。 + +#### Scenario: 未就绪的本地创建 +- **WHEN** AI 未就绪、保存的 Normal Map 为真,用户创建 Flat 或 Solid +- **THEN** 创建过程不因该遗留值启动 AI Job,法线模式为 NONE + +### Requirement: 法线适配跟随当前几何 +Flat 与 Solid 共用节点组后的法线适配 SHALL 支持厚度变化,Depth 入口 SHALL 保持各自法线空间和轴向转换正确。 + +#### Scenario: Flat 增厚 +- **WHEN** 带法线的 Flat 调整为 Balloon 正厚度 +- **THEN** 材质按当前几何参与法线适配,不因初始 Flat 预设而缺失 Balloon 所需连接 + +#### Scenario: 深度入口轴向与双面 +- **WHEN** 分别检查两个 Depth 入口的 -Z、+X 输出,以及 Depth Balloon 的 Double Sided 开关 +- **THEN** 对应法线图被正确加载和连接,前后表面的着色方向与几何一致 diff --git a/openspec/changes/reorganize-cutout-shapes/specs/cutout-shape-presets/spec.md b/openspec/changes/reorganize-cutout-shapes/specs/cutout-shape-presets/spec.md new file mode 100644 index 0000000..b937c72 --- /dev/null +++ b/openspec/changes/reorganize-cutout-shapes/specs/cutout-shape-presets/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: 四个创建入口映射三个节点组 +系统 SHALL 提供 Flat、Solid、Depth Solid、Depth Balloon 四个入口,并默认使用 Solid。Flat 与 Solid SHALL 共用 O Image Cutout;Depth Solid SHALL 使用 O Image Depth Cutout;Depth Balloon SHALL 使用 O Image Depth Balloon。 + +#### Scenario: 普通默认创建 +- **WHEN** 用户以默认形态创建 Cutout +- **THEN** 对象使用 O Image Cutout,Thickness Mode 为 Balloon,Thickness 为 1 + +#### Scenario: 显式选择入口 +- **WHEN** 用户分别选择四个入口 +- **THEN** 系统按上述映射加载三个资产,Flat 的 Thickness 为 0,另外三个入口的 Thickness 为 1 + +### Requirement: 三个节点组直接输出最终几何 +三个 Cutout 节点组 SHALL 可直接作为 Geometry Nodes 修改器使用,并承担各自的清理、塑形、平滑与轴向输出。发布资产 MUST 移除旧 Shape 总切换器及旧 Surface、Balloon、Depth Surface 子组。 + +#### Scenario: 检查新建修改器与发布资产 +- **WHEN** 新建任一 Cutout 并检查其修改器与资产 +- **THEN** 修改器直接引用对应节点组,不存在跨四种形态的 Shape 输入;Cutout 资产集合恰为三个指定节点组 + +#### Scenario: 输出轴向 +- **WHEN** 对同一输入分别使用 -Z 与 +X 的 Inward Axis +- **THEN** 几何与对象矩阵配对转换,保持相同世界空间位置及正确朝向 + +### Requirement: 普通与深度 Cutout 具有双厚度方式 +O Image Cutout 与 O Image Depth Cutout SHALL 提供 Balloon、Uniform 两种 Thickness Mode,默认 Balloon。Balloon 厚度 SHALL 表示倍率,Uniform 厚度 SHALL 表示距离,输入语义与单位必须明确区分。 + +#### Scenario: 普通平面与体积转换 +- **WHEN** O Image Cutout 的 Balloon Thickness 从 0 调整到 1 +- **THEN** 相同基础网格由单层平面变为当前 Balloon 的正背面体积,无需重算 AI 或重建对象 + +#### Scenario: 等厚板片 +- **WHEN** O Image Cutout 选择 Uniform 并设置非零厚度 +- **THEN** 输出当前 Surface 的等厚板片形态;厚度为零时输出单层平面 + +#### Scenario: 厚度单位 +- **WHEN** 用户检查或切换两种 Thickness Mode +- **THEN** Balloon 的输入明确为倍率,Uniform 的输入明确为距离,不将一种单位的值隐式解释为另一种单位 + +### Requirement: Depth Balloon 默认双面 +O Image Depth Balloon SHALL 保留原 Depth Balloon 的参考平面塑形行为,并默认开启 Double Sided。 + +#### Scenario: 默认与单面输出 +- **WHEN** 用户创建 Depth Balloon,再关闭 Double Sided +- **THEN** 初始输出采用原算法的双面塑形,关闭后采用原算法的单面塑形,其余参数语义保持一致 + +### Requirement: 公共网格属性与前置清理 +所有入口 SHALL 准备 o_balloon;三个节点组 SHALL 在塑形前复用按原平面岛总面积清理小区域的 Cleanup 规则,并在输出保留 UVMap、o_balloon 与用户属性。 + +#### Scenario: Flat 后续增厚 +- **WHEN** 用户将 Flat 的 Thickness 调为正值 +- **THEN** 已有 o_balloon 可直接参与塑形,UV 与材质映射保持有效 + +#### Scenario: 小岛清理 +- **WHEN** 输入包含总面积低于 Cleanup 阈值的独立平面岛 +- **THEN** 三个节点组均在塑形前删除该岛,保留大于阈值的岛 diff --git a/openspec/changes/reorganize-cutout-shapes/specs/depth-cutout-volume/spec.md b/openspec/changes/reorganize-cutout-shapes/specs/depth-cutout-volume/spec.md new file mode 100644 index 0000000..3d32380 --- /dev/null +++ b/openspec/changes/reorganize-cutout-shapes/specs/depth-cutout-volume/spec.md @@ -0,0 +1,45 @@ +## ADDED Requirements + +### Requirement: 平滑背面保留空间位置 +O Image Depth Cutout 的 Balloon 模式 SHALL 以投影正面减 o_balloon 估计中心位置,固定轮廓与切口并平滑内部中心场,再用该中心场与 Balloon 轮廓构造背面。关闭边缘与整体平滑时,正面 MUST 保持原深度表面位置。 + +#### Scenario: 带噪声的已知体积 +- **WHEN** 输入正面由线性变化的中心深度、Balloon 轮廓和内部高频噪声构成,Thickness 为 1,额外几何平滑关闭 +- **THEN** 背面接近中心深度减 Balloon 轮廓,背面噪声 RMS 小于正面噪声的 1%,正面顶点位置误差小于 0.00001 个局部单位 + +#### Scenario: 前后距离保留 +- **WHEN** 输入有宽厚主体和处于不同深度的细长结构 +- **THEN** 背面形成按轮廓变化的厚度,各部分保留局部前后位置,不统一回到同一个参考平面 + +### Requirement: 两种厚度方式保留深度表面 +深度 Cutout SHALL 在 Balloon 模式使用平滑背面,在 Uniform 模式保留原 Depth Surface 的等厚行为;两种方式的零厚度 SHALL 输出单层深度表面。 + +#### Scenario: 零厚度 +- **WHEN** 两种厚度方式分别设为零 +- **THEN** 输出相同投影与 Split 结果的单层深度表面,不生成重叠背面或零面积侧壁 + +#### Scenario: 等厚方式 +- **WHEN** 选择 Uniform 并设为正厚度 +- **THEN** 正背面保持原 Depth Surface 的沿内向轴等厚关系 + +### Requirement: Split 独立于显示深度幅度 +Split SHALL 使用原始相邻面采样 Z 差经 Uniform Scale 标定后与变形前跨度比较,Cleanup MUST 位于 Split 前;Depth Scale SHALL 仅控制投影呈现幅度。 + +#### Scenario: Depth Scale 不改变切边 +- **WHEN** 固定输入和 Split,将 Depth Scale 依次设为 0、0.25、1、2、4 +- **THEN** 零厚度输出的切边拓扑保持一致,深度位移随 Depth Scale 变化 + +#### Scenario: 保留分离产生的小片 +- **WHEN** 原平面岛面积通过 Cleanup,但 Split 后的部分小片低于该面积阈值 +- **THEN** 这些分离小片仍被保留并参与后续厚度构造 + +### Requirement: 厚度网格闭合且轮廓无固定厚边 +正厚度网格 SHALL 在原轮廓合并正背面,并独立封闭 Split 切口;SHALL 保留原型中的深凹比例厚度下限、边缘平滑和整体 Smooth,且不提供 Rim Thickness。 + +#### Scenario: 五张实图验收 +- **WHEN** 使用噬菌体、蜘蛛、幼虫、心脏、棕榈树缓存输入,分别求值无平滑、边缘与整体平滑、平滑加 Split +- **THEN** 15 个正厚度组合均为有限坐标、无非流形边、无法线连接错误和零面积面,输出保留 UVMap 与 o_balloon 并移除专用暂存属性 + +#### Scenario: 边缘与整体控制 +- **WHEN** 调整 Edge Smooth、Smooth 和 Smooth Iterations +- **THEN** 相应几何平滑生效,Smooth Iterations 为零时整体平滑不改变位置,原轮廓不产生固定厚度的边框 diff --git a/openspec/changes/reorganize-cutout-shapes/tasks.md b/openspec/changes/reorganize-cutout-shapes/tasks.md new file mode 100644 index 0000000..d567b24 --- /dev/null +++ b/openspec/changes/reorganize-cutout-shapes/tasks.md @@ -0,0 +1,30 @@ +## 1. 创建预设与参数协议 + +- [x] 1.1 将形态标识与入口文案统一为 FLAT / Flat、SOLID / Solid、DEPTH_SOLID / Depth Solid、DEPTH_BALLOON / Depth Balloon,默认 Solid,更新 Pie 与直接 Operator 调用测试。 +- [x] 1.2 建立四预设到三资产的映射及默认值,删除旧 Shape 索引协议、旧名称与转发路径,覆盖对象命名和 AI 可用性门控。 +- [x] 1.3 确定并实现两个 Cutout 的 Balloon / Uniform 厚度输入,验证默认顺序、倍率与距离语义、零厚度及模式切换;距离 socket 使用 DISTANCE,输入结构使用 SINGLE。 + +## 2. 三个独立几何节点资产 + +- [x] 2.1 将 O Image Cutout 构建为直接修改器,合并普通 Balloon 与等厚 Surface 行为;所有预设准备 o_balloon,保证 Flat 可直接增厚。 +- [x] 2.2 将已验证的平滑中心场、背面厚度与闭合方法纳入正式构建函数,构建 O Image Depth Cutout 的 Balloon 分支并保留 Uniform 分支。 +- [x] 2.3 将原型中防止轮廓耳三角形和内部零轮廓弦退化的处理纳入合适的正式网格模块,增加针对闭合退化的求值测试。 +- [x] 2.4 复用公共 Cleanup、Smooth、轴向输出与暂存属性清理;保留 Depth Cutout 的前置 Cleanup、原始深度 Split 和 Edge Smooth 控制。 +- [x] 2.5 将 O Image Depth Balloon 改为直接修改器并默认 Double Sided 开启,保留其塑形和单面开关行为。 +- [x] 2.6 删除总 Shape 切换器和三个旧子组资产,更新构建清单、节点加载及结构验证;处理同名旧 O Image Cutout 已加载时的新资产识别,避免复用旧接口。 + +## 3. Normal Map 与材质 + +- [x] 3.1 在业务层统一法线策略:Flat / Solid 受勾选控制,Depth Solid 固定 OBJECT,Depth Balloon 固定 TANGENT;Job 参数与结果接收共用解析。 +- [x] 3.2 更新 Normal Map 工具说明,保持用户勾选值不被深度创建改写,覆盖未就绪时遗留开启值和直接调用 Operator 的测试。 +- [x] 3.3 调整 Flat / Solid 共用节点后的材质法线适配,验证从 Flat 增厚以及 Balloon / Uniform 切换时的连接。 +- [x] 3.4 验证两个深度入口的法线结果加载、材质连接、双面着色与 Inward Axis 转换。 + +## 4. 求值与视觉验收 + +- [x] 4.1 更新接口、默认值、资产集合和几何测试,覆盖三资产直接输出、普通与深度零厚度、Uniform 等厚、Depth Balloon 默认双面。 +- [x] 4.2 保留 Cleanup 在 Split 前和 Depth Scale 0 / 0.25 / 1 / 2 / 4 不改变切边拓扑的回归测试。 +- [x] 4.3 用已知中心深度加 Balloon 轮廓与噪声的合成曲面验证平滑背面、正面保留及参考深度平移。 +- [x] 4.4 复用五张缓存输入,在独立 Blender 脚本中检查 15 个平滑与分离组合的闭合、退化、属性保留和求值时间;渲染背面与侧面,对照已接受原型,不直接控制用户 Blender。 +- [x] 4.5 在独立 Blender 节点编辑器按实际绘制尺寸检查三个节点组的总览与局部,验证主轴、重复区域、socket 顺序和未使用输出隐藏。 +- [x] 4.6 同步节点资产说明,重建 O_AnyImage.blend,运行全部节点验证和项目测试,记录结果;核对相关未归档规范在后续归档时采用本提案的目标行为。 diff --git a/openspec/changes/reorganize-cutout-shapes/validation.md b/openspec/changes/reorganize-cutout-shapes/validation.md new file mode 100644 index 0000000..050cb9b --- /dev/null +++ b/openspec/changes/reorganize-cutout-shapes/validation.md @@ -0,0 +1,24 @@ +# 验收记录 + +2026-09-07,Windows;资产构建与独立节点编辑器使用 Blender 4.5.10 LTS,Python 求值与测试使用 bpy 4.5.3。 + +## 自动验证 + +- `tools/node_assets/build.py` 完成资产重建;`validate.py`、`validate_mesh_plane.py`、`validate_cutout.py` 全部通过。 +- 全量 pytest:664 passed、2 xfailed。覆盖四入口、三资产、双厚度模式、前置 Cleanup、原始深度 Split、闭合、背面平滑和法线策略。 +- 测试完成后本地 bpy 运行时退出阶段发生原生崩溃(Windows 3221225477)。独立脚本调用 `pytest.main` 并将其返回码写入文件,确认返回 0;进程随后仍以访问冲突退出。测试断言均通过,进程退出问题保留为本地验证环境限制。 + +## 几何与视觉 + +- 噬菌体、蜘蛛、幼虫、心脏、棕榈树,共 15 个平滑与分离组合:非流形边、非流形顶点、零面积面均为 0,坐标有限,保留 UVMap 和 o_balloon。 +- 与已接受原型逐对象比较,顶点和面数量一致;双向最近点最大位置差 0.00001258(图像尺度约 4)。 +- 本机单次网格求值约 0.018–0.203 秒,包含依赖图更新与网格提取;用于观察成本,不作为性能保证。 +- 背面、侧面和斜视渲染通过。Depth Solid 的 FACE 法线权重令贴图仅影响正面,背面与侧壁使用几何法线。 +- 两个深度入口使用正式 O Image Layer 材质,检查两个 Inward Axis 的正背面法线方向渲染;归一化像素平均绝对差均小于 0.00000009。 +- 独立隐藏 Blender 节点编辑器检查三组总览、中心场 Repeat 和闭合局部;实际绘制矩形无重叠。结构验证同时检查接口顺序、SINGLE 和未使用输出隐藏。 + +实验文件与详细 JSON、截图、日志位于 `build/cutout_surface_balloon/`;可编辑样例为 `cutout-production.blend`。以上验证复用缓存输入,未控制用户 Blender 实例。 + +## 归档衔接 + +`gate-cutout-ai-options`、`replace-depth-surface-stretch-with-split`、`standardize-cutout-z-axis` 后续归档时,采用本变更的四入口、三直接资产、前置 Cleanup 与原始深度 Split,保留 AI 门控和轴向约定。 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index be54fc8..057d819 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9e0b94f8957625b95f9bccb1c99b059c52ef058080e76eabcaa8416917f3a2c3 -size 2059430 +oid sha256:d3ed2e9d5d007c100ad0de7ccba5e99e0b13ba95354301b16991c9f8822e2485 +size 2964670 diff --git a/src/anyimage/common/node.py b/src/anyimage/common/node.py index 158d2b6..fabe14f 100644 --- a/src/anyimage/common/node.py +++ b/src/anyimage/common/node.py @@ -10,10 +10,12 @@ def node_asset_path(): return Path(__file__).resolve().parents[1] / "assets" / NODE_ASSET_NAME -def load_node_group(group_name): +def load_node_group(group_name, *, asset_revision=None): existing = bpy.data.node_groups.get(group_name) if existing is not None: - return existing + if asset_revision is None or existing.get("anyimage_asset_revision") == asset_revision: + return existing + existing.name = group_name + " (Saved)" asset_path = node_asset_path() if not asset_path.is_file(): diff --git a/src/anyimage/common/object.py b/src/anyimage/common/object.py index 269c498..b557706 100644 --- a/src/anyimage/common/object.py +++ b/src/anyimage/common/object.py @@ -3,11 +3,11 @@ import bpy -def modifier_input_identifier(node_group, name): +def modifier_input_identifier(node_group, name, *, subtype=None): for item in node_group.interface.items_tree: if item.item_type != "SOCKET" or item.in_out != "INPUT": continue - if item.name == name: + if item.name == name and (subtype is None or getattr(item, "subtype", None) == subtype): return item.identifier raise RuntimeError(f"Geometry Nodes input is missing: {name}") diff --git a/src/anyimage/operators/cutout_tool/fine_mesh.py b/src/anyimage/operators/cutout_tool/fine_mesh.py index 91924b6..01fcd1d 100644 --- a/src/anyimage/operators/cutout_tool/fine_mesh.py +++ b/src/anyimage/operators/cutout_tool/fine_mesh.py @@ -563,6 +563,11 @@ def triangulate(contours, spine, path_edges, free, spacing): edges.extend(((boundary_count + a, boundary_count + b) for a, b in path_edges)) result = delaunay_2d_cdt([Vector(v) for v in p], edges, [], 0, 1e-07, True) points = np.asarray([tuple(v) for v in result[0]]) + # Restore input precision before testing area and containment. Float32 CDT + # coordinates can turn collinear outline samples into artificial slivers. + for vertex, originals in enumerate(result[3]): + if originals: + points[vertex] = p[originals].mean(axis=0) faces = np.asarray(result[2], dtype=np.int32) faces = faces[g.points_inside_contours(points[faces].mean(axis=1), contours)] area = np.abs( diff --git a/src/anyimage/operators/cutout_tool/geometry.py b/src/anyimage/operators/cutout_tool/geometry.py index 298f0f2..350a7a1 100644 --- a/src/anyimage/operators/cutout_tool/geometry.py +++ b/src/anyimage/operators/cutout_tool/geometry.py @@ -1238,6 +1238,35 @@ class BaseShape: balloon: object +def refine_balloon_triangles(points, faces, boundary): + """Add interior samples to boundary ears and interior boundary chords.""" + from collections import Counter + import numpy as np + + edges = Counter(tuple(sorted((face[i], face[(i + 1) % 3]))) for face in faces for i in range(3)) + values, midpoints = list(points), {} + for edge, count in edges.items(): + if count == 2 and all(boundary[index] for index in edge): + midpoints[edge] = len(values) + values.append(points[list(edge)].mean(axis=0)) + refined = [] + for face in faces: + ring = [] + for index, vertex in enumerate(face): + ring.append(vertex) + midpoint = midpoints.get(tuple(sorted((vertex, face[(index + 1) % 3])))) + if midpoint is not None: + ring.append(midpoint) + if len(ring) == 3 and not all(boundary[vertex] for vertex in face): + refined.append(tuple(face)) + continue + center = len(values) + values.append(points[list(face)].mean(axis=0)) + refined.extend((center, ring[i], ring[(i + 1) % len(ring)]) for i in range(len(ring))) + points = np.asarray(values) + return points, refined, boundary_vertices_from_faces(len(points), refined) + + def cutout_domain( selection_contours, bounds, @@ -1276,6 +1305,17 @@ def cutout_domain( (start, end, faces.tolist(), pixel_to_planar(contour)) for start, end, faces, contour in pixel_components ] + refined_points, refined_faces, refined_components = [], [], [] + offset = 0 + for start, end, component_faces, contour in components: + points, faces, _ = refine_balloon_triangles(planar[start:end], component_faces, boundary[start:end]) + refined_points.append(points) + refined_faces.extend(tuple(offset + index for index in face) for face in faces) + refined_components.append((offset, offset + len(points), faces, contour)) + offset += len(points) + planar = np.concatenate(refined_points) + planar_faces, components = refined_faces, refined_components + boundary = boundary_vertices_from_faces(len(planar), planar_faces) profile = balloon_profile( balloon_algorithm, planar, planar_faces, boundary, components ) diff --git a/src/anyimage/operators/cutout_tool/interaction.py b/src/anyimage/operators/cutout_tool/interaction.py index c570701..7c97b87 100644 --- a/src/anyimage/operators/cutout_tool/interaction.py +++ b/src/anyimage/operators/cutout_tool/interaction.py @@ -4,10 +4,10 @@ from ...properties import ai_ready from .shape import CUTOUT_SHAPE_LABELS CUTOUT_SHAPE_BUTTONS = ( - ("SURFACE", "MESH_GRID"), - ("BALLOON", "MESH_UVSPHERE"), + ("FLAT", "MESH_GRID"), + ("SOLID", "MESH_UVSPHERE"), ("DEPTH_BALLOON", "META_BALL"), - ("DEPTH_SURFACE", "SURFACE_NCURVE"), + ("DEPTH_SOLID", "SURFACE_NCURVE"), ) CUTOUT_MESH_DETAIL_SPACING = { diff --git a/src/anyimage/operators/cutout_tool/main.py b/src/anyimage/operators/cutout_tool/main.py index cf8e73a..875d424 100644 --- a/src/anyimage/operators/cutout_tool/main.py +++ b/src/anyimage/operators/cutout_tool/main.py @@ -73,15 +73,17 @@ _HIDDEN = {"HIDDEN", "SKIP_SAVE"} def normal_mode_for_shape(shape, generate_normal): + if shape == "DEPTH_SOLID": + return NormalMode.OBJECT + if shape == "DEPTH_BALLOON": + return NormalMode.TANGENT if not generate_normal: return NormalMode.NONE - if shape == "DEPTH_SURFACE": - return NormalMode.OBJECT return NormalMode.TANGENT def depth_mode_for_shape(shape): - if shape == "DEPTH_SURFACE": + if shape == "DEPTH_SOLID": return "VECTOR" if shape == "DEPTH_BALLOON": return "Z" @@ -200,7 +202,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): source_object_name: bpy.props.StringProperty(options=_HIDDEN) selection_path_json: bpy.props.StringProperty(options=_HIDDEN) selection_bounds_json: bpy.props.StringProperty(options=_HIDDEN) - shape: bpy.props.StringProperty(options=_HIDDEN) + shape: bpy.props.StringProperty(default="SOLID", options=_HIDDEN) point_spacing: bpy.props.FloatProperty( default=32.0, min=0.01, diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index 98c0dfa..0326124 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -31,10 +31,9 @@ from .geometry import ( ) from .shape import ( BALLOON_ATTRIBUTE_NAME, - BALLOON_CUTOUT_SHAPES, + NORMAL_SCALE_ATTRIBUTE_NAME, CUTOUT_X_TO_Z, - CUTOUT_NODE_GROUP_NAME, - CUTOUT_SHAPE_INDICES, + CUTOUT_NODE_GROUP_NAMES, DEPTH_CUTOUT_SHAPES, ) @@ -131,11 +130,11 @@ def create_cropped_color_image(source_object, crop_bounds, source_rgba): return image -def load_cutout_node_group(): - return load_node_group(CUTOUT_NODE_GROUP_NAME) +def load_cutout_node_group(shape): + return load_node_group(CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=11) -def _connect_balloon_normal_scale(material): +def _connect_cutout_normal_scale(material): nodes = material.node_tree.nodes image_layer = next( node @@ -146,7 +145,7 @@ def _connect_balloon_normal_scale(material): ) attribute = nodes.new("ShaderNodeAttribute") attribute.attribute_type = "GEOMETRY" - attribute.attribute_name = BALLOON_ATTRIBUTE_NAME + attribute.attribute_name = NORMAL_SCALE_ATTRIBUTE_NAME attribute.location = (-550.0, -250.0) material.node_tree.links.new( attribute.outputs["Fac"], image_layer.inputs["Normal Scale"] @@ -206,8 +205,7 @@ def create_shape_object( scene=context.scene, ) depth_reference = None - if shape in BALLOON_CUTOUT_SHAPES: - _connect_balloon_normal_scale(material) + _connect_cutout_normal_scale(material) uniform_scale = None depth_direction = None if shape in DEPTH_CUTOUT_SHAPES: @@ -227,7 +225,7 @@ def create_shape_object( depth_reference = DEFAULT_DEPTH_BASELINE if shape == "DEPTH_BALLOON": depth_direction = DEFAULT_DEPTH_DIRECTION - elif shape == "DEPTH_SURFACE": + elif shape == "DEPTH_SOLID": depth_reference = metric_depth_reference( camera_depth, calibration_valid, @@ -268,22 +266,19 @@ def create_shape_object( inward_axis, ) mesh_object["anyimage_mesh_shape"] = shape - node_group = load_cutout_node_group() + node_group = load_cutout_node_group(shape) modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES") modifier.node_group = node_group modifier.show_group_selector = False for name, value in ( - ("Shape", CUTOUT_SHAPE_INDICES[shape]), ( "Inward Axis", 0 if inward_axis == CutoutInwardAxis.NEGATIVE_Z else 1, ), - ("Cleanup", cleanup_value), - ("Smooth Iterations", 0), - ("Smooth", 1.0), + ("Cleanup Threshold", cleanup_value), ( "Thickness", - 1.0 if shape in {"BALLOON", "DEPTH_BALLOON"} else 0.0, + 0.0 if shape == "FLAT" else 1.0, ), ): set_modifier_input( diff --git a/src/anyimage/operators/cutout_tool/shape.py b/src/anyimage/operators/cutout_tool/shape.py index 22a274a..09a8fdd 100644 --- a/src/anyimage/operators/cutout_tool/shape.py +++ b/src/anyimage/operators/cutout_tool/shape.py @@ -1,7 +1,7 @@ """Cutout Shape identifiers shared by runtime and node-asset tools.""" BALLOON_ATTRIBUTE_NAME = "o_balloon" -CUTOUT_NODE_GROUP_NAME = "O Image Cutout" +NORMAL_SCALE_ATTRIBUTE_NAME = "o_normal_scale" CUTOUT_X_TO_Z = ( (0.0, -1.0, 0.0, 0.0), (0.0, 0.0, 1.0, 0.0), @@ -15,20 +15,16 @@ CUTOUT_Z_TO_X = ( (0.0, 0.0, 0.0, 1.0), ) CUTOUT_NODE_GROUP_NAMES = { - "SURFACE": "O Image Surface", - "BALLOON": "O Image Balloon", + "FLAT": "O Image Cutout", + "SOLID": "O Image Cutout", "DEPTH_BALLOON": "O Image Depth Balloon", - "DEPTH_SURFACE": "O Image Depth Surface", + "DEPTH_SOLID": "O Image Depth Cutout", } CUTOUT_SHAPE_LABELS = { - "SURFACE": "Surface", - "BALLOON": "Balloon", + "FLAT": "Flat", + "SOLID": "Solid", "DEPTH_BALLOON": "Depth Balloon", - "DEPTH_SURFACE": "Depth Surface", -} -CUTOUT_SHAPE_INDICES = { - shape: index for index, shape in enumerate(CUTOUT_NODE_GROUP_NAMES) + "DEPTH_SOLID": "Depth Solid", } CUTOUT_SHAPES = frozenset(CUTOUT_NODE_GROUP_NAMES) -DEPTH_CUTOUT_SHAPES = frozenset({"DEPTH_BALLOON", "DEPTH_SURFACE"}) -BALLOON_CUTOUT_SHAPES = frozenset({"BALLOON", "DEPTH_BALLOON"}) +DEPTH_CUTOUT_SHAPES = frozenset({"DEPTH_BALLOON", "DEPTH_SOLID"}) diff --git a/src/anyimage/operators/upscale.py b/src/anyimage/operators/upscale.py index 2289424..d9ace05 100644 --- a/src/anyimage/operators/upscale.py +++ b/src/anyimage/operators/upscale.py @@ -29,7 +29,7 @@ from ..common.image import ( _HIDDEN = {"HIDDEN", "SKIP_SAVE"} -UPSCALE_FACTOR = 4 +UPSCALE_FACTOR = 2 def upscale_output_size(image): @@ -49,7 +49,7 @@ def upscale_input_size_allowed(image, max_input_size): class UpscaleImage(JobOperatorBase, bpy.types.Operator): bl_idname = "anyimage.upscale_image" bl_label = "Upscale" - bl_description = "Upscale this Image Empty by 4x with an AI model" + bl_description = "Upscale this Image Empty by 2x with an AI model" bl_options = {"UNDO"} job_type = "upscale-image" starting_message = "Starting Image Empty upscale" diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index db290b0..dbc74b7 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -251,8 +251,8 @@ def cutout_generate_normal_property(): return bpy.props.BoolProperty( name="Normal Map", description=( - "Generate a MoGe-2 normal texture; Depth Surface uses Object Space " - "and other shapes use Tangent Space" + "Generate a normal map for Flat and Solid; " + "Depth Solid and Depth Balloon always generate normal maps" ), default=False, ) diff --git a/src/anyimage/server/model_catalog.py b/src/anyimage/server/model_catalog.py index 65accde..022723a 100644 --- a/src/anyimage/server/model_catalog.py +++ b/src/anyimage/server/model_catalog.py @@ -99,7 +99,7 @@ REALESRGAN_GENERAL_WDN_X4V3 = ModelSpec( directory_name="realesr-general-wdn-x4v3", repository="xinntao/Real-ESRGAN", license_name="BSD 3-Clause", - description="Fast and conservative 4x image upscaler using ONNX Runtime", + description="Fast and conservative 2x output from a 4x ONNX model", supports_gs=False, download_patterns=("realesr-general-wdn-x4v3.onnx",), ready_patterns=("realesr-general-wdn-x4v3.onnx",), @@ -117,7 +117,7 @@ SAFMN_MODEL = ModelSpec( directory_name="safmn-real-x4", repository="sunny2109/SAFMN", license_name="Apache 2.0", - description="Balanced real-world 4x image upscaler using ONNX Runtime", + description="Balanced 2x output from a real-world 4x ONNX model", supports_gs=False, download_patterns=("safmn-real-x4.onnx",), ready_patterns=("safmn-real-x4.onnx",), @@ -136,7 +136,7 @@ REALESRGAN_X4PLUS = ModelSpec( repository="jonathanst29/tinier-upscale-models", license_name="BSD 3-Clause", huggingface_repository="jonathanst29/tinier-upscale-models", - description="Detailed general-purpose 4x image upscaler using ONNX Runtime", + description="Detailed 2x output from a general-purpose 4x ONNX model", supports_gs=False, download_patterns=("realesrgan-x4plus.onnx",), ready_patterns=("realesrgan-x4plus.onnx",), @@ -149,12 +149,37 @@ REALESRGAN_X4PLUS = ModelSpec( ) +PISA_SR = ModelSpec( + key="PISA_SR", + label="PiSA-SR", + directory_name="pisa-sr", + repository="csslc/PiSA-SR", + license_name="Apache 2.0 / CreativeML Open RAIL++-M", + description="Generative 2x upscale with fixed detail strength 0.7", + supports_gs=False, + download_patterns=("encoder.onnx", "decoder.onnx", "unet-pixel.onnx", "unet-full.onnx", "noise.npy", "LICENSE-APACHE.txt", "LICENSE-MODEL.txt", "NOTICE.txt"), + ready_patterns=("encoder.onnx", "decoder.onnx", "unet-pixel.onnx", "unet-full.onnx", "noise.npy", "LICENSE-APACHE.txt", "LICENSE-MODEL.txt", "NOTICE.txt"), + r2_directory="pisa-sr", + r2_files=( + ("encoder.onnx", 68426712, "4b93e4a3d81c3349a78ab96619501e39ee8f815d14a20b73fc4f969e30a1ceeb"), + ("decoder.onnx", 99084589, "c9021384c402e267654907527fa5f0ee86cab1814bfd96233216a15ae8e83c4c"), + ("unet-pixel.onnx", 1732713007, "4f381574ad584050d0534c8cca794fb5828cb8e99e148f8efc1aaac20833c0b1"), + ("unet-full.onnx", 1732713007, "3b0fd44ff1093608a65865c3f26d0060775337e1fc117d8ac29aacbe748d3f9c"), + ("noise.npy", 32896, "a94ed01e2e963022cfa8ec01e04d2e96e599408ef0c385878347eaacd25a663b"), + ("LICENSE-APACHE.txt", 11358, "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30"), + ("LICENSE-MODEL.txt", 14206, "e514164be7f2ca2c0fb39e7283b01ab4a06d802837eaf171aa4645cfd7ee1157"), + ("NOTICE.txt", 1081, "ccb55fcb98cce42401368b88e3ccd555f4ff49ac1347c6ce063061dd48726050"), + ), +) + + UPSCALE_MODELS = { model.key: model for model in ( REALESRGAN_GENERAL_WDN_X4V3, SAFMN_MODEL, REALESRGAN_X4PLUS, + PISA_SR, ) } diff --git a/src/anyimage/server/model_manager.py b/src/anyimage/server/model_manager.py index 3463bf1..47527e1 100644 --- a/src/anyimage/server/model_manager.py +++ b/src/anyimage/server/model_manager.py @@ -1,4 +1,5 @@ import gc +import hashlib import time from pathlib import Path from types import SimpleNamespace @@ -69,6 +70,9 @@ class ModelSessionCache: def get_upscale(self, model_dir, requested_device): from .models.onnx_upscale import create_session + if Path(model_dir).name == "pisa-sr": + from .models.onnx_pisa_sr import PisaSession as create_session + key = (str(Path(model_dir).resolve()), requested_device, "onnx") if self.upscale_model is not None and self.upscale_key == key: return self.upscale_model @@ -91,11 +95,17 @@ class ModelSessionCache: self._release_runtime_cache() def release_upscale(self): + close = getattr(self.upscale_model, "close", None) + if close is not None: + close() self.upscale_model = None self.upscale_key = None self._release_runtime_cache() def close(self): + close = getattr(self.upscale_model, "close", None) + if close is not None: + close() self.ben2_model = None self.da3_model = None self.moge_model = None @@ -124,6 +134,7 @@ class ModelManager: def __init__(self, models_directory): self.models_directory = Path(models_directory) self.sessions = ModelSessionCache() + self.verified_files = {} def directory(self, model_key): model = model_catalog.get_downloadable_model(model_key) @@ -132,6 +143,27 @@ class ModelManager: def ready(self, model_key): model = model_catalog.get_downloadable_model(model_key) directory = self.directory(model_key) + if model_key == "PISA_SR": + for filename, size, checksum in model.r2_files: + path = directory / filename + try: + stat = path.stat() + if stat.st_size != size: + return False + identity = (stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns) + cached = self.verified_files.get(path) + # Windows can coalesce timestamps for immediately repeated writes. + cacheable = size >= 1024 * 1024 and time.time_ns() - stat.st_mtime_ns > 1_000_000_000 + if not cacheable or cached != (identity, checksum): + with path.open("rb") as stream: + actual = hashlib.file_digest(stream, "sha256").hexdigest() + if actual != checksum: + return False + if cacheable: + self.verified_files[path] = (identity, checksum) + except OSError: + return False + return True return all(any(directory.glob(pattern)) for pattern in model.ready_patterns) def get_ben2(self, model_dir, device): diff --git a/src/anyimage/server/models/onnx_pisa_sr.py b/src/anyimage/server/models/onnx_pisa_sr.py new file mode 100644 index 0000000..0823e3b --- /dev/null +++ b/src/anyimage/server/models/onnx_pisa_sr.py @@ -0,0 +1,153 @@ +"""Fixed-strength PiSA-SR inference with one resident ONNX component.""" + +import gc +from pathlib import Path +from tempfile import TemporaryDirectory + +import numpy as np +from PIL import Image + +from .onnx_runtime import create_session, run_session +from .onnx_upscale import RESOURCE_ERROR_TOKENS, UpscaleResourceError + +TILE_SIZE = 512 +OVERLAP = 128 +LATENT_SCALE = np.float16(0.18215) +SEMANTIC_STRENGTH = np.float16(0.7) + + +def tile_starts(length): + padded = max(TILE_SIZE, (length + 7) // 8 * 8) + starts = list(range(0, padded - TILE_SIZE + 1, TILE_SIZE - OVERLAP)) + if starts[-1] != padded - TILE_SIZE: + starts.append(padded - TILE_SIZE) + return starts, padded + + +def correct_color(pixels, source): + target = pixels.astype(np.float32) / 255 + reference = np.asarray(source, dtype=np.float32) / 255 + axes = (0, 1) + deviation = np.sqrt(target.var(axis=axes, ddof=1) + 1e-5) + reference_deviation = np.sqrt(reference.var(axis=axes, ddof=1) + 1e-5) + corrected = (target - target.mean(axis=axes)) / deviation + corrected = corrected * reference_deviation + reference.mean(axis=axes) + return Image.fromarray((corrected.clip(0, 1) * 255).astype(np.uint8)) + + +class PisaSession: + def __init__(self, directory, device): + if device == "coreml": + raise RuntimeError("PiSA-SR CoreML is not validated; select CPU for this model") + self.directory = Path(directory) + self.device = device + self.session = None + self.component = None + self.noise = np.load(self.directory / "noise.npy", allow_pickle=False) + if self.noise.shape != (1, 4, 64, 64) or not np.isfinite(self.noise).all(): + raise ValueError("Invalid PiSA-SR noise asset") + + def close(self): + self.session = None + self.component = None + gc.collect() + + def activate(self, component): + if self.component == component: + return + self.close() + self.session = create_session(self.directory / f"{component}.onnx", self.device) + self.session.disable_fallback() + if "CoreMLExecutionProvider" in self.session.get_providers(): + self.close() + raise RuntimeError("PiSA-SR CoreML is not validated; select CPU for this model") + self.component = component + + def run(self, value, final=False): + try: + result = run_session( + self.session, ["output"], + {"input": np.ascontiguousarray(value, dtype=np.float16)}, + release_memory=final, + )[0] + except Exception as error: + if "CUDAExecutionProvider" in self.session.get_providers(): + raise RuntimeError( + "PiSA-SR failed on CUDA; select DirectML or CPU. " + str(error) + ) from error + raise + if not np.isfinite(result).all(): + raise RuntimeError("PiSA-SR returned non-finite pixels on the selected device") + return result + + +def infer(model, image, cancel_check=None, *, release_memory=True): + def check(): + if cancel_check is not None and cancel_check(): + raise RuntimeError("Task cancelled") + + source = image.convert("RGB").resize( + (image.width * 2, image.height * 2), Image.Resampling.BICUBIC, + ) + rows, height = tile_starts(source.height) + columns, width = tile_starts(source.width) + coordinates = [(y, x) for y in rows for x in columns] + array = np.asarray(source) + array = np.pad(array, ((0, height-source.height), (0, width-source.width), (0, 0)), + mode="reflect" if min(source.size) > 1 else "edge") + try: + with TemporaryDirectory(prefix="anyimage-pisa-") as temporary: + directory = Path(temporary) + check() + model.activate("encoder") + for index, (y, x) in enumerate(coordinates): + check() + tile = array[y:y+TILE_SIZE, x:x+TILE_SIZE].astype(np.float32) / 255 + tensor = (tile.transpose(2, 0, 1)[None] * 2 - 1).astype(np.float16) + mean, logvar = np.split(model.run(tensor, index == len(coordinates)-1), 2, axis=1) + noise = np.take(np.take(model.noise, (np.arange(64)+y//8) % 64, axis=2), + (np.arange(64)+x//8) % 64, axis=3) + std = np.exp(logvar.clip(-30, 20) * np.float16(0.5)).astype(np.float16) + latent = ((mean + std * noise) * LATENT_SCALE).astype(np.float16) + np.save(directory / f"latent-{index}.npy", latent) + for component in ("unet-pixel", "unet-full"): + check() + model.activate(component) + for index in range(len(coordinates)): + check() + latent = np.load(directory / f"latent-{index}.npy") + prediction = model.run(latent, index == len(coordinates)-1) + pixel_path = directory / f"pixel-{index}.npy" + if component == "unet-pixel": + np.save(pixel_path, prediction) + else: + pixel = np.load(pixel_path) + corrected = latent - (pixel + SEMANTIC_STRENGTH * (prediction-pixel)) + np.save(directory / f"latent-{index}.npy", corrected) + pixel_path.unlink() + check() + model.activate("decoder") + result = np.zeros((height, width, 3), dtype=np.float32) + weights = np.zeros((height, width, 1), dtype=np.float32) + ramp = np.minimum(np.arange(TILE_SIZE)+1, np.arange(TILE_SIZE, 0, -1)) + ramp = np.minimum(ramp / OVERLAP, 1).astype(np.float32) + blend = (ramp[:, None] * ramp[None, :])[..., None] + for index, (y, x) in enumerate(coordinates): + check() + latent = np.load(directory / f"latent-{index}.npy") + decoded = model.run(latent, release_memory and index == len(coordinates)-1) + pixels = decoded[0].transpose(1, 2, 0).astype(np.float32) + result[y:y+TILE_SIZE, x:x+TILE_SIZE] += pixels * blend + weights[y:y+TILE_SIZE, x:x+TILE_SIZE] += blend + check() + result = (result / weights)[:source.height, :source.width] + pixels = ((result.clip(-1, 1)*0.5+0.5)*255).clip(0, 255).astype(np.uint8) + return correct_color(pixels, source) + except (MemoryError, UnicodeDecodeError) as error: + model.close() + raise UpscaleResourceError("PiSA-SR exhausted available inference memory") from error + except Exception as error: + model.close() + if any(token in str(error).lower() for token in RESOURCE_ERROR_TOKENS): + raise UpscaleResourceError("PiSA-SR exhausted available inference memory") from error + raise diff --git a/src/anyimage/server/models/onnx_runtime.py b/src/anyimage/server/models/onnx_runtime.py index fe5bdb2..7b82c41 100644 --- a/src/anyimage/server/models/onnx_runtime.py +++ b/src/anyimage/server/models/onnx_runtime.py @@ -62,9 +62,16 @@ def create_session( elif provider == "CoreMLExecutionProvider" and coreml_options: provider = (provider, dict(coreml_options)) configured_providers.append(provider) + session_options = {} + if "DmlExecutionProvider" in providers: + options = onnxruntime.SessionOptions() + options.enable_mem_pattern = False + options.execution_mode = onnxruntime.ExecutionMode.ORT_SEQUENTIAL + session_options["sess_options"] = options return onnxruntime.InferenceSession( str(path), providers=configured_providers, + **session_options, ) diff --git a/src/anyimage/server/models/upscale.py b/src/anyimage/server/models/upscale.py index 124c38b..5add460 100644 --- a/src/anyimage/server/models/upscale.py +++ b/src/anyimage/server/models/upscale.py @@ -19,6 +19,9 @@ def infer_one( from .onnx_upscale import UpscaleResourceError, infer + if parameters["model"] == "PISA_SR": + from .onnx_pisa_sr import infer + model_directory = model_manager.directory(parameters["model"]) model = model_manager.get_upscale( model_directory, @@ -37,6 +40,10 @@ def infer_one( cancel_check=cancel_check, release_memory=release_memory, ) + prediction = prediction.resize( + (source.width * 2, source.height * 2), + Image.Resampling.LANCZOS, + ) if source_alpha is None: return prediction alpha = source_alpha.resize( diff --git a/tests/test_blender_addon.py b/tests/test_blender_addon.py index 947a3c2..f9fad29 100644 --- a/tests/test_blender_addon.py +++ b/tests/test_blender_addon.py @@ -1252,7 +1252,7 @@ class BlenderAddonTest(unittest.TestCase): np.ones((8, 8), dtype=np.float32), (1, 1, 9, 9) ) source_rgba = np.ones((10, 10, 4), dtype=np.float32) - for shape in ("SURFACE", "BALLOON"): + for shape in ("FLAT", "SOLID"): with self.subTest(shape=shape): operator = SimpleNamespace( source_object_name="Source", @@ -1350,7 +1350,7 @@ class BlenderAddonTest(unittest.TestCase): operator.selection_path_json = json.dumps( {"points": [[1, 1], [7, 1], [7, 3], [1, 3]]} ) - operator.shape = "DEPTH_SURFACE" + operator.shape = "DEPTH_SOLID" operator.point_spacing = 32 operator.generate_normal = False operator.report = lambda *_args: None @@ -1428,7 +1428,7 @@ class BlenderAddonTest(unittest.TestCase): selection_path_json=json.dumps( {"points": [[1, 1], [7, 1], [7, 3], [1, 3]]} ), - shape="SURFACE", + shape="FLAT", point_spacing=32, generate_normal=False, report=lambda level, message: reports.append((level, message)), @@ -1474,7 +1474,7 @@ class BlenderAddonTest(unittest.TestCase): operator.fine_outline = False operator.input_path = __file__ operator.source_object_name = "Source" - operator.shape = "DEPTH_SURFACE" + operator.shape = "DEPTH_SOLID" operator.model = self.anyimage.properties.DEFAULT_MOGE2_MODEL_KEY operator.resolution_level = 5 operator.max_ai_input_size = 2048 @@ -1580,7 +1580,7 @@ class BlenderAddonTest(unittest.TestCase): self.assertEqual( labels["anyimage.upscale_image"], - "Upscale (2560 × 1920)", + "Upscale (1280 × 960)", ) def test_image_menu_opens_environment_settings_when_ai_is_ready(self): @@ -3999,7 +3999,7 @@ class BlenderAddonTest(unittest.TestCase): operator.source_object_name = "Source" operator.selection_bounds_json = "[10,20,14,23]" operator.input_path = __file__ - operator.shape = "SURFACE" + operator.shape = "FLAT" operator.point_spacing = 32 operator.generate_normal = True operator._selection_mask = selection_mask @@ -4030,7 +4030,7 @@ class BlenderAddonTest(unittest.TestCase): selection_mask.bounds, ) - self.assertEqual(message, "Surface is ready") + self.assertEqual(message, "Flat is ready") content_values = create_shape.call_args.args[4] np.testing.assert_array_equal(content_values, pure_values) self.assertEqual(create_shape.call_args.args[5], selection_mask.bounds) diff --git a/tests/test_cutout_fine_mesh.py b/tests/test_cutout_fine_mesh.py index 97f7933..bdee6a1 100644 --- a/tests/test_cutout_fine_mesh.py +++ b/tests/test_cutout_fine_mesh.py @@ -228,6 +228,27 @@ def test_cdt_preserves_constraint_split_by_an_interior_vertex(): assert not g.boundary_vertices_from_faces(len(points), faces)[constraints].any() +@pytest.mark.parametrize("offset", [0, 4096, 16384]) +def test_cdt_collinear_outline_samples_do_not_become_sliver_faces(offset): + contour = np.asarray(((0., 0.), (240., 0.), (240., 160.), (0., 160.))) + contour = contour @ np.asarray(((.763, -.646), (.646, .763))) + contour += np.asarray((420.123456, 540.234567)) + offset + contours, spine, edges = fine.subdivide_constraints( + [contour], np.empty((0, 2)), [], 16 + ) + free = g.uniform_interior_points_for_contours(contours, 16) + points, faces, _ = fine.triangulate(contours, spine, edges, free, 16) + triangles = points[faces] + area = abs(np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])) + longest_squared = np.max(np.sum((triangles - np.roll(triangles, 1, axis=1))**2, axis=2), axis=1) + assert np.min(area / longest_squared) > 1e-4 + assert area.sum() / 2 == pytest.approx(abs(g.contour_area(contour)), rel=1e-9) + boundary = g.boundary_vertices_from_faces(len(points), faces) + np.testing.assert_allclose(fine.ContourDomain([contour]).clearance(points[boundary]), 0, atol=1e-9) + # Every subdivided outline point survives; removing slivers must not skip it. + assert cKDTree(points[boundary]).query(contours[0])[0].max() < 1e-9 + + def test_contour_validation_accepts_pixel_contacts_and_rejects_crossings(): crossing = np.asarray(((0, 0), (4, 4), (0, 4), (4, 0))) assert not fine.valid_contours([crossing]) diff --git a/tests/test_cutout_geometry.py b/tests/test_cutout_geometry.py index 3aa82cc..5568303 100644 --- a/tests/test_cutout_geometry.py +++ b/tests/test_cutout_geometry.py @@ -66,8 +66,8 @@ def test_cutout_node_groups_are_loaded_from_the_asset(): cutout_object, "load_node_group", return_value=loaded ) as load: for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES: - assert cutout_object.load_cutout_node_group() is loaded - load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAME) + assert cutout_object.load_cutout_node_group(shape) is loaded + load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=11) assert load.call_count == 4 diff --git a/tests/test_cutout_interaction.py b/tests/test_cutout_interaction.py index a90aaf6..0804167 100644 --- a/tests/test_cutout_interaction.py +++ b/tests/test_cutout_interaction.py @@ -86,12 +86,12 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch): ) assert [call[2].shape for call in window_manager.pie.calls] == [ - "SURFACE", - "BALLOON", + "FLAT", + "SOLID", ] assert [call[1]["text"] for call in window_manager.pie.calls] == [ - "Surface", - "Balloon", + "Flat", + "Solid", ] assert all( call[0] == "anyimage.cutout_selection_to_shape" @@ -134,10 +134,10 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): ) assert [call[2].shape for call in window_manager.pie.calls] == [ - "SURFACE", - "BALLOON", + "FLAT", + "SOLID", "DEPTH_BALLOON", - "DEPTH_SURFACE", + "DEPTH_SOLID", ] assert all(call[2].generate_normal for call in window_manager.pie.calls) assert all(call[2].alpha_threshold == 0.1 for call in window_manager.pie.calls) @@ -158,11 +158,12 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): def test_cutout_normal_space_is_derived_from_shape(): - assert cutout_main.normal_mode_for_shape("DEPTH_SURFACE", False) == "NONE" - assert cutout_main.normal_mode_for_shape("SURFACE", True) == "TANGENT" - assert cutout_main.normal_mode_for_shape("BALLOON", True) == "TANGENT" + assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", False) == "OBJECT" + assert cutout_main.normal_mode_for_shape("DEPTH_BALLOON", False) == "TANGENT" + assert cutout_main.normal_mode_for_shape("FLAT", True) == "TANGENT" + assert cutout_main.normal_mode_for_shape("SOLID", True) == "TANGENT" assert cutout_main.normal_mode_for_shape("DEPTH_BALLOON", True) == "TANGENT" - assert cutout_main.normal_mode_for_shape("DEPTH_SURFACE", True) == "OBJECT" + assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", True) == "OBJECT" def test_cutout_mesh_detail_maps_to_denser_sampling(): diff --git a/tests/test_cutout_objects.py b/tests/test_cutout_objects.py index 362adf5..5ebfe1a 100644 --- a/tests/test_cutout_objects.py +++ b/tests/test_cutout_objects.py @@ -396,7 +396,7 @@ def test_object_normal_image_follows_cutout_inward_axis(): assert np.isclose(actual[3], 0.3) -@pytest.mark.parametrize("shape", ("SURFACE", "BALLOON")) +@pytest.mark.parametrize("shape", ("FLAT", "SOLID")) def test_cutout_axes_produce_the_same_world_geometry(shape): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new("Axis Source", width=32, height=16, alpha=True) @@ -499,305 +499,32 @@ def test_z_depth_texture_keeps_depth_outside_positive_validity(tmp_path): assert np.array_equal(packed[..., 3], frame.validity) -def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): +def test_cutout_presets_load_three_direct_modifier_assets(): bpy.ops.wm.read_factory_settings(use_empty=True) + groups = {shape: cutout_object.load_cutout_node_group(shape) for shape in cutout_shape.CUTOUT_SHAPES} + assert groups["FLAT"] == groups["SOLID"] + assert len(set(groups.values())) == 3 + for shape, group in groups.items(): + assert group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape] + assert group.is_modifier + assert "Shape" not in _inputs(group) + assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) + assert all(socket.is_linked or socket.hide for node in group.nodes for socket in node.outputs) + for shape in ("SOLID", "DEPTH_SOLID"): + group = groups[shape] + inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"} + assert next(s for s in group.interface.items_tree if s.name == "Thickness").default_value == 1 + thickness = [item for item in group.interface.items_tree if item.name == "Thickness"] + assert [item.subtype for item in thickness] == ["NONE", "DISTANCE"] + assert inputs["Thickness"].subtype == "DISTANCE" + assert inputs["Mode"].socket_type == "NodeSocketMenu" + assert inputs["Mode"].default_value == "Balloon" + assert inputs["Smooth"].default_value == 0 + assert inputs["Smooth Weight"].default_value == 1 + assert "Edge Smooth" not in inputs + assert not any(node.bl_idname == "GeometryNodeRepeatInput" and node.inputs["Iterations"].default_value == 8 for node in group.nodes) + assert next(item for item in groups["DEPTH_BALLOON"].interface.items_tree if item.name == "Double Sided").default_value - plane = node_asset.load_node_group("O Mesh Plane") - depth_plane = node_asset.load_node_group("O Image Depth Plane") - cutout = cutout_object.load_cutout_node_group() - assert plane.name == "O Mesh Plane" - assert cutout.name == cutout_shape.CUTOUT_NODE_GROUP_NAME - assert all( - cutout_object.load_cutout_node_group() == cutout - for shape in cutout_shape.CUTOUT_SHAPES - ) - assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness"] - assert _inputs(depth_plane) == [ - "Geometry", - "Subdivide", - "Thickness", - "Depth Scale", - "Reference Depth", - "Uniform Scale", - "Depth Image", - ] - assert _inputs(cutout) == [ - "Geometry", - "Shape", - "Inward Axis", - "Thickness", - "Depth Scale", - "Double Sided", - "Smooth Iterations", - "Cleanup", - "Smooth", - "Split", - "Reference Depth", - "Depth Direction", - "Uniform Scale", - "Depth Image", - ] - panels = [item for item in cutout.interface.items_tree if item.item_type == "PANEL"] - assert [panel.name for panel in panels] == ["Options", "Data"] - assert [item.name for item in panels[0].interface_items] == [ - "Cleanup", - "Smooth", - "Split", - "Reference Depth", - "Depth Direction", - ] - assert [item.name for item in panels[1].interface_items] == [ - "Uniform Scale", - "Depth Image", - ] - assert all(panel.default_closed for panel in panels) - - shape_socket = next( - item - for item in cutout.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Shape" - ) - assert shape_socket.socket_type == "NodeSocketMenu" - assert not shape_socket.menu_expanded - menu_switch = next( - node for node in cutout.nodes if node.bl_idname == "GeometryNodeMenuSwitch" - ) - labels = { - shape: shape.replace("_", " ").title() - for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES - } - assert [item.name for item in menu_switch.enum_items] == list(labels.values()) - assert any( - link.from_socket.name == "Shape" - and link.to_node == menu_switch - and link.to_socket.name == "Menu" - for link in cutout.links - ) - axis_switch = next( - node - for node in cutout.nodes - if node.bl_idname == "GeometryNodeMenuSwitch" - and [item.name for item in node.enum_items] == ["-Z", "+X"] - ) - negative_z_link = next( - link - for link in cutout.links - if link.to_node == axis_switch and link.to_socket.name == "-Z" - ) - positive_x_link = next( - link - for link in cutout.links - if link.to_node == axis_switch and link.to_socket.name == "+X" - ) - assert positive_x_link.from_node.bl_idname == "GeometryNodeTransform" - assert any( - link.from_socket == negative_z_link.from_socket - and link.to_node == positive_x_link.from_node - and link.to_socket.name == "Geometry" - for link in cutout.links - ) - - child_groups = { - shape: bpy.data.node_groups[name] - for shape, name in cutout_shape.CUTOUT_NODE_GROUP_NAMES.items() - } - assert cutout.is_modifier - assert all(not group.is_modifier for group in child_groups.values()) - child_nodes = { - node.node_tree: node - for node in cutout.nodes - if node.bl_idname == "GeometryNodeGroup" - } - assert set(child_nodes) == set(child_groups.values()) - for shape, group in child_groups.items(): - label = labels[shape] - assert any( - link.from_node == child_nodes[group] - and link.to_node == menu_switch - and link.to_socket.name == label - for link in cutout.links - ) - - assert _inputs(child_groups["SURFACE"]) == ["Geometry", "Thickness"] - assert _inputs(child_groups["BALLOON"]) == ["Geometry", "Thickness"] - assert _inputs(child_groups["DEPTH_BALLOON"]) == [ - "Geometry", - "Thickness", - "Depth Scale", - "Double Sided", - "Reference Depth", - "Depth Direction", - "Uniform Scale", - "Depth Image", - ] - assert _inputs(child_groups["DEPTH_SURFACE"]) == [ - "Geometry", - "Thickness", - "Depth Scale", - "Split", - "Cleanup", - "Reference Depth", - "Uniform Scale", - "Depth Image", - ] - - dependencies = {} - for link in cutout.links: - if link.from_node.bl_idname != "NodeGroupInput": - continue - if link.to_node.bl_idname != "GeometryNodeGroup": - continue - shape = next( - shape - for shape, group in child_groups.items() - if group == link.to_node.node_tree - ) - dependencies.setdefault(link.from_socket.name, set()).add(shape) - assert dependencies == { - "Geometry": {"DEPTH_SURFACE"}, - "Thickness": set(cutout_shape.CUTOUT_SHAPES), - "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, - "Split": {"DEPTH_SURFACE"}, - "Double Sided": {"DEPTH_BALLOON"}, - "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, - "Depth Direction": {"DEPTH_BALLOON"}, - "Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, - "Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, - } - - assert ( - sum(node.bl_idname == "GeometryNodeInputMeshIsland" for node in cutout.nodes) - == 1 - ) - assert not any( - node.bl_idname == "GeometryNodeAttributeStatistic" for node in cutout.nodes - ) - assert all( - not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes) - for shape, group in child_groups.items() if shape != "DEPTH_SURFACE" - ) - assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in cutout.nodes) - assert all( - not any(node.bl_idname == "GeometryNodeBlurAttribute" for node in group.nodes) - for group in child_groups.values() - ) - repeat_counts = { - shape: sum(node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes) - for shape, group in child_groups.items() - } - assert repeat_counts == { - "SURFACE": 1, - "BALLOON": 0, - "DEPTH_BALLOON": 0, - "DEPTH_SURFACE": 1, - } - assert ( - sum(node.bl_idname == "GeometryNodeRepeatInput" for node in cutout.nodes) == 1 - ) - - smooth = next( - item - for item in cutout.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Smooth" - ) - iterations = next( - item - for item in cutout.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Smooth Iterations" - ) - cleanup = next( - item - for item in cutout.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Cleanup" - ) - assert cleanup.default_value == 0.0 - assert cleanup.subtype == "NONE" - split_control = next( - item - for item in cutout.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Split" - ) - child_split = next( - item - for item in child_groups["DEPTH_SURFACE"].interface.items_tree - if item.item_type == "SOCKET" and item.name == "Split" - ) - assert split_control.default_value == 0.0 - assert split_control.min_value == 0.0 - assert split_control.max_value == 1.0 - assert split_control.subtype == "FACTOR" - assert getattr(split_control.parent, "name", "") == "Options" - assert child_split.default_value == 0.0 - assert child_split.min_value == 0.0 - assert child_split.max_value == 1.0 - assert child_split.subtype == "FACTOR" - assert getattr(child_split.parent, "name", "") == "Options" - depth_surface_nodes = child_groups["DEPTH_SURFACE"].nodes - assert any(node.bl_idname == "GeometryNodeSplitEdges" for node in depth_surface_nodes) - from validate_cutout import validate_depth_surface_attributes - validate_depth_surface_attributes(child_groups["DEPTH_SURFACE"]) - assert [node.domain for node in depth_surface_nodes - if node.bl_idname == "GeometryNodeDeleteGeometry"] == ["FACE"] - assert "Stretch Limit" not in [item.name for item in child_groups["DEPTH_SURFACE"].interface.items_tree] - cleanup_scale = next( - link.to_node - for link in cutout.links - if link.from_socket.name == "Cleanup" - and link.from_node.bl_idname == "NodeGroupInput" - ) - assert cleanup_scale.bl_idname == "ShaderNodeMath" - assert cleanup_scale.operation == "MULTIPLY" - assert np.isclose(cleanup_scale.inputs[1].default_value, 0.01) - assert smooth.default_value == 1.0 - assert smooth.subtype == "FACTOR" - assert iterations.default_value == 0 - assert iterations.min_value == 0 - assert getattr(iterations.parent, "name", "") == "" - thickness = next( - item - for item in cutout.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Thickness" - ) - assert thickness.subtype == "NONE" - assert thickness.default_value == 0.0 - - for group in child_groups.values(): - extrudes = [ - node for node in group.nodes if node.bl_idname == "GeometryNodeExtrudeMesh" - ] - assert extrudes - assert all(not node.inputs["Individual"].default_value for node in extrudes) - assert all( - node.inputs["Offset Scale"].default_value == 1.0 for node in extrudes - ) - for group in ( - child_groups["SURFACE"], - child_groups["DEPTH_SURFACE"], - depth_plane, - ): - thickness = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Thickness" - ) - assert thickness.subtype == "DISTANCE" - depth_subdivide = next( - item - for item in depth_plane.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Subdivide" - ) - assert depth_subdivide.default_value == 6 - for group in ( - child_groups["BALLOON"], - child_groups["DEPTH_BALLOON"], - ): - thickness = next( - item - for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Thickness" - ) - assert thickness.subtype == "NONE" - assert thickness.default_value == 1.0 def test_image_layer_switches_between_tangent_and_object_space_normal(): @@ -939,7 +666,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): bpy.context.collection.objects.link(source) result = _create_cutout_shape( source, - "BALLOON", + "SOLID", 8, ) @@ -960,7 +687,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): if node.bl_idname == "ShaderNodeAttribute" ) assert normal_scale_attribute.attribute_type == "GEOMETRY" - assert normal_scale_attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME + assert normal_scale_attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME image_layer = _group_node(material_data.material_node_group(), material_tree) normal_scale_link = next( link @@ -970,7 +697,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): ) assert normal_scale_link.from_node == normal_scale_attribute assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"] - assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME + assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["SOLID"] inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0)) assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6) @@ -990,7 +717,7 @@ def test_teddy_preference_writes_the_shared_balloon_attribute(): ): result = _create_cutout_shape( source, - "SURFACE", + "FLAT", 8, ) @@ -998,7 +725,7 @@ def test_teddy_preference_writes_the_shared_balloon_attribute(): profile = np.empty(len(result.data.vertices), dtype=np.float32) attribute.data.foreach_get("value", profile) assert profile.min() == 0.0 and profile.max() > 0.0 - assert not any( + assert any( node.bl_idname == "ShaderNodeAttribute" for node in result.data.materials[0].node_tree.nodes ) @@ -1013,7 +740,7 @@ def test_cutout_material_uses_ior_1_2(): source.empty_display_size = 2.0 bpy.context.collection.objects.link(source) - result = _create_cutout_shape(source, "SURFACE", 8) + result = _create_cutout_shape(source, "FLAT", 8) shader = next( node @@ -1111,7 +838,7 @@ def test_depth_surface_protected_smooth_evaluates(): result = _create_cutout_shape( source, - "DEPTH_SURFACE", + "DEPTH_SOLID", 8, depth_image=depth_image, depth_metadata=depth_metadata, @@ -1120,29 +847,24 @@ def test_depth_surface_protected_smooth_evaluates(): assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices) assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes modifier = result.modifiers[0] - assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME - assert ( - modifier[object_data.modifier_input_identifier(modifier.node_group, "Shape")] - == cutout_shape.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"] - ) + assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["DEPTH_SOLID"] expected_cleanup = cutout_geometry.cutout_cleanup_value( cutout_geometry.cutout_sampling_spacing(source, 8), cutout_geometry.world_plane_metric(source.matrix_world)[0], ) assert np.isclose( - modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup")], + modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")], expected_cleanup, ) - assert ( - modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth")] - == 1.0 - ) + assert np.isclose(modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")], 1.0) assert ( modifier[ - object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations") + object_data.modifier_input_identifier(modifier.node_group, "Smooth") ] == 0 ) + for name, value in (("Mode", 1), ("Front Inflation", 0.0)): + object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value) reference_id = object_data.modifier_input_identifier( modifier.node_group, "Reference Depth", @@ -1150,7 +872,7 @@ def test_depth_surface_protected_smooth_evaluates(): assert modifier[reference_id] == 8.0 object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(modifier.node_group, "Smooth"), + object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"), 0.0, ) evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get()) @@ -1164,7 +886,7 @@ def test_depth_surface_protected_smooth_evaluates(): object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(modifier.node_group, "Thickness"), + object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"), 1.0, ) result.update_tag(refresh={"DATA"}) @@ -1181,12 +903,12 @@ def test_depth_surface_protected_smooth_evaluates(): object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations"), + object_data.modifier_input_identifier(modifier.node_group, "Smooth"), 4, ) object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(modifier.node_group, "Smooth"), + object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight"), 1.0, ) result.update_tag(refresh={"DATA"}) @@ -1230,11 +952,16 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge(): result = _create_cutout_shape( source, - "DEPTH_SURFACE", + "DEPTH_SOLID", 8, depth_image=_depth_image(points, validity), depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0), ) + modifier = result.modifiers[0] + for name, value in (("Thickness", 0.0), ("Smooth", 0)): + object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(modifier.node_group, name), value) + result.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() positions = _evaluated_positions(result) assert np.isfinite(positions).all() @@ -1439,14 +1166,11 @@ def test_depth_plane_builds_a_fixed_base_terrain_block(): def _add_cutout_modifier(obj, shape): - group = cutout_object.load_cutout_node_group() + group = cutout_object.load_cutout_node_group(shape) modifier = obj.modifiers.new(group.name, "NODES") modifier.node_group = group - object_data.set_modifier_input( - modifier, - object_data.modifier_input_identifier(group, "Shape"), - cutout_shape.CUTOUT_SHAPE_INDICES[shape], - ) + if shape in {"FLAT", "DEPTH_SOLID"}: + object_data.set_modifier_input(modifier, object_data.modifier_input_identifier(group, "Mode"), 1) object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(group, "Inward Axis"), @@ -1454,17 +1178,17 @@ def _add_cutout_modifier(obj, shape): ) object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(group, "Cleanup"), + object_data.modifier_input_identifier(group, "Cleanup Threshold"), + 0.0, + ) + object_data.set_modifier_input( + modifier, + object_data.modifier_input_identifier(group, "Smooth Weight"), 0.0, ) object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(group, "Smooth"), - 0.0, - ) - object_data.set_modifier_input( - modifier, - object_data.modifier_input_identifier(group, "Smooth Iterations"), 1, ) return modifier @@ -1485,7 +1209,7 @@ def _depth_surface_step_object(): uv_layer.data[loop.index].uv = (coordinate.x * 0.5, coordinate.y * 0.5) obj = bpy.data.objects.new("Depth Surface Step", data) bpy.context.collection.objects.link(obj) - modifier = _add_cutout_modifier(obj, "DEPTH_SURFACE") + modifier = _add_cutout_modifier(obj, "DEPTH_SOLID") points = np.empty((3, 3, 3), dtype=np.float32) for y in range(3): @@ -1499,7 +1223,7 @@ def _depth_surface_step_object(): ("Uniform Scale", 1.0), ("Reference Depth", 1.0), ("Thickness", 0.0), - ("Smooth Iterations", 0), + ("Smooth", 0), ): object_data.set_modifier_input( modifier, @@ -1543,7 +1267,7 @@ def _evaluated_topology(obj): def test_depth_surface_split_preserves_original_faces(): 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") + split_id = object_data.modifier_input_identifier(modifier.node_group, "Split Threshold") for value in (0., .5, 1.): object_data.set_modifier_input(modifier, split_id, value) obj.update_tag(refresh={"DATA"}) @@ -1565,16 +1289,16 @@ def test_cutout_thickness_keeps_one_connected_shell(): data.from_pydata(vertices, (), faces) obj = bpy.data.objects.new("Connected Cutout", data) bpy.context.collection.objects.link(obj) - modifier = _add_cutout_modifier(obj, "SURFACE") + modifier = _add_cutout_modifier(obj, "FLAT") object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(modifier.node_group, "Cleanup"), + object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"), 0.0, ) object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(modifier.node_group, "Thickness"), + object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"), 1.0, ) @@ -1605,7 +1329,7 @@ def test_cutout_thickness_keeps_one_connected_shell(): object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(modifier.node_group, "Thickness"), + object_data.modifier_input_identifier(modifier.node_group, "Thickness", subtype="DISTANCE"), 2.4, ) obj.update_tag(refresh={"DATA"}) @@ -1647,11 +1371,11 @@ def test_balloon_uses_injected_profile_at_default_height(): ) obj = bpy.data.objects.new("Balloon Profile", data) bpy.context.collection.objects.link(obj) - modifier = _add_cutout_modifier(obj, "BALLOON") + modifier = _add_cutout_modifier(obj, "SOLID") object_data.set_modifier_input( modifier, - object_data.modifier_input_identifier(modifier.node_group, "Cleanup"), + object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold"), 0.0, ) object_data.set_modifier_input( @@ -1716,13 +1440,13 @@ def test_cutout_cleanup_uses_absolute_area_with_many_small_islands(): x = 5.0 + island * 0.1 vertices.extend(((x, 5.0, 0.0), (x + 0.01, 5.0, 0.0), (x, 5.01, 0.0))) faces.append((start, start + 1, start + 2)) - data = bpy.data.meshes.new("Cleanup") + data = bpy.data.meshes.new("Cleanup Threshold") data.from_pydata(vertices, (), faces) - obj = bpy.data.objects.new("Cleanup", data) + obj = bpy.data.objects.new("Cleanup Threshold", data) bpy.context.collection.objects.link(obj) - modifier = _add_cutout_modifier(obj, "SURFACE") + modifier = _add_cutout_modifier(obj, "FLAT") - cleanup_id = object_data.modifier_input_identifier(modifier.node_group, "Cleanup") + cleanup_id = object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold") object_data.set_modifier_input(modifier, cleanup_id, 0.0) bpy.context.view_layer.update() @@ -1829,7 +1553,7 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): ) smooth_id = object_data.modifier_input_identifier( modifier.node_group, - "Smooth", + "Smooth Weight", ) assert modifier[smooth_id] == 1.0 object_data.set_modifier_input(modifier, smooth_id, 0.0) @@ -2056,7 +1780,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): cutout_main.create_cutout_shape( bpy.context, source, - "SURFACE", + "FLAT", 16, content_values, selection_mask.bounds, @@ -2069,28 +1793,24 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): result = bpy.context.view_layer.objects.active assert result is not source assert result.type == "MESH" - assert result["anyimage_mesh_shape"] == "SURFACE" + assert result["anyimage_mesh_shape"] == "FLAT" modifier = result.modifiers[0] - assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME - assert ( - modifier[object_data.modifier_input_identifier(modifier.node_group, "Shape")] - == cutout_shape.CUTOUT_SHAPE_INDICES["SURFACE"] - ) + assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["FLAT"] expected_cleanup = cutout_geometry.cutout_cleanup_value( cutout_geometry.cutout_sampling_spacing(source, 16), cutout_geometry.world_plane_metric(source.matrix_world)[0], ) assert np.isclose( - modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup")], + modifier[object_data.modifier_input_identifier(modifier.node_group, "Cleanup Threshold")], expected_cleanup, ) assert ( - modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth")] + modifier[object_data.modifier_input_identifier(modifier.node_group, "Smooth Weight")] == 1.0 ) assert ( modifier[ - object_data.modifier_input_identifier(modifier.node_group, "Smooth Iterations") + object_data.modifier_input_identifier(modifier.node_group, "Smooth") ] == 0 ) @@ -2127,9 +1847,6 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): def test_balloon_normal_scale_uses_existing_image_layer_input(): bpy.ops.wm.read_factory_settings(use_empty=True) - assert cutout_shape.BALLOON_CUTOUT_SHAPES == frozenset( - {"BALLOON", "DEPTH_BALLOON"} - ) group = bpy.data.node_groups.new( material_data.IMAGE_MATERIAL_NODE_GROUP_NAME, "ShaderNodeTree", @@ -2145,7 +1862,7 @@ def test_balloon_normal_scale_uses_existing_image_layer_input(): image_layer = image_material.node_tree.nodes.new("ShaderNodeGroup") image_layer.node_tree = group - cutout_object._connect_balloon_normal_scale(image_material) + cutout_object._connect_cutout_normal_scale(image_material) assert _inputs(group) == ["Normal Scale"] attribute = next( @@ -2154,7 +1871,7 @@ def test_balloon_normal_scale_uses_existing_image_layer_input(): if node != image_layer ) assert attribute.bl_idname == "ShaderNodeAttribute" - assert attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME + assert attribute.attribute_name == cutout_shape.NORMAL_SCALE_ATTRIBUTE_NAME link = next(iter(image_material.node_tree.links)) assert link.from_socket == attribute.outputs["Fac"] assert link.to_socket == image_layer.inputs["Normal Scale"] diff --git a/tests/test_cutout_volume.py b/tests/test_cutout_volume.py new file mode 100644 index 0000000..fe79dfb --- /dev/null +++ b/tests/test_cutout_volume.py @@ -0,0 +1,301 @@ +"""Contracts for smooth depth volume, editable presets and normal masks.""" +from pathlib import Path +import sys + +import bpy +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "tools/node_assets")) +from build_cutout_modifiers import build_cutout_group +from cutout_volume import build_volume_fields +from cutout_rounding import build_volume_rounding +from anyimage.operators.cutout_tool.object import load_cutout_node_group +from anyimage.operators.cutout_tool.main import normal_mode_for_shape +from anyimage.operators.cutout_tool.geometry import refine_balloon_triangles, poisson_balloon_profile +from anyimage.common.object import modifier_input_identifier, set_modifier_input + + +@pytest.mark.parametrize("enabled", [False, True]) +def test_depth_presets_always_generate_the_correct_normal_space(enabled): + assert normal_mode_for_shape("DEPTH_SOLID", enabled) == "OBJECT" + assert normal_mode_for_shape("DEPTH_BALLOON", enabled) == "TANGENT" + for shape in ("FLAT", "SOLID"): + assert normal_mode_for_shape(shape, enabled) == ("TANGENT" if enabled else "NONE") + + +def test_new_cutout_does_not_reuse_a_saved_wrapper(): + bpy.ops.wm.read_factory_settings(use_empty=True) + saved = bpy.data.node_groups.new("O Image Cutout", "GeometryNodeTree") + saved.interface.new_socket(name="Shape", in_out="INPUT", socket_type="NodeSocketMenu") + saved.use_fake_user = True + current = load_cutout_node_group("SOLID") + assert current != saved and current.get("anyimage_asset_revision") == 11 + assert saved in bpy.data.node_groups.values() + assert current == load_cutout_node_group("FLAT") + assert any(item.name == "Shape" for item in saved.interface.items_tree) + + +def test_refined_boundary_ears_have_interior_balloon_samples(): + points = np.array([(0, 0), (1, 0), (1, 1), (0, 1)], dtype=float) + points, faces, boundary = refine_balloon_triangles(points, [(0, 1, 2), (0, 2, 3)], np.ones(4, bool)) + profile = poisson_balloon_profile(points, faces, boundary) + assert np.all(profile[boundary] == 0) + assert all(any(profile[index] > 0 for index in face) for face in faces) + + +def test_depth_rounding_options_are_consolidated(): + bpy.ops.wm.read_factory_settings(use_empty=True) + group = build_cutout_group("DEPTH_SOLID") + inputs = {s.name: s for s in group.interface.items_tree if s.item_type == "SOCKET" and s.in_out == "INPUT"} + for name, default in (("Edge Round", 0.2), ("Front Inflation", 1), ("Normal Smooth", 50)): + assert inputs[name].default_value == pytest.approx(default) + assert inputs[name].structure_type == "SINGLE" + assert inputs["Edge Round"].parent.name == "Options" + names = list(inputs) + assert names[names.index("Front Inflation") - 1] == "Edge Round" + assert [s.name for s in inputs.values() if s.parent and s.parent.name == "Options"] == [ + "Cleanup Threshold", "Split Threshold", "Reference Depth", "Edge Round", "Front Inflation", "Normal Smooth", "Smooth Weight", + ] + assert not set(inputs) & {"Edge Round Range", "Back Rim Range", "Normal Influence", "Edge Round Amount", "Back Rim Fullness"} + assert all(n.inputs["Name"].default_value != "unused" for n in group.nodes if n.bl_idname == "GeometryNodeRemoveAttribute") + + +@pytest.mark.parametrize("iterations", [0, 50]) +def test_uniform_thickness_follows_the_tilted_front_normal(iterations): + from scipy.spatial import cKDTree + from test_depth_surface_nodes import surface, evaluated, assert_closed + obj, set_value = surface(step=0, resolution=4) + image = bpy.data.images["Camera"] + pixels = np.array(image.pixels[:], dtype=np.float32).reshape(32, 64, 4) + pixels[..., 2] = 1 + 0.4 * pixels[..., 0] + image.pixels.foreach_set(pixels.ravel()) + image.update() + set_value("Normal Smooth", iterations) + front, _ = evaluated(obj) + set_value("Thickness", 0.2) + points, faces = evaluated(obj) + expected = front - 0.2 * np.array((0.4, 0, 1)) / np.sqrt(1.16) + assert cKDTree(points).query(expected)[0].max() < 1e-5 + assert cKDTree(points).query(front)[0].max() < 1e-5 + assert_closed(faces) + + +def test_balloon_shell_converges_to_front_with_thickness(): + from scipy.spatial import cKDTree + from test_depth_surface_nodes import surface, evaluated, assert_closed + obj, set_value = surface(step=1, resolution=10) + xy = np.array([v.co[:2] for v in obj.data.vertices]) + profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) + profile[abs(profile) < 1e-7] = 0 + obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) + set_value("Mode", 0) + set_value("Edge Round", 0.0) + set_value("Normal Smooth", 0) + modifier = obj.modifiers[0] + thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE") + def amount(value): + set_modifier_input(modifier, thickness, value) + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + return evaluated(obj) + front, _ = amount(0) + tree = cKDTree(front) + gaps = [] + for value in (0.01, 0.001, 0.0001): + points, faces = amount(value) + assert_closed(faces) + gaps.append(tree.query(points)[0].max()) + np.testing.assert_allclose(np.array(gaps[1:]) / gaps[:-1], 0.1, rtol=0.02) + + +@pytest.mark.parametrize("amount", [0.0, 1.0]) +def test_rounding_shares_front_and_back_falloff(amount): + bpy.ops.wm.read_factory_settings(use_empty=True) + n = 15 + xy = np.array([(x, y) for y in np.linspace(-1, 1, n) for x in np.linspace(-1, 1, n)]) + balloon = 0.4 * np.maximum(0, (1 - xy[:, 0]**2) * (1 - xy[:, 1]**2)) + depth = 0.2 * xy[:, 0] + 0.3 * xy[:, 1] + 1.4 * balloon + faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)] + mesh = bpy.data.meshes.new("Round profile") + mesh.from_pydata(np.column_stack((xy, depth)), [], faces) + mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", balloon) + obj = bpy.data.objects.new("Round profile", mesh) + bpy.context.collection.objects.link(obj) + group = bpy.data.node_groups.new("Round field test", "GeometryNodeTree") + for name, kind, default in (("Geometry", "NodeSocketGeometry", None), ("Thickness", "NodeSocketFloat", 1), ("Edge Round", "NodeSocketFloat", amount), ("Normal Smooth", "NodeSocketInt", 20)): + socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind) + if default is not None: + socket.default_value = default + group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + source = group.nodes.new("NodeGroupInput") + result = build_volume_fields(group, source.outputs["Geometry"]) + result = build_volume_rounding(group, result, (5000, 0)) + group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"]) + obj.modifiers.new("Round", "NODES").node_group = group + bpy.context.view_layer.update() + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + result = evaluated.to_mesh() + try: + def values(name): + return np.array([v.value for v in result.attributes["o_balloon_surface_" + name].data]) + center = values("center") + u = balloon / balloon.max() + weight = amount * (1 - u**3 * (u * (6*u - 15) + 10)) + delta = (center + balloon - depth) * weight + original = np.maximum(depth - center + balloon, 0.05 * balloon) + np.testing.assert_allclose(values("edge_weight"), weight, atol=1e-6) + np.testing.assert_allclose(values("thickness"), original + delta + balloon * weight, atol=1e-6) + np.testing.assert_allclose([v.co.z for v in result.vertices], depth + delta, atol=1e-6) + normals = np.array([v.vector[:] for v in result.attributes["o_balloon_surface_normal"].data]) + np.testing.assert_allclose(np.linalg.norm(normals, axis=1), 1, atol=1e-6) + assert np.max(abs(normals[:, :2])) > 0.1 + finally: + evaluated.to_mesh_clear() + + +def test_smooth_back_rejects_front_noise_and_preserves_center_depth(): + bpy.ops.wm.read_factory_settings(use_empty=True) + n = 41 + points = np.array([(x, y) for y in np.linspace(-1, 1, n) for x in np.linspace(-1, 1, n)]) + profile = np.sqrt(np.maximum(0, np.cos(points[:, 0]*np.pi/2)*np.cos(points[:, 1]*np.pi/2))) * 0.6 + boundary = np.max(abs(points), axis=1) > 0.999 + profile[boundary] = 0 + center = 0.3*points[:, 0] + 0.6*points[:, 1] + noise = 0.08*np.sin(points[:, 0]*23)*np.sin(points[:, 1]*19) + noise[boundary] = 0 + faces = [(y*n+x, y*n+x+1, (y+1)*n+x+1) for y in range(n-1) for x in range(n-1)] + faces += [(y*n+x, (y+1)*n+x+1, (y+1)*n+x) for y in range(n-1) for x in range(n-1)] + mesh = bpy.data.meshes.new("Noisy front") + mesh.from_pydata(np.column_stack((points, center + profile + noise)), [], faces) + mesh.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile.astype(np.float32)) + obj = bpy.data.objects.new("Noisy front", mesh) + bpy.context.collection.objects.link(obj) + group = bpy.data.node_groups.new("Volume field test", "GeometryNodeTree") + for name, kind in (("Geometry", "NodeSocketGeometry"), ("Thickness", "NodeSocketFloat")): + socket = group.interface.new_socket(name=name, in_out="INPUT", socket_type=kind) + if name == "Thickness": + socket.default_value = 1 + group.interface.new_socket(name="Geometry", in_out="OUTPUT", socket_type="NodeSocketGeometry") + source = group.nodes.new("NodeGroupInput") + result = build_volume_fields(group, source.outputs["Geometry"]) + group.links.new(result, group.nodes.new("NodeGroupOutput").inputs["Geometry"]) + obj.modifiers.new("Volume", "NODES").node_group = group + bpy.context.view_layer.update() + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + result = evaluated.to_mesh() + try: + thickness = np.array([item.value for item in result.attributes["o_balloon_surface_thickness"].data]) + rear = center + profile + noise - thickness + error = rear - (center - profile) + assert max(abs(error)) < 1e-5 + assert np.sqrt(np.mean(error**2)) < np.sqrt(np.mean(noise**2))*0.01 + assert max(abs(thickness[boundary])) == 0 + finally: + evaluated.to_mesh_clear() + + +def test_depth_cutout_normal_map_only_affects_front_faces(): + from test_depth_surface_nodes import surface + obj, set_value = surface() + set_value("Thickness", 0.3) + bpy.context.view_layer.update() + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = evaluated.to_mesh() + try: + attribute = mesh.attributes["o_normal_scale"] + assert attribute.domain == "FACE" + weights = [item.value for item in attribute.data] + assert weights.count(1) == len(obj.data.polygons) + assert weights.count(0) == len(mesh.polygons) - len(obj.data.polygons) + finally: + evaluated.to_mesh_clear() + + +@pytest.mark.parametrize("structure", [0, 1, 4]) +@pytest.mark.parametrize("thickness", [0.0, 0.02, 0.1]) +def test_uniform_front_inflation_scales_with_distance(thickness, structure): + from test_depth_surface_nodes import surface, evaluated, assert_closed + obj, set_value = surface(step=0, resolution=10) + xy = np.array([v.co[:2] for v in obj.data.vertices]) + profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) + profile[abs(profile) < 1e-7] = 0 + obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) + group = obj.modifiers[0].node_group + store = next(n for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_structure") + for link in list(store.inputs["Value"].links): + group.links.remove(link) + store.inputs["Value"].default_value = structure + set_value("Thickness", thickness) + set_value("Front Inflation", 0.0) + baseline, faces = evaluated(obj) + set_value("Front Inflation", 1.0) + inflated, inflated_faces = evaluated(obj) + assert faces == inflated_faces + delta = np.linalg.norm(inflated - baseline, axis=1) + front = np.isclose(baseline[:, 2], baseline[:, 2].max(), atol=1e-6) + np.testing.assert_allclose(delta[front], thickness, atol=2e-6) + np.testing.assert_allclose(delta[~front], 0, atol=2e-6) + if thickness: + assert_closed(inflated_faces) + else: + np.testing.assert_array_equal(inflated, baseline) + + +def test_normal_smooth_controls_front_and_rear_directions(): + from test_depth_surface_nodes import surface, evaluated + obj, set_value = surface(step=1, resolution=10) + set_value("Front Inflation", 1.0) + xy = np.array([v.co[:2] for v in obj.data.vertices]) + profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) + profile[abs(profile) < 1e-7] = 0 + obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) + group = obj.modifiers[0].node_group + set_value("Mode", 0) + set_modifier_input(obj.modifiers[0], modifier_input_identifier(group, "Thickness", subtype="NONE"), 1.0) + masks = [] + coordinates = [] + for iterations in (0, 50): + set_value("Normal Smooth", iterations) + points, faces = evaluated(obj) + ev = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + mesh = ev.to_mesh() + mask = [v.value for v in mesh.attributes["o_normal_scale"].data] + front = {v for face, weight in zip(faces, mask) if weight > 0.5 for v in face} + rear = set(range(len(points))) - front + ev.to_mesh_clear() + coordinates.append(points) + masks.append((front, rear)) + assert masks[0] == masks[1] + for ids in masks[0]: + assert np.max(abs(coordinates[1][list(ids)] - coordinates[0][list(ids)])) > 1e-5 + + +def test_balloon_suppression_is_stronger_without_a_startup_threshold(): + from test_depth_surface_nodes import surface, evaluated + obj, set_value = surface(step=0, resolution=10) + xy = np.array([v.co[:2] for v in obj.data.vertices]) + profile = 0.3 * np.sin(np.pi * xy[:, 0] / 2) * np.sin(np.pi * xy[:, 1]) + profile[abs(profile) < 1e-7] = 0 + obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set("value", profile) + group = obj.modifiers[0].node_group + store = next(n for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_structure") + for link in list(store.inputs["Value"].links): + group.links.remove(link) + store.inputs["Value"].default_value = 1 + set_value("Mode", 0) + set_value("Edge Round", 0) + thickness_id = modifier_input_identifier(group, "Thickness", subtype="NONE") + previous = 0 + for thickness in (0.001, 0.01, 0.1, 0.5, 1): + set_modifier_input(obj.modifiers[0], thickness_id, thickness) + set_value("Front Inflation", 0) + baseline, _ = evaluated(obj) + set_value("Front Inflation", 1) + inflated, _ = evaluated(obj) + growth = np.linalg.norm(inflated - baseline, axis=1).max() / profile.max() + assert growth > previous + previous = growth + assert growth == pytest.approx(0.323, abs=0.001) diff --git a/tests/test_depth_surface_nodes.py b/tests/test_depth_surface_nodes.py index b60fbb2..04473c5 100644 --- a/tests/test_depth_surface_nodes.py +++ b/tests/test_depth_surface_nodes.py @@ -12,16 +12,12 @@ import pytest ROOT = Path(__file__).parents[1] sys.path.insert(0, str(ROOT / "src")) sys.path.insert(0, str(ROOT / "tools" / "node_assets")) -from build_cutout import build_node_group, build_cutout_modifier_group +from build_cutout_modifiers import build_cutout_group def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False): bpy.ops.wm.read_factory_settings(use_empty=True) - groups = { - shape: build_node_group(shape) - for shape in ("SURFACE", "BALLOON", "DEPTH_BALLOON", "DEPTH_SURFACE") - } - group = build_cutout_modifier_group(groups) if wrapper else groups["DEPTH_SURFACE"] + group = build_cutout_group("DEPTH_SOLID") # Two columns with a complete depth jump along their common boundary. points = [ (x * size / resolution, y * size / resolution, 0) @@ -75,17 +71,47 @@ def surface(*, step=6.0, size=1.0, resolution=2, wrapper=False): ("Reference Depth", size), ("Depth Scale", 1.0), ("Thickness", 0.0), - ("Cleanup", 0.0), - ("Split", 0.0), + ("Front Inflation", 0.0), + ("Split Threshold", 0.0), + ("Mode", 1), + ("Smooth", 0), ): set_value(name, value) if wrapper: - set_value("Shape", 3) + set_value("Cleanup Threshold", 0.0) set_value("Inward Axis", 0) - set_value("Smooth Iterations", 0) + set_value("Smooth", 0) return obj, set_value +@pytest.mark.parametrize("mode", [0, 1]) +@pytest.mark.parametrize("split", [0.0, 0.5]) +def test_shell_walls_directly_connect_caps(mode, split): + from anyimage.common.object import modifier_input_identifier, set_modifier_input + + obj, set_value = surface(resolution=4) + obj.data.attributes.new("o_balloon", "FLOAT", "POINT").data.foreach_set( + "value", np.full(len(obj.data.vertices), 0.3) + ) + set_value("Split Threshold", split) + front, faces = evaluated(obj) + edges = Counter(tuple(sorted((a, b))) for face in faces for a, b in zip(face, (*face[1:], face[0]))) + boundary_count = sum(count == 1 for count in edges.values()) + set_value("Mode", mode) + modifier = obj.modifiers[0] + thickness = modifier_input_identifier(modifier.node_group, "Thickness", subtype="NONE" if mode == 0 else "DISTANCE") + for amount in (0.01, 2.0): + set_modifier_input(modifier, thickness, amount) + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + points, shell_faces = evaluated(obj) + assert len(points) == 2 * len(front) + assert len(shell_faces) == 2 * len(faces) + boundary_count + assert all(len(face) == 4 for face in shell_faces) + assert np.isfinite(points).all() + assert_closed(shell_faces) + + def evaluated(obj): result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) mesh = result.to_mesh() @@ -108,12 +134,16 @@ def assert_closed(faces): @pytest.mark.parametrize("split", [0.0, 0.5]) def test_surface_caps_and_segmented_walls_are_connected(thickness, split): obj, set_value = surface() - set_value("Split", split) + set_value("Split Threshold", split) set_value("Thickness", thickness) coords, faces = evaluated(obj) assert np.isfinite(coords).all() assert_closed(faces) - assert coords[:, 2].min() == pytest.approx(-6 - thickness, abs=1e-5) + set_value("Thickness", 0.0) + front, _ = evaluated(obj) + from scipy.spatial import cKDTree + assert cKDTree(front).query(coords)[0].max() <= thickness + 1e-5 + assert cKDTree(coords).query(front)[0].max() < 1e-5 @pytest.mark.parametrize("size", [0.01, 1.0, 100.0]) @@ -122,7 +152,7 @@ def test_split_mapping_and_uniform_scale(size): # Face centers are size/2 apart: gradient is 4. counts = [] for split in (0.0, 0.25, 0.5, 1.0): - set_value("Split", split) + set_value("Split Threshold", split) coords, faces = evaluated(obj) counts.append(len(coords)) assert len(faces) == 8 @@ -130,32 +160,55 @@ def test_split_mapping_and_uniform_scale(size): assert counts[3] == 18 set_value("Depth Scale", 0.0) coords, faces = evaluated(obj) - assert len(coords) == 15 and np.abs(coords[:, 2]).max() < 1e-6 + assert len(coords) == 18 and np.abs(coords[:, 2]).max() < 1e-6 def test_midpoint_selects_gradient_six_and_preserves_faces(): obj, set_value = surface(step=3.0) - set_value("Split", 0.5) + set_value("Split Threshold", 0.5) coords, faces = evaluated(obj) assert len(coords) == 18 and len(faces) == 8 assert set(np.round(coords[:, 2], 4)) == {0.0, -3.0} -def test_cleanup_removes_new_islands_before_thickness(): - obj, set_value = surface() - set_value("Cleanup", 1.1) +def test_cleanup_keeps_split_parts_of_a_large_source_island(): + obj, set_value = surface(wrapper=True) + set_value("Cleanup Threshold", 110.0) + assert len(evaluated(obj)[1]) == 8 + set_value("Split Threshold", 0.5) assert len(evaluated(obj)[1]) == 8 - set_value("Split", 0.5) - assert len(evaluated(obj)[1]) == 0 set_value("Thickness", 0.2) + assert_closed(evaluated(obj)[1]) + set_value("Cleanup Threshold", 210.0) assert len(evaluated(obj)[1]) == 0 +@pytest.mark.parametrize("depth_scale", [0.0, 0.25, 1.0, 2.0, 4.0]) +def test_split_topology_uses_depth_before_display_scaling(depth_scale): + obj, set_value = surface(step=3.0) + set_value("Split Threshold", 0.5) + original, original_faces = evaluated(obj) + set_value("Depth Scale", depth_scale) + coords, faces = evaluated(obj) + assert len(coords) == len(original) == 18 + assert faces == original_faces + np.testing.assert_allclose(coords[:, 2], original[:, 2] * depth_scale, atol=1e-6) + + +@pytest.mark.parametrize("shape", ["SOLID", "DEPTH_SOLID", "DEPTH_BALLOON"]) +def test_each_asset_cleans_the_original_geometry_before_shaping(shape): + bpy.ops.wm.read_factory_settings(use_empty=True) + group = build_cutout_group(shape) + links = [link for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"] + assert len(links) == 1 + assert links[0].to_node.bl_idname == "GeometryNodeDeleteGeometry" + + def test_full_wrapper_smooth_keeps_closed_shell(): obj, set_value = surface(wrapper=True) - set_value("Split", 0.5) + set_value("Split Threshold", 0.5) set_value("Thickness", 0.2) - set_value("Smooth Iterations", 2) + set_value("Smooth", 2) coords, faces = evaluated(obj) assert np.isfinite(coords).all() assert_closed(faces) @@ -206,7 +259,7 @@ def test_coincident_independent_sheets_do_not_weld_together(): def test_planar_depth_has_no_split_at_any_density(resolution): obj, set_value = surface(step=0.0, resolution=resolution) original, original_faces = evaluated(obj) - set_value("Split", 1.0) + set_value("Split Threshold", 1.0) coords, faces = evaluated(obj) assert len(coords) == len(obj.data.vertices) assert len(faces) == len(obj.data.polygons) @@ -216,7 +269,7 @@ def test_planar_depth_has_no_split_at_any_density(resolution): def test_small_positive_split_preserves_unsplit_projection(): obj, set_value = surface(step=2.0) original, original_faces = evaluated(obj) - set_value("Split", 0.01) + set_value("Split Threshold", 0.01) coords, faces = evaluated(obj) np.testing.assert_allclose(coords, original, atol=1e-6) assert faces == original_faces @@ -225,7 +278,7 @@ def test_small_positive_split_preserves_unsplit_projection(): def test_cut_vertices_preserve_camera_rays_and_other_vertices(): obj, set_value = surface(step=3.0) original, original_faces = evaluated(obj) - set_value("Split", 0.5) + set_value("Split Threshold", 0.5) coords, faces = evaluated(obj) for source_face, result_face in zip(original_faces, faces): for source_index, result_index in zip(source_face, result_face): @@ -240,11 +293,15 @@ def test_cut_vertices_preserve_camera_rays_and_other_vertices(): def test_stored_point_positions_prevent_depth_resampling_on_side_walls(): obj, set_value = surface(step=3.0) - set_value("Split", 0.5) + set_value("Split Threshold", 0.5) set_value("Thickness", 0.1) coords, faces = evaluated(obj) assert_closed(faces) - assert set(np.round(coords[:, 2], 4)) == {0.0, -0.1, -3.0, -3.1} + set_value("Thickness", 0.0) + front, _ = evaluated(obj) + from scipy.spatial import cKDTree + assert cKDTree(front).query(coords)[0].max() <= 0.1 + 1e-5 + assert cKDTree(coords).query(front)[0].max() < 1e-5 @pytest.mark.parametrize("thickness", [0.0, 0.2]) @@ -255,9 +312,9 @@ def test_temporary_attributes_are_removed_and_user_attributes_survive(thickness, attribute = obj.data.attributes.new("o_user_weight", "FLOAT", "POINT") for value in attribute.data: value.value = 0.75 - set_value("Split", split) + set_value("Split Threshold", split) set_value("Thickness", thickness) - set_value("Smooth Iterations", 2) + set_value("Smooth", 2) result = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) mesh = result.to_mesh() try: @@ -282,7 +339,7 @@ def test_zero_center_distance_does_not_split_or_produce_nan(): for li in face.loop_indices: uv.data[li].uv = (0.25 if face.index == 0 else 0.75, 0.5) obj.data = mesh - set_value("Split", 1.0) + set_value("Split Threshold", 1.0) coords, faces = evaluated(obj) assert len(coords) == 4 and len(faces) == 2 assert np.isfinite(coords).all() @@ -300,7 +357,7 @@ def test_triangle_corner_weights_do_not_open_sloped_side_walls(): pixels[..., 2] += np.arange(32)[:, None] * 0.02 image.pixels.foreach_set(pixels.ravel()) image.update() - set_value("Split", 0.5) + set_value("Split Threshold", 0.5) set_value("Thickness", 0.8) coords, faces = evaluated(obj) assert_closed(faces) @@ -315,7 +372,7 @@ def test_linear_depth_slope_has_density_independent_selection(resolution): pixels[..., 2] = 1 + 4 * pixels[..., 0] image.pixels.foreach_set(pixels.ravel()) image.update() - set_value("Split", 0.5) + set_value("Split Threshold", 0.5) assert len(evaluated(obj)[0]) == len(obj.data.vertices) - set_value("Split", 1.0) + set_value("Split Threshold", 1.0) assert len(evaluated(obj)[0]) > len(obj.data.vertices) diff --git a/tests/test_model_catalog.py b/tests/test_model_catalog.py index 9a4d6eb..f1ca73d 100644 --- a/tests/test_model_catalog.py +++ b/tests/test_model_catalog.py @@ -81,7 +81,7 @@ class ModelCatalogTest(unittest.TestCase): "SAFMN_REAL_X4": ("safmn-real-x4.onnx", 22657477, "8f13867b"), "REALESRGAN_X4PLUS": ("realesrgan-x4plus.onnx", 67051618, "4ed6a45a"), } - self.assertEqual(set(self.models.UPSCALE_MODELS), set(expected)) + self.assertEqual(set(self.models.UPSCALE_MODELS), set(expected) | {"PISA_SR"}) for key, (filename, size, checksum_prefix) in expected.items(): with self.subTest(model=key): model = self.models.get_downloadable_model(key) diff --git a/tests/test_model_onnx_runtime.py b/tests/test_model_onnx_runtime.py index 15db497..200b135 100644 --- a/tests/test_model_onnx_runtime.py +++ b/tests/test_model_onnx_runtime.py @@ -1,7 +1,7 @@ import sys import unittest from pathlib import Path -from types import ModuleType +from types import ModuleType, SimpleNamespace from unittest.mock import patch @@ -31,6 +31,20 @@ class FakeSession: class OnnxRuntimeTest(unittest.TestCase): + def test_directml_disables_parallel_execution_and_memory_pattern(self): + options = SimpleNamespace(enable_mem_pattern=True, execution_mode="parallel") + runtime = ModuleType("onnxruntime") + runtime.get_available_providers = lambda: ["DmlExecutionProvider"] + runtime.SessionOptions = lambda: options + runtime.ExecutionMode = SimpleNamespace(ORT_SEQUENTIAL="sequential") + calls = [] + runtime.InferenceSession = lambda path, **kwargs: calls.append(kwargs) + with patch.dict(sys.modules, {"onnxruntime": runtime}): + onnx_runtime.create_session("model.onnx", "directml") + self.assertFalse(options.enable_mem_pattern) + self.assertEqual(options.execution_mode, "sequential") + self.assertIs(calls[0]["sess_options"], options) + def create_runtime(self, available): calls = [] runtime = ModuleType("onnxruntime") diff --git a/tests/test_model_pisa_sr.py b/tests/test_model_pisa_sr.py new file mode 100644 index 0000000..b9c835c --- /dev/null +++ b/tests/test_model_pisa_sr.py @@ -0,0 +1,144 @@ +import hashlib +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import numpy as np +import pytest +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src/anyimage")) +from server.models import onnx_pisa_sr as pisa +from server.models import upscale +from server.model_manager import ModelManager, ModelSessionCache +from server import model_catalog + + +class FakePisa: + def __init__(self): + self.noise = np.zeros((1, 4, 64, 64), dtype=np.float16) + self.stages = [] + self.closed = False + self.decoder_inputs = [] + + def activate(self, component): + self.component = component + self.stages.append(component) + + def run(self, tensor, final=False): + if self.component == "encoder": + return np.zeros((1,8,64,64), dtype=np.float16) + if self.component == "unet-pixel": + return np.full_like(tensor, .1) + if self.component == "unet-full": + return np.full_like(tensor, .2) + self.decoder_inputs.append(tensor.copy()) + return np.zeros((1,3,512,512), dtype=np.float16) + + def close(self): + self.closed = True + + +@pytest.mark.parametrize("size", [(1,1), (7,5), (256,256), (513,257)]) +def test_preset_output_size_and_prediction_combination(size): + model = FakePisa() + output = pisa.infer(model, Image.new("RGB", size, (32,64,96))) + assert output.size == (size[0]*2, size[1]*2) + assert model.stages == ["encoder", "unet-pixel", "unet-full", "decoder"] + for latent in model.decoder_inputs: + np.testing.assert_allclose(latent, -.17, atol=.0002) + assert np.isfinite(np.asarray(output)).all() + + +def test_cancel_cleans_intermediate_files_and_session(tmp_path): + model = FakePisa() + original = pisa.TemporaryDirectory + def temporary(**kwargs): + return original(dir=tmp_path, **kwargs) + checks = 0 + def cancel(): + nonlocal checks + checks += 1 + return checks == 4 + with patch.object(pisa, "TemporaryDirectory", side_effect=temporary): + with pytest.raises(RuntimeError, match="cancelled"): + pisa.infer(model, Image.new("RGB", (256,256)), cancel) + assert model.closed + assert not list(tmp_path.iterdir()) + + +def test_stage_releases_previous_session_before_loading(tmp_path): + np.save(tmp_path / "noise.npy", np.zeros((1,4,64,64), dtype=np.float16)) + model = pisa.PisaSession(tmp_path, "cpu") + loaded = [] + def create(path, device): + assert model.session is None + loaded.append(path.stem) + return SimpleNamespace(get_providers=lambda: ["CPUExecutionProvider"], disable_fallback=lambda: None) + with patch.object(pisa, "create_session", side_effect=create): + model.activate("unet-pixel") + model.activate("unet-full") + assert loaded == ["unet-pixel", "unet-full"] + + +def test_resource_error_closes_pipeline(): + model = FakePisa() + with patch.object(model, "run", side_effect=RuntimeError("out of memory")): + with pytest.raises(pisa.UpscaleResourceError): + pisa.infer(model, Image.new("RGB", (256,256))) + assert model.closed + + +def test_pisa_readiness_checks_every_asset_and_corruption(tmp_path): + content = b"verified" + digest = hashlib.sha256(content).hexdigest() + files = tuple((name, len(content), digest) for name in ("encoder.onnx", "noise.npy")) + spec = SimpleNamespace(directory_name="pisa-sr", r2_files=files) + directory = tmp_path / "pisa-sr" + directory.mkdir() + manager = ModelManager(tmp_path) + with patch.object(model_catalog, "get_downloadable_model", return_value=spec): + assert not manager.ready("PISA_SR") + for name, _, _ in files: + (directory / name).write_bytes(content) + assert manager.ready("PISA_SR") + (directory / "noise.npy").write_bytes(b"damaged!") + assert not manager.ready("PISA_SR") + + +@pytest.mark.parametrize("key", ["REALESRGAN_GENERAL_WDN_X4V3", "SAFMN_REAL_X4", "REALESRGAN_X4PLUS"]) +def test_existing_models_resize_full_prediction_then_restore_alpha(tmp_path, key): + rng = np.random.default_rng(1) + source = Image.fromarray(rng.integers(0,256,(5,7,4),dtype=np.uint8)) + path = tmp_path / "source.png" + source.save(path) + prediction = Image.fromarray(rng.integers(0,256,(20,28,3),dtype=np.uint8)) + manager = SimpleNamespace(directory=lambda _: tmp_path, get_upscale=lambda *args: object()) + with patch("server.models.onnx_upscale.infer", return_value=prediction): + output = upscale.infer_one(manager, {"model":key,"device":"cpu"}, path, preserve_alpha=True) + np.testing.assert_array_equal(output.convert("RGB"), prediction.resize((14,10), Image.Resampling.LANCZOS)) + np.testing.assert_array_equal(output.getchannel("A"), source.getchannel("A").resize((14,10), Image.Resampling.LANCZOS)) + + +def test_pisa_cache_reuse_and_clear(tmp_path): + cache = ModelSessionCache() + with patch.object(pisa, "PisaSession", return_value=object()) as create: + first = cache.get_upscale(tmp_path / "pisa-sr", "cpu") + assert cache.get_upscale(tmp_path / "pisa-sr", "cpu") is first + assert create.call_count == 1 + cache.close() + assert cache.upscale_model is None + + +def test_coreml_returns_explicit_support_reason(tmp_path): + with pytest.raises(RuntimeError, match="select CPU"): + pisa.PisaSession(tmp_path, "coreml") + + +def test_clear_closes_pisa_components_even_with_external_reference(): + cache = ModelSessionCache() + model = FakePisa() + cache.upscale_model = model + cache.close() + assert model.closed diff --git a/tests/test_node_assets.py b/tests/test_node_assets.py index 0a9d94b..ee3427e 100644 --- a/tests/test_node_assets.py +++ b/tests/test_node_assets.py @@ -24,7 +24,7 @@ class NodeAssetsTest(unittest.TestCase): for group in target.node_groups if group.bl_idname == "GeometryNodeTree" ] - self.assertEqual(len(groups), 7) + self.assertEqual(len(groups), 5) for group in groups: validate_node_layout(group) diff --git a/tests/test_server_jobs.py b/tests/test_server_jobs.py index 3e692f6..a9f9b11 100644 --- a/tests/test_server_jobs.py +++ b/tests/test_server_jobs.py @@ -1060,7 +1060,7 @@ class ServerJobsTest(unittest.TestCase): with Image.open(context.directory / "upscale.0001.png") as result: self.assertEqual(result.mode, "RGBA") - self.assertEqual(result.size, (20, 12)) + self.assertEqual(result.size, (10, 6)) alpha = np.asarray(result.getchannel("A")) expected_alpha = np.asarray( Image.fromarray(pixels[:, :, 3], mode="L").resize( diff --git a/tests/test_upscale_undo.py b/tests/test_upscale_undo.py new file mode 100644 index 0000000..02acf30 --- /dev/null +++ b/tests/test_upscale_undo.py @@ -0,0 +1,50 @@ +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import bpy +import numpy as np +from PIL import Image + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) +from anyimage.operators.upscale import UpscaleImage +from anyimage.common.image import image_pixels + + +def test_upscale_result_replacement_undo_restores_source(tmp_path): + source = bpy.data.images.new("Upscale undo source", width=8, height=8, alpha=True) + source.pixels.foreach_set(np.ones(8*8*4, dtype=np.float32)) + source.pack() + owner = bpy.data.objects.new("Upscale undo owner", None) + owner.empty_display_type = "IMAGE" + owner.data = source + bpy.context.scene.collection.objects.link(owner) + owner_name, source_name = owner.name, source.name + before = image_pixels(source) + Image.new("RGBA", (16,16), (32,64,96,128)).save(tmp_path / "upscale.0001.png") + + def execute(operator, context): + operator.source_object_name = owner_name + operator.source_image_name = source_name + operator.model = "PISA_SR" + operator.response(context, SimpleNamespace(directory=tmp_path)) + return {"FINISHED"} + + bpy.context.preferences.edit.use_global_undo = True + with patch.object(UpscaleImage, "execute", execute), patch.object( + UpscaleImage, "poll", classmethod(lambda cls, context: True) + ): + bpy.utils.register_class(UpscaleImage) + try: + bpy.ops.ed.undo_push(message="Before Upscale") + assert bpy.ops.anyimage.upscale_image("EXEC_DEFAULT", True) == {"FINISHED"} + assert tuple(bpy.data.objects[owner_name].data.size) == (16,16) + assert bpy.ops.ed.undo() == {"FINISHED"} + restored = bpy.data.objects[owner_name].data + assert tuple(restored.size) == (8,8) + np.testing.assert_array_equal(image_pixels(restored), before) + finally: + bpy.utils.unregister_class(UpscaleImage) + bpy.data.objects.remove(bpy.data.objects[owner_name], do_unlink=True) + bpy.data.images.remove(bpy.data.images[source_name]) diff --git a/tools/model_licenses/pisa-sr/LICENSE-APACHE.txt b/tools/model_licenses/pisa-sr/LICENSE-APACHE.txt new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/tools/model_licenses/pisa-sr/LICENSE-APACHE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/tools/model_licenses/pisa-sr/LICENSE-MODEL.txt b/tools/model_licenses/pisa-sr/LICENSE-MODEL.txt new file mode 100644 index 0000000..9684533 --- /dev/null +++ b/tools/model_licenses/pisa-sr/LICENSE-MODEL.txt @@ -0,0 +1,84 @@ +Copyright (c) 2022 Stability AI and contributors + +CreativeML Open RAIL++-M License +dated November 24, 2022 + +Section I: PREAMBLE + +Multimodal generative models are being widely adopted and used, and have the potential to transform the way artists, among other individuals, conceive and benefit from AI or ML technologies as a tool for content creation. + +Notwithstanding the current and potential benefits that these artifacts can bring to society at large, there are also concerns about potential misuses of them, either due to their technical limitations or ethical considerations. + +In short, this license strives for both the open and responsible downstream use of the accompanying model. When it comes to the open character, we took inspiration from open source permissive licenses regarding the grant of IP rights. Referring to the downstream responsible use, we added use-based restrictions not permitting the use of the Model in very specific scenarios, in order for the licensor to be able to enforce the license in case potential misuses of the Model may occur. At the same time, we strive to promote open and responsible research on generative models for art and content generation. + +Even though downstream derivative versions of the model could be released under different licensing terms, the latter will always have to include - at minimum - the same use-based restrictions as the ones in the original license (this license). We believe in the intersection between open and responsible AI development; thus, this License aims to strike a balance between both in order to enable responsible open-science in the field of AI. + +This License governs the use of the model (and its derivatives) and is informed by the model card associated with the model. + +NOW THEREFORE, You and Licensor agree as follows: + +1. Definitions + +- "License" means the terms and conditions for use, reproduction, and Distribution as defined in this document. +- "Data" means a collection of information and/or content extracted from the dataset used with the Model, including to train, pretrain, or otherwise evaluate the Model. The Data is not licensed under this License. +- "Output" means the results of operating a Model as embodied in informational content resulting therefrom. +- "Model" means any accompanying machine-learning based assemblies (including checkpoints), consisting of learnt weights, parameters (including optimizer states), corresponding to the model architecture as embodied in the Complementary Material, that have been trained or tuned, in whole or in part on the Data, using the Complementary Material. +- "Derivatives of the Model" means all modifications to the Model, works based on the Model, or any other model which is created or initialized by transfer of patterns of the weights, parameters, activations or output of the Model, to the other model, in order to cause the other model to perform similarly to the Model, including - but not limited to - distillation methods entailing the use of intermediate data representations or methods based on the generation of synthetic data by the Model for training the other model. +- "Complementary Material" means the accompanying source code and scripts used to define, run, load, benchmark or evaluate the Model, and used to prepare data for training or evaluation, if any. This includes any accompanying documentation, tutorials, examples, etc, if any. +- "Distribution" means any transmission, reproduction, publication or other sharing of the Model or Derivatives of the Model to a third party, including providing the Model as a hosted service made available by electronic or other remote means - e.g. API-based or web access. +- "Licensor" means the copyright owner or entity authorized by the copyright owner that is granting the License, including the persons or entities that may have rights in the Model and/or distributing the Model. +- "You" (or "Your") means an individual or Legal Entity exercising permissions granted by this License and/or making use of the Model for whichever purpose and in any field of use, including usage of the Model in an end-use application - e.g. chatbot, translator, image generator. +- "Third Parties" means individuals or legal entities that are not under common control with Licensor or You. +- "Contribution" means any work of authorship, including the original version of the Model and any modifications or additions to that Model or Derivatives of the Model thereof, that is intentionally submitted to Licensor for inclusion in the Model by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Model, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." +- "Contributor" means Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Model. + +Section II: INTELLECTUAL PROPERTY RIGHTS + +Both copyright and patent grants apply to the Model, Derivatives of the Model and Complementary Material. The Model and Derivatives of the Model are subject to additional terms as described in Section III. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare, publicly display, publicly perform, sublicense, and distribute the Complementary Material, the Model, and Derivatives of the Model. +3. Grant of Patent License. Subject to the terms and conditions of this License and where and as applicable, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this paragraph) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Model and the Complementary Material, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Model to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Model and/or Complementary Material or a Contribution incorporated within the Model and/or Complementary Material constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for the Model and/or Work shall terminate as of the date such litigation is asserted or filed. + +Section III: CONDITIONS OF USAGE, DISTRIBUTION AND REDISTRIBUTION + +4. Distribution and Redistribution. You may host for Third Party remote access purposes (e.g. software-as-a-service), reproduce and distribute copies of the Model or Derivatives of the Model thereof in any medium, with or without modifications, provided that You meet the following conditions: +Use-based restrictions as referenced in paragraph 5 MUST be included as an enforceable provision by You in any type of legal agreement (e.g. a license) governing the use and/or distribution of the Model or Derivatives of the Model, and You shall give notice to subsequent users You Distribute to, that the Model or Derivatives of the Model are subject to paragraph 5. This provision does not apply to the use of Complementary Material. +You must give any Third Party recipients of the Model or Derivatives of the Model a copy of this License; +You must cause any modified files to carry prominent notices stating that You changed the files; +You must retain all copyright, patent, trademark, and attribution notices excluding those notices that do not pertain to any part of the Model, Derivatives of the Model. +You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions - respecting paragraph 4.a. - for use, reproduction, or Distribution of Your modifications, or for any such Derivatives of the Model as a whole, provided Your use, reproduction, and Distribution of the Model otherwise complies with the conditions stated in this License. +5. Use-based restrictions. The restrictions set forth in Attachment A are considered Use-based restrictions. Therefore You cannot use the Model and the Derivatives of the Model for the specified restricted uses. You may use the Model subject to this License, including only for lawful purposes and in accordance with the License. Use may include creating any content with, finetuning, updating, running, training, evaluating and/or reparametrizing the Model. You shall require all of Your users who use the Model or a Derivative of the Model to comply with the terms of this paragraph (paragraph 5). +6. The Output You Generate. Except as set forth herein, Licensor claims no rights in the Output You generate using the Model. You are accountable for the Output you generate and its subsequent uses. No use of the output can contravene any provision as stated in the License. + +Section IV: OTHER PROVISIONS + +7. Updates and Runtime Restrictions. To the maximum extent permitted by law, Licensor reserves the right to restrict (remotely or otherwise) usage of the Model in violation of this License. +8. Trademarks and related. Nothing in this License permits You to make use of Licensors’ trademarks, trade names, logos or to otherwise suggest endorsement or misrepresent the relationship between the parties; and any rights not expressly granted herein are reserved by the Licensors. +9. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Model and the Complementary Material (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Model, Derivatives of the Model, and the Complementary Material and assume any risks associated with Your exercise of permissions under this License. +10. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Model and the Complementary Material (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. +11. Accepting Warranty or Additional Liability. While redistributing the Model, Derivatives of the Model and the Complementary Material thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. +12. If any provision of this License is held to be invalid, illegal or unenforceable, the remaining provisions shall be unaffected thereby and remain valid as if such provision had not been set forth herein. + +END OF TERMS AND CONDITIONS + + + + +Attachment A + +Use Restrictions + +You agree not to use the Model or Derivatives of the Model: + +- In any way that violates any applicable national, federal, state, local or international law or regulation; +- For the purpose of exploiting, harming or attempting to exploit or harm minors in any way; +- To generate or disseminate verifiably false information and/or content with the purpose of harming others; +- To generate or disseminate personal identifiable information that can be used to harm an individual; +- To defame, disparage or otherwise harass others; +- For fully automated decision making that adversely impacts an individual’s legal rights or otherwise creates or modifies a binding, enforceable obligation; +- For any use intended to or which has the effect of discriminating against or harming individuals or groups based on online or offline social behavior or known or predicted personal or personality characteristics; +- To exploit any of the vulnerabilities of a specific group of persons based on their age, social, physical or mental characteristics, in order to materially distort the behavior of a person pertaining to that group in a manner that causes or is likely to cause that person or another person physical or psychological harm; +- For any use intended to or which has the effect of discriminating against individuals or groups based on legally protected characteristics or categories; +- To provide medical advice and medical results interpretation; +- To generate or disseminate information for the purpose to be used for administration of justice, law enforcement, immigration or asylum processes, such as predicting an individual will commit fraud/crime commitment (e.g. by text profiling, drawing causal relationships between assertions made in documents, indiscriminate and arbitrarily-targeted use). + diff --git a/tools/model_licenses/pisa-sr/NOTICE.txt b/tools/model_licenses/pisa-sr/NOTICE.txt new file mode 100644 index 0000000..397e76b --- /dev/null +++ b/tools/model_licenses/pisa-sr/NOTICE.txt @@ -0,0 +1,21 @@ +PiSA-SR ONNX models for AnyImage + +PiSA-SR: Lingchen Sun and contributors, https://github.com/csslc/PiSA-SR +Source commit: 9c76e12e261d1980148738a0cff41e5e27d18c9f +PiSA-SR is released under Apache License 2.0; see LICENSE-APACHE.txt. + +Stable Diffusion 2.1: Copyright (c) 2022 Stability AI and contributors. +Base weights: sd2-community/stable-diffusion-2-1-base +Revision: 4e63672c03103b6c636b8fb4119ba982469b2955 +Base weights and derivatives are subject to CreativeML Open RAIL++-M, +including paragraph 5 and Attachment A use restrictions. All users must +comply with these terms; see the accompanying LICENSE-MODEL.txt. +License copy: sd2-community/stable-diffusion-2, LICENSE-MODEL. + +AnyImage modifications: merged pixel and semantic LoRA adapters, FP16 +ONNX conversion, fixed empty prompt and timestep, VAE encoder/decoder +separation. Runtime combines pixel/full predictions at semantic strength 0.7. +noise.npy contains a fixed seed 42 latent noise field. + +ONNX architecture derives from Hugging Face Diffusers and PiSA-SR. +Copyright 2023 The HuggingFace Team. All rights reserved. diff --git a/tools/node_assets/build.py b/tools/node_assets/build.py index 2c0a2b1..19ae0f7 100644 --- a/tools/node_assets/build.py +++ b/tools/node_assets/build.py @@ -12,10 +12,9 @@ sys.path.insert( str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), ) -from build_cutout import ( +from build_cutout_modifiers import ( CUTOUT_NODE_GROUP_NAMES, - build_cutout_modifier_group, - build_node_group, + build_cutout_group, ) from build_depth_plane import build_depth_plane_group from build_material_layers import build_material_layer_group @@ -38,11 +37,8 @@ def main(): build_material_layer_group() build_material_layer_group(depth_plane=True) build_shadeless_group() - shape_groups = { - shape: build_node_group(shape) - for shape in CUTOUT_NODE_GROUP_NAMES - } - build_cutout_modifier_group(shape_groups) + for shape in CUTOUT_NODE_GROUP_NAMES: + build_cutout_group(shape) OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False) diff --git a/tools/node_assets/build_cutout_modifiers.py b/tools/node_assets/build_cutout_modifiers.py new file mode 100644 index 0000000..e319d13 --- /dev/null +++ b/tools/node_assets/build_cutout_modifiers.py @@ -0,0 +1,252 @@ +"""Build the three directly editable Cutout modifier assets.""" +import bpy +from types import SimpleNamespace +from mathutils import Matrix + +from anyimage.operators.cutout_tool.shape import CUTOUT_NODE_GROUP_NAMES, CUTOUT_Z_TO_X, NORMAL_SCALE_ATTRIBUTE_NAME +from cutout_shapes import ( + _socket, _float_input, _bool_input, _vector_input, _group_input, _math, + _island_cleanup, _build_cutout, _build_balloon, _build_depth_balloon, + _build_depth_surface, _shade_output, +) +from cutout_shape_layouts import LAYOUTS +from cutout_depth_surface_layout import POSITIONS, INPUT_REGIONS, layout_geometry_references +from cutout_layout import layout_stage, finish_layout +from cutout_volume import build_volume_fields, shift_stage +from cutout_rounding import build_volume_rounding +from cutout_inflation import build_front_fields +from cutout_shell import build_front_normals, build_shell_position +from node_graph import expand_smooth + + +def _menu(group, control, names, kind, location): + selector = next((node for node in group.nodes if node.bl_idname == "GeometryNodeMenuSwitch" and node.inputs["Menu"].is_linked and node.inputs["Menu"].links[0].from_socket.name == control), None) + if selector is None: + selector = group.nodes.new("GeometryNodeMenuSwitch") + selector.data_type = "BOOLEAN" + selector.location = (location[0] - 240, location[1] + 450) + for item, name in zip(selector.enum_items, names): + item.name = name + selector.inputs[names[0]].default_value = False + selector.inputs[names[1]].default_value = True + local = _group_input(group.nodes, (location[0] - 480, location[1] + 450), {control}) + group.links.new(local.outputs[control], selector.inputs["Menu"]) + node = group.nodes.new("GeometryNodeSwitch") + node.input_type = kind + node.location = location + group.links.new(selector.outputs[0], node.inputs["Switch"]) + return SimpleNamespace(inputs={names[0]: node.inputs["False"], names[1]: node.inputs["True"]}, outputs=node.outputs) + + +def _read(group, name, location): + node = group.nodes.new("GeometryNodeInputNamedAttribute") + node.data_type = "FLOAT" + node.inputs["Name"].default_value = name + node.location = location + return node.outputs["Attribute"] + + +def _store(group, geometry, name, value, location, domain="POINT"): + node = group.nodes.new("GeometryNodeStoreNamedAttribute") + node.domain, node.data_type = domain, "FLOAT" + node.location = location + node.inputs["Name"].default_value = name + group.links.new(geometry, node.inputs["Geometry"]) + if isinstance(value, (int, float)): + node.inputs["Value"].default_value = value + else: + group.links.new(value, node.inputs["Value"]) + return node.outputs["Geometry"] + + +def _depth_volume(group, stage): + """Insert the volume field ahead of identity-based shell construction.""" + links = group.links + projection = stage["Set Position"].outputs["Geometry"] + shell_input = stage["Store Named Attribute.005"].inputs["Geometry"] + for node in group.nodes: + if node.location.x >= 4150: + node.location.x += 5100 + projection = build_front_normals(group, projection) + volume = build_volume_fields(group, projection, (4150, 0)) + shared = build_front_fields(group, volume.node.inputs["Geometry"].links[0].from_socket) + links.new(shared, volume.node.inputs["Geometry"]) + uniform_control = _group_input(group.nodes, (8210, -330), {"Uniform Thickness"}) + uniform = _store(group, shared, "o_balloon_surface_thickness", uniform_control.outputs["Uniform Thickness"], (8490, -330)) + volume.node.location.x = uniform.node.location.x + choose = _menu(group, "Mode", ("Balloon", "Uniform"), "GEOMETRY", (9000, 0)) + links.new(volume, choose.inputs["Balloon"]) + links.new(uniform, choose.inputs["Uniform"]) + links.new(choose.outputs[0], shell_input) + thickness = _read(group, "o_balloon_surface_thickness", (12000, -1100)) + links.new(thickness, stage["Math.018"].inputs[1]) + control = _group_input(group.nodes, (13300, 700), {"Thickness", "Uniform Thickness"}) + amount = _menu(group, "Mode", ("Balloon", "Uniform"), "FLOAT", (13600, 500)) + links.new(control.outputs["Thickness"], amount.inputs["Balloon"]) + links.new(control.outputs["Uniform Thickness"], amount.inputs["Uniform"]) + links.new(amount.outputs[0], stage["Math.019"].inputs[0]) + identity_thickness = _read(group, "o_balloon_surface_thickness", (11100, 1500)) + positive = _math(group.nodes, "GREATER_THAN", (11340, 1500)) + positive.inputs[1].default_value = 1e-12 + links.new(identity_thickness, positive.inputs[0]) + layer = _math(group.nodes, "MULTIPLY", (11570, 1200)) + links.new(positive.outputs[0], layer.inputs[0]) + links.new(stage["Separate XYZ.006"].outputs["Z"], layer.inputs[1]) + links.new(layer.outputs[0], stage["Combine XYZ.002"].inputs["Z"]) + for node in group.nodes: + if node.location.x >= 12070: + node.location.x += 400 + for name in ("Flip Faces", "Flip Faces.001"): + source = stage[name].outputs["Mesh"] + outgoing = [link for link in links if link.from_socket == source] + targets = [link.to_socket for link in outgoing] + for link in outgoing: + links.remove(link) + masked = _store(group, source, NORMAL_SCALE_ATTRIBUTE_NAME, 0.0, (12100, stage[name].location.y), domain="FACE") + for target in targets: + links.new(masked, target) + + for node in group.nodes: + if node.location.x >= 8490: + node.location.x += 3200 + volume.node.location.x = 8290 + rounded = build_volume_rounding(group, volume, (8600, 0)) + rounded.node.location.x = uniform.node.location.x + 40 + links.new(rounded, choose.inputs["Balloon"]) + restored = stage["Vector Math.004"].outputs[0] + targets = [link.to_socket for link in restored.links] + uniform_mode = choose.outputs[0].node.inputs["Switch"].links[0].from_socket + oriented = build_shell_position(group, restored, uniform_mode, (14100, -2100)) + for target in targets: + links.new(oriented, target) + + +def build_cutout_group(shape): + name = CUTOUT_NODE_GROUP_NAMES[shape] + existing = bpy.data.node_groups.get(name) + if existing is not None: + return existing + group = bpy.data.node_groups.new(name, "GeometryNodeTree") + group.is_modifier, group.use_fake_user = True, True + group.color_tag = "GEOMETRY" + group["anyimage_asset_revision"] = 11 + depth = shape in {"DEPTH_SOLID", "DEPTH_BALLOON"} + dual = shape != "DEPTH_BALLOON" + _socket(group, "Geometry", "INPUT", "NodeSocketGeometry") + if dual: + _socket(group, "Mode", "INPUT", "NodeSocketMenu") + _float_input(group, "Thickness", 1.0).description = "Balloon profile multiplier; zero returns the sheet" + if dual: + _float_input(group, "Uniform Thickness", 0.0, subtype="DISTANCE").description = "Distance used only by Uniform thickness mode" + if depth: + _float_input(group, "Depth Scale", 1.0) + if shape == "DEPTH_BALLOON": + _bool_input(group, "Double Sided", True) + iterations = _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) + if shape == "DEPTH_SOLID": + _float_input(group, "Split Threshold", 0.0, maximum=1, subtype="FACTOR", parent=options) + if depth: + _float_input(group, "Reference Depth", 1.0, subtype="DISTANCE", parent=options) + if shape == "DEPTH_SOLID": + _float_input(group, "Edge Round", 0.2, maximum=1, subtype="FACTOR", parent=options) + _float_input(group, "Front Inflation", 1.0, maximum=32, parent=options) + normal_iterations = _socket(group, "Normal Smooth", "INPUT", "NodeSocketInt", options) + normal_iterations.default_value = 50 + normal_iterations.min_value, normal_iterations.max_value = 0, 50 + _float_input(group, "Smooth Weight", 1.0, maximum=1, subtype="FACTOR", parent=options) + if shape == "DEPTH_BALLOON": + _vector_input(group, "Depth Direction", (0, 0, 1), subtype="DIRECTION", parent=options) + data = group.interface.new_panel(name="Data", default_closed=True) + _socket(group, "Inward Axis", "INPUT", "NodeSocketMenu", data).hide_in_modifier = True + if depth: + _float_input(group, "Uniform Scale", 1.0, parent=data).hide_in_modifier = True + _socket(group, "Depth Image", "INPUT", "NodeSocketImage", data).hide_in_modifier = True + _socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") + controls = _group_input(group.nodes, (-5000, 0), set()) + previous = set(group.nodes) + if shape == "DEPTH_SOLID": + geometry = _build_depth_surface(group, controls.outputs["Geometry"], controls.outputs["Thickness"], controls.outputs["Depth Scale"], controls.outputs["Split Threshold"], controls.outputs["Uniform Scale"], controls.outputs["Reference Depth"], controls.outputs["Depth Image"]) + stage = layout_stage(group, previous, POSITIONS, INPUT_REGIONS) + _depth_volume(group, stage) + end = 18000 + elif shape == "DEPTH_BALLOON": + geometry = _build_depth_balloon(group, controls.outputs["Geometry"], controls.outputs["Thickness"], controls.outputs["Depth Scale"], controls.outputs["Double Sided"], controls.outputs["Uniform Scale"], controls.outputs["Depth Direction"], controls.outputs["Depth Image"], controls.outputs["Reference Depth"]) + layout_stage(group, previous, *LAYOUTS["DEPTH_BALLOON"]) + end = 3000 + else: + balloon = _build_balloon(group, controls.outputs["Geometry"], controls.outputs["Thickness"]) + layout_stage(group, previous, *LAYOUTS["BALLOON"]) + previous = set(group.nodes) + uniform = _build_cutout(group, controls.outputs["Geometry"], controls.outputs["Uniform Thickness"]) + layout_stage(group, previous, *LAYOUTS["UNIFORM"], offset=(0, -2400)) + balloon.node.location.x = uniform.node.location.x + choose = _menu(group, "Mode", ("Balloon", "Uniform"), "GEOMETRY", (3200, 0)) + group.links.new(balloon, choose.inputs["Balloon"]) + group.links.new(uniform, choose.inputs["Uniform"]) + geometry, end = choose.outputs[0], 3700 + planar_inputs = [link.to_socket for link in group.links if link.from_node.bl_idname == "NodeGroupInput" and link.from_socket.name == "Geometry"] + base = _group_input(group.nodes, (-4800, 0), {"Geometry", "Cleanup Threshold"}) + area = _math(group.nodes, "MULTIPLY", (-4520, 500)) + area.inputs[1].default_value = 0.01 + group.links.new(base.outputs["Cleanup Threshold"], area.inputs[0]) + cleaned = _island_cleanup(group, base.outputs["Geometry"], area.outputs[0], (-4520, 0)) + if shape == "DEPTH_SOLID": + cleaned = _store(group, cleaned, NORMAL_SCALE_ATTRIBUTE_NAME, 1.0, (-3540, 0), domain="FACE") + for target in planar_inputs: + group.links.new(cleaned, target) + previous = set(group.nodes) + geometry = expand_smooth(group, geometry, iterations_name="Smooth", weight_name="Smooth Weight") + shift_stage(group, previous, (end, -550)) + end += 3450 + if shape == "DEPTH_SOLID": + remove = group.nodes.new("GeometryNodeRemoveAttribute") + remove.pattern_mode = "WILDCARD" + remove.inputs["Name"].default_value = "o_balloon_surface_*" + remove.location = (end, 0) + group.links.new(geometry, remove.inputs["Geometry"]) + geometry, end = remove.outputs[0], end + 300 + else: + profile = _read(group, "o_balloon", (end, 800)) + scale = profile + if dual: + amount = _group_input(group.nodes, (end, 1100), {"Thickness"}) + enabled = _math(group.nodes, "GREATER_THAN", (end + 250, 1100)) + enabled.inputs[1].default_value = 1e-6 + group.links.new(amount.outputs["Thickness"], enabled.inputs[0]) + inflated = group.nodes.new("GeometryNodeSwitch") + inflated.input_type = "FLOAT" + inflated.location = (end + 480, 800) + inflated.inputs["False"].default_value = 1 + group.links.new(enabled.outputs[0], inflated.inputs["Switch"]) + group.links.new(profile, inflated.inputs["True"]) + mode = _menu(group, "Mode", ("Balloon", "Uniform"), "FLOAT", (end + 750, 800)) + mode.inputs["Uniform"].default_value = 1 + group.links.new(inflated.outputs[0], mode.inputs["Balloon"]) + scale = mode.outputs[0] + geometry = _store(group, geometry, NORMAL_SCALE_ATTRIBUTE_NAME, scale, (end + 1050, 0)) + end += 1350 + orient = group.nodes.new("GeometryNodeTransform") + orient.location = (end, -350) + orient.inputs["Rotation"].default_value = Matrix(CUTOUT_Z_TO_X).to_euler() + group.links.new(geometry, orient.inputs["Geometry"]) + axis = _menu(group, "Inward Axis", ("-Z", "+X"), "GEOMETRY", (end + 300, 0)) + group.links.new(geometry, axis.inputs["-Z"]) + group.links.new(orient.outputs[0], axis.inputs["+X"]) + _shade_output(group, axis.outputs[0], (end + 600, 0)) + if shape == "DEPTH_SOLID": + layout_geometry_references(group, stage) + finish_layout(group) + for item in group.interface.items_tree: + if item.item_type != "SOCKET" or item.in_out != "INPUT": + continue + if item.name == "Mode": + item.default_value = "Balloon" + elif item.name == "Inward Axis": + item.default_value = "-Z" + elif item.name == "Uniform Thickness": + item.name = "Thickness" + return group diff --git a/tools/node_assets/cutout_depth_surface.py b/tools/node_assets/cutout_depth_surface.py index 4bed9fd..99b8bca 100644 --- a/tools/node_assets/cutout_depth_surface.py +++ b/tools/node_assets/cutout_depth_surface.py @@ -8,7 +8,6 @@ TEMPORARY_ATTRIBUTES = { "camera": ("FLOAT_VECTOR", "POINT"), "surface_position": ("FLOAT_VECTOR", "POINT"), "source_vertex": ("INT", "POINT"), - "wall_layers": ("FLOAT", "POINT"), } @@ -50,7 +49,7 @@ def _math(group, operation, a, b=None): return node.outputs[0] -def split_depth_surface(group, geometry, image, strength, uniform, depth_scale): +def split_depth_surface(group, geometry, image, strength, uniform): """Keep each face's camera sample on its own corners before splitting.""" nodes, links = group.nodes, group.links uv = nodes.new("GeometryNodeInputNamedAttribute") @@ -93,7 +92,6 @@ def split_depth_surface(group, geometry, image, strength, uniform, depth_scale): links.new(center, distance.inputs[index]) delta = _math(group, "ABSOLUTE", _math(group, "SUBTRACT", *depths)) scaled = _math(group, "MULTIPLY", delta, uniform) - scaled = _math(group, "MULTIPLY", scaled, _math(group, "ABSOLUTE", depth_scale)) # Cross multiplication avoids division and stays invariant under uniform scaling. selected = _math( group, @@ -171,7 +169,7 @@ def store_surface_camera(group, geometry, original, strength): def thicken_depth_surface(group, geometry, thickness): - """Connect caps and wall rings by source vertex and layer identity.""" + """Connect caps with single quads by source vertex and cap identity.""" nodes, links = group.nodes, group.links position = nodes.new("GeometryNodeInputPosition") geometry = _store(group, geometry, "surface_position", position.outputs[0]) @@ -186,33 +184,7 @@ def thicken_depth_surface(group, geometry, thickness): geometry = _store( group, sheet.outputs["Geometry"], "source_vertex", index.outputs[0] ) - edge = nodes.new("GeometryNodeInputMeshEdgeVertices") - length = nodes.new("ShaderNodeVectorMath") - length.operation = "DISTANCE" - links.new(edge.outputs["Position 1"], length.inputs[0]) - links.new(edge.outputs["Position 2"], length.inputs[1]) - neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors") - statistics = nodes.new("GeometryNodeAttributeStatistic") - statistics.domain = "EDGE" - statistics.data_type = "FLOAT" - links.new(geometry, statistics.inputs["Geometry"]) - links.new( - _math(group, "COMPARE", neighbors.outputs["Face Count"], 1.0), - statistics.inputs["Selection"], - ) - links.new(length.outputs["Value"], statistics.inputs["Attribute"]) - count = _math( - group, - "CEIL", - _math( - group, - "DIVIDE", - thickness, - _math(group, "MAXIMUM", statistics.outputs["Median"], 1e-6), - ), - ) - count = _math(group, "MINIMUM", _math(group, "MAXIMUM", count, 1.0), 128.0) - saved = _store(group, geometry, "wall_layers", count) + saved = geometry # Weld in an integer identity grid, never in scene coordinates. Nearby cuts # retain distinct IDs; copies of one rim vertex share the same ID and layer. @@ -224,41 +196,26 @@ def thicken_depth_surface(group, geometry, thickness): links.new(_read(group, "source_vertex"), part.inputs[0]) links.new(part.outputs[0], identity.inputs[axis]) - repeat_output = nodes.new("GeometryNodeRepeatOutput") - repeat_input = nodes.new("GeometryNodeRepeatInput") - repeat_input.pair_with_output(repeat_output) - repeat_output.repeat_items.clear() - repeat_output.repeat_items.new("GEOMETRY", "Geometry") - repeat_output.repeat_items.new("GEOMETRY", "Sides") - links.new(count, repeat_input.inputs["Iterations"]) - links.new(saved, repeat_input.inputs["Geometry"]) extrude = nodes.new("GeometryNodeExtrudeMesh") extrude.mode = "FACES" extrude.inputs["Individual"].default_value = False extrusion_offset = nodes.new("ShaderNodeCombineXYZ") extrusion_offset.inputs["Z"].default_value = -1.0 links.new(extrusion_offset.outputs[0], extrude.inputs["Offset"]) - links.new(repeat_input.outputs["Geometry"], extrude.inputs["Mesh"]) + links.new(saved, extrude.inputs["Mesh"]) sides = nodes.new("GeometryNodeSeparateGeometry") sides.domain = "FACE" links.new(extrude.outputs["Mesh"], sides.inputs["Geometry"]) links.new(extrude.outputs["Side"], sides.inputs["Selection"]) - join_sides = nodes.new("GeometryNodeJoinGeometry") - links.new(repeat_input.outputs["Sides"], join_sides.inputs["Geometry"]) - links.new(sides.outputs["Selection"], join_sides.inputs["Geometry"]) - links.new(sides.outputs["Inverted"], repeat_output.inputs["Geometry"]) - links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"]) offset = nodes.new("ShaderNodeCombineXYZ") - links.new( - _math(group, "MULTIPLY", _read(group, "wall_layers"), -1.0), offset.inputs["Z"] - ) + offset.inputs["Z"].default_value = -1.0 back = nodes.new("GeometryNodeSetPosition") links.new(saved, back.inputs["Geometry"]) links.new(offset.outputs[0], back.inputs["Offset"]) flip_back = nodes.new("GeometryNodeFlipFaces") links.new(back.outputs["Geometry"], flip_back.inputs["Mesh"]) flip_sides = nodes.new("GeometryNodeFlipFaces") - links.new(repeat_output.outputs["Sides"], flip_sides.inputs["Mesh"]) + links.new(sides.outputs["Selection"], flip_sides.inputs["Mesh"]) join = nodes.new("GeometryNodeJoinGeometry") for value in ( saved, @@ -274,12 +231,11 @@ def thicken_depth_surface(group, geometry, thickness): merge.inputs["Distance"].default_value = 0.1 links.new(encoded.outputs["Geometry"], merge.inputs["Geometry"]) displacement = nodes.new("ShaderNodeCombineXYZ") - step = _math(group, "DIVIDE", thickness, _read(group, "wall_layers")) layer_position = nodes.new("GeometryNodeInputPosition") layer_axes = nodes.new("ShaderNodeSeparateXYZ") links.new(layer_position.outputs[0], layer_axes.inputs[0]) links.new( - _math(group, "MULTIPLY", layer_axes.outputs["Z"], step), + _math(group, "MULTIPLY", layer_axes.outputs["Z"], thickness), displacement.inputs["Z"], ) restored = nodes.new("ShaderNodeVectorMath") diff --git a/tools/node_assets/cutout_depth_surface_layout.py b/tools/node_assets/cutout_depth_surface_layout.py index f0e6eec..ccd7095 100644 --- a/tools/node_assets/cutout_depth_surface_layout.py +++ b/tools/node_assets/cutout_depth_surface_layout.py @@ -1,11 +1,11 @@ """Arrange staggered calculation branches on both sides of the geometry path.""" -from node_layout import arrange_nodes +from cutout_layout import route_geometry # Main geometry advances left to right. Local fields grow to either side of it; # a long calculation may start before the geometry input or span several stages. POSITIONS = { - "Position": (0, 260), + "Position": (-240, 110), "Store Named Attribute": (0, 0), "Store Named Attribute.001": (260, 0), "Store Named Attribute.002": (520, 0), @@ -13,9 +13,9 @@ POSITIONS = { "Split Edges": (1060, 0), "Switch": (1360, 0), # Face sampling stays near its stores; selection forms a dense field cluster. - "Named Attribute": (-400, -320), - "Evaluate on Domain": (-180, -320), - "Image Texture": (20, -320), + "Named Attribute": (-440, 380), + "Evaluate on Domain": (-220, 380), + "Image Texture": (20, 380), "Named Attribute.003": (260, 260), "Named Attribute.001": (-1090, 1060), "Separate XYZ": (-870, 1060), @@ -32,80 +32,55 @@ POSITIONS = { "Math": (-530, 1040), "Math.001": (-350, 1040), "Math.002": (-170, 1040), - "Math.003": (-350, 830), - "Math.004": (10, 1040), - "Math.005": (190, 1040), - "Math.006": (190, 790), - "Math.007": (370, 980), - "Math.008": (190, 620), - "Math.009": (550, 910), + "Math.003": (190, 1040), + "Math.004": (190, 790), + "Math.005": (370, 980), + "Math.006": (190, 620), + "Math.007": (550, 910), "Edge Neighbors": (370, 590), - "Math.010": (550, 590), - "Math.011": (730, 810), + "Math.008": (550, 590), + "Math.009": (730, 810), "Evaluate on Domain.001": (730, 670), "Evaluate on Domain.002": (910, 670), - "Math.012": (1090, 670), - "Math.013": (1360, 280), - # Cleanup is a separate compact cluster before projection. - "Face Area": (1460, 780), - "Mesh Island": (1460, 580), - "Accumulate Field": (1680, 780), - "Math.014": (1900, 580), - "Delete Geometry": (2120, 0), + "Math.010": (1090, 670), + "Math.011": (1360, 280), # Camera-ray reconstruction runs below; scene projection runs above. "Named Attribute.004": (1290, -440), "Image Texture.001": (1510, -440), "Separate XYZ.001": (1740, -440), "Named Attribute.005": (1510, -710), "Separate XYZ.002": (1740, -710), - "Math.017": (1960, -440), + "Math.014": (1960, -440), "Vector Math.001": (2180, -440), "Named Attribute.006": (1740, -940), - "Math.015": (1510, -940), - "Math.016": (1960, -940), + "Math.012": (1510, -940), + "Math.013": (1960, -940), "Switch.001": (2600, -240), "Store Named Attribute.004": (2920, 0), "Named Attribute.007": (2340, 1080), "Vector Math.002": (2560, 1080), "Separate XYZ.003": (2740, 1080), - "Math.018": (3100, 730), - "Math.019": (3280, 730), - "Math.020": (2740, 910), + "Math.015": (3100, 730), + "Math.016": (3280, 730), + "Math.017": (2740, 910), "Combine XYZ": (2920, 1040), "Position.001": (2920, 790), "Mix": (3100, 950), "Separate XYZ.004": (3280, 950), "Combine XYZ.001": (3460, 850), "Set Position": (3870, 0), - # Short preparation fields sit above the axis; the long wall-count branch - # sits below, reading geometry back from the source-vertex store. + # Front and rear caps share a single extrusion along the geometry axis. "Position.002": (4560, 340), "Store Named Attribute.005": (4560, 0), "Vector Math.003": (4780, 340), "Set Position.001": (4780, 0), "Index": (5010, 340), "Store Named Attribute.006": (5010, 0), - "Edge Vertices": (3470, -610), - "Vector Math.004": (3700, -610), - "Edge Neighbors.001": (3470, -850), - "Math.021": (3700, -850), - "Attribute Statistic": (3920, -610), - "Math.022": (4150, -610), - "Math.023": (4370, -610), - "Math.024": (4590, -610), - "Math.025": (4820, -610), - "Math.026": (5040, -610), - "Store Named Attribute.007": (5310, 0), - "Repeat Input": (5620, 0), - "Extrude Mesh": (5850, 0), - "Combine XYZ.003": (5620, -340), - "Separate Geometry": (6070, 0), - "Join Geometry": (6300, -180), - "Repeat Output": (6520, 0), - "Flip Faces.001": (6740, 0), - "Join Geometry.001": (6970, 0), - "Named Attribute.010": (5620, -530), - "Math.027": (5850, -530), + "Extrude Mesh": (5500, 0), + "Combine XYZ.003": (5260, 350), + "Separate Geometry": (5780, 0), + "Flip Faces.001": (6300, 0), + "Join Geometry": (6970, 0), "Combine XYZ.004": (6070, -530), "Set Position.002": (6300, -320), "Flip Faces": (6740, -320), @@ -122,14 +97,12 @@ POSITIONS = { "Merge by Distance": (7640, 0), "Position.003": (6910, -950), "Separate XYZ.005": (7160, -950), - "Named Attribute.011": (6900, -710), - "Math.028": (7140, -580), - "Math.029": (7380, -750), + "Math.018": (7380, -750), "Combine XYZ.005": (7600, -680), - "Named Attribute.012": (7600, -370), - "Vector Math.005": (7830, -340), + "Named Attribute.010": (7600, -370), + "Vector Math.004": (7830, -340), "Set Position.004": (8080, 0), - "Math.030": (8310, 260), + "Math.019": (8310, 260), "Switch.002": (8310, 0), "Remove Named Attribute": (9000, 0), "Set Shade Smooth": (9220, 0), @@ -139,71 +112,34 @@ POSITIONS = { INPUT_REGIONS = ( (("Store Named Attribute",), (-240, 0)), (("Switch",), (1100, -300)), - (("Image Texture",), (-180, -190)), + (("Image Texture",), (30, 520)), (("Math.002",), (-350, 1140)), - (("Math.003",), (-530, 830)), - (("Math.005",), (10, 1140)), - (("Math.013",), (1100, 320)), - (("Math.014",), (1680, 400)), + (("Math.003",), (10, 1140)), + (("Math.011",), (1100, 320)), (("Image Texture.001",), (1290, -270)), - (("Math.015",), (1290, -940)), + (("Math.012",), (1290, -940)), (("Vector Math.002",), (2340, 1200)), - (("Math.018", "Math.019"), (2920, 680)), + (("Math.015", "Math.016"), (2920, 680)), (("Mix",), (2920, 900)), - (("Math.023",), (4150, -820)), - (("Math.028",), (6900, -540)), - (("Math.030",), (8080, 300)), + (("Math.019",), (8080, 300)), ) -def _route(group, source_name, output, target_name, input_name, points): - """Place geometry references around the local branches they serve.""" - source = group.nodes[source_name].outputs[output] - target = group.nodes[target_name].inputs[input_name] - for link in list(group.links): - if link.from_socket == source and link.to_socket == target: - group.links.remove(link) - for point in points: - node = group.nodes.new("NodeReroute") - node.location = point - node.select = False - group.links.new(source, node.inputs[0]) - source = node.outputs[0] - group.links.new(source, target) +def layout_geometry_references(group, stage): + """Separate the split bypass and long projection references from fields.""" + links = group.links + entry = stage["Store Named Attribute"].inputs["Geometry"] + source = entry.links[0].from_socket + fork = route_geometry(group, source, [(-3250, -40), (-1450, -40), (-290, -40)]) + links.new(fork, entry) + bypass = route_geometry(group, fork, [(-210, -280), (500, -280), (1100, -280)]) + links.new(bypass, stage["Switch"].inputs["False"]) - -def layout_depth_surface(group): - """Keep the geometry path readable while staggering long and short branches.""" - arrange_nodes(group, POSITIONS, INPUT_REGIONS) - _route( - group, - "Repeat Input", - "Sides", - "Join Geometry", - "Geometry", - ((5810, -170), (6280, -170)), - ) - _route( - group, - "Store Named Attribute.007", - "Geometry", - "Join Geometry.001", - "Geometry", - ((5530, 150), (6950, 150)), - ) - _route( - group, - "Store Named Attribute.007", - "Geometry", - "Set Position.002", - "Geometry", - ((5530, -270), (6280, -270)), - ) - _route( - group, - "Set Position", - "Geometry", - "Switch.002", - "True", - ((4110, 240), (8290, 240)), - ) + projection = next(n.outputs["Geometry"] for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_front_normal") + shared = next(n.outputs["Geometry"] for n in group.nodes if n.bl_idname == "GeometryNodeStoreNamedAttribute" and n.inputs["Name"].default_value == "o_balloon_surface_inflation_normal") + uniform = next(link.to_socket for link in shared.links if link.to_node.bl_idname == "GeometryNodeStoreNamedAttribute" and link.to_node.inputs["Name"].default_value == "o_balloon_surface_thickness") + fork = route_geometry(group, projection, [(4360, -40), (4650, -1700), (8500, -1700), (11400, -1700)]) + uniform_route = route_geometry(group, shared, [(8250, -40), (8460, -1450), (11400, -1450), (11420, -370)]) + links.new(uniform_route, uniform) + surface_route = route_geometry(group, fork, [(13500, -1700), (16800, -1700)]) + links.new(surface_route, stage["Switch.002"].inputs["True"]) diff --git a/tools/node_assets/cutout_inflation.py b/tools/node_assets/cutout_inflation.py new file mode 100644 index 0000000..0c32ec3 --- /dev/null +++ b/tools/node_assets/cutout_inflation.py @@ -0,0 +1,142 @@ +"""Estimate existing front volume and add continuous normal-directed inflation.""" +from cutout_volume import node + + +def _math(group, operation, a, b, location): + result = node(group, "ShaderNodeMath", location) + result.operation = operation + for index, value in enumerate((a, b)): + if hasattr(value, "node"): + group.links.new(value, result.inputs[index]) + else: + result.inputs[index].default_value = value + return result.outputs[0] + + +def _read(group, name, location, kind="FLOAT"): + result = node(group, "GeometryNodeInputNamedAttribute", location) + result.data_type = kind + result.inputs["Name"].default_value = "o_balloon_surface_" + name + return result.outputs["Attribute"] + + +def _store(group, geometry, name, value, location, kind="FLOAT"): + result = node(group, "GeometryNodeStoreNamedAttribute", location) + result.data_type, result.domain = kind, "POINT" + result.inputs["Name"].default_value = "o_balloon_surface_" + name + group.links.new(geometry, result.inputs["Geometry"]) + group.links.new(value, result.inputs["Value"]) + return result.outputs["Geometry"] + + +def build_front_fields(group, geometry): + """Fit structural amplitude over neighbouring points before creating the shell.""" + links = group.links + + def math(operation, a, b, x, y): + return _math(group, operation, a, b, (4800 + x, -2500 + y)) + + def blur(value, x, y, weight=None): + result = node(group, "GeometryNodeBlurAttribute", (4800 + x, -2500 + y)) + result.data_type = "FLOAT" + result.inputs["Iterations"].default_value = 256 + links.new(value, result.inputs["Value"]) + if weight is not None: + links.new(weight, result.inputs["Weight"]) + return result.outputs[0] + + profile_node = node(group, "GeometryNodeInputNamedAttribute", (4800, -2500)) + profile_node.data_type = "FLOAT" + profile_node.inputs["Name"].default_value = "o_balloon" + profile = profile_node.outputs["Attribute"] + rim = _read(group, "rim", (4800, -3050)) + interior = math("SUBTRACT", 1, rim, 240, -550) + position = node(group, "GeometryNodeInputPosition", (4800, -1900)) + axes = node(group, "ShaderNodeSeparateXYZ", (5040, -1900)) + links.new(position.outputs[0], axes.inputs[0]) + depth = axes.outputs["Z"] + depth_slow = blur(depth, 480, 600, interior) + profile_slow = blur(profile, 480, 0, interior) + depth_bump = math("SUBTRACT", depth, depth_slow, 720, 600) + profile_bump = math("SUBTRACT", profile, profile_slow, 720, 0) + numerator = math("MULTIPLY", depth_bump, profile_bump, 960, 600) + denominator = math("MULTIPLY", profile_bump, profile_bump, 960, 0) + numerator = blur(numerator, 1200, 600) + denominator = blur(denominator, 1200, 0) + denominator = math("MAXIMUM", denominator, 1e-10, 1440, 0) + structure = math("DIVIDE", numerator, denominator, 1680, 600) + structure = math("MAXIMUM", structure, 0, 1920, 600) + carrier = _store(group, geometry, "structure", structure, (7300, 0)) + + normal = _read(group, "front_normal", (7100, -600), "FLOAT_VECTOR") + controls = node(group, "NodeGroupInput", (6860, -1100)) + # Keep the accepted broad front-direction filter at the default of 50; + # zero disables both this filter and the initial front/back normal filters. + spread = _math(group, "MULTIPLY", controls.outputs["Normal Smooth"], 256 / 50, (7100, -1100)) + smooth = node(group, "GeometryNodeBlurAttribute", (7340, -600)) + smooth.data_type = "FLOAT_VECTOR" + links.new(normal, smooth.inputs["Value"]) + links.new(spread, smooth.inputs["Iterations"]) + normalize = node(group, "ShaderNodeVectorMath", (7580, -600)) + normalize.operation = "NORMALIZE" + links.new(smooth.outputs[0], normalize.inputs[0]) + carrier = _store(group, carrier, "inflation_normal", normalize.outputs[0], (7770, 0), "FLOAT_VECTOR") + return carrier + + +def build_inflation_position(group, position, distance, uniform): + """Grow both modes continuously while leaving the rear cap in place.""" + previous = set(group.nodes) + links = group.links + + def math(operation, a, b, x, y): + return _math(group, operation, a, b, (x, y)) + + structure = _read(group, "structure", (14000, -4300)) + control = node(group, "NodeGroupInput", (13760, -5000)) + a = math("MULTIPLY", control.outputs["Thickness"], control.outputs["Front Inflation"], 14240, -5000) + # A shifted cubic blend preserves continuous growth and height order. + shifted = math("ADD", a, 0.75, 14480, -5000) + quadratic = math("MULTIPLY", a, shifted, 14720, -5000) + quadratic = math("ADD", quadratic, 0.1875, 14960, -5000) + q = math("MULTIPLY", a, quadratic, 15200, -5000) + k = math("ADD", structure, 0.25, 14240, -4300) + square = math("MULTIPLY", k, k, 14480, -4300) + cube = math("MULTIPLY", square, k, 14720, -4300) + total = math("ADD", cube, q, 14960, -4300) + root = math("POWER", total, 1 / 3, 15200, -4300) + root_square = math("MULTIPLY", root, root, 15440, -4000) + product = math("MULTIPLY", root, k, 15440, -4300) + denominator = math("ADD", root_square, product, 15680, -4300) + denominator = math("ADD", denominator, square, 15920, -4300) + response = math("DIVIDE", q, denominator, 15920, -4800) + profile = node(group, "GeometryNodeInputNamedAttribute", (15920, -3700)) + profile.data_type = "FLOAT" + profile.inputs["Name"].default_value = "o_balloon" + balloon = math("MULTIPLY", profile.outputs["Attribute"], response, 16160, -4250) + uniform_control = node(group, "NodeGroupInput", (15920, -5100)) + uniform_amount = math("MULTIPLY", uniform_control.outputs["Uniform Thickness"], uniform_control.outputs["Front Inflation"], 16160, -5100) + selected = node(group, "GeometryNodeSwitch", (16400, -4550)) + selected.input_type = "FLOAT" + links.new(uniform, selected.inputs["Switch"]) + links.new(balloon, selected.inputs["False"]) + links.new(uniform_amount, selected.inputs["True"]) + displacement = selected.outputs[0] + height = _read(group, "thickness", (14000, -5500)) + layer = math("DIVIDE", distance, height, 14240, -5500) + layer = math("ADD", 1, layer, 14480, -5500) + layer = math("MAXIMUM", layer, 0, 14720, -5500) + layer = math("MINIMUM", layer, 1, 14960, -5500) + displacement = math("MULTIPLY", displacement, layer, 16640, -4550) + normal = _read(group, "inflation_normal", (16640, -4000), "FLOAT_VECTOR") + offset = node(group, "ShaderNodeVectorMath", (16880, -4550)) + offset.operation = "SCALE" + links.new(normal, offset.inputs[0]) + links.new(displacement, offset.inputs[3]) + result = node(group, "ShaderNodeVectorMath", (17120, -4550)) + result.operation = "ADD" + links.new(position, result.inputs[0]) + links.new(offset.outputs[0], result.inputs[1]) + for item in set(group.nodes) - previous: + item.location.y = -3550 + (item.location.y + 3700) * 0.7 + return result.outputs[0] diff --git a/tools/node_assets/cutout_layout.py b/tools/node_assets/cutout_layout.py new file mode 100644 index 0000000..c715844 --- /dev/null +++ b/tools/node_assets/cutout_layout.py @@ -0,0 +1,53 @@ +"""Place independent Cutout stages using authored local layouts.""" +from collections import Counter + + +def route_geometry(group, source, positions): + """Guide a shared forward geometry reference through authored waypoints.""" + for position in positions: + reroute = group.nodes.new("NodeReroute") + reroute.location = position + group.links.new(source, reroute.inputs[0]) + source = reroute.outputs[0] + return source + + +def layout_stage(group, previous, positions, input_regions, offset=(0, 0)): + """Map stage-local default names without renaming Blender nodes.""" + counts, stage = Counter(), {} + for node in group.nodes: + if node in previous or node.bl_idname == "NodeGroupInput": + continue + base, dot, suffix = node.name.rpartition(".") + base = base if dot and suffix.isdigit() else node.name + index = counts[base] + counts[base] += 1 + stage[base if index == 0 else f"{base}.{index:03d}"] = node + assert set(stage) == set(positions) - {"Set Shade Smooth", "Group Output"}, (group.name, set(stage) ^ set(positions)) + for name, node in stage.items(): + x, y = positions[name] + node.location = (x + offset[0], y + offset[1]) + inputs = [(link.to_socket, link.from_socket.name) for link in group.links if link.to_node in stage.values() and link.from_node.bl_idname == "NodeGroupInput"] + for targets, (x, y) in input_regions: + sockets = [(socket, name) for socket, name in inputs if socket.node in [stage[key] for key in targets if key in stage]] + if not sockets: + continue + control = group.nodes.new("NodeGroupInput") + control.location = (x + offset[0], y + offset[1]) + for socket, name in sockets: + group.links.new(control.outputs[name], socket) + return stage + + +def finish_layout(group): + """Expose linked fields and retain readable constants.""" + for node in group.nodes: + node.select = False + node.width = 200 if "Attribute" in node.bl_idname else 160 + if node.bl_idname in {"ShaderNodeMath", "ShaderNodeVectorMath"}: + node.hide = all(socket.is_linked for socket in node.inputs if socket.enabled and not socket.hide) + for socket in node.outputs: + socket.hide = not socket.is_linked + for node in list(group.nodes): + if node.bl_idname == "NodeGroupInput" and not any(socket.is_linked for socket in node.outputs): + group.nodes.remove(node) diff --git a/tools/node_assets/cutout_rounding.py b/tools/node_assets/cutout_rounding.py new file mode 100644 index 0000000..9e09423 --- /dev/null +++ b/tools/node_assets/cutout_rounding.py @@ -0,0 +1,101 @@ +"""Round depth volume around the smoothed center-surface normals.""" +from cutout_volume import node, shift_stage + + +def build_volume_rounding(group, volume, offset): + previous = set(group.nodes) + links = group.links + + def math(operation, a, b, location): + result = node(group, "ShaderNodeMath", location) + result.operation = operation + for index, value in enumerate((a, b)): + if isinstance(value, (int, float)): + result.inputs[index].default_value = value + else: + links.new(value, result.inputs[index]) + return result.outputs[0] + + def read(name, location, kind="FLOAT"): + result = node(group, "GeometryNodeInputNamedAttribute", location) + result.data_type = kind + result.inputs["Name"].default_value = "o_balloon_surface_" + name + return result.outputs["Attribute"] + + def store(geometry, name, value, location, kind="FLOAT"): + result = node(group, "GeometryNodeStoreNamedAttribute", location) + result.data_type, result.domain = kind, "POINT" + result.inputs["Name"].default_value = "o_balloon_surface_" + name + links.new(geometry, result.inputs["Geometry"]) + links.new(value, result.inputs["Value"]) + return result.outputs["Geometry"] + + control = node(group, "NodeGroupInput", (0, 1250)) + profile = node(group, "GeometryNodeInputNamedAttribute", (0, 850)) + profile.data_type = "FLOAT" + profile.inputs["Name"].default_value = "o_balloon" + balloon = profile.outputs["Attribute"] + maximum = node(group, "GeometryNodeAttributeStatistic", (240, 850)) + maximum.data_type, maximum.domain = "FLOAT", "POINT" + links.new(volume, maximum.inputs["Geometry"]) + links.new(balloon, maximum.inputs["Attribute"]) + limit = math("MAXIMUM", maximum.outputs["Max"], 1e-8, (480, 850)) + falloff = node(group, "ShaderNodeMapRange", (720, 850)) + falloff.interpolation_type = "SMOOTHERSTEP" + falloff.inputs["To Min"].default_value = 1 + falloff.inputs["To Max"].default_value = 0 + links.new(balloon, falloff.inputs["Value"]) + links.new(limit, falloff.inputs["From Max"]) + weight = math("MULTIPLY", falloff.outputs[0], control.outputs["Edge Round"], (960, 850)) + center = read("center", (0, 1900)) + position = node(group, "GeometryNodeInputPosition", (0, 2250)) + axes = node(group, "ShaderNodeSeparateXYZ", (240, 2250)) + links.new(position.outputs[0], axes.inputs[0]) + target = math("ADD", center, balloon, (480, 1900)) + delta = math("SUBTRACT", target, axes.outputs["Z"], (720, 1900)) + delta = math("MULTIPLY", delta, weight, (960, 1900)) + enabled = math("MINIMUM", control.outputs["Thickness"], 1, (480, 1300)) + enabled = math("MAXIMUM", enabled, 0, (720, 1300)) + delta = math("MULTIPLY", delta, enabled, (1200, 1900)) + carrier = store(volume, "edge_weight", weight, (1200, 0)) + carrier = store(carrier, "front_delta", delta, (1440, 0)) + rear = math("MULTIPLY", balloon, weight, (1200, 850)) + rear = math("MULTIPLY", rear, control.outputs["Thickness"], (1440, 850)) + thickness = read("thickness", (1440, 1250)) + total = math("ADD", thickness, delta, (1680, 1250)) + total = math("ADD", total, rear, (1920, 1250)) + carrier = store(carrier, "thickness", total, (1920, 0)) + + # Evaluate normals on a separate center surface with identical point indices. + center_position = node(group, "ShaderNodeCombineXYZ", (480, -550)) + for axis in ("X", "Y"): + links.new(axes.outputs[axis], center_position.inputs[axis]) + links.new(center, center_position.inputs["Z"]) + surface = node(group, "GeometryNodeSetPosition", (720, -550)) + links.new(volume, surface.inputs["Geometry"]) + links.new(center_position.outputs[0], surface.inputs["Position"]) + normal = node(group, "GeometryNodeInputNormal", (720, -1000)) + blur = node(group, "GeometryNodeBlurAttribute", (960, -1000)) + blur.data_type = "FLOAT_VECTOR" + links.new(normal.outputs[0], blur.inputs["Value"]) + local = node(group, "NodeGroupInput", (720, -1300)) + links.new(local.outputs["Normal Smooth"], blur.inputs["Iterations"]) + normalize = node(group, "ShaderNodeVectorMath", (1200, -1000)) + normalize.operation = "NORMALIZE" + links.new(blur.outputs[0], normalize.inputs[0]) + index = node(group, "GeometryNodeInputIndex", (1200, -1350)) + sample = node(group, "GeometryNodeSampleIndex", (1440, -550)) + sample.data_type, sample.domain = "FLOAT_VECTOR", "POINT" + links.new(surface.outputs[0], sample.inputs["Geometry"]) + links.new(normalize.outputs[0], sample.inputs["Value"]) + links.new(index.outputs[0], sample.inputs["Index"]) + carrier = store(carrier, "normal", sample.outputs[0], (2160, 0), "FLOAT_VECTOR") + captured = read("front_delta", (2160, 850)) + displacement = node(group, "ShaderNodeCombineXYZ", (2400, 850)) + links.new(captured, displacement.inputs["Z"]) + front = node(group, "GeometryNodeSetPosition", (2640, 0)) + links.new(carrier, front.inputs["Geometry"]) + links.new(displacement.outputs[0], front.inputs["Offset"]) + shift_stage(group, previous, offset) + return front.outputs[0] + diff --git a/tools/node_assets/cutout_shape_layouts.py b/tools/node_assets/cutout_shape_layouts.py new file mode 100644 index 0000000..e3703e5 --- /dev/null +++ b/tools/node_assets/cutout_shape_layouts.py @@ -0,0 +1,141 @@ +"""Authored local positions for Cutout shape stages.""" + +LAYOUTS = {'BALLOON': ({'Named Attribute': (-950, 440), + 'Attribute Statistic': (-730, 1020), + 'Math': (-500, 1020), + 'Math.001': (-280, 1020), + 'Map Range': (-50, 1020), + 'Math.002': (-500, 440), + 'Math.003': (-500, 240), + 'Math.004': (-280, 240), + 'Extrude Mesh': (0, 0), + 'Switch': (220, 440), + 'Position': (20, 240), + 'Separate XYZ': (220, 240), + 'Combine XYZ': (420, 340), + 'Set Position': (690, 0), + 'Separate Geometry': (910, 0), + 'Flip Faces': (1140, 0), + 'Combine XYZ.001': (910, 530), + 'Set Position.001': (1140, 300), + 'Combine XYZ.002': (690, -550), + 'Set Position.002': (910, -300), + 'Flip Faces.001': (1140, -300), + 'Join Geometry': (1430, 0), + 'Merge by Distance': (1650, 0), + 'Math.005': (2100, 310), + 'Switch.001': (2100, 0), + 'Set Shade Smooth': (2320, 0), + 'Group Output': (2550, 0)}, + ((('Extrude Mesh',), (-220, 0)), + (('Attribute Statistic',), (-950, 1020)), + (('Math.002', 'Math.003'), (-730, 240)), + (('Set Position.001',), (910, 300)), + (('Set Position.002',), (690, -300)), + (('Math.005',), (1870, 340)), + (('Switch.001',), (1870, -240)))), + 'UNIFORM': ({'Math': (-90, 680), + 'Math.001': (140, 680), + 'Edge Vertices': (-1630, -340), + 'Vector Math': (-1410, -340), + 'Edge Neighbors': (-1630, -560), + 'Math.002': (-1410, -560), + 'Attribute Statistic': (-1190, -340), + 'Math.003': (-960, -340), + 'Math.004': (-740, -340), + 'Float to Integer': (-520, -340), + 'Integer Math': (-290, -340), + 'Integer Math.001': (-70, -340), + 'Math.005': (190, -580), + 'Math.006': (410, -580), + 'Combine XYZ': (1630, 480), + 'Set Position': (1860, 260), + 'Repeat Input': (410, 0), + 'Extrude Mesh': (640, 0), + 'Separate Geometry': (860, 0), + 'Join Geometry': (1080, -180), + 'Repeat Output': (1310, 0), + 'Combine XYZ.001': (1410, 270), + 'Set Position.001': (1630, 0), + 'Flip Faces': (1860, 0), + 'Combine XYZ.002': (1410, -470), + 'Set Position.002': (1630, -260), + 'Flip Faces.001': (1860, -260), + 'Join Geometry.001': (2150, 0), + 'Merge by Distance': (2370, 0), + 'Math.007': (2820, 290), + 'Switch': (2820, 0), + 'Set Shade Smooth': (3040, 0), + 'Group Output': (3270, 0)}, + ((('Math',), (-310, 680)), + (('Attribute Statistic',), (-1410, -170)), + (('Math.004',), (-960, -610)), + (('Math.005',), (-30, -580)), + (('Repeat Input',), (190, 0)), + (('Set Position',), (1630, 260)), + (('Set Position.002',), (1410, -260)), + (('Math.007',), (2600, 310)), + (('Switch',), (2600, -240)))), + 'DEPTH_BALLOON': ({'Named Attribute': (-1120, 650), + 'Attribute Statistic': (-890, 650), + 'Math': (-670, 650), + 'Math.001': (-450, 650), + 'Map Range': (-220, 650), + 'Named Attribute.001': (-1790, -440), + 'Image Texture': (-1570, -440), + 'Separate XYZ': (-1340, -440), + 'Math.002': (-1120, -440), + 'Vector Math': (-2240, -950), + 'Separate XYZ.001': (-2010, -950), + 'Position': (-2240, -1260), + 'Separate XYZ.002': (-2010, -1260), + 'Math.003': (-1790, -950), + 'Math.004': (-1790, -1160), + 'Math.005': (-1570, -950), + 'Math.006': (-1340, -950), + 'Math.007': (-1120, -950), + 'Math.008': (-890, -440), + 'Math.009': (-670, -440), + 'Math.010': (-450, -440), + 'Math.011': (-670, 310), + 'Math.012': (-670, 70), + 'Math.013': (-450, 70), + 'Mix': (20, 440), + 'Math.014': (240, 710), + 'Switch': (460, 710), + 'Extrude Mesh': (0, 0), + 'Switch.001': (460, 310), + 'Position.001': (280, 530), + 'Separate XYZ.003': (460, 530), + 'Combine XYZ': (660, 430), + 'Set Position': (930, 0), + 'Separate Geometry': (1150, 0), + 'Flip Faces': (1600, 0), + 'Combine XYZ.001': (1380, 570), + 'Set Position.001': (1600, 330), + 'Combine XYZ.002': (1150, -570), + 'Set Position.002': (1380, -320), + 'Flip Faces.001': (1600, -320), + 'Join Geometry': (1870, 0), + 'Merge by Distance': (2320, 0), + 'Position.002': (1600, 1120), + 'Separate XYZ.004': (1820, 1120), + 'Combine XYZ.003': (2050, 950), + 'Set Position.003': (2320, 680), + 'Math.015': (2560, -270), + 'Switch.002': (2560, 0), + 'Set Shade Smooth': (2790, 0), + 'Group Output': (3010, 0)}, + ((('Extrude Mesh',), (-220, 0)), + (('Attribute Statistic',), (-1120, 850)), + (('Image Texture',), (-1790, -270)), + (('Math.002',), (-1340, -750)), + (('Vector Math',), (-2460, -950)), + (('Math.007',), (-1340, -1160)), + (('Math.010',), (-670, -650)), + (('Math.011', 'Math.012'), (-890, 310)), + (('Switch',), (240, 920)), + (('Set Position.001',), (1380, 330)), + (('Set Position.002',), (1150, -320)), + (('Set Position.003',), (2100, 610)), + (('Math.015',), (2340, -270))))} diff --git a/tools/node_assets/build_cutout.py b/tools/node_assets/cutout_shapes.py similarity index 62% rename from tools/node_assets/build_cutout.py rename to tools/node_assets/cutout_shapes.py index 4cda7a8..062497d 100644 --- a/tools/node_assets/build_cutout.py +++ b/tools/node_assets/cutout_shapes.py @@ -1,24 +1,6 @@ -"""Build the four Cutout Geometry Nodes groups stored in O_AnyImage.blend.""" - - -import bpy -from node_graph import expand_smooth -from cutout_depth_surface import ( - store_surface_camera, - remove_surface_attributes, - split_depth_surface, - thicken_depth_surface, -) -from cutout_depth_surface_layout import layout_depth_surface -from geometry_layouts import layout_geometry - -from anyimage.operators.cutout_tool.shape import ( - BALLOON_ATTRIBUTE_NAME, - CUTOUT_Z_TO_X, - CUTOUT_NODE_GROUP_NAME, - CUTOUT_NODE_GROUP_NAMES, - CUTOUT_SHAPE_LABELS, -) +"""Construct the planar and depth-based Cutout shape stages.""" +from cutout_depth_surface import store_surface_camera, remove_surface_attributes, split_depth_surface, thicken_depth_surface +from anyimage.operators.cutout_tool.shape import BALLOON_ATTRIBUTE_NAME def _socket(group, name, in_out, socket_type, parent=None): @@ -42,47 +24,6 @@ def _group_input(nodes, location, visible_names): return node -def _localize_group_inputs(group, controls): - """Place short, socket-specific Group Inputs beside their consumers.""" - links = group.links - outgoing = [link for link in links if link.from_node == controls] - by_socket = {} - for link in outgoing: - if link.from_socket.name == "Geometry": - continue - by_socket.setdefault(link.from_socket.name, []).append(link) - - for socket_name, socket_links in by_socket.items(): - ordered = sorted(socket_links, key=lambda link: link.to_node.location.x) - clusters = [] - for link in ordered: - if ( - not clusters - or link.to_node.location.x - clusters[-1][-1].to_node.location.x > 900.0 - ): - clusters.append([]) - clusters[-1].append(link) - for cluster in clusters: - targets = [link.to_node for link in cluster] - location = [ - min(node.location.x for node in targets) - 260.0, - max(node.location.y for node in targets) + 320.0, - ] - while any( - abs(node.location.x - location[0]) < 190.0 - and abs(node.location.y - location[1]) < 190.0 - for node in group.nodes - ): - location[1] += 220.0 - local = _group_input(group.nodes, location, {socket_name}) - for link in cluster: - target = link.to_socket - links.remove(link) - links.new(local.outputs[socket_name], target) - for output in controls.outputs: - output.hide = output.name != "Geometry" - - def _float_input( group, name, @@ -610,14 +551,12 @@ def _build_depth_surface( uniform_scale, reference_depth, depth_image, - cleanup, ): nodes = group.nodes links = group.links geometry = split_depth_surface( - group, geometry, depth_image, split, uniform_scale, depth_scale + group, geometry, depth_image, split, uniform_scale ) - geometry = _island_cleanup(group, geometry, cleanup, (1800.0, 850.0)) original_camera = _position_field(group, depth_image, (-200.0, -200.0)) geometry, camera_position = store_surface_camera( group, geometry, original_camera, split @@ -672,366 +611,3 @@ def _build_depth_surface( group, project_geometry.outputs["Geometry"], shaped, thickness, (11800.0, 400.0) ) return remove_surface_attributes(group, geometry) - - -def build_node_group(shape): - group_name = CUTOUT_NODE_GROUP_NAMES[shape] - group = bpy.data.node_groups.get(group_name) - if group is not None: - return group - group = bpy.data.node_groups.new(group_name, "GeometryNodeTree") - group.is_modifier = False - _socket(group, "Geometry", "INPUT", "NodeSocketGeometry") - if shape in {"BALLOON", "DEPTH_BALLOON"}: - thickness = _float_input(group, "Thickness", 1.0) - thickness.description = ( - "Multiplier for the injected Poisson Balloon profile; zero keeps " - "the source sheet" - ) - else: - thickness = _float_input(group, "Thickness", 0.0, 0.0, None, subtype="DISTANCE") - thickness.description = "Final thickness in scene distance" - - depth_image = None - split = None - if shape == "DEPTH_BALLOON": - depth_scale = _float_input(group, "Depth Scale", 1.0) - depth_scale.description = "Scale the fitted-plane-corrected metric depth" - double_sided = _bool_input(group, "Double Sided", False) - double_sided.description = ( - "Mirror the complete front surface across the local XY plane" - ) - elif shape == "DEPTH_SURFACE": - depth_scale = _float_input(group, "Depth Scale", 1.0) - - if shape == "DEPTH_BALLOON": - options = group.interface.new_panel(name="Options", default_closed=True) - reference_depth = _float_input( - group, - "Reference Depth", - 1.0, - 0.0, - None, - subtype="DISTANCE", - parent=options, - ) - reference_depth.description = ( - "Shared upper-percentile metric depth for outward placement" - ) - depth_direction = _vector_input( - group, - "Depth Direction", - (0.0, 0.0, 1.0), - subtype="DIRECTION", - parent=options, - ) - depth_direction.description = ( - "Normal direction of the fitted metric depth plane" - ) - elif shape == "DEPTH_SURFACE": - options = group.interface.new_panel(name="Options", default_closed=True) - split = _float_input( - group, - "Split", - 0.0, - 0.0, - 1.0, - subtype="FACTOR", - parent=options, - ) - split.description = "Separate depth discontinuities; zero disables splitting" - cleanup = _float_input(group, "Cleanup", 0.0, 0.0, None, parent=options) - cleanup.description = "Remove split islands below this local-area threshold" - reference_depth = _float_input( - group, - "Reference Depth", - 1.0, - 0.0, - None, - subtype="DISTANCE", - parent=options, - ) - reference_depth.description = ( - "Scaled camera depth placed on the original image sheet" - ) - - if shape in {"DEPTH_BALLOON", "DEPTH_SURFACE"}: - data = group.interface.new_panel(name="Data", default_closed=True) - uniform_scale = _float_input(group, "Uniform Scale", 1.0, 0.0, parent=data) - uniform_scale.description = ( - "Internal uniform conversion from MoGe meters to local distance" - ) - uniform_scale.hide_in_modifier = True - depth_image = _socket(group, "Depth Image", "INPUT", "NodeSocketImage", data) - depth_image.description = ( - f"Internally generated {CUTOUT_SHAPE_LABELS[shape]} data" - ) - depth_image.hide_in_modifier = True - _socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") - - controls = _group_input( - group.nodes, - (-1600.0, 500.0), - { - "Geometry", - "Thickness", - "Double Sided", - "Depth Image", - "Depth Scale", - "Uniform Scale", - "Reference Depth", - "Depth Direction", - "Split", - "Cleanup", - }, - ) - geometry = controls.outputs["Geometry"] - if shape == "SURFACE": - geometry = _build_cutout(group, geometry, controls.outputs["Thickness"]) - output_location = (4050.0, 300.0) - elif shape == "BALLOON": - geometry = _build_balloon( - group, - geometry, - controls.outputs["Thickness"], - ) - output_location = (4650.0, 650.0) - elif shape == "DEPTH_BALLOON": - geometry = _build_depth_balloon( - group, - geometry, - controls.outputs["Thickness"], - controls.outputs["Depth Scale"], - controls.outputs["Double Sided"], - controls.outputs["Uniform Scale"], - controls.outputs["Depth Direction"], - controls.outputs["Depth Image"], - controls.outputs["Reference Depth"], - ) - output_location = (5100.0, 600.0) - else: - geometry = _build_depth_surface( - group, - geometry, - controls.outputs["Thickness"], - controls.outputs["Depth Scale"], - controls.outputs["Split"], - controls.outputs["Uniform Scale"], - controls.outputs["Reference Depth"], - controls.outputs["Depth Image"], - controls.outputs["Cleanup"], - ) - output_location = (12100.0, 400.0) - _localize_group_inputs(group, controls) - _shade_output(group, geometry, output_location) - if shape == "DEPTH_SURFACE": - layout_depth_surface(group) - else: - layout_geometry(group) - group.color_tag = "GEOMETRY" - group.description = { - "SURFACE": "Symmetrically thicken a Surface sheet.", - "BALLOON": "Inflate a Surface sheet from its boundary profile.", - "DEPTH_BALLOON": ( - "Mix Balloon profile and metric image depth around a fitted plane." - ), - "DEPTH_SURFACE": ( - "Project and thicken a Surface sheet from hidden camera points." - ), - }[shape] - group.use_fake_user = True - return group - - -def build_cutout_modifier_group(shape_groups): - group = bpy.data.node_groups.get(CUTOUT_NODE_GROUP_NAME) - if group is not None: - return group - group = bpy.data.node_groups.new(CUTOUT_NODE_GROUP_NAME, "GeometryNodeTree") - group.is_modifier = True - _socket(group, "Geometry", "INPUT", "NodeSocketGeometry") - _socket(group, "Shape", "INPUT", "NodeSocketMenu") - inward_axis = _socket(group, "Inward Axis", "INPUT", "NodeSocketMenu") - inward_axis.hide_in_modifier = True - - thickness = _float_input(group, "Thickness", 0.0, 0.0) - thickness.description = ( - "Scene thickness for Surface shapes or Balloon profile multiplier" - ) - depth_scale = _float_input(group, "Depth Scale", 1.0, 0.0) - depth_scale.description = "Scale metric image depth" - double_sided = _bool_input(group, "Double Sided", False) - double_sided.description = ( - "Mirror the complete Depth Balloon front surface across the local XY plane" - ) - - iterations = _socket(group, "Smooth Iterations", "INPUT", "NodeSocketInt") - iterations.default_value = 0 - iterations.min_value = 0 - iterations.max_value = 20 - iterations.description = "Number of smoothing passes" - options = group.interface.new_panel(name="Options", default_closed=True) - cleanup = _float_input( - group, - "Cleanup", - 0.0, - 0.0, - None, - parent=options, - ) - cleanup.description = "Delete mesh islands below this absolute local-area threshold" - smooth = _float_input( - group, - "Smooth", - 1.0, - 0.0, - 1.0, - subtype="FACTOR", - parent=options, - ) - smooth.description = "Blend each smoothing pass into the selected shape" - split = _float_input( - group, - "Split", - 0.0, - 0.0, - 1.0, - subtype="FACTOR", - parent=options, - ) - split.description = "Separate Depth Surface discontinuities; zero disables splitting" - - reference_depth = _float_input( - group, - "Reference Depth", - 1.0, - 0.0, - None, - subtype="DISTANCE", - parent=options, - ) - reference_depth.description = "Depth of the flat base plane for both Depth shapes" - depth_direction = _vector_input( - group, - "Depth Direction", - (0.0, 0.0, 1.0), - subtype="DIRECTION", - parent=options, - ) - depth_direction.description = "Normal direction of the fitted metric depth plane" - - data = group.interface.new_panel(name="Data", default_closed=True) - uniform_scale = _float_input(group, "Uniform Scale", 1.0, 0.0, parent=data) - uniform_scale.description = ( - "Internal uniform conversion from MoGe meters to local distance" - ) - uniform_scale.hide_in_modifier = True - depth_image = _socket(group, "Depth Image", "INPUT", "NodeSocketImage", data) - depth_image.description = "Internally generated Cutout depth data" - depth_image.hide_in_modifier = True - _socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry") - - base_controls = _group_input( - group.nodes, - (-2700.0, 500.0), - {"Geometry", "Cleanup"}, - ) - cleanup_area = _math(group.nodes, "MULTIPLY", (-2500.0, 750.0)) - cleanup_area.inputs[1].default_value = 0.01 - group.links.new(base_controls.outputs["Cleanup"], cleanup_area.inputs[0]) - geometry = _island_cleanup( - group, - base_controls.outputs["Geometry"], - cleanup_area.outputs[0], - (-2350.0, 250.0), - ) - - branch_y = { - "SURFACE": 1150.0, - "BALLOON": 550.0, - "DEPTH_BALLOON": -50.0, - "DEPTH_SURFACE": -650.0, - } - branch_nodes = {} - for shape, y in branch_y.items(): - node = group.nodes.new("GeometryNodeGroup") - node.node_tree = shape_groups[shape] - node.location = (-850.0, y) - node.width = 220.0 - group.links.new( - base_controls.outputs["Geometry"] if shape == "DEPTH_SURFACE" else geometry, - node.inputs["Geometry"], - ) - visible = { - socket.name - for socket in node.inputs - if socket.name and socket.name != "Geometry" - } - controls = _group_input(group.nodes, (-1200.0, y + 120.0), visible) - for socket in node.inputs: - if not socket.name or socket.name == "Geometry": - continue - group.links.new( - cleanup_area.outputs[0] if socket.name == "Cleanup" else controls.outputs[socket.name], - socket, - ) - branch_nodes[shape] = node - - menu_switch = group.nodes.new("GeometryNodeMenuSwitch") - menu_switch.data_type = "GEOMETRY" - shapes = list(CUTOUT_NODE_GROUP_NAMES) - for item, shape in zip(menu_switch.enum_items, shapes[:2]): - item.name = CUTOUT_SHAPE_LABELS[shape] - for shape in shapes[2:]: - menu_switch.enum_items.new(CUTOUT_SHAPE_LABELS[shape]) - menu_switch.location = (-350.0, 250.0) - - shape_controls = _group_input( - group.nodes, - (-700.0, -1200.0), - {"Shape"}, - ) - group.links.new(shape_controls.outputs["Shape"], menu_switch.inputs["Menu"]) - for shape, node in branch_nodes.items(): - group.links.new( - node.outputs["Geometry"], - menu_switch.inputs[CUTOUT_SHAPE_LABELS[shape]], - ) - - geometry = expand_smooth( - group, - menu_switch.outputs["Output"], - iterations_name="Smooth Iterations", - ) - from mathutils import Matrix - - orient = group.nodes.new("GeometryNodeTransform") - orient.inputs["Rotation"].default_value = Matrix(CUTOUT_Z_TO_X).to_euler() - orient.location = (3300.0, 350.0) - group.links.new(geometry, orient.inputs["Geometry"]) - - axis_switch = group.nodes.new("GeometryNodeMenuSwitch") - axis_switch.data_type = "GEOMETRY" - axis_switch.enum_items[0].name = "-Z" - axis_switch.enum_items[1].name = "+X" - axis_switch.location = (3550.0, 550.0) - axis_controls = _group_input( - group.nodes, - (3300.0, -650.0), - {"Inward Axis"}, - ) - group.links.new(axis_controls.outputs["Inward Axis"], axis_switch.inputs["Menu"]) - group.links.new(geometry, axis_switch.inputs["-Z"]) - group.links.new(orient.outputs["Geometry"], axis_switch.inputs["+X"]) - _shade_output(group, axis_switch.outputs["Output"], (3850.0, 550.0)) - layout_geometry(group) - group.color_tag = "GEOMETRY" - group.description = ( - "Build Surface, Balloon, Depth Balloon, or Depth Surface from one " - "menu-controlled Cutout modifier." - ) - for node in group.nodes: - for socket in node.outputs: - socket.hide = not socket.is_linked - group.use_fake_user = True - return group diff --git a/tools/node_assets/cutout_shell.py b/tools/node_assets/cutout_shell.py new file mode 100644 index 0000000..ae397e2 --- /dev/null +++ b/tools/node_assets/cutout_shell.py @@ -0,0 +1,122 @@ +"""Continue a front-normal thin shell into the rounded depth volume.""" +from cutout_inflation import build_inflation_position +from cutout_volume import node, shift_stage + + +def build_front_normals(group, geometry): + links = group.links + normal = node(group, "GeometryNodeInputNormal", (3400, -650)) + control = node(group, "NodeGroupInput", (3400, -950)) + blur = node(group, "GeometryNodeBlurAttribute", (3640, -650)) + blur.data_type = "FLOAT_VECTOR" + links.new(normal.outputs[0], blur.inputs["Value"]) + links.new(control.outputs["Normal Smooth"], blur.inputs["Iterations"]) + normalize = node(group, "ShaderNodeVectorMath", (3880, -650)) + normalize.operation = "NORMALIZE" + links.new(blur.outputs[0], normalize.inputs[0]) + store = node(group, "GeometryNodeStoreNamedAttribute", (4110, 0)) + store.data_type, store.domain = "FLOAT_VECTOR", "POINT" + store.inputs["Name"].default_value = "o_balloon_surface_front_normal" + links.new(geometry, store.inputs["Geometry"]) + links.new(normalize.outputs[0], store.inputs["Value"]) + return store.outputs[0] + + +def build_shell_position(group, position, uniform, offset): + previous = set(group.nodes) + links = group.links + + def connect(value, target): + if hasattr(value, "node"): + links.new(value, target) + else: + target.default_value = value + + def math(operation, a, b, location): + result = node(group, "ShaderNodeMath", location) + result.operation = operation + connect(a, result.inputs[0]) + connect(b, result.inputs[1]) + return result.outputs[0] + + def vector(operation, a, b, location): + result = node(group, "ShaderNodeVectorMath", location) + result.operation = operation + connect(a, result.inputs[0]) + connect(b, result.inputs[3 if operation == "SCALE" else 1]) + return result.outputs[0] + + def read(name, location, kind="FLOAT"): + result = node(group, "GeometryNodeInputNamedAttribute", location) + result.data_type = kind + result.inputs["Name"].default_value = name + return result.outputs["Attribute"] + + def z(value, location): + result = node(group, "ShaderNodeSeparateXYZ", location) + links.new(value, result.inputs[0]) + return result.outputs["Z"] + + # Uniform and the thin end of Balloon share the actual front normal. + front = read("o_depth_surface_position", (0, 0), "FLOAT_VECTOR") + front_z = z(front, (240, 0)) + raw_z = z(position, (240, 250)) + distance = math("SUBTRACT", raw_z, front_z, (480, 250)) + normal = read("o_balloon_surface_front_normal", (480, 550), "FLOAT_VECTOR") + thin_offset = vector("SCALE", normal, distance, (720, 550)) + uniform_position = vector("ADD", front, thin_offset, (3120, 550)) + + # R = t * (front_z - center) + (1 - t) * front_delta, with t clamped. + # This avoids division by Thickness and removes the old zero-thickness drift. + control = node(group, "NodeGroupInput", (0, -400)) + clamp = node(group, "ShaderNodeClamp", (240, -400)) + links.new(control.outputs["Thickness"], clamp.inputs["Value"]) + t = clamp.outputs[0] + center = read("o_balloon_surface_center", (0, -750)) + delta = read("o_balloon_surface_front_delta", (480, -1050)) + radius = math("SUBTRACT", front_z, center, (480, -750)) + radius = math("MULTIPLY", radius, t, (720, -750)) + remaining = math("SUBTRACT", 1, t, (480, -400)) + remainder = math("MULTIPLY", remaining, delta, (720, -1050)) + radius = math("ADD", radius, remainder, (960, -750)) + + # Front and rear use the same edge field, evaluated on their own caps. + edge = read("o_balloon_surface_edge_weight", (0, -1450)) + mask = read("o_normal_scale", (240, -1800)) + remaining = math("SUBTRACT", 1, edge, (240, -1450)) + remaining = math("MULTIPLY", remaining, mask, (480, -1450)) + weight = math("SUBTRACT", 1, remaining, (720, -1450)) + difference = math("SUBTRACT", weight, edge, (960, -1450)) + base = math("MULTIPLY", difference, radius, (1200, -1450)) + layer = math("MULTIPLY", weight, distance, (1200, -1800)) + rear_radius = math("ADD", base, layer, (1440, -1450)) + front_radius = math("MULTIPLY", edge, radius, (1440, -750)) + normal = read("o_balloon_surface_normal", (1200, -300), "FLOAT_VECTOR") + direction = vector("SUBTRACT", normal, (0, 0, 1), (1440, -300)) + front_shift = vector("SCALE", direction, front_radius, (1680, -750)) + front_position = vector("ADD", front, front_shift, (1920, -750)) + rear_shift = vector("SCALE", direction, rear_radius, (1680, -1450)) + axis_offset = node(group, "ShaderNodeCombineXYZ", (1680, -1800)) + links.new(distance, axis_offset.inputs["Z"]) + full_offset = vector("ADD", axis_offset.outputs[0], rear_shift, (1920, -1450)) + + # Smoothly reach the existing full-volume shape at Thickness >= 1. + transition = node(group, "ShaderNodeMapRange", (1920, 0)) + transition.interpolation_type = "SMOOTHERSTEP" + links.new(t, transition.inputs["Value"]) + mix = node(group, "ShaderNodeMix", (2400, -1100)) + mix.data_type = "VECTOR" + links.new(transition.outputs[0], mix.inputs[0]) + links.new(thin_offset, mix.inputs[4]) + links.new(full_offset, mix.inputs[5]) + balloon_position = vector("ADD", front_position, mix.outputs["Result"], (3120, -750)) + choose = node(group, "GeometryNodeSwitch", (3400, 0)) + choose.input_type = "VECTOR" + links.new(uniform, choose.inputs["Switch"]) + links.new(balloon_position, choose.inputs["False"]) + links.new(uniform_position, choose.inputs["True"]) + for item in set(group.nodes) - previous: + item.location.x *= 0.9 + item.location.y *= 0.65 + shift_stage(group, previous, offset) + return build_inflation_position(group, choose.outputs[0], distance, uniform) diff --git a/tools/node_assets/cutout_volume.py b/tools/node_assets/cutout_volume.py new file mode 100644 index 0000000..ff68a6f --- /dev/null +++ b/tools/node_assets/cutout_volume.py @@ -0,0 +1,88 @@ +"""Build the smooth center field for depth volume.""" +from node_graph import edge_boundary_field + + +def node(group, kind, location): + result = group.nodes.new(kind) + result.location = location + return result + + +def shift_stage(group, previous, offset): + for item in group.nodes: + if item not in previous: + item.location.x += offset[0] + item.location.y += offset[1] + + +def build_volume_fields(group, geometry, offset=(0, 0)): + previous = set(group.nodes) + boundary = edge_boundary_field(group.nodes, group.links, (0, -400)) + store = node(group, "GeometryNodeStoreNamedAttribute", (860, 0)) + store.domain, store.data_type = "POINT", "FLOAT" + store.inputs["Name"].default_value = "o_balloon_surface_rim" + group.links.new(geometry, store.inputs["Geometry"]) + group.links.new(boundary, store.inputs["Value"]) + profile = node(group, "GeometryNodeInputNamedAttribute", (860, 700)) + profile.data_type = "FLOAT" + profile.inputs["Name"].default_value = "o_balloon" + links = group.links + def math(operation, a, b, location): + result = node(group, "ShaderNodeMath", location) + result.operation = operation + for index, value in enumerate((a, b)): + if isinstance(value, (int, float)): + result.inputs[index].default_value = value + else: + links.new(value, result.inputs[index]) + return result.outputs[0] + position = node(group, "GeometryNodeInputPosition", (860, 1100)) + axes = node(group, "ShaderNodeSeparateXYZ", (1100, 1100)) + links.new(position.outputs[0], axes.inputs[0]) + seed = math("SUBTRACT", axes.outputs["Z"], profile.outputs["Attribute"], (1340, 900)) + center = node(group, "GeometryNodeStoreNamedAttribute", (1340, 0)) + center.domain, center.data_type = "POINT", "FLOAT" + center.inputs["Name"].default_value = "o_balloon_surface_center" + links.new(store.outputs["Geometry"], center.inputs["Geometry"]) + links.new(seed, center.inputs["Value"]) + current = node(group, "GeometryNodeInputNamedAttribute", (1660, -330)) + current.data_type = "FLOAT" + current.inputs["Name"].default_value = "o_balloon_surface_center" + rim = node(group, "GeometryNodeInputNamedAttribute", (1660, -660)) + rim.data_type = "FLOAT" + rim.inputs["Name"].default_value = "o_balloon_surface_rim" + interior = math("SUBTRACT", 1, rim.outputs["Attribute"], (1890, -660)) + blur = node(group, "GeometryNodeBlurAttribute", (2120, -330)) + blur.data_type = "FLOAT" + blur.inputs["Iterations"].default_value = 256 + links.new(current.outputs["Attribute"], blur.inputs["Value"]) + links.new(interior, blur.inputs["Weight"]) + update = node(group, "GeometryNodeStoreNamedAttribute", (2370, 0)) + update.domain, update.data_type = "POINT", "FLOAT" + update.inputs["Name"].default_value = "o_balloon_surface_center" + links.new(center.outputs["Geometry"], update.inputs["Geometry"]) + links.new(blur.outputs[0], update.inputs["Value"]) + baseline = node(group, "GeometryNodeInputNamedAttribute", (2940, 1000)) + baseline.data_type = "FLOAT" + baseline.inputs["Name"].default_value = "o_balloon_surface_center" + profile_end = node(group, "GeometryNodeInputNamedAttribute", (2940, 700)) + profile_end.data_type = "FLOAT" + profile_end.inputs["Name"].default_value = "o_balloon" + position_end = node(group, "GeometryNodeInputPosition", (2940, 1360)) + axes_end = node(group, "ShaderNodeSeparateXYZ", (3180, 1360)) + links.new(position_end.outputs[0], axes_end.inputs[0]) + residual = math("SUBTRACT", axes_end.outputs["Z"], baseline.outputs["Attribute"], (3420, 1000)) + full = math("ADD", residual, profile_end.outputs["Attribute"], (3660, 1000)) + minimum = math("MULTIPLY", profile_end.outputs["Attribute"], 0.05, (3420, 700)) + positive = math("MAXIMUM", full, minimum, (3900, 1000)) + control = node(group, "NodeGroupInput", (3900, 700)) + scaled = math("MULTIPLY", positive, control.outputs["Thickness"], (4140, 1000)) + thickness = node(group, "GeometryNodeStoreNamedAttribute", (4140, 0)) + thickness.domain, thickness.data_type = "POINT", "FLOAT" + thickness.inputs["Name"].default_value = "o_balloon_surface_thickness" + group.links.new(update.outputs["Geometry"], thickness.inputs["Geometry"]) + group.links.new(scaled, thickness.inputs["Value"]) + shift_stage(group, previous, offset) + return thickness.outputs["Geometry"] + + diff --git a/tools/node_assets/geometry_layouts.py b/tools/node_assets/geometry_layouts.py index 3a849e8..9f0e823 100644 --- a/tools/node_assets/geometry_layouts.py +++ b/tools/node_assets/geometry_layouts.py @@ -70,70 +70,6 @@ LAYOUTS = { (("Bounding Box.002",), (-550, 140)), ), ), - "O Image Cutout": ( - { - "Math": (-170, 580), - "Mesh Island": (-620, 140), - "Face Area": (-620, 340), - "Accumulate Field": (-400, 340), - "Math.001": (50, 310), - "Delete Geometry": (430, 270), - "Group": (1030, 520), - "Group.001": (1030, 300), - "Group.002": (1030, 80), - "Group.003": (1030, -220), - "Menu Switch": (1390, 240), - "Repeat Input": (2170, 0), - "Repeat Output": (5090, 0), - "Vertex Neighbors": (2170, -780), - "Compare": (2390, -780), - "Edge Neighbors": (1940, -1020), - "Compare.001": (2170, -1020), - "Evaluate on Domain": (2390, -1020), - "Boolean Math": (2610, -780), - "Evaluate on Domain.001": (2840, -580), - "Separate Geometry": (3110, -310), - "Position": (3300, 540), - "Blur Attribute": (3520, 540), - "Position.001": (3110, -780), - "Blur Attribute.001": (3340, -780), - "Sample Nearest": (3340, -310), - "Sample Index": (3590, -310), - "Vertex Neighbors.001": (2390, 950), - "Compare.002": (2610, 950), - "Edge Neighbors.001": (2170, 1220), - "Compare.003": (2390, 1220), - "Evaluate on Domain.002": (2610, 1220), - "Boolean Math.001": (2840, 950), - "Switch": (3890, 340), - "Mix": (4130, 340), - "Normal": (3060, 1500), - "Blur Attribute.002": (3280, 1500), - "Vector Math": (3510, 1500), - "Switch.001": (3730, 1500), - "Math.002": (3950, 1360), - "Switch.002": (4370, 410), - "Position.002": (4370, 680), - "Mix.001": (4660, 410), - "Set Position": (4830, 0), - "Transform Geometry": (5450, -340), - "Menu Switch.001": (5740, 0), - "Set Shade Smooth": (5970, 0), - "Group Output": (6190, 0), - }, - ( - (("Math",), (-400, 580)), - (("Delete Geometry",), (430, 530)), - (("Group",), (810, 520)), - (("Group.001",), (810, 300)), - (("Group.002",), (810, 80)), - (("Group.003",), (810, -220)), - (("Menu Switch",), (1390, 380)), - (("Repeat Input",), (1940, 0)), - (("Math.002",), (3730, 1220)), - (("Menu Switch.001",), (5520, 340)), - ), - ), "O Image Depth Plane": ( { "Group": (0, 0), @@ -173,163 +109,6 @@ LAYOUTS = { (("Math.007",), (1000, -980)), ), ), - "O Image Balloon": ( - { - "Named Attribute": (-950, 440), - "Attribute Statistic": (-730, 1020), - "Math": (-500, 1020), - "Math.001": (-280, 1020), - "Map Range": (-50, 1020), - "Math.002": (-500, 440), - "Math.003": (-500, 240), - "Math.004": (-280, 240), - "Extrude Mesh": (0, 0), - "Switch": (220, 440), - "Position": (20, 240), - "Separate XYZ": (220, 240), - "Combine XYZ": (420, 340), - "Set Position": (690, 0), - "Separate Geometry": (910, 0), - "Flip Faces": (1140, 0), - "Combine XYZ.001": (910, 530), - "Set Position.001": (1140, 300), - "Combine XYZ.002": (690, -550), - "Set Position.002": (910, -300), - "Flip Faces.001": (1140, -300), - "Join Geometry": (1430, 0), - "Merge by Distance": (1650, 0), - "Math.005": (2100, 310), - "Switch.001": (2100, 0), - "Set Shade Smooth": (2320, 0), - "Group Output": (2550, 0), - }, - ( - (("Extrude Mesh",), (-220, 0)), - (("Attribute Statistic",), (-950, 1020)), - (("Math.002", "Math.003"), (-730, 240)), - (("Set Position.001",), (910, 300)), - (("Set Position.002",), (690, -300)), - (("Math.005",), (1870, 340)), - (("Switch.001",), (1870, -240)), - ), - ), - "O Image Surface": ( - { - "Math": (-90, 680), - "Math.001": (140, 680), - "Edge Vertices": (-1630, -340), - "Vector Math": (-1410, -340), - "Edge Neighbors": (-1630, -560), - "Math.002": (-1410, -560), - "Attribute Statistic": (-1190, -340), - "Math.003": (-960, -340), - "Math.004": (-740, -340), - "Float to Integer": (-520, -340), - "Integer Math": (-290, -340), - "Integer Math.001": (-70, -340), - "Math.005": (190, -580), - "Math.006": (410, -580), - "Combine XYZ": (1630, 480), - "Set Position": (1860, 260), - "Repeat Input": (410, 0), - "Extrude Mesh": (640, 0), - "Separate Geometry": (860, 0), - "Join Geometry": (1080, -180), - "Repeat Output": (1310, 0), - "Combine XYZ.001": (1410, 270), - "Set Position.001": (1630, 0), - "Flip Faces": (1860, 0), - "Combine XYZ.002": (1410, -470), - "Set Position.002": (1630, -260), - "Flip Faces.001": (1860, -260), - "Join Geometry.001": (2150, 0), - "Merge by Distance": (2370, 0), - "Math.007": (2820, 290), - "Switch": (2820, 0), - "Set Shade Smooth": (3040, 0), - "Group Output": (3270, 0), - }, - ( - (("Math",), (-310, 680)), - (("Attribute Statistic",), (-1410, -170)), - (("Math.004",), (-960, -610)), - (("Math.005",), (-30, -580)), - (("Repeat Input",), (190, 0)), - (("Set Position",), (1630, 260)), - (("Set Position.002",), (1410, -260)), - (("Math.007",), (2600, 310)), - (("Switch",), (2600, -240)), - ), - ), - "O Image Depth Balloon": ( - { - "Named Attribute": (-1120, 650), - "Attribute Statistic": (-890, 650), - "Math": (-670, 650), - "Math.001": (-450, 650), - "Map Range": (-220, 650), - "Named Attribute.001": (-1790, -440), - "Image Texture": (-1570, -440), - "Separate XYZ": (-1340, -440), - "Math.002": (-1120, -440), - "Vector Math": (-2240, -950), - "Separate XYZ.001": (-2010, -950), - "Position": (-2240, -1260), - "Separate XYZ.002": (-2010, -1260), - "Math.003": (-1790, -950), - "Math.004": (-1790, -1160), - "Math.005": (-1570, -950), - "Math.006": (-1340, -950), - "Math.007": (-1120, -950), - "Math.008": (-890, -440), - "Math.009": (-670, -440), - "Math.010": (-450, -440), - "Math.011": (-670, 310), - "Math.012": (-670, 70), - "Math.013": (-450, 70), - "Mix": (20, 440), - "Math.014": (240, 710), - "Switch": (460, 710), - "Extrude Mesh": (0, 0), - "Switch.001": (460, 310), - "Position.001": (280, 530), - "Separate XYZ.003": (460, 530), - "Combine XYZ": (660, 430), - "Set Position": (930, 0), - "Separate Geometry": (1150, 0), - "Flip Faces": (1600, 0), - "Combine XYZ.001": (1380, 570), - "Set Position.001": (1600, 330), - "Combine XYZ.002": (1150, -570), - "Set Position.002": (1380, -320), - "Flip Faces.001": (1600, -320), - "Join Geometry": (1870, 0), - "Merge by Distance": (2320, 0), - "Position.002": (1600, 1120), - "Separate XYZ.004": (1820, 1120), - "Combine XYZ.003": (2050, 950), - "Set Position.003": (2320, 680), - "Math.015": (2560, -270), - "Switch.002": (2560, 0), - "Set Shade Smooth": (2790, 0), - "Group Output": (3010, 0), - }, - ( - (("Extrude Mesh",), (-220, 0)), - (("Attribute Statistic",), (-1120, 850)), - (("Image Texture",), (-1790, -270)), - (("Math.002",), (-1340, -750)), - (("Vector Math",), (-2460, -950)), - (("Math.007",), (-1340, -1160)), - (("Math.010",), (-670, -650)), - (("Math.011", "Math.012"), (-890, 310)), - (("Switch",), (240, 920)), - (("Set Position.001",), (1380, 330)), - (("Set Position.002",), (1150, -320)), - (("Set Position.003",), (2100, 610)), - (("Math.015",), (2340, -270)), - ), - ), } diff --git a/tools/node_assets/node_graph.py b/tools/node_assets/node_graph.py index c13a1ab..ea417ec 100644 --- a/tools/node_assets/node_graph.py +++ b/tools/node_assets/node_graph.py @@ -56,13 +56,13 @@ def edge_boundary_field(nodes, links, location): def expand_smooth( - group, geometry, x_offset=0.0, iterations_name="Iterations" + group, geometry, x_offset=0.0, iterations_name="Iterations", weight_name="Smooth" ): nodes = group.nodes links = group.links existing_nodes = set(nodes) iteration_controls = group_input(nodes, (550.0, 1150.0), {iterations_name}) - weight_controls = group_input(nodes, (2300.0, 1450.0), {"Smooth"}) + weight_controls = group_input(nodes, (2300.0, 1450.0), {weight_name}) repeat_output = nodes.new("GeometryNodeRepeatOutput") repeat_output.location = (3050.0, 550.0) repeat_input = nodes.new("GeometryNodeRepeatInput") @@ -153,7 +153,7 @@ def expand_smooth( smooth_weight = nodes.new("ShaderNodeMath") smooth_weight.operation = "MULTIPLY" smooth_weight.location = (2300.0, 1050.0) - links.new(weight_controls.outputs["Smooth"], smooth_weight.inputs[0]) + links.new(weight_controls.outputs[weight_name], smooth_weight.inputs[0]) links.new(sharp_switch.outputs["Output"], smooth_weight.inputs[1]) target_position = nodes.new("GeometryNodeSwitch") @@ -178,4 +178,3 @@ def expand_smooth( for node in set(nodes) - existing_nodes: node.location.x += x_offset return repeat_output.outputs["Geometry"] - diff --git a/tools/node_assets/validate.py b/tools/node_assets/validate.py index c10a9dd..dc268e9 100644 --- a/tools/node_assets/validate.py +++ b/tools/node_assets/validate.py @@ -18,7 +18,6 @@ from anyimage.common.material import ( ) from anyimage.common.node import load_node_group, node_asset_path from anyimage.operators.cutout_tool.shape import ( - CUTOUT_NODE_GROUP_NAME, CUTOUT_NODE_GROUP_NAMES, ) @@ -41,9 +40,8 @@ def main(): image_layer = load_node_group(IMAGE_MATERIAL_NODE_GROUP_NAME) depth_layer = load_node_group(DEPTH_MATERIAL_NODE_GROUP_NAME) shadeless = load_node_group("O Shadeless") - cutout = load_node_group(CUTOUT_NODE_GROUP_NAME) cutout_groups = { - name: bpy.data.node_groups[name] for name in CUTOUT_NODE_GROUP_NAMES.values() + name: load_node_group(name) for name in set(CUTOUT_NODE_GROUP_NAMES.values()) } expected_names = { "O Mesh Plane", @@ -51,7 +49,6 @@ def main(): IMAGE_MATERIAL_NODE_GROUP_NAME, DEPTH_MATERIAL_NODE_GROUP_NAME, "O Shadeless", - CUTOUT_NODE_GROUP_NAME, *CUTOUT_NODE_GROUP_NAMES.values(), } with bpy.data.libraries.load(str(node_asset_path()), link=False) as (source, _): @@ -115,19 +112,10 @@ def main(): assert shadeless.bl_idname == "ShaderNodeTree" assert input_names(shadeless) == ["Color"] - assert cutout.name == CUTOUT_NODE_GROUP_NAME - assert cutout.bl_idname == "GeometryNodeTree" - assert cutout.is_modifier - assert input_names(cutout)[:4] == [ - "Geometry", - "Shape", - "Inward Axis", - "Thickness", - ] assert set(cutout_groups) == set(CUTOUT_NODE_GROUP_NAMES.values()) for group in cutout_groups.values(): assert group.bl_idname == "GeometryNodeTree" - assert not group.is_modifier + assert group.is_modifier assert input_names(group)[0] == "Geometry" public_groups = { @@ -136,7 +124,7 @@ def main(): image_layer, depth_layer, shadeless, - cutout, + *cutout_groups.values(), } loaded = {*public_groups, *cutout_groups.values()} for group in loaded: diff --git a/tools/node_assets/validate_cutout.py b/tools/node_assets/validate_cutout.py index d82828c..b5b192d 100644 --- a/tools/node_assets/validate_cutout.py +++ b/tools/node_assets/validate_cutout.py @@ -1,676 +1,92 @@ +"""Validate direct Cutout assets and their evaluated defaults.""" +from pathlib import Path import sys from collections import Counter -from itertools import pairwise -from pathlib import Path - import bpy +import numpy as np -PROJECT_ROOT = Path(__file__).parents[2] +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "src")) sys.path.insert(0, str(Path(__file__).parent)) -sys.path.insert(0, str(PROJECT_ROOT / "src")) sys.path.insert( 0, - str(next((PROJECT_ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), + str(next((ROOT / "src" / "anyimage" / "wheels").glob("blendjob-*.whl"))), ) - +from anyimage.operators.cutout_tool.object import load_cutout_node_group +from anyimage.common.object import modifier_input_identifier, set_modifier_input from node_layout import validate_node_layout -from anyimage.common.object import ( - modifier_input_identifier, - set_modifier_input, -) -from anyimage.operators.cutout_tool import object as cutout_objects -from anyimage.operators.cutout_tool import shape as shapes - - -def evaluated_mesh(obj): - evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) - result = evaluated.to_mesh() - return evaluated, result - - -def validate_depth_surface_layout(group): - """Check the geometry path, stage spacing and drawn node bounds.""" - nodes = [node for node in group.nodes if node.bl_idname != "NodeReroute"] - main = [ - group.nodes[name] - for name in ( - "Store Named Attribute", - "Store Named Attribute.001", - "Store Named Attribute.002", - "Store Named Attribute.003", - "Split Edges", - "Switch", - "Delete Geometry", - "Store Named Attribute.004", - "Set Position", - "Store Named Attribute.005", - "Set Position.001", - "Store Named Attribute.006", - "Store Named Attribute.007", - "Repeat Input", - "Extrude Mesh", - "Separate Geometry", - "Repeat Output", - "Flip Faces.001", - "Join Geometry.001", - "Set Position.003", - "Merge by Distance", - "Set Position.004", - "Switch.002", - "Remove Named Attribute", - "Set Shade Smooth", - "Group Output", - ) - ] - assert len({node.location.y for node in main}) == 1 - assert all(right.location.x > left.location.x for left, right in pairwise(main)) - for left, right in ( - ("Switch", "Delete Geometry"), - ("Delete Geometry", "Store Named Attribute.004"), - ("Set Position", "Store Named Attribute.005"), - ("Switch.002", "Remove Named Attribute"), - ): - a, b = group.nodes[left], group.nodes[right] - assert b.location.x - (a.location.x + a.width) >= 280 - assert not any(n.bl_idname == "FunctionNodeInputString" for n in nodes) - assert min(n.location.x for n in nodes) < main[0].location.x - - def height(node): - if node.dimensions.y: - return node.dimensions.y - if node.hide or node.bl_idname == "NodeReroute": - return 30.0 - if node.bl_idname == "GeometryNodeJoinGeometry": - return 60.0 - return node.height - - for index, node in enumerate(nodes): - width = node.dimensions.x or node.width - for other in nodes[index + 1 :]: - other_width = other.dimensions.x or other.width - overlap = ( - node.location.x < other.location.x + other_width - and node.location.x + width > other.location.x - and node.location.y - height(node) < other.location.y - and node.location.y > other.location.y - height(other) - ) - assert not overlap, (node.name, other.name) - repeat_input = next( - node for node in nodes if node.bl_idname == "GeometryNodeRepeatInput" - ) - repeat_output = repeat_input.paired_output - inside = {repeat_output} - pending = [repeat_input] - while pending: - node = pending.pop() - if node in inside: - continue - inside.add(node) - pending.extend(link.to_node for link in group.links if link.from_node == node) - zone_bottom = min(node.location.y - height(node) for node in inside) - 60.0 - zone_top = max(node.location.y for node in inside) + 60.0 - for node in nodes: - if repeat_input.location.x <= node.location.x <= repeat_output.location.x: - assert ( - node in inside - or node.location.y <= zone_bottom - or node.location.y - height(node) >= zone_top - ), node.name - - -def validate_depth_surface_attributes(group): - """Check temporary fields are typed, locally read and removed at output.""" - expected = { - "o_depth_face_center": ("FLOAT_VECTOR", "FACE"), - "o_depth_face_camera": ("FLOAT_VECTOR", "FACE"), - "o_depth_corner_camera": ("FLOAT_VECTOR", "CORNER"), - "o_depth_cut_vertex": ("BOOLEAN", "CORNER"), - "o_depth_camera": ("FLOAT_VECTOR", "POINT"), - "o_depth_surface_position": ("FLOAT_VECTOR", "POINT"), - "o_depth_source_vertex": ("INT", "POINT"), - "o_depth_wall_layers": ("FLOAT", "POINT"), - } - stores = { - n.inputs["Name"].default_value: (n.data_type, n.domain) - for n in group.nodes - if n.bl_idname == "GeometryNodeStoreNamedAttribute" - } - assert stores == expected - assert not any(n.bl_idname == "GeometryNodeCaptureAttribute" for n in group.nodes) - removes = [n for n in group.nodes if n.bl_idname == "GeometryNodeRemoveAttribute"] - assert len(removes) == 1 - assert removes[0].pattern_mode == "WILDCARD" - assert removes[0].inputs["Name"].default_value == "o_depth_*" - for node in group.nodes: - if node.bl_idname == "GeometryNodeInputNamedAttribute": - name = node.inputs["Name"].default_value - assert name == "UVMap" or node.data_type == expected[name][0] - assert all( - abs(link.to_node.location.x - node.location.x) <= 600 - for link in group.links - if link.from_node == node - ) - - -def input_names(group): - return [ - item.name - for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.in_out == "INPUT" - ] - - -def grid_object(name): - vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)] - faces = [] - for y in range(2): - for x in range(2): - a = y * 3 + x - faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) - data = bpy.data.meshes.new(name) - data.from_pydata(vertices, (), faces) - uv_layer = data.uv_layers.new(name="UVMap") - for loop in data.loops: - coordinate = data.vertices[loop.vertex_index].co - uv_layer.data[loop.index].uv = (coordinate.x * 0.5, coordinate.y * 0.5) - obj = bpy.data.objects.new(name, data) - bpy.context.scene.collection.objects.link(obj) - return obj - - -def add_modifier(obj, shape, thickness): - group = cutout_objects.load_cutout_node_group() - modifier = obj.modifiers.new(group.name, "NODES") - modifier.node_group = group - for name, value in ( - ("Shape", shapes.CUTOUT_SHAPE_INDICES[shape]), - ("Inward Axis", 0), - ("Cleanup", 0.0), - ("Smooth", 0.0), - ("Smooth Iterations", 0), - ("Thickness", thickness), - ): - set_modifier_input( - modifier, - modifier_input_identifier(group, name), - value, - ) - return modifier - def main(): bpy.ops.wm.read_factory_settings(use_empty=True) - wrapper = cutout_objects.load_cutout_node_group() - assert all( - cutout_objects.load_cutout_node_group() == wrapper - for shape in shapes.CUTOUT_SHAPES - ) - assert wrapper.name == shapes.CUTOUT_NODE_GROUP_NAME - assert wrapper.is_modifier - groups = { - shape: bpy.data.node_groups[name] - for shape, name in shapes.CUTOUT_NODE_GROUP_NAMES.items() - } - validate_depth_surface_layout(groups["DEPTH_SURFACE"]) - assert all(not group.is_modifier for group in groups.values()) - - menu_switch = next( - node for node in wrapper.nodes if node.bl_idname == "GeometryNodeMenuSwitch" - ) - assert [item.name for item in menu_switch.enum_items] == list( - shapes.CUTOUT_SHAPE_LABELS.values() - ) - assert any( - link.from_socket.name == "Shape" - and link.to_node == menu_switch - and link.to_socket.name == "Menu" - for link in wrapper.links - ) - axis_switch = next( - node - for node in wrapper.nodes - if node.bl_idname == "GeometryNodeMenuSwitch" - and [item.name for item in node.enum_items] == ["-Z", "+X"] - ) - assert any( - link.from_socket.name == "Inward Axis" - and link.to_node == axis_switch - and link.to_socket.name == "Menu" - for link in wrapper.links - ) - assert all( - any( - link.to_node == axis_switch and link.to_socket.name == axis - for link in wrapper.links - ) - for axis in ("-Z", "+X") - ) - negative_z_link = next( - link - for link in wrapper.links - if link.to_node == axis_switch and link.to_socket.name == "-Z" - ) - positive_x_link = next( - link - for link in wrapper.links - if link.to_node == axis_switch and link.to_socket.name == "+X" - ) - assert positive_x_link.from_node.bl_idname == "GeometryNodeTransform" - assert any( - link.from_socket == negative_z_link.from_socket - and link.to_node == positive_x_link.from_node - and link.to_socket.name == "Geometry" - for link in wrapper.links - ) - child_nodes = { - node.node_tree: node - for node in wrapper.nodes - if node.bl_idname == "GeometryNodeGroup" - } - assert set(child_nodes) == set(groups.values()) - for shape, group in groups.items(): - assert any( - link.from_node == child_nodes[group] - and link.to_node == menu_switch - and link.to_socket.name == shapes.CUTOUT_SHAPE_LABELS[shape] - for link in wrapper.links - ) - - assert input_names(wrapper) == [ - "Geometry", - "Shape", - "Inward Axis", - "Thickness", - "Depth Scale", - "Double Sided", - "Smooth Iterations", - "Cleanup", - "Smooth", - "Split", - "Reference Depth", - "Depth Direction", - "Uniform Scale", - "Depth Image", - ] - expected_inputs = { - "SURFACE": ["Geometry", "Thickness"], - "BALLOON": ["Geometry", "Thickness"], - "DEPTH_BALLOON": [ - "Geometry", - "Thickness", - "Depth Scale", - "Double Sided", - "Reference Depth", - "Depth Direction", - "Uniform Scale", - "Depth Image", - ], - "DEPTH_SURFACE": [ - "Geometry", - "Thickness", - "Depth Scale", - "Split", - "Cleanup", - "Reference Depth", - "Uniform Scale", - "Depth Image", - ], - } - for shape, group in groups.items(): - assert input_names(group) == expected_inputs[shape] - - dependencies = {} - for link in wrapper.links: - if link.from_node.bl_idname != "NodeGroupInput": - continue - if link.to_node.bl_idname != "GeometryNodeGroup": - continue - shape = next( - shape for shape, group in groups.items() if group == link.to_node.node_tree - ) - dependencies.setdefault(link.from_socket.name, set()).add(shape) - assert dependencies == { - "Geometry": {"DEPTH_SURFACE"}, - "Thickness": set(shapes.CUTOUT_SHAPES), - "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, - "Split": {"DEPTH_SURFACE"}, - "Double Sided": {"DEPTH_BALLOON"}, - "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, - "Depth Direction": {"DEPTH_BALLOON"}, - "Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, - "Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, - } - - repeat_counts = { - shape: sum(node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes) - for shape, group in groups.items() - } - assert repeat_counts == { - "SURFACE": 1, - "BALLOON": 0, - "DEPTH_BALLOON": 0, - "DEPTH_SURFACE": 1, - } - assert ( - sum(node.bl_idname == "GeometryNodeRepeatInput" for node in wrapper.nodes) == 1 - ) - assert ( - sum(node.bl_idname == "GeometryNodeInputMeshIsland" for node in wrapper.nodes) - == 1 - ) - assert not any( - node.bl_idname == "GeometryNodeAttributeStatistic" for node in wrapper.nodes - ) - assert all( - not any(node.bl_idname == "GeometryNodeInputMeshIsland" for node in group.nodes) - for shape, group in groups.items() - if shape != "DEPTH_SURFACE" - ) - assert any(node.bl_idname == "GeometryNodeBlurAttribute" for node in wrapper.nodes) - smooth = next( - item - for item in wrapper.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Smooth" - ) - iterations = next( - item - for item in wrapper.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Smooth Iterations" - ) - cleanup = next( - item - for item in wrapper.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Cleanup" - ) - assert cleanup.default_value == 0.0 - assert cleanup.subtype == "NONE" - split_control = next( - item - for item in wrapper.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Split" - ) - child_split = next( - item - for item in groups["DEPTH_SURFACE"].interface.items_tree - if item.item_type == "SOCKET" and item.name == "Split" - ) - assert split_control.default_value == 0.0 - assert split_control.min_value == 0.0 - assert split_control.max_value == 1.0 - assert split_control.subtype == "FACTOR" - assert getattr(split_control.parent, "name", "") == "Options" - assert child_split.default_value == 0.0 - assert child_split.min_value == 0.0 - assert child_split.max_value == 1.0 - assert child_split.subtype == "FACTOR" - assert getattr(child_split.parent, "name", "") == "Options" - depth_surface_nodes = groups["DEPTH_SURFACE"].nodes - assert any( - node.bl_idname == "GeometryNodeSplitEdges" for node in depth_surface_nodes - ) - validate_depth_surface_attributes(groups["DEPTH_SURFACE"]) - assert [ - node.domain - for node in depth_surface_nodes - if node.bl_idname == "GeometryNodeDeleteGeometry" - ] == ["FACE"] - assert "Stretch Limit" not in [ - item.name for item in groups["DEPTH_SURFACE"].interface.items_tree - ] - assert all( - socket.is_linked or socket.hide - for node in depth_surface_nodes - for socket in node.outputs - ) - for node in depth_surface_nodes: - if node.bl_idname == "GeometryNodeRepeatInput": - assert node.paired_output.location.x > node.location.x - cleanup_scale = next( - link.to_node - for link in wrapper.links - if link.from_socket.name == "Cleanup" - and link.from_node.bl_idname == "NodeGroupInput" - ) - assert cleanup_scale.bl_idname == "ShaderNodeMath" - assert cleanup_scale.operation == "MULTIPLY" - assert abs(cleanup_scale.inputs[1].default_value - 0.01) < 1e-7 - assert smooth.default_value == 1.0 - assert iterations.default_value == 0 - assert iterations.min_value == 0 - assert getattr(iterations.parent, "name", "") == "" - assert all( - not any(node.bl_idname == "GeometryNodeBlurAttribute" for node in group.nodes) - for group in groups.values() - ) - - for group in (wrapper, *groups.values()): + groups = {shape: load_cutout_node_group(shape) for shape in ("FLAT", "SOLID", "DEPTH_SOLID", "DEPTH_BALLOON")} + assert groups["FLAT"] == groups["SOLID"] + assert len(set(groups.values())) == 3 + for group in set(groups.values()): validate_node_layout(group) - for item in group.interface.items_tree: - if item.item_type == "SOCKET" and item.in_out == "INPUT": - assert item.structure_type == "SINGLE" - surface = grid_object("Surface Validation") - add_modifier(surface, "SURFACE", 2.4) - evaluated, result = evaluated_mesh(surface) - try: - assert len(result.vertices) == 34 - assert len(result.polygons) == 40 - side_faces = [ - polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1 - ] - assert len(side_faces) == 24 - finally: - evaluated.to_mesh_clear() - - balloon = grid_object("Balloon Validation") - profile = balloon.data.attributes.new( - shapes.BALLOON_ATTRIBUTE_NAME, "FLOAT", "POINT" - ) - profile.data.foreach_set( - "value", - (0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0), - ) - modifier = add_modifier(balloon, "BALLOON", 1.0) - evaluated, result = evaluated_mesh(balloon) - try: - initial_faces = len(result.polygons) - initial_extent = max(abs(vertex.co.z) for vertex in result.vertices) - finally: - evaluated.to_mesh_clear() - set_modifier_input( - modifier, - modifier_input_identifier(modifier.node_group, "Thickness"), - 2.0, - ) - balloon.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - evaluated, result = evaluated_mesh(balloon) - try: - assert len(result.polygons) == initial_faces - extent = max(abs(vertex.co.z) for vertex in result.vertices) - assert abs(extent - initial_extent * 2.0) < 1e-5 - finally: - evaluated.to_mesh_clear() - - depth_balloon = grid_object("Depth Balloon Metric Validation") - profile = depth_balloon.data.attributes.new( - shapes.BALLOON_ATTRIBUTE_NAME, "FLOAT", "POINT" - ) - profile.data.foreach_set("value", (1.0,) * 9) - modifier = add_modifier(depth_balloon, "DEPTH_BALLOON", 0.0) - point_image = bpy.data.images.new( - "Depth Balloon Points", - width=3, - height=3, - alpha=True, - float_buffer=True, - ) - point_image.colorspace_settings.name = "Non-Color" - point_image.alpha_mode = "CHANNEL_PACKED" - pixels = [] - for z in range(3): - for y in range(3): - if (y, z) == (1, 1): - depth = 1.5 - elif (y, z) == (0, 0): - depth = 2.5 + assert group.is_modifier and group.get("anyimage_asset_revision") == 11 + assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) + inputs = {item.name: item for item in group.interface.items_tree if item.item_type == "SOCKET" and item.in_out == "INPUT"} + assert "Shape" not in inputs and "Rim Thickness" not in inputs + assert next(s for s in group.interface.items_tree if s.name == "Thickness").default_value == 1 + assert all(item.structure_type == "SINGLE" for item in inputs.values()) + if "Mode" in inputs: + assert inputs["Thickness"].subtype == "DISTANCE" + assert inputs["Mode"].default_value == "Balloon" + if "Double Sided" in inputs: + assert inputs["Double Sided"].default_value + if group == groups["DEPTH_SOLID"]: + assert np.isclose(inputs["Edge Round"].default_value, 0.2) + assert inputs["Edge Round"].parent.name == "Options" + assert list(inputs)[list(inputs).index("Front Inflation") - 1] == "Edge Round" + assert inputs["Normal Smooth"].default_value == 50 + assert inputs["Front Inflation"].default_value == 1 + assert inputs["Front Inflation"].parent.name == "Options" + assert not set(inputs) & {"Edge Round Range", "Back Rim Range", "Normal Influence", "Edge Round Amount", "Back Rim Fullness"} + 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"] + elif group == groups["DEPTH_BALLOON"]: + expected += ["Reference Depth"] + expected += ["Smooth Weight"] + if group == groups["DEPTH_BALLOON"]: + expected += ["Depth Direction"] + 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"] + 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)]) + profile = mesh.attributes.new("o_balloon", "FLOAT", "POINT") + profile.data.foreach_set("value", [0,0,0,0,0.6]) + obj = bpy.data.objects.new("Profile", mesh) + bpy.context.collection.objects.link(obj) + modifier = obj.modifiers.new("Cutout", "NODES") + modifier.node_group = groups["SOLID"] + for mode, thickness in ((0, 0), (0, 1), (1, 0), (1, 0.4)): + for name, value in (("Mode", mode), ("Thickness", thickness)): + subtype = ("DISTANCE" if mode else "NONE") if name == "Thickness" else None + set_modifier_input(modifier, modifier_input_identifier(modifier.node_group, name, subtype=subtype), value) + obj.update_tag(refresh={"DATA"}) + bpy.context.view_layer.update() + evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) + result = evaluated.to_mesh() + try: + z = np.array([vertex.co.z for vertex in result.vertices]) + assert np.isfinite(z).all() and len(z) + if thickness == 0: + assert len(result.polygons) == 4 and np.max(abs(z)) < 1e-7 + assert all(abs(item.value - 1) < 1e-7 for item in result.attributes["o_normal_scale"].data) else: - depth = 2.0 - pixels.extend((0.0, 0.0, depth, 0.0)) - point_image.pixels.foreach_set(pixels) - for name, value in ( - ("Depth Image", point_image), - ("Depth Scale", 1.0), - ("Uniform Scale", 1.0), - ("Reference Depth", 2.0), - ("Depth Direction", (0.0, 0.0, 1.0)), - ): - set_modifier_input( - modifier, - modifier_input_identifier(modifier.node_group, name), - value, - ) - depth_balloon.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - evaluated, result = evaluated_mesh(depth_balloon) - try: - z_values = [vertex.co.z for vertex in result.vertices] - assert abs(min(z_values)) < 1e-5 - assert abs(max(z_values) - 0.5) < 1e-5 - finally: - evaluated.to_mesh_clear() - - pixels[(1 * 3 + 1) * 4 + 3] = -1.0 - point_image.pixels.foreach_set(pixels) - point_image.update() - depth_balloon.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - evaluated, result = evaluated_mesh(depth_balloon) - try: - z_values = [vertex.co.z for vertex in result.vertices] - assert abs(min(z_values)) < 1e-5 - assert abs(max(z_values) - 0.5) < 1e-5 - finally: - evaluated.to_mesh_clear() - - depth_surface = grid_object("Depth Surface Projection Validation") - modifier = add_modifier(depth_surface, "DEPTH_SURFACE", 0.0) - point_image = bpy.data.images.new( - "Depth Surface Points", - width=3, - height=3, - alpha=True, - float_buffer=True, - ) - point_image.colorspace_settings.name = "Non-Color" - point_image.alpha_mode = "CHANNEL_PACKED" - pixels = [] - for y in range(3): - for x in range(3): - depth = 7.0 if (x, y) == (0, 0) else 2.0 + x * 0.25 - pixels.extend((x * 0.5, -y * 0.5, depth, 0.0)) - point_image.pixels.foreach_set(pixels) - for name, value in ( - ("Depth Image", point_image), - ("Depth Scale", 1.0), - ("Uniform Scale", 1.0), - ("Reference Depth", 2.0), - ("Smooth Iterations", 0), - ): - set_modifier_input( - modifier, - modifier_input_identifier(modifier.node_group, name), - value, - ) - depth_surface.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - evaluated, result = evaluated_mesh(depth_surface) - try: - coordinates = [tuple(vertex.co) for vertex in result.vertices] - assert all( - all( - abs(actual - expected) < 1e-5 for actual, expected in zip(point, target) - ) - for point, target in zip( - coordinates, - ( - ( - x * 0.5, - y * 0.5, - -5.0 if (x, y) == (0, 0) else -x * 0.25, - ) - for y in range(3) - for x in range(3) - ), - ) - ) - finally: - evaluated.to_mesh_clear() - - set_modifier_input( - modifier, - modifier_input_identifier(modifier.node_group, "Split"), - 0.6, - ) - depth_surface.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - evaluated, result = evaluated_mesh(depth_surface) - try: - assert len(result.polygons) == 8 - face_edges = { - tuple(sorted((start, end))) - for polygon in result.polygons - for start, end in zip( - polygon.vertices, - (*polygon.vertices[1:], polygon.vertices[0]), - ) - } - assert all(tuple(sorted(edge.vertices)) in face_edges for edge in result.edges) - face_vertices = { - vertex for polygon in result.polygons for vertex in polygon.vertices - } - assert face_vertices == set(range(len(result.vertices))) - finally: - evaluated.to_mesh_clear() - - set_modifier_input( - modifier, - modifier_input_identifier(modifier.node_group, "Depth Scale"), - 0.0, - ) - depth_surface.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - evaluated, result = evaluated_mesh(depth_surface) - try: - assert len(result.vertices) == 9 - assert len(result.polygons) == 8 - finally: - evaluated.to_mesh_clear() - - set_modifier_input( - modifier, modifier_input_identifier(modifier.node_group, "Depth Scale"), 1.0 - ) - set_modifier_input( - modifier, modifier_input_identifier(modifier.node_group, "Thickness"), 0.2 - ) - depth_surface.update_tag(refresh={"DATA"}) - bpy.context.view_layer.update() - evaluated, result = evaluated_mesh(depth_surface) - try: - edge_uses = Counter( - tuple(sorted((a, b))) - for face in result.polygons - for a, b in zip(face.vertices, (*face.vertices[1:], face.vertices[0])) - ) - assert edge_uses and set(edge_uses.values()) == {2} - finally: - evaluated.to_mesh_clear() - - print( - f"Validated Blender {bpy.app.version_string}: " - f"{wrapper.name} wrapping " - f"{', '.join(group.name for group in groups.values())}" - ) + radius = 0.6 if mode == 0 else 0.2 + assert np.isclose(z.min(), -radius) and np.isclose(z.max(), radius) + uses = Counter(tuple(sorted((a,b))) for face in result.polygons for a,b in zip(face.vertices, (*face.vertices[1:],face.vertices[0]))) + assert set(uses.values()) == {2} + finally: + evaluated.to_mesh_clear() + print("Validated three Cutout assets, four presets, both thickness modes and closed defaults") if __name__ == "__main__": diff --git a/tools/pisa_sr_export.py b/tools/pisa_sr_export.py new file mode 100644 index 0000000..ec58eeb --- /dev/null +++ b/tools/pisa_sr_export.py @@ -0,0 +1,198 @@ +# /// script +# requires-python = "==3.11.*" +# dependencies = [ +# "torch==2.7.1", "torchvision==0.22.1", "diffusers==0.25.0", +# "transformers==4.28.1", "peft==0.9.0", "accelerate==0.27.2", +# "huggingface-hub==0.25.2", "numpy==1.26.4", "onnx==1.19.0", +# ] +# [tool.uv.sources] +# torch = { index = "pytorch" } +# torchvision = { index = "pytorch" } +# [[tool.uv.index]] +# name = "pytorch" +# url = "https://download.pytorch.org/whl/cu128" +# explicit = true +# /// +"""Export PiSA-SR components from pinned upstream weights on CUDA.""" + +import argparse +import hashlib +import json +import sys +from pathlib import Path + +SOURCE_COMMIT = "9c76e12e261d1980148738a0cff41e5e27d18c9f" +BASE_REPOSITORY = "sd2-community/stable-diffusion-2-1-base" +BASE_REVISION = "4e63672c03103b6c636b8fb4119ba982469b2955" +CHECKPOINT_SHA256 = "13502f7bde5e4e585f84f24c3d904d9c848e068bf89f984046fe87da81a391f6" +CHECKPOINT_URL = "https://drive.google.com/uc?export=download&id=1PWFM5zUd6lNMOyKX64anvUXhKEOCrf8L" + + +def finalize_assets(destination): + import shutil + import onnx + + for path in destination.glob("*.onnx"): + model = onnx.load(path) + onnx.helper.set_model_props(model, { + "license": "Apache-2.0; CreativeML Open RAIL++-M (LICENSE-MODEL.txt)", + "modifications": "AnyImage: merged PiSA-SR LoRA; FP16; fixed empty prompt and timestep; split VAE. See NOTICE.txt.", + "source_commit": SOURCE_COMMIT, + }) + onnx.save(model, path) + for path in (Path(__file__).parent / "model_licenses/pisa-sr").glob("*.txt"): + shutil.copyfile(path, destination / path.name) + + +def prepare_sources(cache): + import io + import zipfile + from urllib.request import urlopen + from huggingface_hub import snapshot_download + + source = cache / f"PiSA-SR-{SOURCE_COMMIT}" + if not source.exists(): + url = f"https://codeload.github.com/csslc/PiSA-SR/zip/{SOURCE_COMMIT}" + with urlopen(url, timeout=60) as response: + archive = zipfile.ZipFile(io.BytesIO(response.read())) + for member in archive.infolist(): + destination = (cache / member.filename).resolve() + if not destination.is_relative_to(cache.resolve()): + raise ValueError("Invalid upstream archive path") + archive.extractall(cache) + checkpoint = cache / "pisa_sr.pkl" + if not checkpoint.exists(): + with urlopen(CHECKPOINT_URL, timeout=60) as response: + checkpoint.write_bytes(response.read()) + if hashlib.sha256(checkpoint.read_bytes()).hexdigest() != CHECKPOINT_SHA256: + raise ValueError("PiSA-SR checkpoint checksum mismatch") + base = snapshot_download( + BASE_REPOSITORY, revision=BASE_REVISION, local_dir=cache / "base-model", + allow_patterns=["*/*.json", "tokenizer/*", "*/*.fp16.safetensors"], + ) + return source, Path(base), checkpoint + + +def export_components(source, base, checkpoint, destination): + import gc + import numpy as np + import torch + from peft import LoraConfig + from diffusers.utils import set_weights_and_activate_adapters + from transformers import AutoTokenizer, CLIPTextModel + + if hashlib.sha256(checkpoint.read_bytes()).hexdigest() != CHECKPOINT_SHA256: + raise ValueError("PiSA-SR checkpoint checksum mismatch") + sys.path.insert(0, str(source)) + from src.models.autoencoder_kl import AutoencoderKL + from src.models.unet_2d_condition import UNet2DConditionModel + + class Residual(torch.nn.Module): + def __init__(self, model, prompt): + super().__init__() + self.model = model + self.register_buffer("prompt", prompt) + self.register_buffer("timestep", torch.tensor([1], dtype=torch.long)) + + def forward(self, latent): + return self.model(latent, self.timestep, encoder_hidden_states=self.prompt).sample + + class Encoder(torch.nn.Module): + def __init__(self, model): + super().__init__() + self.encoder = model.encoder + self.quant_conv = model.quant_conv + + def forward(self, image): + return self.quant_conv(self.encoder(image)) + + class Decoder(torch.nn.Module): + def __init__(self, model): + super().__init__() + self.decoder = model.decoder + self.post_quant_conv = model.post_quant_conv + + def forward(self, latent): + return self.decoder(self.post_quant_conv(latent / 0.18215)).clamp(-1, 1) + + def export(wrapper, component, tensor): + wrapper.eval().cuda() + with torch.inference_mode(): + torch.onnx.export( + wrapper, (tensor.cuda(),), str(destination / f"{component}.onnx"), + opset_version=17, input_names=["input"], output_names=["output"], + do_constant_folding=True, dynamo=False, + ) + wrapper.cpu() + torch.cuda.empty_cache() + + destination.mkdir(parents=True, exist_ok=True) + torch.set_num_threads(8) + torch.backends.cuda.matmul.allow_tf32 = False + tokenizer = AutoTokenizer.from_pretrained(base, subfolder="tokenizer") + text = CLIPTextModel.from_pretrained( + base, subfolder="text_encoder", variant="fp16", torch_dtype=torch.float16, + ).eval().cuda() + with torch.inference_mode(): + tokens = tokenizer("", max_length=tokenizer.model_max_length, + padding="max_length", truncation=True, return_tensors="pt") + prompt = text(tokens.input_ids.cuda())[0].cpu() + del text + torch.cuda.empty_cache() + state = torch.load(checkpoint, map_location="cpu", weights_only=True) + latent = torch.zeros((1, 4, 64, 64), dtype=torch.float16) + unet = UNet2DConditionModel.from_pretrained(base, subfolder="unet", variant="fp16") + for strength, component in (("pix", "unet-pixel"), ("sem", "unet-full")): + adapters = [] + for part in ("encoder", "decoder", "others"): + key = f"{part}_modules" + config = LoraConfig( + r=state[f"lora_rank_unet_{strength}"], init_lora_weights="gaussian", + target_modules=state[f"unet_lora_{key}_{strength}"], + ) + name = f"default_{part}_{strength}" + unet.add_adapter(config, adapter_name=name) + adapters.append(name) + for name, parameter in unet.named_parameters(): + if "lora" in name: + parameter.data.copy_(state["state_dict_unet"][name]) + set_weights_and_activate_adapters(unet, adapters, [1.0]*3) + unet.merge_and_unload() + # Export a half-precision copy; retain FP32 weights for the semantic merge. + import copy + wrapper = Residual(copy.deepcopy(unet).half(), prompt) + export(wrapper, component, latent) + del wrapper + gc.collect() + del unet, state + gc.collect() + vae = AutoencoderKL.from_pretrained(base, subfolder="vae", variant="fp16", torch_dtype=torch.float16) + export(Encoder(vae), "encoder", torch.zeros((1,3,512,512), dtype=torch.float16)) + export(Decoder(vae), "decoder", latent) + torch.manual_seed(42) + np.save(destination / "noise.npy", torch.randn((1,4,64,64), device="cuda", dtype=torch.float16).cpu().numpy()) + finalize_assets(destination) + files = [] + for path in sorted(destination.iterdir()): + if path.suffix in (".onnx", ".npy", ".txt"): + with path.open("rb") as stream: + checksum = hashlib.file_digest(stream, "sha256").hexdigest() + files.append((path.name, path.stat().st_size, checksum)) + (destination / "provenance.json").write_text(json.dumps({ + "source_commit": SOURCE_COMMIT, "base_repository": BASE_REPOSITORY, + "base_revision": BASE_REVISION, "checkpoint_sha256": CHECKPOINT_SHA256, + "licenses": ["Apache-2.0", "CreativeML Open RAIL++-M"], "files": files, + }, indent=2)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--cache", type=Path, default=Path(".model-cache/pisa-sr")) + parser.add_argument("--destination", type=Path, default=Path("models/pisa-sr")) + args = parser.parse_args() + args.cache.mkdir(parents=True, exist_ok=True) + export_components(*prepare_sources(args.cache), args.destination) + + +if __name__ == "__main__": + main() diff --git a/tools/sync_models.py b/tools/sync_models.py index 8fbf7ef..d2d053e 100644 --- a/tools/sync_models.py +++ b/tools/sync_models.py @@ -193,6 +193,14 @@ def prepare_model(model): if model.build: build_model(model) return + if model.key == "PISA_SR": + subprocess.run( + ["uv", "run", "--script", str(PROJECT_ROOT / "tools/pisa_sr_export.py")], + cwd=PROJECT_ROOT, check=True, + ) + if not verify_file(model.destination, model.size, model.sha256): + raise RuntimeError("PiSA-SR export differs from the published catalog") + return if not model.source_url: raise RuntimeError(f"No upstream source or builder for {model.key}") download_file(