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"] 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.image_base_name()` | 去除图片或视频扩展名和 Blender 数字后缀,得到数据块基础名。 |
| `image.depth_data_name()` | 生成 Depth 结果数据块文件名。 | | `image.depth_data_name()` | 生成 Depth 结果数据块文件名。 |
| `image.color_data_name()` | 生成 Color 结果数据块文件名。 | | `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_normal_result_image()` | 加载、命名、设为 Non-Color 并 Pack Normal Map。 |
| `image.load_packed_rgba_image()` | 加载已有 RGBA 文件,继承源图颜色空间和 Alpha mode,命名并 Pack。 | | `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.replace_empty_image()` | 执行 Image Empty 结果替换、共享引用分流、旧数据清理、帧设置继承和可选画布对齐。 |
| `image.place_empty_image_result()` | 按 Keep Original 决定替换源 Empty 或复制结果,并选择、激活结果 Empty。 | | `image.place_empty_image_result()` | 按 Keep Original 决定替换源 Empty 或复制结果,并选择、激活结果 Empty。 |
| `image.image_empty_bounds()` | 计算 Image 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 与视图交互 ## Image Selection 与视图交互
这组公共能力使用固定术语: 这组公共能力使用固定术语:
@@ -67,6 +70,9 @@
| --- | --- | | --- | --- |
| `selection.selection_path_bounds()` | 校验 Selection Path 并返回整数像素包围框。 | | `selection.selection_path_bounds()` | 校验 Selection Path 并返回整数像素包围框。 |
| `selection.rasterize_selection_path()` | 以局部 non-zero scanline 把 `SelectionPath` 栅格化为纯几何 `SelectionMask`。 | | `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 像素。 | | `selection.crop_pixels_to_alpha()` | 按 Alpha 非零区域紧裁切 RGBA 像素。 |
## Depth 产物 ## Depth 产物
@@ -87,7 +93,7 @@
| 函数 | 职责 | | 函数 | 职责 |
| --- | --- | | --- | --- |
| `node.load_node_group()` | 从随扩展分发的 Blend 资产加载指定 Node Group。 | | `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.modifier_input_identifier()` | 按接口名称解析 Geometry Nodes Modifier 输入 Identifier。 |
| `object.set_modifier_input()` | 跨受支持 Blender RNA 形式设置 Modifier 输入。 | | `object.set_modifier_input()` | 跨受支持 Blender RNA 形式设置 Modifier 输入。 |
| `object.finalize_object_result()` | 激活结果对象,并按需替换或保留源 Empty。 | | `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 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`; 1. 修改 `tools/node_assets/build_*.py`;

View File

@@ -39,13 +39,13 @@ EXR 的 RGB 重复保存完整相机 Z,Alpha 保存模型 Validity;JSON 保
### 创建 Depth Plane ### 创建 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` 只沿正法线移动可变面与中间环,再按各层位置应用深度位移: `O Image Depth Plane` 把 `O Mesh Plane` 的固定底面放在原图片平面,`Thickness` 只沿正法线移动可变面与中间环,再按各层位置应用深度位移:
```mermaid ```mermaid
flowchart TD 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["负向位移限制在固定底面之前"] Offset --> Clamp["负向位移限制在固定底面之前"]
Block["O Mesh Plane 厚度体块"] --> Weight["固定底面 0 → 中间环 → 可变面 1"] Block["O Mesh Plane 厚度体块"] --> Weight["固定底面 0 → 中间环 → 可变面 1"]
Clamp --> Apply["位移 × 层权重"] Clamp --> Apply["位移 × 层权重"]

View File

@@ -18,10 +18,11 @@ flowchart TD
Path --> Mask["栅格化 SelectionMask"] Path --> Mask["栅格化 SelectionMask"]
Mask --> Refine{"Box / Lasso Refine Selection?"} Mask --> Refine{"Box / Lasso Refine Selection?"}
Refine -- "否" --> Local["源 Alpha × 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"] Kind -- "Perspective" --> Quad["四点 Quad 与 Homography"]
Local --> Result["创建结果 Image"] Local --> Result["创建结果 Image"]
Job --> Result Refined --> Result
Quad --> Result Quad --> Result
Result --> Keep{"当前子工具 Keep Original?"} Result --> Keep{"当前子工具 Keep Original?"}
Keep -- "否" --> Replace["替换源 Empty"] Keep -- "否" --> Replace["替换源 Empty"]
@@ -32,10 +33,12 @@ flowchart TD
`box.py`、`lasso.py` 和 `polyline.py` 定义 WorkspaceTool,交互与落地集中在 `selection.py`。公共 `CropSelectionGesture` 从 `common/viewport.py` 读取当前子工具的独立设置,并把内存中的 `SelectionPath` 交给 `CropImageSelection`。 `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 ## Crop Perspective
`perspective.py` 接受任意顺序的四个角点并规范为凸 Quad,创建 512 × 512 预览,随后按目标宽高比执行分块 Homography。输出最长边不超过 8192 px,总像素不超过 16,777,216;插值使用预乘 Alpha,结果按可见 Alpha 紧裁切。 `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 下选择并激活结果对象。 四种 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"] Bounds["写出几何 bounds 矩形 RGBA"]
Job["执行 Refine Selection,生成 Depth / Normal 产物"] Job["执行 Refine Selection,生成 Depth / Normal 产物"]
Encoded["加载并 Pack 输入或 Refine RGBA"] Encoded["加载并 Pack 输入或 Refine RGBA"]
Ready["汇总内容 Mask 与 Shape 输入"] Ready["贴图 Alpha × Selection Mask"]
Build["构建 BaseShape"] Build["构建 BaseShape"]
Create["创建 Blender 对象"] Create["创建 Blender 对象"]
@@ -33,13 +33,13 @@ Surface 与 Balloon 在本地组合源 Alpha 与几何 Selection 后构建 `Base
Cutout 工具由 `__init__.py` 注册,交互从 `main.py` 中的 `SelectCutoutSelection` 开始。它接收 Lasso 或双击整图手势,只构建内存中的 `SelectionPath`,然后调用 `interaction.py` 打开 Shape 饼菜单。此时不读取源图像素、不栅格化 Mask,也不扫描可见 Alpha。 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 ### 栅格化 Selection
用户选定 Shape 后,`main.py` 中的 `CutoutSelectionToShape` 接过主线。它从自身 Property 恢复 `SelectionPath`,再使用 `common/selection.py` 以 non-zero winding 栅格化为一次纯几何 `SelectionMask`:非反选只处理 Path Bounds 与 2 px 抗锯齿边缘,反选覆盖完整图片范围。`values` 保存局部几何权重,`bounds` 保存它在源图中的位置;栅格阶段不读取、接收或合并源 Alpha。 用户选定 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 需要的产物: `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`; - 需要 Vector Depth 时写出相机空间 XYZ 组成的 `vector-depth.exr`,需要 Z Depth 时写出 `z-depth.exr`,两者都配套 `depth.json`;
- **Normal Map** 根据既定空间写出 `tangent-normal.png` 或 `object-normal.png`。 - **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**,最终颜色图使用源图裁切分辨率。 **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 ### 构建 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 值提取外轮廓和孔洞; 1. 从内容 Alpha 值提取外轮廓和孔洞;
2. 按 Mesh Detail 对应的采样间距生成轮廓与内部采样点; 2. 按 Mesh Detail 对应的采样间距生成轮廓与内部采样点;
@@ -79,7 +79,7 @@ Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth
### 创建 Blender 对象 ### 创建 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 如何形成结果 | 必需后台产物 | | Shape | Modifier 如何形成结果 | 必需后台产物 |
| --- | --- | --- | | --- | --- | --- |
@@ -88,7 +88,7 @@ Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth
| Depth Balloon | 在 Balloon 上叠加相对基准面的深度 | `z-depth.exr`、`depth.json` | | Depth Balloon | 在 Balloon 上叠加相对基准面的深度 | `z-depth.exr`、`depth.json` |
| Depth Surface | 使用相机点 XYZ 重建透视表面 | `vector-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 流程结束。 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 的颜色数据 | | `remove-background` | `image.0001.png` 起 | 替换 Image Empty 的颜色数据 |
| `upscale-image` | `upscale.0001.png` 起 | 替换或新建高清颜色数据 | | `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-depth-plane-geometry` | `image.0001.*`、`z-depth.exr`、`depth.json` | 创建 Depth Plane |
| `generate-cutout-artifacts` | 可选 Selection、Depth 与 Normal 文件 | 创建 Cutout Shape 与材质 | | `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 对象协议: 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`; - 需要 Vector Depth 时请求 `vector-depth.exr` 与 `depth.json`;
- 需要 Z Depth 时请求 `z-depth.exr` 与 `depth.json`; - 需要 Z Depth 时请求 `z-depth.exr` 与 `depth.json`;
- Normal Map 按空间请求 `tangent-normal.png` 或 `object-normal.png`。 - 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 产物 ## Debug 产物

View File

@@ -48,13 +48,13 @@ Job 在阶段边界调用 `context.check_cancelled()`,并把稳定产物写入
| `generate-cutout-artifacts` | 按需执行 Refine Selection,并生成 Depth 或 Normal 文件产物 | | `generate-cutout-artifacts` | 按需执行 Refine Selection,并生成 Depth 或 Normal 文件产物 |
| `debug-*` | 保存模型适配层的直接输出 | | `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 的模型分析图长边;材质颜色图使用原始分辨率。 `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 继续通过媒体入口处理单图、视频和序列帧。 `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 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.""" """Create a packed Image datablock from edited RGBA pixels."""
output_width, output_height = (int(value) for value in output_size) output_width, output_height = (int(value) for value in output_size)
if output_width <= 0 or output_height <= 0: 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: if len(pixels) != output_width * output_height * 4:
raise ValueError("The edited pixels do not match the image dimensions") raise ValueError("The edited pixels do not match the image dimensions")
image = bpy.data.images.new( image = bpy.data.images.new(
f"{image_base_name(source_image)}{suffix}", source_image.name,
width=output_width, width=output_width,
height=output_height, height=output_height,
alpha=True, alpha=True,
@@ -525,11 +525,12 @@ def place_empty_image_result(
def load_color_result_image( def _load_packed_result_image(
source_object, source_object,
output_directory, output_directory,
*, *,
frame_prefix="image.", frame_prefix,
result_name,
): ):
output_directory = Path(output_directory) output_directory = Path(output_directory)
color_paths = sorted_frame_paths(output_directory, frame_prefix) 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 raise RuntimeError("Unable to load the color result") from error
try: try:
result_name = color_data_name(source_object)
if animated: if animated:
pack_sequence_if_possible(result_image, len(color_paths)) pack_sequence_if_possible(result_image, len(color_paths))
else: else:
@@ -567,6 +567,36 @@ def load_color_result_image(
return 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): def image_empty_bounds(source_object):
image_width, image_height = source_object.data.size image_width, image_height = source_object.data.size
if image_width <= 0 or image_height <= 0: if image_width <= 0 or image_height <= 0:

View File

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

View File

@@ -3,6 +3,7 @@
import json import json
import math import math
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path
class ImageEditWarning(ValueError): class ImageEditWarning(ValueError):
@@ -86,6 +87,29 @@ class SelectionMask:
return result 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): def selection_path_bounds(points):
import numpy as np import numpy as np

View File

@@ -89,7 +89,7 @@ def create_depth_plane_object(
("Subdivide", max(0, min(10, int(subdivisions)))), ("Subdivide", max(0, min(10, int(subdivisions)))),
("Thickness", 0.1), ("Thickness", 0.1),
("Depth Scale", 1.0), ("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), ("Uniform Scale", uniform_scale),
("Depth Image", depth_image), ("Depth Image", depth_image),
): ):

View File

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

View File

@@ -11,8 +11,7 @@ from ...common.ai import (
) )
from ...common.image import ( from ...common.image import (
cleanup_image_input, cleanup_image_input,
create_pixel_result_image, create_image_edit_result,
image_base_name,
image_rgba, image_rgba,
load_packed_rgba_image, load_packed_rgba_image,
place_empty_image_result, place_empty_image_result,
@@ -28,7 +27,10 @@ from ...common.viewport import (
) )
from ...common.selection import ( from ...common.selection import (
ImageEditWarning, ImageEditWarning,
cleanup_selection_mask,
load_selection_mask,
rasterize_selection_path, rasterize_selection_path,
save_selection_mask,
) )
_HIDDEN = {"HIDDEN", "SKIP_SAVE"} _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( def _edit_image_object(
context, context,
source_object, source_object,
@@ -78,7 +95,7 @@ def _edit_image_object(
selection_mask, selection_mask,
) )
left, top, right, bottom = crop_bounds left, top, right, bottom = crop_bounds
result_image = create_pixel_result_image( result_image = create_image_edit_result(
image, image,
pixels, pixels,
(right - left, bottom - top), (right - left, bottom - top),
@@ -145,13 +162,23 @@ class CropImageSelection(CropSelectionGesture, bpy.types.Operator):
source_rgba, source_rgba,
selection_mask.bounds, 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( result = bpy.ops.anyimage.refine_crop_selection(
input_path=str(source_path), input_path=str(source_path),
selection_mask_path=str(selection_mask_path),
source_object_name=self.source_object_name, source_object_name=self.source_object_name,
selection_bounds_json=json.dumps(selection_mask.bounds), selection_bounds_json=json.dumps(selection_mask.bounds),
keep_original=keep_original, keep_original=keep_original,
) )
if "CANCELLED" in result: if "CANCELLED" in result:
cleanup_selection_mask(selection_mask_path)
cleanup_image_input(source_path, True) cleanup_image_input(source_path, True)
return {"CANCELLED"} return {"CANCELLED"}
return {"FINISHED"} return {"FINISHED"}
@@ -175,6 +202,7 @@ class RefineCropSelection(JobOperatorBase, bpy.types.Operator):
starting_message = "Refining image selection" starting_message = "Refining image selection"
input_path: bpy.props.StringProperty(options=_HIDDEN) 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"}) source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
selection_bounds_json: 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) keep_original: bpy.props.BoolProperty(default=False, options=_HIDDEN)
@@ -191,23 +219,23 @@ class RefineCropSelection(JobOperatorBase, bpy.types.Operator):
def response(self, context, result): def response(self, context, result):
source_object = require_image_empty(self.source_object_name) source_object = require_image_empty(self.source_object_name)
source_bounds = tuple(json.loads(self.selection_bounds_json)) bounds = tuple(json.loads(self.selection_bounds_json))
local_bounds = tuple( selection_mask = load_selection_mask(self.selection_mask_path, bounds)
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],
)
source_image = source_object.data source_image = source_object.data
refined_image = load_packed_rgba_image( encoded_image = load_packed_rgba_image(
result.file("selection_image"), result.file("selection_image"),
source_image, source_image,
image_base_name(source_image), "AnyImage Refine Result",
) )
refined_image = None
try: 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( place_empty_image_result(
context, context,
source_object, source_object,
@@ -216,14 +244,18 @@ class RefineCropSelection(JobOperatorBase, bpy.types.Operator):
keep_original=bool(getattr(self, "keep_original", False)), keep_original=bool(getattr(self, "keep_original", False)),
) )
except Exception: 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) bpy.data.images.remove(refined_image, do_unlink=True)
raise 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"): if "FINISHED" not in bpy.ops.ed.undo_push(message="Crop Image Selection"):
raise RuntimeError("Unable to create the Crop Image Selection Undo step") raise RuntimeError("Unable to create the Crop Image Selection Undo step")
return "Crop Selection refined" return "Crop Selection refined"
def cleanup(self): def cleanup(self):
cleanup_selection_mask(getattr(self, "selection_mask_path", ""))
cleanup_image_input(self.input_path, True) cleanup_image_input(self.input_path, True)

View File

@@ -4,7 +4,6 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from ...preferences import addon_preferences from ...preferences import addon_preferences
from ...server.media.resolution import limited_image_size
from ...common.image import image_empty_bounds 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( def extract_cutout_contours(
content_values, content_values,
mesh_resolution, point_spacing,
threshold=CUTOUT_ALPHA_THRESHOLD, 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( return extract_selection_contours(
content_values, content_values,
max_resolution=contour_resolution, 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) crop_width, crop_height = (int(value) for value in crop_size)
limited_width, limited_height = limited_image_size( spacing = float(point_spacing)
(crop_width, crop_height), if crop_width <= 0 or crop_height <= 0:
max_input_size, raise ValueError("The Cutout bounds must be positive")
) if spacing <= 0.0:
scale = max(limited_width, limited_height) / max(crop_width, crop_height) raise ValueError("Cutout point spacing must be greater than zero")
return max(4, min(1024, round(int(mesh_resolution) * scale))) 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 import math
x_min, x_max, y_min, y_max = image_empty_bounds(source_object) x_min, x_max, y_min, y_max = image_empty_bounds(source_object)
metric, _world_z_scale = world_plane_metric(source_object.matrix_world) metric, _world_z_scale = world_plane_metric(source_object.matrix_world)
width = (x_max - x_min) * math.sqrt(max(metric[0][0], 0.0)) 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)) 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): 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( def build_base_shape(
context, context,
source_object, source_object,
mesh_resolution, point_spacing,
content_values, content_values,
content_bounds, content_bounds,
): ):
@@ -1410,7 +1415,7 @@ def build_base_shape(
) )
selection_contours = extract_cutout_contours( selection_contours = extract_cutout_contours(
content_values, content_values,
mesh_resolution, point_spacing,
threshold=CUTOUT_ALPHA_THRESHOLD, threshold=CUTOUT_ALPHA_THRESHOLD,
) )
world_metric, world_z_scale = world_plane_metric(source_object.matrix_world) 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") raise ValueError("The Image Empty has a degenerate world transform")
preferences = addon_preferences(context) preferences = addon_preferences(context)
balloon_algorithm = getattr(preferences, "balloon_profile_algorithm", "POISSON") 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( domain = cutout_domain(
selection_contours, selection_contours,
bounds, bounds,

View File

@@ -1,9 +1,5 @@
"""Cutout Selection validation and Shape-menu interaction helpers.""" """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 ...properties import ai_ready
from .shape import CUTOUT_SHAPE_LABELS 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): def cutout_mesh_detail_spacing(mesh_detail):
return CUTOUT_MESH_DETAIL_SPACING[str(mesh_detail)] return CUTOUT_MESH_DETAIL_SPACING[str(mesh_detail)]
@@ -44,7 +31,7 @@ def _pie_button(
selection_path_json, selection_path_json,
refine_selection, refine_selection,
generate_normal, generate_normal,
mesh_resolution, point_spacing,
inward_axis, inward_axis,
): ):
operator = pie.operator( operator = pie.operator(
@@ -57,7 +44,7 @@ def _pie_button(
operator.shape = shape operator.shape = shape
operator.refine_selection = refine_selection operator.refine_selection = refine_selection
operator.generate_normal = generate_normal operator.generate_normal = generate_normal
operator.mesh_resolution = mesh_resolution operator.point_spacing = point_spacing
operator.inward_axis = inward_axis operator.inward_axis = inward_axis
@@ -77,7 +64,6 @@ def open_shape_pie(
generate_normal = ready and bool( generate_normal = ready and bool(
getattr(settings, "cutout_generate_normal", False) 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")) inward_axis = str(getattr(settings, "cutout_inward_axis", "NEGATIVE_Z"))
def draw(menu, _context): def draw(menu, _context):
@@ -93,7 +79,7 @@ def open_shape_pie(
selection_path_json, selection_path_json,
refine_selection, refine_selection,
generate_normal, generate_normal,
mesh_resolution, point_spacing,
inward_axis, inward_axis,
) )

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,27 +1,24 @@
import json
import time import time
from ..selection import refine_selection from ..selection import refine_selection
def run(context, parameters): 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() started = time.perf_counter()
context.progress(0.05, "Preparing image Selection") context.progress(0.05, "Preparing image Selection")
context.check_cancelled() context.check_cancelled()
output_directory = context.directory output_directory = context.directory
output_directory.mkdir(parents=True, exist_ok=True) output_directory.mkdir(parents=True, exist_ok=True)
context.progress(0.2, f"Loading BEN2 for {parameters['device'].upper()}") context.progress(0.2, f"Loading BEN2 for {parameters['device'].upper()}")
output_path, bounds = refine_selection( output_path = refine_selection(
context, context,
parameters["input"], parameters["input"],
parameters["model"], parameters["model"],
parameters["device"], parameters["device"],
output_directory / "image.png", 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 elapsed_ms = (time.perf_counter() - started) * 1000.0
message = f"Image Selection refined in {elapsed_ms:.0f} ms" message = f"Image Selection refined in {elapsed_ms:.0f} ms"
context.progress(1.0, message) 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 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( def refine_selection(
context, context,
input_path, input_path,
@@ -37,6 +19,5 @@ def refine_selection(
preserve_input_alpha=True, preserve_input_alpha=True,
) )
context.check_cancelled() context.check_cancelled()
output, bounds = _crop_to_alpha(foreground) foreground.convert("RGBA").save(output_path, format="PNG", compress_level=1)
output.save(output_path, format="PNG", compress_level=1) return output_path
return output_path, bounds

View File

@@ -1242,7 +1242,7 @@ class BlenderAddonTest(unittest.TestCase):
} }
), ),
shape=shape, shape=shape,
mesh_resolution=64, point_spacing=32,
generate_normal=False, generate_normal=False,
refine_selection=False, refine_selection=False,
report=lambda *_args: None, report=lambda *_args: None,
@@ -1300,7 +1300,7 @@ class BlenderAddonTest(unittest.TestCase):
context, context,
source, source,
shape, shape,
64, 32.0,
selection_mask.values, selection_mask.values,
selection_mask.bounds, selection_mask.bounds,
inward_axis="NEGATIVE_Z", inward_axis="NEGATIVE_Z",
@@ -1331,7 +1331,7 @@ class BlenderAddonTest(unittest.TestCase):
} }
) )
operator.shape = "DEPTH_SURFACE" operator.shape = "DEPTH_SURFACE"
operator.mesh_resolution = 64 operator.point_spacing = 32
operator.generate_normal = False operator.generate_normal = False
operator.refine_selection = False operator.refine_selection = False
operator.report = lambda *_args: None operator.report = lambda *_args: None
@@ -1413,7 +1413,7 @@ class BlenderAddonTest(unittest.TestCase):
} }
), ),
shape="SURFACE", shape="SURFACE",
mesh_resolution=64, point_spacing=32,
generate_normal=False, generate_normal=False,
refine_selection=False, refine_selection=False,
report=lambda level, message: reports.append((level, message)), report=lambda level, message: reports.append((level, message)),
@@ -1721,6 +1721,78 @@ class BlenderAddonTest(unittest.TestCase):
self.assertIs(result, expected) self.assertIs(result, expected)
self.assertNotIn("REGISTER", operator.bl_options) 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): def test_packed_image_export_does_not_use_scene_render_transform(self):
with tempfile.TemporaryDirectory() as directory: with tempfile.TemporaryDirectory() as directory:
output_directory = Path(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[:, :, 3] == 1.0))
self.assertTrue(np.all(rgba[:, :, 0] == np.arange(2, 7)[None, :])) 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 import numpy as np
source = SimpleNamespace(data=SimpleNamespace(size=(8, 6))) source = SimpleNamespace(data=SimpleNamespace(size=(8, 6)))
@@ -2725,6 +2797,11 @@ class BlenderAddonTest(unittest.TestCase):
"prepare_image_bounds_input", "prepare_image_bounds_input",
return_value=Path(__file__), return_value=Path(__file__),
) as prepare_input, ) 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), patch.object(self.edit_mask.bpy, "ops", ops, create=True),
): ):
result = self.edit_mask.CropImageSelection._submit_selection_path( result = self.edit_mask.CropImageSelection._submit_selection_path(
@@ -2741,29 +2818,55 @@ class BlenderAddonTest(unittest.TestCase):
source_rgba, source_rgba,
selection_mask.bounds, 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( source_image = SimpleNamespace(
name="Poster.png", name="Poster.png",
colorspace_settings=SimpleNamespace(name="sRGB"), colorspace_settings=SimpleNamespace(name="sRGB"),
alpha_mode="STRAIGHT", alpha_mode="STRAIGHT",
) )
source = SimpleNamespace(data=source_image) source = SimpleNamespace(data=source_image)
refined = SimpleNamespace(users=0) encoded = SimpleNamespace(users=0)
files = { refined = SimpleNamespace(users=1)
"selection_bounds": SimpleNamespace( files = {"selection_image": Path(__file__)}
read_text=lambda encoding: "[2,1,5,4]"
),
"selection_image": Path(__file__),
}
result = SimpleNamespace(file=lambda key: files[key]) result = SimpleNamespace(file=lambda key: files[key])
operator = self.edit_mask.RefineCropSelection() operator = self.edit_mask.RefineCropSelection()
operator.source_object_name = "Source" operator.source_object_name = "Source"
operator.selection_bounds_json = "[10,20,18,26]" 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( ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"})) ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
) )
remove = Mock()
with ( with (
patch.object( patch.object(
@@ -2774,14 +2877,30 @@ class BlenderAddonTest(unittest.TestCase):
patch.object( patch.object(
self.edit_mask, self.edit_mask,
"load_packed_rgba_image", "load_packed_rgba_image",
return_value=refined, return_value=encoded,
) as load_result, ) 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( patch.object(
self.edit_mask, 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, ) 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), patch.object(self.edit_mask.bpy, "ops", ops, create=True),
): ):
message = operator.response(None, result) message = operator.response(None, result)
@@ -2790,17 +2909,38 @@ class BlenderAddonTest(unittest.TestCase):
load_result.assert_called_once_with( load_result.assert_called_once_with(
Path(__file__), Path(__file__),
source_image, 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( place.assert_called_once_with(
None, None,
source, source,
refined, refined,
placement_bounds=(12, 21, 15, 24), placement_bounds=(10, 20, 18, 26),
keep_original=False, 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): def test_cutout_ai_response_reuses_color_image_and_keeps_lasso_for_no_refine(self):
import numpy as np import numpy as np
@@ -2818,7 +2958,7 @@ class BlenderAddonTest(unittest.TestCase):
operator.selection_bounds_json = "[10,20,14,23]" operator.selection_bounds_json = "[10,20,14,23]"
operator.input_path = __file__ operator.input_path = __file__
operator.shape = "SURFACE" operator.shape = "SURFACE"
operator.mesh_resolution = 32 operator.point_spacing = 32
operator.generate_normal = True operator.generate_normal = True
operator.refine_selection = False operator.refine_selection = False
operator._selection_mask = selection_mask 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.assertEqual(create_shape.call_args.args[5], selection_mask.bounds)
self.assertIs(create_shape.call_args.kwargs["color_image"], color_image) 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 import numpy as np
source = SimpleNamespace(data=SimpleNamespace()) source = SimpleNamespace(data=SimpleNamespace())
@@ -2859,11 +2999,13 @@ class BlenderAddonTest(unittest.TestCase):
operator.selection_bounds_json = "[10,20,16,24]" operator.selection_bounds_json = "[10,20,16,24]"
operator.input_path = __file__ operator.input_path = __file__
operator.shape = "SURFACE" operator.shape = "SURFACE"
operator.mesh_resolution = 32 operator.point_spacing = 32
operator.generate_normal = False operator.generate_normal = False
operator.refine_selection = True 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( operator._selection_mask = self.image_selection.SelectionMask(
np.zeros((4, 6), dtype=np.float32), selection_values,
(10, 20, 16, 24), (10, 20, 16, 24),
) )
result = SimpleNamespace( result = SimpleNamespace(
@@ -2882,8 +3024,8 @@ class BlenderAddonTest(unittest.TestCase):
"_generated_color_result", "_generated_color_result",
return_value=( return_value=(
color_image, color_image,
np.ones((2, 3), dtype=np.float32), np.ones((4, 6), dtype=np.float32),
(12, 21, 15, 23), (10, 20, 16, 24),
), ),
), ),
patch.object(self.cutout_main, "create_cutout_shape") as create_shape, 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] content_values = create_shape.call_args.args[4]
np.testing.assert_array_equal( np.testing.assert_array_equal(
content_values, 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) self.assertIs(create_shape.call_args.kwargs["color_image"], color_image)
def test_image_selection_uses_white_dashed_segments(self): 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 from anyimage.server.models.geometry import GeometryFrame
def test_cutout_mesh_detail_uses_the_cropped_analysis_scale(): def test_cutout_mesh_limit_caps_actual_sampling_resolution():
assert cutout_geometry.limit_cutout_mesh_resolution(256, (4096, 1024), 2048) == 128 assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 1024) == 32
assert cutout_geometry.limit_cutout_mesh_resolution(256, (1024, 512), 2048) == 256 assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 64) == 64
assert cutout_geometry.limit_cutout_mesh_resolution(8, (8192, 4096), 2048) == 4 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): 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))),),), components=(((((0, 0), (1, 0), (0, 1))),),),
image_size=(1, 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: 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(values, 32) == expected
assert cutout_geometry.extract_cutout_contours(selection_mask.values, 256) == 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[0].kwargs["max_resolution"] == 512
assert trace.call_args_list[1].kwargs["max_resolution"] == 1024 assert trace.call_args_list[1].kwargs["max_resolution"] == 1024
def test_cutout_sampling_spacing_uses_the_complete_source_plane(): 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 ( with (
patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)), patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object( 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), 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(): 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].refine_selection for call in window_manager.pie.calls)
assert not any(call[2].generate_normal 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) 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 SelectionPath.from_json(call[2].selection_path_json).invert
for call in window_manager.pie.calls 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) 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" 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(): def test_cutout_mesh_detail_maps_to_denser_sampling():
assert [ assert [
interaction.cutout_mesh_detail_spacing(detail) 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), (65536, 32768),
(0, 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") 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 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(): def test_object_normal_image_follows_cutout_inward_axis():
bpy.ops.wm.read_factory_settings(use_empty=True) bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new( image = bpy.data.images.new(
@@ -292,7 +337,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Subdivide", "Subdivide",
"Thickness", "Thickness",
"Depth Scale", "Depth Scale",
"Base Plane Depth", "Reference Depth",
"Uniform Scale", "Uniform Scale",
"Depth Image", "Depth Image",
] ]
@@ -306,7 +351,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Smooth Iterations", "Smooth Iterations",
"Cleanup", "Cleanup",
"Smooth", "Smooth",
"Base Plane Depth", "Reference Depth",
"Depth Direction", "Depth Direction",
"Uniform Scale", "Uniform Scale",
"Depth Image", "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] == [ assert [item.name for item in panels[0].interface_items] == [
"Cleanup", "Cleanup",
"Smooth", "Smooth",
"Base Plane Depth", "Reference Depth",
"Depth Direction", "Depth Direction",
] ]
assert [item.name for item in panels[1].interface_items] == [ 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", "Thickness",
"Depth Scale", "Depth Scale",
"Double Sided", "Double Sided",
"Base Plane Depth", "Reference Depth",
"Depth Direction", "Depth Direction",
"Uniform Scale", "Uniform Scale",
"Depth Image", "Depth Image",
@@ -384,7 +429,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Geometry", "Geometry",
"Thickness", "Thickness",
"Depth Scale", "Depth Scale",
"Base Plane Depth", "Reference Depth",
"Uniform Scale", "Uniform Scale",
"Depth Image", "Depth Image",
] ]
@@ -405,7 +450,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Thickness": set(cutout_shape.CUTOUT_SHAPES), "Thickness": set(cutout_shape.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Double Sided": {"DEPTH_BALLOON"}, "Double Sided": {"DEPTH_BALLOON"},
"Base Plane Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Direction": {"DEPTH_BALLOON"}, "Depth Direction": {"DEPTH_BALLOON"},
"Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Image": {"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" if node.bl_idname == "ShaderNodeBsdfPrincipled"
) )
assert np.isclose(shader.inputs["IOR"].default_value, 1.2) 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(): def test_cutout_material_uses_flat_tangent_normal():
bpy.ops.wm.read_factory_settings(use_empty=True) 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( reference_id = object_data.modifier_input_identifier(
modifier.node_group, modifier.node_group,
"Base Plane Depth", "Reference Depth",
) )
assert modifier[reference_id] == 8.0 assert modifier[reference_id] == 8.0
object_data.set_modifier_input( object_data.set_modifier_input(
@@ -1013,7 +1102,7 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
depth_modifier[ depth_modifier[
object_data.modifier_input_identifier( object_data.modifier_input_identifier(
depth_modifier.node_group, depth_modifier.node_group,
"Base Plane Depth", "Reference Depth",
) )
], ],
uniform_scale, uniform_scale,
@@ -1078,7 +1167,7 @@ def test_depth_plane_builds_a_fixed_base_terrain_block():
evaluated.to_mesh_clear() evaluated.to_mesh_clear()
set_input("Depth Scale", 1.0) set_input("Depth Scale", 1.0)
set_input("Base Plane Depth", 1.2) set_input("Reference Depth", 1.2)
outward = _evaluated_positions(result) outward = _evaluated_positions(result)
assert np.isclose(outward[:, 2].min(), 0.0, atol=1e-6) assert np.isclose(outward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(outward[:, 2].max(), 0.3, atol=1e-5) 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 len(z_levels) >= 4
assert np.allclose(np.diff(z_levels), np.diff(z_levels)[0], atol=1e-5) 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) inward = _evaluated_positions(result)
assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6) assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(inward[:, 2].max(), 0.05, atol=1e-5) 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) limited = _evaluated_positions(result)
assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6) assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6)
assert limited[:, 2].max() > 0.0 assert limited[:, 2].max() > 0.0
assert limited[:, 2].max() < 0.001 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) outward_beyond_thickness = _evaluated_positions(result)
assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5) assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5)
set_input("Thickness", 0.0) set_input("Thickness", 0.0)
set_input("Base Plane Depth", 1.2) set_input("Reference Depth", 1.2)
sheet = _evaluated_positions(result) sheet = _evaluated_positions(result)
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5) 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( depth_reference_id = object_data.modifier_input_identifier(
modifier.node_group, modifier.node_group,
"Base Plane Depth", "Reference Depth",
) )
assert not np.isclose(modifier[depth_reference_id], 8.0) assert not np.isclose(modifier[depth_reference_id], 8.0)
depth_direction_id = object_data.modifier_input_identifier( 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)), points=((1, 2), (8, 2), (4, 9)),
invert=True, invert=True,
) )
value = selection_path.to_json() value = selection_path.to_json()
assert set(json.loads(value)) == {"points", "invert"} 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(): def test_nonzero_winding_keeps_same_direction_overlap():
points = ( points = (
(1, 1), (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(all(path.is_file() for path in result.values()))
self.assertTrue(infer.call_args.kwargs["include_points"]) 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): def test_generate_cutout_artifacts_only_returns_requested_outputs(self):
job = sys.modules["server.jobs.cutout"] job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png" input_path = self.storage_root / "input.png"
@@ -750,7 +790,7 @@ class ServerRuntimeTest(unittest.TestCase):
normal_mode="OBJECT", 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"] job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png" input_path = self.storage_root / "input.png"
input_path.write_bytes(b"selection") input_path.write_bytes(b"selection")
@@ -763,7 +803,7 @@ class ServerRuntimeTest(unittest.TestCase):
def generate(_context, _input, _model, _device, output): def generate(_context, _input, _model, _device, output):
output.write_bytes(b"refined") output.write_bytes(b"refined")
return output, (2, 3, 8, 9) return output
with patch.object(job, "refine_selection", side_effect=generate): with patch.object(job, "refine_selection", side_effect=generate):
result = job.run( result = job.run(
@@ -778,12 +818,62 @@ class ServerRuntimeTest(unittest.TestCase):
self.assertEqual( self.assertEqual(
result, 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", "input": str(input_path),
"selection_bounds": "selection_bounds.json", "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"), self.assertEqual(result["selection_image"], "selection.png")
"[2,3,8,9]", 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) options = group.interface.new_panel(name="Options", default_closed=True)
reference_depth = _float_input( reference_depth = _float_input(
group, group,
"Base Plane Depth", "Reference Depth",
1.0, 1.0,
0.0, 0.0,
None, None,
@@ -736,7 +736,7 @@ def build_node_group(shape):
options = group.interface.new_panel(name="Options", default_closed=True) options = group.interface.new_panel(name="Options", default_closed=True)
reference_depth = _float_input( reference_depth = _float_input(
group, group,
"Base Plane Depth", "Reference Depth",
1.0, 1.0,
0.0, 0.0,
None, None,
@@ -771,7 +771,7 @@ def build_node_group(shape):
"Depth Image", "Depth Image",
"Depth Scale", "Depth Scale",
"Uniform Scale", "Uniform Scale",
"Base Plane Depth", "Reference Depth",
"Depth Direction", "Depth Direction",
}, },
) )
@@ -796,7 +796,7 @@ def build_node_group(shape):
controls.outputs["Uniform Scale"], controls.outputs["Uniform Scale"],
controls.outputs["Depth Direction"], controls.outputs["Depth Direction"],
controls.outputs["Depth Image"], controls.outputs["Depth Image"],
controls.outputs["Base Plane Depth"], controls.outputs["Reference Depth"],
) )
output_location = (5100.0, 600.0) output_location = (5100.0, 600.0)
else: else:
@@ -806,7 +806,7 @@ def build_node_group(shape):
controls.outputs["Thickness"], controls.outputs["Thickness"],
controls.outputs["Depth Scale"], controls.outputs["Depth Scale"],
controls.outputs["Uniform Scale"], controls.outputs["Uniform Scale"],
controls.outputs["Base Plane Depth"], controls.outputs["Reference Depth"],
controls.outputs["Depth Image"], controls.outputs["Depth Image"],
) )
output_location = (8200.0, 400.0) output_location = (8200.0, 400.0)
@@ -877,7 +877,7 @@ def build_cutout_modifier_group(shape_groups):
reference_depth = _float_input( reference_depth = _float_input(
group, group,
"Base Plane Depth", "Reference Depth",
1.0, 1.0,
0.0, 0.0,
None, 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) options = group.interface.new_panel(name="Options", default_closed=True)
base_plane_depth = _interface_socket( base_plane_depth = _interface_socket(
group, group,
"Base Plane Depth", "Reference Depth",
"INPUT", "INPUT",
"NodeSocketFloat", "NodeSocketFloat",
parent=options, parent=options,
@@ -98,7 +98,7 @@ def build_depth_plane_group(mesh_plane_group):
shape_input = _group_input( shape_input = _group_input(
nodes, nodes,
(-1550.0, 950.0), (-1550.0, 950.0),
{"Thickness", "Depth Scale", "Base Plane Depth"}, {"Thickness", "Depth Scale", "Reference Depth"},
) )
image_input = _group_input( image_input = _group_input(
nodes, 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(depth_components.outputs["X"], metric_depth.inputs[0])
links.new(image_input.outputs["Uniform Scale"], metric_depth.inputs[1]) links.new(image_input.outputs["Uniform Scale"], metric_depth.inputs[1])
relative_depth = _math(nodes, "SUBTRACT", (50.0, -350.0)) 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]) links.new(metric_depth.outputs[0], relative_depth.inputs[1])
full_offset = _math(nodes, "MULTIPLY", (300.0, -350.0)) full_offset = _math(nodes, "MULTIPLY", (300.0, -350.0))
links.new(relative_depth.outputs[0], full_offset.inputs[0]) links.new(relative_depth.outputs[0], full_offset.inputs[0])

View File

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

View File

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