From bdbde1a10dd45b6e581e8a57ea4ca549926a6d00 Mon Sep 17 00:00:00 2001 From: nan Date: Thu, 3 Sep 2026 01:02:10 +0800 Subject: [PATCH] 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. --- docs/internals/architecture.md | 2 +- docs/internals/common.md | 12 +- docs/internals/node-assets.md | 2 + docs/internals/operators/convert-to-plane.md | 4 +- docs/internals/operators/crop-tool.md | 9 +- docs/internals/operators/cutout-tool.md | 16 +- docs/internals/outputs.md | 6 +- docs/internals/runtime.md | 4 +- .../.openspec.yaml | 2 + .../design.md | 69 ++++++ .../proposal.md | 30 +++ .../specs/cutout-mesh-sampling/spec.md | 28 +++ .../specs/image-edit-results/spec.md | 43 ++++ .../tasks.md | 24 +++ .../.openspec.yaml | 2 + .../refine-image-output-semantics/design.md | 98 +++++++++ .../refine-image-output-semantics/proposal.md | 33 +++ .../specs/geometry-depth-controls/spec.md | 23 ++ .../specs/image-result-materials/spec.md | 16 ++ .../selection-content-processing/spec.md | 68 ++++++ .../refine-image-output-semantics/tasks.md | 30 +++ src/anyimage/assets/O_AnyImage.blend | 4 +- src/anyimage/common/image.py | 40 +++- src/anyimage/common/material.py | 4 + src/anyimage/common/selection.py | 24 +++ .../operators/convert_to_plane/object.py | 2 +- .../operators/crop_tool/perspective.py | 4 +- src/anyimage/operators/crop_tool/selection.py | 64 ++++-- .../operators/cutout_tool/geometry.py | 35 +-- .../operators/cutout_tool/interaction.py | 20 +- src/anyimage/operators/cutout_tool/main.py | 62 ++---- src/anyimage/operators/cutout_tool/object.py | 6 +- src/anyimage/operators/remove_background.py | 4 +- src/anyimage/operators/upscale.py | 4 +- src/anyimage/server/jobs/cutout.py | 13 +- .../server/jobs/refine_image_selection.py | 9 +- .../server/selection/refine_selection.py | 23 +- tests/test_blender_addon.py | 204 +++++++++++++++--- tests/test_cutout_geometry.py | 46 +++- tests/test_cutout_interaction.py | 12 +- tests/test_cutout_objects.py | 117 ++++++++-- tests/test_selection.py | 19 +- tests/test_server_runtime.py | 106 ++++++++- tools/node_assets/build_cutout.py | 12 +- tools/node_assets/build_depth_plane.py | 6 +- tools/node_assets/validate.py | 4 +- tools/node_assets/validate_cutout.py | 12 +- 47 files changed, 1120 insertions(+), 257 deletions(-) create mode 100644 openspec/changes/fix-cutout-density-and-image-edit-names/.openspec.yaml create mode 100644 openspec/changes/fix-cutout-density-and-image-edit-names/design.md create mode 100644 openspec/changes/fix-cutout-density-and-image-edit-names/proposal.md create mode 100644 openspec/changes/fix-cutout-density-and-image-edit-names/specs/cutout-mesh-sampling/spec.md create mode 100644 openspec/changes/fix-cutout-density-and-image-edit-names/specs/image-edit-results/spec.md create mode 100644 openspec/changes/fix-cutout-density-and-image-edit-names/tasks.md create mode 100644 openspec/changes/refine-image-output-semantics/.openspec.yaml create mode 100644 openspec/changes/refine-image-output-semantics/design.md create mode 100644 openspec/changes/refine-image-output-semantics/proposal.md create mode 100644 openspec/changes/refine-image-output-semantics/specs/geometry-depth-controls/spec.md create mode 100644 openspec/changes/refine-image-output-semantics/specs/image-result-materials/spec.md create mode 100644 openspec/changes/refine-image-output-semantics/specs/selection-content-processing/spec.md create mode 100644 openspec/changes/refine-image-output-semantics/tasks.md diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index 166142a..57c2d7b 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -38,7 +38,7 @@ flowchart TD Menu --> Runtime["注册 JobRuntime"] ``` -`__init__.py` 按依赖顺序注册,并在注销时逆序清理。扩展级设置统一由 `preferences.addon_preferences()` 读取;`runtime.py` 只消费 `configured_storage_root` 回调,不持有 Blender 偏好设置的定位逻辑。交互中的临时状态和 Debug 参数保存在 `Scene.anyimage_settings`。`max_ai_input_size` 与 `max_cutout_mesh_resolution` 都在 `preferences.py` 定义与读取:前者由 Operator 冻结到 AI Job 请求,后者在 Blender 侧统一限制所有 Cutout Shape 的网格采样密度。Upscale 还用 AI 输入上限判断当前 Image 是否允许执行。 +`__init__.py` 按依赖顺序注册,并在注销时逆序清理。扩展级设置统一由 `preferences.addon_preferences()` 读取;`runtime.py` 只消费 `configured_storage_root` 回调,不持有 Blender 偏好设置的定位逻辑。交互中的临时状态和 Debug 参数保存在 `Scene.anyimage_settings`。`max_ai_input_size` 与 `max_cutout_mesh_resolution` 都在 `preferences.py` 定义与读取:前者由 Operator 冻结到 AI Job 请求,后者在 Blender 侧统一限制所有 Cutout Shape 实际几何范围的最长采样边。Upscale 还用 AI 输入上限判断当前 Image 是否允许执行。 ## 同步与异步路径 diff --git a/docs/internals/common.md b/docs/internals/common.md index 800aa9d..9455de5 100644 --- a/docs/internals/common.md +++ b/docs/internals/common.md @@ -22,14 +22,17 @@ | `image.image_base_name()` | 去除图片或视频扩展名和 Blender 数字后缀,得到数据块基础名。 | | `image.depth_data_name()` | 生成 Depth 结果数据块文件名。 | | `image.color_data_name()` | 生成 Color 结果数据块文件名。 | -| `image.load_color_result_image()` | 加载、命名、Pack 并配置 Job 的 Color 或 Sequence 结果;不修改 Empty。 | +| `image.load_image_edit_result()` | 加载、Pack 并配置直接编辑源 Image 的 Job 结果,以源 Image 完整名称请求新数据块名称。 | +| `image.load_color_result_image()` | 加载、命名、Pack 并配置材质 Color 或 Sequence 结果;不修改 Empty。 | | `image.load_normal_result_image()` | 加载、命名、设为 Non-Color 并 Pack Normal Map。 | | `image.load_packed_rgba_image()` | 加载已有 RGBA 文件,继承源图颜色空间和 Alpha mode,命名并 Pack。 | -| `image.create_pixel_result_image()` | 从本地编辑后的 RGBA 像素创建 Packed Image;不修改 Empty。 | +| `image.create_image_edit_result()` | 从本地编辑后的 RGBA 像素创建 Packed Image,以源 Image 完整名称请求新数据块名称;不修改 Empty。 | | `image.replace_empty_image()` | 执行 Image Empty 结果替换、共享引用分流、旧数据清理、帧设置继承和可选画布对齐。 | | `image.place_empty_image_result()` | 按 Keep Original 决定替换源 Empty 或复制结果,并选择、激活结果 Empty。 | | `image.image_empty_bounds()` | 计算 Image Empty 在对象局部坐标中的实际显示边界。 | +Remove Background、Upscale 与四种 Crop 都属于直接 Image 编辑,结果保留源 Image 数据块的完整名称,包括名称中的 `.png` 等格式文字。源数据仍被其他对象引用或启用 Keep Original 时,重名结果由 Blender 自动追加数字后缀;源数据可清理时,替换入口让结果恢复准确原名。`color_data_name()`、`depth_data_name()`、`normal_data_name()` 仅用于 Plane 与 Cutout 等材质派生贴图。 + ## Image Selection 与视图交互 这组公共能力使用固定术语: @@ -67,6 +70,9 @@ | --- | --- | | `selection.selection_path_bounds()` | 校验 Selection Path 并返回整数像素包围框。 | | `selection.rasterize_selection_path()` | 以局部 non-zero scanline 把 `SelectionPath` 栅格化为纯几何 `SelectionMask`。 | +| `selection.save_selection_mask()` | 将一次栅格化的局部 Mask 保存为 Blender 异步响应使用的 sidecar。 | +| `selection.load_selection_mask()` | 从本地 sidecar 恢复带原 bounds 的 `SelectionMask`。 | +| `selection.cleanup_selection_mask()` | 删除本地 Selection Mask sidecar。 | | `selection.crop_pixels_to_alpha()` | 按 Alpha 非零区域紧裁切 RGBA 像素。 | ## Depth 产物 @@ -87,7 +93,7 @@ | 函数 | 职责 | | --- | --- | | `node.load_node_group()` | 从随扩展分发的 Blend 资产加载指定 Node Group。 | -| `material.create_image_material()` | 从 Image Empty 创建包含颜色、位移、Normal Map、Alpha 和动画设置的完整材质。 | +| `material.create_image_material()` | 从 Image Empty 创建包含颜色、位移、Normal Map、Alpha 和动画设置的完整材质;非 Shadeless 材质由 Color 直接驱动 Principled IOR Level。 | | `object.modifier_input_identifier()` | 按接口名称解析 Geometry Nodes Modifier 输入 Identifier。 | | `object.set_modifier_input()` | 跨受支持 Blender RNA 形式设置 Modifier 输入。 | | `object.finalize_object_result()` | 激活结果对象,并按需替换或保留源 Empty。 | diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index 2392d82..7815879 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -41,6 +41,8 @@ uv run build_node --skip-tests `O Image Cutout` 在四个 Shape 分支与平滑之后统一处理 **Inward Axis**。Shape 子节点保持内部 `+X` 朝向;默认 `-Z` 分支执行一次轴转换,`+X` 分支保留内部坐标。运行时对象矩阵与该接口配对,保证两种模式落在相同世界空间位置。 +`O Image Depth Plane`、`O Image Cutout`、`O Image Depth Balloon` 与 `O Image Depth Surface` 统一使用 **Reference Depth** 作为采样深度的运算基准。 + ## 修改节点组 1. 修改 `tools/node_assets/build_*.py`; diff --git a/docs/internals/operators/convert-to-plane.md b/docs/internals/operators/convert-to-plane.md index f55c6e0..ac07162 100644 --- a/docs/internals/operators/convert-to-plane.md +++ b/docs/internals/operators/convert-to-plane.md @@ -39,13 +39,13 @@ EXR 的 RGB 重复保存完整相机 Z,Alpha 保存模型 Validity;JSON 保 ### 创建 Depth Plane -Job 返回后,`object.create_depth_plane_from_result()` 加载并 Pack `z-depth.exr` 与 `tangent-normal.png`。Depth 数据块仍由 `depth_data_name()` 命名;Normal 图片设为 Non-Color,并以 Tangent Space 接入 `O Image Layer`。结果材质与 Cutout 一样使用 Principled IOR `1.2`。`depth.json` 的参考深度乘以统一尺度后初始化 **Base Plane Depth**,使它和采样深度处于同一局部距离单位。 +Job 返回后,`object.create_depth_plane_from_result()` 加载并 Pack `z-depth.exr` 与 `tangent-normal.png`。Depth 数据块仍由 `depth_data_name()` 命名;Normal 图片设为 Non-Color,并以 Tangent Space 接入 `O Image Layer`。结果材质与 Cutout 一样使用 Principled IOR `1.2`,并由 `O Image Layer` Color 直接驱动 IOR Level。`depth.json` 的参考深度乘以统一尺度后初始化 **Reference Depth**,使它和采样深度处于同一局部距离单位。 `O Image Depth Plane` 把 `O Mesh Plane` 的固定底面放在原图片平面,`Thickness` 只沿正法线移动可变面与中间环,再按各层位置应用深度位移: ```mermaid flowchart TD - Depth["Z Depth × Uniform Scale"] --> Offset["(Base Plane Depth - Depth) × Depth Scale"] + Depth["Z Depth × Uniform Scale"] --> Offset["(Reference Depth - Depth) × Depth Scale"] Offset --> Clamp["负向位移限制在固定底面之前"] Block["O Mesh Plane 厚度体块"] --> Weight["固定底面 0 → 中间环 → 可变面 1"] Clamp --> Apply["位移 × 层权重"] diff --git a/docs/internals/operators/crop-tool.md b/docs/internals/operators/crop-tool.md index 1ec1ea9..0a57697 100644 --- a/docs/internals/operators/crop-tool.md +++ b/docs/internals/operators/crop-tool.md @@ -18,10 +18,11 @@ flowchart TD Path --> Mask["栅格化 SelectionMask"] Mask --> Refine{"Box / Lasso Refine Selection?"} Refine -- "否" --> Local["源 Alpha × Selection"] - Refine -- "是" --> Job["refine-image-selection"] + Refine -- "是" --> Job["refine-image-selection
保持 Selection bounds"] + Job --> Refined["BEN2 Alpha × Selection"] Kind -- "Perspective" --> Quad["四点 Quad 与 Homography"] Local --> Result["创建结果 Image"] - Job --> Result + Refined --> Result Quad --> Result Result --> Keep{"当前子工具 Keep Original?"} Keep -- "否" --> Replace["替换源 Empty"] @@ -32,10 +33,12 @@ flowchart TD `box.py`、`lasso.py` 和 `polyline.py` 定义 WorkspaceTool,交互与落地集中在 `selection.py`。公共 `CropSelectionGesture` 从 `common/viewport.py` 读取当前子工具的独立设置,并把内存中的 `SelectionPath` 交给 `CropImageSelection`。 -本地路径在几何 bounds 内把 Selection 值乘入源 Alpha,再按可见内容紧裁切。Box 或 Lasso 开启 Refine Selection 时,Operator 写出保留源 Alpha 的 bounds 矩形并提交 `server/jobs/refine_image_selection.py`;响应直接加载 BEN2 RGBA。Job request 不包含 Keep Original,保留策略只在 Blender 主线程落地结果时执行。 +本地路径在几何 bounds 内把 Selection 值乘入源 Alpha,再按可见内容紧裁切。Box 或 Lasso 开启 Refine Selection 时,Operator 写出保留源 Alpha 的 bounds 矩形,并把已栅格化的 Selection Mask 保存为同一临时容器内的 Blender 本地 sidecar。`server/jobs/refine_image_selection.py` 返回与输入同尺寸的 `Source Alpha × BEN2 Alpha` RGBA;Blender 读取 sidecar,把 Selection 值乘入结果 Alpha,并以提交前的 bounds 创建结果 Image。Job request 只包含图片输入、模型和设备;Keep Original 在 Blender 主线程落地结果时执行。 ## Crop Perspective `perspective.py` 接受任意顺序的四个角点并规范为凸 Quad,创建 512 × 512 预览,随后按目标宽高比执行分块 Homography。输出最长边不超过 8192 px,总像素不超过 16,777,216;插值使用预乘 Alpha,结果按可见 Alpha 紧裁切。 四种 Crop 最终都调用 `common/image.py` 的 `place_empty_image_result()`。该入口负责替换或复制 Image Empty、画布对齐、Collection 归属,以及 Keep Original 下选择并激活结果对象。 + +本地像素路径与 Refine Job 路径都通过公共 Image 编辑结果入口创建数据块。结果以源 Image 的完整名称请求命名,不移除 `.png` 等名称内容,也不添加材质贴图后缀;源 Image 被保留时产生的 `.001` 等数字后缀完全由 Blender 处理。 diff --git a/docs/internals/operators/cutout-tool.md b/docs/internals/operators/cutout-tool.md index f21c2cf..84cb1f8 100644 --- a/docs/internals/operators/cutout-tool.md +++ b/docs/internals/operators/cutout-tool.md @@ -15,7 +15,7 @@ flowchart TD Bounds["写出几何 bounds 矩形 RGBA"] Job["执行 Refine Selection,生成 Depth / Normal 产物"] Encoded["加载并 Pack 输入或 Refine RGBA"] - Ready["汇总内容 Mask 与 Shape 输入"] + Ready["贴图 Alpha × Selection Mask"] Build["构建 BaseShape"] Create["创建 Blender 对象"] @@ -33,13 +33,13 @@ Surface 与 Balloon 在本地组合源 Alpha 与几何 Selection 后构建 `Base Cutout 工具由 `__init__.py` 注册,交互从 `main.py` 中的 `SelectCutoutSelection` 开始。它接收 Lasso 或双击整图手势,只构建内存中的 `SelectionPath`,然后调用 `interaction.py` 打开 Shape 饼菜单。此时不读取源图像素、不栅格化 Mask,也不扫描可见 Alpha。 -`interaction.py` 读取工具选项,并在构建菜单按钮时把只含 Points 与 Invert 的完整 `SelectionPath` 编码一次,同时把当前 **Inward Axis** 固定到各 `CutoutSelectionToShape`。这次编码只用于跨 Blender Operator 传值,不属于 Selection Mask 构建。**Mesh Detail** 的 Low、Medium、High、Ultra 分别映射到 32、16、8、4 px 的采样间距,再换算期望网格分辨率;档位越高,采样越密。菜单中的 Surface、Balloon、Depth Balloon 和 Depth Surface,以及它们对应的节点分支索引,统一来自 `shape.py`。 +`interaction.py` 读取工具选项,并在构建菜单按钮时把只含 Points 与 Invert 的完整 `SelectionPath` 编码一次,同时把当前 **Inward Axis** 固定到各 `CutoutSelectionToShape`。这次编码只用于跨 Blender Operator 传值,不属于 Selection Mask 构建。**Mesh Detail** 的 Low、Medium、High、Ultra 分别携带 32、16、8、4 px 的源图采样间距;档位越高,采样越密。菜单中的 Surface、Balloon、Depth Balloon 和 Depth Surface,以及它们对应的节点分支索引,统一来自 `shape.py`。 ### 栅格化 Selection 用户选定 Shape 后,`main.py` 中的 `CutoutSelectionToShape` 接过主线。它从自身 Property 恢复 `SelectionPath`,再使用 `common/selection.py` 以 non-zero winding 栅格化为一次纯几何 `SelectionMask`:非反选只处理 Path Bounds 与 2 px 抗锯齿边缘,反选覆盖完整图片范围。`values` 保存局部几何权重,`bounds` 保存它在源图中的位置;栅格阶段不读取、接收或合并源 Alpha。 -实际 Cutout 业务随后读取一次源 RGBA。无需 AI 时,它在几何 bounds 内显式计算 `source_alpha × selection_values`,由该内容 Mask 判断空内容并约束轮廓;原始 `SelectionMask` 不被修改。此时也会应用 **Maximum Cutout Mesh Resolution**,统一限制四种 Shape 几何范围的最长采样边。 +实际 Cutout 业务随后读取一次源 RGBA。无需 AI 时,它在几何 bounds 内显式计算 `source_alpha × selection_values`,由该内容 Mask 判断空内容并约束轮廓;原始 `SelectionMask` 不被修改。此时按 `Selection bounds 长边 ÷ Mesh Detail 像素间距` 计算实际采样长边;只有它超过 **Maximum Cutout Mesh Resolution** 时才等比增大有效间距。bounds 的原始像素长边本身不会触发降密。 ### 需要后台生成数据? @@ -55,7 +55,7 @@ Surface 和 Balloon 的本地路径直接使用本地内容 Mask。 `main.py` 先通过 `common/image.py` 把 Selection 几何 bounds 内的原始 RGBA 写成矩形输入。该文件保留源 Alpha,不预乘 Lasso 值;文件、Packed 或内存图片都使用同一个入口。随后把请求提交给 `server/jobs/cutout.py`,生成本次 Shape 需要的产物: -- **Refine Selection** 调用 `server/selection/refine_selection.py`,写出收紧后的 `selection.png` 和 `selection_bounds.json`; +- **Refine Selection** 调用 `server/selection/refine_selection.py`,写出与输入 bounds 同尺寸的 `selection.png`; - 需要 Vector Depth 时写出相机空间 XYZ 组成的 `vector-depth.exr`,需要 Z Depth 时写出 `z-depth.exr`,两者都配套 `depth.json`; - **Normal Map** 根据既定空间写出 `tangent-normal.png` 或 `object-normal.png`。 @@ -63,11 +63,11 @@ Depth 与 Normal Map 共享同一个 MoGe-2 `GeometryFrame`。模型输入先按 **Maximum AI Input Size** 约束 BEN2 和 MoGe-2 的分析图长边;网格密度使用 **Maximum Cutout Mesh Resolution**,最终颜色图使用源图裁切分辨率。 -Refine 把 Lasso 视为矩形上下文,因此 BEN2 可以保留 bounds 内、Lasso 外的内容。启用 Refine 时,最终内容 Alpha 完全来自 BEN2;只生成 Depth 或 Normal 时,最终几何仍以本地几何 Selection 值乘矩形输入 Alpha 约束。 +Refine 的贴图内容 Alpha 统一为 `Source Alpha × BEN2 Alpha`。Color 直接使用该 RGBA;Normal 的可见区域、MoGe-2 黑底输入和 Depth Metadata 的参考区域使用同一 Alpha。Depth EXR 的 RGB 仍保存连续 Field,Alpha 保存模型 Validity。Selection Mask 只在 Blender 构建网格时与贴图 Alpha 相乘。 ### 构建 BaseShape -Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth 与 Normal 图片,并选取一份已有编码 RGBA:启用 Refine 时使用 BEN2 输出;只生成 Depth 或 Normal 时使用提交前的矩形输入。它从同一局部 Image 读取 Alpha 构建内容 Mask,并把这个 Image 直接交给颜色材质。`main.create_cutout_shape()` 调用 `geometry.py` 中的 `build_base_shape()`: +Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth 与 Normal 图片,并选取一份已有编码 RGBA:启用 Refine 时使用完整 bounds 的 BEN2 输出;只生成 Depth 或 Normal 时使用提交前的矩形输入。它从同一 Image 读取 Alpha,与已栅格化的 Selection Mask 相乘后构建网格内容 Mask;颜色及 AI 贴图保持原 Alpha。`main.create_cutout_shape()` 调用 `geometry.py` 中的 `build_base_shape()`: 1. 从内容 Alpha 值提取外轮廓和孔洞; 2. 按 Mesh Detail 对应的采样间距生成轮廓与内部采样点; @@ -79,7 +79,7 @@ Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth ### 创建 Blender 对象 -`main.create_cutout_shape()` 把 `BaseShape`、颜色 Image 和可选产物交给 `object.py` 的 `create_shape_object()`。这里创建 Material、Mesh Object,并挂上统一的 `O Image Cutout` Modifier。材质通过 `common/material.py` 创建,Cutout 使用 `1.2` 的 IOR。本地 Cutout 创建一次几何 bounds 颜色图;AI Cutout 直接复用已编码并 Pack 的矩形输入或 BEN2 输出,不再重读完整源图并重新编码同一颜色内容。 +`main.create_cutout_shape()` 把 `BaseShape`、颜色 Image 和可选产物交给 `object.py` 的 `create_shape_object()`。这里创建 Material、Mesh Object,并挂上统一的 `O Image Cutout` Modifier。材质通过 `common/material.py` 创建,Cutout 使用 `1.2` 的 IOR,并由 `O Image Layer` Color 直接驱动 Principled BSDF 的 IOR Level。本地 Cutout 创建一次几何 bounds 颜色图;AI Cutout 直接复用已编码并 Pack 的矩形输入或 BEN2 输出。 | Shape | Modifier 如何形成结果 | 必需后台产物 | | --- | --- | --- | @@ -88,7 +88,7 @@ Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth | Depth Balloon | 在 Balloon 上叠加相对基准面的深度 | `z-depth.exr`、`depth.json` | | Depth Surface | 使用相机点 XYZ 重建透视表面 | `vector-depth.exr`、`depth.json` | -Depth Shape 的 Base Plane Depth,以及 Depth Balloon 的 Direction,都由 `object.py` 调用 `geometry.py` 的标定函数,根据最终内容 Alpha 计算。透明像素达到 `5%` 时使用有效内容区域自适应标定,否则使用稳定的固定基准。 +Depth Shape 的 Reference Depth,以及 Depth Balloon 的 Direction,都由 `object.py` 调用 `geometry.py` 的标定函数,根据最终网格内容 Mask 计算。透明像素达到 `5%` 时使用有效内容区域自适应标定,否则使用稳定的固定基准。 Normal Map 关闭时材质使用几何法线;开启时,`object.py` 把生成的图片和已经确定的 Normal Space 交给材质节点。最后 `main.py` 提交 Undo,整条 Cutout 流程结束。 diff --git a/docs/internals/outputs.md b/docs/internals/outputs.md index 6cd2e62..974bfe5 100644 --- a/docs/internals/outputs.md +++ b/docs/internals/outputs.md @@ -8,7 +8,7 @@ Server 在 Job 目录写入图片、JSON 或 Debug NPZ;Blender 根据 Job 返 | | | | | `remove-background` | `image.0001.png` 起 | 替换 Image Empty 的颜色数据 | | `upscale-image` | `upscale.0001.png` 起 | 替换或新建高清颜色数据 | -| `refine-image-selection` | `image.png`、`bounds.json` | Image Box / Lasso 读取 Refine Selection 结果 | +| `refine-image-selection` | `image.png` | Image Box / Lasso 读取完整 bounds 的 Refine Selection 结果 | | `generate-depth-plane-geometry` | `image.0001.*`、`z-depth.exr`、`depth.json` | 创建 Depth Plane | | `generate-cutout-artifacts` | 可选 Selection、Depth 与 Normal 文件 | 创建 Cutout Shape 与材质 | @@ -34,12 +34,12 @@ Blender 通过 `common/depth.py` 加载 EXR,设为 Non-Color 并 Pack。Depth Blender 先把 `SelectionPath` 栅格化为 `SelectionMask`,再把局部 RGBA 作为 Server 输入。MoGe-2 输入统一按 `RGB × Alpha` 合成到黑底;完全透明像素下的隐藏 RGB 不影响预测。Server 不接收 Path 或 Mask 对象协议: -- 开启 Refine Selection 时,`selection.png` 保存细化后的局部 RGBA,`selection_bounds.json` 保存它相对输入裁切图的范围; +- 开启 Refine Selection 时,`selection.png` 以输入 bounds 的完整尺寸保存 `Source Alpha × BEN2 Alpha` RGBA; - 需要 Vector Depth 时请求 `vector-depth.exr` 与 `depth.json`; - 需要 Z Depth 时请求 `z-depth.exr` 与 `depth.json`; - Normal Map 按空间请求 `tangent-normal.png` 或 `object-normal.png`。 -Surface 与 Balloon 的本地路径直接使用 `SelectionMask` 构建 Shape。最终颜色图由 Blender 按 Selection 范围裁切源 Image,并保留源 RGBA。 +Cutout 的 Color、Normal 与 Depth 使用同一图片 Alpha 内容范围;Blender 再将该 Alpha 与 `SelectionMask` 相乘构建 Shape。Surface 与 Balloon 的本地路径直接组合源 Alpha 和 `SelectionMask`。 ## Debug 产物 diff --git a/docs/internals/runtime.md b/docs/internals/runtime.md index dbad813..21ee79b 100644 --- a/docs/internals/runtime.md +++ b/docs/internals/runtime.md @@ -48,13 +48,13 @@ Job 在阶段边界调用 `context.check_cancelled()`,并把稳定产物写入 | `generate-cutout-artifacts` | 按需执行 Refine Selection,并生成 Depth 或 Normal 文件产物 | | `debug-*` | 保存模型适配层的直接输出 | -`generate-depth-plane-geometry` 对应 `jobs/depth_plane.py`;`generate-cutout-artifacts` 对应 `jobs/cutout.py`。共享的 MoGe-2 推理编排位于 `geometry/prediction_artifacts.py`,它把最终 Selection RGBA 合成到黑底后执行推理,并把单帧 `GeometryFrame` 交给 Depth、Normal 与 Metadata Writer。Depth EXR 与元数据写入位于 `geometry/depth_texture.py`,Normal 编码位于 `geometry/normal_texture.py`,Refine Selection 的通用实现位于 `selection/refine_selection.py`。 +`generate-depth-plane-geometry` 对应 `jobs/depth_plane.py`;`generate-cutout-artifacts` 对应 `jobs/cutout.py`。共享的 MoGe-2 推理编排位于 `geometry/prediction_artifacts.py`,它把最终 Selection RGBA 合成到黑底后执行推理,并把单帧 `GeometryFrame` 交给 Depth、Normal 与 Metadata Writer。Depth EXR 与元数据写入位于 `geometry/depth_texture.py`,Normal 编码位于 `geometry/normal_texture.py`。`selection/refine_selection.py` 使用 BEN2 生成与输入同尺寸的 `Source Alpha × BEN2 Alpha` RGBA;Selection Mask 由 Blender 在最终图片或网格阶段组合。 ## 尺寸约束 `server/media/resolution.py` 提供 **Maximum AI Input Size**,默认 `2048 px`。它限制 Upscale 的可提交源图长边,以及 Depth、Normal Map、Refine Selection 的模型分析图长边;材质颜色图使用原始分辨率。 -Blender 侧的 **Maximum Cutout Mesh Resolution** 统一限制所有 Cutout Shape 的网格采样密度。 +Blender 侧的 **Maximum Cutout Mesh Resolution** 统一限制所有 Cutout Shape 实际几何范围的最长网格采样边,不限制 Selection bounds 的原始像素尺寸。 `generate-depth-plane-geometry` 与 `generate-cutout-artifacts` 只接受一帧,因此 Depth Plane 与 Cutout 是静态图工作流。Remove Background 与 Upscale 继续通过媒体入口处理单图、视频和序列帧。 diff --git a/openspec/changes/fix-cutout-density-and-image-edit-names/.openspec.yaml b/openspec/changes/fix-cutout-density-and-image-edit-names/.openspec.yaml new file mode 100644 index 0000000..032461f --- /dev/null +++ b/openspec/changes/fix-cutout-density-and-image-edit-names/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-02 diff --git a/openspec/changes/fix-cutout-density-and-image-edit-names/design.md b/openspec/changes/fix-cutout-density-and-image-edit-names/design.md new file mode 100644 index 0000000..b4ce711 --- /dev/null +++ b/openspec/changes/fix-cutout-density-and-image-edit-names/design.md @@ -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` 中已经创建的数据块。若回退,代码可整体恢复;已有结果数据不需要转换。 diff --git a/openspec/changes/fix-cutout-density-and-image-edit-names/proposal.md b/openspec/changes/fix-cutout-density-and-image-edit-names/proposal.md new file mode 100644 index 0000000..3b24e9e --- /dev/null +++ b/openspec/changes/fix-cutout-density-and-image-edit-names/proposal.md @@ -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 协议。 diff --git a/openspec/changes/fix-cutout-density-and-image-edit-names/specs/cutout-mesh-sampling/spec.md b/openspec/changes/fix-cutout-density-and-image-edit-names/specs/cutout-mesh-sampling/spec.md new file mode 100644 index 0000000..6734ce8 --- /dev/null +++ b/openspec/changes/fix-cutout-density-and-image-edit-names/specs/cutout-mesh-sampling/spec.md @@ -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** 系统等比增大网格采样间距,使实际采样长边不超过配置值 + diff --git a/openspec/changes/fix-cutout-density-and-image-edit-names/specs/image-edit-results/spec.md b/openspec/changes/fix-cutout-density-and-image-edit-names/specs/image-edit-results/spec.md new file mode 100644 index 0000000..a646273 --- /dev/null +++ b/openspec/changes/fix-cutout-density-and-image-edit-names/specs/image-edit-results/spec.md @@ -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 数据继续按对应材质贴图规则命名 + diff --git a/openspec/changes/fix-cutout-density-and-image-edit-names/tasks.md b/openspec/changes/fix-cutout-density-and-image-edit-names/tasks.md new file mode 100644 index 0000000..f51bf55 --- /dev/null +++ b/openspec/changes/fix-cutout-density-and-image-edit-names/tasks.md @@ -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`,确认全部测试通过且没有恢复旧命名入口、兼容层或图片尺寸限制胶水代码 diff --git a/openspec/changes/refine-image-output-semantics/.openspec.yaml b/openspec/changes/refine-image-output-semantics/.openspec.yaml new file mode 100644 index 0000000..032461f --- /dev/null +++ b/openspec/changes/refine-image-output-semantics/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-02 diff --git a/openspec/changes/refine-image-output-semantics/design.md b/openspec/changes/refine-image-output-semantics/design.md new file mode 100644 index 0000000..d3632ce --- /dev/null +++ b/openspec/changes/refine-image-output-semantics/design.md @@ -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 + +无。 diff --git a/openspec/changes/refine-image-output-semantics/proposal.md b/openspec/changes/refine-image-output-semantics/proposal.md new file mode 100644 index 0000000..1ed8474 --- /dev/null +++ b/openspec/changes/refine-image-output-semantics/proposal.md @@ -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 模型或进程边界。 diff --git a/openspec/changes/refine-image-output-semantics/specs/geometry-depth-controls/spec.md b/openspec/changes/refine-image-output-semantics/specs/geometry-depth-controls/spec.md new file mode 100644 index 0000000..2a8236b --- /dev/null +++ b/openspec/changes/refine-image-output-semantics/specs/geometry-depth-controls/spec.md @@ -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` 的既有相对深度关系更新几何 diff --git a/openspec/changes/refine-image-output-semantics/specs/image-result-materials/spec.md b/openspec/changes/refine-image-output-semantics/specs/image-result-materials/spec.md new file mode 100644 index 0000000..8e44f88 --- /dev/null +++ b/openspec/changes/refine-image-output-semantics/specs/image-result-materials/spec.md @@ -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 diff --git a/openspec/changes/refine-image-output-semantics/specs/selection-content-processing/spec.md b/openspec/changes/refine-image-output-semantics/specs/selection-content-processing/spec.md new file mode 100644 index 0000000..22a9582 --- /dev/null +++ b/openspec/changes/refine-image-output-semantics/specs/selection-content-processing/spec.md @@ -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 diff --git a/openspec/changes/refine-image-output-semantics/tasks.md b/openspec/changes/refine-image-output-semantics/tasks.md new file mode 100644 index 0000000..64d0ea3 --- /dev/null +++ b/openspec/changes/refine-image-output-semantics/tasks.md @@ -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 引入的回归,不构建其他文档或发布产物。 diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index f0ea4da..3ae742d 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7242cfe77e3ea8be3699bcf4ecb8932448997d73830204333a4d84bdfc50808b -size 1664356 +oid sha256:a548cfe1c2c61e9dda4ab96c118c94acfb794863c6e5163fe3a79a8e6bb2f621 +size 1664352 diff --git a/src/anyimage/common/image.py b/src/anyimage/common/image.py index 87e2443..ea8abdf 100644 --- a/src/anyimage/common/image.py +++ b/src/anyimage/common/image.py @@ -405,7 +405,7 @@ def load_normal_result_image(path, source_object): return image -def create_pixel_result_image(source_image, pixels, output_size, suffix=""): +def create_image_edit_result(source_image, pixels, output_size): """Create a packed Image datablock from edited RGBA pixels.""" output_width, output_height = (int(value) for value in output_size) if output_width <= 0 or output_height <= 0: @@ -413,7 +413,7 @@ def create_pixel_result_image(source_image, pixels, output_size, suffix=""): if len(pixels) != output_width * output_height * 4: raise ValueError("The edited pixels do not match the image dimensions") image = bpy.data.images.new( - f"{image_base_name(source_image)}{suffix}", + source_image.name, width=output_width, height=output_height, alpha=True, @@ -525,11 +525,12 @@ def place_empty_image_result( -def load_color_result_image( +def _load_packed_result_image( source_object, output_directory, *, - frame_prefix="image.", + frame_prefix, + result_name, ): output_directory = Path(output_directory) color_paths = sorted_frame_paths(output_directory, frame_prefix) @@ -555,7 +556,6 @@ def load_color_result_image( raise RuntimeError("Unable to load the color result") from error try: - result_name = color_data_name(source_object) if animated: pack_sequence_if_possible(result_image, len(color_paths)) else: @@ -567,6 +567,36 @@ def load_color_result_image( return result_image +def load_image_edit_result( + source_object, + output_directory, + *, + frame_prefix="image.", +): + """Load one packed Job result that directly edits the source Image.""" + return _load_packed_result_image( + source_object, + output_directory, + frame_prefix=frame_prefix, + result_name=source_object.data.name, + ) + + +def load_color_result_image( + source_object, + output_directory, + *, + frame_prefix="image.", +): + """Load one packed Color texture result for a generated material.""" + return _load_packed_result_image( + source_object, + output_directory, + frame_prefix=frame_prefix, + result_name=color_data_name(source_object), + ) + + def image_empty_bounds(source_object): image_width, image_height = source_object.data.size if image_width <= 0 or image_height <= 0: diff --git a/src/anyimage/common/material.py b/src/anyimage/common/material.py index c883c5c..52e0c59 100644 --- a/src/anyimage/common/material.py +++ b/src/anyimage/common/material.py @@ -208,6 +208,10 @@ def create_image_material( material_inputs.outputs["Roughness"], shader.inputs["Roughness"], ) + links.new( + material_inputs.outputs["Color"], + shader.inputs["Specular IOR Level"], + ) links.new(shader.outputs["BSDF"], output.inputs["Surface"]) if displacement_image is not None: diff --git a/src/anyimage/common/selection.py b/src/anyimage/common/selection.py index 837ce9d..05507f2 100644 --- a/src/anyimage/common/selection.py +++ b/src/anyimage/common/selection.py @@ -3,6 +3,7 @@ import json import math from dataclasses import dataclass +from pathlib import Path class ImageEditWarning(ValueError): @@ -86,6 +87,29 @@ class SelectionMask: return result +def save_selection_mask(path, selection_mask): + """Save one local Selection Mask beside a prepared image input.""" + import numpy as np + + path = Path(path).parent / "selection-mask.npy" + np.save(path, selection_mask.values, allow_pickle=False) + return path + + +def load_selection_mask(path, bounds): + """Load a local Selection Mask sidecar without allowing object data.""" + import numpy as np + + values = np.load(Path(path), allow_pickle=False) + return SelectionMask(values, bounds) + + +def cleanup_selection_mask(path): + """Remove a local Selection Mask sidecar.""" + if path: + Path(path).unlink(missing_ok=True) + + def selection_path_bounds(points): import numpy as np diff --git a/src/anyimage/operators/convert_to_plane/object.py b/src/anyimage/operators/convert_to_plane/object.py index 16aa973..b85ba2f 100644 --- a/src/anyimage/operators/convert_to_plane/object.py +++ b/src/anyimage/operators/convert_to_plane/object.py @@ -89,7 +89,7 @@ def create_depth_plane_object( ("Subdivide", max(0, min(10, int(subdivisions)))), ("Thickness", 0.1), ("Depth Scale", 1.0), - ("Base Plane Depth", depth_metadata["reference_depth"] * uniform_scale), + ("Reference Depth", depth_metadata["reference_depth"] * uniform_scale), ("Uniform Scale", uniform_scale), ("Depth Image", depth_image), ): diff --git a/src/anyimage/operators/crop_tool/perspective.py b/src/anyimage/operators/crop_tool/perspective.py index 1f2462e..9cb2d5e 100644 --- a/src/anyimage/operators/crop_tool/perspective.py +++ b/src/anyimage/operators/crop_tool/perspective.py @@ -18,7 +18,7 @@ from ...common.viewport import ( ) from ...common.selection import crop_pixels_to_alpha from ...common.image import ( - create_pixel_result_image, + create_image_edit_result, image_base_name, image_pixels, image_empty_bounds, @@ -829,7 +829,7 @@ class CropImagePerspective(bpy.types.Operator): quad, self.aspect_ratio, ) - result_image = create_pixel_result_image( + result_image = create_image_edit_result( source_object.data, pixels, output_size, diff --git a/src/anyimage/operators/crop_tool/selection.py b/src/anyimage/operators/crop_tool/selection.py index 756b3c7..5966372 100644 --- a/src/anyimage/operators/crop_tool/selection.py +++ b/src/anyimage/operators/crop_tool/selection.py @@ -11,8 +11,7 @@ from ...common.ai import ( ) from ...common.image import ( cleanup_image_input, - create_pixel_result_image, - image_base_name, + create_image_edit_result, image_rgba, load_packed_rgba_image, place_empty_image_result, @@ -28,7 +27,10 @@ from ...common.viewport import ( ) from ...common.selection import ( ImageEditWarning, + cleanup_selection_mask, + load_selection_mask, rasterize_selection_path, + save_selection_mask, ) _HIDDEN = {"HIDDEN", "SKIP_SAVE"} @@ -64,6 +66,21 @@ def _edit_image_mask_pixels( ) +def _apply_local_selection_mask(source_rgba, selection_mask): + """Apply a same-sized Selection Mask without changing the image canvas.""" + import numpy as np + + rgba = np.asarray(source_rgba, dtype=np.float32).copy() + if rgba.ndim != 3 or rgba.shape[2] != 4: + raise ValueError("The Refine result must contain RGBA pixels") + if rgba.shape[:2] != selection_mask.values.shape: + raise ValueError("The Refine result does not match the Selection") + rgba[:, :, 3] *= selection_mask.values + if not np.any(rgba[:, :, 3] > 0.0): + raise ImageEditWarning("The edit contains no visible image pixels") + return np.flipud(rgba).ravel() + + def _edit_image_object( context, source_object, @@ -78,7 +95,7 @@ def _edit_image_object( selection_mask, ) left, top, right, bottom = crop_bounds - result_image = create_pixel_result_image( + result_image = create_image_edit_result( image, pixels, (right - left, bottom - top), @@ -145,13 +162,23 @@ class CropImageSelection(CropSelectionGesture, bpy.types.Operator): source_rgba, selection_mask.bounds, ) + try: + selection_mask_path = save_selection_mask( + source_path, + selection_mask, + ) + except Exception: + cleanup_image_input(source_path, True) + raise result = bpy.ops.anyimage.refine_crop_selection( input_path=str(source_path), + selection_mask_path=str(selection_mask_path), source_object_name=self.source_object_name, selection_bounds_json=json.dumps(selection_mask.bounds), keep_original=keep_original, ) if "CANCELLED" in result: + cleanup_selection_mask(selection_mask_path) cleanup_image_input(source_path, True) return {"CANCELLED"} return {"FINISHED"} @@ -175,6 +202,7 @@ class RefineCropSelection(JobOperatorBase, bpy.types.Operator): starting_message = "Refining image selection" input_path: bpy.props.StringProperty(options=_HIDDEN) + selection_mask_path: bpy.props.StringProperty(options=_HIDDEN) source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) selection_bounds_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) keep_original: bpy.props.BoolProperty(default=False, options=_HIDDEN) @@ -191,23 +219,23 @@ class RefineCropSelection(JobOperatorBase, bpy.types.Operator): def response(self, context, result): source_object = require_image_empty(self.source_object_name) - source_bounds = tuple(json.loads(self.selection_bounds_json)) - local_bounds = tuple( - json.loads(result.file("selection_bounds").read_text(encoding="utf-8")) - ) - bounds = ( - source_bounds[0] + local_bounds[0], - source_bounds[1] + local_bounds[1], - source_bounds[0] + local_bounds[2], - source_bounds[1] + local_bounds[3], - ) + bounds = tuple(json.loads(self.selection_bounds_json)) + selection_mask = load_selection_mask(self.selection_mask_path, bounds) source_image = source_object.data - refined_image = load_packed_rgba_image( + encoded_image = load_packed_rgba_image( result.file("selection_image"), source_image, - image_base_name(source_image), + "AnyImage Refine Result", ) + refined_image = None try: + rgba = image_rgba(encoded_image) + pixels = _apply_local_selection_mask(rgba, selection_mask) + refined_image = create_image_edit_result( + source_image, + pixels, + selection_mask.values.shape[::-1], + ) place_empty_image_result( context, source_object, @@ -216,14 +244,18 @@ class RefineCropSelection(JobOperatorBase, bpy.types.Operator): keep_original=bool(getattr(self, "keep_original", False)), ) except Exception: - if refined_image.users == 0: + if refined_image is not None and refined_image.users == 0: bpy.data.images.remove(refined_image, do_unlink=True) raise + finally: + if encoded_image.users == 0: + bpy.data.images.remove(encoded_image, do_unlink=True) if "FINISHED" not in bpy.ops.ed.undo_push(message="Crop Image Selection"): raise RuntimeError("Unable to create the Crop Image Selection Undo step") return "Crop Selection refined" def cleanup(self): + cleanup_selection_mask(getattr(self, "selection_mask_path", "")) cleanup_image_input(self.input_path, True) diff --git a/src/anyimage/operators/cutout_tool/geometry.py b/src/anyimage/operators/cutout_tool/geometry.py index de8f419..0952f52 100644 --- a/src/anyimage/operators/cutout_tool/geometry.py +++ b/src/anyimage/operators/cutout_tool/geometry.py @@ -4,7 +4,6 @@ import math from dataclasses import dataclass from ...preferences import addon_preferences -from ...server.media.resolution import limited_image_size from ...common.image import image_empty_bounds @@ -1342,10 +1341,12 @@ def crop_plane_bounds(source_object, crop_bounds, source_size): def extract_cutout_contours( content_values, - mesh_resolution, + point_spacing, threshold=CUTOUT_ALPHA_THRESHOLD, ): - contour_resolution = max(512, min(int(mesh_resolution) * 8, 1024)) + height, width = content_values.shape + sampling_resolution = max(width, height) / float(point_spacing) + contour_resolution = max(512, min(round(sampling_resolution * 8), 1024)) return extract_selection_contours( content_values, max_resolution=contour_resolution, @@ -1353,24 +1354,28 @@ def extract_cutout_contours( ) -def limit_cutout_mesh_resolution(mesh_resolution, crop_size, max_input_size): +def limit_cutout_point_spacing(point_spacing, crop_size, max_mesh_resolution): crop_width, crop_height = (int(value) for value in crop_size) - limited_width, limited_height = limited_image_size( - (crop_width, crop_height), - max_input_size, - ) - scale = max(limited_width, limited_height) / max(crop_width, crop_height) - return max(4, min(1024, round(int(mesh_resolution) * scale))) + spacing = float(point_spacing) + if crop_width <= 0 or crop_height <= 0: + raise ValueError("The Cutout bounds must be positive") + if spacing <= 0.0: + raise ValueError("Cutout point spacing must be greater than zero") + maximum = int(max_mesh_resolution) + if maximum <= 0: + raise ValueError("Maximum Cutout mesh resolution must be positive") + return max(spacing, max(crop_width, crop_height) / maximum) -def cutout_sampling_spacing(source_object, mesh_resolution): +def cutout_sampling_spacing(source_object, point_spacing): import math x_min, x_max, y_min, y_max = image_empty_bounds(source_object) metric, _world_z_scale = world_plane_metric(source_object.matrix_world) width = (x_max - x_min) * math.sqrt(max(metric[0][0], 0.0)) height = (y_max - y_min) * math.sqrt(max(metric[1][1], 0.0)) - return max(width, height) / max(int(mesh_resolution), 8) + image_width, image_height = (int(value) for value in source_object.data.size) + return max(width / image_width, height / image_height) * float(point_spacing) def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD): @@ -1397,7 +1402,7 @@ def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_T def build_base_shape( context, source_object, - mesh_resolution, + point_spacing, content_values, content_bounds, ): @@ -1410,7 +1415,7 @@ def build_base_shape( ) selection_contours = extract_cutout_contours( content_values, - mesh_resolution, + point_spacing, threshold=CUTOUT_ALPHA_THRESHOLD, ) world_metric, world_z_scale = world_plane_metric(source_object.matrix_world) @@ -1418,7 +1423,7 @@ def build_base_shape( raise ValueError("The Image Empty has a degenerate world transform") preferences = addon_preferences(context) balloon_algorithm = getattr(preferences, "balloon_profile_algorithm", "POISSON") - point_spacing = cutout_sampling_spacing(source_object, mesh_resolution) + point_spacing = cutout_sampling_spacing(source_object, point_spacing) domain = cutout_domain( selection_contours, bounds, diff --git a/src/anyimage/operators/cutout_tool/interaction.py b/src/anyimage/operators/cutout_tool/interaction.py index c265a8c..bb33454 100644 --- a/src/anyimage/operators/cutout_tool/interaction.py +++ b/src/anyimage/operators/cutout_tool/interaction.py @@ -1,9 +1,5 @@ """Cutout Selection validation and Shape-menu interaction helpers.""" -from ...preferences import ( - MAX_CUTOUT_MESH_RESOLUTION, - MIN_CUTOUT_MESH_RESOLUTION, -) from ...properties import ai_ready from .shape import CUTOUT_SHAPE_LABELS @@ -22,15 +18,6 @@ CUTOUT_MESH_DETAIL_SPACING = { } -def cutout_mesh_resolution(image_size, point_spacing): - longest_edge = max(int(value) for value in image_size) - detail = round(longest_edge / max(int(point_spacing), 1)) - return max( - MIN_CUTOUT_MESH_RESOLUTION, - min(MAX_CUTOUT_MESH_RESOLUTION, detail), - ) - - def cutout_mesh_detail_spacing(mesh_detail): return CUTOUT_MESH_DETAIL_SPACING[str(mesh_detail)] @@ -44,7 +31,7 @@ def _pie_button( selection_path_json, refine_selection, generate_normal, - mesh_resolution, + point_spacing, inward_axis, ): operator = pie.operator( @@ -57,7 +44,7 @@ def _pie_button( operator.shape = shape operator.refine_selection = refine_selection operator.generate_normal = generate_normal - operator.mesh_resolution = mesh_resolution + operator.point_spacing = point_spacing operator.inward_axis = inward_axis @@ -77,7 +64,6 @@ def open_shape_pie( generate_normal = ready and bool( getattr(settings, "cutout_generate_normal", False) ) - mesh_resolution = cutout_mesh_resolution(source_object.data.size, point_spacing) inward_axis = str(getattr(settings, "cutout_inward_axis", "NEGATIVE_Z")) def draw(menu, _context): @@ -93,7 +79,7 @@ def open_shape_pie( selection_path_json, refine_selection, generate_normal, - mesh_resolution, + point_spacing, inward_axis, ) diff --git a/src/anyimage/operators/cutout_tool/main.py b/src/anyimage/operators/cutout_tool/main.py index 2a86db1..acd06e8 100644 --- a/src/anyimage/operators/cutout_tool/main.py +++ b/src/anyimage/operators/cutout_tool/main.py @@ -5,8 +5,6 @@ import json import bpy from ...preferences import ( - MAX_CUTOUT_MESH_RESOLUTION, - MIN_CUTOUT_MESH_RESOLUTION, addon_preferences, configured_max_ai_input_size, configured_max_cutout_mesh_resolution, @@ -60,7 +58,7 @@ from ...common.selection import ( from .geometry import ( CUTOUT_ALPHA_THRESHOLD, build_base_shape, - limit_cutout_mesh_resolution, + limit_cutout_point_spacing, ) from .interaction import open_shape_pie from .object import ( @@ -95,7 +93,7 @@ def create_cutout_shape( context, source_object, shape, - mesh_resolution, + point_spacing, content_values, content_bounds, *, @@ -110,7 +108,7 @@ def create_cutout_shape( base_shape = build_base_shape( context, source_object, - mesh_resolution, + point_spacing, content_values, content_bounds, ) @@ -118,7 +116,7 @@ def create_cutout_shape( context, source_object, shape, - mesh_resolution, + point_spacing, content_values, content_bounds, base_shape, @@ -164,21 +162,8 @@ def _generated_color_result(result, input_path, source_object, source_bounds): value = result.value if isinstance(result.value, dict) else {} if "selection_image" not in value: path = input_path - bounds = source_bounds else: path = result.file("selection_image") - local_bounds = tuple( - int(value) - for value in json.loads( - result.file("selection_bounds").read_text(encoding="utf-8") - ) - ) - bounds = ( - source_bounds[0] + local_bounds[0], - source_bounds[1] + local_bounds[1], - source_bounds[0] + local_bounds[2], - source_bounds[1] + local_bounds[3], - ) image = load_packed_rgba_image( path, source_object.data, @@ -190,7 +175,7 @@ def _generated_color_result(result, input_path, source_object, source_bounds): if image.users == 0: bpy.data.images.remove(image, do_unlink=True) raise - return image, alpha, bounds + return image, alpha, source_bounds def cutout_geometry_settings(context): @@ -215,10 +200,9 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): selection_path_json: bpy.props.StringProperty(options=_HIDDEN) selection_bounds_json: bpy.props.StringProperty(options=_HIDDEN) shape: bpy.props.StringProperty(options=_HIDDEN) - mesh_resolution: bpy.props.IntProperty( - default=128, - min=MIN_CUTOUT_MESH_RESOLUTION, - max=MAX_CUTOUT_MESH_RESOLUTION, + point_spacing: bpy.props.FloatProperty( + default=32.0, + min=0.01, options=_HIDDEN, ) model: bpy.props.StringProperty( @@ -274,8 +258,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): source_object.select_set(True) crop_width = selection_mask.bounds[2] - selection_mask.bounds[0] crop_height = selection_mask.bounds[3] - selection_mask.bounds[1] - self.mesh_resolution = limit_cutout_mesh_resolution( - self.mesh_resolution, + self.point_spacing = limit_cutout_point_spacing( + self.point_spacing, (crop_width, crop_height), configured_max_cutout_mesh_resolution(context), ) @@ -295,7 +279,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): context, source_object, self.shape, - self.mesh_resolution, + self.point_spacing, content_values, selection_mask.bounds, inward_axis=self.inward_axis, @@ -387,20 +371,14 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): depth_image = None shape_started = False try: - if "selection_image" in value: - content_values = _cutout_content_values( - source_alpha, - ) - content_bounds = color_bounds - else: - selection_mask = getattr(self, "_selection_mask", None) - if selection_mask is None: - raise RuntimeError("The Cutout Selection is no longer available") - content_values = _cutout_content_values( - source_alpha, - selection_mask.values, - ) - content_bounds = selection_mask.bounds + selection_mask = getattr(self, "_selection_mask", None) + if selection_mask is None: + raise RuntimeError("The Cutout Selection is no longer available") + content_values = _cutout_content_values( + source_alpha, + selection_mask.values, + ) + content_bounds = color_bounds normal_mode = normal_mode_for_shape(self.shape, self.generate_normal) normal_key = f"{normal_mode.value.lower()}_normal" if normal_key in value: @@ -424,7 +402,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): context, source_object, self.shape, - self.mesh_resolution, + self.point_spacing, content_values, content_bounds, depth_image=depth_image, diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index 4e884cd..7e530f6 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -156,7 +156,7 @@ def create_shape_object( context, source_object, shape, - mesh_resolution, + point_spacing, content_values, content_bounds, base_shape, @@ -183,7 +183,7 @@ def create_shape_object( ) world_metric, _world_z_scale = world_plane_metric(source_object.matrix_world) cleanup_value = cutout_cleanup_value( - cutout_sampling_spacing(source_object, mesh_resolution), + cutout_sampling_spacing(source_object, point_spacing), world_metric, ) material = None @@ -293,7 +293,7 @@ def create_shape_object( for name, value in ( ("Depth Scale", 1.0), ("Uniform Scale", uniform_scale), - ("Base Plane Depth", depth_reference), + ("Reference Depth", depth_reference), ("Depth Image", depth_image), ): set_modifier_input( diff --git a/src/anyimage/operators/remove_background.py b/src/anyimage/operators/remove_background.py index 4c0cad7..e13fc24 100644 --- a/src/anyimage/operators/remove_background.py +++ b/src/anyimage/operators/remove_background.py @@ -71,13 +71,13 @@ class RemoveImageBackground(bpy.types.Operator): def apply_background_response(result, source_object_name): from ..common.image import ( - load_color_result_image, + load_image_edit_result, replace_empty_image, require_image_empty, ) source_object = require_image_empty(source_object_name) - result_image = load_color_result_image(source_object, result.directory) + result_image = load_image_edit_result(source_object, result.directory) replace_empty_image(source_object, result_image) if "FINISHED" not in bpy.ops.ed.undo_push(message="Remove Background"): raise RuntimeError("Unable to create the Remove Background Undo step") diff --git a/src/anyimage/operators/upscale.py b/src/anyimage/operators/upscale.py index 780a602..2289424 100644 --- a/src/anyimage/operators/upscale.py +++ b/src/anyimage/operators/upscale.py @@ -19,7 +19,7 @@ from ..common.image import ( cleanup_image_input, default_extract_frames, is_image_empty, - load_color_result_image, + load_image_edit_result, prepare_image_input, source_start_frame, replace_empty_image, @@ -148,7 +148,7 @@ class UpscaleImage(JobOperatorBase, bpy.types.Operator): source_image = bpy.data.images.get(self.source_image_name) if source_image is None: raise RuntimeError("The source image is no longer available") - result_image = load_color_result_image( + result_image = load_image_edit_result( source_object, result.directory, frame_prefix="upscale.", diff --git a/src/anyimage/server/jobs/cutout.py b/src/anyimage/server/jobs/cutout.py index c73e7f2..a4890be 100644 --- a/src/anyimage/server/jobs/cutout.py +++ b/src/anyimage/server/jobs/cutout.py @@ -1,6 +1,5 @@ """Generate optional AI files from one rectangular Cutout image input.""" -import json from pathlib import Path from ..geometry.prediction_artifacts import generate_moge2_artifacts @@ -15,22 +14,14 @@ def run(context, parameters): result = {} if parameters.get("refine_selection"): context.progress(0.2, "Refining Selection") - selection_image_path, bounds = refine_selection( + selection_image_path = refine_selection( context, input_path, parameters["refine_model"], parameters["device"], context.directory / "selection.png", ) - bounds_path = context.directory / "selection_bounds.json" - bounds_path.write_text( - json.dumps(bounds, separators=(",", ":")), - encoding="utf-8", - ) - result.update( - selection_image=selection_image_path.name, - selection_bounds=bounds_path.name, - ) + result["selection_image"] = selection_image_path.name depth_mode = str(parameters.get("depth_mode", "NONE")) normal_mode = str(parameters.get("normal_mode", "NONE")) diff --git a/src/anyimage/server/jobs/refine_image_selection.py b/src/anyimage/server/jobs/refine_image_selection.py index 514f9b1..7271cfd 100644 --- a/src/anyimage/server/jobs/refine_image_selection.py +++ b/src/anyimage/server/jobs/refine_image_selection.py @@ -1,27 +1,24 @@ -import json import time from ..selection import refine_selection def run(context, parameters): - """Refine a gesture mask with BEN2 and return its source-image bounds.""" + """Refine foreground Alpha while preserving the submitted bounds canvas.""" started = time.perf_counter() context.progress(0.05, "Preparing image Selection") context.check_cancelled() output_directory = context.directory output_directory.mkdir(parents=True, exist_ok=True) context.progress(0.2, f"Loading BEN2 for {parameters['device'].upper()}") - output_path, bounds = refine_selection( + output_path = refine_selection( context, parameters["input"], parameters["model"], parameters["device"], output_directory / "image.png", ) - bounds_path = output_directory / "bounds.json" - bounds_path.write_text(json.dumps(bounds, separators=(",", ":")), encoding="utf-8") elapsed_ms = (time.perf_counter() - started) * 1000.0 message = f"Image Selection refined in {elapsed_ms:.0f} ms" context.progress(1.0, message) - return {"selection_image": output_path.name, "selection_bounds": bounds_path.name} + return {"selection_image": output_path.name} diff --git a/src/anyimage/server/selection/refine_selection.py b/src/anyimage/server/selection/refine_selection.py index 4eaea55..cfcc0dd 100644 --- a/src/anyimage/server/selection/refine_selection.py +++ b/src/anyimage/server/selection/refine_selection.py @@ -1,24 +1,6 @@ from ..models.ben2 import infer_foreground -def _crop_to_alpha(image): - import numpy as np - - pixels = np.asarray(image.convert("RGBA"), dtype=np.uint8) - visible = pixels[:, :, 3] > 0 - visible_y = np.flatnonzero(np.any(visible, axis=1)) - visible_x = np.flatnonzero(np.any(visible, axis=0)) - if not len(visible_x): - raise ValueError("The generated Selection contains no visible pixels") - bounds = ( - int(visible_x[0]), - int(visible_y[0]), - int(visible_x[-1]) + 1, - int(visible_y[-1]) + 1, - ) - return image.crop(bounds), bounds - - def refine_selection( context, input_path, @@ -37,6 +19,5 @@ def refine_selection( preserve_input_alpha=True, ) context.check_cancelled() - output, bounds = _crop_to_alpha(foreground) - output.save(output_path, format="PNG", compress_level=1) - return output_path, bounds + foreground.convert("RGBA").save(output_path, format="PNG", compress_level=1) + return output_path diff --git a/tests/test_blender_addon.py b/tests/test_blender_addon.py index 86c4e0d..5e54c59 100644 --- a/tests/test_blender_addon.py +++ b/tests/test_blender_addon.py @@ -1242,7 +1242,7 @@ class BlenderAddonTest(unittest.TestCase): } ), shape=shape, - mesh_resolution=64, + point_spacing=32, generate_normal=False, refine_selection=False, report=lambda *_args: None, @@ -1300,7 +1300,7 @@ class BlenderAddonTest(unittest.TestCase): context, source, shape, - 64, + 32.0, selection_mask.values, selection_mask.bounds, inward_axis="NEGATIVE_Z", @@ -1331,7 +1331,7 @@ class BlenderAddonTest(unittest.TestCase): } ) operator.shape = "DEPTH_SURFACE" - operator.mesh_resolution = 64 + operator.point_spacing = 32 operator.generate_normal = False operator.refine_selection = False operator.report = lambda *_args: None @@ -1413,7 +1413,7 @@ class BlenderAddonTest(unittest.TestCase): } ), shape="SURFACE", - mesh_resolution=64, + point_spacing=32, generate_normal=False, refine_selection=False, report=lambda level, message: reports.append((level, message)), @@ -1721,6 +1721,78 @@ class BlenderAddonTest(unittest.TestCase): self.assertIs(result, expected) self.assertNotIn("REGISTER", operator.bl_options) + def test_remove_background_loads_a_plain_image_edit_result(self): + source = SimpleNamespace() + result_image = SimpleNamespace() + result = SimpleNamespace(directory=Path("result")) + ops = SimpleNamespace( + ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"})) + ) + with ( + patch.object(self.image_data, "require_image_empty", return_value=source), + patch.object( + self.image_data, + "load_image_edit_result", + return_value=result_image, + ) as load, + patch.object(self.image_data, "replace_empty_image") as replace, + patch.object(self.remove_background.bpy, "ops", ops, create=True), + ): + self.remove_background.apply_background_response(result, "Source") + + load.assert_called_once_with(source, Path("result")) + replace.assert_called_once_with(source, result_image) + + def test_upscale_loads_a_plain_image_edit_result(self): + source_image = SimpleNamespace() + source = SimpleNamespace(data=source_image) + result_image = SimpleNamespace() + result = SimpleNamespace(directory=Path("result")) + operator = self.upscale.UpscaleImage() + operator.source_object_name = "Source" + operator.source_image_name = "Poster.png" + operator.model = "SAFMN_REAL_X4" + images = SimpleNamespace(get=lambda name: source_image if name == "Poster.png" else None) + with ( + patch.object(self.upscale, "require_image_empty", return_value=source), + patch.object( + self.upscale, + "load_image_edit_result", + return_value=result_image, + ) as load, + patch.object(self.upscale, "replace_empty_image") as replace, + patch.object( + self.upscale, + "upscale_model_label", + return_value="SAFMN Real x4", + ), + patch.object( + self.upscale.bpy, + "data", + SimpleNamespace(images=images), + ), + ): + message = operator.response(None, result) + + load.assert_called_once_with( + source, + Path("result"), + frame_prefix="upscale.", + ) + replace.assert_called_once_with(source, result_image) + self.assertEqual(message, "Image is upscaled with SAFMN Real x4") + + def test_crop_tools_share_the_plain_pixel_edit_result_creator(self): + self.assertIs( + self.edit_mask.create_image_edit_result, + self.image_data.create_image_edit_result, + ) + self.assertIs( + self.image_perspective.create_image_edit_result, + self.image_data.create_image_edit_result, + ) + self.assertFalse(hasattr(self.image_data, "create_pixel_result_image")) + def test_packed_image_export_does_not_use_scene_render_transform(self): with tempfile.TemporaryDirectory() as directory: output_directory = Path(directory) @@ -2681,7 +2753,7 @@ class BlenderAddonTest(unittest.TestCase): self.assertTrue(np.all(rgba[:, :, 3] == 1.0)) self.assertTrue(np.all(rgba[:, :, 0] == np.arange(2, 7)[None, :])) - def test_refined_image_edit_writes_the_selection_bounds_image(self): + def test_refined_image_edit_writes_one_local_selection_mask(self): import numpy as np source = SimpleNamespace(data=SimpleNamespace(size=(8, 6))) @@ -2725,6 +2797,11 @@ class BlenderAddonTest(unittest.TestCase): "prepare_image_bounds_input", return_value=Path(__file__), ) as prepare_input, + patch.object( + self.edit_mask, + "save_selection_mask", + return_value=Path("selection-mask.npy"), + ) as save_mask, patch.object(self.edit_mask.bpy, "ops", ops, create=True), ): result = self.edit_mask.CropImageSelection._submit_selection_path( @@ -2741,29 +2818,55 @@ class BlenderAddonTest(unittest.TestCase): source_rgba, selection_mask.bounds, ) - refine.assert_called_once() + save_mask.assert_called_once_with(Path(__file__), selection_mask) + self.assertEqual( + refine.call_args.kwargs["selection_mask_path"], + "selection-mask.npy", + ) + + def test_refined_image_request_excludes_the_local_selection_mask(self): + operator = self.edit_mask.RefineCropSelection() + operator.input_path = __file__ + operator.selection_mask_path = "selection-mask.npy" + with ( + patch.object(self.edit_mask, "require_environment"), + patch.object(self.edit_mask, "require_model"), + patch.object(self.edit_mask, "production_device", return_value="CPU"), + ): + parameters = operator.request(None) + + self.assertEqual(parameters["input"], __file__) + self.assertNotIn("selection_mask_path", parameters) + + def test_refined_image_response_applies_the_saved_mask_on_original_bounds(self): + import numpy as np - def test_refined_image_response_uses_the_encoded_result_directly(self): source_image = SimpleNamespace( name="Poster.png", colorspace_settings=SimpleNamespace(name="sRGB"), alpha_mode="STRAIGHT", ) source = SimpleNamespace(data=source_image) - refined = SimpleNamespace(users=0) - files = { - "selection_bounds": SimpleNamespace( - read_text=lambda encoding: "[2,1,5,4]" - ), - "selection_image": Path(__file__), - } + encoded = SimpleNamespace(users=0) + refined = SimpleNamespace(users=1) + files = {"selection_image": Path(__file__)} result = SimpleNamespace(file=lambda key: files[key]) operator = self.edit_mask.RefineCropSelection() operator.source_object_name = "Source" operator.selection_bounds_json = "[10,20,18,26]" + operator.selection_mask_path = "selection-mask.npy" + operator.keep_original = True + values = np.ones((6, 8), dtype=np.float32) + values[:, 0] = 0.0 + selection_mask = self.image_selection.SelectionMask( + values, + (10, 20, 18, 26), + ) + rgba = np.ones((6, 8, 4), dtype=np.float32) ops = SimpleNamespace( ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"})) ) + remove = Mock() with ( patch.object( @@ -2774,14 +2877,30 @@ class BlenderAddonTest(unittest.TestCase): patch.object( self.edit_mask, "load_packed_rgba_image", - return_value=refined, + return_value=encoded, ) as load_result, - patch.object(self.edit_mask, "place_empty_image_result") as place, - patch.object(self.edit_mask, "image_rgba") as read_rgba, patch.object( self.edit_mask, - "create_pixel_result_image", + "load_selection_mask", + return_value=selection_mask, + ) as load_mask, + patch.object(self.edit_mask, "place_empty_image_result") as place, + patch.object( + self.edit_mask, + "image_rgba", + return_value=rgba, + ) as read_rgba, + patch.object( + self.edit_mask, + "create_image_edit_result", + return_value=refined, ) as create_pixels, + patch.object( + self.edit_mask.bpy.data, + "images", + SimpleNamespace(remove=remove), + create=True, + ), patch.object(self.edit_mask.bpy, "ops", ops, create=True), ): message = operator.response(None, result) @@ -2790,17 +2909,38 @@ class BlenderAddonTest(unittest.TestCase): load_result.assert_called_once_with( Path(__file__), source_image, - "Poster", + "AnyImage Refine Result", ) + load_mask.assert_called_once_with("selection-mask.npy", (10, 20, 18, 26)) + read_rgba.assert_called_once_with(encoded) + pixels = create_pixels.call_args.args[1] + masked = np.flipud(pixels.reshape((6, 8, 4))) + np.testing.assert_array_equal(masked[:, 0, 3], 0.0) + np.testing.assert_array_equal(masked[:, 1:, 3], 1.0) place.assert_called_once_with( None, source, refined, - placement_bounds=(12, 21, 15, 24), - keep_original=False, + placement_bounds=(10, 20, 18, 26), + keep_original=True, ) - read_rgba.assert_not_called() - create_pixels.assert_not_called() + 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, + ) def test_cutout_ai_response_reuses_color_image_and_keeps_lasso_for_no_refine(self): import numpy as np @@ -2818,7 +2958,7 @@ class BlenderAddonTest(unittest.TestCase): operator.selection_bounds_json = "[10,20,14,23]" operator.input_path = __file__ operator.shape = "SURFACE" - operator.mesh_resolution = 32 + operator.point_spacing = 32 operator.generate_normal = True operator.refine_selection = False operator._selection_mask = selection_mask @@ -2849,7 +2989,7 @@ class BlenderAddonTest(unittest.TestCase): self.assertEqual(create_shape.call_args.args[5], selection_mask.bounds) self.assertIs(create_shape.call_args.kwargs["color_image"], color_image) - def test_cutout_refine_keeps_ben2_content_outside_the_lasso(self): + def test_cutout_refine_keeps_textures_but_masks_the_mesh(self): import numpy as np source = SimpleNamespace(data=SimpleNamespace()) @@ -2859,11 +2999,13 @@ class BlenderAddonTest(unittest.TestCase): operator.selection_bounds_json = "[10,20,16,24]" operator.input_path = __file__ operator.shape = "SURFACE" - operator.mesh_resolution = 32 + operator.point_spacing = 32 operator.generate_normal = False operator.refine_selection = True + selection_values = np.ones((4, 6), dtype=np.float32) + selection_values[:, 0] = 0.0 operator._selection_mask = self.image_selection.SelectionMask( - np.zeros((4, 6), dtype=np.float32), + selection_values, (10, 20, 16, 24), ) result = SimpleNamespace( @@ -2882,8 +3024,8 @@ class BlenderAddonTest(unittest.TestCase): "_generated_color_result", return_value=( color_image, - np.ones((2, 3), dtype=np.float32), - (12, 21, 15, 23), + np.ones((4, 6), dtype=np.float32), + (10, 20, 16, 24), ), ), patch.object(self.cutout_main, "create_cutout_shape") as create_shape, @@ -2893,9 +3035,9 @@ class BlenderAddonTest(unittest.TestCase): content_values = create_shape.call_args.args[4] np.testing.assert_array_equal( content_values, - np.ones((2, 3), dtype=np.float32), + selection_values, ) - self.assertEqual(create_shape.call_args.args[5], (12, 21, 15, 23)) + self.assertEqual(create_shape.call_args.args[5], (10, 20, 16, 24)) self.assertIs(create_shape.call_args.kwargs["color_image"], color_image) def test_image_selection_uses_white_dashed_segments(self): diff --git a/tests/test_cutout_geometry.py b/tests/test_cutout_geometry.py index 3089185..9c369ff 100644 --- a/tests/test_cutout_geometry.py +++ b/tests/test_cutout_geometry.py @@ -34,10 +34,10 @@ from anyimage.server.models import da3 as da3_model from anyimage.server.models.geometry import GeometryFrame -def test_cutout_mesh_detail_uses_the_cropped_analysis_scale(): - assert cutout_geometry.limit_cutout_mesh_resolution(256, (4096, 1024), 2048) == 128 - assert cutout_geometry.limit_cutout_mesh_resolution(256, (1024, 512), 2048) == 256 - assert cutout_geometry.limit_cutout_mesh_resolution(8, (8192, 4096), 2048) == 4 +def test_cutout_mesh_limit_caps_actual_sampling_resolution(): + assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 1024) == 32 + assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 64) == 64 + assert cutout_geometry.limit_cutout_point_spacing(4, (8192, 4096), 1024) == 8 def test_depth_metadata_loader_uses_one_schema_for_plane_and_cutout(tmp_path): @@ -378,17 +378,20 @@ def test_blender_cutout_contour_resolution_tracks_mesh_detail(): components=(((((0, 0), (1, 0), (0, 1))),),), image_size=(1, 1), ) - selection_mask = SelectionMask(np.ones((2, 2), dtype=np.float32), (0, 0, 2, 2)) + values = SimpleNamespace(shape=(1024, 1024)) with patch.object(cutout_geometry, "extract_selection_contours", return_value=expected) as trace: - assert cutout_geometry.extract_cutout_contours(selection_mask.values, 32) == expected - assert cutout_geometry.extract_cutout_contours(selection_mask.values, 256) == expected + assert cutout_geometry.extract_cutout_contours(values, 32) == expected + assert cutout_geometry.extract_cutout_contours(values, 4) == expected assert trace.call_args_list[0].kwargs["max_resolution"] == 512 assert trace.call_args_list[1].kwargs["max_resolution"] == 1024 def test_cutout_sampling_spacing_uses_the_complete_source_plane(): - source = SimpleNamespace(matrix_world=object()) + source = SimpleNamespace( + data=SimpleNamespace(size=(400, 200)), + matrix_world=object(), + ) with ( patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)), patch.object( @@ -397,9 +400,32 @@ def test_cutout_sampling_spacing_uses_the_complete_source_plane(): return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0), ), ): - spacing = cutout_geometry.cutout_sampling_spacing(source, 200) + spacing = cutout_geometry.cutout_sampling_spacing(source, 32) - assert spacing == 0.02 + assert spacing == 0.32 + + +def test_cutout_sampling_spacing_tracks_upscaled_source_pixels(): + original = SimpleNamespace( + data=SimpleNamespace(size=(2048, 1024)), + matrix_world=object(), + ) + upscaled = SimpleNamespace( + data=SimpleNamespace(size=(8192, 4096)), + matrix_world=object(), + ) + with ( + patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)), + patch.object( + cutout_geometry, + "world_plane_metric", + return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0), + ), + ): + original_spacing = cutout_geometry.cutout_sampling_spacing(original, 32) + upscaled_spacing = cutout_geometry.cutout_sampling_spacing(upscaled, 32) + + assert upscaled_spacing == original_spacing / 4 def test_cutout_cleanup_value_scales_the_ui_without_changing_node_area(): diff --git a/tests/test_cutout_interaction.py b/tests/test_cutout_interaction.py index 51c2c60..4b9b673 100644 --- a/tests/test_cutout_interaction.py +++ b/tests/test_cutout_interaction.py @@ -81,7 +81,7 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch): ) assert not any(call[2].refine_selection for call in window_manager.pie.calls) assert not any(call[2].generate_normal for call in window_manager.pie.calls) - assert all(call[2].mesh_resolution == 64 for call in window_manager.pie.calls) + assert all(call[2].point_spacing == 32 for call in window_manager.pie.calls) assert all(call[2].inward_axis == "NEGATIVE_Z" for call in window_manager.pie.calls) @@ -134,7 +134,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): SelectionPath.from_json(call[2].selection_path_json).invert for call in window_manager.pie.calls ) - assert all(call[2].mesh_resolution == 256 for call in window_manager.pie.calls) + assert all(call[2].point_spacing == 16 for call in window_manager.pie.calls) assert all(call[2].inward_axis == "POSITIVE_X" for call in window_manager.pie.calls) @@ -146,12 +146,6 @@ def test_cutout_normal_space_is_derived_from_shape(): assert cutout_main.normal_mode_for_shape("DEPTH_SURFACE", True) == "OBJECT" -def test_cutout_mesh_detail_tracks_the_source_long_edge_and_clamps(): - assert interaction.cutout_mesh_resolution((4096, 2048), 32) == 128 - assert interaction.cutout_mesh_resolution((16, 8), 4) == 4 - assert interaction.cutout_mesh_resolution((65536, 32768), 4) == 1024 - - def test_cutout_mesh_detail_maps_to_denser_sampling(): assert [ interaction.cutout_mesh_detail_spacing(detail) @@ -207,7 +201,7 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa (65536, 32768), (0, 32768), ) - assert window_manager.pie.calls[0][2].mesh_resolution == 1024 + assert window_manager.pie.calls[0][2].point_spacing == 32 assert not hasattr(cutout_main, "pending_selection") diff --git a/tests/test_cutout_objects.py b/tests/test_cutout_objects.py index dd8caf7..3d22441 100644 --- a/tests/test_cutout_objects.py +++ b/tests/test_cutout_objects.py @@ -157,6 +157,51 @@ def test_keep_original_places_and_activates_result_copy(): assert bpy.context.view_layer.objects.active == placed +def test_image_edit_result_uses_complete_shared_source_name(): + bpy.ops.wm.read_factory_settings(use_empty=True) + source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True) + source = bpy.data.objects.new("Poster", None) + source.empty_display_type = "IMAGE" + source.data = source_image + shared = bpy.data.objects.new("Poster Shared", None) + shared.empty_display_type = "IMAGE" + shared.data = source_image + bpy.context.collection.objects.link(source) + bpy.context.collection.objects.link(shared) + + result_image = image_data.create_image_edit_result( + source_image, + np.zeros(4 * 4 * 4, dtype=np.float32), + (4, 4), + ) + image_data.replace_empty_image(source, result_image) + + assert shared.data == source_image + assert source.data == result_image + assert result_image.name == "Poster.png.001" + assert "_color" not in result_image.name + + +def test_image_edit_result_recovers_unshared_source_name(): + bpy.ops.wm.read_factory_settings(use_empty=True) + source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True) + source = bpy.data.objects.new("Poster", None) + source.empty_display_type = "IMAGE" + source.data = source_image + bpy.context.collection.objects.link(source) + + result_image = image_data.create_image_edit_result( + source_image, + np.zeros(4 * 4 * 4, dtype=np.float32), + (4, 4), + ) + image_data.replace_empty_image(source, result_image) + + assert source.data == result_image + assert result_image.name == "Poster.png" + assert bpy.data.images.get("Poster.png") == result_image + + def test_object_normal_image_follows_cutout_inward_axis(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new( @@ -292,7 +337,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): "Subdivide", "Thickness", "Depth Scale", - "Base Plane Depth", + "Reference Depth", "Uniform Scale", "Depth Image", ] @@ -306,7 +351,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): "Smooth Iterations", "Cleanup", "Smooth", - "Base Plane Depth", + "Reference Depth", "Depth Direction", "Uniform Scale", "Depth Image", @@ -316,7 +361,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): assert [item.name for item in panels[0].interface_items] == [ "Cleanup", "Smooth", - "Base Plane Depth", + "Reference Depth", "Depth Direction", ] assert [item.name for item in panels[1].interface_items] == [ @@ -375,7 +420,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): "Thickness", "Depth Scale", "Double Sided", - "Base Plane Depth", + "Reference Depth", "Depth Direction", "Uniform Scale", "Depth Image", @@ -384,7 +429,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): "Geometry", "Thickness", "Depth Scale", - "Base Plane Depth", + "Reference Depth", "Uniform Scale", "Depth Image", ] @@ -405,7 +450,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): "Thickness": set(cutout_shape.CUTOUT_SHAPES), "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Double Sided": {"DEPTH_BALLOON"}, - "Base Plane Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Direction": {"DEPTH_BALLOON"}, "Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, @@ -740,6 +785,50 @@ def test_cutout_material_uses_ior_1_2(): if node.bl_idname == "ShaderNodeBsdfPrincipled" ) assert np.isclose(shader.inputs["IOR"].default_value, 1.2) + image_layer = _group_node( + material_data.material_node_group(), + result.data.materials[0].node_tree, + ) + ior_level_link = next( + link + for link in result.data.materials[0].node_tree.links + if link.to_node == shader + and link.to_socket == shader.inputs["Specular IOR Level"] + ) + assert ior_level_link.from_node == image_layer + assert ior_level_link.from_socket == image_layer.outputs["Color"] + + +def test_plane_material_color_drives_ior_level_but_shadeless_does_not(): + bpy.ops.wm.read_factory_settings(use_empty=True) + image = bpy.data.images.new("Plane", width=2, height=2, alpha=True) + source = SimpleNamespace(data=SimpleNamespace(name="Plane")) + + material = material_data.create_image_material(source, image, 0.0) + shader = next( + node + for node in material.node_tree.nodes + if node.bl_idname == "ShaderNodeBsdfPrincipled" + ) + image_layer = _group_node(material_data.material_node_group(), material.node_tree) + assert any( + link.from_node == image_layer + and link.from_socket == image_layer.outputs["Color"] + and link.to_node == shader + and link.to_socket == shader.inputs["Specular IOR Level"] + for link in material.node_tree.links + ) + + shadeless = material_data.create_image_material( + source, + image, + 0.0, + shadeless=True, + ) + assert not any( + node.bl_idname == "ShaderNodeBsdfPrincipled" + for node in shadeless.node_tree.nodes + ) def test_cutout_material_uses_flat_tangent_normal(): bpy.ops.wm.read_factory_settings(use_empty=True) @@ -820,7 +909,7 @@ def test_depth_surface_protected_smooth_evaluates(): ) reference_id = object_data.modifier_input_identifier( modifier.node_group, - "Base Plane Depth", + "Reference Depth", ) assert modifier[reference_id] == 8.0 object_data.set_modifier_input( @@ -1013,7 +1102,7 @@ def test_converted_planes_center_the_origin_without_moving_world_corners(): depth_modifier[ object_data.modifier_input_identifier( depth_modifier.node_group, - "Base Plane Depth", + "Reference Depth", ) ], uniform_scale, @@ -1078,7 +1167,7 @@ def test_depth_plane_builds_a_fixed_base_terrain_block(): evaluated.to_mesh_clear() set_input("Depth Scale", 1.0) - set_input("Base Plane Depth", 1.2) + set_input("Reference Depth", 1.2) outward = _evaluated_positions(result) assert np.isclose(outward[:, 2].min(), 0.0, atol=1e-6) assert np.isclose(outward[:, 2].max(), 0.3, atol=1e-5) @@ -1092,23 +1181,23 @@ def test_depth_plane_builds_a_fixed_base_terrain_block(): assert len(z_levels) >= 4 assert np.allclose(np.diff(z_levels), np.diff(z_levels)[0], atol=1e-5) - set_input("Base Plane Depth", 0.95) + set_input("Reference Depth", 0.95) inward = _evaluated_positions(result) assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6) assert np.isclose(inward[:, 2].max(), 0.05, atol=1e-5) - set_input("Base Plane Depth", 0.0) + set_input("Reference Depth", 0.0) limited = _evaluated_positions(result) assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6) assert limited[:, 2].max() > 0.0 assert limited[:, 2].max() < 0.001 - set_input("Base Plane Depth", 1.3) + set_input("Reference Depth", 1.3) outward_beyond_thickness = _evaluated_positions(result) assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5) set_input("Thickness", 0.0) - set_input("Base Plane Depth", 1.2) + set_input("Reference Depth", 1.2) sheet = _evaluated_positions(result) assert np.allclose(sheet[:, 2], 0.2, atol=1e-5) @@ -1401,7 +1490,7 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): depth_reference_id = object_data.modifier_input_identifier( modifier.node_group, - "Base Plane Depth", + "Reference Depth", ) assert not np.isclose(modifier[depth_reference_id], 8.0) depth_direction_id = object_data.modifier_input_identifier( diff --git a/tests/test_selection.py b/tests/test_selection.py index b35ada9..81054f7 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -78,7 +78,6 @@ def test_selection_path_json_contains_only_points_and_invert(): points=((1, 2), (8, 2), (4, 9)), invert=True, ) - value = selection_path.to_json() assert set(json.loads(value)) == {"points", "invert"} @@ -95,6 +94,24 @@ def test_selection_path_json_contains_only_points_and_invert(): ) +def test_selection_mask_sidecar_round_trip_and_cleanup(tmp_path): + original = selection.SelectionMask( + np.asarray(((0.0, 0.25), (0.5, 1.0)), dtype=np.float32), + (3, 4, 5, 6), + ) + input_path = tmp_path / "input.png" + input_path.write_bytes(b"input") + + path = selection.save_selection_mask(input_path, original) + loaded = selection.load_selection_mask(path, original.bounds) + + assert path.parent == input_path.parent + np.testing.assert_array_equal(loaded.values, original.values) + assert loaded.bounds == original.bounds + selection.cleanup_selection_mask(path) + assert not path.exists() + + def test_nonzero_winding_keeps_same_direction_overlap(): points = ( (1, 1), diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 7cf3613..73066cb 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -700,6 +700,46 @@ class ServerRuntimeTest(unittest.TestCase): self.assertTrue(all(path.is_file() for path in result.values())) self.assertTrue(infer.call_args.kwargs["include_points"]) + def test_refine_selection_preserves_the_full_alpha_canvas(self): + import numpy as np + from PIL import Image + + refine = importlib.import_module("server.selection.refine_selection") + input_path = self.storage_root / "input.png" + output_path = self.storage_root / "output.png" + source = np.zeros((3, 4, 4), dtype=np.uint8) + source[..., :3] = (32, 64, 96) + source[..., 3] = np.asarray( + ((0, 64, 128, 255), (255, 128, 64, 0), (0, 0, 255, 255)), + dtype=np.uint8, + ) + Image.fromarray(source, mode="RGBA").save(input_path) + foreground = Image.fromarray(source, mode="RGBA") + context = SimpleNamespace( + resource=lambda _name: SimpleNamespace(directory=lambda _model: "model"), + check_cancelled=lambda: None, + ) + + with patch.object( + refine, + "infer_foreground", + return_value=(foreground, 0.0, 0.0), + ) as infer: + result = refine.refine_selection( + context, + input_path, + "BEN2_BASE", + "cpu", + output_path, + ) + + self.assertEqual(result, output_path) + infer.assert_called_once() + self.assertTrue(infer.call_args.kwargs["preserve_input_alpha"]) + with Image.open(output_path) as output: + self.assertEqual(output.size, (4, 3)) + np.testing.assert_array_equal(np.asarray(output.convert("RGBA")), source) + def test_generate_cutout_artifacts_only_returns_requested_outputs(self): job = sys.modules["server.jobs.cutout"] input_path = self.storage_root / "input.png" @@ -750,7 +790,7 @@ class ServerRuntimeTest(unittest.TestCase): normal_mode="OBJECT", ) - def test_generate_cutout_artifacts_refines_the_bounds_image(self): + def test_generate_cutout_artifacts_refines_the_full_bounds_image(self): job = sys.modules["server.jobs.cutout"] input_path = self.storage_root / "input.png" input_path.write_bytes(b"selection") @@ -763,7 +803,7 @@ class ServerRuntimeTest(unittest.TestCase): def generate(_context, _input, _model, _device, output): output.write_bytes(b"refined") - return output, (2, 3, 8, 9) + return output with patch.object(job, "refine_selection", side_effect=generate): result = job.run( @@ -778,12 +818,62 @@ class ServerRuntimeTest(unittest.TestCase): self.assertEqual( result, + {"selection_image": "selection.png"}, + ) + self.assertFalse((directory / "selection_bounds.json").exists()) + + def test_refined_cutout_artifacts_share_the_full_refine_image(self): + job = sys.modules["server.jobs.cutout"] + input_path = self.storage_root / "input.png" + input_path.write_bytes(b"selection") + directory = self.storage_root / "job" + context = SimpleNamespace( + directory=directory, + progress=lambda *_args: None, + check_cancelled=lambda: None, + ) + refined_path = directory / "selection.png" + + def refine(_context, _input, _model, _device, output): + output.write_bytes(b"refined") + return output + + with ( + patch.object(job, "refine_selection", side_effect=refine), + patch.object( + job, + "generate_moge2_artifacts", + return_value={ + "z_depth": directory / "z-depth.exr", + "depth_metadata": directory / "depth.json", + "tangent_normal": directory / "tangent-normal.png", + }, + ) as generate, + ): + result = job.run( + context, + { + "input": str(input_path), + "refine_selection": True, + "refine_model": "BEN2_BASE", + "device": "cpu", + "depth_mode": "Z", + "normal_mode": "TANGENT", + }, + ) + + self.assertEqual(result["selection_image"], "selection.png") + generate.assert_called_once_with( + context, { - "selection_image": "selection.png", - "selection_bounds": "selection_bounds.json", + "input": str(input_path), + "refine_selection": True, + "refine_model": "BEN2_BASE", + "device": "cpu", + "depth_mode": "Z", + "normal_mode": "TANGENT", }, - ) - self.assertEqual( - (directory / "selection_bounds.json").read_text(encoding="utf-8"), - "[2,3,8,9]", + refined_path, + depth_mode="Z", + normal_mode="TANGENT", ) diff --git a/tools/node_assets/build_cutout.py b/tools/node_assets/build_cutout.py index 0f94e62..d573d54 100644 --- a/tools/node_assets/build_cutout.py +++ b/tools/node_assets/build_cutout.py @@ -712,7 +712,7 @@ def build_node_group(shape): options = group.interface.new_panel(name="Options", default_closed=True) reference_depth = _float_input( group, - "Base Plane Depth", + "Reference Depth", 1.0, 0.0, None, @@ -736,7 +736,7 @@ def build_node_group(shape): options = group.interface.new_panel(name="Options", default_closed=True) reference_depth = _float_input( group, - "Base Plane Depth", + "Reference Depth", 1.0, 0.0, None, @@ -771,7 +771,7 @@ def build_node_group(shape): "Depth Image", "Depth Scale", "Uniform Scale", - "Base Plane Depth", + "Reference Depth", "Depth Direction", }, ) @@ -796,7 +796,7 @@ def build_node_group(shape): controls.outputs["Uniform Scale"], controls.outputs["Depth Direction"], controls.outputs["Depth Image"], - controls.outputs["Base Plane Depth"], + controls.outputs["Reference Depth"], ) output_location = (5100.0, 600.0) else: @@ -806,7 +806,7 @@ def build_node_group(shape): controls.outputs["Thickness"], controls.outputs["Depth Scale"], controls.outputs["Uniform Scale"], - controls.outputs["Base Plane Depth"], + controls.outputs["Reference Depth"], controls.outputs["Depth Image"], ) output_location = (8200.0, 400.0) @@ -877,7 +877,7 @@ def build_cutout_modifier_group(shape_groups): reference_depth = _float_input( group, - "Base Plane Depth", + "Reference Depth", 1.0, 0.0, None, diff --git a/tools/node_assets/build_depth_plane.py b/tools/node_assets/build_depth_plane.py index f9d92c6..8cb922c 100644 --- a/tools/node_assets/build_depth_plane.py +++ b/tools/node_assets/build_depth_plane.py @@ -59,7 +59,7 @@ def build_depth_plane_group(mesh_plane_group): options = group.interface.new_panel(name="Options", default_closed=True) base_plane_depth = _interface_socket( group, - "Base Plane Depth", + "Reference Depth", "INPUT", "NodeSocketFloat", parent=options, @@ -98,7 +98,7 @@ def build_depth_plane_group(mesh_plane_group): shape_input = _group_input( nodes, (-1550.0, 950.0), - {"Thickness", "Depth Scale", "Base Plane Depth"}, + {"Thickness", "Depth Scale", "Reference Depth"}, ) image_input = _group_input( nodes, @@ -142,7 +142,7 @@ def build_depth_plane_group(mesh_plane_group): links.new(depth_components.outputs["X"], metric_depth.inputs[0]) links.new(image_input.outputs["Uniform Scale"], metric_depth.inputs[1]) relative_depth = _math(nodes, "SUBTRACT", (50.0, -350.0)) - links.new(shape_input.outputs["Base Plane Depth"], relative_depth.inputs[0]) + links.new(shape_input.outputs["Reference Depth"], relative_depth.inputs[0]) links.new(metric_depth.outputs[0], relative_depth.inputs[1]) full_offset = _math(nodes, "MULTIPLY", (300.0, -350.0)) links.new(relative_depth.outputs[0], full_offset.inputs[0]) diff --git a/tools/node_assets/validate.py b/tools/node_assets/validate.py index 91d8fe1..530e1e2 100644 --- a/tools/node_assets/validate.py +++ b/tools/node_assets/validate.py @@ -70,7 +70,7 @@ def main(): "Subdivide", "Thickness", "Depth Scale", - "Base Plane Depth", + "Reference Depth", "Uniform Scale", "Depth Image", ] @@ -79,7 +79,7 @@ def main(): ] assert [panel.name for panel in depth_panels] == ["Options", "Data"] assert [item.name for item in depth_panels[0].interface_items] == [ - "Base Plane Depth" + "Reference Depth" ] assert [item.name for item in depth_panels[1].interface_items] == [ "Uniform Scale", diff --git a/tools/node_assets/validate_cutout.py b/tools/node_assets/validate_cutout.py index 78918a1..1ca1c32 100644 --- a/tools/node_assets/validate_cutout.py +++ b/tools/node_assets/validate_cutout.py @@ -140,7 +140,7 @@ def main(): "Smooth Iterations", "Cleanup", "Smooth", - "Base Plane Depth", + "Reference Depth", "Depth Direction", "Uniform Scale", "Depth Image", @@ -153,7 +153,7 @@ def main(): "Thickness", "Depth Scale", "Double Sided", - "Base Plane Depth", + "Reference Depth", "Depth Direction", "Uniform Scale", "Depth Image", @@ -162,7 +162,7 @@ def main(): "Geometry", "Thickness", "Depth Scale", - "Base Plane Depth", + "Reference Depth", "Uniform Scale", "Depth Image", ], @@ -184,7 +184,7 @@ def main(): "Thickness": set(shapes.CUTOUT_SHAPES), "Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Double Sided": {"DEPTH_BALLOON"}, - "Base Plane Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, + "Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Direction": {"DEPTH_BALLOON"}, "Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, "Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"}, @@ -347,7 +347,7 @@ def main(): ("Depth Image", point_image), ("Depth Scale", 1.0), ("Uniform Scale", 1.0), - ("Base Plane Depth", 2.0), + ("Reference Depth", 2.0), ("Depth Direction", (0.0, 0.0, 1.0)), ): set_modifier_input( @@ -398,7 +398,7 @@ def main(): ("Depth Image", point_image), ("Depth Scale", 1.0), ("Uniform Scale", 1.0), - ("Base Plane Depth", 2.0), + ("Reference Depth", 2.0), ("Smooth Iterations", 0), ): set_modifier_input(