Refactor Cutout and Image Editing Logic

- Renamed "Base Plane Depth" to "Reference Depth" across validate_cutout.py for consistency.
- Introduced a new specification for Cutout mesh sampling to ensure source pixel spacing is preserved and limits are applied correctly.
- Unified naming conventions for image editing results to maintain consistency across Remove Background, Upscale, and Crop operations.
- Updated the handling of Refine Selection to ensure it retains the original bounds and does not apply unnecessary cropping.
- Enhanced material generation logic to connect image color directly to IOR Level, improving the rendering of dark areas.
- Established clear requirements for geometry depth controls and image result materials to ensure consistent behavior across different components.
- Removed outdated logic and ensured all changes are documented and tested for regression.
This commit is contained in:
2026-09-03 01:02:10 +08:00
parent a6041cbe03
commit bdbde1a10d
47 changed files with 1120 additions and 257 deletions

View File

@@ -38,7 +38,7 @@ flowchart TD
Menu --> Runtime["注册 JobRuntime"]
```
`__init__.py` 按依赖顺序注册,并在注销时逆序清理。扩展级设置统一由 `preferences.addon_preferences()` 读取;`runtime.py` 只消费 `configured_storage_root` 回调,不持有 Blender 偏好设置的定位逻辑。交互中的临时状态和 Debug 参数保存在 `Scene.anyimage_settings`。`max_ai_input_size` 与 `max_cutout_mesh_resolution` 都在 `preferences.py` 定义与读取:前者由 Operator 冻结到 AI Job 请求,后者在 Blender 侧统一限制所有 Cutout Shape 的网格采样密度。Upscale 还用 AI 输入上限判断当前 Image 是否允许执行。
`__init__.py` 按依赖顺序注册,并在注销时逆序清理。扩展级设置统一由 `preferences.addon_preferences()` 读取;`runtime.py` 只消费 `configured_storage_root` 回调,不持有 Blender 偏好设置的定位逻辑。交互中的临时状态和 Debug 参数保存在 `Scene.anyimage_settings`。`max_ai_input_size` 与 `max_cutout_mesh_resolution` 都在 `preferences.py` 定义与读取:前者由 Operator 冻结到 AI Job 请求,后者在 Blender 侧统一限制所有 Cutout Shape 实际几何范围的最长采样边。Upscale 还用 AI 输入上限判断当前 Image 是否允许执行。
## 同步与异步路径

View File

@@ -22,14 +22,17 @@
| `image.image_base_name()` | 去除图片或视频扩展名和 Blender 数字后缀,得到数据块基础名。 |
| `image.depth_data_name()` | 生成 Depth 结果数据块文件名。 |
| `image.color_data_name()` | 生成 Color 结果数据块文件名。 |
| `image.load_color_result_image()` | 加载、命名、Pack 并配置 Job 的 Color 或 Sequence 结果;不修改 Empty。 |
| `image.load_image_edit_result()` | 加载、Pack 并配置直接编辑源 Image 的 Job 结果,以源 Image 完整名称请求新数据块名称。 |
| `image.load_color_result_image()` | 加载、命名、Pack 并配置材质 Color 或 Sequence 结果;不修改 Empty。 |
| `image.load_normal_result_image()` | 加载、命名、设为 Non-Color 并 Pack Normal Map。 |
| `image.load_packed_rgba_image()` | 加载已有 RGBA 文件,继承源图颜色空间和 Alpha mode,命名并 Pack。 |
| `image.create_pixel_result_image()` | 从本地编辑后的 RGBA 像素创建 Packed Image;不修改 Empty。 |
| `image.create_image_edit_result()` | 从本地编辑后的 RGBA 像素创建 Packed Image,以源 Image 完整名称请求新数据块名称;不修改 Empty。 |
| `image.replace_empty_image()` | 执行 Image Empty 结果替换、共享引用分流、旧数据清理、帧设置继承和可选画布对齐。 |
| `image.place_empty_image_result()` | 按 Keep Original 决定替换源 Empty 或复制结果,并选择、激活结果 Empty。 |
| `image.image_empty_bounds()` | 计算 Image Empty 在对象局部坐标中的实际显示边界。 |
Remove Background、Upscale 与四种 Crop 都属于直接 Image 编辑,结果保留源 Image 数据块的完整名称,包括名称中的 `.png` 等格式文字。源数据仍被其他对象引用或启用 Keep Original 时,重名结果由 Blender 自动追加数字后缀;源数据可清理时,替换入口让结果恢复准确原名。`color_data_name()`、`depth_data_name()`、`normal_data_name()` 仅用于 Plane 与 Cutout 等材质派生贴图。
## Image Selection 与视图交互
这组公共能力使用固定术语:
@@ -67,6 +70,9 @@
| --- | --- |
| `selection.selection_path_bounds()` | 校验 Selection Path 并返回整数像素包围框。 |
| `selection.rasterize_selection_path()` | 以局部 non-zero scanline 把 `SelectionPath` 栅格化为纯几何 `SelectionMask`。 |
| `selection.save_selection_mask()` | 将一次栅格化的局部 Mask 保存为 Blender 异步响应使用的 sidecar。 |
| `selection.load_selection_mask()` | 从本地 sidecar 恢复带原 bounds 的 `SelectionMask`。 |
| `selection.cleanup_selection_mask()` | 删除本地 Selection Mask sidecar。 |
| `selection.crop_pixels_to_alpha()` | 按 Alpha 非零区域紧裁切 RGBA 像素。 |
## Depth 产物
@@ -87,7 +93,7 @@
| 函数 | 职责 |
| --- | --- |
| `node.load_node_group()` | 从随扩展分发的 Blend 资产加载指定 Node Group。 |
| `material.create_image_material()` | 从 Image Empty 创建包含颜色、位移、Normal Map、Alpha 和动画设置的完整材质。 |
| `material.create_image_material()` | 从 Image Empty 创建包含颜色、位移、Normal Map、Alpha 和动画设置的完整材质;非 Shadeless 材质由 Color 直接驱动 Principled IOR Level。 |
| `object.modifier_input_identifier()` | 按接口名称解析 Geometry Nodes Modifier 输入 Identifier。 |
| `object.set_modifier_input()` | 跨受支持 Blender RNA 形式设置 Modifier 输入。 |
| `object.finalize_object_result()` | 激活结果对象,并按需替换或保留源 Empty。 |

View File

@@ -41,6 +41,8 @@ uv run build_node --skip-tests
`O Image Cutout` 在四个 Shape 分支与平滑之后统一处理 **Inward Axis**。Shape 子节点保持内部 `+X` 朝向;默认 `-Z` 分支执行一次轴转换,`+X` 分支保留内部坐标。运行时对象矩阵与该接口配对,保证两种模式落在相同世界空间位置。
`O Image Depth Plane`、`O Image Cutout`、`O Image Depth Balloon` 与 `O Image Depth Surface` 统一使用 **Reference Depth** 作为采样深度的运算基准。
## 修改节点组
1. 修改 `tools/node_assets/build_*.py`;

View File

@@ -39,13 +39,13 @@ EXR 的 RGB 重复保存完整相机 Z,Alpha 保存模型 Validity;JSON 保
### 创建 Depth Plane
Job 返回后,`object.create_depth_plane_from_result()` 加载并 Pack `z-depth.exr` 与 `tangent-normal.png`。Depth 数据块仍由 `depth_data_name()` 命名;Normal 图片设为 Non-Color,并以 Tangent Space 接入 `O Image Layer`。结果材质与 Cutout 一样使用 Principled IOR `1.2`。`depth.json` 的参考深度乘以统一尺度后初始化 **Base Plane Depth**,使它和采样深度处于同一局部距离单位。
Job 返回后,`object.create_depth_plane_from_result()` 加载并 Pack `z-depth.exr` 与 `tangent-normal.png`。Depth 数据块仍由 `depth_data_name()` 命名;Normal 图片设为 Non-Color,并以 Tangent Space 接入 `O Image Layer`。结果材质与 Cutout 一样使用 Principled IOR `1.2`,并由 `O Image Layer` Color 直接驱动 IOR Level。`depth.json` 的参考深度乘以统一尺度后初始化 **Reference Depth**,使它和采样深度处于同一局部距离单位。
`O Image Depth Plane` 把 `O Mesh Plane` 的固定底面放在原图片平面,`Thickness` 只沿正法线移动可变面与中间环,再按各层位置应用深度位移:
```mermaid
flowchart TD
Depth["Z Depth × Uniform Scale"] --> Offset["(Base Plane Depth - Depth) × Depth Scale"]
Depth["Z Depth × Uniform Scale"] --> Offset["(Reference Depth - Depth) × Depth Scale"]
Offset --> Clamp["负向位移限制在固定底面之前"]
Block["O Mesh Plane 厚度体块"] --> Weight["固定底面 0 → 中间环 → 可变面 1"]
Clamp --> Apply["位移 × 层权重"]

View File

@@ -18,10 +18,11 @@ flowchart TD
Path --> Mask["栅格化 SelectionMask"]
Mask --> Refine{"Box / Lasso Refine Selection?"}
Refine -- "否" --> Local["源 Alpha × Selection"]
Refine -- "是" --> Job["refine-image-selection"]
Refine -- "是" --> Job["refine-image-selection<br/>保持 Selection bounds"]
Job --> Refined["BEN2 Alpha × Selection"]
Kind -- "Perspective" --> Quad["四点 Quad 与 Homography"]
Local --> Result["创建结果 Image"]
Job --> Result
Refined --> Result
Quad --> Result
Result --> Keep{"当前子工具 Keep Original?"}
Keep -- "否" --> Replace["替换源 Empty"]
@@ -32,10 +33,12 @@ flowchart TD
`box.py`、`lasso.py` 和 `polyline.py` 定义 WorkspaceTool,交互与落地集中在 `selection.py`。公共 `CropSelectionGesture` 从 `common/viewport.py` 读取当前子工具的独立设置,并把内存中的 `SelectionPath` 交给 `CropImageSelection`。
本地路径在几何 bounds 内把 Selection 值乘入源 Alpha,再按可见内容紧裁切。Box 或 Lasso 开启 Refine Selection 时,Operator 写出保留源 Alpha 的 bounds 矩形并提交 `server/jobs/refine_image_selection.py`;响应直接加载 BEN2 RGBA。Job request 不包含 Keep Original,保留策略只在 Blender 主线程落地结果时执行。
本地路径在几何 bounds 内把 Selection 值乘入源 Alpha,再按可见内容紧裁切。Box 或 Lasso 开启 Refine Selection 时,Operator 写出保留源 Alpha 的 bounds 矩形,并把已栅格化的 Selection Mask 保存为同一临时容器内的 Blender 本地 sidecar。`server/jobs/refine_image_selection.py` 返回与输入同尺寸的 `Source Alpha × BEN2 Alpha` RGBA;Blender 读取 sidecar,把 Selection 值乘入结果 Alpha,并以提交前的 bounds 创建结果 Image。Job request 只包含图片输入、模型和设备;Keep Original 在 Blender 主线程落地结果时执行。
## Crop Perspective
`perspective.py` 接受任意顺序的四个角点并规范为凸 Quad,创建 512 × 512 预览,随后按目标宽高比执行分块 Homography。输出最长边不超过 8192 px,总像素不超过 16,777,216;插值使用预乘 Alpha,结果按可见 Alpha 紧裁切。
四种 Crop 最终都调用 `common/image.py` 的 `place_empty_image_result()`。该入口负责替换或复制 Image Empty、画布对齐、Collection 归属,以及 Keep Original 下选择并激活结果对象。
本地像素路径与 Refine Job 路径都通过公共 Image 编辑结果入口创建数据块。结果以源 Image 的完整名称请求命名,不移除 `.png` 等名称内容,也不添加材质贴图后缀;源 Image 被保留时产生的 `.001` 等数字后缀完全由 Blender 处理。

View File

@@ -15,7 +15,7 @@ flowchart TD
Bounds["写出几何 bounds 矩形 RGBA"]
Job["执行 Refine Selection,生成 Depth / Normal 产物"]
Encoded["加载并 Pack 输入或 Refine RGBA"]
Ready["汇总内容 Mask 与 Shape 输入"]
Ready["贴图 Alpha × Selection Mask"]
Build["构建 BaseShape"]
Create["创建 Blender 对象"]
@@ -33,13 +33,13 @@ Surface 与 Balloon 在本地组合源 Alpha 与几何 Selection 后构建 `Base
Cutout 工具由 `__init__.py` 注册,交互从 `main.py` 中的 `SelectCutoutSelection` 开始。它接收 Lasso 或双击整图手势,只构建内存中的 `SelectionPath`,然后调用 `interaction.py` 打开 Shape 饼菜单。此时不读取源图像素、不栅格化 Mask,也不扫描可见 Alpha。
`interaction.py` 读取工具选项,并在构建菜单按钮时把只含 Points 与 Invert 的完整 `SelectionPath` 编码一次,同时把当前 **Inward Axis** 固定到各 `CutoutSelectionToShape`。这次编码只用于跨 Blender Operator 传值,不属于 Selection Mask 构建。**Mesh Detail** 的 Low、Medium、High、Ultra 分别映射到 32、16、8、4 px 的采样间距,再换算期望网格分辨率;档位越高,采样越密。菜单中的 Surface、Balloon、Depth Balloon 和 Depth Surface,以及它们对应的节点分支索引,统一来自 `shape.py`。
`interaction.py` 读取工具选项,并在构建菜单按钮时把只含 Points 与 Invert 的完整 `SelectionPath` 编码一次,同时把当前 **Inward Axis** 固定到各 `CutoutSelectionToShape`。这次编码只用于跨 Blender Operator 传值,不属于 Selection Mask 构建。**Mesh Detail** 的 Low、Medium、High、Ultra 分别携带 32、16、8、4 px 的源图采样间距;档位越高,采样越密。菜单中的 Surface、Balloon、Depth Balloon 和 Depth Surface,以及它们对应的节点分支索引,统一来自 `shape.py`。
### 栅格化 Selection
用户选定 Shape 后,`main.py` 中的 `CutoutSelectionToShape` 接过主线。它从自身 Property 恢复 `SelectionPath`,再使用 `common/selection.py` 以 non-zero winding 栅格化为一次纯几何 `SelectionMask`:非反选只处理 Path Bounds 与 2 px 抗锯齿边缘,反选覆盖完整图片范围。`values` 保存局部几何权重,`bounds` 保存它在源图中的位置;栅格阶段不读取、接收或合并源 Alpha。
实际 Cutout 业务随后读取一次源 RGBA。无需 AI 时,它在几何 bounds 内显式计算 `source_alpha × selection_values`,由该内容 Mask 判断空内容并约束轮廓;原始 `SelectionMask` 不被修改。此时也会应用 **Maximum Cutout Mesh Resolution**,统一限制四种 Shape 几何范围的最长采样边。
实际 Cutout 业务随后读取一次源 RGBA。无需 AI 时,它在几何 bounds 内显式计算 `source_alpha × selection_values`,由该内容 Mask 判断空内容并约束轮廓;原始 `SelectionMask` 不被修改。此时按 `Selection bounds 长边 ÷ Mesh Detail 像素间距` 计算实际采样长边;只有它超过 **Maximum Cutout Mesh Resolution** 时才等比增大有效间距。bounds 的原始像素长边本身不会触发降密。
### 需要后台生成数据?
@@ -55,7 +55,7 @@ Surface 和 Balloon 的本地路径直接使用本地内容 Mask。
`main.py` 先通过 `common/image.py` 把 Selection 几何 bounds 内的原始 RGBA 写成矩形输入。该文件保留源 Alpha,不预乘 Lasso 值;文件、Packed 或内存图片都使用同一个入口。随后把请求提交给 `server/jobs/cutout.py`,生成本次 Shape 需要的产物:
- **Refine Selection** 调用 `server/selection/refine_selection.py`,写出收紧后的 `selection.png` 和 `selection_bounds.json`;
- **Refine Selection** 调用 `server/selection/refine_selection.py`,写出与输入 bounds 同尺寸的 `selection.png`;
- 需要 Vector Depth 时写出相机空间 XYZ 组成的 `vector-depth.exr`,需要 Z Depth 时写出 `z-depth.exr`,两者都配套 `depth.json`;
- **Normal Map** 根据既定空间写出 `tangent-normal.png` 或 `object-normal.png`。
@@ -63,11 +63,11 @@ Depth 与 Normal Map 共享同一个 MoGe-2 `GeometryFrame`。模型输入先按
**Maximum AI Input Size** 约束 BEN2 和 MoGe-2 的分析图长边;网格密度使用 **Maximum Cutout Mesh Resolution**,最终颜色图使用源图裁切分辨率。
Refine 把 Lasso 视为矩形上下文,因此 BEN2 可以保留 bounds 内、Lasso 外的内容。启用 Refine 时,最终内容 Alpha 完全来自 BEN2;只生成 Depth 或 Normal 时,最终几何仍以本地几何 Selection 值乘矩形输入 Alpha 约束。
Refine 的贴图内容 Alpha 统一为 `Source Alpha × BEN2 Alpha`。Color 直接使用该 RGBA;Normal 的可见区域、MoGe-2 黑底输入和 Depth Metadata 的参考区域使用同一 Alpha。Depth EXR 的 RGB 仍保存连续 Field,Alpha 保存模型 Validity。Selection Mask 只在 Blender 构建网格时与贴图 Alpha 相乘。
### 构建 BaseShape
Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth 与 Normal 图片,并选取一份已有编码 RGBA:启用 Refine 时使用 BEN2 输出;只生成 Depth 或 Normal 时使用提交前的矩形输入。它从同一局部 Image 读取 Alpha 构建内容 Mask,并把这个 Image 直接交给颜色材质。`main.create_cutout_shape()` 调用 `geometry.py` 中的 `build_base_shape()`:
Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth 与 Normal 图片,并选取一份已有编码 RGBA:启用 Refine 时使用完整 bounds 的 BEN2 输出;只生成 Depth 或 Normal 时使用提交前的矩形输入。它从同一 Image 读取 Alpha,与已栅格化的 Selection Mask 相乘后构建网格内容 Mask;颜色及 AI 贴图保持原 Alpha。`main.create_cutout_shape()` 调用 `geometry.py` 中的 `build_base_shape()`:
1. 从内容 Alpha 值提取外轮廓和孔洞;
2. 按 Mesh Detail 对应的采样间距生成轮廓与内部采样点;
@@ -79,7 +79,7 @@ Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth
### 创建 Blender 对象
`main.create_cutout_shape()` 把 `BaseShape`、颜色 Image 和可选产物交给 `object.py` 的 `create_shape_object()`。这里创建 Material、Mesh Object,并挂上统一的 `O Image Cutout` Modifier。材质通过 `common/material.py` 创建,Cutout 使用 `1.2` 的 IOR。本地 Cutout 创建一次几何 bounds 颜色图;AI Cutout 直接复用已编码并 Pack 的矩形输入或 BEN2 输出,不再重读完整源图并重新编码同一颜色内容。
`main.create_cutout_shape()` 把 `BaseShape`、颜色 Image 和可选产物交给 `object.py` 的 `create_shape_object()`。这里创建 Material、Mesh Object,并挂上统一的 `O Image Cutout` Modifier。材质通过 `common/material.py` 创建,Cutout 使用 `1.2` 的 IOR,并由 `O Image Layer` Color 直接驱动 Principled BSDF 的 IOR Level。本地 Cutout 创建一次几何 bounds 颜色图;AI Cutout 直接复用已编码并 Pack 的矩形输入或 BEN2 输出。
| Shape | Modifier 如何形成结果 | 必需后台产物 |
| --- | --- | --- |
@@ -88,7 +88,7 @@ Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth
| Depth Balloon | 在 Balloon 上叠加相对基准面的深度 | `z-depth.exr`、`depth.json` |
| Depth Surface | 使用相机点 XYZ 重建透视表面 | `vector-depth.exr`、`depth.json` |
Depth Shape 的 Base Plane Depth,以及 Depth Balloon 的 Direction,都由 `object.py` 调用 `geometry.py` 的标定函数,根据最终内容 Alpha 计算。透明像素达到 `5%` 时使用有效内容区域自适应标定,否则使用稳定的固定基准。
Depth Shape 的 Reference Depth,以及 Depth Balloon 的 Direction,都由 `object.py` 调用 `geometry.py` 的标定函数,根据最终网格内容 Mask 计算。透明像素达到 `5%` 时使用有效内容区域自适应标定,否则使用稳定的固定基准。
Normal Map 关闭时材质使用几何法线;开启时,`object.py` 把生成的图片和已经确定的 Normal Space 交给材质节点。最后 `main.py` 提交 Undo,整条 Cutout 流程结束。

View File

@@ -8,7 +8,7 @@ Server 在 Job 目录写入图片、JSON 或 Debug NPZ;Blender 根据 Job 返
| | | |
| `remove-background` | `image.0001.png` 起 | 替换 Image Empty 的颜色数据 |
| `upscale-image` | `upscale.0001.png` 起 | 替换或新建高清颜色数据 |
| `refine-image-selection` | `image.png`、`bounds.json` | Image Box / Lasso 读取 Refine Selection 结果 |
| `refine-image-selection` | `image.png` | Image Box / Lasso 读取完整 bounds 的 Refine Selection 结果 |
| `generate-depth-plane-geometry` | `image.0001.*`、`z-depth.exr`、`depth.json` | 创建 Depth Plane |
| `generate-cutout-artifacts` | 可选 Selection、Depth 与 Normal 文件 | 创建 Cutout Shape 与材质 |
@@ -34,12 +34,12 @@ Blender 通过 `common/depth.py` 加载 EXR,设为 Non-Color 并 Pack。Depth
Blender 先把 `SelectionPath` 栅格化为 `SelectionMask`,再把局部 RGBA 作为 Server 输入。MoGe-2 输入统一按 `RGB × Alpha` 合成到黑底;完全透明像素下的隐藏 RGB 不影响预测。Server 不接收 Path 或 Mask 对象协议:
- 开启 Refine Selection 时,`selection.png` 保存细化后的局部 RGBA,`selection_bounds.json` 保存它相对输入裁切图的范围;
- 开启 Refine Selection 时,`selection.png` 以输入 bounds 的完整尺寸保存 `Source Alpha × BEN2 Alpha` RGBA;
- 需要 Vector Depth 时请求 `vector-depth.exr` 与 `depth.json`;
- 需要 Z Depth 时请求 `z-depth.exr` 与 `depth.json`;
- Normal Map 按空间请求 `tangent-normal.png` 或 `object-normal.png`。
Surface 与 Balloon 的本地路径直接使用 `SelectionMask` 构建 Shape。最终颜色图由 Blender 按 Selection 范围裁切源 Image,并保留源 RGBA。
Cutout 的 Color、Normal 与 Depth 使用同一图片 Alpha 内容范围;Blender 再将该 Alpha 与 `SelectionMask` 相乘构建 Shape。Surface 与 Balloon 的本地路径直接组合源 Alpha 和 `SelectionMask`。
## Debug 产物

View File

@@ -48,13 +48,13 @@ Job 在阶段边界调用 `context.check_cancelled()`,并把稳定产物写入
| `generate-cutout-artifacts` | 按需执行 Refine Selection,并生成 Depth 或 Normal 文件产物 |
| `debug-*` | 保存模型适配层的直接输出 |
`generate-depth-plane-geometry` 对应 `jobs/depth_plane.py`;`generate-cutout-artifacts` 对应 `jobs/cutout.py`。共享的 MoGe-2 推理编排位于 `geometry/prediction_artifacts.py`,它把最终 Selection RGBA 合成到黑底后执行推理,并把单帧 `GeometryFrame` 交给 Depth、Normal 与 Metadata Writer。Depth EXR 与元数据写入位于 `geometry/depth_texture.py`,Normal 编码位于 `geometry/normal_texture.py`,Refine Selection 的通用实现位于 `selection/refine_selection.py`。
`generate-depth-plane-geometry` 对应 `jobs/depth_plane.py`;`generate-cutout-artifacts` 对应 `jobs/cutout.py`。共享的 MoGe-2 推理编排位于 `geometry/prediction_artifacts.py`,它把最终 Selection RGBA 合成到黑底后执行推理,并把单帧 `GeometryFrame` 交给 Depth、Normal 与 Metadata Writer。Depth EXR 与元数据写入位于 `geometry/depth_texture.py`,Normal 编码位于 `geometry/normal_texture.py`。`selection/refine_selection.py` 使用 BEN2 生成与输入同尺寸的 `Source Alpha × BEN2 Alpha` RGBA;Selection Mask 由 Blender 在最终图片或网格阶段组合。
## 尺寸约束
`server/media/resolution.py` 提供 **Maximum AI Input Size**,默认 `2048 px`。它限制 Upscale 的可提交源图长边,以及 Depth、Normal Map、Refine Selection 的模型分析图长边;材质颜色图使用原始分辨率。
Blender 侧的 **Maximum Cutout Mesh Resolution** 统一限制所有 Cutout Shape 的网格采样密度。
Blender 侧的 **Maximum Cutout Mesh Resolution** 统一限制所有 Cutout Shape 实际几何范围的最长网格采样边,不限制 Selection bounds 的原始像素尺寸。
`generate-depth-plane-geometry` 与 `generate-cutout-artifacts` 只接受一帧,因此 Depth Plane 与 Cutout 是静态图工作流。Remove Background 与 Upscale 继续通过媒体入口处理单图、视频和序列帧。

View File

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

View File

@@ -0,0 +1,69 @@
## Context
Cutout 交互当前先把 Mesh Detail 换算为整张源图的 `mesh_resolution`,随后又把 Selection bounds 交给通用图片尺寸限制函数。后一步比较的是 bounds 原始像素数而不是预期网格采样数,因此在 Low 的 32 px 间距下,2048 px bounds 会被错误地当作超过默认 1024 网格上限并再次减半。
Image Empty 编辑已经共用替换与共享引用分流,但结果产生仍有两条旧路径:Job 结果使用面向材质的 Color 命名,本地像素结果使用去扩展名的基础名。替换逻辑只在旧 Image 可以被删除时把结果改回源名,所以共享引用会暴露两条路径的不一致。
## Goals / Non-Goals
**Goals:**
- 在 Cutout 全链路保留 Mesh Detail 的源像素间距语义,只对实际几何采样数量应用性能上限。
- 为直接 Image 编辑建立单一结果命名边界,使本地像素结果和 Job 文件结果一致。
- 明确分开 Image 编辑结果与材质派生贴图,删除错误的旧创建入口和调用方中的隐式命名修补。
- 使用真实 Blender 数据块测试覆盖共享与非共享源 Image。
**Non-Goals:**
- 不修改 BEN2、Upscale、Crop 的像素内容、文件编码、动画帧或 Server 协议。
- 不改变 Plane、Depth Plane、Cutout Shape 的材质贴图后缀。
- 不自行生成或解析 Blender 的 `.001` 数字唯一性后缀。
## Decisions
### 1. 保留像素间距,延后计算网格上限
Mesh Detail 在交互边界只产生 32、16、8、4 px 的采样间距,不再提前压缩为受 1024 限制的整图分辨率。Selection bounds 已知后,使用 `bounds 长边 ÷ 像素间距` 得到实际 Cutout 的预期采样长边;只有该值超过配置上限时才等比增大有效间距。几何层再根据源 Image 尺寸与 Image Empty 的世界尺度把有效像素间距换算为世界空间采样间距。
这比修补现有 `mesh_resolution × limited_image_size(bounds)` 公式更直接,因为配置项限制的是网格采样数量,不是可送入图片处理器的像素尺寸。也不采用简单 `min(整图 mesh_resolution, 上限)`,因为小 Selection 位于超大源图时仍应保持所选档位的像素间距。
### 2. 直接 Image 编辑结果始终请求完整源名
在 `common/image.py` 建立明确的 Image 编辑结果创建与加载入口。入口接收源 Image,并在创建或加载后把结果名称设置为 `source_image.name` 原值;旧 Image 尚存在时产生的数字后缀完全交给 Blender。`replace_empty_image()` 继续负责共享引用分流,并在旧 Image 可删除时把结果恢复为准确源名。
Remove Background 与 Upscale 改用 Image 编辑 Job 结果加载入口;三种 Selection Crop、本地 Crop、Refine Crop 和 Perspective Crop 改用同一像素结果创建入口。中间 BEN2 编码图不属于用户结果,可以继续使用临时名称并在响应结束时删除。
不采用在各 Operator 响应中分别写 `result_image.name = source.name`,因为这会继续保留重复逻辑,也无法保证本地 Crop 与异步 Job 的行为一致。
### 3. 材质派生贴图保留独立入口
`color_data_name()`、`depth_data_name()`、`normal_data_name()` 以及材质 Color Job 结果加载只服务 Plane、Depth Plane、Cutout Shape 等派生贴图工作流。若底层加载、Pack、颜色空间与序列设置需要复用,则由公共内部过程接收显式目标名;面向业务的两个公开入口仍分别表达“编辑原 Image”和“生成 Color 贴图”。
不把 `load_color_result_image()` 全局改为源名,因为 Convert to Plane 等调用方确实需要 `_color.png`,全局改名会把本次修复扩散为材质契约变化。
### 4. 删除错误的旧 Image 添加路径
迁移完成后直接删除旧的 Image 添加逻辑:面向 Crop 的旧基础名创建入口不保留默认或可选 `suffix` 形式,纯 Image 编辑也不再允许调用材质 Color 结果入口。同步清理 `image_base_name()` 等仅为旧路径存在的调用方导入、重复赋名和失去调用者的 helper;不添加别名、转发函数或弃用兼容层。
这比保留一个可同时表达“源图编辑”和“派生贴图”的宽泛函数更容易验证,也符合项目一次性迁移、不保留旧路径的约束。
### 5. 测试按可观察的数据块结果组织
纯函数测试验证 Mesh Detail 间距、bounds 实际采样数与上限;Blender 测试以同一 Image 被两个 Empty 引用和单一引用两种场景验证结果数据、名称与旧引用。Operator 测试分别覆盖 Remove Background、Upscale、Selection Crop(本地与 Refine)和 Perspective Crop 都调用统一入口,材质结果测试则确认 `_color` 等名称不变。
## Risks / Trade-offs
- [高分辨率小 Selection 不再被整图 1024 上限提前降密,局部网格可能比当前更多] → 仍按实际 Cutout 采样长边应用配置上限,并加入边界测试。
- [浮点 bounds 与取整可能令采样数相差一个点] → 对上限计算统一取整规则,规格只约束不超过上限和比例密度,不依赖具体三角形数量。
- [动画 Job 结果的 Image 名称可能保留 `.mp4` 等源名称文字] → 名称被定义为数据块身份而非结果文件格式;实际序列来源、Pack 和帧设置继续由加载入口配置。
- [重构公共加载入口误改材质 Color 命名] → 用独立公开入口和回归测试锁定 Image 编辑与材质贴图两种语义。
## Migration Plan
1. 先补充失败测试,分别重现 Upscale 后 Cutout 密度被抵消、共享 Image 出现 `_color` 和丢扩展名。
2. 调整 Cutout 参数传递与实际采样上限计算,移除把 bounds 当图片输入限制的路径。
3. 收拢 Image 编辑结果创建、加载和命名入口,迁移六类编辑工具调用方,同时保留材质贴图入口。
4. 删除错误的旧 Image 添加入口、参数、导入和调用方命名修补,不保留兼容转发。
5. 更新 Common、Crop Tool、Cutout Tool 文档并运行定向测试和全量测试。
本次只改变新执行结果,不迁移现有 `.blend` 中已经创建的数据块。若回退,代码可整体恢复;已有结果数据不需要转换。

View File

@@ -0,0 +1,30 @@
## Why
Cutout 的 Mesh Detail 先按源图像素间距计算,却又按 Selection bounds 的原始像素尺寸缩小网格分辨率,导致 Upscale 后的新增像素无法转化为对应的网格细节。Remove Background、Upscale 与 Crop 同属直接编辑 Image Empty 的工作流,但共享 Image 数据时分别出现 `_color` 后缀或丢失 `.png` 扩展名,说明结果创建仍混用了材质贴图命名与旧的基础名逻辑。
## What Changes
- 让 Cutout 的 Low、Medium、High、Ultra 始终分别保持 32、16、8、4 个源图像素的采样间距;Upscale x4 后,同一图像范围在线性方向获得约 4 倍采样密度,直到实际网格采样数量触及配置上限。
- 将 **Maximum Cutout Mesh Resolution** 只应用于实际网格采样长边,不再把 Selection bounds 的原始像素长边当作待缩放的图片输入尺寸。
- 统一 Remove Background、Upscale、Crop Box、Crop Lasso、Crop Polyline 与 Crop Perspective 的纯 Image 编辑结果命名:始终以源 Image 数据块的完整名称请求新名称,保留 `.png` 等原有格式文字,不主动添加 `_color` 等材质贴图后缀。
- 允许 Blender 在源 Image 仍被共享时自动追加 `.001` 等唯一性后缀;代码不自行模拟、剥离或改写该后缀。
- 收拢纯 Image 编辑结果的创建、加载、命名与替换入口,使材质 Color、Depth、Normal 的派生贴图命名只留在 Plane、Cutout 等材质工作流。
- 删除原有错误的 Image 添加逻辑、旧入口和调用方修补,不保留可选后缀参数、转发函数或兼容层。
## Capabilities
### New Capabilities
- `cutout-mesh-sampling`: 规定 Cutout Mesh Detail 的源像素采样间距、Upscale 后的密度变化及实际网格分辨率上限。
- `image-edit-results`: 规定直接编辑 Image Empty 的结果数据块命名、共享引用分流以及与材质贴图命名的边界。
### Modified Capabilities
无。
## Impact
- 影响 `operators/cutout_tool` 的网格分辨率计算及对应几何、交互测试。
- 影响 `common/image.py` 中 Image 编辑结果的创建、加载、命名和 Empty 替换职责,以及 Remove Background、Upscale、Crop Selection、Refine Crop、Crop Perspective 的调用路径;旧的错误创建入口及无用导入会被移除。
- Plane、Depth Plane、Cutout Shape 使用的 `_color.png`、`_depth.exr`、`_normal.png` 材质贴图命名保持不变。
- 需要同步 Common、Crop Tool、Cutout Tool 与相关运行时文档;不新增依赖或 Server 协议。

View File

@@ -0,0 +1,28 @@
## Purpose
规定 Cutout 网格细节与源图像素之间的稳定关系,并让性能上限约束实际生成的网格采样数量而不是输入图片尺寸。
## ADDED Requirements
### Requirement: Mesh Detail preserves source-pixel spacing
系统 SHALL 将 Cutout 的 Low、Medium、High、Ultra 分别解释为相邻网格采样点之间 32、16、8、4 个源图像素,并在未触及网格上限时保持该间距。
#### Scenario: Low samples every 32 source pixels
- **WHEN** 用户以 Low 创建一个未触及网格上限的 Cutout
- **THEN** 系统按每 32 个源图像素一次的间距构建网格
#### Scenario: Upscale increases linear mesh density
- **WHEN** 同一图片内容在线性宽高各 Upscale 4 倍后,以相同 Mesh Detail、相同相对 Selection 和相同显示尺寸创建 Cutout,且两次均未触及网格上限
- **THEN** Upscale 结果在同一线性内容范围内获得约 4 倍网格采样密度
### Requirement: Cutout mesh limit caps actual sampling resolution
系统 SHALL 仅在 Cutout 实际几何范围的预期采样长边超过 **Maximum Cutout Mesh Resolution** 时增大采样间距,并 SHALL NOT 按 Selection bounds 的原始像素长边直接缩放已经由 Mesh Detail 得到的密度。
#### Scenario: Large pixel bounds remain below the sampling limit
- **WHEN** Selection bounds 的像素长边超过配置值,但按当前 Mesh Detail 计算的实际采样长边没有超过配置值
- **THEN** 系统保持该 Mesh Detail 对应的源像素采样间距
#### Scenario: Actual sampling reaches the configured limit
- **WHEN** Cutout 实际几何范围按当前 Mesh Detail 计算的采样长边超过配置值
- **THEN** 系统等比增大网格采样间距,使实际采样长边不超过配置值

View File

@@ -0,0 +1,43 @@
## Purpose
规定直接编辑 Image Empty 时结果 Image 数据块的统一身份与命名,使共享引用只由 Blender 的数据块唯一性规则产生差异。
## ADDED Requirements
### Requirement: Direct image edits preserve the complete source name
Remove Background、Upscale、Crop Box、Crop Lasso、Crop Polyline 和 Crop Perspective SHALL 使用源 Image 数据块的完整名称请求结果 Image 名称,包括名称中的 `.png` 等格式文字,并 SHALL NOT 主动添加材质贴图后缀。
#### Scenario: Remove Background edits a shared image
- **WHEN** Remove Background 编辑一个仍被其他对象引用的 Image
- **THEN** 新结果以源 Image 的完整名称请求命名,唯一性数字后缀仅由 Blender 自动追加
#### Scenario: Upscale edits a shared image
- **WHEN** Upscale 编辑一个仍被其他对象引用的 Image
- **THEN** 新结果保留源 Image 的完整名称且不添加 `_color`
#### Scenario: Selection Crop edits a shared image
- **WHEN** 任一 Selection Crop 路径编辑一个仍被其他对象引用的 Image,无论是否启用 Refine Selection 或 Keep Original
- **THEN** 新结果保留源 Image 的完整名称且不移除名称中的格式文字
#### Scenario: Perspective Crop edits a shared image
- **WHEN** Crop Perspective 编辑一个仍被其他对象引用的 Image,无论是否启用 Keep Original
- **THEN** 新结果保留源 Image 的完整名称且不移除名称中的格式文字
### Requirement: Image replacement follows Blender data-block identity
系统 SHALL 在源 Image 只由被编辑对象使用时以结果替换并清理旧数据,使结果最终恢复源 Image 的准确名称;源 Image 仍被共享或被 Keep Original 保留时,系统 SHALL 保留源数据并接受 Blender 自动生成的数字唯一性后缀。
#### Scenario: Unshared source is replaced
- **WHEN** 直接图片编辑替换一个没有其他用户且没有 Fake User 的源 Image
- **THEN** 旧 Image 被清理且结果 Image 最终使用源 Image 的准确名称
#### Scenario: Shared source is split
- **WHEN** 直接图片编辑替换一个仍有其他用户的源 Image
- **THEN** 其他用户继续引用源 Image,被编辑对象引用新结果,两个数据块的重名冲突由 Blender 自动解决
### Requirement: Material texture naming remains separate
系统 SHALL 仅对 Plane、Depth Plane、Cutout Shape 等生成材质贴图的工作流使用 `_color.png`、`_depth.exr`、`_normal.png` 等业务后缀,直接图片编辑 SHALL NOT 复用这些派生贴图名称。
#### Scenario: Plane conversion creates material textures
- **WHEN** 图片被转换为需要材质贴图的 Plane 或 Cutout 结果
- **THEN** 其 Color、Depth 和 Normal 数据继续按对应材质贴图规则命名

View File

@@ -0,0 +1,24 @@
## 1. 锁定回归行为
- [x] 1.1 为 Cutout 增加按源像素间距和实际 Selection 采样长边计算的测试,覆盖 Low 的 32 px、Upscale x4 的 4 倍线性密度、bounds 像素超过上限但采样数未超限、实际采样数触及上限,并运行 `uv run pytest tests/test_cutout_interaction.py tests/test_cutout_geometry.py`
- [x] 1.2 为 Image 编辑结果增加真实 Blender 数据块测试,覆盖完整 `.png` 名称、共享源或 Keep Original 产生的 Blender 数字后缀、非共享源恢复准确原名,并运行对应 `tests/test_cutout_objects.py` 用例
- [x] 1.3 为 Remove Background、Upscale、Selection Crop 本地与 Refine 路径、Crop Perspective 增加调用边界测试,确认它们统一请求 Image 编辑结果而不使用 `_color` 或基础名,并运行对应 `tests/test_blender_addon.py` 用例
## 2. 修正 Cutout 网格采样
- [x] 2.1 将 Mesh Detail 的 32、16、8、4 px 间距直接保留到 Cutout 执行阶段,移除交互阶段受整图 1024 限制的提前换算,并运行 Cutout 交互测试验证四档参数
- [x] 2.2 根据 Selection bounds 的预期网格采样长边应用 **Maximum Cutout Mesh Resolution**,把最终有效像素间距换算为世界空间 spacing,删除复用图片输入尺寸限制的错误路径,并运行 Cutout 几何测试验证密度比例和上限
- [x] 2.3 更新同步创建与异步 AI 响应的参数传递,使四种 Cutout Shape 使用同一有效间距,并运行 `uv run pytest tests/test_cutout_interaction.py tests/test_cutout_geometry.py tests/test_cutout_objects.py tests/test_blender_addon.py`
## 3. 统一 Image 编辑结果命名
- [x] 3.1 在 `common/image.py` 建立分别面向像素编辑结果和 Job 编辑结果的共享入口,两者都请求源 Image 完整名称并复用 Pack、颜色空间、动画与错误清理;保留独立材质 Color 加载入口,并运行 Image 数据测试
- [x] 3.2 将 Remove Background 与 Upscale 迁移到共享 Job 编辑结果入口,删除调用方命名修补,并运行对应 Operator 响应测试验证共享与非共享 Image
- [x] 3.3 将 Selection Crop、Refine Crop 和 Crop Perspective 迁移到共享像素编辑结果入口,移除默认 `image_base_name()` 命名,并运行本地、Refine、Perspective 与 Keep Original 测试
- [x] 3.4 删除旧的错误 Image 添加入口、可选后缀参数、重复赋名、无用导入与失去调用者的 helper,不保留别名、转发函数或兼容层,并用 `rg` 确认纯 Image 编辑 Operator 只调用新的共享入口
- [x] 3.5 补充 Plane、Depth Plane 与 Cutout Shape 的材质命名回归断言,确认 `_color.png`、`_depth.exr`、`_normal.png` 不受 Image 编辑结果重构影响,并运行 `uv run pytest tests/test_image_data.py tests/test_cutout_objects.py tests/test_blender_addon.py`
## 4. 文档与完整验证
- [x] 4.1 更新 `docs/internals/common.md`、Crop Tool、Cutout Tool 与相关运行时说明,正面描述 Image 编辑结果完整源名、Blender 自动数字后缀和实际 Cutout 采样上限,并用 `rg` 核对代码与文档术语一致
- [x] 4.2 运行 `uv run pytest`,确认全部测试通过且没有恢复旧命名入口、兼容层或图片尺寸限制胶水代码

View File

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

View File

@@ -0,0 +1,98 @@
## Context
Plane、Depth Plane 与 Cutout 的非 Shadeless 材质都由 `common/material.py` 创建,但 `O Image Layer` 的颜色当前只驱动 Base Color、Subsurface Radius、Emission Color 与 Roughness,Principled BSDF 的 IOR Level 保持常量。Depth Plane 与两个 Depth Cutout 子组把相机参考深度公开为 `Base Plane Depth`,而实现和元数据已经统一使用 `reference_depth`。
Refine Selection 当前把 Selection bounds 内的矩形 RGBA 交给 BEN2,并由 `preserve_input_alpha=True` 得到 `Source Alpha × BEN2 Alpha`,随后 Server 会按可见 Alpha 紧裁切并返回新的局部 bounds。Cutout 在启用 Refine 后直接用 BEN2 Alpha 构建网格,丢失了用户的 Selection Mask;Crop 也直接采用 BEN2 输出。与此同时,Cutout 的 MoGe-2、Normal 与 Depth 需要完整的 Refine 内容范围,不应被 Selection Mask 限制。
## Goals / Non-Goals
**Goals:**
- 让生成材质的暗色区域通过 IOR Level 降低高亮。
- 将 Geometry Nodes 的参考深度接口统一为与实现一致的 `Reference Depth`。
- 固定 Refine 输入与输出的矩形尺寸和 placement bounds。
- 让 Cutout 的 Color、Normal 与 Depth 共享 `Source Alpha × BEN2 Alpha` 内容范围。
- 让 Selection Mask 只约束最终 Crop 图片或 Cutout 网格。
**Non-Goals:**
- 不改变 BEN2、MoGe-2、Normal 或 Depth 模型实现和推理分辨率。
- 不用 Selection Mask 限制 BEN2、MoGe-2 或任何 Cutout 贴图的生成范围。
- 不改变 Depth EXR 的连续 RGB Field 或 MoGe-2 Validity Alpha 语义。
- 不为旧 Geometry Nodes socket 名称、旧 Job 结果 key 或旧节点资产保留兼容层。
- 不改变普通本地 Crop、普通本地 Cutout 或 Shadeless 材质。
## Decisions
### 1. `O Image Layer` Color 直接驱动 IOR Level
在 `create_image_material()` 的 Principled 分支中,将 `O Image Layer` 的 Color 输出直接连接到 Principled BSDF 的 IOR Level。Blender 按 Color-to-Float 规则完成转换,因此黑色得到 `0`、白色得到 `1`;既有 IOR 数值继续独立控制 Fresnel 模型。该公共入口只被 Plane、Depth Plane 与 Cutout 使用,因此无需新增调用参数或材质节点组 socket。
相比新增亮度 Math 节点或只降低默认值,直接连接准确表达颜色层驱动关系,节点更少,也让暗色抑制与原图连续变化。
### 2. 公开接口统一使用 `Reference Depth`
将 `O Image Depth Plane`、`O Image Cutout`、Depth Balloon 与 Depth Surface 的 `Base Plane Depth` socket 全部改名为 `Reference Depth`。Modifier 初始化与节点间连线按新名称直接迁移,内部 `reference_depth` 变量和 `depth.json` 字段保持不变。
相比 `Base Depth`,`Reference Depth` 更准确说明该值是采样深度的运算基准,并与现有代码和元数据术语一致。节点资产随构建脚本重新生成,不保留旧 socket。
### 3. Refine 输出保持提交前的完整矩形画布
`server/selection/refine_selection.py` 只运行 BEN2 并保存与输入相同尺寸的 RGBA,不再按 Alpha 调用紧裁切,也不返回局部 bounds。`refine-image-selection` 和 Cutout artifact Job 删除 `selection_bounds` 产物;Blender 调用方继续使用提交前保存的 Selection bounds 进行 placement。
BEN2 继续使用 `preserve_input_alpha=True`,因此统一 Refine 内容 Alpha 定义为:
```text
refined_alpha = source_alpha × ben2_alpha
```
Server 不接收 SelectionPath、SelectionMask 或 Mask sidecar。相比在 Server 组合 Selection Mask,稳定矩形输出让同一 Refine 结果可以直接服务 Color、Normal 与 Depth,并保持 AI 与用户几何约束分离。
### 4. Cutout 贴图共享 Refine 内容,网格再乘 Selection Mask
启用 Refine 时,Cutout artifact Job 将完整 BEN2 RGBA 同时作为最终 Color 和 MoGe-2 输入。Normal 的可见区域、Depth Metadata 的参考内容区域,以及 MoGe-2 黑底合成均继续从该图片 Alpha 派生。Depth EXR RGB 仍保存完整连续 Field,Alpha 仍只保存 `GeometryFrame.validity`。
Blender 响应阶段读取完整 BEN2 Alpha,并与提交时已栅格化的 `SelectionMask.values` 逐像素相乘:
```text
mesh_content = source_alpha × ben2_alpha × selection_mask
```
由于 Refine 结果保持原 bounds,两个数组尺寸天然一致,不再需要局部 bounds 偏移或 Mask 重采样。组合结果只传给 BaseShape 构建、内容空值检查和网格标定;颜色图片及 Normal、Depth 贴图保持未乘 Selection Mask。
未启用 Refine、但因 Normal 或 Depth 提交 Job 的 Cutout 继续使用完整 bounds 输入,并在 Blender 中执行 `source_alpha × selection_mask`。两条 Cutout 响应路径因此都由同一个“贴图 Alpha × Selection Mask”入口构建网格。
### 5. Crop 在 Blender 中应用 Selection Mask
Crop Refine 同样接收完整 BEN2 RGBA,并沿用提交前 bounds。由于 Crop 的最终产物是图片而不是网格,Blender 必须把 BEN2 输出 Alpha 与原始 Selection Mask 相乘后创建结果图片:
```text
crop_alpha = source_alpha × ben2_alpha × selection_mask
```
Selection 栅格化仍只执行一次。Crop 提交阶段把局部 Mask 值写入与 Bounds 输入同一临时容器的本地 sidecar;该路径只由 Blender 响应读取,不进入 Job request。响应完成 Alpha 组合与结果 Image 创建后统一清理临时容器。相比把 Mask 发送到 Server或再次栅格化 SelectionPath,这保持了 Server 边界和一次栅格化约束。
Crop 结果画布和 placement bounds 保持提交前 Selection bounds,不再按组合后的可见 Alpha紧裁切。
## Risks / Trade-offs
- [直接连接 Color 会让亮色 IOR Level 高于原默认 `0.5`] → 这是明确的颜色驱动语义;节点测试固定直连关系,不额外缩放。
- [Geometry Nodes socket 改名会断开依赖旧接口名的内部设置] → 同步修改所有构建、验证、Modifier 设置和已存节点资产,不保留双 socket。
- [Refine 图片不再紧裁切,透明边缘画布可能更大] → bounds 本来已限定为用户 Selection 的矩形范围,接受稳定对齐换取的少量图片尺寸成本。
- [Crop Mask sidecar 增加一份临时文件] → 与 Bounds 输入共用生命周期,只保存单通道局部值,并由统一 cleanup 删除。
- [Cutout 贴图含有 Selection 外的 BEN2 内容] → 最终网格由组合 Mask 严格限制,网格外纹理不会形成可见表面;AI 保留完整上下文是预期行为。
- [Depth 的“统一内容范围”被误解为覆盖 EXR Alpha] → 测试和文档明确区分业务内容 Alpha 与模型 Validity,保持连续 Field 协议不变。
## Migration Plan
1. 先修改 Refine Server 输出协议和测试,删除紧裁切与局部 bounds 产物。
2. 调整 Crop 与 Cutout Blender 响应,以提交前 bounds 对齐 Mask 并构建最终图片或网格。
3. 增加材质 Color-to-IOR-Level 连接及测试。
4. 一次性重命名所有 Geometry Nodes socket、调用方和验证断言,重新构建并验证节点资产。
5. 更新内部文档并运行相关测试和完整测试。
回退时整体恢复旧 Refine bounds 协议、旧 socket 名称和旧节点资产,不保留新旧并行入口。
## Open Questions
无。

View File

@@ -0,0 +1,33 @@
## Why
Cutout 与 Plane 的材质高亮、Geometry Nodes 深度控制命名,以及 Refine Selection 的最终范围目前缺少一致语义。暗色区域仍会产生明显高亮,`Base Plane Depth` 暴露了多余的 Plane 概念,而 Refine 结果还会忽略用户圈选范围并改变提交前的 bounds。
## What Changes
- 将 `O Image Layer` 的 Color 输出连接到生成材质 Principled BSDF 的 IOR Level,使原图暗色区域同步降低高亮。
- 将所有 Geometry Nodes 接口中的 `Base Plane Depth` 统一改名为 `Reference Depth`,并同步 Modifier 初始化、节点资产验证和内部文档。
- Refine Selection 保留送入 BEN2 前的矩形尺寸与 bounds;Server 不接收 Selection Mask,也不按 BEN2 Alpha 紧裁切结果。
- Cutout 的 Color、Normal 与 Depth 统一基于 `Source Alpha × BEN2 Alpha` 的图片内容生成;Selection Mask 只在 Blender 中与该 Alpha 相乘,用于构建最终网格。
- Crop Refine 同样保留提交前 bounds,并在 Blender 中使用 `Source Alpha × BEN2 Alpha × Selection Mask` 生成最终图片结果。
- **BREAKING(内部 Job 产物协议)**:`refine-image-selection` 与 `generate-cutout-artifacts` 不再返回 Refine 后的局部 bounds 文件;调用方沿用提交前保存的 Selection bounds。
## Capabilities
### New Capabilities
- `image-result-materials`: 规定 Plane、Depth Plane 与 Cutout 生成材质的颜色驱动 IOR Level 行为。
- `geometry-depth-controls`: 规定 Depth Plane 与 Depth Cutout Geometry Nodes 的参考深度公开接口。
- `selection-content-processing`: 规定 Refine Selection 的稳定 bounds、AI 贴图内容范围,以及 Crop 图片和 Cutout 网格对 Selection Mask 的最终约束。
### Modified Capabilities
无。当前 `openspec/specs` 尚无已归档 capability;本 change 将相关行为建立为当前规格。
## Impact
- Blender 材质构建:`src/anyimage/common/material.py` 及材质测试。
- Geometry Nodes:Depth Plane、Cutout 构建与验证脚本,Modifier 输入设置,`O_AnyImage.blend` 节点资产及节点资产文档。
- Refine 数据流:Crop Tool、Cutout Tool、BEN2 Refine Job、Cutout artifact Job、结果加载与内容 Mask 组合。
- Job 结果:删除 Refine 局部 bounds 产物及对应读取逻辑。
- 测试与文档:更新与“BEN2 可保留圈外内容”和紧裁切 bounds 相反的既有断言。
- 不新增运行时依赖,不改变 SelectionPath JSON、BEN2/MoGe-2 模型或进程边界。

View File

@@ -0,0 +1,23 @@
## ADDED Requirements
### Requirement: Geometry depth groups expose Reference Depth
系统 SHALL 在 `O Image Depth Plane`、`O Image Cutout`、Depth Balloon 与 Depth Surface 的 Geometry Nodes 接口中使用 `Reference Depth` 表示深度纹理的参考距离,并且 MUST 不再公开 `Base Plane Depth`。
#### Scenario: Depth Plane initializes its reference
- **WHEN** 系统根据 `depth.json` 创建 Depth Plane Modifier
- **THEN** `reference_depth × uniform_scale` SHALL 写入 `Reference Depth`
#### Scenario: Depth Cutout initializes its reference
- **WHEN** 系统根据最终网格内容标定 Depth Balloon 或 Depth Surface
- **THEN** 标定结果 SHALL 写入主 Cutout 与对应子组的 `Reference Depth`
#### Scenario: Node asset exposes the current interface only
- **WHEN** 系统构建并验证 `O_AnyImage.blend`
- **THEN** 所有相关节点组、面板、依赖连线和 Modifier 输入 SHALL 只包含 `Reference Depth`,不得保留旧名称或兼容 socket
### Requirement: Reference Depth keeps existing geometry meaning
`Reference Depth` SHALL 继续作为采样 Depth 的运算基准,不改变 Depth Plane、Depth Balloon 或 Depth Surface 的几何方向、尺度、厚度和位移公式。
#### Scenario: Reference control changes relative depth
- **WHEN** 用户修改 `Reference Depth`
- **THEN** Geometry Nodes SHALL 使用 `Reference Depth - sampled depth` 的既有相对深度关系更新几何

View File

@@ -0,0 +1,16 @@
## ADDED Requirements
### Requirement: Image color drives generated material IOR Level
系统 SHALL 将 Plane、Depth Plane 与 Cutout 的非 Shadeless 生成材质中 `O Image Layer` 的 Color 输出直接连接到 Principled BSDF 的 IOR Level,同时保留既有 IOR、Base Color、Roughness、Alpha、Normal 与 Displacement 连接。
#### Scenario: Dark image content suppresses highlights
- **WHEN** 生成材质采样到黑色或暗色图片内容
- **THEN** Principled BSDF 的 IOR Level SHALL 接收对应的低颜色值,从而降低该区域的高亮
#### Scenario: Bright image content preserves direct color response
- **WHEN** 生成材质采样到白色或亮色图片内容
- **THEN** Principled BSDF 的 IOR Level SHALL 接收 `O Image Layer` Color 的直接转换值,不经过额外缩放或固定上限
#### Scenario: Generated material keeps its IOR value
- **WHEN** Cutout 或 Depth Plane 以 IOR `1.2` 创建材质
- **THEN** 系统 SHALL 保留 IOR `1.2`,并由 Color 独立驱动 IOR Level

View File

@@ -0,0 +1,68 @@
## ADDED Requirements
### Requirement: Refine preserves the submitted bounds canvas
系统 SHALL 让 BEN2 Refine 输出保持输入 RGBA 的完整矩形尺寸,并沿用提交前的 Selection bounds;Server MUST 不按生成 Alpha 紧裁切输出,也 MUST 不接收 Selection Mask。
#### Scenario: BEN2 output occupies only part of the bounds
- **WHEN** BEN2 只在输入矩形的一部分生成可见 Alpha
- **THEN** Refine 输出 SHALL 保留完整输入尺寸和透明区域,Blender SHALL 沿用提交前保存的 bounds
#### Scenario: Refine Job reports its files
- **WHEN** `refine-image-selection` 或 Cutout artifact Job 完成 Refine
- **THEN** Job SHALL 返回完整尺寸的 Refine 图片,并且 MUST 不生成或返回 Refine 后的局部 bounds 文件
### Requirement: Refined content Alpha combines source and BEN2
BEN2 Refine 内容 Alpha SHALL 等于 `Source Alpha × BEN2 Alpha`,并且 Selection Mask MUST 不参与 BEN2 推理或 Server 端 Refine 输出。
#### Scenario: Source contains transparent pixels
- **WHEN** BEN2 在源 Alpha 为零或半透明的像素生成前景 Alpha
- **THEN** Refine 图片 SHALL 通过 `preserve_input_alpha` 保持源 Alpha 与 BEN2 Alpha 的乘积
#### Scenario: Selection excludes visible content inside bounds
- **WHEN** bounds 内存在位于 Selection Mask 外的可见内容
- **THEN** BEN2 与 Server Refine 输出 SHALL 仍能使用并保留该内容,不得预先乘入 Selection Mask
### Requirement: Cutout textures share refined content scope
启用 Refine Selection 的 Cutout SHALL 使用同一份 `Source Alpha × BEN2 Alpha` 内容生成 Color、Normal 与 Depth 贴图,Selection Mask MUST 不限制这些贴图的生成。
#### Scenario: Cutout generates color and normal
- **WHEN** Cutout 同时启用 Refine Selection 与 Normal Map
- **THEN** Color Alpha 与 Normal 可见区域 SHALL 来自完整 Refine 图片 Alpha,不得乘入 Selection Mask
#### Scenario: Cutout generates depth
- **WHEN** Refined Cutout 生成 Vector 或 Z Depth
- **THEN** MoGe-2 黑底输入和 Depth Metadata 参考内容范围 SHALL 来自完整 Refine 图片 Alpha,不得乘入 Selection Mask
#### Scenario: Depth texture preserves model validity
- **WHEN** 系统写出 Vector 或 Z Depth EXR
- **THEN** RGB SHALL 保留完整连续 Field,Alpha SHALL 保存 MoGe-2 Validity,不得用 Refine Alpha 或 Selection Mask 覆盖
### Requirement: Cutout mesh combines refined content with Selection
Cutout SHALL 只在 Blender 网格构建阶段计算 `Source Alpha × BEN2 Alpha × Selection Mask`,并使用该结果约束 BaseShape、内容空值检查和网格标定。
#### Scenario: BEN2 keeps content outside the user Selection
- **WHEN** BEN2 在 Selection bounds 内保留了 Selection Mask 外的前景
- **THEN** Color、Normal 与 Depth SHALL 保留该内容,但最终 Cutout 网格 MUST 不包含 Selection Mask 外的区域
#### Scenario: Combined Cutout content is empty
- **WHEN** Refine Alpha 与 Selection Mask 相乘后没有超过 Cutout Alpha 阈值的像素
- **THEN** Cutout SHALL 报告空内容且不创建网格
#### Scenario: Cutout generates only Normal or Depth
- **WHEN** Cutout 未启用 Refine、但因 Normal 或 Depth 进入 Job
- **THEN** 贴图 SHALL 使用完整 bounds 源 Alpha,网格 SHALL 使用 `Source Alpha × Selection Mask`
### Requirement: Crop Refine applies Selection in Blender
Crop Refine SHALL 在 Blender 中计算 `Source Alpha × BEN2 Alpha × Selection Mask` 作为最终图片 Alpha,同时保持提交前的矩形尺寸和 placement bounds。
#### Scenario: BEN2 keeps pixels outside a Crop Selection
- **WHEN** BEN2 在 Crop Selection Mask 外生成可见 Alpha
- **THEN** 最终 Crop 图片 SHALL 将这些像素 Alpha 置为零,但 MUST 不改变提交前 bounds
#### Scenario: Crop uses one rasterized Selection
- **WHEN** Crop 提交 Refine Job 并异步接收结果
- **THEN** 系统 SHALL 复用提交阶段生成的 Selection Mask,不得在响应阶段重新栅格化 SelectionPath,也不得把 Mask 发送给 Server
#### Scenario: Combined Crop content is empty
- **WHEN** Refine Alpha 与 Selection Mask 相乘后没有可见像素
- **THEN** Crop SHALL 报告空内容且不创建结果 Image Empty

View File

@@ -0,0 +1,30 @@
## 1. Refine Server 输出协议
- [x] 1.1 修改通用 BEN2 Refine,使其保存与输入同尺寸的 `Source Alpha × BEN2 Alpha` RGBA,删除 Alpha 紧裁切与局部 bounds 计算;用像素和尺寸测试验证透明、半透明与前景局部输出。
- [x] 1.2 修改 `refine-image-selection` 与 Cutout artifact Job,只返回完整 Refine 图片并删除 `selection_bounds` 文件和结果 key;更新 Job 协议测试并确认 request 不包含 Selection Mask。
- [x] 1.3 验证 Cutout Refine 后的 Color、Normal 可见区域、MoGe-2 黑底输入和 Depth Metadata 都消费完整 Refine Alpha,同时 Depth EXR 继续保留连续 RGB Field 与模型 Validity Alpha。
## 2. Crop 与 Cutout 最终 Selection 约束
- [x] 2.1 为 Crop Refine 准备与 Bounds 输入同生命周期的本地 Selection Mask sidecar,确保 Job request 不发送该路径且 cleanup 能完整删除临时容器。
- [x] 2.2 修改 Crop Refine 响应,沿用提交前 bounds,在 Blender 中计算 `Refine Alpha × Selection Mask` 并创建完整 bounds 结果;用圈外 BEN2 内容、空交集、Keep Original 和一次栅格化测试验证。
- [x] 2.3 修改 Cutout Job 响应,让启用 Refine 与仅生成 Normal/Depth 两条路径都以“贴图 Alpha × 原 Selection Mask”构建网格,同时保持颜色及 AI 贴图未乘 Selection;用圈外内容、抗锯齿 Mask、空交集和标定测试验证。
- [x] 2.4 删除 Blender 端 Refine 局部 bounds 偏移与结果读取逻辑,验证 Crop 和 Cutout 的图片尺寸、placement bounds、颜色资产复用和异常清理。
## 3. 生成材质 IOR Level
- [x] 3.1 将 `O Image Layer` Color 直接连接到 Plane、Depth Plane 与 Cutout 生成材质的 Principled BSDF IOR Level,保留既有 IOR 和其他材质连接。
- [x] 3.2 增加材质节点测试,验证 Color-to-IOR-Level 直连、Cutout/Depth Plane IOR `1.2`、普通 Plane 覆盖以及 Shadeless 分支不回归。
## 4. Reference Depth 节点接口
- [x] 4.1 将 Depth Plane、Cutout、Depth Balloon 与 Depth Surface 构建脚本中的 `Base Plane Depth` 一次性改名为 `Reference Depth`,同步 Group Input 可见项、子组连线和面板顺序。
- [x] 4.2 更新 Depth Plane 与 Cutout 对 Modifier 输入的初始化、查找和测试,确保元数据与标定值只写入 `Reference Depth` 且几何公式不变。
- [x] 4.3 更新节点资产验证脚本和结构断言,确认所有相关接口、依赖集合与功能验证不再包含旧名称。
- [x] 4.4 运行 `uv run build_node` 重建并验证 `assets/O_AnyImage.blend`,检查 socket 顺序、节点布局、几何求值和未使用输出隐藏。
## 5. 文档与验证
- [x] 5.1 更新 Crop Tool、Cutout Tool、Convert to Plane、Runtime、Outputs 与节点资产内部文档,说明稳定 Refine bounds、贴图与网格 Mask 边界、IOR Level 连接和 `Reference Depth`。
- [x] 5.2 搜索并删除当前实现和文档中的 `Base Plane Depth`、Refine 局部 bounds 协议及“BEN2 圈外内容直接进入最终网格”的残留描述。
- [x] 5.3 运行相关 Server、Blender、Cutout、材质和节点资产测试,再运行 `uv run pytest` 完整测试;仅修复本 change 引入的回归,不构建其他文档或发布产物。

Binary file not shown.

View File

@@ -405,7 +405,7 @@ def load_normal_result_image(path, source_object):
return image
def create_pixel_result_image(source_image, pixels, output_size, suffix=""):
def create_image_edit_result(source_image, pixels, output_size):
"""Create a packed Image datablock from edited RGBA pixels."""
output_width, output_height = (int(value) for value in output_size)
if output_width <= 0 or output_height <= 0:
@@ -413,7 +413,7 @@ def create_pixel_result_image(source_image, pixels, output_size, suffix=""):
if len(pixels) != output_width * output_height * 4:
raise ValueError("The edited pixels do not match the image dimensions")
image = bpy.data.images.new(
f"{image_base_name(source_image)}{suffix}",
source_image.name,
width=output_width,
height=output_height,
alpha=True,
@@ -525,11 +525,12 @@ def place_empty_image_result(
def load_color_result_image(
def _load_packed_result_image(
source_object,
output_directory,
*,
frame_prefix="image.",
frame_prefix,
result_name,
):
output_directory = Path(output_directory)
color_paths = sorted_frame_paths(output_directory, frame_prefix)
@@ -555,7 +556,6 @@ def load_color_result_image(
raise RuntimeError("Unable to load the color result") from error
try:
result_name = color_data_name(source_object)
if animated:
pack_sequence_if_possible(result_image, len(color_paths))
else:
@@ -567,6 +567,36 @@ def load_color_result_image(
return result_image
def load_image_edit_result(
source_object,
output_directory,
*,
frame_prefix="image.",
):
"""Load one packed Job result that directly edits the source Image."""
return _load_packed_result_image(
source_object,
output_directory,
frame_prefix=frame_prefix,
result_name=source_object.data.name,
)
def load_color_result_image(
source_object,
output_directory,
*,
frame_prefix="image.",
):
"""Load one packed Color texture result for a generated material."""
return _load_packed_result_image(
source_object,
output_directory,
frame_prefix=frame_prefix,
result_name=color_data_name(source_object),
)
def image_empty_bounds(source_object):
image_width, image_height = source_object.data.size
if image_width <= 0 or image_height <= 0:

View File

@@ -208,6 +208,10 @@ def create_image_material(
material_inputs.outputs["Roughness"],
shader.inputs["Roughness"],
)
links.new(
material_inputs.outputs["Color"],
shader.inputs["Specular IOR Level"],
)
links.new(shader.outputs["BSDF"], output.inputs["Surface"])
if displacement_image is not None:

View File

@@ -3,6 +3,7 @@
import json
import math
from dataclasses import dataclass
from pathlib import Path
class ImageEditWarning(ValueError):
@@ -86,6 +87,29 @@ class SelectionMask:
return result
def save_selection_mask(path, selection_mask):
"""Save one local Selection Mask beside a prepared image input."""
import numpy as np
path = Path(path).parent / "selection-mask.npy"
np.save(path, selection_mask.values, allow_pickle=False)
return path
def load_selection_mask(path, bounds):
"""Load a local Selection Mask sidecar without allowing object data."""
import numpy as np
values = np.load(Path(path), allow_pickle=False)
return SelectionMask(values, bounds)
def cleanup_selection_mask(path):
"""Remove a local Selection Mask sidecar."""
if path:
Path(path).unlink(missing_ok=True)
def selection_path_bounds(points):
import numpy as np

View File

@@ -89,7 +89,7 @@ def create_depth_plane_object(
("Subdivide", max(0, min(10, int(subdivisions)))),
("Thickness", 0.1),
("Depth Scale", 1.0),
("Base Plane Depth", depth_metadata["reference_depth"] * uniform_scale),
("Reference Depth", depth_metadata["reference_depth"] * uniform_scale),
("Uniform Scale", uniform_scale),
("Depth Image", depth_image),
):

View File

@@ -18,7 +18,7 @@ from ...common.viewport import (
)
from ...common.selection import crop_pixels_to_alpha
from ...common.image import (
create_pixel_result_image,
create_image_edit_result,
image_base_name,
image_pixels,
image_empty_bounds,
@@ -829,7 +829,7 @@ class CropImagePerspective(bpy.types.Operator):
quad,
self.aspect_ratio,
)
result_image = create_pixel_result_image(
result_image = create_image_edit_result(
source_object.data,
pixels,
output_size,

View File

@@ -11,8 +11,7 @@ from ...common.ai import (
)
from ...common.image import (
cleanup_image_input,
create_pixel_result_image,
image_base_name,
create_image_edit_result,
image_rgba,
load_packed_rgba_image,
place_empty_image_result,
@@ -28,7 +27,10 @@ from ...common.viewport import (
)
from ...common.selection import (
ImageEditWarning,
cleanup_selection_mask,
load_selection_mask,
rasterize_selection_path,
save_selection_mask,
)
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
@@ -64,6 +66,21 @@ def _edit_image_mask_pixels(
)
def _apply_local_selection_mask(source_rgba, selection_mask):
"""Apply a same-sized Selection Mask without changing the image canvas."""
import numpy as np
rgba = np.asarray(source_rgba, dtype=np.float32).copy()
if rgba.ndim != 3 or rgba.shape[2] != 4:
raise ValueError("The Refine result must contain RGBA pixels")
if rgba.shape[:2] != selection_mask.values.shape:
raise ValueError("The Refine result does not match the Selection")
rgba[:, :, 3] *= selection_mask.values
if not np.any(rgba[:, :, 3] > 0.0):
raise ImageEditWarning("The edit contains no visible image pixels")
return np.flipud(rgba).ravel()
def _edit_image_object(
context,
source_object,
@@ -78,7 +95,7 @@ def _edit_image_object(
selection_mask,
)
left, top, right, bottom = crop_bounds
result_image = create_pixel_result_image(
result_image = create_image_edit_result(
image,
pixels,
(right - left, bottom - top),
@@ -145,13 +162,23 @@ class CropImageSelection(CropSelectionGesture, bpy.types.Operator):
source_rgba,
selection_mask.bounds,
)
try:
selection_mask_path = save_selection_mask(
source_path,
selection_mask,
)
except Exception:
cleanup_image_input(source_path, True)
raise
result = bpy.ops.anyimage.refine_crop_selection(
input_path=str(source_path),
selection_mask_path=str(selection_mask_path),
source_object_name=self.source_object_name,
selection_bounds_json=json.dumps(selection_mask.bounds),
keep_original=keep_original,
)
if "CANCELLED" in result:
cleanup_selection_mask(selection_mask_path)
cleanup_image_input(source_path, True)
return {"CANCELLED"}
return {"FINISHED"}
@@ -175,6 +202,7 @@ class RefineCropSelection(JobOperatorBase, bpy.types.Operator):
starting_message = "Refining image selection"
input_path: bpy.props.StringProperty(options=_HIDDEN)
selection_mask_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
selection_bounds_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
keep_original: bpy.props.BoolProperty(default=False, options=_HIDDEN)
@@ -191,23 +219,23 @@ class RefineCropSelection(JobOperatorBase, bpy.types.Operator):
def response(self, context, result):
source_object = require_image_empty(self.source_object_name)
source_bounds = tuple(json.loads(self.selection_bounds_json))
local_bounds = tuple(
json.loads(result.file("selection_bounds").read_text(encoding="utf-8"))
)
bounds = (
source_bounds[0] + local_bounds[0],
source_bounds[1] + local_bounds[1],
source_bounds[0] + local_bounds[2],
source_bounds[1] + local_bounds[3],
)
bounds = tuple(json.loads(self.selection_bounds_json))
selection_mask = load_selection_mask(self.selection_mask_path, bounds)
source_image = source_object.data
refined_image = load_packed_rgba_image(
encoded_image = load_packed_rgba_image(
result.file("selection_image"),
source_image,
image_base_name(source_image),
"AnyImage Refine Result",
)
refined_image = None
try:
rgba = image_rgba(encoded_image)
pixels = _apply_local_selection_mask(rgba, selection_mask)
refined_image = create_image_edit_result(
source_image,
pixels,
selection_mask.values.shape[::-1],
)
place_empty_image_result(
context,
source_object,
@@ -216,14 +244,18 @@ class RefineCropSelection(JobOperatorBase, bpy.types.Operator):
keep_original=bool(getattr(self, "keep_original", False)),
)
except Exception:
if refined_image.users == 0:
if refined_image is not None and refined_image.users == 0:
bpy.data.images.remove(refined_image, do_unlink=True)
raise
finally:
if encoded_image.users == 0:
bpy.data.images.remove(encoded_image, do_unlink=True)
if "FINISHED" not in bpy.ops.ed.undo_push(message="Crop Image Selection"):
raise RuntimeError("Unable to create the Crop Image Selection Undo step")
return "Crop Selection refined"
def cleanup(self):
cleanup_selection_mask(getattr(self, "selection_mask_path", ""))
cleanup_image_input(self.input_path, True)

View File

@@ -4,7 +4,6 @@ import math
from dataclasses import dataclass
from ...preferences import addon_preferences
from ...server.media.resolution import limited_image_size
from ...common.image import image_empty_bounds
@@ -1342,10 +1341,12 @@ def crop_plane_bounds(source_object, crop_bounds, source_size):
def extract_cutout_contours(
content_values,
mesh_resolution,
point_spacing,
threshold=CUTOUT_ALPHA_THRESHOLD,
):
contour_resolution = max(512, min(int(mesh_resolution) * 8, 1024))
height, width = content_values.shape
sampling_resolution = max(width, height) / float(point_spacing)
contour_resolution = max(512, min(round(sampling_resolution * 8), 1024))
return extract_selection_contours(
content_values,
max_resolution=contour_resolution,
@@ -1353,24 +1354,28 @@ def extract_cutout_contours(
)
def limit_cutout_mesh_resolution(mesh_resolution, crop_size, max_input_size):
def limit_cutout_point_spacing(point_spacing, crop_size, max_mesh_resolution):
crop_width, crop_height = (int(value) for value in crop_size)
limited_width, limited_height = limited_image_size(
(crop_width, crop_height),
max_input_size,
)
scale = max(limited_width, limited_height) / max(crop_width, crop_height)
return max(4, min(1024, round(int(mesh_resolution) * scale)))
spacing = float(point_spacing)
if crop_width <= 0 or crop_height <= 0:
raise ValueError("The Cutout bounds must be positive")
if spacing <= 0.0:
raise ValueError("Cutout point spacing must be greater than zero")
maximum = int(max_mesh_resolution)
if maximum <= 0:
raise ValueError("Maximum Cutout mesh resolution must be positive")
return max(spacing, max(crop_width, crop_height) / maximum)
def cutout_sampling_spacing(source_object, mesh_resolution):
def cutout_sampling_spacing(source_object, point_spacing):
import math
x_min, x_max, y_min, y_max = image_empty_bounds(source_object)
metric, _world_z_scale = world_plane_metric(source_object.matrix_world)
width = (x_max - x_min) * math.sqrt(max(metric[0][0], 0.0))
height = (y_max - y_min) * math.sqrt(max(metric[1][1], 0.0))
return max(width, height) / max(int(mesh_resolution), 8)
image_width, image_height = (int(value) for value in source_object.data.size)
return max(width / image_width, height / image_height) * float(point_spacing)
def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD):
@@ -1397,7 +1402,7 @@ def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_T
def build_base_shape(
context,
source_object,
mesh_resolution,
point_spacing,
content_values,
content_bounds,
):
@@ -1410,7 +1415,7 @@ def build_base_shape(
)
selection_contours = extract_cutout_contours(
content_values,
mesh_resolution,
point_spacing,
threshold=CUTOUT_ALPHA_THRESHOLD,
)
world_metric, world_z_scale = world_plane_metric(source_object.matrix_world)
@@ -1418,7 +1423,7 @@ def build_base_shape(
raise ValueError("The Image Empty has a degenerate world transform")
preferences = addon_preferences(context)
balloon_algorithm = getattr(preferences, "balloon_profile_algorithm", "POISSON")
point_spacing = cutout_sampling_spacing(source_object, mesh_resolution)
point_spacing = cutout_sampling_spacing(source_object, point_spacing)
domain = cutout_domain(
selection_contours,
bounds,

View File

@@ -1,9 +1,5 @@
"""Cutout Selection validation and Shape-menu interaction helpers."""
from ...preferences import (
MAX_CUTOUT_MESH_RESOLUTION,
MIN_CUTOUT_MESH_RESOLUTION,
)
from ...properties import ai_ready
from .shape import CUTOUT_SHAPE_LABELS
@@ -22,15 +18,6 @@ CUTOUT_MESH_DETAIL_SPACING = {
}
def cutout_mesh_resolution(image_size, point_spacing):
longest_edge = max(int(value) for value in image_size)
detail = round(longest_edge / max(int(point_spacing), 1))
return max(
MIN_CUTOUT_MESH_RESOLUTION,
min(MAX_CUTOUT_MESH_RESOLUTION, detail),
)
def cutout_mesh_detail_spacing(mesh_detail):
return CUTOUT_MESH_DETAIL_SPACING[str(mesh_detail)]
@@ -44,7 +31,7 @@ def _pie_button(
selection_path_json,
refine_selection,
generate_normal,
mesh_resolution,
point_spacing,
inward_axis,
):
operator = pie.operator(
@@ -57,7 +44,7 @@ def _pie_button(
operator.shape = shape
operator.refine_selection = refine_selection
operator.generate_normal = generate_normal
operator.mesh_resolution = mesh_resolution
operator.point_spacing = point_spacing
operator.inward_axis = inward_axis
@@ -77,7 +64,6 @@ def open_shape_pie(
generate_normal = ready and bool(
getattr(settings, "cutout_generate_normal", False)
)
mesh_resolution = cutout_mesh_resolution(source_object.data.size, point_spacing)
inward_axis = str(getattr(settings, "cutout_inward_axis", "NEGATIVE_Z"))
def draw(menu, _context):
@@ -93,7 +79,7 @@ def open_shape_pie(
selection_path_json,
refine_selection,
generate_normal,
mesh_resolution,
point_spacing,
inward_axis,
)

View File

@@ -5,8 +5,6 @@ import json
import bpy
from ...preferences import (
MAX_CUTOUT_MESH_RESOLUTION,
MIN_CUTOUT_MESH_RESOLUTION,
addon_preferences,
configured_max_ai_input_size,
configured_max_cutout_mesh_resolution,
@@ -60,7 +58,7 @@ from ...common.selection import (
from .geometry import (
CUTOUT_ALPHA_THRESHOLD,
build_base_shape,
limit_cutout_mesh_resolution,
limit_cutout_point_spacing,
)
from .interaction import open_shape_pie
from .object import (
@@ -95,7 +93,7 @@ def create_cutout_shape(
context,
source_object,
shape,
mesh_resolution,
point_spacing,
content_values,
content_bounds,
*,
@@ -110,7 +108,7 @@ def create_cutout_shape(
base_shape = build_base_shape(
context,
source_object,
mesh_resolution,
point_spacing,
content_values,
content_bounds,
)
@@ -118,7 +116,7 @@ def create_cutout_shape(
context,
source_object,
shape,
mesh_resolution,
point_spacing,
content_values,
content_bounds,
base_shape,
@@ -164,21 +162,8 @@ def _generated_color_result(result, input_path, source_object, source_bounds):
value = result.value if isinstance(result.value, dict) else {}
if "selection_image" not in value:
path = input_path
bounds = source_bounds
else:
path = result.file("selection_image")
local_bounds = tuple(
int(value)
for value in json.loads(
result.file("selection_bounds").read_text(encoding="utf-8")
)
)
bounds = (
source_bounds[0] + local_bounds[0],
source_bounds[1] + local_bounds[1],
source_bounds[0] + local_bounds[2],
source_bounds[1] + local_bounds[3],
)
image = load_packed_rgba_image(
path,
source_object.data,
@@ -190,7 +175,7 @@ def _generated_color_result(result, input_path, source_object, source_bounds):
if image.users == 0:
bpy.data.images.remove(image, do_unlink=True)
raise
return image, alpha, bounds
return image, alpha, source_bounds
def cutout_geometry_settings(context):
@@ -215,10 +200,9 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
selection_path_json: bpy.props.StringProperty(options=_HIDDEN)
selection_bounds_json: bpy.props.StringProperty(options=_HIDDEN)
shape: bpy.props.StringProperty(options=_HIDDEN)
mesh_resolution: bpy.props.IntProperty(
default=128,
min=MIN_CUTOUT_MESH_RESOLUTION,
max=MAX_CUTOUT_MESH_RESOLUTION,
point_spacing: bpy.props.FloatProperty(
default=32.0,
min=0.01,
options=_HIDDEN,
)
model: bpy.props.StringProperty(
@@ -274,8 +258,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
source_object.select_set(True)
crop_width = selection_mask.bounds[2] - selection_mask.bounds[0]
crop_height = selection_mask.bounds[3] - selection_mask.bounds[1]
self.mesh_resolution = limit_cutout_mesh_resolution(
self.mesh_resolution,
self.point_spacing = limit_cutout_point_spacing(
self.point_spacing,
(crop_width, crop_height),
configured_max_cutout_mesh_resolution(context),
)
@@ -295,7 +279,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
context,
source_object,
self.shape,
self.mesh_resolution,
self.point_spacing,
content_values,
selection_mask.bounds,
inward_axis=self.inward_axis,
@@ -387,12 +371,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
depth_image = None
shape_started = False
try:
if "selection_image" in value:
content_values = _cutout_content_values(
source_alpha,
)
content_bounds = color_bounds
else:
selection_mask = getattr(self, "_selection_mask", None)
if selection_mask is None:
raise RuntimeError("The Cutout Selection is no longer available")
@@ -400,7 +378,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
source_alpha,
selection_mask.values,
)
content_bounds = selection_mask.bounds
content_bounds = color_bounds
normal_mode = normal_mode_for_shape(self.shape, self.generate_normal)
normal_key = f"{normal_mode.value.lower()}_normal"
if normal_key in value:
@@ -424,7 +402,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
context,
source_object,
self.shape,
self.mesh_resolution,
self.point_spacing,
content_values,
content_bounds,
depth_image=depth_image,

View File

@@ -156,7 +156,7 @@ def create_shape_object(
context,
source_object,
shape,
mesh_resolution,
point_spacing,
content_values,
content_bounds,
base_shape,
@@ -183,7 +183,7 @@ def create_shape_object(
)
world_metric, _world_z_scale = world_plane_metric(source_object.matrix_world)
cleanup_value = cutout_cleanup_value(
cutout_sampling_spacing(source_object, mesh_resolution),
cutout_sampling_spacing(source_object, point_spacing),
world_metric,
)
material = None
@@ -293,7 +293,7 @@ def create_shape_object(
for name, value in (
("Depth Scale", 1.0),
("Uniform Scale", uniform_scale),
("Base Plane Depth", depth_reference),
("Reference Depth", depth_reference),
("Depth Image", depth_image),
):
set_modifier_input(

View File

@@ -71,13 +71,13 @@ class RemoveImageBackground(bpy.types.Operator):
def apply_background_response(result, source_object_name):
from ..common.image import (
load_color_result_image,
load_image_edit_result,
replace_empty_image,
require_image_empty,
)
source_object = require_image_empty(source_object_name)
result_image = load_color_result_image(source_object, result.directory)
result_image = load_image_edit_result(source_object, result.directory)
replace_empty_image(source_object, result_image)
if "FINISHED" not in bpy.ops.ed.undo_push(message="Remove Background"):
raise RuntimeError("Unable to create the Remove Background Undo step")

View File

@@ -19,7 +19,7 @@ from ..common.image import (
cleanup_image_input,
default_extract_frames,
is_image_empty,
load_color_result_image,
load_image_edit_result,
prepare_image_input,
source_start_frame,
replace_empty_image,
@@ -148,7 +148,7 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator):
source_image = bpy.data.images.get(self.source_image_name)
if source_image is None:
raise RuntimeError("The source image is no longer available")
result_image = load_color_result_image(
result_image = load_image_edit_result(
source_object,
result.directory,
frame_prefix="upscale.",

View File

@@ -1,6 +1,5 @@
"""Generate optional AI files from one rectangular Cutout image input."""
import json
from pathlib import Path
from ..geometry.prediction_artifacts import generate_moge2_artifacts
@@ -15,22 +14,14 @@ def run(context, parameters):
result = {}
if parameters.get("refine_selection"):
context.progress(0.2, "Refining Selection")
selection_image_path, bounds = refine_selection(
selection_image_path = refine_selection(
context,
input_path,
parameters["refine_model"],
parameters["device"],
context.directory / "selection.png",
)
bounds_path = context.directory / "selection_bounds.json"
bounds_path.write_text(
json.dumps(bounds, separators=(",", ":")),
encoding="utf-8",
)
result.update(
selection_image=selection_image_path.name,
selection_bounds=bounds_path.name,
)
result["selection_image"] = selection_image_path.name
depth_mode = str(parameters.get("depth_mode", "NONE"))
normal_mode = str(parameters.get("normal_mode", "NONE"))

View File

@@ -1,27 +1,24 @@
import json
import time
from ..selection import refine_selection
def run(context, parameters):
"""Refine a gesture mask with BEN2 and return its source-image bounds."""
"""Refine foreground Alpha while preserving the submitted bounds canvas."""
started = time.perf_counter()
context.progress(0.05, "Preparing image Selection")
context.check_cancelled()
output_directory = context.directory
output_directory.mkdir(parents=True, exist_ok=True)
context.progress(0.2, f"Loading BEN2 for {parameters['device'].upper()}")
output_path, bounds = refine_selection(
output_path = refine_selection(
context,
parameters["input"],
parameters["model"],
parameters["device"],
output_directory / "image.png",
)
bounds_path = output_directory / "bounds.json"
bounds_path.write_text(json.dumps(bounds, separators=(",", ":")), encoding="utf-8")
elapsed_ms = (time.perf_counter() - started) * 1000.0
message = f"Image Selection refined in {elapsed_ms:.0f} ms"
context.progress(1.0, message)
return {"selection_image": output_path.name, "selection_bounds": bounds_path.name}
return {"selection_image": output_path.name}

View File

@@ -1,24 +1,6 @@
from ..models.ben2 import infer_foreground
def _crop_to_alpha(image):
import numpy as np
pixels = np.asarray(image.convert("RGBA"), dtype=np.uint8)
visible = pixels[:, :, 3] > 0
visible_y = np.flatnonzero(np.any(visible, axis=1))
visible_x = np.flatnonzero(np.any(visible, axis=0))
if not len(visible_x):
raise ValueError("The generated Selection contains no visible pixels")
bounds = (
int(visible_x[0]),
int(visible_y[0]),
int(visible_x[-1]) + 1,
int(visible_y[-1]) + 1,
)
return image.crop(bounds), bounds
def refine_selection(
context,
input_path,
@@ -37,6 +19,5 @@ def refine_selection(
preserve_input_alpha=True,
)
context.check_cancelled()
output, bounds = _crop_to_alpha(foreground)
output.save(output_path, format="PNG", compress_level=1)
return output_path, bounds
foreground.convert("RGBA").save(output_path, format="PNG", compress_level=1)
return output_path

View File

@@ -1242,7 +1242,7 @@ class BlenderAddonTest(unittest.TestCase):
}
),
shape=shape,
mesh_resolution=64,
point_spacing=32,
generate_normal=False,
refine_selection=False,
report=lambda *_args: None,
@@ -1300,7 +1300,7 @@ class BlenderAddonTest(unittest.TestCase):
context,
source,
shape,
64,
32.0,
selection_mask.values,
selection_mask.bounds,
inward_axis="NEGATIVE_Z",
@@ -1331,7 +1331,7 @@ class BlenderAddonTest(unittest.TestCase):
}
)
operator.shape = "DEPTH_SURFACE"
operator.mesh_resolution = 64
operator.point_spacing = 32
operator.generate_normal = False
operator.refine_selection = False
operator.report = lambda *_args: None
@@ -1413,7 +1413,7 @@ class BlenderAddonTest(unittest.TestCase):
}
),
shape="SURFACE",
mesh_resolution=64,
point_spacing=32,
generate_normal=False,
refine_selection=False,
report=lambda level, message: reports.append((level, message)),
@@ -1721,6 +1721,78 @@ class BlenderAddonTest(unittest.TestCase):
self.assertIs(result, expected)
self.assertNotIn("REGISTER", operator.bl_options)
def test_remove_background_loads_a_plain_image_edit_result(self):
source = SimpleNamespace()
result_image = SimpleNamespace()
result = SimpleNamespace(directory=Path("result"))
ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
)
with (
patch.object(self.image_data, "require_image_empty", return_value=source),
patch.object(
self.image_data,
"load_image_edit_result",
return_value=result_image,
) as load,
patch.object(self.image_data, "replace_empty_image") as replace,
patch.object(self.remove_background.bpy, "ops", ops, create=True),
):
self.remove_background.apply_background_response(result, "Source")
load.assert_called_once_with(source, Path("result"))
replace.assert_called_once_with(source, result_image)
def test_upscale_loads_a_plain_image_edit_result(self):
source_image = SimpleNamespace()
source = SimpleNamespace(data=source_image)
result_image = SimpleNamespace()
result = SimpleNamespace(directory=Path("result"))
operator = self.upscale.UpscaleImage()
operator.source_object_name = "Source"
operator.source_image_name = "Poster.png"
operator.model = "SAFMN_REAL_X4"
images = SimpleNamespace(get=lambda name: source_image if name == "Poster.png" else None)
with (
patch.object(self.upscale, "require_image_empty", return_value=source),
patch.object(
self.upscale,
"load_image_edit_result",
return_value=result_image,
) as load,
patch.object(self.upscale, "replace_empty_image") as replace,
patch.object(
self.upscale,
"upscale_model_label",
return_value="SAFMN Real x4",
),
patch.object(
self.upscale.bpy,
"data",
SimpleNamespace(images=images),
),
):
message = operator.response(None, result)
load.assert_called_once_with(
source,
Path("result"),
frame_prefix="upscale.",
)
replace.assert_called_once_with(source, result_image)
self.assertEqual(message, "Image is upscaled with SAFMN Real x4")
def test_crop_tools_share_the_plain_pixel_edit_result_creator(self):
self.assertIs(
self.edit_mask.create_image_edit_result,
self.image_data.create_image_edit_result,
)
self.assertIs(
self.image_perspective.create_image_edit_result,
self.image_data.create_image_edit_result,
)
self.assertFalse(hasattr(self.image_data, "create_pixel_result_image"))
def test_packed_image_export_does_not_use_scene_render_transform(self):
with tempfile.TemporaryDirectory() as directory:
output_directory = Path(directory)
@@ -2681,7 +2753,7 @@ class BlenderAddonTest(unittest.TestCase):
self.assertTrue(np.all(rgba[:, :, 3] == 1.0))
self.assertTrue(np.all(rgba[:, :, 0] == np.arange(2, 7)[None, :]))
def test_refined_image_edit_writes_the_selection_bounds_image(self):
def test_refined_image_edit_writes_one_local_selection_mask(self):
import numpy as np
source = SimpleNamespace(data=SimpleNamespace(size=(8, 6)))
@@ -2725,6 +2797,11 @@ class BlenderAddonTest(unittest.TestCase):
"prepare_image_bounds_input",
return_value=Path(__file__),
) as prepare_input,
patch.object(
self.edit_mask,
"save_selection_mask",
return_value=Path("selection-mask.npy"),
) as save_mask,
patch.object(self.edit_mask.bpy, "ops", ops, create=True),
):
result = self.edit_mask.CropImageSelection._submit_selection_path(
@@ -2741,29 +2818,55 @@ class BlenderAddonTest(unittest.TestCase):
source_rgba,
selection_mask.bounds,
)
refine.assert_called_once()
save_mask.assert_called_once_with(Path(__file__), selection_mask)
self.assertEqual(
refine.call_args.kwargs["selection_mask_path"],
"selection-mask.npy",
)
def test_refined_image_request_excludes_the_local_selection_mask(self):
operator = self.edit_mask.RefineCropSelection()
operator.input_path = __file__
operator.selection_mask_path = "selection-mask.npy"
with (
patch.object(self.edit_mask, "require_environment"),
patch.object(self.edit_mask, "require_model"),
patch.object(self.edit_mask, "production_device", return_value="CPU"),
):
parameters = operator.request(None)
self.assertEqual(parameters["input"], __file__)
self.assertNotIn("selection_mask_path", parameters)
def test_refined_image_response_applies_the_saved_mask_on_original_bounds(self):
import numpy as np
def test_refined_image_response_uses_the_encoded_result_directly(self):
source_image = SimpleNamespace(
name="Poster.png",
colorspace_settings=SimpleNamespace(name="sRGB"),
alpha_mode="STRAIGHT",
)
source = SimpleNamespace(data=source_image)
refined = SimpleNamespace(users=0)
files = {
"selection_bounds": SimpleNamespace(
read_text=lambda encoding: "[2,1,5,4]"
),
"selection_image": Path(__file__),
}
encoded = SimpleNamespace(users=0)
refined = SimpleNamespace(users=1)
files = {"selection_image": Path(__file__)}
result = SimpleNamespace(file=lambda key: files[key])
operator = self.edit_mask.RefineCropSelection()
operator.source_object_name = "Source"
operator.selection_bounds_json = "[10,20,18,26]"
operator.selection_mask_path = "selection-mask.npy"
operator.keep_original = True
values = np.ones((6, 8), dtype=np.float32)
values[:, 0] = 0.0
selection_mask = self.image_selection.SelectionMask(
values,
(10, 20, 18, 26),
)
rgba = np.ones((6, 8, 4), dtype=np.float32)
ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
)
remove = Mock()
with (
patch.object(
@@ -2774,14 +2877,30 @@ class BlenderAddonTest(unittest.TestCase):
patch.object(
self.edit_mask,
"load_packed_rgba_image",
return_value=refined,
return_value=encoded,
) as load_result,
patch.object(self.edit_mask, "place_empty_image_result") as place,
patch.object(self.edit_mask, "image_rgba") as read_rgba,
patch.object(
self.edit_mask,
"create_pixel_result_image",
"load_selection_mask",
return_value=selection_mask,
) as load_mask,
patch.object(self.edit_mask, "place_empty_image_result") as place,
patch.object(
self.edit_mask,
"image_rgba",
return_value=rgba,
) as read_rgba,
patch.object(
self.edit_mask,
"create_image_edit_result",
return_value=refined,
) as create_pixels,
patch.object(
self.edit_mask.bpy.data,
"images",
SimpleNamespace(remove=remove),
create=True,
),
patch.object(self.edit_mask.bpy, "ops", ops, create=True),
):
message = operator.response(None, result)
@@ -2790,17 +2909,38 @@ class BlenderAddonTest(unittest.TestCase):
load_result.assert_called_once_with(
Path(__file__),
source_image,
"Poster",
"AnyImage Refine Result",
)
load_mask.assert_called_once_with("selection-mask.npy", (10, 20, 18, 26))
read_rgba.assert_called_once_with(encoded)
pixels = create_pixels.call_args.args[1]
masked = np.flipud(pixels.reshape((6, 8, 4)))
np.testing.assert_array_equal(masked[:, 0, 3], 0.0)
np.testing.assert_array_equal(masked[:, 1:, 3], 1.0)
place.assert_called_once_with(
None,
source,
refined,
placement_bounds=(12, 21, 15, 24),
keep_original=False,
placement_bounds=(10, 20, 18, 26),
keep_original=True,
)
remove.assert_called_once_with(encoded, do_unlink=True)
def test_refined_image_mask_rejects_an_empty_intersection(self):
import numpy as np
selection_mask = self.image_selection.SelectionMask(
np.zeros((2, 3), dtype=np.float32),
(1, 2, 4, 4),
)
with self.assertRaisesRegex(
self.image_selection.ImageEditWarning,
"no visible image pixels",
):
self.edit_mask._apply_local_selection_mask(
np.ones((2, 3, 4), dtype=np.float32),
selection_mask,
)
read_rgba.assert_not_called()
create_pixels.assert_not_called()
def test_cutout_ai_response_reuses_color_image_and_keeps_lasso_for_no_refine(self):
import numpy as np
@@ -2818,7 +2958,7 @@ class BlenderAddonTest(unittest.TestCase):
operator.selection_bounds_json = "[10,20,14,23]"
operator.input_path = __file__
operator.shape = "SURFACE"
operator.mesh_resolution = 32
operator.point_spacing = 32
operator.generate_normal = True
operator.refine_selection = False
operator._selection_mask = selection_mask
@@ -2849,7 +2989,7 @@ class BlenderAddonTest(unittest.TestCase):
self.assertEqual(create_shape.call_args.args[5], selection_mask.bounds)
self.assertIs(create_shape.call_args.kwargs["color_image"], color_image)
def test_cutout_refine_keeps_ben2_content_outside_the_lasso(self):
def test_cutout_refine_keeps_textures_but_masks_the_mesh(self):
import numpy as np
source = SimpleNamespace(data=SimpleNamespace())
@@ -2859,11 +2999,13 @@ class BlenderAddonTest(unittest.TestCase):
operator.selection_bounds_json = "[10,20,16,24]"
operator.input_path = __file__
operator.shape = "SURFACE"
operator.mesh_resolution = 32
operator.point_spacing = 32
operator.generate_normal = False
operator.refine_selection = True
selection_values = np.ones((4, 6), dtype=np.float32)
selection_values[:, 0] = 0.0
operator._selection_mask = self.image_selection.SelectionMask(
np.zeros((4, 6), dtype=np.float32),
selection_values,
(10, 20, 16, 24),
)
result = SimpleNamespace(
@@ -2882,8 +3024,8 @@ class BlenderAddonTest(unittest.TestCase):
"_generated_color_result",
return_value=(
color_image,
np.ones((2, 3), dtype=np.float32),
(12, 21, 15, 23),
np.ones((4, 6), dtype=np.float32),
(10, 20, 16, 24),
),
),
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
@@ -2893,9 +3035,9 @@ class BlenderAddonTest(unittest.TestCase):
content_values = create_shape.call_args.args[4]
np.testing.assert_array_equal(
content_values,
np.ones((2, 3), dtype=np.float32),
selection_values,
)
self.assertEqual(create_shape.call_args.args[5], (12, 21, 15, 23))
self.assertEqual(create_shape.call_args.args[5], (10, 20, 16, 24))
self.assertIs(create_shape.call_args.kwargs["color_image"], color_image)
def test_image_selection_uses_white_dashed_segments(self):

View File

@@ -34,10 +34,10 @@ from anyimage.server.models import da3 as da3_model
from anyimage.server.models.geometry import GeometryFrame
def test_cutout_mesh_detail_uses_the_cropped_analysis_scale():
assert cutout_geometry.limit_cutout_mesh_resolution(256, (4096, 1024), 2048) == 128
assert cutout_geometry.limit_cutout_mesh_resolution(256, (1024, 512), 2048) == 256
assert cutout_geometry.limit_cutout_mesh_resolution(8, (8192, 4096), 2048) == 4
def test_cutout_mesh_limit_caps_actual_sampling_resolution():
assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 1024) == 32
assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 64) == 64
assert cutout_geometry.limit_cutout_point_spacing(4, (8192, 4096), 1024) == 8
def test_depth_metadata_loader_uses_one_schema_for_plane_and_cutout(tmp_path):
@@ -378,17 +378,20 @@ def test_blender_cutout_contour_resolution_tracks_mesh_detail():
components=(((((0, 0), (1, 0), (0, 1))),),),
image_size=(1, 1),
)
selection_mask = SelectionMask(np.ones((2, 2), dtype=np.float32), (0, 0, 2, 2))
values = SimpleNamespace(shape=(1024, 1024))
with patch.object(cutout_geometry, "extract_selection_contours", return_value=expected) as trace:
assert cutout_geometry.extract_cutout_contours(selection_mask.values, 32) == expected
assert cutout_geometry.extract_cutout_contours(selection_mask.values, 256) == expected
assert cutout_geometry.extract_cutout_contours(values, 32) == expected
assert cutout_geometry.extract_cutout_contours(values, 4) == expected
assert trace.call_args_list[0].kwargs["max_resolution"] == 512
assert trace.call_args_list[1].kwargs["max_resolution"] == 1024
def test_cutout_sampling_spacing_uses_the_complete_source_plane():
source = SimpleNamespace(matrix_world=object())
source = SimpleNamespace(
data=SimpleNamespace(size=(400, 200)),
matrix_world=object(),
)
with (
patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object(
@@ -397,9 +400,32 @@ def test_cutout_sampling_spacing_uses_the_complete_source_plane():
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
spacing = cutout_geometry.cutout_sampling_spacing(source, 200)
spacing = cutout_geometry.cutout_sampling_spacing(source, 32)
assert spacing == 0.02
assert spacing == 0.32
def test_cutout_sampling_spacing_tracks_upscaled_source_pixels():
original = SimpleNamespace(
data=SimpleNamespace(size=(2048, 1024)),
matrix_world=object(),
)
upscaled = SimpleNamespace(
data=SimpleNamespace(size=(8192, 4096)),
matrix_world=object(),
)
with (
patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object(
cutout_geometry,
"world_plane_metric",
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
original_spacing = cutout_geometry.cutout_sampling_spacing(original, 32)
upscaled_spacing = cutout_geometry.cutout_sampling_spacing(upscaled, 32)
assert upscaled_spacing == original_spacing / 4
def test_cutout_cleanup_value_scales_the_ui_without_changing_node_area():

View File

@@ -81,7 +81,7 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
)
assert not any(call[2].refine_selection for call in window_manager.pie.calls)
assert not any(call[2].generate_normal for call in window_manager.pie.calls)
assert all(call[2].mesh_resolution == 64 for call in window_manager.pie.calls)
assert all(call[2].point_spacing == 32 for call in window_manager.pie.calls)
assert all(call[2].inward_axis == "NEGATIVE_Z" for call in window_manager.pie.calls)
@@ -134,7 +134,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
SelectionPath.from_json(call[2].selection_path_json).invert
for call in window_manager.pie.calls
)
assert all(call[2].mesh_resolution == 256 for call in window_manager.pie.calls)
assert all(call[2].point_spacing == 16 for call in window_manager.pie.calls)
assert all(call[2].inward_axis == "POSITIVE_X" for call in window_manager.pie.calls)
@@ -146,12 +146,6 @@ def test_cutout_normal_space_is_derived_from_shape():
assert cutout_main.normal_mode_for_shape("DEPTH_SURFACE", True) == "OBJECT"
def test_cutout_mesh_detail_tracks_the_source_long_edge_and_clamps():
assert interaction.cutout_mesh_resolution((4096, 2048), 32) == 128
assert interaction.cutout_mesh_resolution((16, 8), 4) == 4
assert interaction.cutout_mesh_resolution((65536, 32768), 4) == 1024
def test_cutout_mesh_detail_maps_to_denser_sampling():
assert [
interaction.cutout_mesh_detail_spacing(detail)
@@ -207,7 +201,7 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
(65536, 32768),
(0, 32768),
)
assert window_manager.pie.calls[0][2].mesh_resolution == 1024
assert window_manager.pie.calls[0][2].point_spacing == 32
assert not hasattr(cutout_main, "pending_selection")

View File

@@ -157,6 +157,51 @@ def test_keep_original_places_and_activates_result_copy():
assert bpy.context.view_layer.objects.active == placed
def test_image_edit_result_uses_complete_shared_source_name():
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True)
source = bpy.data.objects.new("Poster", None)
source.empty_display_type = "IMAGE"
source.data = source_image
shared = bpy.data.objects.new("Poster Shared", None)
shared.empty_display_type = "IMAGE"
shared.data = source_image
bpy.context.collection.objects.link(source)
bpy.context.collection.objects.link(shared)
result_image = image_data.create_image_edit_result(
source_image,
np.zeros(4 * 4 * 4, dtype=np.float32),
(4, 4),
)
image_data.replace_empty_image(source, result_image)
assert shared.data == source_image
assert source.data == result_image
assert result_image.name == "Poster.png.001"
assert "_color" not in result_image.name
def test_image_edit_result_recovers_unshared_source_name():
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True)
source = bpy.data.objects.new("Poster", None)
source.empty_display_type = "IMAGE"
source.data = source_image
bpy.context.collection.objects.link(source)
result_image = image_data.create_image_edit_result(
source_image,
np.zeros(4 * 4 * 4, dtype=np.float32),
(4, 4),
)
image_data.replace_empty_image(source, result_image)
assert source.data == result_image
assert result_image.name == "Poster.png"
assert bpy.data.images.get("Poster.png") == result_image
def test_object_normal_image_follows_cutout_inward_axis():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new(
@@ -292,7 +337,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Subdivide",
"Thickness",
"Depth Scale",
"Base Plane Depth",
"Reference Depth",
"Uniform Scale",
"Depth Image",
]
@@ -306,7 +351,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Smooth Iterations",
"Cleanup",
"Smooth",
"Base Plane Depth",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
@@ -316,7 +361,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
assert [item.name for item in panels[0].interface_items] == [
"Cleanup",
"Smooth",
"Base Plane Depth",
"Reference Depth",
"Depth Direction",
]
assert [item.name for item in panels[1].interface_items] == [
@@ -375,7 +420,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Thickness",
"Depth Scale",
"Double Sided",
"Base Plane Depth",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
@@ -384,7 +429,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Geometry",
"Thickness",
"Depth Scale",
"Base Plane Depth",
"Reference Depth",
"Uniform Scale",
"Depth Image",
]
@@ -405,7 +450,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Thickness": set(cutout_shape.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Double Sided": {"DEPTH_BALLOON"},
"Base Plane Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Direction": {"DEPTH_BALLOON"},
"Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
@@ -740,6 +785,50 @@ def test_cutout_material_uses_ior_1_2():
if node.bl_idname == "ShaderNodeBsdfPrincipled"
)
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
image_layer = _group_node(
material_data.material_node_group(),
result.data.materials[0].node_tree,
)
ior_level_link = next(
link
for link in result.data.materials[0].node_tree.links
if link.to_node == shader
and link.to_socket == shader.inputs["Specular IOR Level"]
)
assert ior_level_link.from_node == image_layer
assert ior_level_link.from_socket == image_layer.outputs["Color"]
def test_plane_material_color_drives_ior_level_but_shadeless_does_not():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Plane", width=2, height=2, alpha=True)
source = SimpleNamespace(data=SimpleNamespace(name="Plane"))
material = material_data.create_image_material(source, image, 0.0)
shader = next(
node
for node in material.node_tree.nodes
if node.bl_idname == "ShaderNodeBsdfPrincipled"
)
image_layer = _group_node(material_data.material_node_group(), material.node_tree)
assert any(
link.from_node == image_layer
and link.from_socket == image_layer.outputs["Color"]
and link.to_node == shader
and link.to_socket == shader.inputs["Specular IOR Level"]
for link in material.node_tree.links
)
shadeless = material_data.create_image_material(
source,
image,
0.0,
shadeless=True,
)
assert not any(
node.bl_idname == "ShaderNodeBsdfPrincipled"
for node in shadeless.node_tree.nodes
)
def test_cutout_material_uses_flat_tangent_normal():
bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -820,7 +909,7 @@ def test_depth_surface_protected_smooth_evaluates():
)
reference_id = object_data.modifier_input_identifier(
modifier.node_group,
"Base Plane Depth",
"Reference Depth",
)
assert modifier[reference_id] == 8.0
object_data.set_modifier_input(
@@ -1013,7 +1102,7 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
depth_modifier[
object_data.modifier_input_identifier(
depth_modifier.node_group,
"Base Plane Depth",
"Reference Depth",
)
],
uniform_scale,
@@ -1078,7 +1167,7 @@ def test_depth_plane_builds_a_fixed_base_terrain_block():
evaluated.to_mesh_clear()
set_input("Depth Scale", 1.0)
set_input("Base Plane Depth", 1.2)
set_input("Reference Depth", 1.2)
outward = _evaluated_positions(result)
assert np.isclose(outward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(outward[:, 2].max(), 0.3, atol=1e-5)
@@ -1092,23 +1181,23 @@ def test_depth_plane_builds_a_fixed_base_terrain_block():
assert len(z_levels) >= 4
assert np.allclose(np.diff(z_levels), np.diff(z_levels)[0], atol=1e-5)
set_input("Base Plane Depth", 0.95)
set_input("Reference Depth", 0.95)
inward = _evaluated_positions(result)
assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(inward[:, 2].max(), 0.05, atol=1e-5)
set_input("Base Plane Depth", 0.0)
set_input("Reference Depth", 0.0)
limited = _evaluated_positions(result)
assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6)
assert limited[:, 2].max() > 0.0
assert limited[:, 2].max() < 0.001
set_input("Base Plane Depth", 1.3)
set_input("Reference Depth", 1.3)
outward_beyond_thickness = _evaluated_positions(result)
assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5)
set_input("Thickness", 0.0)
set_input("Base Plane Depth", 1.2)
set_input("Reference Depth", 1.2)
sheet = _evaluated_positions(result)
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5)
@@ -1401,7 +1490,7 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller():
depth_reference_id = object_data.modifier_input_identifier(
modifier.node_group,
"Base Plane Depth",
"Reference Depth",
)
assert not np.isclose(modifier[depth_reference_id], 8.0)
depth_direction_id = object_data.modifier_input_identifier(

View File

@@ -78,7 +78,6 @@ def test_selection_path_json_contains_only_points_and_invert():
points=((1, 2), (8, 2), (4, 9)),
invert=True,
)
value = selection_path.to_json()
assert set(json.loads(value)) == {"points", "invert"}
@@ -95,6 +94,24 @@ def test_selection_path_json_contains_only_points_and_invert():
)
def test_selection_mask_sidecar_round_trip_and_cleanup(tmp_path):
original = selection.SelectionMask(
np.asarray(((0.0, 0.25), (0.5, 1.0)), dtype=np.float32),
(3, 4, 5, 6),
)
input_path = tmp_path / "input.png"
input_path.write_bytes(b"input")
path = selection.save_selection_mask(input_path, original)
loaded = selection.load_selection_mask(path, original.bounds)
assert path.parent == input_path.parent
np.testing.assert_array_equal(loaded.values, original.values)
assert loaded.bounds == original.bounds
selection.cleanup_selection_mask(path)
assert not path.exists()
def test_nonzero_winding_keeps_same_direction_overlap():
points = (
(1, 1),

View File

@@ -700,6 +700,46 @@ class ServerRuntimeTest(unittest.TestCase):
self.assertTrue(all(path.is_file() for path in result.values()))
self.assertTrue(infer.call_args.kwargs["include_points"])
def test_refine_selection_preserves_the_full_alpha_canvas(self):
import numpy as np
from PIL import Image
refine = importlib.import_module("server.selection.refine_selection")
input_path = self.storage_root / "input.png"
output_path = self.storage_root / "output.png"
source = np.zeros((3, 4, 4), dtype=np.uint8)
source[..., :3] = (32, 64, 96)
source[..., 3] = np.asarray(
((0, 64, 128, 255), (255, 128, 64, 0), (0, 0, 255, 255)),
dtype=np.uint8,
)
Image.fromarray(source, mode="RGBA").save(input_path)
foreground = Image.fromarray(source, mode="RGBA")
context = SimpleNamespace(
resource=lambda _name: SimpleNamespace(directory=lambda _model: "model"),
check_cancelled=lambda: None,
)
with patch.object(
refine,
"infer_foreground",
return_value=(foreground, 0.0, 0.0),
) as infer:
result = refine.refine_selection(
context,
input_path,
"BEN2_BASE",
"cpu",
output_path,
)
self.assertEqual(result, output_path)
infer.assert_called_once()
self.assertTrue(infer.call_args.kwargs["preserve_input_alpha"])
with Image.open(output_path) as output:
self.assertEqual(output.size, (4, 3))
np.testing.assert_array_equal(np.asarray(output.convert("RGBA")), source)
def test_generate_cutout_artifacts_only_returns_requested_outputs(self):
job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png"
@@ -750,7 +790,7 @@ class ServerRuntimeTest(unittest.TestCase):
normal_mode="OBJECT",
)
def test_generate_cutout_artifacts_refines_the_bounds_image(self):
def test_generate_cutout_artifacts_refines_the_full_bounds_image(self):
job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png"
input_path.write_bytes(b"selection")
@@ -763,7 +803,7 @@ class ServerRuntimeTest(unittest.TestCase):
def generate(_context, _input, _model, _device, output):
output.write_bytes(b"refined")
return output, (2, 3, 8, 9)
return output
with patch.object(job, "refine_selection", side_effect=generate):
result = job.run(
@@ -778,12 +818,62 @@ class ServerRuntimeTest(unittest.TestCase):
self.assertEqual(
result,
{"selection_image": "selection.png"},
)
self.assertFalse((directory / "selection_bounds.json").exists())
def test_refined_cutout_artifacts_share_the_full_refine_image(self):
job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png"
input_path.write_bytes(b"selection")
directory = self.storage_root / "job"
context = SimpleNamespace(
directory=directory,
progress=lambda *_args: None,
check_cancelled=lambda: None,
)
refined_path = directory / "selection.png"
def refine(_context, _input, _model, _device, output):
output.write_bytes(b"refined")
return output
with (
patch.object(job, "refine_selection", side_effect=refine),
patch.object(
job,
"generate_moge2_artifacts",
return_value={
"z_depth": directory / "z-depth.exr",
"depth_metadata": directory / "depth.json",
"tangent_normal": directory / "tangent-normal.png",
},
) as generate,
):
result = job.run(
context,
{
"selection_image": "selection.png",
"selection_bounds": "selection_bounds.json",
"input": str(input_path),
"refine_selection": True,
"refine_model": "BEN2_BASE",
"device": "cpu",
"depth_mode": "Z",
"normal_mode": "TANGENT",
},
)
self.assertEqual(
(directory / "selection_bounds.json").read_text(encoding="utf-8"),
"[2,3,8,9]",
self.assertEqual(result["selection_image"], "selection.png")
generate.assert_called_once_with(
context,
{
"input": str(input_path),
"refine_selection": True,
"refine_model": "BEN2_BASE",
"device": "cpu",
"depth_mode": "Z",
"normal_mode": "TANGENT",
},
refined_path,
depth_mode="Z",
normal_mode="TANGENT",
)

View File

@@ -712,7 +712,7 @@ def build_node_group(shape):
options = group.interface.new_panel(name="Options", default_closed=True)
reference_depth = _float_input(
group,
"Base Plane Depth",
"Reference Depth",
1.0,
0.0,
None,
@@ -736,7 +736,7 @@ def build_node_group(shape):
options = group.interface.new_panel(name="Options", default_closed=True)
reference_depth = _float_input(
group,
"Base Plane Depth",
"Reference Depth",
1.0,
0.0,
None,
@@ -771,7 +771,7 @@ def build_node_group(shape):
"Depth Image",
"Depth Scale",
"Uniform Scale",
"Base Plane Depth",
"Reference Depth",
"Depth Direction",
},
)
@@ -796,7 +796,7 @@ def build_node_group(shape):
controls.outputs["Uniform Scale"],
controls.outputs["Depth Direction"],
controls.outputs["Depth Image"],
controls.outputs["Base Plane Depth"],
controls.outputs["Reference Depth"],
)
output_location = (5100.0, 600.0)
else:
@@ -806,7 +806,7 @@ def build_node_group(shape):
controls.outputs["Thickness"],
controls.outputs["Depth Scale"],
controls.outputs["Uniform Scale"],
controls.outputs["Base Plane Depth"],
controls.outputs["Reference Depth"],
controls.outputs["Depth Image"],
)
output_location = (8200.0, 400.0)
@@ -877,7 +877,7 @@ def build_cutout_modifier_group(shape_groups):
reference_depth = _float_input(
group,
"Base Plane Depth",
"Reference Depth",
1.0,
0.0,
None,

View File

@@ -59,7 +59,7 @@ def build_depth_plane_group(mesh_plane_group):
options = group.interface.new_panel(name="Options", default_closed=True)
base_plane_depth = _interface_socket(
group,
"Base Plane Depth",
"Reference Depth",
"INPUT",
"NodeSocketFloat",
parent=options,
@@ -98,7 +98,7 @@ def build_depth_plane_group(mesh_plane_group):
shape_input = _group_input(
nodes,
(-1550.0, 950.0),
{"Thickness", "Depth Scale", "Base Plane Depth"},
{"Thickness", "Depth Scale", "Reference Depth"},
)
image_input = _group_input(
nodes,
@@ -142,7 +142,7 @@ def build_depth_plane_group(mesh_plane_group):
links.new(depth_components.outputs["X"], metric_depth.inputs[0])
links.new(image_input.outputs["Uniform Scale"], metric_depth.inputs[1])
relative_depth = _math(nodes, "SUBTRACT", (50.0, -350.0))
links.new(shape_input.outputs["Base Plane Depth"], relative_depth.inputs[0])
links.new(shape_input.outputs["Reference Depth"], relative_depth.inputs[0])
links.new(metric_depth.outputs[0], relative_depth.inputs[1])
full_offset = _math(nodes, "MULTIPLY", (300.0, -350.0))
links.new(relative_depth.outputs[0], full_offset.inputs[0])

View File

@@ -70,7 +70,7 @@ def main():
"Subdivide",
"Thickness",
"Depth Scale",
"Base Plane Depth",
"Reference Depth",
"Uniform Scale",
"Depth Image",
]
@@ -79,7 +79,7 @@ def main():
]
assert [panel.name for panel in depth_panels] == ["Options", "Data"]
assert [item.name for item in depth_panels[0].interface_items] == [
"Base Plane Depth"
"Reference Depth"
]
assert [item.name for item in depth_panels[1].interface_items] == [
"Uniform Scale",

View File

@@ -140,7 +140,7 @@ def main():
"Smooth Iterations",
"Cleanup",
"Smooth",
"Base Plane Depth",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
@@ -153,7 +153,7 @@ def main():
"Thickness",
"Depth Scale",
"Double Sided",
"Base Plane Depth",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
@@ -162,7 +162,7 @@ def main():
"Geometry",
"Thickness",
"Depth Scale",
"Base Plane Depth",
"Reference Depth",
"Uniform Scale",
"Depth Image",
],
@@ -184,7 +184,7 @@ def main():
"Thickness": set(shapes.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Double Sided": {"DEPTH_BALLOON"},
"Base Plane Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Direction": {"DEPTH_BALLOON"},
"Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
@@ -347,7 +347,7 @@ def main():
("Depth Image", point_image),
("Depth Scale", 1.0),
("Uniform Scale", 1.0),
("Base Plane Depth", 2.0),
("Reference Depth", 2.0),
("Depth Direction", (0.0, 0.0, 1.0)),
):
set_modifier_input(
@@ -398,7 +398,7 @@ def main():
("Depth Image", point_image),
("Depth Scale", 1.0),
("Uniform Scale", 1.0),
("Base Plane Depth", 2.0),
("Reference Depth", 2.0),
("Smooth Iterations", 0),
):
set_modifier_input(