diff --git a/README.md b/README.md index 9bcca3f..2f745cb 100644 --- a/README.md +++ b/README.md @@ -8,5 +8,5 @@ AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 P - 用 **Plane** 创建可调厚度和细分的图片网格 - 用 **Depth Plane** 把静态图片转换为可调节的立体面片 - 用 **Cutout** 从套索选区生成 Surface、Balloon 与两种 Depth Shape -- 用 **Crop Box / Lasso / Polyline / Perspective** 清理或矫正 Image Empty +- 用 **Frame** 从当前视角重新取景并合并 Image Empty,用 **Mask** 的 Lasso、Brush 或 Polyline 编辑 Alpha;Brush 拖动时以红色或灰色填充预览准确范围,释放后提交;用 **Rectify** 校正透视 - 用 **Remove Background / Upscale** 处理单图、视频和序列帧 diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 581e8b5..4507f64 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -27,7 +27,7 @@ macOS arm64 提供 **CPU** 与 **CoreML**。CoreML 由系统在 Apple CPU、GPU Environment 检测后显示 Diagnostics。Depth & Normal、Remove Background、Upscale 与 Cutout 是默认展开的可折叠功能区,各自包含对应设置和模型状态。其中: - **Default Upscale Model**:Upscale 使用的模型,默认是 General WDN x4v3。 -- **Maximum AI Input Size**:Upscale、Depth、Normal Map 与 Refine Selection 的 AI 输入长边上限,默认 2048 px;颜色贴图保留原始分辨率。 +- **Maximum AI Input Size**:Upscale、Depth 与 Normal Map 的 AI 输入长边上限,默认 2048 px;颜色贴图保留原始分辨率。 - **Maximum Cutout Mesh Resolution**:所有 Cutout Shape 的网格采样长边上限,默认 1024,独立于 AI 输入尺寸。 Depth & Normal 提供三档 MoGe-2,Depth Plane 与 Cutout Depth / Normal Map 共用当前选择。可选模型的 **Ready** 状态或 **Download** 按钮位于选择项下方;Cutout 区提供独立网格上限与 Balloon Profile。 diff --git a/docs/index.md b/docs/index.md index 9bcca3f..2f745cb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,5 +8,5 @@ AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 P - 用 **Plane** 创建可调厚度和细分的图片网格 - 用 **Depth Plane** 把静态图片转换为可调节的立体面片 - 用 **Cutout** 从套索选区生成 Surface、Balloon 与两种 Depth Shape -- 用 **Crop Box / Lasso / Polyline / Perspective** 清理或矫正 Image Empty +- 用 **Frame** 从当前视角重新取景并合并 Image Empty,用 **Mask** 的 Lasso、Brush 或 Polyline 编辑 Alpha;Brush 拖动时以红色或灰色填充预览准确范围,释放后提交;用 **Rectify** 校正透视 - 用 **Remove Background / Upscale** 处理单图、视频和序列帧 diff --git a/docs/internals/architecture.md b/docs/internals/architecture.md index 57c2d7b..3bcfba5 100644 --- a/docs/internals/architecture.md +++ b/docs/internals/architecture.md @@ -32,17 +32,17 @@ Operator 向 Server 发送普通字典。Server 返回文件名和 Job 目录; ```mermaid flowchart TD Register["anyimage.register()"] --> Types["注册 Preferences / PropertyGroup / Operator / Panel"] - Types --> Tools["注册 Crop Tool 与 Cutout WorkspaceTool"] + Types --> Tools["注册 Image Edit 与 Cutout WorkspaceTool"] Tools --> RNA["挂载 Scene 与 Image Property"] RNA --> Menu["挂载上下文菜单和剪贴板快捷键"] 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_frame_resolution` 与 `max_cutout_mesh_resolution` 都在 `preferences.py` 定义与读取:它们分别限制 AI 分析图、Frame 输出和 Cutout 几何采样的最长边。Upscale 还用 AI 输入上限判断当前 Image 是否允许执行。 ## 同步与异步路径 -无需 AI 或媒体处理的功能直接在 Blender 中完成,例如 Plane、普通 Crop、Crop Perspective,以及未启用 Refine Selection / Normal Map 的 Surface 和 Balloon。需要模型或独立文件处理时,功能 Operator 派生自 `JobOperatorBase`: +无需 AI 或媒体处理的功能直接在 Blender 中完成,例如 Plane、Frame、Mask、Rectify,以及不生成 Normal Map 的 Surface 和 Balloon。需要模型或独立文件处理时,功能 Operator 派生自 `JobOperatorBase`: ```mermaid sequenceDiagram diff --git a/docs/internals/common.md b/docs/internals/common.md index 9455de5..15ac4de 100644 --- a/docs/internals/common.md +++ b/docs/internals/common.md @@ -12,6 +12,12 @@ | `image.require_image_empty()` | 按名称取得 Image Empty;对象已失效时抛错。 | | `image.image_pixels()` | 以 Blender 原始顺序读取一维 float32 像素。 | | `image.image_rgba()` | 读取为 Top-down RGBA float32 数组。 | +| `image.homography_from_points()` | 求解四点之间的 Homography。 | +| `image.projective_output_size()` | 按源像素密度、目标宽高比和资源上限计算投影输出尺寸。 | +| `image.premultiplied_rgba()` | 将 Blender 顺序像素转换为 Top-down 预乘 Alpha RGBA。 | +| `image.sample_premultiplied_rgba()` | 在图片边界坐标中双线性采样预乘 Alpha RGBA,范围外返回透明。 | +| `image.straight_rgba()` | 转换为 straight RGBA,并把相邻可见 RGB 扩展到一圈透明边缘。 | +| `image.warp_projective_pixels()` | 将源 Quad 重采样为矩形 Blender Image 像素。 | | `image.video_total_frames()` | 返回 Movie 或 Sequence 总帧数,静态图返回 1。 | | `image.image_user_settings()` | 读取 Image Empty 的帧起点、偏移、时长和循环设置。 | | `image.default_extract_frames()` | 取得当前 Empty 默认应处理的帧数。 | @@ -28,23 +34,23 @@ | `image.load_packed_rgba_image()` | 加载已有 RGBA 文件,继承源图颜色空间和 Alpha mode,命名并 Pack。 | | `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 等材质派生贴图。 +Remove Background、Upscale、Frame、Mask 与 Rectify 都属于直接 Image 编辑,结果保留 active 或源 Image 数据块的完整名称,包括名称中的 `.png` 等格式文字。源数据仍被其他对象引用时,重名结果由 Blender 自动追加数字后缀;源数据可清理时,替换入口让结果恢复准确原名。`color_data_name()`、`depth_data_name()`、`normal_data_name()` 仅用于 Plane 与 Cutout 等材质派生贴图。 ## Image Selection 与视图交互 这组公共能力使用固定术语: -- Selection 是用户在图片空间圈定的业务范围;`SelectionPath` 保存路径点和反选状态,并提供完整值的 JSON 编解码。 +- Selection 是用户在图片空间圈定的业务范围;`SelectionPath` 只保存路径点,并提供 JSON 编解码。 - `SelectionMask` 是 Selection 的纯几何栅格化结果,只包含局部像素权重 `values` 和源图几何 `bounds`。 - Mask 表示二维像素权重或 Alpha 运算。 -- Blender 对象 Selection 由上下文明确区分;图片选区细化动作称为 Refine Selection。 -`viewport.py` 处理 View3D 中的点击、坐标投影、`CropSelectionGesture`、工具设置和 Overlay 绘制;手势完成时只把内存中的 `SelectionPath` 交给实际工具。`selection.py` 负责图片空间 Selection 的值、栅格化和 Alpha 裁切;`SelectionPath → SelectionMask` 只在图片编辑或 Shape 生成阶段执行。 +`viewport.py` 处理 View3D 中的点击、坐标投影、`ImageGesture` 和 Overlay 绘制;它为 Lasso、Brush 与 Polyline 提供对应状态机,Brush 拖动阶段只维护简化后的屏幕路径及 Overlay、提交共用的圆形印记和连接条。`selection.py` 负责图片空间 Selection 的值、栅格化和 Alpha 修整;`SelectionPath → SelectionMask` 只在图片编辑或 Shape 生成阶段执行。Frame 在自身业务模块中组合冻结的 Viewport 投影矩阵与对象世界矩阵,把屏幕像素映射到 Image Empty 局部平面;正交视图接受任意有限深度,透视视图只采样观察方向前方的有效交点。 -所有 Path 使用 non-zero winding 填充,同方向重复绕行的重叠区域保持选中。栅格器按路径边与像素中心扫描行的交点累计 winding:非反选只建立 Path Bounds 的局部画布,抗锯齿时增加 2 px 边缘;反选使用完整图片范围。栅格阶段不接收图片 Alpha 或颜色,内容相交、空内容和内容 bounds 由 Crop Tool 或 Cutout 业务处理。 +Viewport 对象命中继续由 Blender 原生点选决定。Mask、Rectify 与 Cutout 点到另一对象时只保留原生选择结果;没有命中对象时保留当前 active Image Empty,并把透明区域或 Viewport 空白作为图片操作起点。Frame 的普通空白点击继续清空选择。手势是否与图片范围或可见内容有效相交,由各业务在提交阶段判断。 + +所有 Path 使用 non-zero winding 填充,同方向重复绕行的重叠区域保持选中。栅格器按路径边与像素中心扫描行的交点累计 winding,只建立 Path Bounds 的局部画布,抗锯齿时增加 2 px 边缘。栅格阶段不接收图片 Alpha 或颜色,Mask 与 Cutout 在各自业务层组合几何 Mask 和图片 Alpha。 ### Viewport 交互 @@ -52,13 +58,19 @@ Remove Background、Upscale 与四种 Crop 都属于直接 Image 编辑,结果 | --- | --- | | `viewport.report_image_edit_exception()` | 将可预期的 Selection 警告和异常映射为 Operator Report。 | | `viewport.image_edit_poll()` | 判断当前上下文是否允许启动图片编辑工具。 | -| `viewport.resolve_image_edit_click()` | 解析点击到 Image Empty,并保持或恢复选择状态。 | +| `viewport.resolve_image_edit_click()` | 执行原生点选;点到另一对象时只保留选择结果,空命中时保留 active Image Empty 并开始点选型编辑。 | +| `viewport.active_image_edit_source()` | 要求并取得当前 Selection 内的 active Image Empty。 | +| `viewport.image_edit_selection_keymap()` | 创建普通点击和 Shift 点击的对象选择 keymap,并配置普通点击的空命中策略。 | +| `viewport.image_edit_drag_keymap()` | 创建拖动图片手势及对象点选 keymap。 | +| `viewport.image_edit_point_keymap()` | 创建点选型图片手势及 Shift 追加选择 keymap。 | | `viewport.active_view3d_tool_id()` | 取得当前 View3D Workspace Tool ID。 | -| `viewport.apply_crop_settings()` | 将当前 Crop 子工具的持久选项同步到手势 Operator。 | -| `viewport.draw_crop_settings()` | 绘制当前 Crop 子工具的独立选项。 | | `viewport.draw_dashed_line()` | 绘制手势或透视框虚线。 | | `viewport.draw_polygon_fill()` | 绘制手势或透视框填充预览。 | -| `viewport.gesture_property()` | 声明统一的 Box、Lasso 和 Polyline RNA 属性。 | +| `viewport.ImageGesture` | 复用 Lasso、Brush 与 Polyline 的 Modal 生命周期、源图解析和 Overlay 绘制。 | +| `viewport.brush_footprint_polygons()` | 生成 Brush Overlay 与释放提交共用的圆形印记和连接条。 | +| `viewport.preview_union_geometry()` | 在 screen-space scanline bands 中融合 Brush Primitive,并返回一次填充与外轮廓虚线。 | +| `viewport.preview_fill_geometry()` | 三角化 Lasso、Polyline 的 non-zero winding 填充与外轮廓。 | +| `viewport.rectangle_screen_path()` | 将两个屏幕点规范为 Frame 使用的矩形四角。 | | `viewport.screen_path_to_image_pixels()` | 将屏幕路径投影为 Image Empty 像素坐标。 | | `viewport.serialize_matrix()` | 将 4×4 Matrix 序列化为 Operator 可保存的字符串。 | | `viewport.deserialize_matrix()` | 恢复已序列化的 4×4 Matrix。 | @@ -70,10 +82,7 @@ Remove Background、Upscale 与四种 Crop 都属于直接 Image 编辑,结果 | --- | --- | | `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 像素。 | +| `selection.trim_pixels_to_alpha()` | 按 Alpha 非零区域修整 RGBA 像素。 | ## Depth 产物 diff --git a/docs/internals/node-assets.md b/docs/internals/node-assets.md index 7815879..9e14277 100644 --- a/docs/internals/node-assets.md +++ b/docs/internals/node-assets.md @@ -39,7 +39,7 @@ uv run build_node --skip-tests 构建从空 Factory 文件生成全部节点组,关闭 `.blend1` 备份,并覆盖唯一发布资产。 -`O Image Cutout` 在四个 Shape 分支与平滑之后统一处理 **Inward Axis**。Shape 子节点保持内部 `+X` 朝向;默认 `-Z` 分支执行一次轴转换,`+X` 分支保留内部坐标。运行时对象矩阵与该接口配对,保证两种模式落在相同世界空间位置。 +四个 Cutout Shape 子节点统一接收局部 XY 平面,并以 `-Z` 表示向图片内部的方向。`O Image Cutout` 在 Shape 分支与平滑之后处理 **Inward Axis**:默认 `-Z` 直接输出原生几何,`+X` 分支执行一次 Z 到 X 的轴转换;运行时对象矩阵使用逆变换补偿,保证两种模式落在相同世界空间位置。 `O Image Depth Plane`、`O Image Cutout`、`O Image Depth Balloon` 与 `O Image Depth Surface` 统一使用 **Reference Depth** 作为采样深度的运算基准。 diff --git a/docs/internals/operators/crop-tool.md b/docs/internals/operators/crop-tool.md deleted file mode 100644 index 0a57697..0000000 --- a/docs/internals/operators/crop-tool.md +++ /dev/null @@ -1,44 +0,0 @@ -# Crop Tool - -工具栏保留两个独立 AnyImage 入口:四合一 Crop Tool 组与紧接其后的 Cutout Tool。Crop Tool 依次包含 Crop Box、Crop Lasso、Crop Polyline 和 Crop Perspective。 - -## 工具设置 - -四个子工具各自保存 **Keep Original**,默认关闭。关闭时结果替换源 Image Empty;开启时复制源对象承载结果,保留但取消选择原 Empty,并选择、激活新结果。 - -Crop Box、Crop Lasso 和 Crop Polyline 还分别保存 **Invert**;Box 与 Lasso 分别保存 **Refine Selection**。这些状态互不共享。Crop Perspective 没有 Invert 或 Refine Selection。Crop 不注册选项字母快捷键。 - -## 执行过程 - -```mermaid -flowchart TD - Group["Crop Box / Lasso / Polyline / Perspective"] - Group --> Kind{"Selection Crop?"} - Kind -- "Box / Lasso / Polyline" --> Path["采集 SelectionPath"] - Path --> Mask["栅格化 SelectionMask"] - Mask --> Refine{"Box / Lasso Refine Selection?"} - Refine -- "否" --> Local["源 Alpha × Selection"] - Refine -- "是" --> Job["refine-image-selection
保持 Selection bounds"] - Job --> Refined["BEN2 Alpha × Selection"] - Kind -- "Perspective" --> Quad["四点 Quad 与 Homography"] - Local --> Result["创建结果 Image"] - Refined --> Result - Quad --> Result - Result --> Keep{"当前子工具 Keep Original?"} - Keep -- "否" --> Replace["替换源 Empty"] - Keep -- "是" --> Copy["保留源 Empty
选择并激活结果 Empty"] -``` - -## Selection Crop - -`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 矩形,并把已栅格化的 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 84cb1f8..512334a 100644 --- a/docs/internals/operators/cutout-tool.md +++ b/docs/internals/operators/cutout-tool.md @@ -1,6 +1,6 @@ # Cutout Tool -Cutout 从图片上的一个 Selection 开始,可按需执行 Refine Selection、生成 Depth 或 Normal Map,最后在 Blender 中创建带材质与 Modifier 的 Mesh Object。 +Cutout 从图片上的一个 Selection 开始,可按需生成 Depth 或 Normal Map,最后在 Blender 中创建带材质与 Modifier 的 Mesh Object。 ## 执行过程 @@ -13,8 +13,8 @@ flowchart TD Route{"需要后台生成数据?"} Local["读取局部 RGBA
源 Alpha × Selection 值"] Bounds["写出几何 bounds 矩形 RGBA"] - Job["执行 Refine Selection,生成 Depth / Normal 产物"] - Encoded["加载并 Pack 输入或 Refine RGBA"] + Job["生成 Depth / Normal 产物"] + Encoded["加载并 Pack 输入 RGBA"] Ready["贴图 Alpha × Selection Mask"] Build["构建 BaseShape"] Create["创建 Blender 对象"] @@ -25,7 +25,7 @@ flowchart TD Ready --> Build --> Create ``` -Surface 与 Balloon 在本地组合源 Alpha 与几何 Selection 后构建 `BaseShape`;Refine Selection、Depth Shape 和 Normal Map 先通过同一个矩形图片协议生成所需产物。 +Surface 与 Balloon 在本地组合源 Alpha 与几何 Selection 后构建 `BaseShape`;Depth Shape 和 Normal Map 先通过同一个矩形图片协议生成所需产物。 ## 节点说明 @@ -33,41 +33,38 @@ 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`。 +对象命中由 Blender 原生点选决定。普通点击另一对象时只切换 active;空白单击保留当前 active Image Empty。Lasso 可从透明区域或 Viewport 空白开始拖动,双击也可从空命中位置打开整图 Shape 菜单。起始点不判断图片范围或 Alpha,完整 Selection 的几何与内容有效性在后续栅格化和 Shape 构建阶段判断。 + +`interaction.py` 读取工具选项,并在构建菜单按钮时把只含 Points 的完整 `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。 +用户选定 Shape 后,`main.py` 中的 `CutoutSelectionToShape` 接过主线。它从自身 Property 恢复 `SelectionPath`,再使用 `common/selection.py` 以 non-zero winding 在 Path Bounds 与 2 px 抗锯齿边缘内栅格化为一次纯几何 `SelectionMask`。`values` 保存局部几何权重,`bounds` 保存它在源图中的位置;栅格阶段不读取、接收或合并源 Alpha。 实际 Cutout 业务随后读取一次源 RGBA。无需 AI 时,它在几何 bounds 内显式计算 `source_alpha × selection_values`,由该内容 Mask 判断空内容并约束轮廓;原始 `SelectionMask` 不被修改。此时按 `Selection bounds 长边 ÷ Mesh Detail 像素间距` 计算实际采样长边;只有它超过 **Maximum Cutout Mesh Resolution** 时才等比增大有效间距。bounds 的原始像素长边本身不会触发降密。 ### 需要后台生成数据? -只有以下情况需要提交 Job: - -- 开启 **Refine Selection**; -- 选择 Depth Balloon 或 Depth Surface; -- 开启 **Normal Map**。 +选择 Depth Balloon、Depth Surface 或开启 **Normal Map** 时需要提交 Job。 Surface 和 Balloon 的本地路径直接使用本地内容 Mask。 -### 执行 Refine Selection,生成 Depth / Normal 产物 +### 生成 Depth / Normal 产物 `main.py` 先通过 `common/image.py` 把 Selection 几何 bounds 内的原始 RGBA 写成矩形输入。该文件保留源 Alpha,不预乘 Lasso 值;文件、Packed 或内存图片都使用同一个入口。随后把请求提交给 `server/jobs/cutout.py`,生成本次 Shape 需要的产物: -- **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`。 -Depth 与 Normal Map 共享同一个 MoGe-2 `GeometryFrame`。模型输入先按 Alpha 合成到黑底;Vector/Z Depth 的 RGB 保留完整连续 Field,Alpha 保存模型 Validity。Depth Surface 的 Normal Map 编码为 Cutout 对象空间,其正面是对象局部 `-X`;其余 Shape 使用 Tangent Space。`main.normal_mode_for_shape()` 根据 Shape 确定 Normal Space。 +Depth 与 Normal Map 共享同一个 MoGe-2 `GeometryFrame`。模型输入先按 Alpha 合成到黑底;Vector/Z Depth 的 RGB 保留完整连续 Field,Alpha 保存模型 Validity。Depth Surface 的 Normal Map 编码为 Cutout 原生 XY / Z 对象空间,正面中性法线为局部 `+Z`;其余 Shape 使用 Tangent Space。`main.normal_mode_for_shape()` 根据 Shape 确定 Normal Space。 -**Maximum AI Input Size** 约束 BEN2 和 MoGe-2 的分析图长边;网格密度使用 **Maximum Cutout Mesh Resolution**,最终颜色图使用源图裁切分辨率。 +**Maximum AI Input Size** 约束 MoGe-2 的分析图长边;网格密度使用 **Maximum Cutout Mesh Resolution**,最终颜色图使用 Selection bounds 分辨率。 -Refine 的贴图内容 Alpha 统一为 `Source Alpha × BEN2 Alpha`。Color 直接使用该 RGBA;Normal 的可见区域、MoGe-2 黑底输入和 Depth Metadata 的参考区域使用同一 Alpha。Depth EXR 的 RGB 仍保存连续 Field,Alpha 保存模型 Validity。Selection Mask 只在 Blender 构建网格时与贴图 Alpha 相乘。 +Color 使用 Selection bounds 内的源 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 时使用完整 bounds 的 BEN2 输出;只生成 Depth 或 Normal 时使用提交前的矩形输入。它从同一 Image 读取 Alpha,与已栅格化的 Selection Mask 相乘后构建网格内容 Mask;颜色及 AI 贴图保持原 Alpha。`main.create_cutout_shape()` 调用 `geometry.py` 中的 `build_base_shape()`: +Job 完成后,`main.py` 在 Blender 主线程加载并 Pack 可选的 Depth 与 Normal 图片,并复用提交前的矩形 RGBA 输入作为颜色图。它从同一 Image 读取 Alpha,与已栅格化的 Selection Mask 相乘后构建网格内容 Mask;颜色及 AI 贴图保持原 Alpha。`main.create_cutout_shape()` 调用 `geometry.py` 中的 `build_base_shape()`: 1. 从内容 Alpha 值提取外轮廓和孔洞; 2. 按 Mesh Detail 对应的采样间距生成轮廓与内部采样点; @@ -79,7 +76,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,并由 `O Image Layer` Color 直接驱动 Principled BSDF 的 IOR Level。本地 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 的矩形输入。 | Shape | Modifier 如何形成结果 | 必需后台产物 | | --- | --- | --- | @@ -96,16 +93,16 @@ Normal Map 关闭时材质使用几何法线;开启时,`object.py` 把生成 ```mermaid flowchart TD - Base["四种 Shape
内部几何 +X"] --> Switch{"Inward Axis"} - Switch -- "+X" --> Legacy["节点几何不转换
对象矩阵追加轴变换"] - Switch -- "-Z(默认)" --> Orient["O Image Cutout 最终几何轴转换
对象沿用源 Empty local transform"] - Legacy --> World["相同世界空间结果"] + Base["BaseShape 与四种 Shape
原生 XY / Z"] --> Switch{"Inward Axis"} + Switch -- "-Z(默认)" --> Native["节点几何直接输出
对象沿用源 Empty local transform"] + Switch -- "+X" --> Orient["O Image Cutout 执行 Z → X
对象矩阵执行 X → Z"] + Native --> World["相同世界空间结果"] Orient --> World Normal["Depth Surface
Object Space Normal"] --> Mode{"Inward Axis"} - Mode -- "+X" --> Keep["保持原通道"] - Mode -- "-Z" --> Swizzle["(-y, z, -x)"] + Mode -- "-Z" --> Keep["保持原生 Z 轴通道"] + Mode -- "+X" --> Swizzle["按 Z → X 转换一次"] ``` -Shape 子节点始终按内部 `+X` 建模。统一的 `O Image Cutout` Modifier 在最终输出处按选项切换轴转换;`object.py` 同步选择对象矩阵,因此默认 `-Z` 与源 Image Empty 的 local transform 一致,旧 `+X` 模式仍保持相同世界空间形状。Depth Surface 的 Object Space Normal 仅在 `-Z` 模式执行通道转换;Tangent Space Normal 不变。 +BaseShape 与 Shape 子节点始终在局部 XY 平面建模,并沿 `-Z` 向图片内部生成厚度和深度。统一的 `O Image Cutout` Modifier 只在选择 `+X` 时转换最终几何;`object.py` 同步使用逆矩阵,因此默认 `-Z` 与源 Image Empty 的 local transform 一致,`+X` 模式仍保持相同世界空间形状。Depth Surface 的 Object Space Normal 默认直接使用原生 Z 轴 Artifact,仅在 `+X` 模式转换通道;Tangent Space Normal 不变。 Balloon 与 Depth Balloon 的材质会创建 Geometry Attribute 节点,把 `o_balloon` 的 Float 输出直接连接到 `O Image Layer` 已有的 **Normal Scale**;Surface 与 Depth Surface 不建立这条连接。 diff --git a/docs/internals/operators/image-edit-tool.md b/docs/internals/operators/image-edit-tool.md new file mode 100644 index 0000000..1e098f3 --- /dev/null +++ b/docs/internals/operators/image-edit-tool.md @@ -0,0 +1,57 @@ +# Image Edit Tool + +工具栏包含 **Frame**、**Mask**、**Rectify** 三合一 Image Edit 组,Cutout 作为紧接其后的独立工具。 + +## 工具设置 + +Frame 和 Rectify 没有业务选项。Frame 让 active Image Empty 承载合成结果,并删除其他参与合并的选中 Image Empty;Rectify 直接替换 active Image Empty 的图片。 + +Mask 保存 **Gesture**、**Mode** 和 **Radius**。Gesture 包含 Lasso、Brush 与 Polyline;Mode 包含 Set、Add 与 Subtract,默认使用 Subtract;Radius 默认 25 px,仅在 Brush 时显示和生效。 + +## 执行过程 + +```mermaid +flowchart TD + Group["Frame / Mask / Rectify"] + Group --> Kind{"子工具"} + Kind -- "Frame" --> Sources["选中 still Image Empty
active 为对象和深度基准"] + Sources --> Project["Frame 像素生成观察射线
与图片平面求交"] + Project --> Composite["逐像素深度与 Alpha 合成"] + Composite --> Canvas["完整 Frame Canvas
范围外透明"] + Canvas --> Facing["active 朝向当前视角
删除其他参与对象"] + Kind -- "Mask" --> Gesture{"Lasso / Brush / Polyline"} + Gesture --> Mask["栅格化 SelectionMask"] + Mask --> Mode{"Set / Add / Subtract"} + Mode --> Alpha["只合成当前 Alpha
保持完整 Canvas"] + Kind -- "Rectify" --> Quad["四点 Quad 与 Homography"] + Alpha --> Replace["替换 active Empty 的 Image"] + Quad --> Replace +``` + +## Frame + +`frame.py` 读取所有选中的 still Image Empty,并要求 active object 是 Selection 内的 still Image Empty。仅有 active 状态但未选中的图片不构成输入;active 是非图片对象时,即使 Selection 中存在图片也以 warning 取消。Frame 可以从 Viewport 任意位置开始,但结果矩形必须与 active 图片投射相交。选中的非 Image Empty 被忽略;任一选中 Image Empty 是视频或序列时整体取消。 + +每个参与图片使用冻结的 Viewport 投影矩阵与对象世界矩阵建立屏幕到图片平面的 Projective Transform。Frame 输出像素中心沿当前观察射线映射到图片局部平面,再换算为源像素坐标;有限且位于 Image Empty 显示边界内的交点参与采样。正交视图接受任意有限视图深度;透视视图只接受观察方向前方的交点。图片部分跨越透视观察原点时,前方区域继续采样,后方、平行射线和图片范围外透明。 + +输出像素按观察射线上的几何深度从远到近排序,再以预乘 Alpha 合成;同深度时 active 位于最上层。Frame 与 active 的交叠通过连续有效投射区域和 Frame 矩形的正面积交集判断,不依赖源 Alpha 或离散输出像素中心。输出分辨率使用 Frame 在当前 Region 中的屏幕像素宽高,不超过 Region 尺寸,并由 Preferences 的 **Maximum Frame Resolution** 等比限制最长边;总像素不超过 16,777,216。透明边缘保留在完整 Canvas 中;紧邻内容的一圈透明像素保留相邻 RGB,避免线性插值产生黑边。 + +Frame 与 Cutout Lasso 只在 `CLICK_DRAG` 后进入手势。Mask 与 Rectify 使用 `PRESS` 启动。三个绘制型工具都由 Blender 原生点选决定命中对象:点到另一对象时只切换 active,不开始编辑;没有命中对象时保留当前 active Image Empty,因此透明区域和 Viewport 空白都可作为起点。Mask 使用 `PAINT_CROSS` cursor;Brush 单击生成一个圆形印记。Polyline 通过逐点单击建立路径,至少三个点后在起点周围显示随鼠标接近而放大的闭合圈,并以点击 15 px UI Scale 命中范围、双击或 Enter 完成;Backspace 删除最后一点。手势与图片范围、可见内容的有效性在提交阶段判断。Frame 的普通空白点击继续清空选择,Shift 点击由显式 Viewport 对象选择 keymap 处理。 + +结果平面本地 XY 平行视平面且正面朝向观察者。正交视图与 active 原点位于透视观察方向前方时,结果沿用 active 对象原点的视图深度;透视 active 原点位于视后方但图片仍有有效前方交叠时,结果使用有效交叠区域屏幕中心与 active 图片平面的交点深度。active Blender Object 保留名称、Collection 和身份并承载 packed 合成 Image,其他参与 Image Empty 随后删除;整个操作属于一个 Undo 步骤。 + +## Mask + +`mask.py` 定义一个 WorkspaceTool 和一个 `EditImageAlpha` Operator。Operator 启动时冻结 Scene 中保存的 Gesture、Mode 和 Radius;Lasso 把自由手绘闭合路径转换为图片像素,Polyline 把逐点闭合路径送入同一个 SelectionMask 与 Alpha 合成入口。 + +Brush 拖动阶段只维护经过距离与近共线简化的屏幕轨迹,并为每个采样点生成圆、为每对相邻点生成等宽连接条;不读取源 RGBA,不创建临时 Image,也不投影、栅格化或更新 Pixel Buffer。Overlay 在局部 screen-space scanline bands 中合并这些 Primitive,生成无内部接缝、尖角或三角孔的一次填充与闭合外轮廓;Subtract 为红色,Set/Add 为灰色。释放后把同一组 Primitive 投影到图片,以逐像素最大值合入单个覆盖画布,再通过公共 Alpha 合成与 Image 替换入口提交 Packed 结果。取消或切换工具只结束 Overlay,源 Image 与共享引用始终不变。 + +设当前 Alpha 为 `A`、手势权重为 `M`:Set 计算 `A × M`,Add 计算 `max(A, M)`,Subtract 计算 `A × (1 - M)`。RGB 原样保留。结果始终使用源图片宽高和原有画框,因此 Image Empty 的 identity、matrix、display size 和 offset 保持不变;全透明结果同样有效。 + +## Rectify + +`rectify.py` 接受任意顺序的四个角点并规范为凸 Quad,创建 512 × 512 预览,随后按目标宽高比执行分块 Homography。输出最长边不超过 8192 px,总像素不超过 16,777,216;插值使用预乘 Alpha,结果按可见 Alpha trim 后映射回源图片平面。 + +Mask 与 Rectify 最终都以 packed Image 调用 `common/image.py` 的 `replace_empty_image()`。Mask 不提供 placement bounds;Rectify 使用校正区域的 placement bounds 调整图片画框。Frame 复用同模块的 Image 创建、Homography、预乘采样和单对象替换函数,并在自身业务边界完成多图合成与对象删除。 + +三个工具的结果都以源 Image 的完整名称请求命名,不移除 `.png` 等名称内容,也不添加材质贴图后缀。源数据仍被其他对象引用时,Blender 为结果追加数字后缀;源数据可清理时,替换入口让结果恢复准确原名。 diff --git a/docs/internals/operators/index.md b/docs/internals/operators/index.md index b2b0a6b..c8df31c 100644 --- a/docs/internals/operators/index.md +++ b/docs/internals/operators/index.md @@ -9,7 +9,7 @@ | `operators/clipboard_image/` | [Clipboard Image](clipboard-image.md) | 系统剪贴板读取、粘贴与快捷键 | | `operators/convert_to_plane/` | [Convert to Plane](convert-to-plane.md) | Mesh Plane 与 Depth Plane 创建 | | `operators/cutout_tool/` | [Cutout Tool](cutout-tool.md) | Selection、基础 Shape 构建与 Blender 对象创建 | -| `operators/crop_tool/` | [Crop Tool](crop-tool.md) | Box、Lasso、Polyline 与 Perspective 图片裁切 | +| `operators/image_edit_tool/` | [Image Edit Tool](image-edit-tool.md) | Frame 合成、Mask Alpha 编辑与 Rectify 透视校正 | | `operators/debug.py` | [Debug Operator](debug.md) | 模型接口与原始产物验证 | | `operators/remove_background.py` | [Remove Background](remove-background.md) | BEN2 去背与结果替换 | | `operators/upscale.py` | [Upscale](upscale.md) | 当前 Image 的 4× 高清与输入长边检查 | diff --git a/docs/internals/outputs.md b/docs/internals/outputs.md index 974bfe5..e1b0e84 100644 --- a/docs/internals/outputs.md +++ b/docs/internals/outputs.md @@ -8,9 +8,8 @@ Server 在 Job 目录写入图片、JSON 或 Debug NPZ;Blender 根据 Job 返 | | | | | `remove-background` | `image.0001.png` 起 | 替换 Image Empty 的颜色数据 | | `upscale-image` | `upscale.0001.png` 起 | 替换或新建高清颜色数据 | -| `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 与材质 | +| `generate-cutout-artifacts` | 可选 Depth 与 Normal 文件 | 创建 Cutout Shape 与材质 | Depth Job 保存可由 Blender 直接加载的模型空间 EXR;Blender 主线程负责轮廓提取、三角化、Depth 标定和数据创建。 @@ -24,7 +23,7 @@ MoGe-2 的原始结果由模型适配器转换为单帧 `GeometryFrame`。它包 | `z-depth.exr` | RGB 重复保存完整相机 Z,Alpha 保存 Validity | | `depth.json` | 保存 `image_size`、3×3 `intrinsics` 与有效区域的 `reference_depth` | | `tangent-normal.png` | Tangent Space Normal Map | -| `object-normal.png` | Object Space Normal Map | +| `object-normal.png` | Cutout 原生 XY / Z 空间的 Object Space Normal Map | MoGe-2 的布尔 Mask 以 `0/1` 写入 Validity。Mask 外的有限 Depth/XYZ 仍保留在 RGB,使 Geometry Nodes 的线性采样维持连续;输入 Alpha、正深度和有限性只参与 `depth.json` 的参考样本筛选。任一待写 Field 包含 NaN/Inf 时不生成 EXR。 @@ -34,7 +33,6 @@ 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` 以输入 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`。 diff --git a/docs/internals/runtime.md b/docs/internals/runtime.md index 21ee79b..d0243f2 100644 --- a/docs/internals/runtime.md +++ b/docs/internals/runtime.md @@ -14,13 +14,12 @@ src/anyimage/ ├── model_manager.py # 模型目录与 Session 生命周期 ├── jobs/ # 公开协议与流程编排 ├── media/ # 图片、视频、序列帧和尺寸处理 - ├── selection/ # 通用 BEN2 Selection 细化 ├── geometry/ # Depth / Normal 文件产物与相机元数据 ├── models/ # 模型适配与 ONNX 推理 └── outputs/ # Debug Prediction 序列化 ``` -`jobs/` 编排任务,领域目录提供媒体、Selection、Geometry、模型和输出能力。 +`jobs/` 编排任务,领域目录提供媒体、Geometry、模型和输出能力。 ## 生命周期 @@ -43,16 +42,15 @@ Job 在阶段边界调用 `context.check_cancelled()`,并把稳定产物写入 | `download-model`、`download-required-models` | 准备模型文件 | | `remove-background` | 生成 BEN2 透明前景 | | `upscale-image` | 4× 图片或帧序列 | -| `refine-image-selection` | 为 Image Box / Lasso 执行 Refine Selection | | `generate-depth-plane-geometry` | 为 Depth Plane 复制颜色并生成 Z Depth 与相机元数据 | -| `generate-cutout-artifacts` | 按需执行 Refine Selection,并生成 Depth 或 Normal 文件产物 | +| `generate-cutout-artifacts` | 为 Cutout 生成 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`。`selection/refine_selection.py` 使用 BEN2 生成与输入同尺寸的 `Source Alpha × BEN2 Alpha` RGBA;Selection Mask 由 Blender 在最终图片或网格阶段组合。 +`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 Mask 由 Blender 在最终图片或网格阶段组合。 ## 尺寸约束 -`server/media/resolution.py` 提供 **Maximum AI Input Size**,默认 `2048 px`。它限制 Upscale 的可提交源图长边,以及 Depth、Normal Map、Refine Selection 的模型分析图长边;材质颜色图使用原始分辨率。 +`server/media/resolution.py` 提供 **Maximum AI Input Size**,默认 `2048 px`。它限制 Upscale 的可提交源图长边,以及 Depth、Normal Map 的模型分析图长边;材质颜色图使用原始分辨率。 Blender 侧的 **Maximum Cutout Mesh Resolution** 统一限制所有 Cutout Shape 实际几何范围的最长网格采样边,不限制 Selection bounds 的原始像素尺寸。 @@ -60,7 +58,7 @@ Blender 侧的 **Maximum Cutout Mesh Resolution** 统一限制所有 Cutout Shap ## 模型 -Depth Plane、Cutout Depth 与 Normal Map 使用 MoGe-2,Remove Background 与 Refine Selection 使用 BEN2,Upscale 使用所选高清模型。MoGe-2 生产推理直接返回 `GeometryFrame`;DA3 调试推理保留模型原始 Prediction,并可按帧适配到相同结构。 +Depth Plane、Cutout Depth 与 Normal Map 使用 MoGe-2,Remove Background 使用 BEN2,Upscale 使用所选高清模型。MoGe-2 生产推理直接返回 `GeometryFrame`;DA3 调试推理保留模型原始 Prediction,并可按帧适配到相同结构。 `ModelManager` 按模型族管理下载目录与 ONNX Session,同一族只保留最近使用的模型和 Device,避免同类模型重复占用资源。 diff --git a/openspec/changes/add-live-mask-preview-and-polyline/.openspec.yaml b/openspec/changes/add-live-mask-preview-and-polyline/.openspec.yaml new file mode 100644 index 0000000..9696e00 --- /dev/null +++ b/openspec/changes/add-live-mask-preview-and-polyline/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/openspec/changes/add-live-mask-preview-and-polyline/design.md b/openspec/changes/add-live-mask-preview-and-polyline/design.md new file mode 100644 index 0000000..762122c --- /dev/null +++ b/openspec/changes/add-live-mask-preview-and-polyline/design.md @@ -0,0 +1,84 @@ +## Context + +Mask 的 Lasso、Brush 与 Polyline 共用 `ImageGesture`。第一版 Brush 实时预览在 Modal 期间创建同尺寸 Generated Image,并以最多 30 FPS 更新 Pixel Buffer;4K 实测表明 `image.update()` 仍可能上传完整纹理,频繁更新会造成明显卡顿,红色填充 Overlay 也会随着 Flush 时机出现或消失。 + +因此 Brush 回到与 Lasso、Polyline 一致的事务边界:手势期间只保留简化后的屏幕轨迹并绘制几何 Overlay,不读取、创建或修改 Image;鼠标释放后才投影和栅格化完整笔画,创建一个最终 Image 并替换 active Image Empty。 + +Keep Original 与 Refine Selection 已无运行时代码,但测试中仍有旧字段缺席断言和一个多余参数。`SelectionPath.invert` 也已无生产调用方,却继续扩大 JSON 协议并让栅格器保留完整画布反选分支。 + +## Goals / Non-Goals + +**Goals:** + +- Brush 拖动期间显示与 Lasso、Polyline 相同的虚线和 Mode 填充,不更新 active Image Empty。 +- Overlay 与释放提交复用同一组 Brush Primitive 和 union 语义,保证预览范围就是最终 Selection。 +- 增加与原 Crop Polyline 手势一致、但使用当前 Mask 术语和 Alpha 主链的 Polyline。 +- 将 Mask 默认 Mode 改为 Subtract。 +- 删除 Keep Original、Refine Selection、Selection Invert 的剩余状态和历史测试噪音。 + +**Non-Goals:** + +- Lasso 与 Polyline 在路径闭合前只显示几何 Overlay,不实时修改 Image Alpha。 +- 不编辑共享源 Image 的 Pixel Buffer,不改变 Frame、Rectify、Cutout Shape 或 Server 行为。 +- 不在 Modal 拖动阶段创建预览 Image、读取源 RGBA 或执行图片级计算。 +- 不增加 Pillow、OpenCV 或 GPU Compute 依赖。 + +## Decisions + +### 1. Brush 拖动阶段只维护屏幕轨迹 + +Brush 确认开始编辑后只保存屏幕坐标。鼠标移动使用与 Lasso 相同的最小距离和近共线简化,避免静止事件或直线移动积累冗余点。Modal 期间不调用 `image_rgba()`、`bpy.data.images.new()`、Pixel Buffer 写入、`image.update()` 或 `pack()`,active Image Empty 始终引用原 Image。 + +RMB、Esc、工具切换和异常只移除 Draw Handler 并结束 Operator,不需要临时 Image 恢复或数据块清理。这样拖动性能只与 Viewport Overlay 和简化后的屏幕轨迹有关,不随图片分辨率变化。 + +### 2. Overlay 与提交共享 Brush Primitive Union + +简化后的中心轨迹生成一组稳定 Primitive:每个采样点对应一个圆,每对相邻点对应一个等宽连接条。Viewport 在 screen space 对这些 Primitive 做 union;鼠标释放后把同一组 Primitive 投影到图片,逐个栅格化并以逐像素 `max` 合入单个覆盖画布。转向、折返与交错都由圆和连接条的并集定义,不再从中心线偏移出一个可能改变 winding 的自交多边形,因此不会产生 miter 尖角或未填充三角。 + +Selection 完成后读取一次源 RGBA,并根据操作开始 Alpha `A0` 与 Mask `M` 合成: + +```text +Set A' = A0 × M +Add A' = max(A0, M) +Subtract A' = A0 × (1 - M) +``` + +`apply_alpha_mask()` 使用同一纯 Alpha 合成函数;释放阶段只保留一个累计覆盖画布,不保存逐 Primitive Mask 列表。 + +不选择在每次刷新时重新栅格化完整轨迹,因为成本随轨迹长度持续增加;也不保存每个 Primitive 的独立 Mask,因为内存会随鼠标事件数增长。 + +### 3. Brush 使用与 Lasso、Polyline 相同的 Overlay + +Viewport Overlay 在每个有效移动事件后,把全部 Brush Primitive 在局部屏幕 bounds 内以 2 px scanline bands 做区间 union,再由合并 bands 生成填充三角形,并从相邻 bands 的边界差异连接出闭合外轮廓。内部重叠边和 band 接缝不会进入虚线,急转与交错区域保持连续填充。该过程只处理 Viewport 坐标,不读取或栅格化源 Image。 + +Subtract 使用红色半透明填充,Set 与 Add 使用灰色半透明填充;虚线保持相同样式。Overlay 不随任何 Image 状态变化,并让用户在释放前看清即将作用的准确区域。 + +### 4. Polyline 是 Mask 的第三种 Gesture + +`mask_gesture_property()` 增加 `POLYLINE`,单一 Mask WorkspaceTool 和 `EditImageAlpha` Operator 保持不变。`ImageGesture` 恢复无 Crop 术语的 Polyline 状态机:首次点击建立起点,后续左键提交顶点,鼠标移动预览下一条边;至少三个点后,点击起点 8 px 范围或按 Enter 完成,Backspace 删除最后一点,RMB/Esc 取消。 + +完成后把已提交顶点转换为一个 `SelectionPath`,与 Lasso 一样栅格化并进入当前 Mode 的 Alpha 合成。Radius 只属于 Brush;Polyline Tool Settings 只显示 Gesture 与 Mode。 + +### 5. SelectionPath 只保存 Points + +删除 `SelectionPath.invert`、JSON 中的 `invert`、完整画布反选 bounds 和 `1 - mask` 分支。Cutout 的 Lasso 与双击整图都已经提供正向 Path,不需要迁移运行时状态。JSON 继续用于 Cutout Operator 间传值,但只包含 `points`。 + +删除重复验证 Keep Original、Refine Selection 和旧 Crop 字段缺席的历史断言;仍通过当前 Scene Property、Operator annotation、Job request 和打包文件的正向精确集合测试固定公开边界。测试夹具中的多余 `keep_original=False` 同步删除。 + +## Risks / Trade-offs + +- [高分辨率图片会让释放后的提交耗时可感知] → 将图片级投影、栅格化和结果创建集中到一次释放事件,避免拖动期间反复上传纹理。 +- [长而复杂的笔画会保留较多屏幕点] → 使用与 Lasso 相同的距离与近共线简化,只保留表达轨迹形状所需的点。 +- [Set 模式会让笔画外 Alpha 归零] → 保持既有 Set 公式;默认 Subtract 仍覆盖最常用的擦除行为。 +- [复杂笔画包含大量重叠 Primitive] → screen-space scanline 先合并每个 band 的区间,再生成一次填充和外轮廓;图片空间只在释放时使用单个累计覆盖画布。 +- [Polyline PRESS 事件与对象点选冲突] → 沿用 `resolve_image_edit_click()`,首次点击另一对象只切换 active,再次点击当前 Image 才开始手势。 + +## Migration Plan + +先完成已实现的 `replace-crop-with-mask-and-rectify` 作为基线。实现时先精简 SelectionPath,再加入 Polyline 状态机,随后实现 Brush 虚线 Overlay 与释放栅格化,最后删除失去调用方的 Helper 和历史测试。Scene 中未显式保存 Mode 的文件自动使用新的 Subtract 默认值;已经保存的 Mode 值保持不变。 + +回滚时可恢复本 change 前的 Mask 实现;它不迁移持久文件、Server 数据或 Blender Object 结构。 + +## Open Questions + +无。4K 实测决定拖动阶段不再更新 Image;后续实测进一步要求 Overlay 与提交共享圆角 footprint,并恢复 Mode 填充。 diff --git a/openspec/changes/add-live-mask-preview-and-polyline/proposal.md b/openspec/changes/add-live-mask-preview-and-polyline/proposal.md new file mode 100644 index 0000000..683f9e7 --- /dev/null +++ b/openspec/changes/add-live-mask-preview-and-polyline/proposal.md @@ -0,0 +1,26 @@ +## Why + +Mask Brush 的实时 Image 预览在 4K 图片上会频繁上传大纹理,造成明显卡顿;红色填充 Overlay 还会随 Image 刷新时机出现或消失。Brush 更适合像 Lasso、Polyline 一样在手势期间只显示稳定的几何反馈,释放后再提交 Alpha 修改。与此同时,已删除的 Keep Original、Refine Selection 和 Invert 仍有测试或通用数据分支残留,Brush 还会为整条轨迹积累大量临时 Mask,适合在扩展 Gesture 时一并收紧。 + +## What Changes + +- **BREAKING**:Mask Mode 默认值从 Set 改为 Subtract,使 Mask 默认表现为擦除 Alpha。 +- Brush 按住拖动期间不读取或修改 Image Pixel Buffer;Overlay 与释放提交复用同一组圆形印记、连接条和 union 规则,急转与交错区域融合且虚线只保留融合后的外轮廓。显示沿用 Lasso、Polyline 的 Mode 填充:Subtract 为红色,Set/Add 为灰色。 +- Mask Gesture 增加 Polyline,沿用逐点单击、移动预览、点击起点或 Enter 完成、Backspace 撤回一点的交互,并复用 Set、Add、Subtract Alpha 合成。 +- 删除无生产调用方的 `SelectionPath.invert`、全画布反选栅格分支及旧字段序列化,清理只验证 Keep Original、Refine Selection 缺席的历史测试和测试参数。 +- 删除由中心线偏移生成单一自交外框的方案,避免急转时 winding 产生三角孔洞;改为直接合并 Brush 圆和连接条。 + +## Capabilities + +### New Capabilities + +- `mask-editing`: 定义 Mask 的 Lasso、Brush、Polyline Gesture,Subtract 默认模式,Brush 虚线预览、释放提交与取消语义,以及无旧编辑状态的精简 Selection 数据边界。 + +### Modified Capabilities + +## Impact + +- 影响 `properties.py` 的 Mask 默认设置,以及 `operators/image_edit_tool/mask.py` 的释放提交与 Brush Selection 主链。 +- 影响 `common/viewport.py` 的 Gesture 状态机、Polyline 与 Brush Overlay,以及 `common/selection.py` 的 SelectionPath 表示。 +- 影响 Mask、Selection、Cutout、注册、交互与打包测试,并同步 Image Edit、Cutout 和 Common 内部文档。 +- 不增加第三方依赖,不改变 Frame、Rectify、Cutout Shape、Remove Background 或 Server Job 协议。 diff --git a/openspec/changes/add-live-mask-preview-and-polyline/specs/mask-editing/spec.md b/openspec/changes/add-live-mask-preview-and-polyline/specs/mask-editing/spec.md new file mode 100644 index 0000000..58be337 --- /dev/null +++ b/openspec/changes/add-live-mask-preview-and-polyline/specs/mask-editing/spec.md @@ -0,0 +1,94 @@ +## ADDED Requirements + +### Requirement: Mask exposes three gestures with Subtract by default +系统 SHALL 在单一 Mask Tool 中提供 Lasso、Brush 和 Polyline Gesture,并 SHALL 提供 Set、Add、Subtract Mode。新 Scene 的 Mask Mode MUST 默认为 Subtract;已有 Scene 中已保存的 Mode MUST 保持其值。Radius MUST 只在 Brush Gesture 下显示和生效。 + +#### Scenario: New scene uses Subtract +- **WHEN** 新 Scene 尚未保存 Mask Mode +- **THEN** Mask Tool Settings 与新启动的 Mask Operator 使用 Subtract + +#### Scenario: Gesture settings remain unified +- **WHEN** 用户在 Lasso、Brush 与 Polyline 之间切换 +- **THEN** 系统仍使用同一个 Mask Tool、Mode 设置和 Alpha 合成入口,并只在 Brush 下显示 Radius + +### Requirement: Brush defers Image work until release +Brush MUST 在手势完成前保持 active Image Empty 的 Image 不变。拖动期间 MUST NOT 读取源 RGBA、创建临时 Image、栅格化图片级 Mask、写入 Pixel Buffer、调用 `image.update()` 或 Pack;鼠标释放后 SHALL 一次性计算并提交结果。 + +#### Scenario: Dragging leaves the Image untouched +- **WHEN** 用户按住左键移动 Brush 且手势尚未完成 +- **THEN** active Image Empty 继续显示并引用操作开始时的 Image,只通过 Viewport Overlay 呈现待提交范围 + +#### Scenario: Shared source remains unchanged during the gesture +- **WHEN** active Image Empty 与另一个对象共享源 Image 并开始 Brush +- **THEN** 两个对象都继续引用且显示原始 Image,直到 active Image Empty 在释放后接收新结果 + +#### Scenario: Stationary hold performs no Image work +- **WHEN** 用户按住 Brush 且鼠标没有产生有效位移 +- **THEN** 系统不执行 Image 更新或图片级计算 + +### Requirement: Brush completion and cancellation are transactional +Brush 完成时 MUST 创建并 Pack 一个最终结果 Image,并 SHALL 通过公共替换入口让 active Image Empty 承载结果。取消、工具切换或错误 MUST 保持源 Image 不变且不产生完成的编辑;成功完成 MUST 保持对象 identity、matrix、display size、offset、共享源引用和单步 Undo 语义。 + +#### Scenario: Completing a Brush persists one result +- **WHEN** 用户释放左键完成 Brush +- **THEN** 系统计算完整覆盖、创建并 Pack 一个同尺寸结果,active Image Empty 的画框和对象身份保持不变 + +#### Scenario: Cancelling restores the source +- **WHEN** 用户在 Brush 期间按 RMB 或 Esc +- **THEN** active Image Empty 仍引用操作前 Image,且没有临时 Image 或完成的 Undo 编辑 + +#### Scenario: Tool switch cleans the preview +- **WHEN** Brush 运行期间 active WorkspaceTool 发生变化 +- **THEN** 系统只结束 Overlay 与 Modal,Image 保持不变 + +### Requirement: Brush uses one continuous primitive union +Brush MUST 从简化后的屏幕轨迹生成圆形采样点和等宽连接条。Overlay 与释放后的 Selection MUST 使用同一组 Primitive 和 union 语义;急转、折返、自交与重叠区域 MUST 保持连续,MUST NOT 出现超出 Radius 的尖角或未填充三角。图片空间 union MUST 以逐像素最大值累计到单个覆盖画布,不得保存逐 Primitive Mask 列表。 + +#### Scenario: Sparse events leave no gap +- **WHEN** 两个连续 Brush 采样点之间的距离大于 Radius +- **THEN** 两点之间的胶囊完整覆盖连线且不出现空隙 + +#### Scenario: Turning does not create a horn or hole +- **WHEN** Brush 中心轨迹发生方向变化 +- **THEN** 圆形采样点与连接条融合,Overlay 与最终 Selection 都不出现 miter 尖角或未填充三角 + +#### Scenario: Preview and result share the footprint +- **WHEN** 用户释放 Brush +- **THEN** 系统投影并栅格化拖动时显示的同一组 Brush Primitive,并使用相同的 union 规则 + +### Requirement: Brush matches the Lasso and Polyline overlay +Brush SHALL 在每个有效鼠标事件后绘制整次未提交笔画的 footprint。Overlay MUST 复用 Lasso、Polyline 的虚线边界、non-zero winding 三角化和半透明填充;自交区域 MUST 融合为一个填充区域,虚线 MUST 只绘制融合后的外轮廓而不得保留内部交错边。Subtract MUST 使用红色填充,Set 与 Add MUST 使用灰色填充。 + +#### Scenario: Overlay persists until release +- **WHEN** 用户继续拖动 Brush 且尚未释放左键 +- **THEN** Viewport Overlay 以当前 Mode 的填充和虚线边界显示从起点到当前点的准确 footprint,Image 本身保持不变 + +#### Scenario: Crossing strokes merge visually +- **WHEN** Brush footprint 与自身交错或重叠 +- **THEN** 交错部分显示为连续的一整块填充,虚线只围绕融合区域的外边界 + +### Requirement: Polyline edits Alpha through the Mask pipeline +Polyline SHALL 以逐点单击构建闭合 Selection:鼠标移动 MUST 预览下一条边;至少三个点后点击起点 8 px 范围或按 Enter MUST 完成;Backspace MUST 删除最后一个已提交点;RMB 或 Esc MUST 取消。完成的 Polyline MUST 栅格化为一个 SelectionMask,并 MUST 以当前 Set、Add 或 Subtract Mode 修改当前 Alpha,保持 RGB、图片尺寸和 Image Empty 画框不变。 + +#### Scenario: Clicking the start closes Polyline +- **WHEN** 用户已提交至少三个点并在起点 8 px 范围内单击 +- **THEN** 系统闭合路径并应用当前 Mask Mode + +#### Scenario: Enter confirms Polyline +- **WHEN** 用户已提交至少三个点并按 Enter 或 Numpad Enter +- **THEN** 系统闭合路径并应用当前 Mask Mode + +#### Scenario: Backspace removes one point +- **WHEN** Polyline 已有两个以上已提交点且用户按 Backspace +- **THEN** 系统删除最后一点并继续预览未完成路径 + +### Requirement: Image selections carry only positive path geometry +`SelectionPath` SHALL 只保存并序列化 Points。Mask、Cutout、Scene Property、Operator 参数和 Server Job 请求 MUST NOT 声明或传递 Invert、Keep Original、Refine Selection 或 Refine Model 状态;Selection 栅格化 SHALL 只建立带抗锯齿 Padding 的局部 Path Bounds。 + +#### Scenario: Cutout serializes a path +- **WHEN** Cutout 把 Lasso SelectionPath 传给 Shape Operator +- **THEN** JSON 只包含 `points`,恢复后得到相同的正向路径 + +#### Scenario: Public state contains current concepts +- **WHEN** 系统枚举 Scene Property、Mask/Cutout Operator annotation 和 Cutout Job 请求 +- **THEN** 只出现当前 Gesture、Mode、Radius、Shape、Depth 与 Normal 业务字段 diff --git a/openspec/changes/add-live-mask-preview-and-polyline/tasks.md b/openspec/changes/add-live-mask-preview-and-polyline/tasks.md new file mode 100644 index 0000000..4137404 --- /dev/null +++ b/openspec/changes/add-live-mask-preview-and-polyline/tasks.md @@ -0,0 +1,35 @@ +## 1. Mask 设置与 Selection 精简 + +- [x] 1.1 将 Mask Mode 默认值改为 Subtract,在 Gesture 中增加 Polyline,并更新 Scene Property、Operator 与 Tool Settings 测试 +- [x] 1.2 删除 `SelectionPath.invert`、JSON `invert` 字段、完整画布反选 bounds 和补集栅格化分支,使 Cutout 序列化只传递 points +- [x] 1.3 清理测试中遗留的 Keep Original、Refine Selection 参数及重复历史缺席断言,以当前 Property、Operator、Job 和打包内容的精确集合固定公开边界 +- [x] 1.4 更新 Selection 与 Cutout 测试,验证局部抗锯齿 bounds、JSON 往返、整图 Cutout 和普通 Lasso 在精简后保持正确 + +## 2. Polyline Gesture + +- [x] 2.1 在无 Crop 术语的 `ImageGesture` 中实现 Polyline 点提交、移动预览、8 px 起点闭合、Enter 完成、Backspace 回退和取消状态机 +- [x] 2.2 为 Polyline 绘制闭合预览、虚线轮廓和起点指示,并更新 Mask 状态文字与 Radius 显示条件 +- [x] 2.3 将完成的 Polyline 接入 Lasso 共用的 SelectionMask 与 Alpha 合成入口,验证 Set、Add、Subtract、画布保持和对象点选语义 +- [x] 2.4 补充 Polyline Modal、Overlay、闭合方式、无效路径、取消和工具切换测试 + +## 3. Brush Footprint 与 Alpha + +- [x] 3.1 将 Alpha 公式整理为完整 Selection 共用的纯函数,固定 Set、Add、Subtract 结果 +- [x] 3.2 从简化后的屏幕轨迹生成圆形采样点和等宽连接条,并在 screen space 与图片空间分别执行同语义 union +- [x] 3.3 删除逐 Primitive Mask 列表和失去调用方的 `merge_selection_masks()`,图片空间只保留一个累计覆盖画布 +- [x] 3.4 补充单击、稀疏移动、急转折返、自交、倾斜/缩放投影与图片边界测试 + +## 4. Brush 虚线预览与延迟提交 + +- [x] 4.1 删除临时 Image、dirty RGBA、30 FPS Flush 与实时 Pixel Buffer 更新,使 Brush 拖动阶段不读取或修改 Image +- [x] 4.2 将 Brush Primitive 在 screen-space scanline bands 中融合,消除内部交错边、急转尖角和未填充三角,并复用 Lasso、Polyline 的填充配色 +- [x] 4.3 让 Overlay 与鼠标释放后的 Selection 栅格化复用同一组 Primitive 与 union 语义,再通过公共 Alpha 与 Image 替换入口一次性提交 +- [x] 4.4 取消、异常与工具切换只结束 Overlay,不创建临时 Image 或恢复分支,并保持对象 identity、transform、画框和单步 Undo +- [x] 4.5 更新 Brush 急转折返、Primitive Union、融合外轮廓、Overlay 配色、释放提交和真实 Blender Image 生命周期测试 + +## 5. 文档与验证 + +- [x] 5.1 更新 README 与 Image Edit、Common、Cutout 内部文档,说明三种 Gesture、Subtract 默认值、Brush Mode 填充、Primitive Union、释放提交和只含 points 的 SelectionPath +- [x] 5.2 搜索并清除运行时与文档中的 Keep Original、Refine Selection、Selection Invert、旧 Brush 全轨迹合并 Helper 和历史 Crop Polyline 名称 +- [x] 5.3 运行 Selection、Blender Addon、Cutout、Image Edit 与 Packaging 定向测试并修正失败 +- [x] 5.4 运行 `uv run pytest` 和 OpenSpec strict validation,不构建文档、节点资产或发布产物 diff --git a/openspec/changes/enable-image-alpha-blending-and-premul/.openspec.yaml b/openspec/changes/enable-image-alpha-blending-and-premul/.openspec.yaml new file mode 100644 index 0000000..1d9aeef --- /dev/null +++ b/openspec/changes/enable-image-alpha-blending-and-premul/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-04 diff --git a/openspec/changes/enable-image-alpha-blending-and-premul/design.md b/openspec/changes/enable-image-alpha-blending-and-premul/design.md new file mode 100644 index 0000000..a65e708 --- /dev/null +++ b/openspec/changes/enable-image-alpha-blending-and-premul/design.md @@ -0,0 +1,99 @@ +## Context + +用户已确认 Empty 的 Alpha Blending 有效,并要求继续采用 Premultiplied。当前 `create_image_edit_result()` 设置 STRAIGHT,Job Color 加载设置 CHANNEL_PACKED,Plane 复制和 Cutout 裁剪图片继承源模式。材质 Color 与 Alpha 已分别连接,材质使用 DITHERED。 + +本次采用 Blender `PREMUL` 解释模式作为用户指定的默认策略。它涉及读写和采样解释,并非纯粹的显示开关;业务像素依然需要保留独立 RGB,包括 Alpha 为零时的颜色。 + +## Goals / Non-Goals + +**Goals:** + +- AnyImage 新建及编辑结果 Empty 开启连续 Alpha 混合。 +- 编辑结果与 Mesh Color 图片统一默认 PREMUL。 +- 保留 Mask Add 的颜色恢复、Frame 底层 RGB 和现有 Alpha 计算。 +- 保持共享源保护、撤销和失败恢复语义。 +- Frame 投射边界同时平滑 RGB 与 Alpha,保持内部细节及既有输出尺寸。 + +**Non-Goals:** + +交付范围为图片呈现默认值、必要的数据读写保护及 Frame 像素边界采样。Depth、Normal、节点资产、Mesh 几何和 Server 算法沿用既有约定;普通用户图片在未参与操作时保持原设置。 + +## Decisions + +### 1. 先验证模式切换,再调整公共入口 + +先用真实 Blender 构造包含非零隐藏 RGB 和 Alpha 0、0.25、0.5、1 的小图片,对 GENERATED byte buffer、浮点源及加载的 RGBA PNG 验证:模式切换前后读取、Pack、PNG 字节、临时 blend 保存重开和再次编辑。 + +PREMUL 由用户明确要求,因此本次不是把最终持久化 RGB 数值统一乘 Alpha。业务编辑与导出仍需得到和现有算法一致的 RGB/Alpha。具体设置顺序由真实 Blender 测试决定;如需在入口短暂切换解释模式以保留像素,须保证恢复最终 PREMUL,且仅发生在当前操作拥有的结果 Image 上。 + +用户自行验证后明确要求直接落地 PREMUL。模式设置在操作拥有的 Color 结果上,不增加双图片存储。业务读取 PREMUL 图片时临时使用 STRAIGHT 解释取得原始 RGB,并立即恢复最终 PREMUL;这只改变读取解释,不修改 Image 像素。完整浮点重载、序列逐帧和实际视口验证单独列为未完成验收。 + +### 2. Empty 混合在成功提交时设置 + +`replace_empty_image()` 成功提交结果时开启 `use_empty_image_alpha`;Frame 自定义恢复分支同步检查该设置。原 Image、混合开关和显示范围均纳入失败恢复。取消手势不修改源对象。 + +剪贴板创建 Image Empty 时直接开启同一开关。`color[3]`、显示深度、显示方向和 image_user 设置继续保留各自业务含义。 + +### 3. 图片模式在 Color 产物边界统一 + +```mermaid +flowchart TD + Local[Mask / Frame / Rectify] --> Create[公共编辑结果创建] + Jobs[Remove Background / Upscale 等图片编辑 Job] --> Load[公共图片结果加载] + Create --> Mode[结果 Image 默认 PREMUL] + Load --> Mode + Mode --> Replace[替换 Empty 并开启 Alpha Blending] + Plane[Plane 复制 / Cutout 裁剪 / AI Color] --> Material[公共材质 Color 入口] + Material --> Color[Color Image 默认 PREMUL] + Clipboard[新建 Image Empty] --> Blend[开启 Alpha Blending] +``` + +公共 Color 创建、加载和材质配置复用 `common/image.py` 中职责明确的模式设置入口。避免在每个 Operator 重复赋值,或继续保留 STRAIGHT/CHANNEL_PACKED 的竞争性最终设置。只读像素计算仍可在临时数组中预乘;Rectify 保留原采样,Frame 按下节扩展几何边界采样,各样本沿用已确认的图层合成公式。 + +普通图片通过共享编辑 Job 加载入口落地时也采用 PREMUL,包括 Upscale;视频和序列沿用现有时间映射并验证多个帧的读取。剪贴板仅创建 Reference 时,本次只保证对象 Alpha Blending,图片模式在后续编辑或 Mesh Color 入口配置。 + +### 4. Mesh 配置的是 Image,而非节点独立模式 + +`create_image_material()` 为 Color 图片统一设置 PREMUL。Plane 使用既有复制图片,Cutout 使用既有裁剪结果,AI 分支使用新加载的 Color Image;不得通过修改原共享源图片间接影响未参与对象。若某个入口直接复用源图片,先沿用创建独立 Color 产物的规则再配置。 + +Normal 和 Depth 继续保留独立通道及 Non-Color 语义。保留 Color/Alpha 连线、Linear 插值和 DITHERED;材质构建后的颜色空间设置也纳入像素保留测试。 + +### 5. Frame 边界使用 4×4 子像素覆盖采样 + +输出像素覆盖的屏幕区域与源投射边界或深度层级交界相交时,在像素内使用均匀 4×4 网格的中心点采样。边界判定基于像素覆盖区域与几何边界,不能仅以像素中心命中作为条件;透视跨观察平面的情况复用现有有效求交规则。覆盖和顺序稳定的内部像素保持原中心双线性采样,以保留对齐图片的内部细节。 + +每个子样本独立计算有效覆盖和深度排序,再执行当前底层 RGB 打底、前景按 Alpha 叠加及独立 Alpha 合成。完全没有源覆盖的样本 Alpha 为零;最终 Alpha 为 16 个子样本 Alpha 的算术平均。RGB 为有几何覆盖样本的合成 RGB 等权平均,权重与源 Alpha 无关。没有任何覆盖时保持透明黑色,避免 Mask Add 显露不存在的源外颜色。 + +```mermaid +flowchart TD + Pixel[输出像素覆盖区域] --> Boundary{覆盖或层级边界} + Boundary -- 否 --> Center[原像素中心采样] + Boundary -- 是 --> Grid[4×4 子像素网格] + Grid --> Layers[逐样本有效求交与深度排序] + Layers --> Composite[逐样本底层 RGB 与前景合成] + Composite --> Resolve[Alpha 按全部样本平均
RGB 按有覆盖样本平均] + Center --> Output[完整 RGBA Canvas] + Resolve --> Output + Output --> Edge[清除完全无覆盖像素并交付结果] +``` + +两图重叠边界处,每个样本都有底图时,RGB 自然按前图几何覆盖比例混入底图;恢复 Alpha 后仍保留该过渡。单图外缘的无源样本只降低覆盖 Alpha,不以黑色压暗有效 RGB。源投射之外更远处没有可恢复内容,继续保持透明黑色。 + +按现有行块处理,可分批累积子样本,避免分配宽高各放大四倍的整图缓冲。4×4 是内部固定采样策略,不增加用户选项;评估耗时和峰值内存,边界优化必须与参考逐样本结果一致。 + +## Risks / Trade-offs + +- [PREMUL 解释与当前像素数值约定不同] → 先验证实际 Blender byte/float、PNG 读写和重复编辑,记录差异而非只断言枚举值。 +- [Alpha Blending 出现层间排序现象] → 验证 Empty 重叠与场景遮挡,保留对象深度策略,并记录已知显示限制。 +- [改变 Image 模式影响共享使用者] → 配置操作拥有的结果或 Color 副本,测试未参与共享对象的模式和像素。 +- [白色已混入 Frame RGB] → 保留用户确认的 RGB 合成规则;PREMUL 策略不承诺消除所有来源的白边。 +- [边界判定漏掉细窄投射] → 覆盖测试包含中心未命中但子样本命中的倾斜条带及跨观察平面投射。 +- [抗锯齿增加耗时或使内部变糊] → 只对几何边界启用多样本,按块累积,测试内部对齐细节与典型最大分辨率耗时/内存。 + +## Migration Plan + +先通过真实 Blender 像素保留验证,再实现公共默认值与集成测试。现有场景不做加载时批量修改,后续编辑成功或生成 Mesh Color 时应用默认值。只运行测试与 OpenSpec 校验,不构建产物或同步修改产品文档。 + +## Open Questions + +业务默认值已确定;PREMUL 与现有像素读写的匹配方式以第一组 Blender 验证作为实施门槛。 diff --git a/openspec/changes/enable-image-alpha-blending-and-premul/proposal.md b/openspec/changes/enable-image-alpha-blending-and-premul/proposal.md new file mode 100644 index 0000000..5483075 --- /dev/null +++ b/openspec/changes/enable-image-alpha-blending-and-premul/proposal.md @@ -0,0 +1,34 @@ +## Why + +用户已验证 Empty 开启 Alpha Blending 能改善渐变显示,并确认希望 AnyImage 处理后的图片及 Mesh Color 图片默认使用 Premultiplied。目前本地编辑、Job 结果与材质图片采用不同 Alpha 模式,需要统一这些交付入口并验证重复编辑和保存行为。 + +Frame 当前按像素中心判断图片投射覆盖,倾斜边缘存在阶梯;需要在 RGB 和 Alpha 中共同保留子像素覆盖信息,使 Mask Add 恢复后的图层边界也能平滑过渡。 + +## What Changes + +- AnyImage 新建或成功替换图片的 Image Empty 默认开启 `use_empty_image_alpha`,保留原有对象不透明度和深度设置。 +- Mask、Frame、Rectify 及 Remove Background 等公共图片编辑结果默认设置 `Image.alpha_mode = 'PREMUL'`。 +- Plane、Depth Plane 和 Cutout 的 Color 图片默认使用 `PREMUL`;共享普通 Color 结果加载入口的图片采用同一约定。 +- 将用户要求落实为 Blender Alpha 解释模式策略,保留既有 RGB/Alpha 像素编辑算法与透明 RGB;不额外永久执行 RGB 乘 Alpha。 +- 验证 Mask Add、重复 Frame、实际 Packed Image、保存重载及 Mesh 材质采样,避免模式切换改变可恢复的颜色数据。 +- Frame 几何边界采用 4×4 子像素覆盖采样,逐样本完成深度与图层合成,再汇总 RGB/Alpha;内部区域保留现有中心采样,输出尺寸不变。 + +## Capabilities + +### New Capabilities + +- `image-alpha-presentation`: AnyImage 图片与 Image Empty 的 Alpha 展示默认值、Color 材质图片策略及数据保留约束。 +- `frame-edge-antialiasing`: Frame 投射轮廓与图层边界抗锯齿,包含恢复 Alpha 后的 RGB 过渡和源外颜色保护。 + +### Modified Capabilities + +无。 + +## Impact + +- `src/anyimage/common/image.py`:公共图片创建、加载、替换入口和像素保留验证。 +- `src/anyimage/operators/image_edit_tool/frame.py`:边界子像素覆盖、逐样本排序合成及分块汇总。 +- `src/anyimage/common/material.py`:Mesh Color 图片进入材质时的统一 Alpha 模式。 +- `src/anyimage/operators/clipboard_image/actions.py`:新建 Image Empty 的 Alpha Blending。 +- `src/anyimage/operators/convert_to_plane/object.py`、`src/anyimage/operators/cutout_tool/object.py`:复制或裁剪创建的 Color 图片。 +- 相应 Blender 单元与集成测试。沿用现有依赖、节点资产和编辑算法;实施阶段不同步修改产品文档,不构建产物。 diff --git a/openspec/changes/enable-image-alpha-blending-and-premul/specs/frame-edge-antialiasing/spec.md b/openspec/changes/enable-image-alpha-blending-and-premul/specs/frame-edge-antialiasing/spec.md new file mode 100644 index 0000000..666284c --- /dev/null +++ b/openspec/changes/enable-image-alpha-blending-and-premul/specs/frame-edge-antialiasing/spec.md @@ -0,0 +1,81 @@ +## ADDED Requirements + +### Requirement: Frame samples geometric boundary coverage + +Frame SHALL 在输出像素与源投射轮廓或前后层级交界相交时使用固定 4×4 均匀子像素中心样本,分别判断有效覆盖及深度顺序。每个样本 SHALL 沿用现有底层 RGB 打底、前景预乘采样与独立 Alpha 合成公式。 + +#### Scenario: An opaque tilted edge covers half a pixel + +- **WHEN** 单张不透明图片仅覆盖该像素的 8 个子样本,其余 8 个没有源覆盖 +- **THEN** 输出 Alpha SHALL 为 0.5 +- **AND** 该像素 SHALL 不因中心点位于边缘某一侧而变为全有或全无 + +#### Scenario: The center misses a narrow projection + +- **WHEN** 像素中心未命中投射,但 4×4 网格存在有效命中 +- **THEN** 有效子样本 SHALL 参与边界合成 + +#### Scenario: Depth order changes inside a pixel + +- **WHEN** 两个源平面在像素覆盖区域内交换前后关系 +- **THEN** 每个子样本 SHALL 使用自己的深度顺序 +- **AND** 同深度 SHALL 沿用 active 置顶及现有稳定顺序 + +### Requirement: Frame resolves RGB and alpha with independent coverage weights + +边界像素 Alpha SHALL 为全部 16 个子样本 Alpha 的算术平均,无源样本贡献零。RGB SHALL 为具有几何源覆盖的子样本合成 RGB 的等权平均,SHALL NOT 以源 Alpha 作为 RGB 平均权重。全无覆盖像素 SHALL 保持透明黑色,不向 Frame Canvas 外扩展 RGB。 + +#### Scenario: Mask Add reveals the canvas outside every source + +- **WHEN** Frame 范围大于所有源投射,随后 Mask Add 将完全无覆盖区域的 Alpha 恢复为 1 +- **THEN** 该区域 RGB SHALL 保持黑色 +- **AND** 源边缘颜色 SHALL NOT 形成一像素外框 + +#### Scenario: A half-transparent foreground partially covers a transparent bottom image + +- **WHEN** 透明蓝色底图覆盖全部 16 个样本,Alpha 为 0.5 的红色前图覆盖其中 8 个 +- **THEN** 输出 RGB SHALL 为 (0.25, 0, 0.75) +- **AND** 输出 Alpha SHALL 为 0.25 + +#### Scenario: A white outer edge meets empty canvas + +- **WHEN** 不透明白色源覆盖 8 个子样本,其余没有源覆盖 +- **THEN** 输出 RGB SHALL 保持 (1, 1, 1),Alpha SHALL 为 0.5 +- **AND** 无源区域的黑色 SHALL 不混入白色边缘 + +#### Scenario: Transparent layers meet along an oblique boundary + +- **WHEN** 子样本合成结果 Alpha 均为零,但各自底层 RGB 不同 +- **THEN** 输出 SHALL 保留这些有效 RGB 的覆盖平均 +- **AND** Alpha SHALL 保持零 + +### Requirement: Restoring alpha preserves antialiased layer boundaries + +Frame SHALL 将边界颜色过渡存入结果 RGB,使实际 Packed Image 经 Mask Add 后仍保留图层间的抗锯齿过渡。 + +#### Scenario: Mask Add reveals a tilted foreground over a background + +- **WHEN** 倾斜图片与黑色或白色底图合成,经 Pack 后执行 Mask Add 恢复 Alpha +- **THEN** 边界 RGB SHALL 保留子像素覆盖混合的中间颜色 +- **AND** Mask Add SHALL 不将边缘恢复为单点覆盖产生的两色跳变 + +### Requirement: Frame keeps interior detail and bounded output processing + +Frame SHALL 对覆盖与层级稳定的内部像素保留现有中心双线性采样。结果 Canvas、输出尺寸限制和对象放置 SHALL 保持原约定;子样本处理 SHALL 按块执行并保持跨块结果一致。 + +#### Scenario: Pixel-aligned interior contains fine detail + +- **WHEN** 原尺寸对齐的图片内部包含交替明暗像素,且该区域没有几何层级边界 +- **THEN** 内部 RGB 与 Alpha SHALL 保持现有采样结果 + +#### Scenario: A boundary crosses processing chunks + +- **WHEN** 相同斜边使用不同的行块尺寸执行 Frame +- **THEN** 输出 SHALL 在浮点误差范围内一致 +- **AND** 边界 SHALL 不出现行块接缝 + +#### Scenario: Frame uses the maximum output resolution + +- **WHEN** 输出达到配置的最大分辨率 +- **THEN** 抗锯齿 SHALL 保持原输出尺寸 +- **AND** 系统 SHALL 不为全部源分配完整 4 倍宽、4 倍高的输出副本 diff --git a/openspec/changes/enable-image-alpha-blending-and-premul/specs/image-alpha-presentation/spec.md b/openspec/changes/enable-image-alpha-blending-and-premul/specs/image-alpha-presentation/spec.md new file mode 100644 index 0000000..c673e48 --- /dev/null +++ b/openspec/changes/enable-image-alpha-blending-and-premul/specs/image-alpha-presentation/spec.md @@ -0,0 +1,94 @@ +## ADDED Requirements + +### Requirement: Image Empty results enable alpha blending + +AnyImage SHALL 为新建的 Reference Image Empty 及成功提交图片编辑结果的 Image Empty 设置 `use_empty_image_alpha = True`,并保留对象原有整体不透明度与深度设置。 + +#### Scenario: An alpha editing operation succeeds + +- **WHEN** Mask、Frame、Rectify 或 Remove Background 成功替换 Image Empty 图片 +- **THEN** 承载结果的 Empty SHALL 开启 Alpha Blending +- **AND** 对象 `color[3]` 与 `empty_image_depth` SHALL 保持原值 + +#### Scenario: A reference image is pasted + +- **WHEN** AnyImage 新建剪贴板 Reference Image Empty +- **THEN** 对象 SHALL 默认开启 Alpha Blending + +#### Scenario: Editing is cancelled or fails + +- **WHEN** 用户取消手势或结果提交失败 +- **THEN** 原 Image 和 Empty Alpha Blending 状态 SHALL 保持或恢复到操作前 + +### Requirement: Edited color images default to PREMUL + +AnyImage SHALL 将本地图片编辑结果及公共图片编辑 Job 加载结果的最终 `Image.alpha_mode` 设置为 `PREMUL`。该默认值 SHALL 在成功交付后生效,SHALL NOT 依赖源图片原模式。 + +#### Scenario: Local alpha editing creates a packed result + +- **WHEN** Mask、Frame 或 Rectify 从 STRAIGHT、CHANNEL_PACKED 或 PREMUL 源创建结果 +- **THEN** 结果 Image SHALL 使用 PREMUL 并保留现有 Pack 规则 + +#### Scenario: A background removal result is loaded + +- **WHEN** Remove Background 或共享图片编辑结果加载入口的 Upscale 返回 RGBA 图片 +- **THEN** 加载结果 SHALL 使用 PREMUL +- **AND** 序列结果 SHALL 保留原有帧映射、播放范围及逐帧像素内容 + +### Requirement: Alpha presentation preserves editable pixel content + +Alpha 呈现默认值 SHALL 保留既有业务编辑算法产出的 RGB 与 Alpha 数值,包括完全透明 RGB,并 SHALL 保证 Pack、保存重载和重复编辑后仍可读取与恢复这些内容。系统 SHALL NOT 为设置 PREMUL 而永久清除透明 RGB 或重复乘入 Alpha。 + +#### Scenario: Mask Add restores hidden color after reload + +- **WHEN** 图片经过 Mask Subtract、结果 Pack、blend 保存重开,再执行 Mask Add +- **THEN** 恢复区域 SHALL 保留原隐藏 RGB,误差仅限既有编码精度 +- **AND** 结果 SHALL 继续使用 PREMUL + +#### Scenario: A PREMUL result is framed again + +- **WHEN** PREMUL 结果以像素对齐映射再次执行 Frame +- **THEN** RGB 和 Alpha SHALL 符合 Frame 图层合成与边界覆盖采样规则 +- **AND** 模式设置 SHALL 不导致额外 Alpha 相乘或颜色衰减 + +#### Scenario: Foreground alpha is restored over a white bottom image + +- **WHEN** 当前 Frame 合成包含透明白色底图及 Alpha 渐变前景,随后恢复结果 Alpha +- **THEN** RGB SHALL 保留前景到白色的连续混合 +- **AND** Alpha SHALL 沿用当前独立合成与编辑公式 + +#### Scenario: Packed pixels and exported PNG are inspected + +- **WHEN** 检查 byte buffer 编辑结果、浮点源派生结果及加载的 PNG 结果 +- **THEN** 操作前后、Pack 和导出重载后的业务 RGB/Alpha SHALL 在约定编码精度内一致 + +### Requirement: Mesh Color images use PREMUL independently of data textures + +AnyImage 为 Plane、Depth Plane 及 Cutout 创建的材质 SHALL 将 Color Image 配置为 PREMUL,并保持独立 Color/Alpha 连线及当前材质透明策略。Depth 和 Normal Image SHALL 保留各自数据通道、Alpha 模式与颜色空间约定。 + +#### Scenario: A local mesh uses copied or cropped color + +- **WHEN** Plane 使用源图片副本或 Cutout 使用裁剪 Color 图片构建材质 +- **THEN** Color Image SHALL 为 PREMUL +- **AND** 构建过程 SHALL 保留既有 Color 像素及颜色空间策略 + +#### Scenario: AI geometry uses generated textures + +- **WHEN** Depth Plane 或 Cutout 加载 AI Color、Depth 和 Normal 结果 +- **THEN** Color Image SHALL 为 PREMUL +- **AND** Depth 与 Normal SHALL 保留原有数据解释方式 + +#### Scenario: Source images have other users + +- **WHEN** 被编辑或转换的源 Image 同时被未参与的对象引用 +- **THEN** 模式设置 SHALL 作用于结果 Image 或独立 Color 副本 +- **AND** 未参与对象引用的源 Image 模式与像素 SHALL 保持不变 + +### Requirement: Defaults are applied at operation boundaries + +AnyImage SHALL 在新建或成功交付范围内应用本策略,SHALL NOT 在注册、加载场景或普通重绘时批量修改已有图片。 + +#### Scenario: An existing scene is opened + +- **WHEN** 用户打开含有已有 Image Empty 和材质的场景 +- **THEN** 未参与 AnyImage 新操作的图片及对象设置 SHALL 保持原样 diff --git a/openspec/changes/enable-image-alpha-blending-and-premul/tasks.md b/openspec/changes/enable-image-alpha-blending-and-premul/tasks.md new file mode 100644 index 0000000..fba97d7 --- /dev/null +++ b/openspec/changes/enable-image-alpha-blending-and-premul/tasks.md @@ -0,0 +1,30 @@ +## 1. Blender 数据保留验证 + +用户确认直接实施 PREMUL。byte GENERATED 与加载 PNG 的 Blender 4.5.3 重载解释差异保留在 `tests/test_image_premul_retention.py`;原始 Packed PNG 字节保持。公共业务像素入口在 PREMUL 下临时按 STRAIGHT 读取原始 RGB 并立即恢复模式,避免 Mask 将 Blender 展开的 RGB 写回。已验证 byte/float 独立 Color 产物与 Mask → Frame → Pack → reload → Mask Add;完整浮点保存重载和序列逐帧验收仍待完成。 + +- [x] 1.1 使用真实 Blender 为 byte/float 与 RGBA PNG 建立 PREMUL 模式切换、读取、Pack、导出和 blend 保存重开测试,包含隐藏 RGB 与连续 Alpha;记录适用的设置顺序。 +- [x] 1.2 验证 PREMUL 结果再次 Mask Add、Frame 和序列逐帧读取仍符合原像素算法;业务读取须绕过 Blender 的 PREMUL RGB 展开并恢复最终模式。 + +## 2. 公共默认值 + +- [x] 2.1 在 common 中统一 Color Image 的 PREMUL 设置与必要的像素保留操作,接入本地编辑和公共图片 Job 结果入口。 +- [x] 2.2 在成功替换和新建 Reference Image Empty 时开启 use_empty_image_alpha,补全失败恢复,保留不透明度、深度与时间映射。 +- [x] 2.3 在材质 Color 入口及 Plane/Cutout Color 创建路径应用 PREMUL,保护共享源 Image,并保持 Depth/Normal 数据贴图原设置。 + +## 3. Frame 边缘抗锯齿 + +- [x] 3.1 增加覆盖及层级边界判定与 4×4 子像素参考采样,复用有效求交和逐样本排序;稳定内部继续中心采样。 +- [x] 3.2 按全部子样本平均 Alpha,按有效覆盖样本平均 RGB,保留底层颜色规则并清除完全无覆盖的源外 RGB;采用分块累积。 +- [x] 3.3 补充倾斜轮廓、中心漏采细条、交叉平面、透明图层、半覆盖数值、源外无黑边及内部细节测试。 +- [x] 3.4 验证实际 Frame → Pack → Mask Add 的黑/白底图边缘 RGB,检查行块一致性并记录典型最大分辨率的耗时和峰值内存。 + +## 4. 集成与验收 + +- [x] 4.1 覆盖 Mask、Frame、Rectify、Remove Background 及共享加载入口的最终模式、取消/失败恢复和撤销边界测试。 +- [x] 4.2 覆盖 Plane、Depth Plane、Cutout 的 Color Image、材质连线、颜色空间切换和共享源保护测试。 +- [x] 4.3 使用黑/白背景与渐变边缘检查 Empty Alpha Blending、PREMUL 及 Mesh 显示,并记录多图排序或尚未完成的视口验收限制。 +- [x] 4.4 运行定向测试、完整 uv run pytest 与 OpenSpec 严格校验;不修改产品文档,不构建节点、文档或扩展包。 + +最终自动测试:460 passed,56 subtests passed;OpenSpec strict 校验通过。当前 10/13 项完成,1.1、1.2 和 4.3 的剩余验收如上所述,不作为用户已确认的默认值落地阻塞。 + +验收记录:两层小纹理源、默认 2048×2048 输出约 7.916 秒,tracemalloc 峰值 359.2 MiB;8192×2048(16,777,216 像素上限)约 32.481 秒、1436.6 MiB。该数值为 Python/NumPy 跟踪内存,不是进程 RSS;未为源分配整图 4×4 放大副本。行块 1/3/256 结果一致。实际 Blender 视口、多图排序与完整序列验收尚未执行。 diff --git a/openspec/changes/match-polyline-gesture-interaction/.openspec.yaml b/openspec/changes/match-polyline-gesture-interaction/.openspec.yaml new file mode 100644 index 0000000..9696e00 --- /dev/null +++ b/openspec/changes/match-polyline-gesture-interaction/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/openspec/changes/match-polyline-gesture-interaction/design.md b/openspec/changes/match-polyline-gesture-interaction/design.md new file mode 100644 index 0000000..b44d334 --- /dev/null +++ b/openspec/changes/match-polyline-gesture-interaction/design.md @@ -0,0 +1,66 @@ +## Context + +`ImageGesture` 已在屏幕空间维护 Polyline 的已提交顶点与移动光标,并在三个顶点后允许点击起点 8 px 范围完成。当前闭合圈只在进入命中范围后以固定尺寸出现,缺少接近过程反馈;Modal 也不处理 `DOUBLE_CLICK`。Mask 使用单一 WorkspaceTool 承载 Lasso、Brush 与 Polyline,当前未声明工具光标。 + +Blender 的 Polyline Gesture 将实际点击半径与更大的视觉提示范围分开:实际命中半径为 `15 × UI Scale`,鼠标进入约为该半径平方的提示距离后,起点圈从 `1 × UI Scale` 平滑放大到实际命中半径。Polyline modal keymap 将左键双击作为 Confirm;Sculpt 的 Box、Lasso、Line 与 Polyline Mask/Hide 工具统一使用 `PAINT_CROSS`。 + +## Goals / Non-Goals + +**Goals:** + +- 让 Polyline 起点闭合提示具有与 Blender 一致的渐进接近反馈和命中范围。 +- 支持双击完成有效 Polyline,并保留现有完成、回退和取消方式。 +- 让统一 Mask Tool 使用适合区域编辑的十字光标。 +- 将距离、缩放与事件判定保持为易单测的屏幕空间逻辑。 + +**Non-Goals:** + +- 不调用或包装 Blender 内建 Polyline Operator。 +- 不改变 Polyline 顶点投影、Selection 栅格化或 Alpha 合成。 +- 不加入起点吸附、自动修改最后一个顶点或新的用户设置。 +- 不拆分现有单一 Mask Tool。 + +## Decisions + +### 1. 分离闭合命中半径与视觉提示距离 + +定义 15 px 基础闭合半径,并使用操作开始时读取的 UI Scale 得到实际命中半径。视觉提示距离沿用 Blender 的 `scaled_radius²`;只有已提交至少三个点且鼠标位于该范围内时才绘制起点圈。 + +提示圈半径使用 smoothstep 随距离插值:鼠标位于提示范围边缘时接近 `1 × UI Scale`,位于起点时等于实际闭合半径。绘制使用一明一暗的相邻轮廓,使提示在亮、暗图片上都可辨认。 + +选择独立的纯计算 Helper,而不是在 Draw Handler 内直接散落公式,以便精确测试边界、单调性和 UI Scale。 + +### 2. 点击只使用实际闭合半径 + +渐进提示范围只控制视觉反馈,不扩大可点击区域。普通左键按下仍先判断是否已有三个点且光标位于实际闭合半径内;命中时直接完成并且不提交新顶点,否则沿用现有最小点距规则添加顶点。 + +不使用整个提示范围作为命中范围,避免鼠标离起点较远时意外闭合。 + +### 3. Modal 同时处理原生双击与连续按下 + +`ImageGesture._modal_polyline()` 在普通左键按下分支之前处理 `LEFTMOUSE` 的 `DOUBLE_CLICK`。同时记录最近一次左键按下的位置与时间:当 Blender 只向 Python Modal 连续发送两个 `PRESS` 时,使用用户设置中的双击间隔和 `5 px × UI Scale` 位移范围识别第二击。当已提交至少三个点时复用 `_confirm_polyline()`;点数不足时保持 Modal,不应用 Selection。 + +Blender 内建 Gesture 依靠 Modal Keymap 将双击转换为 Confirm,但当前 Python Operator 没有该映射,因此不能只依赖 `DOUBLE_CLICK` 事件。不为 WorkspaceTool 增加第二个启动 keymap 项,因为双击发生时 Operator 已处于 Modal,连续按下判定可以避免再次启动 Operator 或复制完成逻辑。双击只完成现有已提交路径,第二击不得添加重复顶点。 + +### 4. 统一 Mask Tool 使用 PAINT_CROSS + +在 `MaskTool` 上声明 `bl_cursor = "PAINT_CROSS"`。这会覆盖 Lasso、Brush 与 Polyline,符合 Blender 对 Sculpt Mask/Hide 区域手势的统一光标约定,也避免根据 Gesture 动态设置和恢复 Window cursor。 + +### 5. 保留当前项目已有控制 + +Enter、Numpad Enter、Backspace、RMB、Esc、工具切换取消和至少三个已提交点的完成门槛保持不变。状态文本补充双击完成,但不改变 Mode 或 Brush Radius 的展示。 + +## Risks / Trade-offs + +- [提示距离按半径平方后明显大于实际命中范围] → 圆圈在远处保持接近 1 px,并通过连续放大表达接近程度;点击仍严格限制在实际半径内。 +- [UI Scale 与 Region 坐标可能在不同平台产生取整差异] → 全程使用浮点屏幕坐标,只在比较和绘制中复用同一个缩放值,并对 1.0 与非 1.0 Scale 建立单测。 +- [Python Modal 可能收到原生 `DOUBLE_CLICK`,也可能只收到连续 `PRESS`] → 同时支持两种事件链;连续按下仅在用户双击时间和小范围位移内成立,第二击不追加点。 +- [`PAINT_CROSS` 同时影响非 Polyline Gesture] → Blender 的 Box、Lasso 与 Polyline Mask/Hide 本身采用同一光标,因此统一行为优先于引入动态 cursor 生命周期。 + +## Migration Plan + +先替换闭合提示计算与绘制,再加入双击事件和 Tool cursor,随后更新状态文本、测试与内部文档。此变更不迁移持久数据;回滚只需恢复旧的固定半径提示、事件分支与默认 cursor。 + +## Open Questions + +无。交互常量、光标范围和双击完成语义均以 Blender 当前 Polyline Gesture 为准。 diff --git a/openspec/changes/match-polyline-gesture-interaction/proposal.md b/openspec/changes/match-polyline-gesture-interaction/proposal.md new file mode 100644 index 0000000..c42a7b5 --- /dev/null +++ b/openspec/changes/match-polyline-gesture-interaction/proposal.md @@ -0,0 +1,26 @@ +## Why + +当前 Polyline 只有固定 8 px 起点命中圈,闭合目标出现突然、范围偏小,并且只能点击起点或按 Enter 完成。对齐 Blender Sculpt Polyline Gesture 的渐进起点提示、双击完成和十字光标,可以让闭合意图更清楚并减少精确回点操作。 + +## What Changes + +- 至少提交三个点后,在起点周围显示随鼠标距离平滑放大的闭合提示圈。 +- 使用与 Blender Polyline Gesture 一致的 15 px 起点点击范围,并按 UI Scale 调整屏幕尺寸。 +- 支持在已有有效 Polygon 时双击左键完成 Polyline,同时保留点击起点、Enter、Backspace、RMB 和 Esc 行为。 +- Mask Tool 使用 `PAINT_CROSS` 光标,让 Lasso、Brush 与 Polyline 保持统一的图片区域编辑指针。 +- 更新 Polyline 状态提示、交互测试和内部文档。 + +## Capabilities + +### New Capabilities + +- `polyline-gesture-interaction`: 定义 Polyline 的渐进闭合提示、起点命中、双击完成、键盘控制与工具光标。 + +### Modified Capabilities + +## Impact + +- 影响 `common/viewport.py` 的 Polyline 屏幕空间提示、命中判断和 Modal 状态机。 +- 影响 `operators/image_edit_tool/mask.py` 的 Mask Tool 光标与状态文本。 +- 影响 Blender Add-on 交互测试与 Image Edit Tool 内部文档。 +- 不改变 SelectionPath、SelectionMask、Alpha 合成、图片尺寸、对象结构或 Server 协议,不增加依赖。 diff --git a/openspec/changes/match-polyline-gesture-interaction/specs/polyline-gesture-interaction/spec.md b/openspec/changes/match-polyline-gesture-interaction/specs/polyline-gesture-interaction/spec.md new file mode 100644 index 0000000..3853aef --- /dev/null +++ b/openspec/changes/match-polyline-gesture-interaction/specs/polyline-gesture-interaction/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Polyline exposes a progressive close target +Polyline SHALL 在至少三个已提交点后,根据鼠标到起点的屏幕距离显示渐进闭合提示。实际闭合半径 MUST 为 `15 px × UI Scale`;提示距离 MUST 为实际闭合半径的平方。鼠标从提示范围边缘接近起点时,提示圈半径 MUST 通过平滑插值从 `1 px × UI Scale` 增长到实际闭合半径,并 MUST 使用明暗对比轮廓保持可见。 + +#### Scenario: Cursor approaches the start point +- **WHEN** Polyline 已提交至少三个点,且鼠标从提示范围边缘向起点移动 +- **THEN** 起点提示圈连续放大,并在鼠标到达起点时显示为实际闭合半径 + +#### Scenario: Cursor remains outside the hint distance +- **WHEN** Polyline 已提交至少三个点,但鼠标位于提示距离之外 +- **THEN** 系统不绘制起点闭合提示圈 + +#### Scenario: Polyline has fewer than three points +- **WHEN** Polyline 尚未提交三个点 +- **THEN** 系统不绘制起点闭合提示圈且不允许点击起点完成 + +#### Scenario: UI Scale changes +- **WHEN** Blender UI Scale 不是 1.0 +- **THEN** 闭合命中半径、提示距离与提示圈尺寸使用同一 UI Scale 计算 + +### Requirement: Polyline supports deliberate mouse completion +Polyline SHALL 在至少三个已提交点后允许用户点击起点实际闭合半径内或双击左键完成。起点点击 MUST 不添加新顶点;双击的第二击 MUST 不添加重复顶点。点数不足时,点击或双击 MUST NOT 提交 Selection。 + +#### Scenario: User clicks inside the close target +- **WHEN** Polyline 已提交至少三个点,且用户在起点实际闭合半径内按下左键 +- **THEN** 系统使用现有已提交顶点完成 Polyline,不添加点击位置为新顶点 + +#### Scenario: User double-clicks after three points +- **WHEN** Polyline 已提交至少三个点,且用户双击左键 +- **THEN** 系统完成 Polyline,并且双击的第二击不产生重复顶点 + +#### Scenario: User double-clicks before three points +- **WHEN** Polyline 的已提交点少于三个,且用户双击左键 +- **THEN** 系统保持 Polyline Modal 运行且不提交 Selection + +#### Scenario: User clicks outside the close target +- **WHEN** Polyline 已提交至少三个点,且用户在起点实际闭合半径外单击有效的新位置 +- **THEN** 系统提交该位置为下一个顶点并继续 Polyline + +### Requirement: Polyline retains keyboard and cancellation controls +Polyline MUST 保留 Enter 与 Numpad Enter 完成、Backspace 删除最后一点、RMB 与 Esc 取消以及工具切换取消的现有行为。状态文本 SHALL 同时说明点击起点、双击或 Enter 可以完成,并说明 Backspace 删除一点。 + +#### Scenario: User confirms with Enter +- **WHEN** Polyline 已提交至少三个点,且用户按 Enter 或 Numpad Enter +- **THEN** 系统完成 Polyline + +#### Scenario: User removes the last point +- **WHEN** Polyline 已提交两个或更多点,且用户按 Backspace +- **THEN** 系统删除最后一个已提交点并继续预览 + +#### Scenario: User cancels the gesture +- **WHEN** 用户按 RMB、Esc 或切换到其他工具 +- **THEN** 系统取消未完成的 Polyline 并移除 Overlay + +### Requirement: Mask uses a cross painting cursor +Mask Tool SHALL 为 Lasso、Brush 与 Polyline Gesture 统一使用 Blender 的 `PAINT_CROSS` cursor。 + +#### Scenario: User activates Mask +- **WHEN** 用户在 3D Viewport 激活 Mask Tool +- **THEN** 工具光标显示为 `PAINT_CROSS`,并在切换 Lasso、Brush 与 Polyline 时保持一致 diff --git a/openspec/changes/match-polyline-gesture-interaction/tasks.md b/openspec/changes/match-polyline-gesture-interaction/tasks.md new file mode 100644 index 0000000..dc75202 --- /dev/null +++ b/openspec/changes/match-polyline-gesture-interaction/tasks.md @@ -0,0 +1,18 @@ +## 1. 渐进闭合提示 + +- [x] 1.1 在 `common/viewport.py` 定义基于 15 px 与 UI Scale 的闭合命中半径、提示距离和 smoothstep 提示圈半径纯计算入口 +- [x] 1.2 让 Polyline Overlay 在至少三个点后按鼠标距离绘制明暗双层渐进起点圈,并让点击闭合复用同一实际命中半径 + +## 2. 完成交互与光标 + +- [x] 2.1 在 Polyline Modal 中优先处理左键 `DOUBLE_CLICK`,有效路径直接完成且不追加重复顶点,点数不足时继续运行 +- [x] 2.2 为统一 Mask Tool 设置 `PAINT_CROSS` cursor,并更新状态文本以说明点击起点、双击、Enter 与 Backspace 控制 +- [x] 2.3 兼容 Python Modal 只收到连续 `PRESS` 的双击事件链,并复用 Blender 用户双击间隔 + +## 3. 验证与文档 + +- [x] 3.1 补充渐进提示边界、半径单调性、UI Scale、15 px 点击命中和提示范围不参与命中的测试 +- [x] 3.2 补充双击有效与无效路径、无重复顶点、Mask cursor 和状态文本测试,并保持现有 Enter、Backspace 与取消测试通过 +- [x] 3.3 更新 Image Edit Tool 内部文档中的 Polyline 交互说明 +- [x] 3.4 运行相关交互测试与完整 `uv run pytest`,不构建文档或扩展产物 +- [x] 3.5 补充连续 `PRESS` 双击的回归测试并重新运行相关测试与完整测试 diff --git a/openspec/changes/preserve-frame-bottom-layer-rgb/.openspec.yaml b/openspec/changes/preserve-frame-bottom-layer-rgb/.openspec.yaml new file mode 100644 index 0000000..1d9aeef --- /dev/null +++ b/openspec/changes/preserve-frame-bottom-layer-rgb/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-04 diff --git a/openspec/changes/preserve-frame-bottom-layer-rgb/design.md b/openspec/changes/preserve-frame-bottom-layer-rgb/design.md new file mode 100644 index 0000000..0d12958 --- /dev/null +++ b/openspec/changes/preserve-frame-bottom-layer-rgb/design.md @@ -0,0 +1,51 @@ +## Context + +Frame 使用逐像素有效投影和稳定深度顺序。结果 RGB 需要保存前景到最底层原色的完整渐变,以便 Mask Add 恢复 Alpha 后仍能自然显示。 + +## Goals / Non-Goals + +**Goals:** + +- 最底层原始 RGB 打底,前景按自身 Alpha 混合。 +- Alpha 独立沿用现有 source-over 与数值判定。 +- 复用当前投影、排序、分块采样和 Packed Image 生命周期。 + +**Non-Goals:** + +交付范围为 Frame 的 RGB 合成及测试;Mask、Rectify 与输出 Canvas 延续既有规则。 + +## Decisions + +### 1. 逐像素确定底层 + +复用 frame_source_coordinates 有效性和现有 order,第一个有效样本为底层。相同深度 active 在最上层,其余源沿用名称顺序。正交负深度、透视有效前方交点和局部覆盖按现有规则处理。 + +### 2. RGB 与 Alpha 独立累积 + +底层 RGB 使用未乘 Alpha 的双线性采样初始化。每个更近的前景使用预乘后插值的 P 与 A,执行 C = P + C × (1 - A)。底层自身不再叠加一次。 + +Alpha 从零开始,对包含底层的每一层执行 A_out = A_layer + A_out × (1 - A_layer)。最终 RGB 不除以该 Alpha。 + +```mermaid +flowchart TD + Input[源 RGBA 与有效投影] --> Order[逐像素从远到近排序] + Order --> Base[底层原始 RGB 初始化 C] + Order --> Alpha[所有层按 source-over 累积 Alpha] + Base --> Foreground[前景预乘样本叠加到 C] + Foreground --> Result[独立的 RGB 与 Alpha] + Alpha --> Result + Result --> Pack[边缘扩色与 Packed Image] +``` + +单源只有底层,保留其原始 RGB 采样。多源中前景的透明黑色邻居通过预乘采样排除;半透明前景持续混入底图白色,而不是等 Alpha 为零才切换颜色。 + +### 3. 源外扩色使用最终颜色 + +将合成颜色乘以最终 Alpha 交给现有 straight_rgba 处理 Alpha 数值判定与一圈扩色,再在全部有效覆盖位置写回合成颜色。这样源外扩色来自新的合成 RGB,源内透明色也得以保留。 + +## Risks / Trade-offs + +- [底层重复叠加导致变暗] → RGB 从第二层开始叠加,Alpha 从第一层开始。 +- [透明前景的黑背景污染插值] → 前景坚持预乘后插值,测试黑色透明邻居与白色底图。 +- [恢复 Alpha 前的半透明显示也会改变] → RGB 已融合底色,Alpha 仍单独控制可见性;这是本次确认的颜色语义。 +- [已合并图片无法找回分层信息] → 用户须用原始分层图片重新执行 Frame。 diff --git a/openspec/changes/preserve-frame-bottom-layer-rgb/proposal.md b/openspec/changes/preserve-frame-bottom-layer-rgb/proposal.md new file mode 100644 index 0000000..816ddcb --- /dev/null +++ b/openspec/changes/preserve-frame-bottom-layer-rgb/proposal.md @@ -0,0 +1,28 @@ +## Why + +Mask 保留了被剔除区域的 RGB,但后续 Frame 的预乘 Alpha 处理会丢失这些颜色,使用户再通过 Mask Add 恢复 Alpha 时无法找回图片内容。Frame 需要在透明结果中保留最底层图片的 RGB,同时保持前景渐变和既有 Alpha 合成。 + +## What Changes + +- 对每个输出像素,以现有逐像素深度顺序中最底层的有效图片样本作为隐藏 RGB 来源。 +- 以底层原始 RGB 打底,前景按自身 Alpha 连续混入底色,覆盖完全透明与半透明区域。 +- RGB 与 Alpha 分开合成:前景使用预乘采样叠加到底色,Alpha 独立沿用原有 source-over;恢复 Alpha 后边缘保持渐变。 +- 图片投射外的透明 Canvas 与现有边缘扩色继续保留;扩色仅填充没有有效源覆盖的透明区域。 +- 补充单图 Mask → Frame → Mask Add、多图透明叠放、半透明混合和有效投影边界测试。 + +## Capabilities + +### New Capabilities + +- `frame-transparent-rgb`: 定义 Frame 透明像素的底层 RGB 来源、保留规则及其与 Alpha 合成、边缘扩色的关系。 + +### Modified Capabilities + +无。 + +## Impact + +- `src/anyimage/operators/image_edit_tool/frame.py`:保留原始颜色采样入口,复用当前深度排序和投影有效性,补全透明结果 RGB。 +- `src/anyimage/common/image.py`:复用公共双线性采样与 RGBA 转换能力,保持其他调用方的现有结果。 +- `tests/test_blender_addon.py`、`tests/test_cutout_objects.py`:像素计算及 Packed Image 编辑链路验证。 +- 仅涉及 Blender 本地像素处理与测试;实施后运行测试,不构建文档或扩展包。 diff --git a/openspec/changes/preserve-frame-bottom-layer-rgb/specs/frame-transparent-rgb/spec.md b/openspec/changes/preserve-frame-bottom-layer-rgb/specs/frame-transparent-rgb/spec.md new file mode 100644 index 0000000..ffa1529 --- /dev/null +++ b/openspec/changes/preserve-frame-bottom-layer-rgb/specs/frame-transparent-rgb/spec.md @@ -0,0 +1,91 @@ +## ADDED Requirements + +### Requirement: Frame selects the bottom valid source for hidden RGB + +Frame SHALL 在每个输出像素复用当前有效投影和从远到近的稳定深度顺序,以第一个几何有效样本作为底层 RGB 来源。底层判定 SHALL 独立于 Alpha,并 SHALL 沿用相同深度时 active 位于最上层、其他对象按名称稳定排序的规则。 + +#### Scenario: Transparent foreground overlaps a transparent background +- **WHEN** 蓝色底层和红色前景覆盖同一像素,且两者 Alpha 都为零 +- **THEN** 输出 RGB SHALL 为底层蓝色 +- **AND** 输出 Alpha SHALL 为零 + +#### Scenario: The farthest image does not cover a pixel +- **WHEN** 最远图片在某像素没有有效投影,而其他图片具有有效投影 +- **THEN** 该像素的底层来源 SHALL 为实际覆盖它的最远有效图片 + +#### Scenario: Transparent images share the same depth +- **WHEN** active 与另一透明图片在相同深度覆盖同一像素 +- **THEN** 底层 RGB SHALL 来自非 active 图片 +- **AND** 多个非 active 同深度图片 SHALL 沿用现有名称排序确定底层 + +#### Scenario: Source planes cross +- **WHEN** 两张透明图片的平面相交,并在不同像素交换前后关系 +- **THEN** 各像素的隐藏 RGB SHALL 分别来自该像素处最底层的有效图片 + +#### Scenario: Projection validity depends on the view mode +- **WHEN** 一个候选底层在透视视图中位于观察方向后方,或无法取得有限且位于源边界内的交点 +- **THEN** 该样本 SHALL 不作为底层来源 +- **AND** 正交视图中有限且位于源边界内的非正深度交点 SHALL 继续具有底层资格 + +### Requirement: Frame preserves bottom RGB in transparent output + +Frame SHALL 在最终 straight RGBA 转换后 Alpha 为零且具有有效源覆盖的像素中,保存底层源未乘 Alpha 的 RGB 双线性采样结果。采样 SHALL 使用现有 Frame 投影坐标和边界约定;系统 SHALL 保持当前 Alpha 可见性数值判定与最终 Alpha 结果。 + +#### Scenario: A masked single image is framed without resampling displacement +- **WHEN** 单张图片经 Mask 剔除 Alpha 后,以原尺寸和像素中心对齐的映射执行 Frame +- **THEN** 透明区域的 RGB SHALL 与源图片对应像素一致 +- **AND** Alpha SHALL 保持剔除后的结果 + +#### Scenario: Transparent source colors require interpolation +- **WHEN** Frame 缩放或透视映射在底层完全透明区域取得非整数像素采样位置 +- **THEN** RGB SHALL 为底层原始 RGB 的双线性插值结果 +- **AND** Alpha SHALL 保持零 + +#### Scenario: Mask Add follows a packed Frame result +- **WHEN** 用户对 Mask 剔除后经 Frame 生成的单图 Packed Image 执行 Mask Add +- **THEN** 提升 Alpha 的区域 SHALL 显示 Frame 保留的底层颜色 +- **AND** 该颜色 SHALL 在 Packed Image 像素中实际存在 + +### Requirement: Frame blends foreground RGB over bottom color with independent alpha + +Frame SHALL 以最底层有效源的原始 RGB 双线性样本初始化颜色,随后从远到近对每个前景计算 C = P_front + C × (1 - A_front),其中 P_front 为预乘后插值的 RGB。Alpha SHALL 从零开始对包括底层在内的全部有效层独立执行 source-over,并沿用现有数值判定。最终 RGB SHALL 不除以合成 Alpha。 + +#### Scenario: Both layers are partially transparent +- **WHEN** 蓝色底层 Alpha 为 0.5,红色前景 Alpha 为 0.25 +- **THEN** 输出 Alpha SHALL 为 0.625 +- **AND** straight RGB SHALL 为红色 0.25、绿色 0、蓝色 0.75 + +#### Scenario: A visible foreground overlaps a transparent bottom layer +- **WHEN** 蓝色底层 Alpha 为零,红色前景 Alpha 为 0.25 +- **THEN** 输出 SHALL 为 RGB (0.25, 0, 0.75) 和 Alpha 0.25 +- **AND** 恢复 Alpha 后 SHALL 保留相同 RGB + +#### Scenario: Hidden neighbor colors meet a visible sampling edge +- **WHEN** 前景插值邻域同时含有可见红色像素和完全透明绿色像素 +- **THEN** 前景采样 SHALL 沿用预乘插值结果并按采样 Alpha 混入底色 +- **AND** 隐藏绿色 SHALL 不污染可见红色 + +#### Scenario: Restoring alpha reveals a continuous transition into white + +- **WHEN** 前景原图的黑色背景被 Alpha 隐藏,前景边缘 Alpha 逐渐下降,且底层原始 RGB 为白色、Alpha 为零 +- **THEN** RGB SHALL 随前景 Alpha 连续过渡到白色 +- **AND** Packed Image 经 Mask Add 恢复 Alpha 后 SHALL 保留该过渡 + +### Requirement: Source coverage takes priority over edge color extension + +Frame SHALL 保持完整 Canvas 和源外透明边缘扩色。存在有效源覆盖且最终透明的像素 SHALL 使用底层 RGB;没有有效源覆盖的像素 SHALL 保持 Alpha 零,并沿用紧邻可见内容的一圈 RGB 扩色,其余区域保持透明黑色。 + +#### Scenario: Transparent source pixels border visible content +- **WHEN** 源内部透明像素紧邻可见像素,且隐藏 RGB 与相邻可见颜色不同 +- **THEN** 源内部透明像素 SHALL 保留底层原始 RGB +- **AND** 该 RGB SHALL 不被边缘扩色覆盖 + +#### Scenario: The frame extends beyond all source images +- **WHEN** Frame 包含所有源投影之外的区域 +- **THEN** 外部像素 Alpha SHALL 为零 +- **AND** 一圈扩色之外的无覆盖像素 SHALL 为透明黑色 +- **AND** Canvas 尺寸 SHALL 保持原有 Frame 规则 + +#### Scenario: A visible edge crosses a processing chunk boundary +- **WHEN** 可见边缘跨越 Frame 行块边界 +- **THEN** 透明边缘颜色 SHALL 与使用同一规则处理完整画布的结果一致 diff --git a/openspec/changes/preserve-frame-bottom-layer-rgb/tasks.md b/openspec/changes/preserve-frame-bottom-layer-rgb/tasks.md new file mode 100644 index 0000000..d5382da --- /dev/null +++ b/openspec/changes/preserve-frame-bottom-layer-rgb/tasks.md @@ -0,0 +1,16 @@ +## 1. RGB 合成修正 + +- [x] 1.1 复核有效投影、最底层选择和公共采样入口,保留已有几何行为。 +- [x] 1.2 以底层原始 RGB 初始化,前景逐层按 Alpha 叠加;Alpha 独立沿用原公式,源外扩色使用最终颜色。 + +## 2. 验证 + +- [x] 2.1 更新半透明预期,补充三层合成、白色底图上的前景渐变、预乘插值和单源原始 RGB 测试。 +- [x] 2.2 验证实际 Packed Image 在 Mask Add 后保留前景到白色的连续渐变。 +- [x] 2.3 运行定向与全量测试,校验 OpenSpec;不改产品文档,不构建产物。 + +## 验证结果 + +- 定向测试:51 passed;全量 uv run pytest:440 passed。 +- 实际 Packed PNG 与 Mask Add 结果均验证了前景到白色的连续 RGB 渐变,Alpha 合成保持独立。 +- 未做 Blender 视口人工验收,未构建产物。 diff --git a/openspec/changes/preserve-image-tool-source-on-empty/.openspec.yaml b/openspec/changes/preserve-image-tool-source-on-empty/.openspec.yaml new file mode 100644 index 0000000..9696e00 --- /dev/null +++ b/openspec/changes/preserve-image-tool-source-on-empty/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/openspec/changes/preserve-image-tool-source-on-empty/design.md b/openspec/changes/preserve-image-tool-source-on-empty/design.md new file mode 100644 index 0000000..91273db --- /dev/null +++ b/openspec/changes/preserve-image-tool-source-on-empty/design.md @@ -0,0 +1,66 @@ +## Context + +图片工具没有实现自定义 Viewport Picker。普通点击由 keymap 调用 Blender `view3d.select`;Mask 与 Rectify 因为以 `PRESS` 启动 Operator,由 `resolve_image_edit_click()` 主动调用同一个原生选择。AnyImage 只比较点击前后的 active object,并验证 active source 是已选择的 Image Empty。 + +当前两条入口都把 `deselect_all=True` 传给原生选择。Image Empty 的透明像素可能没有拾取结果,因此透明区域与 Viewport 空白都会清除 active source。Mask、Rectify 和 Cutout 随后的起始动作便依赖第一点命中非透明图片内容。 + +## Goals / Non-Goals + +**Goals:** + +- 让 Mask、Rectify 与 Cutout 在已有 active Image Empty 时从透明区域或 Viewport 空白开始操作。 +- 继续使用 Blender 原生 Picker 决定命中的对象、active object 和普通选择结果。 +- 保持点击另一对象只切换选择、不同时启动图片操作。 +- 将行为变化限制在三个绘制型工具,不改变 Frame 的多选工作流。 + +**Non-Goals:** + +- 不实现只允许 Image Empty 的自定义 Picker、射线检测或 Alpha 命中。 +- 不撤销或过滤 Mesh、Light 等非 Image Empty 的原生选择结果。 +- 不保存隐藏的图片目标,也不在工具激活时冻结 source object。 +- 不改变手势投影、图片范围裁切、可见内容判断或结果生成。 + +## Decisions + +### 1. 原生 Picker 使用不清空选择的空命中策略 + +`resolve_image_edit_click()` 继续调用 `bpy.ops.view3d.select`,但使用 `deselect_all=False`。命中另一对象时 Blender 正常更新 active,函数返回该对象并结束本次 Operator;没有命中时 active 保持为原 Image Empty,函数把同一次事件解释为图片操作起点。 + +相比先执行默认选择再保存和恢复 Selection,这一方式不需要手动复制多选集合、写入 `select_set()` 或重建 active object。相比自定义射线检测,它继续继承 Blender 对遮挡、对象可选状态和 Viewport 的完整规则。 + +### 2. Cutout 单击使用同一空命中策略,Frame 保持原行为 + +Cutout 的 Lasso 由 `CLICK_DRAG` 启动,普通 `CLICK` 仍交给原生对象选择。公共 selection keymap 提供明确的 `deselect_on_empty` 策略:Cutout 传入 false,使空白单击保留 active source;Frame 沿用 true,使其多选图片集合仍可通过空白点击清空。 + +Mask 与 Rectify 的普通按下事件由 Operator 接管,空命中策略由 `resolve_image_edit_click()` 负责。Cutout 的整图双击也复用该解析入口。Shift 点击继续使用现有原生 toggle,不增加过滤规则。 + +不选择全局修改公共 keymap 默认值,因为 Frame 与三个单图片绘制工具的选择目的不同。 + +### 3. Source eligibility 与对象选择保持分离 + +命中任何另一对象时都保留 Blender 的选择结果,无论它是否为 Image Empty,本次不启动编辑。下一次操作仍通过 `active_image_edit_source()` 要求 active object 是已选择的 Image Empty;条件不满足时报告现有 warning。 + +不自动取消非 Image Empty,因为这会把 AnyImage 从业务资格验证扩展成对象选择管理,并使用户无法按 Blender 预期选择其他场景对象。 + +### 4. 起始点不判断图片范围或 Alpha + +没有拾取结果只表示继续使用 active source,不表示起点已经落在图片内容内。Mask、Rectify 与 Cutout 继续把完整手势投影到图片平面,并在提交阶段执行既有的画布相交、Alpha 内容和有效几何检查。完全位于图片外的手势仍取消并报告对应 warning。 + +## Risks / Trade-offs + +- [工具激活时空白点击不再 deselect all] → 这是三个绘制型工具维持操作对象的明确语义;用户仍可使用 Blender 的取消选择命令,Frame 也保留原行为。 +- [点击非 Image Empty 会失去当前图片 active] → 保留原生选择结果比维护隐藏 source 更可预测;用户重新点击目标 Image Empty 后即可继续。 +- [透明区域与真正的 Viewport 空白无法区分] → 两者都作为当前图片平面上的合法起始坐标处理,最终范围检查统一决定是否执行。 +- [共享 keymap 的参数可能意外改变 Frame] → 测试分别固定 Cutout 的 false 与 Frame 的 true,并保持默认值表达 Frame 现状。 + +## Migration Plan + +1. 为公共 selection keymap 增加空命中策略,并仅在 Cutout 选择入口关闭 deselect all。 +2. 修改点击解析入口,使 Mask、Rectify 和 Cutout 双击在空命中时保留 active source。 +3. 更新交互测试和当前内部文档,运行定向测试与完整测试。 + +回退时恢复两个原生选择调用的 `deselect_all=True`;不涉及持久数据、对象结构、Operator 参数或兼容层。 + +## Open Questions + +无。 diff --git a/openspec/changes/preserve-image-tool-source-on-empty/proposal.md b/openspec/changes/preserve-image-tool-source-on-empty/proposal.md new file mode 100644 index 0000000..9dc3243 --- /dev/null +++ b/openspec/changes/preserve-image-tool-source-on-empty/proposal.md @@ -0,0 +1,25 @@ +## Why + +Mask、Rectify 与 Cutout 当前把空白点击交给带 `deselect_all` 的 Blender 原生选择,导致透明像素或 Viewport 空白会清除 active Image Empty,后续手势只能从可拾取的可见像素开始。图片操作的起始坐标不应同时承担维持源对象选择的职责。 + +## What Changes + +- 保留 Blender 原生对象拾取:命中另一对象时正常切换 active,本次不启动图片操作。 +- Mask、Rectify 与 Cutout 在没有命中对象时保留当前 active Image Empty,使手势可从透明区域或 Viewport 空白开始。 +- 继续由各工具在提交阶段判断手势是否与图片画布或可见内容有效相交。 +- Frame 保持现有空白点击取消选择的行为;不建立只允许 Image Empty 的自定义 Picker,也不自动撤销非 Image Empty 的原生选择结果。 + +## Capabilities + +### New Capabilities + +- `image-tool-interaction`: 定义图片工具的原生对象选择、空白起始和源对象有效性规则。 + +### Modified Capabilities + + +## Impact + +- 修改 `common/viewport.py` 中图片工具点击解析与 selection keymap 的空白点击策略。 +- 调整 Mask、Rectify、Cutout 的交互测试与 Image Edit、Cutout、Common 内部文档。 +- 不改变图片、SelectionMask、Cutout Shape、Job 协议、依赖或 Blender 数据结构。 diff --git a/openspec/changes/preserve-image-tool-source-on-empty/specs/image-tool-interaction/spec.md b/openspec/changes/preserve-image-tool-source-on-empty/specs/image-tool-interaction/spec.md new file mode 100644 index 0000000..98124ca --- /dev/null +++ b/openspec/changes/preserve-image-tool-source-on-empty/specs/image-tool-interaction/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: Image tools preserve Blender object picking +Mask、Rectify 与 Cutout SHALL 使用 Blender 原生 Viewport Picker 决定点击命中的对象和 active object。系统 MUST NOT 按对象类型过滤原生命中结果,也 MUST NOT 在命中非 Image Empty 后恢复先前的图片选择。 + +#### Scenario: Another Image Empty is clicked +- **WHEN** 一个 Image Empty 为 active,用户使用 Mask、Rectify 或 Cutout 点击另一可拾取的 Image Empty +- **THEN** Blender 原生选择 SHALL 激活被点击的 Image Empty,且本次事件不得启动图片操作 + +#### Scenario: A non-image object is clicked +- **WHEN** 一个 Image Empty 为 active,用户使用 Mask、Rectify 或 Cutout 点击可拾取的 Mesh、Light 或其他非 Image Empty +- **THEN** 系统 SHALL 保留 Blender 对该对象的原生选择结果,且本次事件不得启动图片操作 + +### Requirement: Drawing image tools preserve the active source on an empty hit +当已选择的 active object 是 Image Empty 时,Mask、Rectify 与 Cutout MUST 在原生 Picker 没有命中对象时保留该 active source,并 MUST NOT 因 `deselect_all` 清空选择。透明图片像素与 Viewport 空白 SHALL 使用相同的空命中语义。 + +#### Scenario: Mask starts on a transparent or empty location +- **WHEN** 已选择的 active Image Empty 存在,用户以 Mask 在没有原生对象命中的位置按下鼠标 +- **THEN** 系统 SHALL 保持该 Image Empty active 并以该位置开始当前 Mask Gesture + +#### Scenario: Rectify starts on a transparent or empty location +- **WHEN** 已选择的 active Image Empty 存在,用户以 Rectify 在没有原生对象命中的位置按下鼠标 +- **THEN** 系统 SHALL 保持该 Image Empty active 并把该位置作为第一个 Rectify 点 + +#### Scenario: Cutout lasso starts outside pickable image content +- **WHEN** 已选择的 active Image Empty 存在,用户以 Cutout 从没有原生对象命中的位置开始拖动 +- **THEN** 系统 SHALL 保持该 Image Empty active 并开始 Cutout Lasso + +#### Scenario: Cutout full image starts from an empty location +- **WHEN** 已选择的 active Image Empty 存在,用户以 Cutout 在没有原生对象命中的位置双击 +- **THEN** 系统 SHALL 保持该 Image Empty active 并打开整图 Cutout Shape 选择 + +#### Scenario: Cutout empty click keeps the source +- **WHEN** 已选择的 active Image Empty 存在,用户以 Cutout 单击没有原生对象命中的位置且没有形成拖动或双击 +- **THEN** 系统 SHALL 保持当前选择且不启动 Cutout Shape 选择 + +### Requirement: Image operation eligibility uses the active selected Image Empty +Mask、Rectify 与 Cutout SHALL 仅在当前 active object 是已选择的 Image Empty 时开始图片操作。系统 MUST NOT 在工具激活时保存隐藏 source,也 MUST NOT 使用先前 active 的图片绕过当前选择。 + +#### Scenario: Active object is not an Image Empty +- **WHEN** 当前 active object 是非 Image Empty 或没有已选择的 active object,用户尝试开始 Mask、Rectify 或 Cutout +- **THEN** 系统 SHALL 取消本次图片操作并提示选择 active Image Empty + +### Requirement: Gesture validity is decided after collection +Mask、Rectify 与 Cutout MUST NOT 根据起始点是否位于图片画布或是否命中非零 Alpha 决定能否开始手势。系统 SHALL 在手势提交阶段使用各工具现有的图片相交、可见内容和有效几何规则决定是否执行。 + +#### Scenario: Gesture starts outside and later overlaps the image +- **WHEN** Mask、Rectify 或 Cutout 的起始点位于图片外,但完成的手势与图片有效相交 +- **THEN** 系统 SHALL 按完整手势执行对应图片操作 + +#### Scenario: Completed gesture remains outside the image +- **WHEN** 完成的 Mask、Rectify 或 Cutout 手势与图片没有有效相交 +- **THEN** 系统 SHALL 在提交阶段取消操作并报告对应 warning + +### Requirement: Frame keeps its existing empty-click selection behavior +Frame SHALL 保持 Blender 风格的空白点击取消选择行为,不得因 Mask、Rectify 与 Cutout 的空命中策略而改变。 + +#### Scenario: Frame clicks an empty viewport location +- **WHEN** 用户在 Frame 激活时普通点击没有原生对象命中的 Viewport 位置 +- **THEN** Frame 的原生选择 keymap SHALL 继续请求 deselect all diff --git a/openspec/changes/preserve-image-tool-source-on-empty/tasks.md b/openspec/changes/preserve-image-tool-source-on-empty/tasks.md new file mode 100644 index 0000000..f36778a --- /dev/null +++ b/openspec/changes/preserve-image-tool-source-on-empty/tasks.md @@ -0,0 +1,18 @@ +## 1. 原生选择策略 + +- [x] 1.1 为图片工具公共 selection keymap 增加明确的空命中 deselect 策略,保持 Frame 默认请求 deselect all +- [x] 1.2 让 `resolve_image_edit_click()` 在原生 Picker 空命中时保留 active object,同时保持命中另一对象时只完成原生选择 +- [x] 1.3 将 Cutout 的普通点击配置为不在空命中时 deselect,并保持 Lasso、整图双击与 Shift toggle 的既有入口 + +## 2. 交互验证 + +- [x] 2.1 补充点击解析测试,覆盖空命中保留 active Image Empty、另一 Image Empty 和非图片对象保留原生选择结果 +- [x] 2.2 补充 Mask 与 Rectify invoke 测试,验证透明或空白起点进入 Modal,而无有效 active Image Empty 时取消 +- [x] 2.3 补充 Cutout keymap、Lasso 和整图双击测试,验证空白点击不清空 source、任意起点可进入既有提交链 +- [x] 2.4 固定 Frame 仍以空白点击 deselect all,并验证 Shift 点击继续使用原生 toggle + +## 3. 文档与校验 + +- [x] 3.1 更新 Image Edit、Cutout 与 Common 内部文档,说明原生对象拾取、空命中保留 source 和提交阶段有效性判断 +- [x] 3.2 运行图片交互、Cutout 与 Blender Addon 定向测试并修正失败 +- [x] 3.3 运行 `uv run pytest` 与 OpenSpec strict validation,不构建文档、节点资产或发布产物 diff --git a/openspec/changes/repair-image-edit-pipelines/.openspec.yaml b/openspec/changes/repair-image-edit-pipelines/.openspec.yaml new file mode 100644 index 0000000..34f54d2 --- /dev/null +++ b/openspec/changes/repair-image-edit-pipelines/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-05 diff --git a/openspec/changes/repair-image-edit-pipelines/design.md b/openspec/changes/repair-image-edit-pipelines/design.md new file mode 100644 index 0000000..ce2e7f0 --- /dev/null +++ b/openspec/changes/repair-image-edit-pipelines/design.md @@ -0,0 +1,76 @@ +## Context + +动机见 proposal.md。Frame、Mask、Rectify 与 Cutout 共享 Blender 侧 Image 和手势函数,业务像素使用 straight RGBA;结果 Image 默认 PREMUL。当前审查以 460 个测试和 56 个子测试通过的工作区为基线,额外探针揭示了普通测试未覆盖的源缓存副作用、透视边界误判和长笔画开销。 + +## Goals / Non-Goals + +设计将数据读取、几何覆盖、呈现缓存与最终提交分开:源数据只读,覆盖只表达几何,预览按变化更新,结果在完成时一次提交。现有 Alpha 合成公式、透明 RGB、工具选择行为及 Cutout 轴向契约作为回归基线。 + +## Decisions + +### 1. 像素读取以当前有效数据为准 + +`image_pixels()` 成为无副作用入口。GENERATED 或 dirty Image 直接读取当前像素缓存;干净图片按其实际编码取得业务 RGBA。需要以 STRAIGHT 解释已重载 Packed PNG 时,在独立、临时 Image 副本上完成解释并在 finally 中释放。模式切换只作用于临时数据;原 Image 的 mode、pixels、is_dirty、packed bytes、users 和共享对象引用保持原状。 + +先用真实 bpy 固定 byte/float、GENERATED/FILE、packed/unpacked、dirty/clean 和原生 PREMUL 浮点源的读数契约,再实现最小读取分支。业务结果继续使用用户确认的 PREMUL 默认值。临时数据只承担解码,不形成常驻缓存或新增持久属性;保存重开后仍读取相同业务 RGB/Alpha,透明 RGB 参与验证。 + +### 2. Frame 在源平面坐标中完成半平面裁剪 + +将 Frame 四边写为齐次投射分量的线性不等式,把透视正深度条件与这些边界共同作用于源图片单位矩形。仅对裁剪后的有限交叠点做透视除法,最后计算屏幕面积与代表深度。正交路径继续接受有限的非正深度。 + +```mermaid +flowchart TD + Source[源图片单位矩形] --> View[透视正深度或正交有效域] + View --> Clip[Frame 四边的齐次半平面裁剪] + Clip --> Divide[有限交叠顶点做透视除法] + Divide --> Area[正面积与代表深度] + Area --> Samples[既有逐像素采样与合成] +``` + +公共半平面裁剪位于 `common/selection.py`,接受二维顶点和线性边界。Rectify 的图片矩形裁剪复用同一实现,Frame 保留自身投影与深度职责。退化、切边与无交叠判断统一采用尺度相关的数值判定。 + +### 3. Brush 路径简化约束整段原始采样 + +保留经过最小点距筛选的原始轨迹。简化每条候选线段时检查该段全部原始点到线段的距离,最大误差为 0.75 个 Viewport 像素;确认的前缀固定,当前尾段保留可更新状态。近共线判断只控制压缩,不能代替整段误差约束。 + +预览与提交消费同一简化路径及 `brush_footprint_polygons()` 图元。公共 Lasso 消费者使用相同误差契约;Polyline 已提交顶点按用户输入保留。原始轨迹和缓存均在取消或完成时释放。 + +### 4. Brush 完整覆盖先合并,最后抗锯齿 + +以所有投影图元的裁剪后联合 bounds 创建一次局部覆盖画布。图元先以硬覆盖合并,公共抗锯齿函数只处理最终并集一次,再得到一个 SelectionMask。这样内部图元边界不会变成半透明接缝,结果也不随相同几何的分段方式变化。 + +```mermaid +flowchart TD + Path[有误差约束的路径] --> Primitives[共享圆形印记和连接条] + Primitives --> Preview[增量屏幕并集预览] + Primitives --> Release[释放鼠标] + Release --> Project[投影到图片空间] + Project --> Union[局部硬覆盖并集] + Union --> AA[统一抗锯齿一次] + AA --> Alpha[现有 Set / Add / Subtract] + Alpha --> Commit[创建 Packed Image 并替换] +``` + +### 5. 预览缓存覆盖已提交前缀与活动尾段 + +屏幕扫描行采用固定原点。已确认路径前缀的图元、扫描行覆盖和外轮廓作为持久的操作内缓存;新增图元只更新其命中的局部扫描行,活动尾段改变时重算受影响范围及相邻轮廓连接。填充与虚线由同一覆盖缓存派生,使用一个版本标记。 + +静止重绘复用已有 GPU batch;路径变化时按受影响块更新批次。取消、完成与工具切换统一释放路径、覆盖和绘制缓存。性能验收同时记录 800/1600/3200 点耗时及新增段触及的计算范围,避免仅靠特定机器的毫秒阈值判断。 + +### 6. 提交入口与输出计算直接表达当前职责 + +- Mask 使用仅含 UNDO 的交互 Operator,移除 REGISTER 和 Operator draw;Scene 工具设置继续在 invoke 时冻结。 +- Frame 直接写入已独立合成的 RGB/Alpha。保留 Alpha 的现有近零判定、源内透明 RGB 和源外 RGBA 全零,删除整图 blended_rgb 备份及后续预乘/反预乘/扩色回路。 +- 删除 `scanline_fill_triangles()`、`preview_fill_triangles()` 转发入口,测试改为验证当前预览几何入口的行为。 +- 删除只写不读的 `_region_size`,合并 `_fill_polygon`、`_outline_polygon` 的重复缓存标记。 + +## Risks / Trade-offs + +- [不同 Blender 图像编码的 Alpha 解释不同] → 以真实 byte/float 和保存重开测试固定读取结果,验证原源脏状态与共享引用,临时副本总在 finally 释放。 +- [接近观察平面的裁剪容易出现数值抵消] → 归一化半平面系数,覆盖焦距 1/1.7/2/4、30° 倾斜、极近前方、无交叠和正交负深度样例。 +- [增量并集容易在尾段替换与自交处留下旧边界] → 与全量参考几何逐次对比,覆盖回折、重叠、长轨迹及模式颜色变化。 +- [原始路径保留增加操作内内存] → 保留紧凑屏幕点,统一释放;读取源 RGBA 与图片级栅格化仍只在提交时发生。 + +## Migration Plan + +先增加失败行为回归,再依次调整只读像素、公共裁剪、Brush 路径与覆盖、增量预览,最后清理旧入口并运行全量测试。全部改动采用当前接口的直接实现,无持久数据迁移;实施过程中保留本轮已有功能与测试。完成后归档本提案时,应与相关未归档功能规格核对同名业务约定。 diff --git a/openspec/changes/repair-image-edit-pipelines/proposal.md b/openspec/changes/repair-image-edit-pipelines/proposal.md new file mode 100644 index 0000000..327c095 --- /dev/null +++ b/openspec/changes/repair-image-edit-pipelines/proposal.md @@ -0,0 +1,33 @@ +## Why + +本轮审查发现,图片读取会重载并覆盖 Blender 像素缓存,Frame 会误判有效透视交叠,Brush 的路径简化、覆盖合并和预览更新存在精度与性能问题。需要修复这些可复现行为,并清理同一调用链中的旧入口和重复计算。 + +## What Changes + +- 让业务像素读取保持源 Image 的像素、Alpha 模式、脏状态与共享引用不变,保留已确认的结果 PREMUL 默认值。 +- Frame 在齐次坐标中裁剪有效投射,再判断连续几何交叠。 +- Brush 使用具有整段误差约束的屏幕路径;完整图元先合并覆盖,再统一抗锯齿。 +- Brush 增量更新屏幕预览,复用填充与外轮廓的单一缓存。 +- Mask 保留单步 Undo,移除没有 execute 实现的 REGISTER 与重做参数面板。 +- Frame 直接输出独立合成的 RGB/Alpha,清理已被覆盖的预乘、反预乘与扩色处理。 +- 将 Frame、Rectify 的矩形裁剪收敛到公共半平面裁剪;删除只供测试调用的转发函数和无消费者状态。 + +## Capabilities + +### New Capabilities + +- `image-pixel-read-integrity`: 业务像素读取的无副作用约束与脏缓存保留。 +- `frame-projection-clipping`: Frame 连续有效交叠的齐次裁剪与合成输出约束。 +- `brush-stroke-processing`: Brush 路径误差、覆盖合并、增量预览与 Mask 提交行为。 + +### Modified Capabilities + +无。当前 `openspec/specs/` 为空;相关功能仍记录在未归档变更中。本提案补充其修复契约,实施以当前工作区为基线,与 `enable-image-alpha-blending-and-premul`、`support-frame-view-plane-intersections`、`add-live-mask-preview-and-polyline`、`preserve-frame-bottom-layer-rgb` 共同验收。 + +## Impact + +- `common/image.py`:像素读取及结果数据保留。 +- `common/selection.py`、`common/viewport.py`:裁剪、路径、覆盖和预览缓存。 +- `operators/image_edit_tool/frame.py`、`mask.py`、`rectify.py`:交叠、提交与共享能力调用。 +- Cutout Lasso 作为公共手势消费者参加回归验证;轴向与节点资产沿用当前实现。 +- 增补行为回归与结构性性能测试,运行全量 pytest;实施仅修改代码和测试,不增加依赖、不构建文档或发布产物。 diff --git a/openspec/changes/repair-image-edit-pipelines/specs/brush-stroke-processing/spec.md b/openspec/changes/repair-image-edit-pipelines/specs/brush-stroke-processing/spec.md new file mode 100644 index 0000000..48ad929 --- /dev/null +++ b/openspec/changes/repair-image-edit-pipelines/specs/brush-stroke-processing/spec.md @@ -0,0 +1,52 @@ +## Purpose + +保证 Brush 的笔画范围准确跟随屏幕轨迹,预览与最终覆盖使用一致的几何并集,使长笔画持续响应,并提供清晰的完成、取消和单步撤销行为。 + +## ADDED Requirements + +### Requirement: Stroke simplification bounds the whole segment error +Brush 与共享自由手绘手势 SHALL 将每个保留原始采样点到简化线段的偏差限制在 0.75 个 Viewport 像素以内。Polyline 的已提交顶点 SHALL 按用户输入保留。 + +#### Scenario: Dense curved stroke is simplified +- **WHEN** 用户以高密度采样绘制弯曲轨迹 +- **THEN** 整段简化误差不超过 0.75 px,连续尾点替换不会累计放大偏差 +- **AND** 预览与提交使用同一有效路径 + +### Requirement: Brush antialiases the complete geometric union +Brush SHALL 对全部圆形印记和连接条的完整几何并集应用一次抗锯齿。几何并集相同的图元分段方式 SHALL 产生相同覆盖值。 + +#### Scenario: Stroke turns through a right angle +- **WHEN** 半径为 2、5 或 25 px 的 Brush 形成直角连接或自交 +- **THEN** 内部连接处不会因图元各自的抗锯齿边界减少覆盖 +- **AND** 提交覆盖符合完整并集的抗锯齿参考结果 + +#### Scenario: Equivalent geometry has different segmentation +- **WHEN** 同一个直线 footprint 由不同数量的共线连接条描述 +- **THEN** 输出 Alpha 覆盖在浮点误差内一致 + +### Requirement: Long stroke preview remains responsive +Brush SHALL 在每次有效路径变化后显示整条待提交笔画的合并填充和外轮廓。局部新增笔画的预览计算 SHALL 限定于变化范围,静止重绘 SHALL 复用已有预览结果。 + +#### Scenario: A short segment is appended to a long stroke +- **WHEN** 已有 800、1600 或 3200 个屏幕点,用户追加只影响局部范围的短线段 +- **THEN** 预览更新覆盖新范围并保留其他区域,计算范围不扩展为完整历史笔画 +- **AND** 预览与相同路径的完整几何参考一致 + +#### Scenario: Tail changes or crosses previous geometry +- **WHEN** 活动尾段变化或与已有笔画相交 +- **THEN** 预览清除失效尾段的覆盖及内部轮廓,呈现当前完整 footprint + +### Requirement: Mask provides one transactional gesture edit +Mask SHALL 在启动时冻结 Scene 的 Gesture、Mode 和 Radius;完成时一次提交 Packed Image 并支持单步 Undo。Mask SHALL NOT 暴露无法执行的操作后参数重做面板。 + +#### Scenario: Stroke is completed and undone +- **WHEN** 用户完成一个 Brush、Lasso 或 Polyline Mask 后执行 Undo +- **THEN** 源图片与对象状态恢复,整次手势只产生一个编辑步骤 + +#### Scenario: Stroke is cancelled or tool changes +- **WHEN** 用户按 Esc、RMB,或切换工具取消未提交手势 +- **THEN** 原 Image 不变,所有操作内路径与预览资源释放 + +#### Scenario: User edits tool settings +- **WHEN** 用户修改 Scene 中的 Mask 工具设置 +- **THEN** 新手势采用新设置,进行中的手势保持启动时设置 diff --git a/openspec/changes/repair-image-edit-pipelines/specs/frame-projection-clipping/spec.md b/openspec/changes/repair-image-edit-pipelines/specs/frame-projection-clipping/spec.md new file mode 100644 index 0000000..4e57c74 --- /dev/null +++ b/openspec/changes/repair-image-edit-pipelines/specs/frame-projection-clipping/spec.md @@ -0,0 +1,36 @@ +## Purpose + +定义 Frame 在透视观察平面附近的连续交叠判定,确保可见且可稳定求交的图片区域能够参与取景,并固定结果透明颜色和无效交叠时的对象保留行为。 + +## ADDED Requirements + +### Requirement: Frame accepts a finite visible intersection +Frame SHALL 依据画框内的连续有效投射判断正面积交叠。完整源图片的角点接近无穷远 SHALL NOT 导致画框内稳定有效的交叠被拒绝。 + +#### Scenario: Tilted source crosses the perspective origin +- **WHEN** 30° 倾斜源平面位于视图深度 0.2 且部分跨越观察平面,透视 X/Y 焦距系数为 1.7、2 或 4,Frame 内有正面积前方交叠 +- **THEN** Frame 接受交叠并生成结果 +- **AND** 对应有效源坐标与实际采样保持一致 + +#### Scenario: Source lies extremely close in front +- **WHEN** 源平面非常接近观察原点但画框内仍存在数值可稳定表达的正面积前方交叠 +- **THEN** Frame 接受交叠,不因源角点的远距离屏幕投射取消操作 + +#### Scenario: Orthographic source has non-positive depth +- **WHEN** 正交 Frame 与具有有限非正视图深度的源平面存在稳定正面积交叠 +- **THEN** Frame 正常完成 + +### Requirement: Invalid active overlap preserves inputs +Frame SHALL 在 active 图片与画框没有稳定正面积交叠时以 warning 取消,并保留全部参与对象的图片、变换和存在状态。 + +#### Scenario: Frame only touches an edge or misses the source +- **WHEN** Frame 仅接触 active 图片投射边缘,或没有有效交叠 +- **THEN** 操作取消,源对象与源图片均保持不变 + +### Requirement: Frame preserves independent color and alpha output +Frame SHALL 保留底层原色与前景混合得到的 RGB、独立累积的 Alpha 及边界子像素覆盖。源内透明 RGB SHALL 保留,完全未覆盖 Canvas SHALL 为 RGBA 全零。 + +#### Scenario: Frame result is revealed by Mask Add +- **WHEN** 多层 Frame 含透明底色与半透明前景,随后执行 Mask Add +- **THEN** 恢复区域显示原合成 RGB,源外透明 Canvas 的 RGB 保持为零 +- **AND** 分块高度变化不会改变合成结果 diff --git a/openspec/changes/repair-image-edit-pipelines/specs/image-pixel-read-integrity/spec.md b/openspec/changes/repair-image-edit-pipelines/specs/image-pixel-read-integrity/spec.md new file mode 100644 index 0000000..f995811 --- /dev/null +++ b/openspec/changes/repair-image-edit-pipelines/specs/image-pixel-read-integrity/spec.md @@ -0,0 +1,37 @@ +## Purpose + +保证图片编辑与网格生成取得当前有效的业务像素,同时保护 Blender 源图片的未保存内容、透明颜色、呈现模式与共享引用,使连续编辑和保存重开具有一致的数据结果。 + +## ADDED Requirements + +### Requirement: Reading business pixels preserves source state +系统 SHALL 在读取业务 RGBA 时保留源 Image 的像素缓存、Alpha 模式、脏状态、Packed 内容和对象引用。读取 SHALL NOT 通过重载源图片覆盖当前有效内容。 + +#### Scenario: Dirty packed image is read +- **WHEN** 用户修改 Packed Image 像素但尚未重新 Pack,图片编辑工具读取该图片 +- **THEN** 读取结果包含最近修改的 RGB/Alpha +- **AND** 源像素、脏状态及原 Packed 内容保持读取前状态 + +#### Scenario: Shared source is read before an edit is cancelled +- **WHEN** 两个对象共享同一 PREMUL Image,其中一个工具读取图片后取消或报错 +- **THEN** 两个对象仍引用相同源数据,源像素缓存与呈现设置保持不变 + +### Requirement: Business pixel interpretation survives persistence +系统 SHALL 保留既有 PREMUL 结果默认值,并在即时读取、Pack 和 blend 保存重开后取得相同业务 RGB/Alpha。误差 SHALL 仅来自既有编码精度,完全透明 RGB SHALL 可以通过 Mask Add 恢复。 + +#### Scenario: Edited image is reopened and read repeatedly +- **WHEN** 含透明及半透明像素的编辑结果保存重开并被多次读取 +- **THEN** 每次返回相同业务像素,结果 Alpha 模式保持 PREMUL +- **AND** 读取不会改变源缓存或产生累计颜色变化 + +#### Scenario: Floating point source is consumed +- **WHEN** 工具读取生成或加载的浮点图片,包括原生预乘 Alpha 数据 +- **THEN** 返回值符合源编码的业务 RGBA 语义并保留当前有效精度 +- **AND** 源图片的像素及模式保持不变 + +### Requirement: Temporary decoding data has bounded lifetime +系统 SHALL 在单次业务读取结束或失败时释放该读取拥有的临时图片数据。 + +#### Scenario: Repeated read or decode failure +- **WHEN** 用户重复触发读取,或读取中的解码发生错误 +- **THEN** 不留下额外 Image 数据块,原图片及共享引用保持不变 diff --git a/openspec/changes/repair-image-edit-pipelines/tasks.md b/openspec/changes/repair-image-edit-pipelines/tasks.md new file mode 100644 index 0000000..fdb9560 --- /dev/null +++ b/openspec/changes/repair-image-edit-pipelines/tasks.md @@ -0,0 +1,34 @@ +## 1. 源像素读取完整性(审查 1) + +- [x] 1.1 增加真实 bpy 回归,覆盖读取前后 pixels、alpha_mode、is_dirty、packed bytes 和共享 users,确认当前 dirty PREMUL 样例能复现内容丢失。 +- [x] 1.2 明确 byte/float、GENERATED/FILE、dirty/clean、packed/unpacked 的读取分支,实现无源副作用的业务 RGBA 读取;以当前脏像素优先及原生浮点 PREMUL 样例验证数值语义。 +- [x] 1.3 验证独立解码副本在成功与异常时释放,并完成 Mask Subtract → Pack → 保存重开 → Mask Add、重复 Frame 及共享源取消回归,确认透明 RGB 和结果 PREMUL 默认值保留。 + +## 2. 公共裁剪与 Frame 交叠(审查 2、8) + +- [x] 2.1 在 common/selection.py 收敛二维半平面裁剪,用矩形完全包含、相离、边接触和退化多边形测试验证统一边界行为。 +- [x] 2.2 改为在源单位矩形上按透视深度与 Frame 齐次边界裁剪,验证 30° 倾斜、深度 0.2、焦距系数 1/1.7/2/4 的交叠与采样一致,并覆盖极近前方、正交负深度和无效交叠。 +- [x] 2.3 Rectify 复用公共裁剪并删除本地重复算法,运行四角规范化、部分交叠和图片外四边形测试,确认输出范围和放置语义保持一致。 + +## 3. Brush 路径与覆盖(审查 3、4) + +- [x] 3.1 保留原始手势采样并实现整段误差受限的简化,使用弯曲密集采样检查每个原始点到对应简化段的距离不超过 0.75 px,回归 Lasso 与 Polyline 顶点行为。 +- [x] 3.2 以联合局部 bounds 合并 Brush 硬覆盖,并复用公共抗锯齿处理完整并集一次;用半径 2/5/25 的直角、自交和等价分段样例验证覆盖一致及接缝消除。 +- [x] 3.3 验证预览与提交消费同一路径和 footprint,检查拖动期间没有 RGBA 读取或图片级计算,释放后只有一次 Packed 结果提交。 + +## 4. 增量预览与状态清理(审查 6、9) + +- [x] 4.1 建立固定屏幕扫描行的已确认前缀缓存及活动尾段,局部更新覆盖、填充和外轮廓;逐次与全量参考对比回折、自交、尾段变化和图元重叠结果。 +- [x] 4.2 静止重绘复用 GPU batch,按变化块更新绘制;记录 800/1600/3200 点的基线与更新耗时,并用计算范围检查确认局部新增段不会重算完整历史。 +- [x] 4.3 合并填充和轮廓缓存标记,删除 _region_size 与仅测试调用的 scanline_fill_triangles、preview_fill_triangles 转发入口;通过全仓引用搜索和当前预览入口测试验证清理完整。 +- [x] 4.4 统一完成、取消、异常和工具切换的路径及预览资源释放;以资源计数、原 Image 引用和连续启动新手势验证没有残留状态。 + +## 5. Mask 提交与 Frame 输出简化(审查 5、7) + +- [x] 5.1 移除 Mask REGISTER 与无效重做 draw,保留 Scene 设置和 UNDO;检查 Blender 类型注册/注销,并验证三种手势完成后的单步撤销及设置冻结。 +- [x] 5.2 Frame 直接写入独立 RGB/Alpha,删除 blended_rgb 整图备份和被覆盖的颜色转换链;用透明底色、半透明前景、源外全零、极小 Alpha 与不同分块高度测试验证结果一致。 + +## 6. 整体回归 + +- [x] 6.1 运行 `uv run pytest`,覆盖 Image Edit、Cutout、Plane、Job 颜色加载和打包内容测试;确认全部通过,并核对本轮已有失败探针均成为通过的回归。 +- [x] 6.2 审查最终 diff,确认九项发现均有对应修复与验证、无旧转发入口或重复裁剪;实施改动限于代码、测试及本提案任务状态,不产生产品文档或构建产物。 diff --git a/openspec/changes/replace-crop-box-with-frame/.openspec.yaml b/openspec/changes/replace-crop-box-with-frame/.openspec.yaml new file mode 100644 index 0000000..9696e00 --- /dev/null +++ b/openspec/changes/replace-crop-box-with-frame/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/openspec/changes/replace-crop-box-with-frame/design.md b/openspec/changes/replace-crop-box-with-frame/design.md new file mode 100644 index 0000000..d8c1a2b --- /dev/null +++ b/openspec/changes/replace-crop-box-with-frame/design.md @@ -0,0 +1,128 @@ +## Context + +Crop Box 当前复用 `CropSelectionGesture`:屏幕矩形被投影到源 Image Empty 平面、栅格化为 Selection Mask、乘入源 Alpha,最后按可见 Alpha 紧裁并通过 `placement_bounds` 放回源平面。这个数据流天然不能表达扩大 Canvas,也不会把倾斜 Image Empty 在当前视角中的梯形外观冻结为新的矩形图片。 + +现有 Crop Perspective 已具备 Homography、预乘 Alpha 插值、输出分辨率限制和 Image 编辑结果创建能力,但它由用户指定源图片平面上的 Quad,再将 Quad 矫正为矩形。Frame 的输入恰好相反:用户直接指定 Viewport 中的目标矩形,系统需要把所有选中 Image Empty 在该视图中的投射采样、按深度合成进矩形,并把 active 对象转到视平面。 + +## Goals / Non-Goals + +**Goals:** + +- 用 Frame 完整替换 Crop Box,并让名称、公开 ID、文件边界、注册与文档一致。 +- 允许用户从 Viewport 任意位置画出大于或小于源投射的结果 Canvas。 +- 只重采样选中 Image 的 RGBA,按逐像素空间深度与 Alpha 合成并冻结当前视角下的几何透视外观。 +- 以当前 Viewport 像素尺寸决定输出分辨率,以 active Image Empty 统一决定结果对象和视图深度。 +- 让结果继续使用 active Blender Object,并在成功后删除其他参与合并的 Image Empty。 +- 删除 Frame 不再需要的 Refine Selection、Invert 和 Keep Original 状态。 +- 复用 Crop Perspective 已验证的投影数学、插值和资源上限。 + +**Non-Goals:** + +- 不把 Frame 实现为 Viewport 截图;场景遮挡、Grid、Gizmo、Overlay、Color Management 和 Image Empty 的显示 tint 不烘焙进图片。 +- 不改变 Crop Lasso、Crop Polyline 或 Crop Perspective 的图片处理结果和独立选项。 +- 不保留 Crop Box Tool ID、Operator、Scene Property 或 Python 导出兼容层。 +- 不支持视频、序列图片或执行后随视角继续变化的动态投射。 +- 不改变 Server Job、依赖、节点组或 Blender 资产。 + +## Decisions + +### 1. Frame 使用 Selection 作为输入集合,active Image Empty 作为唯一基准 + +Frame 启动时要求 active object 是 Selection 内的 still Image Empty,并收集所有选中的 still Image Empty 作为参与源。仅保留 active 状态但未被选中的 Image Empty 不构成输入;active 不是 Image Empty 时,即使 Selection 含有图片也以 warning 取消。选中的非 Image Empty 不参与操作;任一选中 Image Empty 是视频或序列时整体取消,避免只合并 Selection 的一部分。active 必须属于参与集合,并同时作为结果对象、Image 命名/colorspace 和视图深度基准。 + +矩形可以从 Region 内任意位置开始,使大 Canvas 的任意角都能落在所有源投射之外。Frame 完成时必须与 active 的屏幕投射相交;相比仅要求与任意参与对象相交,这能保证承担结果的 active 确实属于结果内容。 + +### 2. 在手势完成时冻结一次投影状态 + +Frame 完成矩形时记录 Region 尺寸、`RegionView3D` 投影/视图矩阵、全部参与对象的世界矩阵与 Image 尺寸,并用这一组状态完成采样、合成与结果放置。矩形四角使用 Region 像素坐标,顺序固定为左上、右上、右下、左下。 + +不保存一个可持续更新的投影关系。Frame 是一次不可逆的像素编辑;冻结状态也避免异步或重绘期间的 Viewport 变化造成图片内容和对象朝向来自不同视图。 + +### 3. 每个参与对象独立投影采样,再逐像素深度合成 + +对每个参与 Image Empty,先把四个本地图片角变换到世界空间,再投影到 Region,得到各自的 `source image pixels → screen` 投射 Quad。Frame 像素中心按矩形线性映射到 screen,再通过每个投射 Homography 的逆变换映射到对应源图片像素;落在该源图片范围外的样本视为透明。 + +同一输出像素取得全部有效样本后,根据观察射线到各源平面交点的距离从远到近排序,并以预乘 Alpha source-over 合成。这样两个倾斜平面发生交叉时,前后关系也可以随像素变化。深度相同时 active 样本最后合成,使 active 明确位于其他输入之上;其他完全同深度样本只需使用稳定对象顺序保证结果可复现。 + +等价的数据流为: + +```mermaid +flowchart TD + Sources["选中 Image RGBA"] + Objects["各 Image Empty 世界四角"] + View["冻结的 Viewport 投影"] + Frame["屏幕 Frame 矩形"] + Quads["各图片的屏幕投射 Quad"] + Samples["Frame 像素中心 → 各 Source Pixel + Depth"] + Composite["远到近预乘 Alpha 合成"] + Result["完整结果 RGBA Canvas"] + Sources --> Samples + Objects --> View --> Quads --> Samples + Frame --> Samples --> Composite --> Result +``` + +插值继续使用 Crop Perspective 的预乘 Alpha 双线性路径,避免透明边缘产生 RGB 色边。Homography 求解、分块采样和透明 RGB 扩展位于公共 Image 像素处理入口,由 Frame 与 Crop Perspective 共用;多源深度计算和合成留在 Frame 业务边界,不让 Perspective 承担 Selection 或对象生命周期语义。 + +Frame 与 active 投射 Quad 没有几何相交时以 warning 取消,防止在 active 替换语义下意外得到无基准内容的图片。非 active 投射可以位于 Frame 外,仍属于成功后被消费的参与输入。结果不调用 `crop_pixels_to_alpha()`,因此即使边缘或全部源像素本身透明,也不会改变用户所画 Canvas。 + +### 4. 输出分辨率使用当前 Viewport 像素 + +直接使用 Frame 在 Region 中的屏幕像素宽高作为输出尺寸,使结果等价于只渲染图片内容的 Viewport 局部 snapshot。结果保持 Frame 屏幕宽高比,宽高不超过当前 Region 对应维度,并按 Preferences 的 `max_frame_resolution` 再等比限制最长边。默认最大长边为 2048 px,公开范围为 64–8192 px。 + +这个尺寸不再受 active 图片分辨率、对象距离或透视缩放影响,因此远处对象和大范围 Frame 不会产生超出当前显示所需的巨大图片。整数取整造成的极小宽高比误差由结果 Image Empty 的实际像素宽高比统一承载。 + +### 4.1 透明边缘保留颜色扩展 + +投影采样和多图合成继续使用预乘 Alpha。转换回 Blender 使用的 straight RGBA 时,完全透明像素保持 Alpha 0,并让紧邻可见像素的一圈透明 RGB 使用相邻可见颜色。这样不改变透明 Canvas 或合成结果,但避免 Image Empty 的线性纹理过滤把外部 RGB 0 混入边缘而形成黑边。 + +### 5. 结果平面位于 active 对象原点的视图深度 + +使用冻结视图的右、上方向作为结果对象本地 `+X`、`+Y`,本地 `+Z` 指向观察者。以 active Image Empty 世界原点确定视图深度,在穿过该点且平行于视平面的平面上反投影 Frame 四角;其中心成为结果对象原点,宽高成为 Image Empty 的世界显示尺寸。 + +结果对象设置居中的 `empty_image_offset`,世界矩阵只表达 Frame 中心、视图朝向及 active 对象其余不参与图片平面尺寸的变换约定,显示大小由反投影矩形和结果像素宽高比共同确定。active Blender Object identity、名称、Collection、自定义属性和选择状态保留,Image 数据块通过公共 Image 编辑替换规则落地。 + +选择 active 原点深度而不是任一源平面与 Frame 中心射线的交点有两个理由:即使 Frame 中心落在 active 投射外也始终可定义;多源输入只有 active 提供明确且稳定的共同深度。代价是换视角后结果平面与各原倾斜平面不再重合,这是冻结投射的必然结果。 + +### 6. Frame 使用独立矩形 modal,不再进入 Selection 管线 + +删除 `box.py` 和 `CropImageSelection` 的 `BOX` 设置映射,新增 `frame.py` 承载 Frame WorkspaceTool、矩形 Overlay、投影采样和同步执行。公共矩形屏幕路径 helper 可以继续复用,但 Frame 不生成 `SelectionPath`、`SelectionMask`、Refine Job 或 `placement_bounds`。 + +`CropSelectionGesture` 继续服务 Crop Lasso、Crop Polyline 和 Cutout Lasso;删除只被旧 Crop Box 使用的 BOX gesture 分支,矩形路径 helper 改由 Frame 直接使用。Frame 在完成全部验证、采样、合成和结果 Image 创建之前不修改任何参与对象。 + +Frame、Crop Lasso 和 Cutout Lasso 的 WorkspaceTool keymap 使用 `CLICK_DRAG`,并显式绑定普通点击与 Shift 点击的 Viewport 对象选择。图片工具不启用 Blender fallback selection keymap,因此 Tool Settings 不出现容易与 Frame、Lasso 混淆的 `Drag: Select Box/Lasso` 设置。Frame 只有确认发生拖动后才收集 Selection 并进入矩形 modal。Polyline 与 Perspective 仍需单击录入点,其 Operator 自行执行对象点选;Shift 点击使用相同的显式追加选择绑定。 + +公共图片编辑源校验同时要求 active object 是 Image Empty 且属于当前 Selection。drag-only 工具在校验失败时直接 warning 并取消;Frame 的 poll 只限制 View3D 上下文,使无效 active 的拖动仍能进入 Operator 并得到明确提示,而不会静默失败。 + +### 7. 成功后由 active 承载结果并消费其他参与对象 + +结果 Image 完整创建并 Pack 后,先更新 active 对象的 Image、世界矩阵、offset 和显示尺寸,再删除其他参与合并的选中 Image Empty。选中的非 Image Empty 从未进入参与集合,因此不修改也不删除。其他对象仍引用某个源 Image 数据块时只删除参与 Object,不强制删除共享 Image;无用户且无 fake user 的输入 Image 按现有生命周期规则清理。 + +全部对象变更属于同一个 Blender Undo 操作。预计算或 Image 创建失败时直接清理临时资源;对象更新阶段异常时恢复 active 的 Image 与变换,并保留尚未删除的参与对象,避免部分合并状态。 + +### 8. 删除 Crop Box 状态并收紧 Crop Tool 入口 + +从 `AnyImageSettings`、设置同步/绘制映射和测试中删除 `crop_box_refine_selection`、`crop_box_invert`、`crop_box_keep_original`。Frame `draw_settings` 不绘制任何业务选项,keymap 只保留鼠标矩形操作,不注册字母快捷键。 + +注册顺序改为 Frame、Crop Lasso、Crop Polyline、Crop Perspective,`ActivateCropTool` 默认激活 `anyimage.frame`。按项目规则同步删除旧类、ID、文档描述和测试期望,不提供转发或迁移属性。 + +## Risks / Trade-offs + +- [源平面接近与视线平行时 Homography 数值不稳定] → 在修改对象前验证投影点、凸性、矩阵条件数和有效输出尺寸;失败时给出用户可理解的提示并取消。 +- [Frame 远大于 active 投射时透明区域较多] → 仍按 Viewport 像素、总像素上限和分块采样处理,范围外直接写透明 Alpha。 +- [高 DPI 或超大 Viewport 仍可能产生较大图片] → 输出不超过 Region 像素尺寸,并由 Preferences 最大长边二次限制。 +- [大量高分辨率参与图片增加采样时间和内存] → 按输出行分块逐源采样并直接累积预乘结果,不同时保留每个源的完整输出 Canvas。 +- [完全共面的非 active 图片没有自然前后关系] → active 固定置顶,其余使用稳定对象顺序作为相同深度 tie-breaker;需要特定层级时由用户把目标设为 active 或拉开空间深度。 +- [冻结后的结果换视角会与原倾斜平面明显不同] → 这是 Frame 的核心产品语义;文档明确它烘焙当前视角并生成 view-facing Image Empty。 +- [Viewport 显示与原始 Image RGBA 受颜色管理影响而略有差异] → Frame 只处理图片数据和几何投射,不做屏幕截图;沿用源 Image colorspace 并排除显示 tint、遮挡和 Overlay。 +- [删除 Crop Box Properties 后旧 `.blend` 仍包含未知保存数据] → Blender 忽略未注册的旧 ID Property;不读取、不迁移,也不影响其他 Crop Property。 +- [替换过程中部分更新导致源对象损坏] → 先完整计算并创建、Pack 结果 Image,再一次性更新源对象;异常时恢复 Image 与矩阵并移除无用户的临时 Image。 + +## Migration Plan + +实施时先提取 Frame 与 Crop Perspective 共用的 Homography/采样函数并保持 Perspective 测试通过,再加入 Frame 的多源投影、深度合成和事务式对象消费;随后切换注册、激活入口和属性,最后删除 Crop Box 文件、映射、测试与文档。代码与测试在同一版本发布,不需要数据迁移或节点资产重建。 + +回滚需要恢复 Crop Box 的工具、三个 Scene Property 和 Selection 路径。已经由 Frame 生成的普通 packed Image 与 Image Empty 不依赖运行时代码,回滚后仍可正常显示,但不会自动恢复操作前的倾斜平面或源图片。 + +## Open Questions + +无。当前产品决策已经确定 Frame 合成所有选中的 still Image Empty,以 Viewport 决定分辨率,以 active 决定结果对象和视图深度,成功后删除其他参与对象。 diff --git a/openspec/changes/replace-crop-box-with-frame/proposal.md b/openspec/changes/replace-crop-box-with-frame/proposal.md new file mode 100644 index 0000000..91f928b --- /dev/null +++ b/openspec/changes/replace-crop-box-with-frame/proposal.md @@ -0,0 +1,32 @@ +## Why + +现有 Crop Box 把屏幕矩形当作 Selection,只能遮罩并紧裁图片内容,无法直接重定义 Image Empty 的完整矩形范围,也无法冻结当前视角下已经形成的透视外观。将它改为 Frame Tool,让用户以当前 Viewport 为基准重新取景,并能在同一次操作中扩大或缩小透明画布。 + +## What Changes + +- **BREAKING**:以 Frame Tool 替换 Crop Box,统一改用 Frame 名称、Tool ID、Operator、源码边界、激活入口和文档,不保留 Crop Box 兼容入口。 +- Frame 在当前 Viewport 中接受一个矩形,将该矩形定义为结果图片的完整 Canvas;矩形可小于或大于源 Image Empty 的当前投射范围。 +- Frame 将所有选中的 still Image Empty 在当前视角中的投射按空间深度与 Alpha 合成为新 RGBA 图片;active Image Empty 作为结果对象和视图深度基准。 +- Frame 输出尺寸以所画矩形在当前 Viewport 中的像素尺寸为依据,不超过当前 Region 分辨率,并受 Preferences 中可配置的最大长边限制。 +- 结果 Image Empty 平行于当前视平面并覆盖所画矩形;所有源投射范围外保持透明。 +- 结果透明边缘保留 Alpha 0,并用相邻可见颜色填充透明像素的 RGB,避免纹理插值产生黑边。 +- Frame 不按可见 Alpha 紧裁结果,透明边缘属于用户定义的 Canvas。 +- **BREAKING**:Frame 始终让 active Image Empty 承载合并结果,并删除其他参与合并的选中 Image Empty;不提供 Refine Selection、Invert 或 Keep Original,并删除 Crop Box 对应的三个持久 Scene Property。 +- Crop Tool 组改为 Frame、Crop Lasso、Crop Polyline、Crop Perspective;其余三个 Crop 子工具的行为和独立选项保持不变。 +- Frame、Crop Lasso 与 Cutout Lasso 只在拖动时进入手势;普通点击继续执行 Viewport 对象选择,但工具设置不显示 Blender fallback selection 的 Drag 选项。其他点选型 Crop 工具在首次点到另一对象时只切换选择,不开始编辑。 +- 图片编辑手势只在 active object 是 Selection 内的 still Image Empty 时启动;active 未选中或不是 Image Empty 时取消并提示用户。 + +## Capabilities + +### New Capabilities + +- `frame-tool`: 规定 Frame 的工具入口、多选 Image Empty 投影合成、Viewport 分辨率基准、矩形 Canvas、透明扩展区域和结果对象放置语义。 + +### Modified Capabilities + + +## Impact + +- 影响 Crop Tool 的注册、菜单激活入口、Scene Properties、Viewport 矩形交互、多源 RGBA 重采样与合成、Image Empty 删除和结果放置及相关测试。 +- 主要涉及 `src/anyimage/operators/crop_tool/`、`src/anyimage/common/viewport.py`、`src/anyimage/common/image.py`、`src/anyimage/properties.py`、注册入口和 Crop Tool 内部文档。 +- 不引入新的运行时依赖,不改变 Lasso、Polyline、Perspective、Server Job 或节点资产。 diff --git a/openspec/changes/replace-crop-box-with-frame/specs/frame-tool/spec.md b/openspec/changes/replace-crop-box-with-frame/specs/frame-tool/spec.md new file mode 100644 index 0000000..09c1536 --- /dev/null +++ b/openspec/changes/replace-crop-box-with-frame/specs/frame-tool/spec.md @@ -0,0 +1,172 @@ +## ADDED Requirements + +### Requirement: Frame replaces Crop Box +系统 SHALL 以 **Frame** 替换 **Crop Box**,并 SHALL 在 Crop Tool 组中按 Frame、Crop Lasso、Crop Polyline、Crop Perspective 的顺序注册四个子工具。Frame 的显示名称、Tool ID、Operator、激活入口、源码边界和文档 SHALL 统一使用 Frame 术语,不得保留 Crop Box 的公开入口或兼容名称。 + +#### Scenario: User opens the Crop Tool group +- **WHEN** 用户展开 Crop Tool 组 +- **THEN** 首个子工具显示为 Frame +- **AND** 其后依次为 Crop Lasso、Crop Polyline 和 Crop Perspective +- **AND** 工具组中不再显示 Crop Box + +#### Scenario: User activates the crop entry +- **WHEN** 用户从 AnyImage 菜单激活 Crop Tool +- **THEN** 系统激活 Frame + +### Requirement: Frame defines the complete output canvas +Frame SHALL 将用户在当前 3D Viewport 中拖出的矩形定义为结果图片的完整矩形 Canvas。该矩形可以小于或大于选中 Image Empty 的当前屏幕投射范围;Canvas 位于所有参与源投射范围外的像素 SHALL 使用透明 RGBA,系统 SHALL NOT 根据可见 Alpha 再次紧裁结果。 + +#### Scenario: User draws a smaller frame +- **WHEN** 用户围绕选中 Image Empty 当前投射的一部分拖出 Frame +- **THEN** 结果图片只包含 Frame 内采样到的投射图案 +- **AND** 结果 Canvas 严格对应用户所画 Frame + +#### Scenario: User draws a larger frame +- **WHEN** 用户拖出的 Frame 超出所有参与 Image Empty 的当前投射范围 +- **THEN** 结果图片保留 Frame 的完整范围 +- **AND** 未被源投射覆盖的区域为透明 +- **AND** 系统不移除这些透明边缘 + +### Requirement: Frame bakes and composites selected viewport projections +Frame SHALL 使用完成矩形手势时的 Viewport 投影、视图朝向、区域尺寸和每个参与 Image Empty 的世界变换,把所有参与图片的当前投射重采样为结果 RGBA。每个输出像素 SHALL 按观察射线上的几何深度将有效样本从远到近排序,再使用预乘 Alpha source-over 合成;active Image Empty SHALL 在相同深度的样本中位于其他参与对象之上。采样 SHALL 使用预乘 Alpha 插值,源图片范围外 SHALL 采样为透明。 + +#### Scenario: One selected image is oblique to the view +- **WHEN** 倾斜的选中 Image Empty 在当前透视视图中呈现为梯形投射,用户完成 Frame +- **THEN** 结果图片烘焙该梯形投射在 Frame 内的当前可见外观 +- **AND** 操作完成瞬间的屏幕图案与操作前一致 + +#### Scenario: Selected images overlap in depth +- **WHEN** 多个参与 Image Empty 的投射在 Frame 内重叠 +- **THEN** 系统逐像素按空间深度从远到近合成 RGBA +- **AND** 较近图片的透明区域仍显示后方图片 + +#### Scenario: Active image overlaps another image at the same depth +- **WHEN** active Image Empty 与另一参与对象在同一深度产生重叠样本 +- **THEN** active Image Empty 的样本合成在另一对象之上 + +#### Scenario: Frame extends beyond the projected source +- **WHEN** Frame 同时覆盖参与源投射内部和外部区域 +- **THEN** 系统重采样并合成内部的源 RGBA +- **AND** 系统为外部区域写入透明 RGBA + +#### Scenario: Frame has no active projection overlap +- **WHEN** 完成的 Frame 与 active Image Empty 的屏幕投射没有几何重叠 +- **THEN** 系统以 warning 提醒用户 +- **AND** 系统取消操作并保留所有选中对象不变 + +### Requirement: Viewport defines output resolution +Frame SHALL 使用所画矩形在当前 Region 中的屏幕像素宽高作为结果尺寸依据,并 SHALL 保持 Frame 的屏幕宽高比。结果宽高 SHALL NOT 超过当前 Region 对应维度,最长边 SHALL NOT 超过 Preferences 中可配置的 Frame 最大长边。active 或其他参与图片的原始分辨率、对象距离和透视缩放 SHALL NOT 直接放大结果尺寸。 + +#### Scenario: Other selected image has a higher resolution +- **WHEN** 非 active 参与图片的分辨率高于 active 图片 +- **THEN** 结果尺寸仍只由当前 Viewport 中的 Frame 像素尺寸决定 +- **AND** 非 active 图片重采样到该结果 Canvas + +#### Scenario: Frame is drawn around a distant image +- **WHEN** active Image Empty 距离视角很远或投射尺度很小 +- **THEN** 结果尺寸仍对应 Frame 的当前屏幕像素尺寸 +- **AND** 对象距离不会产生超出 Viewport 分辨率的图片 + +#### Scenario: Frame exceeds the configured maximum +- **WHEN** Frame 屏幕像素最长边超过 Preferences 配置上限 +- **THEN** 系统等比限制结果尺寸 +- **AND** 结果宽高比继续对应 Frame + +### Requirement: Transparent output edges avoid black interpolation +Frame SHALL 保持未覆盖区域的 Alpha 为 0,并 SHALL 使用相邻可见颜色扩展紧邻可见内容的透明像素 RGB。颜色扩展 SHALL NOT 改变任何像素的 Alpha 或 Frame Canvas 范围。 + +#### Scenario: Projected image ends inside the frame +- **WHEN** 不透明或半透明图片边缘外侧是 Frame 的透明区域 +- **THEN** 外侧像素 Alpha 保持为 0 +- **AND** 线性纹理插值不会因外侧 RGB 0 形成明显黑边 + +### Requirement: Frame creates a view-facing Image Empty +Frame SHALL 使用 active Image Empty 的原 Blender Object 承载结果 Image,并更新其世界变换和 Image Empty 显示范围,使本地 XY 图片平面平行于操作完成时的 Viewport 视平面、本地正面朝向该视角,且结果矩形在该视角中覆盖用户所画 Frame。结果平面 SHALL 位于穿过 active Image Empty 世界原点、并与视平面平行的深度平面上。 + +#### Scenario: User finishes a frame in perspective view +- **WHEN** 用户在透视 Viewport 中完成 Frame +- **THEN** 结果 Image Empty 朝向操作时的视角 +- **AND** 结果四边在该视角中对应 Frame 的四边 +- **AND** 结果使用 active Image Empty 世界原点所在的视图深度 + +#### Scenario: User finishes a frame in orthographic view +- **WHEN** 用户在正交 Viewport 中完成 Frame +- **THEN** 结果 Image Empty 平行于该正交视平面并覆盖 Frame +- **AND** 烘焙后的图案保持操作时的屏幕外观 + +### Requirement: Frame operates on selected still Image Empties +Frame SHALL 以当前 Selection 内的 active still Image Empty 为基准对象,并将所有选中的 still Image Empty 作为参与源;选中的非 Image Empty 对象 SHALL 被忽略。Frame SHALL 允许矩形手势从 Viewport 任意位置开始。active object 未被选中、不是 still Image Empty、任一选中 Image Empty 不是 still image、Frame 尺寸无效或任一参与投影无法稳定求解时,系统 SHALL 取消整个操作并保持所有对象不变。 + +#### Scenario: User starts outside the source projection +- **WHEN** active object 是 still Image Empty,且用户从源投射范围外开始拖出一个与源投射相交的 Frame +- **THEN** 系统使用 active Image Empty 及其他选中的 still Image Empty 完成 Frame 操作 + +#### Scenario: Active object is unsupported +- **WHEN** active object 不是 still Image Empty +- **THEN** Frame 不开始图片重采样、合成或对象替换 +- **AND** 系统以 warning 提醒用户选择一个 active Image Empty + +#### Scenario: Active image is not selected +- **WHEN** active object 是 still Image Empty,但它不属于当前 Selection +- **THEN** Frame 不进入矩形操作 +- **AND** 系统以 warning 提醒用户选择 active Image Empty + +#### Scenario: Selection contains images but active object is not an image +- **WHEN** Selection 含有 still Image Empty,但 active object 是非图片对象 +- **THEN** Frame 不使用 Selection 中的其他图片作为隐式基准 +- **AND** 系统以 warning 取消操作 + +#### Scenario: Selection contains a non-image object +- **WHEN** active object 是 still Image Empty,且 Selection 还包含非 Image Empty 对象 +- **THEN** Frame 忽略非 Image Empty 对象 +- **AND** 非 Image Empty 对象不参与合成或删除 + +#### Scenario: A selected Image Empty is animated +- **WHEN** 任一选中的 Image Empty 使用视频或图片序列 +- **THEN** 系统取消整个 Frame 操作 +- **AND** 所有选中对象保持不变 + +### Requirement: Frame has no edit options +Frame SHALL 始终让 active Image Empty 承载结果,并在结果完整创建和放置后删除其他参与合并的选中 Image Empty。整个替换与删除 SHALL 属于同一个 Undo 操作;失败时 SHALL 保留全部输入对象。Frame SHALL NOT 提供 Refine Selection、Invert 或 Keep Original。系统 SHALL 删除 Crop Box 对应的 `crop_box_refine_selection`、`crop_box_invert` 和 `crop_box_keep_original` Scene Properties;Crop Lasso、Crop Polyline 和 Crop Perspective 的独立选项 SHALL 保持不变。 + +#### Scenario: User opens Frame settings +- **WHEN** 用户激活 Frame +- **THEN** 工具设置不显示 Refine Selection、Invert 或 Keep Original + +#### Scenario: User completes a frame +- **WHEN** Frame 成功生成结果 +- **THEN** active Blender Object 改为承载合成结果 Image 和新的视平面对齐变换 +- **AND** 其他参与合并的 Image Empty 被删除 +- **AND** 被忽略的非 Image Empty 对象保持不变 + +#### Scenario: Multi-image merge is undone +- **WHEN** 用户撤销一次成功的多图 Frame +- **THEN** active Image Empty 恢复操作前的 Image 与变换 +- **AND** 其他被删除的参与 Image Empty 恢复 + +#### Scenario: Existing file contains removed Crop Box properties +- **WHEN** 扩展加载曾保存 Crop Box 选项的 `.blend` 文件 +- **THEN** 运行时不注册或读取这些已删除属性 +- **AND** 其他 Crop 子工具的设置仍按各自属性工作 + +### Requirement: Image tools preserve ordinary click selection +Frame、Crop Lasso 与 Cutout Lasso SHALL 只在鼠标发生拖动后启动图片手势,普通点击 SHALL 使用 Viewport 对象选择行为。Polyline 与 Perspective 首次点击另一对象时 SHALL 只切换 active selection,不开始图片编辑。图片工具 SHALL NOT 启用或显示 Blender fallback selection 的 Drag 设置。 + +所有图片编辑手势 SHALL 只在 active object 是当前 Selection 内的 Image Empty 时启动;否则系统 SHALL 以 warning 取消该次手势。 + +#### Scenario: User clicks another Image Empty while Frame is active +- **WHEN** 已有一个 active Image Empty,用户普通点击另一 Image Empty +- **THEN** Blender 按原生规则选择该对象并使其 active +- **AND** Frame 不开始、不显示矩形 Overlay,也不生成图片 + +#### Scenario: User shift-clicks another object +- **WHEN** 任一图片工具处于 active,用户 Shift 单击另一对象 +- **THEN** Blender 保留原生追加或切换选择行为 + +#### Scenario: User drags while Frame is active +- **WHEN** 用户按住左键并移动到 Blender 的 drag threshold 之外 +- **THEN** Frame 才收集当前 Selection 并进入矩形操作 + +#### Scenario: User opens image tool settings +- **WHEN** 用户激活 Frame、Crop Lasso、Crop Polyline、Crop Perspective 或 Cutout Lasso +- **THEN** Tool Settings 不显示 `Drag: Select Box/Lasso` 等 fallback selection 设置 diff --git a/openspec/changes/replace-crop-box-with-frame/tasks.md b/openspec/changes/replace-crop-box-with-frame/tasks.md new file mode 100644 index 0000000..0f84c07 --- /dev/null +++ b/openspec/changes/replace-crop-box-with-frame/tasks.md @@ -0,0 +1,38 @@ +## 1. 共用投影采样 + +- [x] 1.1 将 Homography 求解、预乘 Alpha 双线性重采样和输出资源限制整理为 Frame 与 Crop Perspective 可复用的公共 Image 像素函数,删除具体工具之间的反向依赖,并用数值、透明边缘、范围外采样、退化矩阵和上限测试验证 +- [x] 1.2 让 Crop Perspective 改用公共投影采样函数,移除原模块中的重复实现,并运行既有 Perspective 输出、预览、Alpha、placement 和异常测试确认行为不变 + +## 2. Frame 像素与空间计算 + +- [x] 2.1 实现每个选中 Image Empty 的世界四角到 Region 投影、Frame 与 active 投射 Quad 相交验证,以及 Frame 像素中心到各源图片像素的映射;用透视、正交、范围内、部分越界、无 active 相交和近退化视角测试验证 +- [x] 2.2 实现基于 Frame 屏幕像素、Region 尺寸与 Preferences 最大长边的输出尺寸计算,保留完整透明 Canvas 且不执行 Alpha 紧裁;用其他源分辨率变化、较小 Frame、较大 Frame、透明源边缘、整数宽高比和输出上限测试验证 +- [x] 2.3 实现多源分块采样、逐像素几何深度排序和预乘 Alpha source-over 合成,active 在相同深度置顶;用前后遮挡、透明叠加、倾斜平面交叉、共面 active 和 Frame 外样本测试验证 +- [x] 2.4 实现穿过 active 对象世界原点的视平面反投影与结果 Image Empty 矩阵/显示范围计算,确保本地 XY 平行视平面、正面朝向观察者、屏幕四边对应 Frame;用透视、正交、平移 active 对象和非均匀源变换测试验证 + +## 3. Frame Tool + +- [x] 3.1 新增 `crop_tool/frame.py`,实现以 active still Image Empty 为基准、收集所有选中 still Image Empty、忽略非图片对象且可从 Region 任意位置开始的矩形 modal、Overlay、状态文本、取消和错误清理;用单选、多选、无效 active、选中视频、混合对象、微小矩形、工具切换和取消测试验证 +- [x] 3.2 在 Frame 完成时冻结视图和所有参与对象状态,创建 packed 合成 Image,并以事务式更新让 active Object 承载新的 Image、世界矩阵、居中 offset 和显示尺寸,再删除其他参与 Image Empty;用对象 identity、名称、colorspace、Collection、共享 Image 生命周期、被忽略对象、异常回滚和单步 Undo 测试验证 +- [x] 3.3 验证多张倾斜源图片在 Frame 前后的操作视角投射与 Alpha 层叠一致,并验证改变视角后结果保持已烘焙合成图而不再跟随原投射平面 + +## 4. 替换 Crop Box + +- [x] 4.1 将 Crop Tool 组、注册导出和默认激活入口改为 Frame、Crop Lasso、Crop Polyline、Crop Perspective,统一使用 `FrameTool`、`anyimage.frame` 和 Frame Operator ID,并用注册顺序、ToolDef、菜单与激活测试验证 +- [x] 4.2 从 Crop Selection Operator 删除 BOX 的 Refine Selection、Invert 和 Keep Original 映射,删除 Crop Box 专用文件、类、ID、公开导出和 BOX gesture 分支,同时保留 Crop Lasso、Crop Polyline 与 Cutout Lasso 的公共 Selection 交互;用搜索和交互测试验证边界 +- [x] 4.3 从 `AnyImageSettings`、设置同步和工具栏绘制中删除 `crop_box_refine_selection`、`crop_box_invert`、`crop_box_keep_original`,确认 Frame 无业务选项且其他三个 Crop 子工具的独立设置不变 + +## 5. 文档与完整验证 + +- [x] 5.1 更新用户文档、架构、Operator 索引、Crop Tool 与公共 Viewport/Image 内部文档,用流程图正面描述 Frame 的多选图片、Viewport 分辨率、active 对象/深度基准、逐像素深度合成、输入对象消费、透明 Canvas、view-facing 结果和无选项行为,并移除当前功能描述中的 Crop Box +- [x] 5.2 运行 Crop、菜单、属性、注册、对象放置、打包与 Blender Addon 相关测试,再运行 `uv run pytest`,确认完整测试集通过且不构建文档或扩展产物 + +## 6. 试用反馈修正 + +- [x] 6.1 更新 proposal、design 与 Frame spec,明确 Viewport snapshot 分辨率、Preferences 最大长边、透明 RGB 边缘扩展、无重叠 warning 和原生点击选择语义 +- [x] 6.2 实现 Frame 的 Viewport 像素输出尺寸和 `max_frame_resolution` Preferences,并补充远距离、Region 与配置上限测试 +- [x] 6.3 统一投影结果的透明 RGB 边缘扩展,将 Frame 无 active overlap 降级为 warning,并补充像素及报告级别测试 +- [x] 6.4 让 Frame、Crop Lasso、Cutout Lasso 使用 drag-only 交互,修正点选型工具的首次对象切换,并用 ToolDef 与交互测试覆盖普通点击、Shift 加选和拖动启动 +- [x] 6.5 运行 Crop、Preferences、注册与 Blender Addon 相关测试,再运行 `uv run pytest`,确认完整测试集通过且不构建文档或扩展产物 +- [x] 6.6 统一要求图片编辑源同时为 active 和 selected Image Empty,让 Frame 在无效 active 时报告 warning,并用 active 未选中、active 非图片但 Selection 含图片及正常混合 Selection 测试验证 +- [x] 6.7 删除所有图片 WorkspaceTool 的 fallback selection 选项,以公共显式 keymap 保留普通点击与 Shift 点击选择,并验证 Tool Settings 不再暴露 `Drag: Select Box/Lasso` diff --git a/openspec/changes/replace-crop-with-mask-and-rectify/.openspec.yaml b/openspec/changes/replace-crop-with-mask-and-rectify/.openspec.yaml new file mode 100644 index 0000000..9696e00 --- /dev/null +++ b/openspec/changes/replace-crop-with-mask-and-rectify/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/openspec/changes/replace-crop-with-mask-and-rectify/design.md b/openspec/changes/replace-crop-with-mask-and-rectify/design.md new file mode 100644 index 0000000..0f4ca88 --- /dev/null +++ b/openspec/changes/replace-crop-with-mask-and-rectify/design.md @@ -0,0 +1,104 @@ +## Context + +当前 `operators/crop_tool/` 同时承载 Frame、Lasso、Polyline 与 Perspective。Lasso 和 Polyline 把一次几何 Selection 乘入源 Alpha,随后按可见内容紧裁切并调整 Image Empty 画框;Lasso 还可以提交 BEN2 Refine Job。三个 Crop 子工具分别保存 Keep Original,公共 `CropSelectionGesture` 因而混合了工具设置、Lasso/Polyline 交互和 Cutout 复用职责。 + +新交互把 Alpha 视为可反复编辑的当前状态:Mask 用 Lasso 或 Brush 生成一次手势 Mask,再以 Set、Add 或 Subtract 合成 Alpha。Frame、Mask、Rectify 是同一个简短的 Image Edit 工具组。当前工作区的 `standardize-cutout-z-axis` 同时修改 Cutout 主链,实施本 change 时应以该 change 完成后的 Cutout 结构为基线。 + +## Goals / Non-Goals + +**Goals:** + +- 用 **Frame / Mask / Rectify** 表达三个工具的真实产物,并删除全部旧 Crop 工具标识。 +- 让一个 Mask WorkSpaceTool 和一个业务 Operator 承载 Lasso、Brush 及统一 Alpha 合成。 +- 让 Mask 的图片尺寸、Image Empty 身份和矩形画框在每次编辑后保持不变。 +- 删除 Crop/Cutout Refine Selection 与 Crop Keep Original 的完整参数和执行路径。 +- 保留 Rectify 已有的四点透视校正、宽高比、输出限制和 Alpha trim 行为。 + +**Non-Goals:** + +- 不为 Mask 维护独立 Blender Mask、Selection 历史或隐藏的原始 Alpha 副本。 +- 不让 Cutout 使用 Brush、Set/Add/Subtract,Cutout 继续以单次 Lasso 定义 Shape 范围。 +- 不改变 Frame 合成、Rectify Homography、Cutout Shape、Depth、Normal Map 或 Remove Background。 +- 不为已删除的类、Operator ID、Tool ID、属性或 Job 保留兼容层。 + +## Decisions + +### 1. 用 Image Edit 模块承载 Frame、Mask、Rectify + +将 `operators/crop_tool/` 改为与业务边界一致的 `operators/image_edit_tool/`,内部文件使用 `frame.py`、`mask.py` 和 `rectify.py`。注册顺序固定为 Frame、Mask、Rectify,Frame 继续作为工具组首项;Cutout 保持为相邻的独立工具。 + +公开名称和 ID 只保留当前术语:`MaskTool`、`EditImageAlpha`、`RectifyTool`、`RectifyImagePerspective`,以及 `anyimage.mask`、`anyimage.edit_image_alpha`、`anyimage.rectify`、`anyimage.rectify_image_perspective`。激活入口改为 Image Edit 语义并默认选择 Frame。 + +不选择继续使用 Crop 模块并只修改标签,因为源码边界、状态属性和错误信息仍会表达已经不存在的裁切模型。 + +### 2. 一个 Mask Tool 读取 Gesture、Mode 与 Radius + +Scene 设置保存 `mask_gesture`、`mask_mode` 和 `mask_radius`:Gesture 为 `LASSO` 或 `BRUSH`,Mode 为 `SET`、`ADD` 或 `SUBTRACT`,Radius 是 Brush 使用的正整数屏幕像素,默认 25 px。Tool Settings 以展开的三个图标显示 Mode;Radius 仅在 Brush 时显示。 + +Mask 只有一个 WorkSpaceTool 和一个 Operator。Operator 在 invoke 时冻结三个设置;Lasso 收集并闭合自由手绘路径,Brush 收集连续笔画并显示当前 Radius 圆环。单击 Brush 生成一个圆形印记,拖动时相邻采样之间生成连续覆盖,不因鼠标事件间距留下空洞。两种手势最终都只产生一个 `SelectionMask` 并进入同一个 Alpha 合成入口。 + +不选择两个 WorkSpaceTool,因为它们只有手势采集不同,分开后会重复模式、结果和设置逻辑,也不符合用户在当前 Tool 内切换 Gesture 的预期。 + +### 3. 直接以当前 Alpha 和本次手势 Mask 合成 + +设当前像素 Alpha 为 `A`,本次带边缘权重的手势 Mask 为 `M`: + +```text +Set A' = A × M +Add A' = max(A, M) +Subtract A' = A × (1 - M) +``` + +所有运算逐像素限制在 `0–1`,RGB 原样复制。Set 保留本次手势内的当前可见内容;Add 可以重新提高已擦除区域的 Alpha;Subtract 降低本次手势覆盖区域的 Alpha。因为不保存初始 Alpha,Add 以本次 Mask 权重为恢复值,可能高于图片导入时的 Alpha;这是直接编辑当前 Alpha 的明确语义。 + +不选择保存隐藏的原始 Alpha,因为它会引入跨图片替换的额外状态、生命周期和同步规则,也会让 Add 不再是直接的 Alpha 涂抹。 + +### 4. Mask 始终替换同尺寸图片而不改变画框 + +Mask 读取完整当前 RGBA,应用 Alpha 合成后创建相同宽高的 packed Image,并用公共图片替换入口赋给 active Image Empty。它不传 `placement_bounds`,因此对象 identity、matrix、display size 和 image offset 不变;共享旧 Image 的其他对象继续引用旧数据。即使结果 Alpha 全部为零也允许完成,仍作为一个 Undo 步骤。 + +删除 Mask 的紧裁切和空 Alpha 拒绝逻辑。Rectify 仍可按自身业务生成不同尺寸并通过 placement bounds 对齐,因此“稳定画框”只属于 Mask。 + +### 5. Brush Radius 使用 Viewport 屏幕像素 + +Radius 与 Blender Circle Select 一样以 Region 屏幕像素表达,使圆形预览与用户看到的范围一致,不受图片分辨率影响。Brush 将屏幕笔画分解为相邻线段的圆角胶囊和端点圆形,在图片平面投影后合并局部 Mask;图片外覆盖被裁掉。实现复用 SelectionMask 的 bounds/value 表示和公共抗锯齿规则,不引入 Pillow、OpenCV 或几何依赖。 + +不选择图片像素 Radius,因为同一设置会随图片分辨率和 Viewport 缩放产生明显不同的屏幕笔刷尺寸。 + +### 6. Rectify 只更换业务名称并删除 Keep Original + +Perspective 的四点采集、凸 Quad 规范化、512 px 预览、目标宽高比、分块 Homography、8192 px 长边限制、总像素限制、可见 Alpha trim 和结果 placement 保持不变。类、ID、状态文本、warning、Undo 名称、文档和测试统一改为 Rectify;完成后直接替换 active Image Empty,不再复制源对象。 + +不选择 `Perspective`,因为它只描述概念;`Rectify` 用一个单词表达把透视四边形校正为矩形的动作,并与 Frame、Mask 的长度一致。 + +### 7. 完整移除 Refine Selection 和 Keep Original 路径 + +Mask 删除 BEN2 属性、AI readiness、临时 bounds 输入、SelectionMask sidecar、异步响应 Operator 和 `refine-image-selection` Job。Cutout 删除对应工具设置、Pie 参数、Operator 参数和 `refine_model` 请求;需要 Depth 或 Normal 时继续提交原始 Selection bounds 矩形,颜色图直接复用该输入。 + +Server Cutout Job 不再导入或执行 refine,删除专用 Job 注册、Job 文件和只为该能力存在的 `server/selection/` 封装。BEN2 模型、缓存、Remove Background 与 Debug 保留。 + +删除三个旧 Crop Keep Original 属性和 Operator 参数。`place_empty_image_result()` 若不再有调用方则删除,由 Mask 和 Rectify 直接复用 `replace_empty_image()`;不保留复制结果对象的公共胶水代码。 + +## Risks / Trade-offs + +- [Add 会让原本透明的像素变得可见] → 文档和公式明确它直接涂抹当前 Alpha;RGB 始终保留,Undo 提供操作级恢复。 +- [屏幕圆形投影到倾斜图片后不再是图片像素圆形] → 以 Viewport 视觉范围为准,并用投影后的胶囊覆盖测试倾斜与缩放图片。 +- [快速拖动产生断裂笔画] → 以相邻事件线段生成连续胶囊,而不是只绘制离散圆点。 +- [模块和 ID 全量改名遗漏注册或注销] → 参数化检查类导出、工具顺序、Operator ID、状态文本、打包内容和逆序注销。 +- [删除 Refine 时误删 BEN2 公共能力] → 测试固定 Remove Background、BEN2 Debug、模型目录和打包内容仍存在。 +- [与 Cutout Z 轴 change 修改同一文件] → 先完成 `standardize-cutout-z-axis`,再基于其最终 `main.py`、测试和文档移除 Refine 分支。 + +## Migration Plan + +1. 先完成并验证 `standardize-cutout-z-axis`,冻结本 change 的 Cutout 基线。 +2. 将 Crop 模块与公共命名迁移为 Image Edit,注册 Frame / Mask / Rectify 并删除 Polyline 和旧 ID。 +3. 实现 Mask 设置、Lasso/Brush 手势、统一 Alpha 合成和稳定画布结果,删除 Keep Original。 +4. 将 Perspective 完整改名为 Rectify,保留 Homography 行为并删除 Keep Original。 +5. 删除 Blender 与 Server 两侧 Refine Selection 路径,验证 Cutout 的本地、Depth 和 Normal 分支。 +6. 更新测试与当前文档并运行完整测试;不构建节点资产、文档站点或发布包。 + +回退必须整体恢复旧 Crop 模块、工具注册、属性、Refine Job 和文档,避免 Blender 请求已删除的 Server Job 或注册不存在的 Tool ID。 + +## Open Questions + +无。 diff --git a/openspec/changes/replace-crop-with-mask-and-rectify/proposal.md b/openspec/changes/replace-crop-with-mask-and-rectify/proposal.md new file mode 100644 index 0000000..af0cc77 --- /dev/null +++ b/openspec/changes/replace-crop-with-mask-and-rectify/proposal.md @@ -0,0 +1,30 @@ +## Why + +现有 Crop Lasso 实际需要反复编辑图片 Alpha,却会紧裁切画布并把 Lasso、Polyline 分成独立工具;Crop Perspective 的名称也没有直接表达透视校正目的。将图片编辑入口收敛为简短的 **Frame / Mask / Rectify**,可以让工具名称、画布行为和用户操作保持一致。 + +## What Changes + +- **BREAKING**:将 Crop Tool 组改为 **Frame / Mask / Rectify**,删除 Crop 命名和旧工具 ID,不保留兼容入口。 +- **BREAKING**:删除 Crop Polyline;Mask 在一个 Tool 中提供 Lasso 与 Brush 两种手势。 +- Mask 提供 Set、Add、Subtract 三种 Alpha 编辑模式;Brush 额外提供可持久保存的 Radius。 +- Mask 始终保留当前图片的完整矩形画布、分辨率和 Image Empty 变换,只修改 Alpha,不再按可见内容紧裁切。 +- 将 Crop Perspective 重命名为 **Rectify**,继续通过四点 Homography 生成透视校正结果。 +- **BREAKING**:删除 Frame / Mask / Rectify 工具组中的 **Keep Original**,结果直接替换 active Image Empty 承载的图片。 +- **BREAKING**:删除 Crop 与 Cutout 的 **Refine Selection** 选项、请求参数、Job 和结果处理;BEN2 继续用于 Remove Background 与调试。 + +## Capabilities + +### New Capabilities + +- `image-edit-tools`: 规定 Frame、Mask、Rectify 的工具组成、命名、Mask 手势与 Alpha 模式、稳定画布,以及结果替换行为。 + +### Modified Capabilities + +无。 + +## Impact + +- 影响 Crop 工具注册、激活入口、WorkspaceTool、公共 Viewport 手势、Scene 设置、图片结果落地和相关测试。 +- 影响 Cutout 的工具设置、交互参数、Job 编排,以及 Server 的 Refine Selection Job 注册与文件。 +- 影响 README、用户索引、内部架构、Operator、Runtime、Output 和公共模块文档。 +- 不新增依赖,不改变 Frame 合成、Rectify Homography、Cutout Shape、Depth、Normal Map、Remove Background 或 BEN2 模型实现。 diff --git a/openspec/changes/replace-crop-with-mask-and-rectify/specs/image-edit-tools/spec.md b/openspec/changes/replace-crop-with-mask-and-rectify/specs/image-edit-tools/spec.md new file mode 100644 index 0000000..6b25ff3 --- /dev/null +++ b/openspec/changes/replace-crop-with-mask-and-rectify/specs/image-edit-tools/spec.md @@ -0,0 +1,124 @@ +## ADDED Requirements + +### Requirement: Image Edit group uses Frame Mask and Rectify +系统 SHALL 将图片编辑工具组依次注册为 **Frame**、**Mask** 和 **Rectify**,并 SHALL 将 Cutout 作为其后的独立工具。系统 MUST NOT 注册旧 Crop Lasso、Crop Polyline 或 Crop Perspective Tool ID,也 MUST NOT 提供旧 ID 的兼容入口。 + +#### Scenario: Image Edit tools are registered +- **WHEN** AnyImage 扩展完成注册 +- **THEN** 3D Viewport 图片编辑工具组按 Frame、Mask、Rectify 顺序显示,Cutout 紧随其后 + +#### Scenario: Legacy Crop tools are absent +- **WHEN** 系统枚举已注册的 AnyImage WorkspaceTool 和 Operator +- **THEN** 不存在 Crop Lasso、Crop Polyline、Crop Perspective 或其旧 ID + +### Requirement: Mask combines Lasso and Brush gestures +Mask SHALL 是一个 WorkSpaceTool 和一个图片 Alpha 编辑 Operator,并 SHALL 提供 `LASSO` 与 `BRUSH` Gesture。两种 Gesture MUST 生成相同的 SelectionMask 产物并进入相同的 Alpha 合成入口。 + +#### Scenario: Lasso edits Alpha +- **WHEN** 用户在 Mask 中选择 Lasso 并完成一个有效闭合手势 +- **THEN** 系统将该 Lasso 栅格化一次并按当前 Mode 编辑 active Image Empty 的 Alpha + +#### Scenario: Brush edits Alpha +- **WHEN** 用户在 Mask 中选择 Brush 并完成一次点击或拖动 +- **THEN** 系统将连续圆形笔画栅格化一次并按当前 Mode 编辑 active Image Empty 的 Alpha + +### Requirement: Mask provides three Alpha modes +Mask SHALL 提供 `SET`、`ADD`、`SUBTRACT` 三种互斥 Mode,并 SHALL 在 Tool Settings 中使用 Set、Add、Subtract 图标展开显示。设当前 Alpha 为 `A`、本次 SelectionMask 权重为 `M`,系统 MUST 分别计算 `A × M`、`max(A, M)`、`A × (1 - M)`,同时保持 RGB 不变。 + +#### Scenario: Set keeps only current content inside the gesture +- **WHEN** 用户以 Set 应用一次 Mask 手势 +- **THEN** 手势外 Alpha 变为零,手势内 Alpha 等于当前 Alpha 与 SelectionMask 权重的乘积 + +#### Scenario: Add extends current Alpha +- **WHEN** 用户以 Add 应用一次 Mask 手势 +- **THEN** 每个像素 Alpha 等于当前 Alpha 与 SelectionMask 权重的较大值 + +#### Scenario: Subtract removes current Alpha +- **WHEN** 用户以 Subtract 应用一次 Mask 手势 +- **THEN** 每个像素 Alpha 等于当前 Alpha 与 SelectionMask 补集的乘积 + +#### Scenario: Fractional edge coverage is preserved +- **WHEN** SelectionMask 边缘包含 `0` 与 `1` 之间的权重 +- **THEN** 系统使用该权重执行对应公式而不是先转为布尔值 + +### Requirement: Mask settings persist per scene +系统 SHALL 在 Scene 中保存 Mask 的 Gesture、Mode 和 Radius。Radius SHALL 是正整数 Region 屏幕像素,默认 25 px,并 MUST 仅在 Gesture 为 Brush 时显示和生效;每次手势开始后 MUST 冻结本次使用的设置。 + +#### Scenario: Mask settings are reused +- **WHEN** 用户设置 Brush、Add 和一个 Radius 后完成一次操作并再次使用 Mask +- **THEN** Tool Settings 保持 Brush、Add 和该 Radius + +#### Scenario: Lasso hides Radius +- **WHEN** Mask Gesture 为 Lasso +- **THEN** Tool Settings 不显示 Radius 且 Lasso 栅格化不读取 Radius + +### Requirement: Brush produces continuous screen-space coverage +Brush SHALL 以当前 Region 中的屏幕像素显示 Radius 圆环,并 MUST 让单击产生圆形覆盖、拖动产生带圆角端点的连续覆盖。快速移动时相邻事件点之间 MUST NOT 留下未覆盖间隙,图片范围外的笔画 MUST 被裁到图片画布。 + +#### Scenario: Single click stamps a circle +- **WHEN** 用户以 Brush 在图片上单击而不拖动 +- **THEN** 系统以点击位置和当前 Radius 生成一个圆形 SelectionMask + +#### Scenario: Sparse motion events remain connected +- **WHEN** 一次 Brush 拖动的相邻采样点距离大于 Radius +- **THEN** 两点之间仍由连续笔画覆盖 + +#### Scenario: Viewport scale does not change displayed Radius +- **WHEN** 用户改变 Viewport 缩放后以同一 Radius 使用 Brush +- **THEN** 笔刷圆环在 Region 中继续使用相同屏幕像素半径 + +### Requirement: Mask preserves the rectangular image canvas +Mask SHALL 创建与当前图片相同宽高的 packed Image,并 SHALL 保持 active Image Empty 的对象身份、matrix、display size 和 image offset。Mask MUST NOT 按 Alpha 紧裁切或传入 placement bounds,且 SHALL 允许产生全透明结果。 + +#### Scenario: Lasso keeps the canvas +- **WHEN** Lasso 手势只覆盖图片的一小部分 +- **THEN** 结果图片尺寸和 Image Empty 矩形画框与操作前完全相同 + +#### Scenario: Brush keeps the canvas +- **WHEN** Brush 手势只覆盖图片的一小部分 +- **THEN** 结果图片尺寸和 Image Empty 矩形画框与操作前完全相同 + +#### Scenario: Entire Alpha can be removed +- **WHEN** Subtract 覆盖当前全部非零 Alpha +- **THEN** 操作成功并产生相同尺寸的全透明结果 + +#### Scenario: Shared source remains unchanged +- **WHEN** active Image Empty 与其他对象共享同一个源 Image +- **THEN** active 对象改为引用新的编辑结果,其他对象继续引用未修改的源 Image + +### Requirement: Mask and Rectify replace the active image +Mask 与 Rectify SHALL 直接替换 active Image Empty 承载的 Image,并 MUST NOT 提供 Keep Original 属性、Operator 参数或复制结果对象的路径。Frame SHALL 继续让 active Image Empty 承载合成结果。 + +#### Scenario: Mask replaces the active result +- **WHEN** 用户完成一次 Mask 操作 +- **THEN** active Image Empty 本身承载结果且系统不创建其副本 + +#### Scenario: Rectify replaces the active result +- **WHEN** 用户完成一次 Rectify 操作 +- **THEN** active Image Empty 本身承载结果且系统不创建其副本 + +### Requirement: Rectify performs four-point perspective correction +Rectify SHALL 接受四个图片点,规范化为有效凸 Quad,并通过 Homography 生成目标宽高比的矩形图片。系统 SHALL 保留现有预览、输出分辨率限制、可见 Alpha trim 和结果 placement 语义,同时 SHALL 在类名、Tool/Operator ID、标签、状态、warning 与 Undo 中统一使用 Rectify 术语。 + +#### Scenario: Four points create a rectified image +- **WHEN** 用户为 still Image Empty 提交四个构成有效凸 Quad 的点 +- **THEN** 系统生成透视校正后的矩形图片并替换 active Image Empty 的 Image + +#### Scenario: Rectify retains output limits +- **WHEN** 四点区域对应的原始输出超过长边或总像素限制 +- **THEN** 系统按既有 Rectify 输出限制等比约束结果 + +### Requirement: Refine Selection is absent from Mask and Cutout +Mask 与 Cutout MUST NOT 声明、显示、保存或传递 Refine Selection。Server MUST NOT 注册 `refine-image-selection` Job,Cutout Job MUST NOT 接收或执行 `refine_selection` 与 `refine_model`;BEN2 Remove Background 与 Debug 能力 SHALL 保持可用。 + +#### Scenario: Mask has no AI refinement +- **WHEN** 用户查看 Mask Tool Settings 或执行 Mask +- **THEN** 不存在 Refine Selection 选项、AI readiness 门控或 BEN2 Job + +#### Scenario: Cutout runs without refinement parameters +- **WHEN** 用户创建本地、Depth 或 Normal Cutout +- **THEN** Blender 和 Server 请求中不包含 Refine Selection 参数,最终 Shape 仍由本地几何 SelectionMask 约束 + +#### Scenario: BEN2 remains available elsewhere +- **WHEN** 用户运行 Remove Background 或 BEN2 Debug +- **THEN** 系统继续使用现有 BEN2 模型与缓存完成请求 diff --git a/openspec/changes/replace-crop-with-mask-and-rectify/tasks.md b/openspec/changes/replace-crop-with-mask-and-rectify/tasks.md new file mode 100644 index 0000000..62cc1d9 --- /dev/null +++ b/openspec/changes/replace-crop-with-mask-and-rectify/tasks.md @@ -0,0 +1,42 @@ +## 1. Image Edit 模块与工具注册 + +- [x] 1.1 确认 `standardize-cutout-z-axis` 已完成并以其最终 Cutout 主链、测试和文档作为实施基线 +- [x] 1.2 将 `operators/crop_tool/` 迁移为 `operators/image_edit_tool/`,建立 Frame、Mask、Rectify 内容边界并删除 Polyline 文件与导出 +- [x] 1.3 将类名、Operator ID、Tool ID、激活入口和状态文字改为 Image Edit、Mask、Rectify 当前术语,不保留旧 Crop 注册或兼容层 +- [x] 1.4 更新扩展注册与逆序注销,补充测试验证 Frame、Mask、Rectify、Cutout 的顺序及旧工具完全缺席 + +## 2. Mask 设置与 Alpha 合成 + +- [x] 2.1 增加持久化的 Mask Gesture、Mode 和 Radius Scene Property,默认使用 Lasso、Set、25 px,并删除旧 Lasso/Polyline Refine、Invert 和 Keep Original 属性 +- [x] 2.2 实现统一 Alpha 合成函数,按 Set `A × M`、Add `max(A, M)`、Subtract `A × (1 - M)` 修改 Alpha并原样保留 RGB +- [x] 2.3 用硬边、分数边缘、连续重复编辑、Add 恢复透明区域和全透明结果测试固定三种 Alpha 公式 +- [x] 2.4 实现单一 Mask WorkSpaceTool 的 Tool Settings,以 Lasso/Brush 切换 Gesture、以三个展开图标切换 Mode,并仅在 Brush 下显示 Radius + +## 3. Lasso 与 Brush 手势 + +- [x] 3.1 将公共 Crop/Polyline modal 拆为无 Crop 术语的图片手势基础能力,让 Mask Lasso 与 Cutout Lasso 只复用路径采样和图片平面投影 +- [x] 3.2 为 Mask Lasso 接入统一 SelectionMask 与 Alpha 合成入口,删除 Refine Job、Invert、紧裁切和空 Alpha 拒绝分支 +- [x] 3.3 实现屏幕像素 Radius 的 Brush 圆环、单击圆形印记和连续拖动胶囊覆盖,并将图片外笔画裁到当前画布 +- [x] 3.4 补充 Brush 单击、稀疏移动连续性、倾斜/缩放图片投影、画布边界、Radius 显示和设置冻结测试 +- [x] 3.5 让 Lasso 与 Brush 创建同尺寸 packed Image 并直接替换 active Image Empty,测试对象身份、matrix、display size、offset、共享源图片和单步 Undo 保持正确 + +## 4. Rectify 与结果替换 + +- [x] 4.1 将 Perspective 文件、类、ID、标签、状态、warning、Undo 和测试统一改为 Rectify,同时保留四点 Quad、预览、宽高比、Homography 和输出限制 +- [x] 4.2 删除 Rectify 的 Keep Original 设置与 Operator 参数,使结果始终由 active Image Empty 承载 +- [x] 4.3 保留 Rectify 的可见 Alpha trim 与 placement 行为,并用既有像素、交互和对象对齐测试验证改名未改变结果 +- [x] 4.4 删除不再有调用方的结果复制 helper 和测试,让 Mask、Rectify 直接复用公共图片替换入口 + +## 5. 删除 Refine Selection + +- [x] 5.1 删除 Cutout Tool Settings、Pie 传值、Operator Property、AI readiness 和请求中的 Refine Selection,仅以 Depth 或 Normal 判定是否提交 Job +- [x] 5.2 简化 Cutout Blender 响应和 Server Job,使本地、Depth、Normal 分支使用原始 Selection bounds 输入并继续由本地 SelectionMask 约束最终 Shape +- [x] 5.3 删除 `refine-image-selection` 注册、专用 Job、Server selection 封装、SelectionMask sidecar 序列化和无调用方的清理代码 +- [x] 5.4 更新 Server、Cutout、打包和模型测试,验证 Refine 参数与文件缺席,同时 Remove Background、BEN2 Debug、模型缓存和 BEN2 包内容保持可用 + +## 6. 文档与完整验证 + +- [x] 6.1 更新 README、用户索引、架构、公共模块、Operator 索引及 Image Edit、Cutout、Runtime、Output 文档,只正面描述 Frame、Mask、Rectify 当前实现 +- [x] 6.2 搜索并清除图片编辑业务中遗留的 Crop Lasso、Crop Polyline、Crop Perspective、Refine Selection、Invert、Keep Original 名称与旧 ID +- [x] 6.3 运行 Selection、Blender Addon、Cutout、Server Runtime、Packaging 定向测试并修正失败 +- [x] 6.4 运行 `uv run pytest` 完整验证,不构建文档、节点资产或发布产物 diff --git a/openspec/changes/standardize-cutout-z-axis/.openspec.yaml b/openspec/changes/standardize-cutout-z-axis/.openspec.yaml new file mode 100644 index 0000000..9696e00 --- /dev/null +++ b/openspec/changes/standardize-cutout-z-axis/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-03 diff --git a/openspec/changes/standardize-cutout-z-axis/design.md b/openspec/changes/standardize-cutout-z-axis/design.md new file mode 100644 index 0000000..cd52a84 --- /dev/null +++ b/openspec/changes/standardize-cutout-z-axis/design.md @@ -0,0 +1,104 @@ +## Context + +Cutout 的二维采样和三角化本来在源 Image Empty 的局部 XY 平面完成,`cutout_domain()` 也先生成 `(x, y, 0)`。`build_base_shape()` 随后通过 `orient_cutout_vertices()` 将顶点变为 `(0, -x, y)`,让四个 Shape 子节点沿 X 轴建模;对象矩阵再根据 **Inward Axis** 选择是否追加一次轴变换。默认 `-Z` 只有在外层 `O Image Cutout` Modifier 求值后才回到源图坐标。 + +这使 Mesh 数据、Shape 子组和最终对象处于不同坐标系。禁用 Modifier、读取原始 Mesh、把 Shape 子组复用到普通 XY 几何,或依赖对象局部轴的后续操作,都会看到 YZ / X 轴约定。与此同时,`O Mesh Plane` 与 `O Image Depth Plane` 已经在 XY 平面生成几何并沿 Z 轴表达厚度和深度。 + +Depth Surface 的 Object Space Normal 也跟随旧 `-X` 原生协议:Server 先编码 X 轴 Cutout 空间,Blender 再为默认 `-Z` 修改图片像素。轴向迁移必须同时覆盖基础 Mesh、四个节点子组、外层节点组、对象矩阵与 Normal 编码,同时保留用户选择 `-Z` 或 `+X` 输出的能力。 + +## Goals / Non-Goals + +**Goals:** + +- 让 Cutout 原始 Mesh、四个 Shape 子组和默认 `-Z` 求值结果使用源 Image Empty 的局部 XY / Z 轴。 +- 让四种 Shape 的内向位移统一沿局部 `-Z`,与现有 Plane 节点的 Z 轴语义一致。 +- 保留 **Inward Axis** 输出选项,让默认 `-Z` 直通原生坐标,只在选择 `+X` 时执行配对的几何、对象矩阵和 Normal 转换。 +- 用 Python 单元测试、节点结构验证和真实 Blender 求值固定原始与求值几何的朝向。 + +**Non-Goals:** + +- 不改变世界空间外观、Cutout 轮廓、UV、材质、厚度数值、Depth Scale 或 Reference Depth 的视觉语义。 +- 不修改 Selection、采样密度、三角化、Job 编排或 Depth Artifact。 +- 不删除或重命名现有 **Inward Axis** 接口和值,也不改变两种选项的世界空间外观。 +- 不重构已经采用 Z 轴的 `O Mesh Plane` 与 `O Image Depth Plane` 主链。 + +## Decisions + +### 1. 保留三角化得到的 XY 顶点作为 BaseShape + +`build_base_shape()` 直接保存 `cutout_domain()` 产生的 `(x, y, 0)`,删除 `orient_cutout_vertices()`。逆时针 Face 顺序保持不变,因此未求值基础面的几何法线为局部 `+Z`,从图片正面观察时与源 Image Empty 一致;“向图片内部”统一定义为局部 `-Z`。 + +默认 `-Z` 的对象矩阵为: + +```text +source_matrix × Translation(crop_center.x, crop_center.y, 0) +``` + +默认路径不再追加轴变换。这样原始 Mesh 顶点、Modifier 输入和默认最终结果共享同一对象空间,世界空间仍由源图矩阵和裁切中心决定。选择 `+X` 时,外层节点组将 Z 轴 Shape 结果转换到 `+X` 内向坐标,对象矩阵追加其逆变换,使两次变换在世界空间抵消。 + +不选择只在对象创建后 Apply Modifier,因为这会丢失非破坏性 Shape 控制,也无法让子节点组直接复用 XY 输入几何。 + +### 2. 将四个 Shape 子组的建模方向整体迁移到 Z + +节点构建脚本中所有表达厚度或轮廓外法向的 X 分量改为 Z 分量,平面内坐标由 Y/Z 改为 X/Y。相关辅助函数按职责改名,不保留 `_x` 转发函数。四个 Shape 使用同一约定: + +```text +image plane = XY +front normal = +Z +inward direction = -Z +``` + +Surface 的前后层、Balloon 的轮廓高度、Depth Balloon 的相对深度和 Depth Surface 的相机点投影都在这一空间内直接产生原生坐标。外层 `O Image Cutout` 继续负责 **Inward Axis** Menu Switch:`-Z` 分支直通原生结果,`+X` 分支在 Cleanup、Smooth 后执行一次 Z 到 X 的坐标转换。 + +不选择保留内部 X 轴后仅提前旋转输入,因为子组单独复用时仍暴露旧约定,基础 Mesh 与节点 Field 的坐标也继续不一致。 + +### 3. Inward Axis 只转换外层输出 + +保留 Scene Property、工具面板项、交互参数、Operator Property、`CutoutInwardAxis` 与节点组 **Inward Axis** socket,枚举值仍为默认 `NEGATIVE_Z` 和可选 `POSITIVE_X`。语义调整为从统一的 Z 轴原生 Shape 选择输出对象空间: + +```text +-Z: geometry = native_z; object_matrix = source_matrix × crop_translation ++X: geometry = Z_TO_X × native_z; object_matrix = source_matrix × crop_translation × X_TO_Z +``` + +`Z_TO_X` 与 `X_TO_Z` 互为逆变换,因此两种选项仍产生相同的世界空间形状。`CUTOUT_AXIS_TO_SOURCE` 可保留并改成清楚表达 X 到 Z 的矩阵名称;不得让四个 Shape 子组依赖该矩阵或旧 X 轴输入。 + +不选择把选项下沉到各 Shape 子组,因为这会重新引入两套内部坐标,并让子组复用者必须理解项目专用的轴切换。 + +### 4. 在 Server 端编码原生 Z 轴 Object Space Normal + +Depth Surface 继续请求 `normal_mode=OBJECT`,但 Object Space 输出直接采用原生 Z 轴 Cutout 空间的通道映射。中性/不可见区域编码为局部 `+Z`,与几何正面一致。默认 `-Z` 时 Blender 直接加载并 Pack 图片;选择 `+X` 时,`orient_object_normal_image()` 使用与几何 `Z_TO_X` 一致的法线矩阵转换通道。 + +Tangent Space Normal 的映射与其他 Shape 保持不变。虽然 Z 轴 Object Space 与 Tangent Space 在当前正交图片基面上可能具有相同的数值通道变换,两者仍保留不同的材质 Normal Space 语义与请求枚举。 + +不选择继续让 Artifact 固定使用旧 X 轴空间,因为默认路径仍需在 Blender 主线程复制和改写整张图片。迁移后只有显式 `+X` 选项承担这项转换成本。 + +### 5. 用三层验证覆盖轴向契约 + +- Python 几何测试检查 BaseShape 位于 `z = 0`、XY bounds 与源裁切一致、Face winding 产生 `+Z` 法线。 +- 节点结构验证检查四个子组只使用 Z 轴,外层组保留 **Inward Axis** 且仅 `+X` 分支包含最终轴变换。 +- Blender 求值测试以 XY Grid 直接驱动每个 Shape,检查平面内 X/Y 不被交换、Thickness/Depth 沿 `-Z`、默认原始 Mesh 与零形变求值结果朝向一致,并验证 `+X` 输出与对象矩阵组合后的世界空间结果不变。 + +已有 `O Mesh Plane` 与 `O Image Depth Plane` 只增加共同轴向断言,不改其实现。 + +## Risks / Trade-offs + +- [X 到 Z 的机械迁移可能遗漏某个位置、法线或边界分量] → 按四种 Shape 分别验证包围盒、法线、厚度和 Depth 求值,并搜索遗留的 X 轴辅助函数与常量。 +- [Face winding 与内向位移可能组合错误,造成背面或侧壁翻转] → 同时检查基础面、前后层和侧壁 Polygon Normal,而不只比较顶点包围盒。 +- [Depth Surface Object Normal 的通道映射可能与几何轴迁移不同步] → 用已知相机法线与不可见像素验证 Z 轴编码,并分别测试默认原样加载和 `+X` 单次转换。 +- [配对矩阵方向写反会让 `+X` 世界空间结果旋转] → 用非对称裁切与非单位源矩阵对比两种 Inward Axis 的世界空间顶点,而不只检查局部包围盒。 +- [重建资产可能引入无关二进制差异] → 仅修改 Cutout 构建路径,运行既有节点资产验证和完整测试,不构建其他文档或发布产物。 + +## Migration Plan + +1. 先调整 BaseShape 与对象放置,补充未求值 Mesh 和世界空间位置测试。 +2. 将四个 Shape 子组从 X 轴迁移到 Z 轴,反转外层 `Inward Axis` 分支的直通/转换职责并更新结构、求值验证。 +3. 保留运行时 **Inward Axis** 属性与参数链,简化默认 `-Z` 对象矩阵并为 `+X` 使用配对补偿。 +4. 修改 Object Space Normal 为 Z 轴原生编码,让默认路径原样加载、`+X` 路径执行配对通道转换,并更新 Server/Blender 测试。 +5. 重建并验证 `O_AnyImage.blend`,运行 Cutout 定向测试和 `uv run pytest`,同步节点资产与 Cutout 内部文档。 + +回退时必须一起恢复节点构建脚本、`O_AnyImage.blend`、BaseShape 坐标、对象放置和 Normal 编码,避免资产与运行时使用不同坐标系。 + +## Open Questions + +无。 diff --git a/openspec/changes/standardize-cutout-z-axis/proposal.md b/openspec/changes/standardize-cutout-z-axis/proposal.md new file mode 100644 index 0000000..e5d9f78 --- /dev/null +++ b/openspec/changes/standardize-cutout-z-axis/proposal.md @@ -0,0 +1,29 @@ +## Why + +Cutout 的基础 Mesh 目前先被转换到 YZ 平面,以局部 `-X` 作为正面法线,再由 `O Image Cutout` Modifier 转回源 Image Empty 的 XY 平面。未求值或未复用该 Modifier 时,基础面片朝向因此与原图不一致,也让 Cutout 与其他图片 Geometry Nodes 使用两套轴向约定。 + +## What Changes + +- 四种 Cutout Shape 统一在局部 XY 平面建模,以局部 `-Z` 表示从图片平面向内的方向;基础 Mesh、节点输入和默认 `-Z` 求值结果共享同一坐标约定。 +- Surface、Balloon、Depth Balloon 与 Depth Surface 中所有厚度、鼓起、深度投影和相关向量运算由 X 轴迁移到 Z 轴。 +- 保留 Cutout 工具与 `O Image Cutout` 的 **Inward Axis** 选项;`-Z` 继续作为默认输出,选择 `+X` 时由外层节点组和对象矩阵从原生 Z 轴结果执行配对转换。 +- Depth Surface 的 Object Space Normal 默认按 Z 轴对象空间生成;仅在选择 `+X` 输出时转换到对应对象空间。 +- **BREAKING(Shape 子节点内部坐标)**:`O Image Surface`、`O Image Balloon`、`O Image Depth Balloon` 与 `O Image Depth Surface` 的原生输入和输出从 X 轴约定改为 Z 轴约定,不提供子组级旧坐标兼容层。 +- 重建并验证节点资产,确保 Cutout 与既有 `O Mesh Plane`、`O Image Depth Plane` 均遵循 Z 轴图片几何约定。 + +## Capabilities + +### New Capabilities + +- `cutout-axis-convention`: 规定 Cutout 基础 Mesh与四种 Shape Geometry Nodes 的原生局部 Z 轴语义,以及 `Inward Axis` 对输出几何、对象放置和 Object Space Normal 的配对转换。 + +### Modified Capabilities + +无。 + +## Impact + +- 影响 Cutout BaseShape 坐标、对象创建与放置,以及既有 `Inward Axis` 参数的节点转换方向。 +- 影响 `O Image Cutout` 及四个 Shape 子节点组的构建脚本、内部坐标约定、验证脚本与发布资产 `O_AnyImage.blend`;外层 `Inward Axis` 接口和值保持不变。 +- 影响 Depth Surface Object Space Normal 的坐标转换及相关 Server、Blender 和真实节点求值测试。 +- 不改变 Cutout 的选区、网格密度、UV、材质、Job 请求或 Depth/Normal 文件格式,也不新增依赖。 diff --git a/openspec/changes/standardize-cutout-z-axis/specs/cutout-axis-convention/spec.md b/openspec/changes/standardize-cutout-z-axis/specs/cutout-axis-convention/spec.md new file mode 100644 index 0000000..0960c24 --- /dev/null +++ b/openspec/changes/standardize-cutout-z-axis/specs/cutout-axis-convention/spec.md @@ -0,0 +1,61 @@ +## ADDED Requirements + +### Requirement: Cutout base mesh matches the source image plane +系统 SHALL 直接在源 Image Empty 的局部 XY 平面创建 Cutout BaseShape,基础顶点 MUST 使用 `z = 0`,逆时针正面 MUST 朝局部 `+Z`,且对象放置 MUST 保持裁切区域的世界空间位置。 + +#### Scenario: Inspect a Cutout before Geometry Nodes evaluation +- **WHEN** 系统已从源图片裁切区域创建 Cutout Mesh,但尚未求值或复用 `O Image Cutout` Modifier +- **THEN** Mesh 位于对象局部 XY 平面,正面朝向与源 Image Empty 一致,且 X/Y 范围对应原裁切区域 + +#### Scenario: Evaluate an undeformed Surface +- **WHEN** Surface Cutout 以零厚度和零平滑求值 +- **THEN** 求值前后的平面内坐标、正面朝向和世界空间位置保持一致,不执行轴交换或补偿旋转 + +### Requirement: Cutout Shape nodes use the local Z axis +`O Image Surface`、`O Image Balloon`、`O Image Depth Balloon` 与 `O Image Depth Surface` SHALL 接收局部 XY 平面几何,并统一使用局部 `-Z` 表示从图片平面向内的方向。外层 `O Image Cutout` 的默认 `-Z` 分支 MUST 直通该原生结果。平面内 X/Y 坐标 MUST 不因 Shape 求值而互换。 + +#### Scenario: Reuse a Shape group with XY geometry +- **WHEN** 任一 Cutout Shape 节点组直接接收位于 XY 平面的输入 Mesh +- **THEN** 节点组沿 Z 轴生成 Shape,并保持输入几何的 X/Y 方向 + +#### Scenario: Build Surface or Balloon thickness +- **WHEN** Surface 或 Balloon 使用正 Thickness 求值 +- **THEN** 厚度和 Balloon 高度沿局部 Z 轴展开,图片内部一侧位于负 Z 方向 + +#### Scenario: Apply depth deformation +- **WHEN** Depth Balloon 或 Depth Surface 使用非零 Depth Scale 求值 +- **THEN** 深度位置在统一的 XY / Z 对象空间中计算,不依赖 X 轴中间坐标系或最终轴变换 + +### Requirement: Cutout exposes selectable output axes +系统 SHALL 保留 **Inward Axis** Scene Property、Operator 参数和 `O Image Cutout` 节点接口。`-Z` MUST 为默认值并直接使用原生 XY / Z Shape 结果;选择 `+X` 时,外层节点组 MUST 将 Z 轴结果转换为 `+X` 内向对象空间,对象矩阵 MUST 执行配对补偿以保持相同世界空间形状。 + +#### Scenario: Create a Cutout from the tool +- **WHEN** 用户打开 Cutout 工具设置并选择任一 Shape +- **THEN** 工具显示并传递 **Inward Axis**,默认 `-Z` 创建结果直接沿用源 Image Empty 的局部轴 + +#### Scenario: Select the positive X output +- **WHEN** 用户把 **Inward Axis** 设为 `+X` +- **THEN** `O Image Cutout` 将原生 Z 轴 Shape 转换为 `+X` 内向的局部几何,对象矩阵同步补偿,世界空间位置和外观与默认结果一致 + +#### Scenario: Reuse a Shape subgroup directly +- **WHEN** 调用方绕过外层 `O Image Cutout` 并直接复用任一 Shape 子节点组 +- **THEN** 子节点组始终使用原生 XY / Z 坐标,不公开或模拟旧 X 轴内部约定 + +### Requirement: Depth Surface normals match the Z-axis object space +Depth Surface 的 Object Space Normal Artifact SHALL 编码原生 Z 轴 Cutout 对象空间;不可见区域 MUST 使用局部 `+Z` 中性法线。默认 `-Z` 输出 MUST 原样使用该图片;选择 `+X` 输出时,Blender MUST 将其转换到配对的 `+X` 对象空间。 + +#### Scenario: Encode a known object-space normal +- **WHEN** Server 为 Depth Surface 编码已知的相机空间法线 +- **THEN** 输出通道直接对应最终 XY / Z Cutout 对象空间,且无需 Blender 后处理即可作为 Object Space Normal 使用 + +#### Scenario: Load a Depth Surface normal image +- **WHEN** Blender 收到 `object-normal.png` 并以默认 `-Z` 创建 Depth Surface 材质 +- **THEN** 图片像素保持 Artifact 原值并以 Object Space 连接到材质 + +#### Scenario: Load a positive X Depth Surface normal image +- **WHEN** Blender 收到 `object-normal.png` 并以 `+X` 创建 Depth Surface 材质 +- **THEN** 图片通道按 Z 到 X 的配对轴变换转换一次,再以 Object Space 连接到材质 + +#### Scenario: Fill an invisible normal region +- **WHEN** Object Space Normal 的像素不在可见内容区域 +- **THEN** 该像素编码局部 `+Z`,不会在正面产生虚假的侧向法线 diff --git a/openspec/changes/standardize-cutout-z-axis/tasks.md b/openspec/changes/standardize-cutout-z-axis/tasks.md new file mode 100644 index 0000000..cd30e06 --- /dev/null +++ b/openspec/changes/standardize-cutout-z-axis/tasks.md @@ -0,0 +1,30 @@ +## 1. 固定轴向契约 + +- [x] 1.1 补充 BaseShape 与对象放置测试,验证原始 Mesh 位于局部 XY、正面为 `+Z`、裁切区域世界空间位置不变 +- [x] 1.2 补充 Cutout 属性、交互和对象测试,验证 `Inward Axis` 继续显示和传递,且 `-Z` 为默认值 +- [x] 1.3 扩展节点结构与真实 Blender 求值测试,分别固定四种 Shape 的 XY 输入、Z 轴形变、Face winding,以及外层 `-Z`/`+X` 配对转换 +- [x] 1.4 补充 Object Space Normal 的 Server 编码与 Blender 加载测试,验证 `+Z` 中性值、默认原样加载和 `+X` 单次通道转换 + +## 2. 统一 BaseShape 与对象坐标 + +- [x] 2.1 让 `build_base_shape()` 直接保留三角化的 `(x, y, 0)` 顶点,删除 X 轴顶点转换及其遗留测试 +- [x] 2.2 让默认 `-Z` 对象矩阵只使用源矩阵与裁切中心平移,并为 `+X` 保留从原生 Z 轴到 X 轴输出的配对矩阵补偿 +- [x] 2.3 保持 Scene Settings、工具面板、交互编码、Operator Property 和对象创建调用链的 `Inward Axis` 接口,清理只服务旧 X 轴内部默认的逻辑 + +## 3. 迁移 Cutout Geometry Nodes + +- [x] 3.1 将 Cutout 节点构建辅助函数从 X 轴厚度/偏移改为 Z 轴,并按最终职责重命名,不保留旧函数转发 +- [x] 3.2 将 Surface、Balloon、Depth Balloon 与 Depth Surface 的平面坐标、厚度、深度投影和向量运算统一迁移到 XY / Z 坐标 +- [x] 3.3 保留 `O Image Cutout` 的 `Inward Axis` socket 与 Menu Switch,让 `-Z` 直通原生 Shape、`+X` 执行 Z 到 X 的最终 Transform,并更新节点布局验证 +- [x] 3.4 更新 Cutout 与既有 Plane 的节点资产验证,重建并验证 `src/anyimage/assets/O_AnyImage.blend` + +## 4. 对齐 Object Space Normal + +- [x] 4.1 修改 Server 的 Object Space Normal 通道映射和不可见像素值,使 Artifact 直接使用最终 XY / Z Cutout 对象空间 +- [x] 4.2 调整 Blender 端 Object Space Normal 转换方向:默认 `-Z` 原样消费 Artifact,仅 `+X` 在响应阶段执行一次 Z 到 X 的通道变换 + +## 5. 文档与完整验证 + +- [x] 5.1 更新 Cutout Tool 与节点资产内部文档,用从上到下的流程说明统一 XY / Z 坐标和四种 Shape 的 `-Z` 内向语义 +- [x] 5.2 搜索并清理 Shape 子组以 X 轴为内部默认的实现和说明,同时确认 `Inward Axis`、`CutoutInwardAxis` 与外层转换只承担可选输出轴职责 +- [x] 5.3 运行 Cutout、Server、Blender 与节点资产定向测试,再运行 `uv run pytest`;仅修复本 change 引入的回归,不构建其他文档或发布产物 diff --git a/openspec/changes/support-frame-view-plane-intersections/.openspec.yaml b/openspec/changes/support-frame-view-plane-intersections/.openspec.yaml new file mode 100644 index 0000000..b269b01 --- /dev/null +++ b/openspec/changes/support-frame-view-plane-intersections/.openspec.yaml @@ -0,0 +1,3 @@ +schema: spec-driven +created: 2026-09-03 + diff --git a/openspec/changes/support-frame-view-plane-intersections/design.md b/openspec/changes/support-frame-view-plane-intersections/design.md new file mode 100644 index 0000000..8e788a4 --- /dev/null +++ b/openspec/changes/support-frame-view-plane-intersections/design.md @@ -0,0 +1,99 @@ +## Context + +Frame 当前把每个 Image Empty 的四个本地角投影到 Region,要求结果是有限凸 Quad,再由 `screen_quad → source pixels` Homography 对输出像素采样。`_frame_source()` 还要求四个角的视图深度全部大于固定阈值,`composite_frame_pixels()` 则把深度不为正的样本设为透明。 + +这套表示只适用于完整图片位于透视观察原点前方的情况: + +- 正交投影的屏幕坐标不因观察原点前后而产生透视奇点,但现有统一深度检查仍会拒绝它。 +- 透视图片穿过观察原点时,完整图片投影会经过无穷远,不能表示成一个有限凸 Quad;然而 Frame 内的许多屏幕射线仍可能与图片前方部分形成稳定交点。 +- 简单删除报错会把非凸、翻转或经过无穷远的角点继续交给 Quad Homography 与 SAT 重叠判断,无法保证采样正确。 + +Frame 仍然只烘焙 Image RGBA 与几何投射,不是 Viewport 截图,因此场景遮挡、Grid、Gizmo、Overlay、显示 tint 与 Color Management 不属于本变更。 + +## Goals / Non-Goals + +**Goals:** + +- 让正交视图中的 Frame 不受透视观察平面深度符号限制。 +- 在透视视图中支持 Image Empty 部分跨越观察原点,只采样观察方向前方的有效部分。 +- 使用同一套输出驱动求交模型计算源像素位置、样本有效性与逐像素深度。 +- 保留无 active 几何交叠 warning,并使判断覆盖无界或被 Frame 截断的投射。 +- 在 active 原点无法作为可见结果深度时,为结果 Image Empty 选择稳定、可解释的前方深度。 + +**Non-Goals:** + +- 不捕获 Blender Viewport framebuffer,也不复刻其他对象遮挡或 Overlay。 +- 不把透视观察方向后方的平面交点镜像到结果中。 +- 不改变 Viewport `clip_start`、`clip_end` 或 Image Empty 自身显示规则。 +- 不改变 Frame 的输出 Canvas、分辨率限制、插值、Alpha、选择集合和对象生命周期语义。 + +## Decisions + +### 1. 以屏幕观察射线到图片平面的映射替代角点 Quad + +对每个源对象,组合冻结的 Viewport 投影矩阵与对象世界矩阵,得到本地图片平面 `z = 0` 到 clip-space 的三阶 Projective Transform。Frame 输出像素中心先映射到 Region screen-space,再通过该变换的逆映射得到图片局部坐标,最后按 Image Empty bounds 换算为源像素坐标。 + +这与“从屏幕像素生成观察射线,再与图片平面求交”等价,但可以继续使用 NumPy 分块向量化,不需要为每个像素创建 Blender `Vector` 或调用几何 API。 + +```mermaid +flowchart TD + Frame["Frame 像素中心"] --> Screen["Region 屏幕坐标"] + View["冻结的 Viewport 投影"] --> Mapping["Screen → Image Local 平面映射"] + Object["Image Empty 世界矩阵"] --> Mapping + Screen --> Mapping --> Local{"有限且位于 Image bounds?"} + Local -->|是| Sample["源 RGBA + View Depth"] + Local -->|否| Clear["透明样本"] + Sample --> Composite["逐像素深度与 Alpha 合成"] +``` + +逆映射的齐次分母接近零表示该屏幕射线与图片平面平行。该像素作为无效透明样本处理;只有整个有效投射无法稳定求解时才取消操作。容差按矩阵和齐次量级计算,不再使用固定世界单位的 `1e-8` 作为整张图片的有效性条件。 + +### 2. 投影模式分别定义样本有效性 + +正交视图的观察射线互相平行,视图深度的符号不构成投影奇点。局部交点有限、映射稳定且位于 Image Empty bounds 内时,样本有效;其带符号视图深度继续用于多个源之间的前后排序。 + +透视视图只接受视图深度严格位于观察方向前方的交点。图片跨越观察原点时,前方交点正常采样,后方交点和射线平行位置透明。深度比较继续在同一个屏幕像素的观察射线上进行,因此可保持多张倾斜图片逐像素变化的遮挡关系。 + +### 3. active 交叠使用可求交区域 + +Frame 成功条件仍要求 Frame 与 active Image Empty 有正面积几何交叠,但交叠对象改为“在当前投影模式下可稳定求交且位于 active 图片 bounds 内的区域”。它不依赖源图片四角形成有限凸 Quad,也不依赖源图片 Alpha。 + +实现使用齐次平面投影裁剪得到 active 的有效前方区域,再与 Frame 矩形裁剪并计算正面积。逐输出像素是否恰好命中 active 不作为唯一成功判断,避免极窄但有效的交叠因输出分辨率取整而被误判。 + +### 4. 结果深度优先保持 active 原点语义 + +正交视图继续使用穿过 active 对象世界原点的视平面放置结果,因为任意有限深度都能保持相同的正交屏幕覆盖。 + +透视视图中,active 原点位于观察方向前方时继续沿用其视图深度,保持既有结果位置。若 active 原点不在前方、但 active 图片与 Frame 存在有效前方交叠,则以该交叠区域的屏幕几何中心生成观察射线,并采用它与 active 图片平面的正深度作为结果平面深度。由此得到的 view-facing 结果仍严格覆盖 Frame,同时位于实际被烘焙内容的代表深度。 + +如果代表射线无法稳定求交,系统取消操作并保持输入不变,不使用任意常量或 Viewport clip 距离猜测结果位置。 + +### 5. 退化只淘汰受影响区域 + +单条射线平行、位于透视观察方向后方或映射到图片 bounds 外时,只让对应源样本透明。以下情况才取消整个操作: + +- 源平面到 screen-space 的 Projective Transform 不可逆或非有限。 +- Frame 与 active 的有效投射没有正面积交叠。 +- active 的结果深度无法按既定规则稳定取得。 +- 既有的 Frame 尺寸、图片数据或对象状态校验失败。 + +非 active 图片没有有效样本时可以继续作为透明参与源,并沿用 Frame 成功后消费全部参与 Image Empty 的既有语义。 + +## Risks / Trade-offs + +- [跨观察原点的投射包含无穷远分界] → 使用输出驱动逆映射,按像素屏蔽齐次分母退化,不构造完整有限 Quad。 +- [容差不当造成边界闪烁或误杀] → 使用与矩阵量级相关的齐次容差,并覆盖不同对象尺度与极近正深度测试。 +- [active 原点在视后方时结果深度发生变化] → 仅在原点无法产生可见结果时使用有效交叠中心深度,正常情况保持既有语义。 +- [有效交叠很窄而输出像素未命中] → 成功判断使用连续几何裁剪面积,不依赖离散采样结果。 +- [正交视图中的带符号深度排序与现有正深度假设不同] → 明确拆分投影模式,并用前后对象、共面 active 和负深度样本测试验证排序。 + +## Migration Plan + +实施时先建立独立的屏幕到图片平面映射和有效区域裁剪函数,再替换 Frame source descriptor 与合成采样,随后调整结果深度选择。保留现有普通透视、Alpha、多图排序、Frame Canvas 和对象替换测试作为回归基线,并加入新边界测试。 + +本变更不修改保存数据或公开 ID,不需要迁移。回滚只需恢复原 Quad 投影描述、四角正深度校验和 active 原点深度放置;已生成的 packed Image Empty 不依赖新运行时代码。 + +## Open Questions + +无。产品语义确定为:正交视图按平行射线求交;透视视图烘焙观察方向前方的有效交点;active 原点无法提供可见结果深度时使用 active 有效交叠区域的代表深度。 + diff --git a/openspec/changes/support-frame-view-plane-intersections/proposal.md b/openspec/changes/support-frame-view-plane-intersections/proposal.md new file mode 100644 index 0000000..a9b77c7 --- /dev/null +++ b/openspec/changes/support-frame-view-plane-intersections/proposal.md @@ -0,0 +1,28 @@ +## Why + +Frame 当前先把 Image Empty 的四个角投影为有限凸四边形,并要求四角的视图深度全部为正。这个前置条件把正交视图错误地套入透视观察平面的限制,也会在倾斜图片靠近透视观察原点、仍有有效可见区域时取消操作。用户已经能在 Viewport 中看到的有效图片投射因此无法被重新取景。 + +## What Changes + +- Frame 改以 Frame 像素对应的观察射线与各 Image Empty 平面求交,决定源像素坐标、有效性和几何深度,不再要求源图片四角全部位于观察平面前方。 +- 正交视图接受任意有限深度的平面交点,只在图片平面与观察射线平行或投影退化时取消。 +- 透视视图只采样观察方向前方的交点;Image Empty 穿过观察原点时,仍烘焙 Frame 内可稳定求交的前方部分,后方与平行射线对应区域保持透明。 +- Frame 与 active Image Empty 的有效投射交叠改为按可求交区域判断,不再依赖完整凸四边形。 +- 当 active 对象原点不在透视观察方向前方、但 active 图片仍有有效交叠时,结果平面使用 active 有效交叠区域的代表深度,保证结果仍位于观察方向前方并覆盖 Frame。 +- 补充正交深度、极近透视、跨观察平面、平行视线、无有效交叠和结果放置测试,并同步 Frame 内部文档。 + +## Capabilities + +### New Capabilities + + +### Modified Capabilities + +- `frame-tool`: 放宽 Frame 的投影有效性边界,以逐射线平面求交支持正交视图和部分跨越透视观察平面的 Image Empty,并定义相应的结果深度。 + +## Impact + +- 主要影响 `src/anyimage/operators/image_edit_tool/frame.py` 的源投影描述、有效区域判断、像素采样、深度合成和结果平面放置。 +- 相关测试集中在 `tests/test_blender_addon.py`,内部说明同步到 `docs/internals/common.md` 与 Image Edit Tool 文档。 +- 不改变 Frame 的工具入口、输出分辨率、Alpha 合成、Selection 输入集合、对象消费或 Undo 语义,不引入依赖,也不修改节点资产。 + diff --git a/openspec/changes/support-frame-view-plane-intersections/specs/frame-tool/spec.md b/openspec/changes/support-frame-view-plane-intersections/specs/frame-tool/spec.md new file mode 100644 index 0000000..9cb7b79 --- /dev/null +++ b/openspec/changes/support-frame-view-plane-intersections/specs/frame-tool/spec.md @@ -0,0 +1,70 @@ +## ADDED Requirements + +### Requirement: Frame samples every valid view-plane intersection +Frame SHALL 根据当前投影模式,以 Frame 屏幕位置对应的观察射线与每个参与 Image Empty 平面求交,并 SHALL 以有限且位于图片显示边界内的交点计算源像素坐标和几何深度。系统 SHALL NOT 仅因 Image Empty 的一个或多个角位于观察平面另一侧而取消整个操作。 + +#### Scenario: User finishes a frame in orthographic view at a non-positive view depth +- **WHEN** active Image Empty 在正交视图中具有有限稳定的屏幕投射,但其视图深度不为正 +- **AND** Frame 与 active 图片的有效投射存在正面积交叠 +- **THEN** 系统完成 Frame +- **AND** 结果保持操作时的正交屏幕图案 + +#### Scenario: Orthographic rays are parallel to the image plane +- **WHEN** 正交观察射线与 active Image Empty 平面平行,无法形成稳定的有限交点 +- **THEN** 系统取消 Frame +- **AND** 所有参与对象保持不变 + +#### Scenario: A perspective image partially crosses the view plane +- **WHEN** Image Empty 的一部分位于透视观察方向前方,另一部分越过观察原点 +- **AND** Frame 与 active 图片的有效前方投射存在正面积交叠 +- **THEN** 系统采样 Frame 内观察方向前方的稳定平面交点 +- **AND** 位于观察方向后方、与射线平行或超出图片边界的源样本为透明 +- **AND** 系统不因完整图片无法形成有限凸四边形而取消操作 + +#### Scenario: A perspective image is extremely close but remains in front +- **WHEN** Image Empty 的有效交点位于透视观察方向前方且非常接近观察原点 +- **AND** 屏幕到图片平面的映射仍可稳定求解 +- **THEN** 系统允许该投射参与 Frame +- **AND** 系统不使用固定世界距离阈值拒绝整张图片 + +#### Scenario: A frame contains a ray parallel to one source plane +- **WHEN** Frame 中部分屏幕位置的观察射线与某个参与 Image Empty 平面平行 +- **THEN** 该源在对应位置贡献透明样本 +- **AND** 其他稳定位置及其他参与源继续正常采样与合成 + +### Requirement: Frame validates active overlap from the valid projection region +Frame SHALL 使用 active Image Empty 在当前投影模式下可稳定求交的连续几何区域判断 Frame 交叠。交叠 SHALL 具有正面积,且 SHALL 独立于 active 图片的 Alpha 和离散输出像素是否恰好命中该区域。 + +#### Scenario: The complete active projection is unbounded but its valid part overlaps the frame +- **WHEN** active Image Empty 跨越透视观察原点,其完整角点投影无法表示为有限凸 Quad +- **AND** 其观察方向前方的有效投射与 Frame 存在正面积交叠 +- **THEN** 系统接受该 active 交叠并继续 Frame + +#### Scenario: Only invalid or rear-facing intersections overlap the frame +- **WHEN** Frame 内不存在 active Image Empty 的稳定前方透视交点,或不存在稳定的正交交点 +- **THEN** 系统以 warning 提醒用户 Frame 没有与 active Image Empty 的有效投射交叠 +- **AND** 系统取消操作并保持所有参与对象不变 + +#### Scenario: A narrow valid overlap falls between output pixel centers +- **WHEN** Frame 与 active 有正面积有效几何交叠,但当前输出尺寸的像素中心没有命中该区域 +- **THEN** 系统仍将该 Frame 视为具有 active 交叠 +- **AND** 离散输出像素可以按实际采样结果保持透明 + +### Requirement: Perspective Frame uses a visible result depth +Frame SHALL 在透视视图中优先使用 active Image Empty 世界原点的正视图深度放置 view-facing 结果。当 active 原点不在观察方向前方、但 active 图片存在有效前方交叠时,系统 SHALL 使用 active 有效交叠区域屏幕几何中心对应的稳定正交点深度放置结果。系统 SHALL NOT 以任意常量深度替代无法求解的结果位置。 + +#### Scenario: Active origin remains in front of the perspective view +- **WHEN** active Image Empty 世界原点位于透视观察方向前方 +- **THEN** 结果平面继续使用 active 原点的视图深度 + +#### Scenario: Active origin is behind while part of the image remains visible +- **WHEN** active Image Empty 世界原点不在透视观察方向前方 +- **AND** active 图片的有效前方投射与 Frame 存在正面积交叠 +- **THEN** 系统使用有效交叠区域屏幕几何中心与 active 图片平面的交点深度 +- **AND** 结果 Image Empty 位于观察方向前方并覆盖用户所画 Frame + +#### Scenario: No stable representative depth exists +- **WHEN** active 原点不能提供正深度,且有效交叠区域的代表射线无法稳定取得正交点深度 +- **THEN** 系统取消 Frame +- **AND** 所有参与对象保持不变 + diff --git a/openspec/changes/support-frame-view-plane-intersections/tasks.md b/openspec/changes/support-frame-view-plane-intersections/tasks.md new file mode 100644 index 0000000..58827af --- /dev/null +++ b/openspec/changes/support-frame-view-plane-intersections/tasks.md @@ -0,0 +1,22 @@ +## 1. 投影与求交基础 + +- [x] 1.1 从冻结的 Viewport 投影矩阵和 Image Empty 世界矩阵构建本地图片平面到 Region screen-space 的 Projective Transform,并实现带量级容差的逆映射与逐点有效性结果 +- [x] 1.2 实现 active 图片有效投射区域与 Frame 矩形的连续几何裁剪和正面积判断,覆盖正交投影、透视前方、跨观察原点、无穷远分界、平行视线和窄交叠 +- [x] 1.3 为屏幕到图片局部坐标、源像素坐标和视图深度计算补充不同对象尺度、极近正深度、固定阈值误判与退化矩阵单元测试 + +## 2. Frame 采样与合成 + +- [x] 2.1 将 Frame source descriptor 从有限角点 Quad 改为屏幕观察射线到图片平面的映射,并按正交与透视模式分别计算样本有效性 +- [x] 2.2 更新分块像素合成:正交视图保留任意有限深度样本,透视视图只保留观察方向前方样本,单条平行或无效射线只让对应源透明 +- [x] 2.3 补充正交负深度多图排序、透视跨平面部分采样、后方透明、单源局部退化、共面 active 置顶和既有普通透视结果回归测试 + +## 3. 结果放置与事务边界 + +- [x] 3.1 保留正常透视和正交视图的 active 原点深度放置;当透视 active 原点不在前方时,从 active 有效交叠区域屏幕几何中心求取代表正深度 +- [x] 3.2 在代表深度不可稳定求解时于创建或替换结果 Image 前取消操作,并验证所有参与 Object、Image 和变换保持不变 +- [x] 3.3 补充 active 原点在前方、位于观察原点、处于后方但图片部分可见,以及没有稳定代表深度的结果矩阵与显示范围测试 + +## 4. 文档与验证 + +- [x] 4.1 更新 Frame 与公共 Viewport/Image 内部文档,用从 Frame 像素到观察射线、图片平面交点、源像素和深度合成的流程正面描述当前实现 +- [x] 4.2 运行 Frame、Image Edit Tool、注册与 Blender Addon 相关测试,再运行 `uv run pytest`,确认完整测试集通过且不构建文档、节点资产或扩展包 diff --git a/src/anyimage/__init__.py b/src/anyimage/__init__.py index 124f44c..9837587 100644 --- a/src/anyimage/__init__.py +++ b/src/anyimage/__init__.py @@ -5,11 +5,10 @@ from .operators.clipboard_image import CLASSES as CLIPBOARD_CLASSES from .menu import AnyImageImageMenu, draw_image_context_menu from .operators import CLASSES as OPERATOR_CLASSES from .operators.cutout_tool import CutoutTool -from .operators.crop_tool import ( - CropBoxTool, - CropLassoTool, - CropPerspectiveTool, - CropPolylineTool, +from .operators.image_edit_tool import ( + FrameTool, + MaskTool, + RectifyTool, ) from .panel import ( Ben2RemovalDebugPanel, @@ -42,20 +41,15 @@ CLASSES = ( def register(): for class_type in CLASSES: bpy.utils.register_class(class_type) - bpy.utils.register_tool(CropBoxTool, separator=True, group=True) + bpy.utils.register_tool(FrameTool, separator=True, group=True) bpy.utils.register_tool( - CropLassoTool, - after={CropBoxTool.bl_idname}, + MaskTool, + after={FrameTool.bl_idname}, separator=False, ) bpy.utils.register_tool( - CropPolylineTool, - after={CropLassoTool.bl_idname}, - separator=False, - ) - bpy.utils.register_tool( - CropPerspectiveTool, - after={CropPolylineTool.bl_idname}, + RectifyTool, + after={MaskTool.bl_idname}, separator=False, ) bpy.utils.register_tool(CutoutTool) @@ -70,10 +64,9 @@ def unregister(): runtime.unregister() clipboard_image.unregister_keymaps() bpy.utils.unregister_tool(CutoutTool) - bpy.utils.unregister_tool(CropPerspectiveTool) - bpy.utils.unregister_tool(CropPolylineTool) - bpy.utils.unregister_tool(CropLassoTool) - bpy.utils.unregister_tool(CropBoxTool) + bpy.utils.unregister_tool(RectifyTool) + bpy.utils.unregister_tool(MaskTool) + bpy.utils.unregister_tool(FrameTool) bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu) bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu) if hasattr(bpy.types.Scene, "anyimage_settings"): diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 6db1d37..11b4607 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:f26a2016d08490b77db73543068082259dfe3b0b4ef32a9dc62a7b0fdedfacbd -size 1731376 +oid sha256:26fbd31b659a16a865c2aa82c91817898f9bd04acd41c72e609a24546054f12a +size 1719984 diff --git a/src/anyimage/common/image.py b/src/anyimage/common/image.py index ea8abdf..10e250c 100644 --- a/src/anyimage/common/image.py +++ b/src/anyimage/common/image.py @@ -1,5 +1,6 @@ """Shared Blender Image and Image Empty lifecycle helpers.""" +import math import re import shutil import tempfile @@ -20,6 +21,10 @@ IMAGE_NAME_SUFFIXES = ( ".tga", ) +MAX_PROJECTIVE_OUTPUT_DIMENSION = 8192 +MAX_PROJECTIVE_OUTPUT_PIXELS = 16_777_216 +PROJECTIVE_CHUNK_ROWS = 256 + VIDEO_NAME_SUFFIXES = ( ".mp4", @@ -52,11 +57,25 @@ def require_image_empty(object_name): def image_pixels(image): - """Read one Blender Image into a flat float32 pixel array.""" + """Read straight-alpha business pixels without changing presentation mode.""" import numpy as np - pixels = np.empty(len(image.pixels), dtype=np.float32) - image.pixels.foreach_get(pixels) + reader = image + if ( + getattr(image, "alpha_mode", None) == "PREMUL" + and not getattr(image, "is_dirty", False) + and not getattr(image, "is_float", False) + and getattr(image, "source", None) != "GENERATED" + ): + reader = image.copy() + try: + if reader is not image: + reader.alpha_mode = "STRAIGHT" + pixels = np.empty(len(reader.pixels), dtype=np.float32) + reader.pixels.foreach_get(pixels) + finally: + if reader is not image: + bpy.data.images.remove(reader) return pixels @@ -70,6 +89,197 @@ def image_rgba(image): return np.flipud(image_pixels(image).reshape((height, width, 4))) +def homography_from_points(source, target): + """Return a projective transform mapping four source points to target.""" + import numpy as np + + source = np.asarray(source, dtype=np.float64) + target = np.asarray(target, dtype=np.float64) + if source.shape != (4, 2) or target.shape != (4, 2): + raise ValueError("A homography requires four source and target points") + rows = [] + values = [] + for (x, y), (u, v) in zip(source, target): + rows.extend( + ( + (x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y), + (0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y), + ) + ) + values.extend((u, v)) + matrix = np.asarray(rows, dtype=np.float64) + if np.linalg.cond(matrix) > 1e12: + raise ValueError("The projective transform is too degenerate") + coefficients = np.linalg.solve(matrix, np.asarray(values, dtype=np.float64)) + return np.append(coefficients, 1.0).reshape((3, 3)) + + +def transform_homography(points, matrix): + """Transform 2D points by one homography.""" + import numpy as np + + points = np.asarray(points, dtype=np.float64) + if points.ndim < 2 or points.shape[-1] != 2: + raise ValueError("Projective points must contain X and Y") + flat = points.reshape((-1, 2)) + homogeneous = np.column_stack((flat, np.ones(len(flat), dtype=np.float64))) + mapped = homogeneous @ np.asarray(matrix, dtype=np.float64).T + denominator = mapped[:, 2] + if np.any(np.abs(denominator) <= 1e-12): + raise ValueError("The projective transform reaches infinity") + mapped = mapped[:, :2] / denominator[:, None] + if not np.isfinite(mapped).all(): + raise ValueError("The projective transform is not finite") + return mapped.reshape(points.shape) + + +def projective_output_size( + quad, + aspect_ratio, + max_dimension=MAX_PROJECTIVE_OUTPUT_DIMENSION, + max_pixels=MAX_PROJECTIVE_OUTPUT_PIXELS, +): + """Choose a detail-preserving output size for a source-space quad.""" + import numpy as np + + quad = np.asarray(quad, dtype=np.float64) + if quad.shape != (4, 2) or not np.isfinite(quad).all(): + raise ValueError("Projective output requires four valid corners") + if not math.isfinite(aspect_ratio) or aspect_ratio <= 0.0: + raise ValueError("Projective output aspect ratio must be greater than zero") + width_pixels = 0.5 * ( + np.linalg.norm(quad[1] - quad[0]) + np.linalg.norm(quad[2] - quad[3]) + ) + height_pixels = 0.5 * ( + np.linalg.norm(quad[2] - quad[1]) + np.linalg.norm(quad[3] - quad[0]) + ) + if min(width_pixels, height_pixels) <= 1e-8: + raise ValueError("Projective output corners are too close together") + height = (aspect_ratio * width_pixels + height_pixels) / ( + aspect_ratio * aspect_ratio + 1.0 + ) + width = aspect_ratio * height + dimension_scale = float(max_dimension) / max(width, height) + pixel_scale = math.sqrt(float(max_pixels) / max(width * height, 1.0)) + scale = min(1.0, dimension_scale, pixel_scale) + return max(2, int(round(width * scale))), max(2, int(round(height * scale))) + + +def premultiplied_rgba(source_pixels, source_size): + """Return top-down premultiplied RGBA pixels.""" + import numpy as np + + width, height = (int(value) for value in source_size) + if min(width, height) <= 0: + raise ValueError("Image dimensions must be positive") + source = np.asarray(source_pixels, dtype=np.float32) + if source.size != width * height * 4: + raise ValueError("The image pixels do not match its dimensions") + source = np.flipud(source.reshape((height, width, 4))).copy() + source[:, :, :3] *= source[:, :, 3:4] + return source + + +def sample_rgba(source, source_points): + """Bilinearly sample top-down RGBA at image-boundary coordinates.""" + import numpy as np + + source = np.asarray(source, dtype=np.float32) + points = np.asarray(source_points, dtype=np.float64) + if source.ndim != 3 or source.shape[2] != 4: + raise ValueError("The source must contain RGBA pixels") + if points.shape[-1] != 2: + raise ValueError("Sample points must contain X and Y") + height, width = source.shape[:2] + flat = points.reshape((-1, 2)) + mapped_x = flat[:, 0] + mapped_y = flat[:, 1] + valid = ( + np.isfinite(mapped_x) + & np.isfinite(mapped_y) + & (mapped_x >= 0.0) + & (mapped_x <= width) + & (mapped_y >= 0.0) + & (mapped_y <= height) + ) + pixel_x = np.clip(mapped_x - 0.5, 0.0, width - 1.0) + pixel_y = np.clip(mapped_y - 0.5, 0.0, height - 1.0) + x0 = np.floor(pixel_x).astype(np.int64) + y0 = np.floor(pixel_y).astype(np.int64) + x1 = np.minimum(x0 + 1, width - 1) + y1 = np.minimum(y0 + 1, height - 1) + fx = (pixel_x - x0)[:, None] + fy = (pixel_y - y0)[:, None] + sampled = ( + source[y0, x0] * (1.0 - fx) * (1.0 - fy) + + source[y0, x1] * fx * (1.0 - fy) + + source[y1, x0] * (1.0 - fx) * fy + + source[y1, x1] * fx * fy + ) + sampled[~valid] = 0.0 + return sampled.reshape((*points.shape[:-1], 4)) + + +def straight_rgba(premultiplied): + """Convert premultiplied RGBA and bleed edge RGB into transparent pixels.""" + import numpy as np + + rgba = np.asarray(premultiplied, dtype=np.float32).copy() + alpha = rgba[..., 3] + visible = alpha > 1e-8 + rgb = rgba[..., :3] + rgb[visible] /= alpha[visible, None] + rgba[~visible] = 0.0 + if rgba.ndim == 3: + unfilled = ~visible + + def fill_edge(target, source): + candidates = unfilled[target] & visible[source] + target_rgb = rgb[target] + target_rgb[candidates] = rgb[source][candidates] + unfilled[target][candidates] = False + + fill_edge((slice(1, None), slice(None)), (slice(None, -1), slice(None))) + fill_edge((slice(None, -1), slice(None)), (slice(1, None), slice(None))) + fill_edge((slice(None), slice(1, None)), (slice(None), slice(None, -1))) + fill_edge((slice(None), slice(None, -1)), (slice(None), slice(1, None))) + fill_edge((slice(1, None), slice(1, None)), (slice(None, -1), slice(None, -1))) + fill_edge((slice(1, None), slice(None, -1)), (slice(None, -1), slice(1, None))) + fill_edge((slice(None, -1), slice(1, None)), (slice(1, None), slice(None, -1))) + fill_edge((slice(None, -1), slice(None, -1)), (slice(1, None), slice(1, None))) + return rgba + + +def warp_projective_pixels(source_pixels, source_size, quad, output_size): + """Warp one source quad into Blender-order straight RGBA pixels.""" + import numpy as np + + output_width, output_height = (int(value) for value in output_size) + if min(output_width, output_height) <= 0: + raise ValueError("Projective output dimensions must be positive") + source = premultiplied_rgba(source_pixels, source_size) + target = np.asarray( + ( + (0.0, 0.0), + (float(output_width), 0.0), + (float(output_width), float(output_height)), + (0.0, float(output_height)), + ), + dtype=np.float64, + ) + output_to_source = homography_from_points(target, quad) + output = np.zeros((output_height, output_width, 4), dtype=np.float32) + target_x = np.arange(output_width, dtype=np.float64) + 0.5 + for row_start in range(0, output_height, PROJECTIVE_CHUNK_ROWS): + row_end = min(row_start + PROJECTIVE_CHUNK_ROWS, output_height) + target_y = np.arange(row_start, row_end, dtype=np.float64) + 0.5 + x, y = np.meshgrid(target_x, target_y) + points = np.stack((x, y), axis=-1) + mapped = transform_homography(points, output_to_source) + output[row_start:row_end] = sample_rgba(source, mapped) + return np.flipud(straight_rgba(output)).ravel() + + def is_animated_image(image): return getattr(image, "source", None) in {"MOVIE", "SEQUENCE"} @@ -405,6 +615,21 @@ def load_normal_result_image(path, source_object): return image +def configure_color_alpha(image): + """Apply the presentation mode to an owned Color image.""" + if image.alpha_mode != "PREMUL": + pixels = ( + image_pixels(image) + if getattr(image, "source", None) == "GENERATED" + or getattr(image, "is_dirty", False) + else None + ) + image.alpha_mode = "PREMUL" + if pixels is not None: + image.pixels.foreach_set(pixels) + image.update() + + 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) @@ -420,7 +645,7 @@ def create_image_edit_result(source_image, pixels, output_size): ) try: image.colorspace_settings.name = source_image.colorspace_settings.name - image.alpha_mode = "STRAIGHT" + configure_color_alpha(image) image.pixels.foreach_set(pixels) image.pack() image.update() @@ -435,7 +660,9 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None): source_image = source_object.data source_name = source_image.name original_fake_user = source_image.use_fake_user + original_blending = getattr(source_object, "use_empty_image_alpha", False) image_user = getattr(source_object, "image_user", None) + original_timing = image_user_settings(source_object) if image_user is not None else None frame_count = ( video_total_frames(result_image) if is_animated_image(result_image) @@ -460,8 +687,13 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None): ) = image_user_settings(source_object) source_image.use_fake_user = True + original_alignment = ( + (source_object.empty_display_size, tuple(source_object.empty_image_offset)) + if alignment is not None else None + ) try: source_object.data = result_image + source_object.use_empty_image_alpha = True if frame_count is not None: configure_image_user( image_user, @@ -474,6 +706,12 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None): source_object.empty_image_offset = alignment[1] except Exception: source_object.data = source_image + source_object.use_empty_image_alpha = original_blending + if original_alignment is not None: + source_object.empty_display_size, source_object.empty_image_offset = original_alignment + if original_timing is not None: + (image_user.frame_start, image_user.frame_offset, + image_user.frame_duration, image_user.use_cyclic) = original_timing source_image.use_fake_user = original_fake_user raise @@ -484,47 +722,6 @@ def replace_empty_image(source_object, result_image, *, placement_bounds=None): return result_image -def place_empty_image_result( - context, - source_object, - result_image, - *, - placement_bounds=None, - keep_original=False, -): - """Place one crop result and optionally preserve its source.""" - if not keep_original: - replace_empty_image( - source_object, - result_image, - placement_bounds=placement_bounds, - ) - return source_object - - result_object = source_object.copy() - collections = tuple(source_object.users_collection) - if not collections: - collections = (context.collection,) - try: - for collection in collections: - collection.objects.link(result_object) - replace_empty_image( - result_object, - result_image, - placement_bounds=placement_bounds, - ) - except Exception: - bpy.data.objects.remove(result_object, do_unlink=True) - raise - - for selected in context.selected_objects: - selected.select_set(False) - result_object.select_set(True) - context.view_layer.objects.active = result_object - return result_object - - - def _load_packed_result_image( source_object, output_directory, @@ -546,8 +743,8 @@ def _load_packed_result_image( str(color_paths[0]), check_existing=False, ) - result_image.alpha_mode = "CHANNEL_PACKED" result_image.colorspace_settings.name = source_color_space + configure_color_alpha(result_image) if animated: force_sequence_source(result_image) except (OSError, RuntimeError, TypeError, ValueError) as error: diff --git a/src/anyimage/common/material.py b/src/anyimage/common/material.py index 52e0c59..da5f3e2 100644 --- a/src/anyimage/common/material.py +++ b/src/anyimage/common/material.py @@ -3,6 +3,7 @@ import bpy from .image import ( + configure_color_alpha, configure_image_user, image_base_name, image_pixels, @@ -106,6 +107,7 @@ def create_image_material( uv_map.location = (-1250.0, 350.0) material_image = color_image set_material_color_space(material_image, scene) + configure_color_alpha(material_image) color_texture.image = material_image color_texture.interpolation = "Linear" color_texture.extension = "EXTEND" diff --git a/src/anyimage/common/selection.py b/src/anyimage/common/selection.py index 05507f2..1c1381f 100644 --- a/src/anyimage/common/selection.py +++ b/src/anyimage/common/selection.py @@ -1,19 +1,45 @@ -"""Selection values, serialization, rasterization, and Alpha cropping.""" +"""Selection values, serialization, rasterization, and composition.""" import json import math from dataclasses import dataclass -from pathlib import Path class ImageEditWarning(ValueError): pass +def clip_polygon_halfplanes(points, boundaries): + """Clip a 2D polygon to linear half-planes ax + by + c >= 0.""" + polygon = tuple(tuple(float(value) for value in point) for point in points) + for coefficients in boundaries: + scale = max(abs(float(value)) for value in coefficients) + if scale == 0.0: + continue + a, b, c = (float(value) / scale for value in coefficients) + result = [] + if not polygon: + break + previous = polygon[-1] + previous_value = math.fsum((a * previous[0], b * previous[1], c)) + for current in polygon: + value = math.fsum((a * current[0], b * current[1], c)) + if (value >= 0.0) != (previous_value >= 0.0): + weight = previous_value / (previous_value - value) + result.append(tuple( + previous[axis] + weight * (current[axis] - previous[axis]) + for axis in range(2) + )) + if value >= 0.0: + result.append(current) + previous, previous_value = current, value + polygon = tuple(result) + return polygon + + @dataclass(frozen=True) class SelectionPath: points: tuple[tuple[float, float], ...] - invert: bool = False def __post_init__(self): points = tuple( @@ -22,26 +48,19 @@ class SelectionPath: ) selection_path_bounds(points) object.__setattr__(self, "points", points) - object.__setattr__(self, "invert", bool(self.invert)) def to_json(self): return json.dumps( - { - "points": self.points, - "invert": self.invert, - }, + {"points": self.points}, separators=(",", ":"), ) @classmethod def from_json(cls, value): data = json.loads(value) - if not isinstance(data, dict) or set(data) != {"points", "invert"}: + if not isinstance(data, dict) or set(data) != {"points"}: raise ValueError("The Selection Path data is invalid") - return cls( - points=tuple(data["points"]), - invert=bool(data["invert"]), - ) + return cls(points=tuple(data["points"])) @dataclass(frozen=True) @@ -87,29 +106,6 @@ 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 @@ -158,8 +154,6 @@ def _selection_canvas_bounds(image_size, selection_path, padding): width, height = (int(value) for value in image_size) if width <= 0 or height <= 0: raise ValueError("The source image has no readable dimensions") - if selection_path.invert: - return 0, 0, width, height left, top, right, bottom = selection_path_bounds(selection_path.points) return ( max(left - padding, 0), @@ -208,15 +202,13 @@ def rasterize_selection_path( mask = _antialias_mask(mask) else: mask = mask.astype(np.float32) - if selection_path.invert: - mask = 1.0 - mask if not np.any(mask > 0.0): raise ImageEditWarning("The selection contains no visible image pixels") return SelectionMask(mask, canvas_bounds) -def crop_pixels_to_alpha(pixels, bounds): - """Crop Blender-order RGBA pixels to their nonzero Alpha bounds.""" +def trim_pixels_to_alpha(pixels, bounds): + """Trim Blender-order RGBA pixels to their nonzero Alpha bounds.""" import numpy as np left, top, right, bottom = bounds @@ -239,3 +231,30 @@ def crop_pixels_to_alpha(pixels, bounds): left + content_right, top + content_bottom, ) + + +def rasterize_selection_union(image_size, paths, *, antialias=False): + """Rasterize a geometric union on one local canvas before antialiasing.""" + import numpy as np + + paths = tuple(paths) + bounds = [ + _selection_canvas_bounds(image_size, path, 2 if antialias else 0) + for path in paths + ] + entries = [(path, box) for path, box in zip(paths, bounds) + if box[2] > box[0] and box[3] > box[1]] + if not entries: + raise ImageEditWarning("The selection contains no visible image pixels") + left = min(box[0] for _, box in entries) + top = min(box[1] for _, box in entries) + right = max(box[2] for _, box in entries) + bottom = max(box[3] for _, box in entries) + coverage = np.zeros((bottom - top, right - left), dtype=bool) + for path, box in entries: + x0, y0, x1, y1 = box + coverage[y0 - top:y1 - top, x0 - left:x1 - left] |= _rasterize_path(box, path.points) + if not coverage.any(): + raise ImageEditWarning("The selection contains no visible image pixels") + values = _antialias_mask(coverage) if antialias else coverage.astype(np.float32) + return SelectionMask(values, (left, top, right, bottom)) diff --git a/src/anyimage/common/viewport.py b/src/anyimage/common/viewport.py index bd52445..4d66d4c 100644 --- a/src/anyimage/common/viewport.py +++ b/src/anyimage/common/viewport.py @@ -2,11 +2,10 @@ import json import math +import time import bpy -from ..properties import ai_ready -from .ai import draw_ai_property, draw_ai_setup from .image import ( image_empty_bounds, is_image_empty, @@ -18,6 +17,48 @@ from .selection import ImageEditWarning, SelectionPath MIN_PATH_DISTANCE = 2.0 +def image_edit_selection_keymap(*, deselect_on_empty=True): + return ( + ( + "view3d.select", + {"type": "LEFTMOUSE", "value": "CLICK"}, + {"properties": [("deselect_all", deselect_on_empty)]}, + ), + ( + "view3d.select", + {"type": "LEFTMOUSE", "value": "CLICK", "shift": True}, + {"properties": [("toggle", True)]}, + ), + ) + + +def image_edit_drag_keymap( + operator_id, + properties=None, + *, + deselect_on_empty=True, +): + return ( + ( + operator_id, + {"type": "LEFTMOUSE", "value": "CLICK_DRAG"}, + properties, + ), + *image_edit_selection_keymap(deselect_on_empty=deselect_on_empty), + ) + + +def image_edit_point_keymap(operator_id, properties=None): + return ( + ( + operator_id, + {"type": "LEFTMOUSE", "value": "PRESS"}, + properties, + ), + image_edit_selection_keymap()[1], + ) + + def simplify_screen_path(path): pixels = [] for point in path: @@ -34,6 +75,8 @@ def screen_path_to_image_pixels( matrix_world, local_bounds, image_size, + *, + simplify=True, ): from bpy_extras import view3d_utils @@ -43,7 +86,8 @@ def screen_path_to_image_pixels( local_width = x_max - x_min local_height = y_max - y_min pixels = [] - for point in simplify_screen_path(path): + screen_points = simplify_screen_path(path) if simplify else path + for point in screen_points: ray_origin = inverse @ view3d_utils.region_2d_to_origin_3d( view_region, view3d, @@ -103,22 +147,9 @@ def image_edit_poll(context): return context.area is not None and context.area.type == "VIEW_3D" -def _restore_selection(context, active_object, selected_objects): - bpy.ops.object.select_all(action="DESELECT") - for selected in selected_objects: - if bpy.data.objects.get(selected.name) is not None: - selected.select_set(True) - if ( - active_object is not None - and bpy.data.objects.get(active_object.name) is not None - ): - context.view_layer.objects.active = active_object - - def resolve_image_edit_click(context, event, report): """Resolve the first click into source selection or image editing.""" active_object = context.active_object - selected_objects = tuple(context.selected_objects) try: bpy.ops.view3d.select( location=(event.mouse_region_x, event.mouse_region_y), @@ -128,15 +159,26 @@ def resolve_image_edit_click(context, event, report): except RuntimeError: pass clicked_object = context.active_object - if is_image_empty(clicked_object) and clicked_object != active_object: + if clicked_object != active_object: return clicked_object, False - if is_image_empty(clicked_object): - return clicked_object, True - if is_image_empty(active_object): - _restore_selection(context, active_object, selected_objects) - return active_object, True - report({"WARNING"}, "Click an Image Empty to use a Crop tool") - return None, False + try: + return active_image_edit_source(context), True + except ImageEditWarning as error: + report({"WARNING"}, str(error)) + return None, False + + +def active_image_edit_source(context): + """Return the selected active Image Empty used by image edit tools.""" + source_object = context.active_object + if ( + not is_image_empty(source_object) + or source_object not in context.selected_objects + ): + raise ImageEditWarning( + "Select an active Image Empty to use an Image tool" + ) + return source_object def active_view3d_tool_id(context): @@ -151,60 +193,14 @@ def active_view3d_tool_id(context): return getattr(tool, "idname", None) -def _crop_setting_name(operator, mapping_name): - setting_names = getattr(operator, mapping_name, {}) - return setting_names.get(operator.gesture) - - -def apply_crop_settings(operator, context): - settings = context.scene.anyimage_settings - setting_name = _crop_setting_name(operator, "refine_selection_settings") - operator.refine_selection = ( - setting_name is not None and ai_ready() and getattr(settings, setting_name) - ) - setting_name = _crop_setting_name(operator, "invert_settings") - if setting_name is not None: - operator.invert = bool(getattr(settings, setting_name)) - setting_name = _crop_setting_name(operator, "keep_original_settings") - if setting_name is not None: - operator.keep_original = bool(getattr(settings, setting_name)) - - -def draw_crop_operator_settings(layout, operator): - if _crop_setting_name(operator, "refine_selection_settings") is not None: - status = draw_ai_setup(layout) - draw_ai_property(layout, operator, "refine_selection", status) - if _crop_setting_name(operator, "invert_settings") is not None: - layout.prop(operator, "invert") - if _crop_setting_name(operator, "keep_original_settings") is not None: - layout.prop(operator, "keep_original") - - -def draw_crop_settings( - context, - layout, - *, - keep_original, - invert=None, - refine_selection=None, -): - settings = context.scene.anyimage_settings - if refine_selection is not None: - status = draw_ai_setup(layout) - draw_ai_property(layout, settings, refine_selection, status) - if invert is not None: - layout.prop(settings, invert) - layout.prop(settings, keep_original) - - -def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0): +def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0, offset=0.0): if dash_length <= 0.0 or gap_length < 0.0: raise ValueError( "Dash length must be positive and gap length cannot be negative" ) pattern_length = dash_length + gap_length segments = [] - distance_offset = 0.0 + distance_offset = float(offset) for start, end in zip(vertices, vertices[1:]): start_x, start_y = (float(value) for value in start) end_x, end_y = (float(value) for value in end) @@ -344,47 +340,103 @@ def triangulated_polygon_vertices(vertices): return _triangulated_polygon_vertices_python(vertices) -def scanline_fill_triangles(vertices, step=2.0): +def _scanline_intervals(vertices, sample_y): + edges = tuple(zip(vertices, (*vertices[1:], vertices[0]))) + events = [] + for start, end in edges: + if (start[1] <= sample_y < end[1]) or (end[1] <= sample_y < start[1]): + factor = (sample_y - start[1]) / (end[1] - start[1]) + events.append( + ( + start[0] + (end[0] - start[0]) * factor, + 1 if end[1] > start[1] else -1, + ) + ) + events.sort(key=lambda event: event[0]) + intervals = [] + winding = 0 + interval_start = None + index = 0 + while index < len(events): + x = events[index][0] + delta = 0 + while index < len(events) and abs(events[index][0] - x) <= 1e-8: + delta += events[index][1] + index += 1 + was_inside = winding != 0 + winding += delta + is_inside = winding != 0 + if not was_inside and is_inside: + interval_start = x + elif was_inside and not is_inside and interval_start is not None: + intervals.append((interval_start, x)) + interval_start = None + return tuple(intervals) + + +def scanline_fill_bands(vertices, step=2.0): vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices) if len(vertices) < 3: return () minimum_y = min(vertex[1] for vertex in vertices) maximum_y = max(vertex[1] for vertex in vertices) band_bottom = minimum_y - triangles = [] - edges = tuple(zip(vertices, (*vertices[1:], vertices[0]))) + bands = [] while band_bottom < maximum_y: band_top = min(band_bottom + step, maximum_y) sample_y = (band_bottom + band_top) * 0.5 - events = [] - for start, end in edges: - if (start[1] <= sample_y < end[1]) or (end[1] <= sample_y < start[1]): - factor = (sample_y - start[1]) / (end[1] - start[1]) - events.append( - ( - start[0] + (end[0] - start[0]) * factor, - 1 if end[1] > start[1] else -1, - ) - ) - events.sort(key=lambda event: event[0]) - intervals = [] - winding = 0 - interval_start = None - index = 0 - while index < len(events): - x = events[index][0] - delta = 0 - while index < len(events) and abs(events[index][0] - x) <= 1e-8: - delta += events[index][1] - index += 1 - was_inside = winding != 0 - winding += delta - is_inside = winding != 0 - if not was_inside and is_inside: - interval_start = x - elif was_inside and not is_inside and interval_start is not None: - intervals.append((interval_start, x)) - interval_start = None + intervals = _scanline_intervals(vertices, sample_y) + bands.append((band_bottom, band_top, tuple(intervals))) + band_bottom = band_top + return tuple(bands) + + +def _merge_intervals(intervals): + merged = [] + for left, right in sorted(intervals): + if right - left <= 1e-8: + continue + if not merged or left > merged[-1][1] + 1e-8: + merged.append([left, right]) + else: + merged[-1][1] = max(merged[-1][1], right) + return tuple((left, right) for left, right in merged) + + +def scanline_union_bands(polygons, step=2.0): + """Union simple polygons into local screen-space scanline bands.""" + polygons = tuple(tuple(polygon) for polygon in polygons if len(polygon) >= 3) + if not polygons: + return () + minimum_y = math.floor(min(point[1] for polygon in polygons for point in polygon) / step) * step + maximum_y = math.ceil(max(point[1] for polygon in polygons for point in polygon) / step) * step + bounds = tuple( + ( + polygon, + min(point[1] for point in polygon), + max(point[1] for point in polygon), + ) + for polygon in polygons + ) + bands = [] + band_bottom = minimum_y + while band_bottom < maximum_y: + band_top = min(band_bottom + step, maximum_y) + sample_y = (band_bottom + band_top) * 0.5 + intervals = _merge_intervals( + interval + for polygon, bottom, top in bounds + if bottom <= sample_y < top + for interval in _scanline_intervals(polygon, sample_y) + ) + bands.append((band_bottom, band_top, intervals)) + band_bottom = band_top + return tuple(bands) + + +def _scanline_band_triangles(bands): + triangles = [] + for band_bottom, band_top, intervals in bands: for left, right in intervals: if right - left <= 1e-8: continue @@ -398,10 +450,102 @@ def scanline_fill_triangles(vertices, step=2.0): (left, band_top), ) ) - band_bottom = band_top return tuple(triangles) +def _subtract_intervals(intervals, covered): + difference = [] + for left, right in intervals: + position = left + for cover_left, cover_right in covered: + if cover_right <= position: + continue + if cover_left >= right: + break + if cover_left > position: + difference.append((position, min(cover_left, right))) + position = max(position, cover_right) + if position >= right: + break + if position < right: + difference.append((position, right)) + return tuple(difference) + + +def _closed_edge_loops(edges): + def point_key(point): + return round(point[0], 8), round(point[1], 8) + + adjacency = {} + keyed_edges = [] + points = {} + for index, (start, end) in enumerate(edges): + start_key = point_key(start) + end_key = point_key(end) + keyed_edges.append((start_key, end_key)) + points[start_key] = start + points[end_key] = end + adjacency.setdefault(start_key, []).append((index, end_key)) + adjacency.setdefault(end_key, []).append((index, start_key)) + unused = set(range(len(keyed_edges))) + loops = [] + while unused: + edge_index = next(iter(unused)) + start_key, current_key = keyed_edges[edge_index] + unused.remove(edge_index) + loop = [points[start_key], points[current_key]] + while current_key != start_key: + candidates = ( + candidate + for candidate in adjacency[current_key] + if candidate[0] in unused + ) + try: + edge_index, next_key = next(candidates) + except StopIteration: + break + unused.remove(edge_index) + current_key = next_key + loop.append(points[current_key]) + if len(loop) > 2: + loops.append(tuple(loop)) + return tuple(loops) + + +def scanline_outline_segments(bands): + """Return only the dashed outer boundary of merged scanline bands.""" + if not bands: + return () + edges = [] + for bottom, top, intervals in bands: + for left, right in intervals: + edges.extend( + ( + ((left, bottom), (left, top)), + ((right, bottom), (right, top)), + ) + ) + boundaries = [ + (bands[0][0], (), bands[0][2]), + *( + (current[0], previous[2], current[2]) + for previous, current in zip(bands, bands[1:]) + ), + (bands[-1][1], bands[-1][2], ()), + ] + for y, lower, upper in boundaries: + for left, right in ( + *_subtract_intervals(lower, upper), + *_subtract_intervals(upper, lower), + ): + edges.append(((left, y), (right, y))) + return tuple( + point + for loop in _closed_edge_loops(edges) + for point in dashed_line_segments(loop) + ) + + def _point_on_segment(point, start, end, epsilon=1e-8): return ( min(start[0], end[0]) - epsilon <= point[0] <= max(start[0], end[0]) + epsilon @@ -464,12 +608,18 @@ def polygon_self_intersects(vertices, cell_size=32.0): return False -def preview_fill_triangles(vertices): +def preview_fill_geometry(vertices): + """Return fill triangles and dashed boundary for one merged polygon.""" + vertices = tuple(vertices) + if len(vertices) < 3: + outline = (*vertices, vertices[0]) if len(vertices) > 1 else () + return (), dashed_line_segments(outline) if not polygon_self_intersects(vertices): triangles = triangulated_polygon_vertices(vertices) if triangles: - return triangles - return scanline_fill_triangles(vertices) + return triangles, dashed_line_segments((*vertices, vertices[0])) + bands = scanline_fill_bands(vertices) + return _scanline_band_triangles(bands), scanline_outline_segments(bands) def draw_polygon_fill( @@ -493,168 +643,350 @@ def draw_polygon_fill( gpu.state.blend_set("NONE") -CROP_TOOL_IDS = { - "BOX": "anyimage.crop_box", - "LASSO": "anyimage.crop_lasso", - "POLYLINE": "anyimage.crop_polyline", -} -POLYLINE_CLOSE_RADIUS = 8.0 LASSO_POINT_DISTANCE = 2.0 LASSO_SIMPLIFY_TOLERANCE = 0.75 +POLYLINE_CLOSE_RADIUS = 15.0 +POLYLINE_CLOSE_MIN_RADIUS = 1.0 +POLYLINE_DOUBLE_CLICK_DISTANCE = 5.0 +POLYLINE_DOUBLE_CLICK_INTERVAL = 0.35 -def append_lasso_point(path, point): - point = (float(point[0]), float(point[1])) - if not path: - path.append(point) - return True - if math.dist(point, path[-1]) < LASSO_POINT_DISTANCE: +def polyline_close_radius(ui_scale=1.0): + """Return the screen-space radius that completes a Polyline.""" + return POLYLINE_CLOSE_RADIUS * float(ui_scale) + + +def polyline_close_indicator_radius(distance, ui_scale=1.0): + """Return Blender's progressive Polyline start-circle radius.""" + scale = float(ui_scale) + close_radius = polyline_close_radius(scale) + hint_distance = close_radius * close_radius + distance = float(distance) + if distance >= hint_distance: + return None + factor = max(0.0, distance / hint_distance) + smooth = factor * factor * (3.0 - 2.0 * factor) + minimum_radius = POLYLINE_CLOSE_MIN_RADIUS * scale + return close_radius + (minimum_radius - close_radius) * smooth + + +def is_polyline_double_click( + previous_point, + previous_time, + point, + current_time, + *, + ui_scale=1.0, + interval=POLYLINE_DOUBLE_CLICK_INTERVAL, +): + """Return whether two raw presses form a Polyline double-click.""" + if previous_point is None or previous_time is None: return False - if len(path) >= 2: - anchor = path[-2] - previous = path[-1] - segment_x = point[0] - anchor[0] - segment_y = point[1] - anchor[1] - segment_length_squared = segment_x * segment_x + segment_y * segment_y - if segment_length_squared > 0.0: - factor = ( - (previous[0] - anchor[0]) * segment_x - + (previous[1] - anchor[1]) * segment_y - ) / segment_length_squared - projected = ( - anchor[0] + segment_x * factor, - anchor[1] + segment_y * factor, - ) - forward = (previous[0] - anchor[0]) * (point[0] - previous[0]) + ( - previous[1] - anchor[1] - ) * (point[1] - previous[1]) - if ( - 0.0 <= factor <= 1.0 - and forward >= 0.0 - and math.dist(previous, projected) <= LASSO_SIMPLIFY_TOLERANCE - ): - path[-1] = point - return True - path.append(point) - return True - - -def gesture_property(): - return bpy.props.EnumProperty( - items=( - ("BOX", "Box", "Edit with a rectangular gesture"), - ("LASSO", "Lasso", "Edit with a freehand gesture"), - ("POLYLINE", "Polyline", "Edit with a clicked polygon"), - ), - default="BOX", - options={"HIDDEN", "SKIP_SAVE"}, + elapsed = float(current_time) - float(previous_time) + return ( + 0.0 <= elapsed <= float(interval) + and math.dist(point, previous_point) + <= POLYLINE_DOUBLE_CLICK_DISTANCE * float(ui_scale) ) -def box_screen_path(start, end): +def append_lasso_point(path, point, samples): + """Append a sample with bounded error over the complete active segment.""" + point = (float(point[0]), float(point[1])) + if not path: + path.append(point) + samples[:] = [point] + return True + if point == path[-1]: + return False + previous = path[-1] + if len(path) >= 2 and 1 < len(samples) < 128: + anchor = path[-2] + dx, dy = point[0] - anchor[0], point[1] - anchor[1] + squared = dx * dx + dy * dy + if squared > 0: + def within_segment(sample): + x, y = sample[0] - anchor[0], sample[1] - anchor[1] + factor = (x * dx + y * dy) / squared + return (0 <= factor <= 1 and + abs(x * dy - y * dx) <= LASSO_SIMPLIFY_TOLERANCE * math.sqrt(squared)) + if all(within_segment(sample) for sample in samples): + path[-1] = point + samples.append(point) + return True + path.append(point) + samples[:] = [previous, point] + return True + + +def rectangle_screen_path(start, end): x0, y0 = start x1, y1 = end if abs(x1 - x0) < 2.0 or abs(y1 - y0) < 2.0: return () - return ((x0, y0), (x1, y0), (x1, y1), (x0, y1)) + left, right = sorted((float(x0), float(x1))) + bottom, top = sorted((float(y0), float(y1))) + return ((left, top), (right, top), (right, bottom), (left, bottom)) -class CropSelectionGesture: +def circle_vertices(center, radius, segments=32): + """Return a closed screen-space circle without repeating its first point.""" + center_x, center_y = (float(value) for value in center) + radius = float(radius) + if radius <= 0.0 or segments < 3: + raise ValueError("The brush circle is invalid") + return tuple( + ( + center_x + math.cos(index * math.tau / segments) * radius, + center_y + math.sin(index * math.tau / segments) * radius, + ) + for index in range(segments) + ) + + +def draw_circle_outline( + center, + radius, + color=(1.0, 1.0, 1.0, 0.95), + line_width=1.0, +): + import gpu + from gpu_extras.batch import batch_for_shader + + vertices = circle_vertices(center, radius) + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + batch = batch_for_shader( + shader, + "LINE_STRIP", + {"pos": (*vertices, vertices[0])}, + ) + gpu.state.blend_set("ALPHA") + gpu.state.line_width_set(line_width) + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + gpu.state.line_width_set(1.0) + gpu.state.blend_set("NONE") + + +def brush_footprint_polygons(path, radius): + """Return circles and connecting strips defining one Brush footprint.""" + points = tuple(path) + if not points: + return () + polygons = [circle_vertices(points[0], radius)] + for start, end in zip(points, points[1:]): + delta_x = end[0] - start[0] + delta_y = end[1] - start[1] + length = math.hypot(delta_x, delta_y) + if length <= 1e-8: + continue + normal_x = -delta_y * radius / length + normal_y = delta_x * radius / length + polygons.append( + ( + (start[0] + normal_x, start[1] + normal_y), + (end[0] + normal_x, end[1] + normal_y), + (end[0] - normal_x, end[1] - normal_y), + (start[0] - normal_x, start[1] - normal_y), + ) + ) + polygons.append(circle_vertices(end, radius)) + return tuple(polygons) + + +def draw_polyline_close_indicator(center, radius, ui_scale=1.0): + draw_circle_outline(center, radius, (1.0, 1.0, 1.0, 0.8)) + draw_circle_outline( + center, + radius + float(ui_scale), + (0.4, 0.4, 0.4, 0.8), + ) + + +class BrushPreview: + """Cache fixed screen rows for a sealed stroke prefix and a movable tail.""" + + rows_per_chunk = 32 + step = 2.0 + + def __init__(self): + self.prefix = {} + self.tail = {} + self.coverage = {} + self.geometry = {} + self.batches = {} + self.shader = None + self.count = 0 + self.key = None + + def _polygon_rows(self, polygons): + return { + round(bottom / self.step): intervals + for bottom, _top, intervals in scanline_union_bands(polygons, self.step) + if intervals + } + + def update(self, path, radius): + key = (len(path), tuple(path[-2:]), radius) + if key == self.key: + return + self.key = key + changed = set(self.tail) + target = max(1, len(path) - 1) + for index in range(self.count, target): + polygons = (circle_vertices(path[0], radius),) if index == 0 else ( + brush_footprint_polygons(path[index - 1:index + 1], radius)[1:] + ) + for row, intervals in self._polygon_rows(polygons).items(): + self.prefix[row] = _merge_intervals((*self.prefix.get(row, ()), *intervals)) + changed.add(row) + self.count = target + self.tail = self._polygon_rows( + brush_footprint_polygons(path[-2:], radius)[1:] + ) if len(path) > 1 else {} + changed.update(self.tail) + chunks = set() + for row in changed: + intervals = _merge_intervals((*self.prefix.get(row, ()), *self.tail.get(row, ()))) + if intervals == self.coverage.get(row, ()): + continue + if intervals: + self.coverage[row] = intervals + else: + self.coverage.pop(row, None) + chunks.update((row // self.rows_per_chunk, (row + 1) // self.rows_per_chunk)) + for chunk in chunks: + self.geometry[chunk] = self._chunk_geometry(chunk) + self.batches.pop(chunk, None) + + def _chunk_geometry(self, chunk): + bands, outline = [], [] + for row in range(chunk * self.rows_per_chunk, (chunk + 1) * self.rows_per_chunk): + bottom = row * self.step + intervals = self.coverage.get(row, ()) + lower = self.coverage.get(row - 1, ()) + bands.append((bottom, bottom + self.step, intervals)) + for left, right in intervals: + for x in (left, right): + outline.extend(dashed_line_segments( + ((x, bottom), (x, bottom + self.step)), offset=bottom + )) + for left, right in (*_subtract_intervals(intervals, lower), + *_subtract_intervals(lower, intervals)): + outline.extend(dashed_line_segments( + ((left, bottom), (right, bottom)), offset=left + )) + return _scanline_band_triangles(bands), tuple(outline) + + def draw(self, color): + import gpu + from gpu_extras.batch import batch_for_shader + + if self.shader is None: + self.shader = gpu.shader.from_builtin("UNIFORM_COLOR") + shader = self.shader + gpu.state.blend_set("ALPHA") + gpu.state.line_width_set(1.0) + try: + shader.bind() + for chunk, (triangles, outline) in self.geometry.items(): + if chunk not in self.batches: + self.batches[chunk] = tuple( + batch_for_shader(shader, kind, {"pos": vertices}) if vertices else None + for kind, vertices in (("TRIS", triangles), ("LINES", outline)) + ) + fill, line = self.batches[chunk] + if fill is not None: + shader.uniform_float("color", color) + fill.draw(shader) + if line is not None: + shader.uniform_float("color", (1.0, 1.0, 1.0, 0.9)) + line.draw(shader) + finally: + gpu.state.blend_set("NONE") + + +class ImageGesture: active_tool_id = None + resolve_on_press = False _path = None _cursor = None _handle = None - _fill_polygon = None + _preview_polygon = None _fill_triangles = () - _outline_polygon = None + _brush_preview = None _outline_segments = () _area_pointer = 0 _region_pointer = 0 - _region_size = (0, 0) - - def draw(self, _context): - draw_crop_operator_settings(self.layout, self) + _path_samples = None + _ui_scale = 1.0 @classmethod def poll(cls, context): return image_edit_poll(context) - def _screen_path(self): - if self.gesture == "BOX": - return box_screen_path(self._path[0], self._cursor) - if self.gesture == "LASSO": - return tuple(self._path) - vertices = list(self._path) - if self._cursor is not None: - vertices.append(self._cursor) - return tuple(vertices) - def _draw_selection(self): if not self._path or not drawing_in_region( self._area_pointer, self._region_pointer ): return - polygon = self._screen_path() - if polygon != self._outline_polygon: - self._outline_polygon = polygon - outline = (*polygon, polygon[0]) if len(polygon) > 1 else () - self._outline_segments = dashed_line_segments(outline) - if len(polygon) >= 3: - if polygon != self._fill_polygon: - self._fill_polygon = polygon - self._fill_triangles = preview_fill_triangles(polygon) - draw_polygon_fill( - polygon, - self._fill_triangles, - (1.0, 0.25, 0.2, 0.16) if self.invert else (1.0, 1.0, 1.0, 0.16), - ) - if len(polygon) > 1: + color = ((1.0, 0.25, 0.2, 0.16) if getattr(self, "mode", "SET") == "SUBTRACT" + else (1.0, 1.0, 1.0, 0.16)) + if self.gesture == "BRUSH": + if self._brush_preview is None: + self._brush_preview = BrushPreview() + self._brush_preview.update(self._path, self.radius) + self._brush_preview.draw(color) + return + polygon = ImageGesture._screen_path(self) + if polygon != self._preview_polygon: + self._preview_polygon = polygon + self._fill_triangles, self._outline_segments = preview_fill_geometry(polygon) + if self._fill_triangles: + draw_polygon_fill(polygon, self._fill_triangles, color) + if self._outline_segments: draw_dashed_line((), segments=self._outline_segments) if ( self.gesture == "POLYLINE" and len(self._path) >= 3 and self._cursor is not None - and math.dist(self._cursor, self._path[0]) <= POLYLINE_CLOSE_RADIUS ): - self._draw_polyline_close_indicator() - - def _draw_polyline_close_indicator(self): - import gpu - from gpu_extras.batch import batch_for_shader - - center_x, center_y = self._path[0] - vertices = tuple( - ( - center_x + math.cos(index * math.tau / 24.0) * POLYLINE_CLOSE_RADIUS, - center_y + math.sin(index * math.tau / 24.0) * POLYLINE_CLOSE_RADIUS, + ui_scale = getattr(self, "_ui_scale", 1.0) + radius = polyline_close_indicator_radius( + math.dist(self._cursor, self._path[0]), + ui_scale, ) - for index in range(25) - ) - shader = gpu.shader.from_builtin("UNIFORM_COLOR") - batch = batch_for_shader(shader, "LINE_STRIP", {"pos": vertices}) - gpu.state.blend_set("ALPHA") - gpu.state.line_width_set(2.0) - shader.bind() - shader.uniform_float("color", (1.0, 1.0, 1.0, 0.95)) - batch.draw(shader) - gpu.state.line_width_set(1.0) - gpu.state.blend_set("NONE") + if radius is not None: + draw_polyline_close_indicator( + self._path[0], + radius, + ui_scale, + ) + + def _screen_path(self): + if self.gesture != "POLYLINE" or self._cursor is None: + return tuple(self._path) + if self._cursor == self._path[-1]: + return tuple(self._path) + return (*self._path, self._cursor) def _set_status(self, context): - invert = "On" if self.invert else "Off" - if self.gesture == "POLYLINE": - action = "click points; click start or Enter to finish; Backspace removes" - else: - action = "drag and release to edit" context.workspace.status_text_set( - f"Crop {self.gesture.title()}: {action}; Invert {invert}; " - "RMB or Esc cancels" + f"{self.gesture.title()}: drag and release; RMB or Esc cancels" ) def _finish(self, context): if self._handle is not None: bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW") self._handle = None + self._path = None + self._path_samples = None + self._cursor = None + self._preview_polygon = None + self._fill_triangles = () + self._outline_segments = () + self._brush_preview = None context.workspace.status_text_set(None) if context.area is not None: context.area.tag_redraw() @@ -665,30 +997,54 @@ class CropSelectionGesture: def _submit_selection_path(self, _context, _selection_path): raise NotImplementedError + def _complete_brush(self, _context, _source_object, _matrix): + raise NotImplementedError + + def _read_gesture_settings(self, _context): + pass + def invoke(self, context, event): - source_object, should_edit = resolve_image_edit_click( - context, event, self.report - ) - if not should_edit: - return {"FINISHED"} if source_object is not None else {"CANCELLED"} + self._read_gesture_settings(context) + if self.resolve_on_press: + source_object, should_edit = resolve_image_edit_click( + context, event, self.report + ) + if not should_edit: + return {"FINISHED"} if source_object is not None else {"CANCELLED"} + else: + try: + source_object = active_image_edit_source(context) + except ImageEditWarning as error: + report_image_edit_exception(self, error) + return {"CANCELLED"} if source_object is None: return {"CANCELLED"} if video_total_frames(source_object.data) > 1: - self.report({"WARNING"}, "Crop tools only support a still image") + self.report({"WARNING"}, "Image gestures only support a still image") return {"CANCELLED"} - apply_crop_settings(self, context) self.source_object_name = source_object.name self.source_matrix_data = serialize_matrix(source_object.matrix_world) point = (float(event.mouse_region_x), float(event.mouse_region_y)) self._path = [point] self._cursor = point - self._fill_polygon = None + self._preview_polygon = None self._fill_triangles = () - self._outline_polygon = None + self._brush_preview = None self._outline_segments = () self._area_pointer = context.area.as_pointer() self._region_pointer = context.region.as_pointer() - self._region_size = (context.region.width, context.region.height) + self._path_samples = [point] + preferences = getattr(context, "preferences", None) + system = getattr(preferences, "system", None) + self._ui_scale = float(getattr(system, "ui_scale", 1.0) or 1.0) + inputs = getattr(preferences, "inputs", None) + double_click_time = float( + getattr(inputs, "double_click_time", POLYLINE_DOUBLE_CLICK_INTERVAL * 1000) + or POLYLINE_DOUBLE_CLICK_INTERVAL * 1000 + ) + self._polyline_double_click_interval = double_click_time / 1000.0 + self._last_polyline_click_point = point + self._last_polyline_click_time = time.monotonic() self._handle = bpy.types.SpaceView3D.draw_handler_add( self._draw_selection, (), "WINDOW", "POST_PIXEL" ) @@ -700,7 +1056,11 @@ class CropSelectionGesture: def _complete(self, context): source_object = require_image_empty(self.source_object_name) matrix = deserialize_matrix(self.source_matrix_data) - path = self._screen_path() + if self.gesture == "BRUSH": + result = self._complete_brush(context, source_object, matrix) + self._finish(context) + return result + path = tuple(self._path) if len(path) < 3: self._finish(context) return {"CANCELLED"} @@ -714,54 +1074,36 @@ class CropSelectionGesture: ) selection_path = SelectionPath( points=selection_path, - invert=self.invert, ) self._finish(context) return self._submit_selection_path(context, selection_path) - def modal(self, context, event): - active_tool = active_view3d_tool_id(context) - expected_tool = self.active_tool_id or CROP_TOOL_IDS[self.gesture] - if active_tool is not None and active_tool != expected_tool: - self._finish(context) - return {"CANCELLED"} - if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS": - self._finish(context) - return {"CANCELLED"} - if self.gesture == "POLYLINE": - return self._modal_polyline(context, event) - if event.type == "MOUSEMOVE": - point = (float(event.mouse_region_x), float(event.mouse_region_y)) - if self.gesture == "LASSO": - append_lasso_point(self._path, point) - self._cursor = point - else: - self._cursor = point - context.area.tag_redraw() + def _cancel_and_finish(self, context): + self._finish(context) + return {"CANCELLED"} + + def _confirm_polyline(self, context): + if len(self._path) < 3: return {"RUNNING_MODAL"} - if event.type == "LEFTMOUSE" and event.value == "RELEASE": - point = (float(event.mouse_region_x), float(event.mouse_region_y)) - if self.gesture == "LASSO": - append_lasso_point(self._path, point) - self._cursor = point - else: - self._cursor = point - try: - return self._complete(context) - except (RuntimeError, TypeError, ValueError) as error: - report_image_edit_exception(self, error) - self._finish(context) - return {"CANCELLED"} - return {"PASS_THROUGH"} + try: + return self._complete(context) + except (RuntimeError, TypeError, ValueError) as error: + report_image_edit_exception(self, error) + return self._cancel_and_finish(context) def _modal_polyline(self, context, event): if event.type == "BACK_SPACE" and event.value == "PRESS": if len(self._path) > 1: self._path.pop() + self._cursor = self._path[-1] + self._last_polyline_click_point = None + self._last_polyline_click_time = None context.area.tag_redraw() return {"RUNNING_MODAL"} if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS": return self._confirm_polyline(context) + if event.type == "LEFTMOUSE" and event.value == "DOUBLE_CLICK": + return self._confirm_polyline(context) if event.type == "MOUSEMOVE": self._cursor = ( float(event.mouse_region_x), @@ -773,20 +1115,56 @@ class CropSelectionGesture: point = (float(event.mouse_region_x), float(event.mouse_region_y)) if ( len(self._path) >= 3 - and math.dist(point, self._path[0]) <= POLYLINE_CLOSE_RADIUS + and math.dist(point, self._path[0]) + <= polyline_close_radius(getattr(self, "_ui_scale", 1.0)) ): return self._confirm_polyline(context) - if math.dist(point, self._path[-1]) >= 2.0: + current_time = time.monotonic() + if len(self._path) >= 3 and is_polyline_double_click( + getattr(self, "_last_polyline_click_point", None), + getattr(self, "_last_polyline_click_time", None), + point, + current_time, + ui_scale=getattr(self, "_ui_scale", 1.0), + interval=getattr( + self, + "_polyline_double_click_interval", + POLYLINE_DOUBLE_CLICK_INTERVAL, + ), + ): + return self._confirm_polyline(context) + if math.dist(point, self._path[-1]) >= LASSO_POINT_DISTANCE: self._path.append(point) + self._last_polyline_click_point = point + self._last_polyline_click_time = current_time + self._cursor = point context.area.tag_redraw() return {"RUNNING_MODAL"} return {"PASS_THROUGH"} - def _confirm_polyline(self, context): - self._cursor = self._path[0] - try: - return self._complete(context) - except (RuntimeError, TypeError, ValueError) as error: - report_image_edit_exception(self, error) - self._finish(context) - return {"CANCELLED"} + def modal(self, context, event): + active_tool = active_view3d_tool_id(context) + expected_tool = self.active_tool_id + if active_tool is not None and active_tool != expected_tool: + return self._cancel_and_finish(context) + if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS": + return self._cancel_and_finish(context) + if self.gesture == "POLYLINE": + return self._modal_polyline(context, event) + if event.type == "MOUSEMOVE": + point = (float(event.mouse_region_x), float(event.mouse_region_y)) + if not append_lasso_point(self._path, point, self._path_samples): + return {"RUNNING_MODAL"} + self._cursor = point + context.area.tag_redraw() + return {"RUNNING_MODAL"} + if event.type == "LEFTMOUSE" and event.value == "RELEASE": + point = (float(event.mouse_region_x), float(event.mouse_region_y)) + append_lasso_point(self._path, point, self._path_samples) + self._cursor = point + try: + return self._complete(context) + except (RuntimeError, TypeError, ValueError) as error: + report_image_edit_exception(self, error) + return self._cancel_and_finish(context) + return {"PASS_THROUGH"} diff --git a/src/anyimage/operators/__init__.py b/src/anyimage/operators/__init__.py index 5e05035..a0acac2 100644 --- a/src/anyimage/operators/__init__.py +++ b/src/anyimage/operators/__init__.py @@ -4,7 +4,7 @@ from .cutout_tool import ( SelectCutoutSelection, ) from .debug import DebugBen2, DebugDa3, DebugMoge2, DebugUpscale -from .crop_tool import CLASSES as CROP_TOOL_CLASSES +from .image_edit_tool import CLASSES as IMAGE_EDIT_TOOL_CLASSES from .convert_to_plane import ( ConvertToDepthPlane, ConvertToPlane, @@ -34,7 +34,7 @@ CLASSES = ( SelectCutoutSelection, RunBackgroundRemoval, ClearModels, - *CROP_TOOL_CLASSES, + *IMAGE_EDIT_TOOL_CLASSES, RemoveImageBackground, UpscaleImage, ConvertToPlane, diff --git a/src/anyimage/operators/clipboard_image/actions.py b/src/anyimage/operators/clipboard_image/actions.py index f2adee8..a4e1371 100644 --- a/src/anyimage/operators/clipboard_image/actions.py +++ b/src/anyimage/operators/clipboard_image/actions.py @@ -173,6 +173,7 @@ def add_reference_image(context, image, location=None): reference = bpy.data.objects.new(image.name, None) reference.empty_display_type = "IMAGE" + reference.use_empty_image_alpha = True reference.data = image reference.empty_display_size = REFERENCE_IMAGE_SIZE context.collection.objects.link(reference) diff --git a/src/anyimage/operators/convert_to_plane/object.py b/src/anyimage/operators/convert_to_plane/object.py index b85ba2f..91d56e7 100644 --- a/src/anyimage/operators/convert_to_plane/object.py +++ b/src/anyimage/operators/convert_to_plane/object.py @@ -4,6 +4,7 @@ import bpy from ...common.image import ( color_data_name, + configure_color_alpha, image_empty_bounds, image_pixels, load_color_result_image, @@ -42,6 +43,7 @@ def create_color_image(source_object): image.pixels.foreach_set(pixels) image.update() image.name = color_data_name(source_object) + configure_color_alpha(image) return image diff --git a/src/anyimage/operators/crop_tool/__init__.py b/src/anyimage/operators/crop_tool/__init__.py deleted file mode 100644 index de82cbe..0000000 --- a/src/anyimage/operators/crop_tool/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from .box import CropBoxTool -from .lasso import CropLassoTool -from .perspective import CropImagePerspective, CropPerspectiveTool -from .polyline import CropPolylineTool -from .selection import ActivateCropTool, CropImageSelection, RefineCropSelection - - -CLASSES = ( - CropImageSelection, - RefineCropSelection, - ActivateCropTool, - CropImagePerspective, -) - -__all__ = ( - "CLASSES", - "CropBoxTool", - "CropLassoTool", - "CropPerspectiveTool", - "CropPolylineTool", -) diff --git a/src/anyimage/operators/crop_tool/box.py b/src/anyimage/operators/crop_tool/box.py deleted file mode 100644 index 0eb1183..0000000 --- a/src/anyimage/operators/crop_tool/box.py +++ /dev/null @@ -1,30 +0,0 @@ -import bpy - -from ...common.viewport import draw_crop_settings -from .selection import CropImageSelection - - -class CropBoxTool(bpy.types.WorkSpaceTool): - bl_space_type = "VIEW_3D" - bl_context_mode = "OBJECT" - bl_idname = "anyimage.crop_box" - bl_label = "Crop Box" - bl_description = "Crop an Image Empty with a rectangular selection" - bl_icon = "ops.sculpt.border_mask" - bl_operator = CropImageSelection.bl_idname - bl_keymap = ( - ( - CropImageSelection.bl_idname, - {"type": "LEFTMOUSE", "value": "PRESS"}, - {"properties": [("gesture", "BOX")]}, - ), - ) - - def draw_settings(context, layout, _tool): - draw_crop_settings( - context, - layout, - refine_selection="crop_box_refine_selection", - invert="crop_box_invert", - keep_original="crop_box_keep_original", - ) diff --git a/src/anyimage/operators/crop_tool/lasso.py b/src/anyimage/operators/crop_tool/lasso.py deleted file mode 100644 index afbe94d..0000000 --- a/src/anyimage/operators/crop_tool/lasso.py +++ /dev/null @@ -1,30 +0,0 @@ -import bpy - -from ...common.viewport import draw_crop_settings -from .selection import CropImageSelection - - -class CropLassoTool(bpy.types.WorkSpaceTool): - bl_space_type = "VIEW_3D" - bl_context_mode = "OBJECT" - bl_idname = "anyimage.crop_lasso" - bl_label = "Crop Lasso" - bl_description = "Crop an Image Empty with a freehand selection" - bl_icon = "ops.sculpt.lasso_mask" - bl_operator = CropImageSelection.bl_idname - bl_keymap = ( - ( - CropImageSelection.bl_idname, - {"type": "LEFTMOUSE", "value": "PRESS"}, - {"properties": [("gesture", "LASSO")]}, - ), - ) - - def draw_settings(context, layout, _tool): - draw_crop_settings( - context, - layout, - refine_selection="crop_lasso_refine_selection", - invert="crop_lasso_invert", - keep_original="crop_lasso_keep_original", - ) diff --git a/src/anyimage/operators/crop_tool/polyline.py b/src/anyimage/operators/crop_tool/polyline.py deleted file mode 100644 index f6e183b..0000000 --- a/src/anyimage/operators/crop_tool/polyline.py +++ /dev/null @@ -1,29 +0,0 @@ -import bpy - -from ...common.viewport import draw_crop_settings -from .selection import CropImageSelection - - -class CropPolylineTool(bpy.types.WorkSpaceTool): - bl_space_type = "VIEW_3D" - bl_context_mode = "OBJECT" - bl_idname = "anyimage.crop_polyline" - bl_label = "Crop Polyline" - bl_description = "Crop an Image Empty with a clicked polygon selection" - bl_icon = "ops.sculpt.polyline_mask" - bl_operator = CropImageSelection.bl_idname - bl_keymap = ( - ( - CropImageSelection.bl_idname, - {"type": "LEFTMOUSE", "value": "PRESS"}, - {"properties": [("gesture", "POLYLINE")]}, - ), - ) - - def draw_settings(context, layout, _tool): - draw_crop_settings( - context, - layout, - invert="crop_polyline_invert", - keep_original="crop_polyline_keep_original", - ) diff --git a/src/anyimage/operators/crop_tool/selection.py b/src/anyimage/operators/crop_tool/selection.py deleted file mode 100644 index 5966372..0000000 --- a/src/anyimage/operators/crop_tool/selection.py +++ /dev/null @@ -1,275 +0,0 @@ -import json - -import bpy - -from ...properties import BEN2_MODEL_KEY, refine_selection_property -from ...runtime import JobOperatorBase -from ...common.ai import ( - production_device, - require_environment, - require_model, -) -from ...common.image import ( - cleanup_image_input, - create_image_edit_result, - image_rgba, - load_packed_rgba_image, - place_empty_image_result, - prepare_image_bounds_input, - require_image_empty, - video_total_frames, -) -from ...common.viewport import ( - CropSelectionGesture, - gesture_property, - image_edit_poll, - report_image_edit_exception, -) -from ...common.selection import ( - ImageEditWarning, - cleanup_selection_mask, - load_selection_mask, - rasterize_selection_path, - save_selection_mask, -) - -_HIDDEN = {"HIDDEN", "SKIP_SAVE"} - - -def _edit_image_mask_pixels( - source_rgba, - selection_mask, -): - """Apply a mask while preserving source RGB, then tightly crop the canvas.""" - import numpy as np - - left, top, right, bottom = selection_mask.bounds - rgba = np.asarray(source_rgba, dtype=np.float32)[top:bottom, left:right].copy() - if rgba.shape[:2] != selection_mask.values.shape: - raise ValueError("The Selection does not match the source RGBA") - rgba[:, :, 3] *= selection_mask.values - visible = rgba[:, :, 3] > 0.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 ImageEditWarning("The edit contains no visible image pixels") - content_left = int(visible_x[0]) - content_top = int(visible_y[0]) - content_right = int(visible_x[-1]) + 1 - content_bottom = int(visible_y[-1]) + 1 - output = rgba[content_top:content_bottom, content_left:content_right] - return np.flipud(output).ravel(), ( - left + content_left, - top + content_top, - left + content_right, - top + content_bottom, - ) - - -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, - source_rgba, - selection_mask, - *, - keep_original=False, -): - image = source_object.data - pixels, crop_bounds = _edit_image_mask_pixels( - source_rgba, - selection_mask, - ) - left, top, right, bottom = crop_bounds - result_image = create_image_edit_result( - image, - pixels, - (right - left, bottom - top), - ) - return place_empty_image_result( - context, - source_object, - result_image, - placement_bounds=crop_bounds, - keep_original=keep_original, - ) - - -class CropImageSelection(CropSelectionGesture, bpy.types.Operator): - refine_selection_settings = { - "BOX": "crop_box_refine_selection", - "LASSO": "crop_lasso_refine_selection", - } - invert_settings = { - "BOX": "crop_box_invert", - "LASSO": "crop_lasso_invert", - "POLYLINE": "crop_polyline_invert", - } - keep_original_settings = { - "BOX": "crop_box_keep_original", - "LASSO": "crop_lasso_keep_original", - "POLYLINE": "crop_polyline_keep_original", - } - bl_idname = "anyimage.crop_image_selection" - bl_label = "Crop Image Selection" - bl_description = "Crop an Image Empty with a selection" - bl_options = {"REGISTER", "UNDO"} - - source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) - source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) - refine_selection: refine_selection_property() - invert: bpy.props.BoolProperty( - name="Invert", - description="Keep the opposite side of the refined gesture mask", - default=False, - options=_HIDDEN, - ) - keep_original: bpy.props.BoolProperty(default=False, options=_HIDDEN) - gesture: gesture_property() - - def _submit_selection_path(self, context, selection_path): - keep_original = bool(getattr(self, "keep_original", False)) - try: - source_object = require_image_empty(self.source_object_name) - except RuntimeError as error: - self.report({"ERROR"}, str(error)) - return {"CANCELLED"} - try: - if video_total_frames(source_object.data) > 1: - raise RuntimeError("Crop tools only support a still image") - selection_mask = rasterize_selection_path( - tuple(source_object.data.size), - selection_path, - ) - source_rgba = image_rgba(source_object.data) - if self.refine_selection: - source_path = prepare_image_bounds_input( - source_object.data, - 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"} - _edit_image_object( - context, - source_object, - source_rgba, - selection_mask, - keep_original=keep_original, - ) - except (RuntimeError, TypeError, ValueError) as error: - report_image_edit_exception(self, error) - return {"CANCELLED"} - return {"FINISHED"} - - -class RefineCropSelection(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.refine_crop_selection" - bl_label = "Refine Crop Selection" - job_type = "refine-image-selection" - 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) - - def request(self, _context): - require_environment() - require_model(BEN2_MODEL_KEY) - return { - "input": str(self.input_path), - "model": BEN2_MODEL_KEY, - "device": production_device().lower(), - } - - def response(self, context, result): - source_object = require_image_empty(self.source_object_name) - - bounds = tuple(json.loads(self.selection_bounds_json)) - selection_mask = load_selection_mask(self.selection_mask_path, bounds) - source_image = source_object.data - encoded_image = load_packed_rgba_image( - result.file("selection_image"), - 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, - refined_image, - placement_bounds=bounds, - keep_original=bool(getattr(self, "keep_original", False)), - ) - except Exception: - 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) - - -class ActivateCropTool(bpy.types.Operator): - bl_idname = "anyimage.activate_crop_tool" - bl_label = "Crop Tool" - bl_description = "Activate Crop Box in the 3D Viewport" - bl_options = {"INTERNAL"} - - @classmethod - def poll(cls, context): - return image_edit_poll(context) - - def execute(self, _context): - return bpy.ops.wm.tool_set_by_id( - name="anyimage.crop_box", space_type="VIEW_3D" - ) diff --git a/src/anyimage/operators/cutout_tool/geometry.py b/src/anyimage/operators/cutout_tool/geometry.py index 0952f52..c18f89b 100644 --- a/src/anyimage/operators/cutout_tool/geometry.py +++ b/src/anyimage/operators/cutout_tool/geometry.py @@ -1213,7 +1213,7 @@ def depth_balloon_projection_parameters( ) if not usable.any(): raise ValueError("MoGe-2 found no valid Depth Balloon samples") - local_coordinates = vertices[:, 1:] + local_coordinates = vertices[:, :2] metric_depth = sampled[usable] * float(uniform_scale) plane = _stable_plane(local_coordinates[usable], metric_depth) reference = metric_depth_reference( @@ -1221,7 +1221,7 @@ def depth_balloon_projection_parameters( calibration_valid, uniform_scale, ) - direction = np.asarray((-plane[0], plane[1], 1.0), dtype=np.float64) + direction = np.asarray((plane[0], plane[1], 1.0), dtype=np.float64) direction /= np.linalg.norm(direction) return reference, tuple(float(value) for value in direction) @@ -1238,19 +1238,6 @@ class BaseShape: balloon: object -def orient_cutout_vertices(vertices): - import numpy as np - - vertices = np.asarray(vertices, dtype=np.float64) - return np.column_stack( - ( - -vertices[:, 2], - -vertices[:, 0], - vertices[:, 1], - ) - ) - - def cutout_domain( selection_contours, bounds, @@ -1433,7 +1420,7 @@ def build_base_shape( ) source_vertices, faces, uv, _boundary, planar, balloon = domain return BaseShape( - vertices=orient_cutout_vertices(source_vertices), + vertices=np.asarray(source_vertices, dtype=np.float64), faces=counter_clockwise_faces(planar, faces), uv=np.asarray(uv, dtype=np.float32), balloon=np.asarray(balloon, dtype=np.float64) / world_z_scale, diff --git a/src/anyimage/operators/cutout_tool/interaction.py b/src/anyimage/operators/cutout_tool/interaction.py index bb33454..4c16e1c 100644 --- a/src/anyimage/operators/cutout_tool/interaction.py +++ b/src/anyimage/operators/cutout_tool/interaction.py @@ -29,7 +29,6 @@ def _pie_button( icon, source_name, selection_path_json, - refine_selection, generate_normal, point_spacing, inward_axis, @@ -42,7 +41,6 @@ def _pie_button( operator.source_object_name = source_name operator.selection_path_json = selection_path_json operator.shape = shape - operator.refine_selection = refine_selection operator.generate_normal = generate_normal operator.point_spacing = point_spacing operator.inward_axis = inward_axis @@ -53,14 +51,12 @@ def open_shape_pie( event, source_object, selection_path, - refine_selection=False, ): source_name = source_object.name selection_path_json = selection_path.to_json() settings = context.scene.anyimage_settings point_spacing = cutout_mesh_detail_spacing(settings.cutout_mesh_detail) ready = ai_ready() - refine_selection = ready and refine_selection generate_normal = ready and bool( getattr(settings, "cutout_generate_normal", False) ) @@ -77,7 +73,6 @@ def open_shape_pie( icon, source_name, selection_path_json, - refine_selection, generate_normal, point_spacing, inward_axis, diff --git a/src/anyimage/operators/cutout_tool/main.py b/src/anyimage/operators/cutout_tool/main.py index acd06e8..09fd20d 100644 --- a/src/anyimage/operators/cutout_tool/main.py +++ b/src/anyimage/operators/cutout_tool/main.py @@ -9,11 +9,7 @@ from ...preferences import ( configured_max_ai_input_size, configured_max_cutout_mesh_resolution, ) -from ...properties import ( - BEN2_MODEL_KEY, - DEFAULT_MOGE2_MODEL_KEY, - refine_selection_property, -) +from ...properties import DEFAULT_MOGE2_MODEL_KEY from ...runtime import JobOperatorBase from ...server.media.resolution import ( DEFAULT_MAX_AI_INPUT_SIZE, @@ -43,9 +39,8 @@ from ...common.image import ( ) from ...common.depth import load_depth_metadata, load_depth_result_image from ...common.viewport import ( - CropSelectionGesture, - apply_crop_settings, - gesture_property, + ImageGesture, + image_edit_drag_keymap, report_image_edit_exception, resolve_image_edit_click, serialize_matrix, @@ -158,14 +153,9 @@ def _cutout_content_values(source_alpha, selection_values=None): return values -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 - else: - path = result.file("selection_image") +def _generated_color_result(input_path, source_object, source_bounds): image = load_packed_rgba_image( - path, + input_path, source_object.data, color_data_name(source_object), ) @@ -222,18 +212,13 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): options=_HIDDEN, ) generate_normal: bpy.props.BoolProperty(default=False, options=_HIDDEN) - refine_selection: bpy.props.BoolProperty(default=False, options=_HIDDEN) inward_axis: bpy.props.StringProperty( default=CutoutInwardAxis.NEGATIVE_Z.value, options=_HIDDEN, ) def _needs_generation(self): - return ( - self.refine_selection - or self.shape in DEPTH_CUTOUT_SHAPES - or self.generate_normal - ) + return self.shape in DEPTH_CUTOUT_SHAPES or self.generate_normal def execute(self, context): try: @@ -321,20 +306,15 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): source_path = require_input_path(self.input_path) depth_mode = depth_mode_for_shape(self.shape) normal_mode = normal_mode_for_shape(self.shape, self.generate_normal) - if self.refine_selection: - require_model(BEN2_MODEL_KEY) if depth_mode != "NONE" or normal_mode != NormalMode.NONE: require_model(self.model) parameters = { "input": str(source_path), "device": production_device().lower(), - "refine_selection": self.refine_selection, "depth_mode": depth_mode, "normal_mode": normal_mode.value, "max_input_size": int(self.max_ai_input_size), } - if self.refine_selection: - parameters["refine_model"] = BEN2_MODEL_KEY if depth_mode != "NONE" or normal_mode != NormalMode.NONE: start_frame = source_start_frame(self.source_object_name) parameters.update( @@ -361,7 +341,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): source_object = require_image_empty(self.source_object_name) source_bounds = tuple(json.loads(self.selection_bounds_json)) color_image, source_alpha, color_bounds = _generated_color_result( - result, self.input_path, source_object, source_bounds, @@ -426,19 +405,16 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator): cleanup_image_input(self.input_path, True) -class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator): +class SelectCutoutSelection(ImageGesture, bpy.types.Operator): bl_idname = "anyimage.select_cutout_selection" bl_label = "Start Cutout Selection" bl_description = "Lasso an Image Empty selection, then choose its mesh shape" bl_options = {"INTERNAL"} active_tool_id = "anyimage.cutout_lasso" - refine_selection_settings = {"LASSO": "cutout_refine_selection"} + gesture = "LASSO" source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) - refine_selection: refine_selection_property() - invert: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"}) - gesture: gesture_property() full_image: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"}) _pie_event = None @@ -462,8 +438,6 @@ class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator): if video_total_frames(source_object.data) > 1: self.report({"WARNING"}, "Cutout only supports a still image") return {"CANCELLED"} - apply_crop_settings(self, context) - self.invert = False self.source_object_name = source_object.name self.source_matrix_data = serialize_matrix(source_object.matrix_world) width, height = (int(value) for value in source_object.data.size) @@ -474,7 +448,7 @@ class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator): def modal(self, context, event): self._pie_event = event - return CropSelectionGesture.modal(self, context, event) + return ImageGesture.modal(self, context, event) def _submit_selection_path(self, context, selection_path): try: @@ -487,7 +461,6 @@ class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator): self._pie_event, source_object, selection_path, - self.refine_selection, ) return {"FINISHED"} @@ -504,19 +477,17 @@ class CutoutTool(bpy.types.WorkSpaceTool): ( SelectCutoutSelection.bl_idname, {"type": "LEFTMOUSE", "value": "DOUBLE_CLICK"}, - {"properties": [("gesture", "LASSO"), ("full_image", True)]}, + {"properties": [("full_image", True)]}, ), - ( + *image_edit_drag_keymap( SelectCutoutSelection.bl_idname, - {"type": "LEFTMOUSE", "value": "PRESS"}, - {"properties": [("gesture", "LASSO")]}, + deselect_on_empty=False, ), ) def draw_settings(context, layout, _tool): settings = context.scene.anyimage_settings status = draw_ai_setup(layout) - draw_ai_property(layout, settings, "cutout_refine_selection", status) draw_ai_property(layout, settings, "cutout_generate_normal", status) layout.prop(settings, "cutout_mesh_detail") layout.prop(settings, "cutout_inward_axis") diff --git a/src/anyimage/operators/cutout_tool/object.py b/src/anyimage/operators/cutout_tool/object.py index 7e530f6..1224b61 100644 --- a/src/anyimage/operators/cutout_tool/object.py +++ b/src/anyimage/operators/cutout_tool/object.py @@ -4,7 +4,7 @@ from enum import StrEnum import bpy -from ...common.image import color_data_name +from ...common.image import color_data_name, configure_color_alpha from ...common.depth import camera_depth_values, depth_uniform_scale from ...common.material import ( IMAGE_MATERIAL_NODE_GROUP_NAME, @@ -31,7 +31,7 @@ from .geometry import ( from .shape import ( BALLOON_ATTRIBUTE_NAME, BALLOON_CUTOUT_SHAPES, - CUTOUT_AXIS_TO_SOURCE, + CUTOUT_X_TO_Z, CUTOUT_NODE_GROUP_NAME, CUTOUT_SHAPE_INDICES, DEPTH_CUTOUT_SHAPES, @@ -55,13 +55,13 @@ def _cutout_world_matrix(source_matrix, crop_center, inward_axis): center_x, center_y = (float(value) for value in crop_center) matrix = source_matrix @ Matrix.Translation((center_x, center_y, 0.0)) if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.POSITIVE_X: - matrix @= Matrix(CUTOUT_AXIS_TO_SOURCE) + matrix @= Matrix(CUTOUT_X_TO_Z) return matrix def orient_object_normal_image(image, inward_axis): - """Convert internal +X Cutout normals to the selected object axes.""" - if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.POSITIVE_X: + """Convert native Z-axis Cutout normals to the selected object axes.""" + if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.NEGATIVE_Z: return image import numpy as np @@ -70,7 +70,7 @@ def orient_object_normal_image(image, inward_axis): rgba = pixels.reshape((-1, 4)) normal = rgba[:, :3] * 2.0 - 1.0 rgba[:, :3] = np.column_stack( - (-normal[:, 1], normal[:, 2], -normal[:, 0]) + (-normal[:, 2], -normal[:, 0], normal[:, 1]) ) * 0.5 + 0.5 image.pixels.foreach_set(pixels) image.update() @@ -120,7 +120,7 @@ def create_cropped_color_image(source_object, crop_bounds, source_rgba): ) try: image.colorspace_settings.name = source.colorspace_settings.name - image.alpha_mode = source.alpha_mode + configure_color_alpha(image) image.pixels.foreach_set(np.flipud(cropped).ravel()) image.pack() image.update() diff --git a/src/anyimage/operators/cutout_tool/shape.py b/src/anyimage/operators/cutout_tool/shape.py index 6ea65e3..22a274a 100644 --- a/src/anyimage/operators/cutout_tool/shape.py +++ b/src/anyimage/operators/cutout_tool/shape.py @@ -2,12 +2,18 @@ BALLOON_ATTRIBUTE_NAME = "o_balloon" CUTOUT_NODE_GROUP_NAME = "O Image Cutout" -CUTOUT_AXIS_TO_SOURCE = ( +CUTOUT_X_TO_Z = ( (0.0, -1.0, 0.0, 0.0), (0.0, 0.0, 1.0, 0.0), (-1.0, 0.0, 0.0, 0.0), (0.0, 0.0, 0.0, 1.0), ) +CUTOUT_Z_TO_X = ( + (0.0, 0.0, -1.0, 0.0), + (-1.0, 0.0, 0.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (0.0, 0.0, 0.0, 1.0), +) CUTOUT_NODE_GROUP_NAMES = { "SURFACE": "O Image Surface", "BALLOON": "O Image Balloon", diff --git a/src/anyimage/operators/image_edit_tool/__init__.py b/src/anyimage/operators/image_edit_tool/__init__.py new file mode 100644 index 0000000..af86b44 --- /dev/null +++ b/src/anyimage/operators/image_edit_tool/__init__.py @@ -0,0 +1,18 @@ +from .frame import ActivateImageEditTool, FrameImages, FrameTool +from .mask import EditImageAlpha, MaskTool +from .rectify import RectifyImagePerspective, RectifyTool + + +CLASSES = ( + FrameImages, + EditImageAlpha, + ActivateImageEditTool, + RectifyImagePerspective, +) + +__all__ = ( + "CLASSES", + "FrameTool", + "MaskTool", + "RectifyTool", +) diff --git a/src/anyimage/operators/image_edit_tool/frame.py b/src/anyimage/operators/image_edit_tool/frame.py new file mode 100644 index 0000000..bb6959a --- /dev/null +++ b/src/anyimage/operators/image_edit_tool/frame.py @@ -0,0 +1,665 @@ +import math + +import bpy + +from ...common.image import ( + MAX_PROJECTIVE_OUTPUT_PIXELS, + PROJECTIVE_CHUNK_ROWS, + create_image_edit_result, + homography_from_points, + image_empty_bounds, + image_pixels, + is_image_empty, + premultiplied_rgba, + replace_empty_image, + sample_rgba, + transform_homography, + video_total_frames, +) +from ...preferences import configured_max_frame_resolution +from ...common.viewport import ( + active_image_edit_source, + active_view3d_tool_id, + draw_dashed_line, + draw_polygon_fill, + drawing_in_region, + image_edit_drag_keymap, + image_edit_poll, + rectangle_screen_path, + report_image_edit_exception, +) +from ...common.selection import ImageEditWarning, clip_polygon_halfplanes + +FRAME_TOOL_ID = "anyimage.frame" +PROJECTIVE_RELATIVE_EPSILON = 1e-12 + + +def _matrix_array(matrix): + import numpy as np + + return np.asarray( + tuple(tuple(float(value) for value in row) for row in matrix), + dtype=np.float64, + ) + + +def frame_source_projection( + projection_matrix, + view_matrix, + matrix_world, + bounds, + region_size, + *, + perspective, +): + """Return normalized image-plane transforms for one frozen view.""" + import numpy as np + + x_min, x_max, y_min, y_max = (float(value) for value in bounds) + region_width, region_height = (float(value) for value in region_size) + if min(x_max - x_min, y_max - y_min, region_width, region_height) <= 0.0: + raise ValueError("The Image Empty projection is invalid") + source_plane = np.asarray( + ( + (x_max - x_min, 0.0, x_min), + (0.0, y_min - y_max, y_max), + (0.0, 0.0, 0.0), + (0.0, 0.0, 1.0), + ), + dtype=np.float64, + ) + world = _matrix_array(matrix_world) + source_to_world = world @ source_plane + source_to_view = _matrix_array(view_matrix) @ source_to_world + source_to_clip = _matrix_array(projection_matrix) @ source_to_world + source_to_screen = np.stack( + ( + 0.5 * (source_to_clip[0] + source_to_clip[3]), + 0.5 * (source_to_clip[1] + source_to_clip[3]), + source_to_clip[3], + ) + ) + scale = float(np.max(np.abs(source_to_screen))) + if not math.isfinite(scale) or scale <= 0.0: + raise ValueError("The Image Empty projection is invalid") + source_to_screen /= scale + if not np.isfinite(source_to_screen).all(): + raise ValueError("The Image Empty projection is invalid") + condition = float(np.linalg.cond(source_to_screen)) + if not math.isfinite(condition) or condition > 1.0 / np.finfo(np.float64).eps: + raise ValueError("The Image Empty is edge-on to the current view") + return { + "bounds": (x_min, x_max, y_min, y_max), + "origin_depth": -float((_matrix_array(view_matrix) @ world)[2, 3]), + "perspective": bool(perspective), + "region_size": (region_width, region_height), + "screen_to_source": np.linalg.inv(source_to_screen), + "source_to_screen": source_to_screen, + "source_to_view": source_to_view, + "source_to_world": source_to_world, + } + + +def _projective_points(points, matrix): + """Map points and return finite results plus per-point validity.""" + import numpy as np + + points = np.asarray(points, dtype=np.float64) + flat = points.reshape((-1, 2)) + homogeneous = np.column_stack((flat, np.ones(len(flat), dtype=np.float64))) + mapped = homogeneous @ np.asarray(matrix, dtype=np.float64).T + magnitude = np.max(np.abs(mapped), axis=1) + valid = ( + np.isfinite(mapped).all(axis=1) + & (np.abs(mapped[:, 2]) > PROJECTIVE_RELATIVE_EPSILON * magnitude) + ) + result = np.full((len(flat), 2), np.nan, dtype=np.float64) + result[valid] = mapped[valid, :2] / mapped[valid, 2, None] + return result.reshape(points.shape), valid.reshape(points.shape[:-1]) + + +def frame_source_coordinates(source, screen_points): + """Return source pixels, view depth, validity, and normalized coordinates.""" + import numpy as np + + screen_points = np.asarray(screen_points, dtype=np.float64) + region_width, region_height = source["region_size"] + normalized_screen = screen_points / (region_width, region_height) + normalized_source, valid = _projective_points( + normalized_screen, + source["screen_to_source"], + ) + flat_source = normalized_source.reshape((-1, 2)) + homogeneous = np.column_stack( + (flat_source, np.ones(len(flat_source), dtype=np.float64)) + ) + depth = -(homogeneous @ source["source_to_view"][2]) + depth = depth.reshape(valid.shape) + valid &= np.isfinite(depth) + if source["perspective"]: + valid &= depth > 0.0 + valid &= ( + (normalized_source[..., 0] >= 0.0) + & (normalized_source[..., 0] <= 1.0) + & (normalized_source[..., 1] >= 0.0) + & (normalized_source[..., 1] <= 1.0) + ) + image_width, image_height = source["image_size"] + source_points = normalized_source * (image_width, image_height) + source_points[~valid] = -1.0 + return source_points, depth, valid, normalized_source + + +def frame_source_overlap(source, frame_quad): + """Clip the source plane before dividing its homogeneous projection.""" + import numpy as np + + frame = np.asarray(frame_quad, dtype=np.float64) / source["region_size"] + left, bottom = frame.min(axis=0) + right, top = frame.max(axis=0) + x, y, w = source["source_to_screen"] + boundaries = [x - left * w, right * w - x, y - bottom * w, top * w - y] + if source["perspective"]: + boundaries.insert(0, -source["source_to_view"][2]) + polygon = clip_polygon_halfplanes( + ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)), boundaries + ) + if len(polygon) < 3: + return () + screen, valid = _projective_points(polygon, source["source_to_screen"]) + if not np.all(valid): + return () + screen *= source["region_size"] + # Center the coordinates to avoid cancellation in a distant small polygon. + centered = screen - screen[0] + area = abs(np.sum(centered[:, 0] * np.roll(centered[:, 1], -1) + - centered[:, 1] * np.roll(centered[:, 0], -1))) * 0.5 + return tuple(map(tuple, screen)) if area > 1e-8 else () + + +def polygon_centroid(polygon): + """Return the area centroid of one non-degenerate screen polygon.""" + cross_sum = 0.0 + x_sum = 0.0 + y_sum = 0.0 + for first, second in zip(polygon, (*polygon[1:], polygon[0])): + cross = first[0] * second[1] - second[0] * first[1] + cross_sum += cross + x_sum += (first[0] + second[0]) * cross + y_sum += (first[1] + second[1]) * cross + if abs(cross_sum) <= 1e-12: + raise ValueError("Frame does not overlap the active Image Empty") + return x_sum / (3.0 * cross_sum), y_sum / (3.0 * cross_sum) + + +def _pixel_quad(image_size): + width, height = (float(value) for value in image_size) + if min(width, height) <= 0.0: + raise ValueError("The source image has no readable dimensions") + return ((0.0, 0.0), (width, 0.0), (width, height), (0.0, height)) + + +def frame_output_size(frame_quad, region_size, max_resolution): + """Choose output resolution from Frame pixels within the current Region.""" + frame_width = abs(float(frame_quad[1][0] - frame_quad[0][0])) + frame_height = abs(float(frame_quad[0][1] - frame_quad[3][1])) + region_width, region_height = (float(value) for value in region_size) + maximum = float(max_resolution) + if min(frame_width, frame_height, region_width, region_height, maximum) <= 0.0: + raise ValueError("The Frame output dimensions must be positive") + scale = min( + 1.0, + region_width / frame_width, + region_height / frame_height, + maximum / max(frame_width, frame_height), + math.sqrt( + MAX_PROJECTIVE_OUTPUT_PIXELS / max(frame_width * frame_height, 1.0) + ), + ) + return ( + max(2, int(round(frame_width * scale))), + max(2, int(round(frame_height * scale))), + ) + + +def _frame_source(source_object, region, view3d, view_matrix, *, active): + import numpy as np + + image_size = tuple(source_object.data.size) + pixels = image_pixels(source_object.data) + width, height = image_size + bounds = image_empty_bounds(source_object) + source = frame_source_projection( + view3d.perspective_matrix, + view_matrix, + source_object.matrix_world, + bounds, + (region.width, region.height), + perspective=view3d.is_perspective, + ) + source.update( + { + "active": bool(active), + "image_size": image_size, + "name": source_object.name, + "object": source_object, + "pixels": premultiplied_rgba(pixels, image_size), + "rgba": np.flipud(pixels.reshape((height, width, 4))), + } + ) + return source + + +def _composite_frame_samples(sources, screen_points): + """Resolve independently sorted samples with an unweighted bottom RGB base.""" + import numpy as np + + samples, depths, coordinates = [], [], [] + for source in sources: + points, depth, valid, _ = frame_source_coordinates(source, screen_points) + sample = sample_rgba(source["pixels"], points) + sample[~valid] = 0 + depths.append(np.where(valid, depth, -math.inf)) + samples.append(sample) + coordinates.append(points) + samples = np.stack(samples) + depths = np.stack(depths) + order = np.argsort(-depths, axis=0, kind="stable") + bottom = order[0] + covered = np.isfinite(np.take_along_axis(depths, bottom[None], axis=0)[0]) + result = np.zeros_like(samples[0]) + for index, source in enumerate(sources): + selected = covered & (bottom == index) + if np.any(selected): + result[..., :3][selected] = sample_rgba( + source["rgba"], coordinates[index][selected] + )[..., :3] + for index in range(len(sources)): + layer = np.take_along_axis(samples, order[index][None, ..., None], axis=0)[0] + alpha = layer[..., 3:4] + result[..., 3:4] = alpha + result[..., 3:4] * (1 - alpha) + if index: + result[..., :3] = layer[..., :3] + result[..., :3] * (1 - alpha) + return result, covered + + +def _frame_boundary_pixels(sources, points, output_to_screen): + """Conservatively detect projected contour and depth-order crossings.""" + import numpy as np + + boundary = np.zeros(points.shape[:-1], dtype=bool) + for source in sources: + width, height = source["region_size"] + mapping = source["screen_to_source"] @ np.diag((1 / width, 1 / height, 1)) @ output_to_screen + u, v, w = mapping + lines = [u, v, u - w, v - w, w] + positive = np.ones_like(boundary) + negative = np.ones_like(boundary) + for line in (u, v, w - u, w - v, w): + radius = 0.5 * (abs(line[0]) + abs(line[1])) + value = points[..., 0] * line[0] + points[..., 1] * line[1] + line[2] + positive &= value + radius >= 0 + negative &= value - radius <= 0 + source_boundary = np.zeros_like(boundary) + if source["perspective"]: + lines.append(source["source_to_view"][2] @ mapping) + for line in lines: + radius = 0.5 * (abs(line[0]) + abs(line[1])) + value = points[..., 0] * line[0] + points[..., 1] * line[1] + line[2] + source_boundary |= np.abs(value) < radius - 1e-10 * max(radius, 1e-20) + boundary |= source_boundary & (positive | negative) + if len(sources) > 1: + first_order = None + for offset in ((-0.499999, -0.499999), (0.499999, -0.499999), + (-0.499999, 0.499999), (0.499999, 0.499999)): + screen = transform_homography(points + offset, output_to_screen) + depths = [] + for source in sources: + _, depth, valid, _ = frame_source_coordinates(source, screen) + depths.append(np.where(valid, depth, -math.inf)) + order = np.argsort(-np.stack(depths), axis=0, kind="stable") + if first_order is None: + first_order = order + else: + boundary |= np.any(order != first_order, axis=0) + return boundary + + +def composite_frame_pixels(sources, frame_quad, output_size): + """Composite projected Image Empty sources into one Blender-order RGBA image.""" + import numpy as np + + output_width, output_height = (int(value) for value in output_size) + if min(output_width, output_height) <= 0: + raise ValueError("The Frame output dimensions must be positive") + sources = tuple( + sorted(sources, key=lambda source: (source["active"], source["name"])) + ) + if not sources or not sources[-1]["active"]: + raise ValueError("Frame requires one active Image Empty") + output_quad = _pixel_quad(output_size) + output_to_screen = homography_from_points(output_quad, frame_quad) + output = np.zeros((output_height, output_width, 4), dtype=np.float32) + covered = np.zeros((output_height, output_width), dtype=bool) + target_x = np.arange(output_width, dtype=np.float64) + 0.5 + for row_start in range(0, output_height, PROJECTIVE_CHUNK_ROWS): + row_end = min(row_start + PROJECTIVE_CHUNK_ROWS, output_height) + target_y = np.arange(row_start, row_end, dtype=np.float64) + 0.5 + x, y = np.meshgrid(target_x, target_y) + points = np.stack((x, y), axis=-1) + composite, coverage = _composite_frame_samples( + sources, transform_homography(points, output_to_screen) + ) + boundary = _frame_boundary_pixels(sources, points, output_to_screen) + if np.any(boundary): + edge_points = points[boundary] + accumulated = np.zeros((len(edge_points), 4), dtype=np.float32) + counts = np.zeros(len(edge_points), dtype=np.int32) + for dy in (-0.375, -0.125, 0.125, 0.375): + for dx in (-0.375, -0.125, 0.125, 0.375): + sample, hit = _composite_frame_samples( + sources, + transform_homography(edge_points + (dx, dy), output_to_screen), + ) + accumulated += sample + counts += hit + accumulated[..., 3] /= 16 + accumulated[..., :3] /= np.maximum(counts, 1)[..., None] + composite[boundary] = accumulated + coverage[boundary] = counts > 0 + covered[row_start:row_end] = coverage + output[row_start:row_end] = composite + output[..., 3][output[..., 3] <= 1e-8] = 0.0 + output[~covered] = 0.0 + return np.flipud(output).ravel() + + +def view_facing_frame_geometry(corners, output_size): + """Return a world matrix array and display size for frame-plane corners.""" + import numpy as np + + corners = np.asarray(corners, dtype=np.float64) + if corners.shape != (4, 3) or not np.isfinite(corners).all(): + raise ValueError("The Frame view plane is invalid") + top_left, top_right, _bottom_right, bottom_left = corners + right = top_right - top_left + up = top_left - bottom_left + width = float(np.linalg.norm(right)) + height = float(np.linalg.norm(up)) + if min(width, height) <= 1e-10: + raise ValueError("Draw a larger Frame") + right /= width + up /= height + forward = np.cross(right, up) + forward_length = float(np.linalg.norm(forward)) + if forward_length <= 1e-10: + raise ValueError("The Frame view plane is invalid") + forward /= forward_length + matrix = np.identity(4, dtype=np.float64) + matrix[:3, 0] = right + matrix[:3, 1] = up + matrix[:3, 2] = forward + matrix[:3, 3] = corners.mean(axis=0) + display_size = width if output_size[0] >= output_size[1] else height + return matrix, display_size + + +def frame_result_transform(frame_quad, source_origin, region, view3d, output_size): + """Return the view-facing object matrix and Image Empty display size.""" + from mathutils import Matrix + from bpy_extras import view3d_utils + + corners = tuple( + tuple( + float(value) + for value in view3d_utils.region_2d_to_location_3d( + region, + view3d, + point, + source_origin, + ) + ) + for point in frame_quad + ) + matrix, display_size = view_facing_frame_geometry(corners, output_size) + return Matrix(tuple(tuple(row) for row in matrix)), display_size + + +def frame_depth_location(active_source, overlap_polygon): + """Return the world point whose view depth places the Frame result.""" + import numpy as np + + source_object = active_source["object"] + if not active_source["perspective"] or active_source["origin_depth"] > 0.0: + return source_object.matrix_world.translation.copy() + centroid = np.asarray((polygon_centroid(overlap_polygon),), dtype=np.float64) + _points, depth, valid, normalized_source = frame_source_coordinates( + active_source, + centroid, + ) + if not bool(valid[0]) or depth[0] <= 0.0: + raise ValueError("The Frame result depth is invalid") + source_point = np.asarray( + (normalized_source[0, 0], normalized_source[0, 1], 1.0), + dtype=np.float64, + ) + world = active_source["source_to_world"] @ source_point + if not np.isfinite(world).all() or abs(world[3]) <= 1e-12: + raise ValueError("The Frame result depth is invalid") + return tuple(float(value) for value in world[:3] / world[3]) + + +def selected_frame_sources(context): + """Return selected still Image Empties with the active object last.""" + active = active_image_edit_source(context) + sources = [ + source for source in context.selected_objects if is_image_empty(source) + ] + for source in sources: + if video_total_frames(source.data) > 1: + raise ValueError("Frame only supports still images") + return tuple( + sorted(sources, key=lambda source: (source is active, source.name)) + ) + + +def apply_frame(context, source_objects, frame_quad): + """Bake selected projections into the active Image Empty.""" + active = context.active_object + if active not in source_objects: + raise ValueError("The active Image Empty is not a Frame source") + view3d = context.region_data + sources = tuple( + _frame_source( + source, + context.region, + view3d, + view3d.view_matrix, + active=source is active, + ) + for source in source_objects + ) + active_source = next(source for source in sources if source["active"]) + active_overlap = frame_source_overlap(active_source, frame_quad) + if not active_overlap: + raise ImageEditWarning("Frame does not overlap the active Image Empty") + output_size = frame_output_size( + frame_quad, + (context.region.width, context.region.height), + configured_max_frame_resolution(context), + ) + pixels = composite_frame_pixels(sources, frame_quad, output_size) + depth_location = frame_depth_location(active_source, active_overlap) + matrix, display_size = frame_result_transform( + frame_quad, + depth_location, + context.region, + view3d, + output_size, + ) + result_image = create_image_edit_result(active.data, pixels, output_size) + active_source_image = active.data + active_source_name = active_source_image.name + old_matrix = active.matrix_world.copy() + old_display_size = active.empty_display_size + old_offset = tuple(active.empty_image_offset) + source_images = [] + for source in source_objects: + if all(image is not source.data for image in source_images): + source_images.append(source.data) + try: + active.matrix_world = matrix + active.empty_display_size = display_size + active.empty_image_offset = (-0.5, -0.5) + replace_empty_image(active, result_image) + except Exception: + active.matrix_world = old_matrix + active.empty_display_size = old_display_size + active.empty_image_offset = old_offset + if result_image.users == 0: + bpy.data.images.remove(result_image, do_unlink=True) + raise + for source in source_objects: + if source is not active: + bpy.data.objects.remove(source, do_unlink=True) + for image in source_images: + try: + if image.users == 0 and not image.use_fake_user: + bpy.data.images.remove(image, do_unlink=True) + if image is active_source_image: + result_image.name = active_source_name + except (ReferenceError, RuntimeError): + pass + return active + + +class FrameImages(bpy.types.Operator): + bl_idname = "anyimage.frame_images" + bl_label = "Frame Images" + bl_description = "Bake selected Image Empties into a view-facing frame" + bl_options = {"UNDO"} + + _active_name = "" + _source_names = () + _start = None + _cursor = None + _handle = None + _area_pointer = 0 + _region_pointer = 0 + + @classmethod + def poll(cls, context): + return image_edit_poll(context) + + def _draw_overlay(self): + if not drawing_in_region(self._area_pointer, self._region_pointer): + return + frame = rectangle_screen_path(self._start, self._cursor) + if not frame: + return + draw_polygon_fill(frame) + draw_dashed_line((*frame, frame[0])) + + def _finish(self, context): + if self._handle is not None: + bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW") + self._handle = None + context.workspace.status_text_set(None) + if context.area is not None: + context.area.tag_redraw() + + def cancel(self, context): + self._finish(context) + + def invoke(self, context, event): + try: + sources = selected_frame_sources(context) + except (TypeError, ValueError) as error: + report_image_edit_exception(self, error) + return {"CANCELLED"} + self._active_name = context.active_object.name + self._source_names = tuple(source.name for source in sources) + point = (float(event.mouse_region_x), float(event.mouse_region_y)) + self._start = point + self._cursor = point + self._area_pointer = context.area.as_pointer() + self._region_pointer = context.region.as_pointer() + self._handle = bpy.types.SpaceView3D.draw_handler_add( + self._draw_overlay, (), "WINDOW", "POST_PIXEL" + ) + context.workspace.status_text_set( + "Frame: drag a rectangle to merge selected Image Empties; " + "RMB or Esc cancels" + ) + context.window_manager.modal_handler_add(self) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + + def modal(self, context, event): + active_tool = active_view3d_tool_id(context) + if active_tool is not None and active_tool != FRAME_TOOL_ID: + self._finish(context) + return {"CANCELLED"} + if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS": + self._finish(context) + return {"CANCELLED"} + if event.type == "MOUSEMOVE": + self._cursor = ( + float(event.mouse_region_x), + float(event.mouse_region_y), + ) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + if event.type == "LEFTMOUSE" and event.value == "RELEASE": + self._cursor = ( + float(event.mouse_region_x), + float(event.mouse_region_y), + ) + frame = rectangle_screen_path(self._start, self._cursor) + if not frame: + self._finish(context) + return {"CANCELLED"} + try: + active = bpy.data.objects.get(self._active_name) + sources = tuple( + bpy.data.objects.get(name) for name in self._source_names + ) + if ( + not is_image_empty(active) + or any(not is_image_empty(source) for source in sources) + or context.active_object is not active + ): + raise ValueError("The selected Image Empties changed during Frame") + apply_frame(context, sources, frame) + except (RuntimeError, TypeError, ValueError) as error: + report_image_edit_exception(self, error) + self._finish(context) + return {"CANCELLED"} + self._finish(context) + return {"FINISHED"} + return {"PASS_THROUGH"} + + +class FrameTool(bpy.types.WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = FRAME_TOOL_ID + bl_label = "Frame" + bl_description = "Bake selected Image Empties into a view-facing frame" + bl_icon = "ops.sculpt.border_mask" + bl_operator = FrameImages.bl_idname + bl_keymap = image_edit_drag_keymap(FrameImages.bl_idname) + + +class ActivateImageEditTool(bpy.types.Operator): + bl_idname = "anyimage.activate_image_edit_tool" + bl_label = "Image Edit" + bl_description = "Activate Frame in the 3D Viewport" + bl_options = {"INTERNAL"} + + @classmethod + def poll(cls, context): + return image_edit_poll(context) + + def execute(self, _context): + return bpy.ops.wm.tool_set_by_id(name=FRAME_TOOL_ID, space_type="VIEW_3D") diff --git a/src/anyimage/operators/image_edit_tool/mask.py b/src/anyimage/operators/image_edit_tool/mask.py new file mode 100644 index 0000000..7a1f786 --- /dev/null +++ b/src/anyimage/operators/image_edit_tool/mask.py @@ -0,0 +1,176 @@ +import bpy + +from ...common.image import ( + create_image_edit_result, + image_empty_bounds, + image_rgba, + replace_empty_image, + require_image_empty, +) +from ...common.selection import ( + rasterize_selection_path, + rasterize_selection_union, + SelectionPath, +) +from ...common.viewport import ( + brush_footprint_polygons, + ImageGesture, + image_edit_point_keymap, + report_image_edit_exception, + screen_path_to_image_pixels, +) +from ...properties import ( + mask_gesture_property, + mask_mode_property, + mask_radius_property, +) + +MASK_TOOL_ID = "anyimage.mask" + + +def compose_alpha(source_alpha, mask_values, mode): + """Compose source Alpha with one same-shaped Mask.""" + import numpy as np + + alpha = np.asarray(source_alpha, dtype=np.float32) + mask = np.asarray(mask_values, dtype=np.float32) + if alpha.shape != mask.shape: + raise ValueError("The source Alpha and Mask must have the same shape") + mode = str(mode) + if mode == "SET": + result = alpha * mask + elif mode == "ADD": + result = np.maximum(alpha, mask) + elif mode == "SUBTRACT": + result = alpha * (1.0 - mask) + else: + raise ValueError(f"Unsupported Mask mode: {mode}") + return np.clip(result, 0.0, 1.0) + + +def apply_alpha_mask(source_rgba, selection_mask, mode): + """Apply one Selection Mask to current Alpha without changing the 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 source image must contain RGBA pixels") + values = selection_mask.full_values((rgba.shape[1], rgba.shape[0])) + rgba[:, :, 3] = compose_alpha(rgba[:, :, 3], values, mode) + return np.flipud(rgba).ravel() + + +def rasterize_brush_path(image_size, path, radius, project_path): + """Project the shared Brush footprint and antialias its complete union.""" + paths = tuple( + SelectionPath(points=tuple(project_path(polygon))) + for polygon in brush_footprint_polygons(path, radius) + ) + return rasterize_selection_union(image_size, paths, antialias=True) + + +class EditImageAlpha(ImageGesture, bpy.types.Operator): + bl_idname = "anyimage.edit_image_alpha" + bl_label = "Edit Image Alpha" + bl_description = "Edit Image Empty Alpha with a lasso, brush, or polyline" + bl_options = {"UNDO"} + active_tool_id = MASK_TOOL_ID + resolve_on_press = True + + source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) + source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) + gesture: mask_gesture_property() + mode: mask_mode_property() + radius: mask_radius_property() + + def _read_gesture_settings(self, context): + settings = context.scene.anyimage_settings + self.gesture = str(settings.mask_gesture) + self.mode = str(settings.mask_mode) + self.radius = int(settings.mask_radius) + + def _set_status(self, context): + if self.gesture == "BRUSH": + action = f"paint with radius {self.radius} px" + elif self.gesture == "POLYLINE": + action = ( + "click points; click start, double-click, or Enter to finish; " + "Backspace removes" + ) + else: + action = "draw and release" + context.workspace.status_text_set( + f"Mask {self.gesture.title()}: {action}; {self.mode.title()}; " + "RMB or Esc cancels" + ) + + def _submit_selection_path(self, context, selection_path): + source_object = require_image_empty(self.source_object_name) + selection_mask = rasterize_selection_path( + tuple(source_object.data.size), + selection_path, + antialias=True, + ) + return self._apply_mask(context, source_object, selection_mask) + + def _complete_brush(self, context, source_object, matrix): + image_size = tuple(source_object.data.size) + local_bounds = image_empty_bounds(source_object) + selection_mask = rasterize_brush_path( + image_size, + self._path, + self.radius, + lambda path: screen_path_to_image_pixels( + path, + context.region, + context.region_data, + matrix, + local_bounds, + image_size, + simplify=False, + ), + ) + return self._apply_mask(context, source_object, selection_mask) + + def _apply_mask(self, context, source_object, selection_mask): + try: + source_image = source_object.data + pixels = apply_alpha_mask( + image_rgba(source_image), + selection_mask, + self.mode, + ) + result_image = create_image_edit_result( + source_image, + pixels, + tuple(source_image.size), + ) + try: + replace_empty_image(source_object, result_image) + except Exception: + if result_image.users == 0: + bpy.data.images.remove(result_image, do_unlink=True) + raise + except (RuntimeError, TypeError, ValueError) as error: + report_image_edit_exception(self, error) + return {"CANCELLED"} + return {"FINISHED"} + + +class MaskTool(bpy.types.WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = MASK_TOOL_ID + bl_label = "Mask" + bl_description = "Edit Image Empty Alpha with a lasso, brush, or polyline" + bl_icon = "ops.sculpt.lasso_mask" + bl_operator = EditImageAlpha.bl_idname + bl_cursor = "PAINT_CROSS" + bl_keymap = image_edit_point_keymap(EditImageAlpha.bl_idname) + + def draw_settings(context, layout, _tool): + settings = context.scene.anyimage_settings + layout.prop(settings, "mask_gesture") + layout.prop(settings, "mask_mode", expand=True, icon_only=True) + if settings.mask_gesture == "BRUSH": + layout.prop(settings, "mask_radius") diff --git a/src/anyimage/operators/crop_tool/perspective.py b/src/anyimage/operators/image_edit_tool/rectify.py similarity index 67% rename from src/anyimage/operators/crop_tool/perspective.py rename to src/anyimage/operators/image_edit_tool/rectify.py index 9cb2d5e..d4528b2 100644 --- a/src/anyimage/operators/crop_tool/perspective.py +++ b/src/anyimage/operators/image_edit_tool/rectify.py @@ -6,31 +6,30 @@ import bpy from ...common.viewport import ( active_view3d_tool_id, deserialize_matrix, - draw_crop_settings, draw_dashed_line, draw_polygon_fill, drawing_in_region, image_edit_poll, + image_edit_point_keymap, report_image_edit_exception, resolve_image_edit_click, screen_path_to_image_pixels, serialize_matrix, ) -from ...common.selection import crop_pixels_to_alpha +from ...common.selection import clip_polygon_halfplanes, trim_pixels_to_alpha from ...common.image import ( create_image_edit_result, image_base_name, image_pixels, image_empty_bounds, - place_empty_image_result, + projective_output_size, + replace_empty_image, require_image_empty, video_total_frames, + warp_projective_pixels, ) MIN_QUAD_AREA = 16.0 -MAX_OUTPUT_DIMENSION = 8192 -MAX_OUTPUT_PIXELS = 16_777_216 -WARP_CHUNK_ROWS = 256 -CROP_PERSPECTIVE_TOOL_ID = "anyimage.crop_perspective" +RECTIFY_TOOL_ID = "anyimage.rectify" PREVIEW_TEXTURE_SIZE = 512 ASPECT_PIXELS_PER_DOUBLE = 240.0 MIN_INTERACTIVE_ASPECT = 0.05 @@ -50,15 +49,15 @@ def validate_perspective_quad(points, min_area=1e-6): quad = np.asarray(points, dtype=np.float64) if quad.shape != (4, 2) or not np.isfinite(quad).all(): - raise ValueError("Perspective requires four valid image corners") + raise ValueError("Rectify requires four valid image corners") edges = np.roll(quad, -1, axis=0) - quad if np.any(np.linalg.norm(edges, axis=1) < 2.0): - raise ValueError("Perspective corners are too close together") + raise ValueError("Rectify corners are too close together") crosses = np.cross(edges, np.roll(edges, -1, axis=0)) if np.any(np.abs(crosses) <= 1e-8) or not ( np.all(crosses > 0.0) or np.all(crosses < 0.0) ): - raise ValueError("Perspective corners must define one convex quadrilateral") + raise ValueError("Rectify corners must define one convex quadrilateral") area = 0.5 * abs( float( np.dot(quad[:, 0], np.roll(quad[:, 1], -1)) @@ -66,7 +65,7 @@ def validate_perspective_quad(points, min_area=1e-6): ) ) if area < min_area: - raise ValueError("Draw a larger perspective quad") + raise ValueError("Draw a larger Rectify quad") return quad @@ -76,11 +75,11 @@ def canonical_perspective_quad(points, min_area=1e-6): points = np.asarray(points, dtype=np.float64) if points.shape != (4, 2) or not np.isfinite(points).all(): - raise ValueError("Perspective requires four valid image corners") + raise ValueError("Rectify requires four valid image corners") distances = np.linalg.norm(points[:, None] - points[None, :], axis=2) distances += np.eye(4) * 1e9 if float(distances.min()) < 2.0: - raise ValueError("Perspective corners are too close together") + raise ValueError("Rectify corners are too close together") center = points.mean(axis=0) ring = points[np.argsort(np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0]))] ring = validate_perspective_quad(ring, min_area) @@ -119,63 +118,15 @@ def interactive_preview_polygon(points, cursor): return preview_polygon(vertices) -def clip_polygon_to_image(points, image_size): - """Clip one polygon to the source image rectangle.""" - polygon = [tuple(float(value) for value in point) for point in points] - width, height = (float(value) for value in image_size) - if width <= 0.0 or height <= 0.0: - raise ValueError("The source image has no readable dimensions") - - def clip(vertices, inside, intersection): - if not vertices: - return [] - result = [] - previous = vertices[-1] - previous_inside = inside(previous) - for current in vertices: - current_inside = inside(current) - if current_inside != previous_inside: - result.append(intersection(previous, current)) - if current_inside: - result.append(current) - previous = current - previous_inside = current_inside - return result - - def vertical_intersection(start, end, x): - factor = (x - start[0]) / (end[0] - start[0]) - return x, start[1] + factor * (end[1] - start[1]) - - def horizontal_intersection(start, end, y): - factor = (y - start[1]) / (end[1] - start[1]) - return start[0] + factor * (end[0] - start[0]), y - - polygon = clip( - polygon, - lambda point: point[0] >= 0.0, - lambda start, end: vertical_intersection(start, end, 0.0), - ) - polygon = clip( - polygon, - lambda point: point[0] <= width, - lambda start, end: vertical_intersection(start, end, width), - ) - polygon = clip( - polygon, - lambda point: point[1] >= 0.0, - lambda start, end: horizontal_intersection(start, end, 0.0), - ) - return clip( - polygon, - lambda point: point[1] <= height, - lambda start, end: horizontal_intersection(start, end, height), - ) - - def perspective_quad_overlaps_image(points, image_size): import numpy as np - clipped = np.asarray(clip_polygon_to_image(points, image_size)) + width, height = image_size + if min(width, height) <= 0: + raise ValueError("The source image has no readable dimensions") + clipped = np.asarray(clip_polygon_halfplanes( + points, ((1, 0, 0), (-1, 0, width), (0, 1, 0), (0, -1, height)) + )) if clipped.ndim != 2 or len(clipped) < 3: return False area = 0.5 * abs( @@ -287,7 +238,7 @@ def draw_aspect_hud(bounds, aspect_ratio, preset_label=None): def create_perspective_preview_image(source_image, pixels): image = bpy.data.images.new( - f"{image_base_name(source_image)}.perspective-preview", + f"{image_base_name(source_image)}.rectify-preview", width=PREVIEW_TEXTURE_SIZE, height=PREVIEW_TEXTURE_SIZE, alpha=True, @@ -309,134 +260,6 @@ def preview_texture_draw_options(blender_version): return {} -def homography_from_points(source, target): - """Return a projective transform mapping four source points to target.""" - import numpy as np - - source = np.asarray(source, dtype=np.float64) - target = np.asarray(target, dtype=np.float64) - if source.shape != (4, 2) or target.shape != (4, 2): - raise ValueError("A homography requires four source and target points") - rows = [] - values = [] - for (x, y), (u, v) in zip(source, target): - rows.extend( - ( - (x, y, 1.0, 0.0, 0.0, 0.0, -u * x, -u * y), - (0.0, 0.0, 0.0, x, y, 1.0, -v * x, -v * y), - ) - ) - values.extend((u, v)) - matrix = np.asarray(rows, dtype=np.float64) - if np.linalg.cond(matrix) > 1e12: - raise ValueError("The perspective quad is too degenerate to transform") - coefficients = np.linalg.solve(matrix, np.asarray(values, dtype=np.float64)) - return np.append(coefficients, 1.0).reshape((3, 3)) - - -def perspective_output_size( - quad, - aspect_ratio, - max_dimension=MAX_OUTPUT_DIMENSION, - max_pixels=MAX_OUTPUT_PIXELS, -): - """Choose a detail-preserving output size for one requested aspect ratio.""" - import numpy as np - - if not math.isfinite(aspect_ratio) or aspect_ratio <= 0.0: - raise ValueError("Perspective aspect ratio must be greater than zero") - quad = validate_perspective_quad(quad) - width_pixels = 0.5 * ( - np.linalg.norm(quad[1] - quad[0]) + np.linalg.norm(quad[2] - quad[3]) - ) - height_pixels = 0.5 * ( - np.linalg.norm(quad[2] - quad[1]) + np.linalg.norm(quad[3] - quad[0]) - ) - height = (aspect_ratio * width_pixels + height_pixels) / ( - aspect_ratio * aspect_ratio + 1.0 - ) - width = aspect_ratio * height - dimension_scale = float(max_dimension) / max(width, height) - pixel_scale = math.sqrt(float(max_pixels) / max(width * height, 1.0)) - scale = min(1.0, dimension_scale, pixel_scale) - return max(2, int(round(width * scale))), max(2, int(round(height * scale))) - - -def warp_perspective_pixels(source_pixels, source_size, quad, output_size): - """Rectify one source quadrilateral into Blender-order straight RGBA pixels.""" - import numpy as np - - source_width, source_height = (int(value) for value in source_size) - output_width, output_height = (int(value) for value in output_size) - if min(source_width, source_height, output_width, output_height) <= 0: - raise ValueError("Perspective image dimensions must be positive") - source = np.asarray(source_pixels, dtype=np.float32) - if source.size != source_width * source_height * 4: - raise ValueError("The source image pixels do not match its dimensions") - source = np.flipud(source.reshape((source_height, source_width, 4))) - source[:, :, :3] *= source[:, :, 3:4] - - quad = validate_perspective_quad(quad) - target = np.asarray( - ( - (0.0, 0.0), - (float(output_width), 0.0), - (float(output_width), float(output_height)), - (0.0, float(output_height)), - ), - dtype=np.float64, - ) - inverse = homography_from_points(target, quad) - output = np.zeros((output_height, output_width, 4), dtype=np.float32) - target_x = np.arange(output_width, dtype=np.float64) + 0.5 - for row_start in range(0, output_height, WARP_CHUNK_ROWS): - row_end = min(row_start + WARP_CHUNK_ROWS, output_height) - target_y = np.arange(row_start, row_end, dtype=np.float64) + 0.5 - x, y = np.meshgrid(target_x, target_y) - target_centers = np.stack( - (x.ravel(), y.ravel(), np.ones(x.size)), axis=0 - ) - mapped = inverse @ target_centers - denominator = mapped[2] - valid_denominator = np.abs(denominator) > 1e-12 - mapped[:, valid_denominator] /= denominator[valid_denominator] - mapped[:, ~valid_denominator] = -1.0 - pixel_x = mapped[0] - 0.5 - pixel_y = mapped[1] - 0.5 - valid = ( - valid_denominator - & np.isfinite(mapped[0]) - & np.isfinite(mapped[1]) - & (mapped[0] >= 0.0) - & (mapped[0] <= source_width) - & (mapped[1] >= 0.0) - & (mapped[1] <= source_height) - ) - pixel_x = np.clip(pixel_x, 0.0, source_width - 1.0) - pixel_y = np.clip(pixel_y, 0.0, source_height - 1.0) - x0 = np.floor(pixel_x).astype(np.int64) - y0 = np.floor(pixel_y).astype(np.int64) - x1 = np.minimum(x0 + 1, source_width - 1) - y1 = np.minimum(y0 + 1, source_height - 1) - fx = (pixel_x - x0)[:, None] - fy = (pixel_y - y0)[:, None] - sampled = ( - source[y0, x0] * (1.0 - fx) * (1.0 - fy) - + source[y0, x1] * fx * (1.0 - fy) - + source[y1, x0] * (1.0 - fx) * fy - + source[y1, x1] * fx * fy - ) - sampled[~valid] = 0.0 - alpha = sampled[:, 3:4] - visible = alpha[:, 0] > 1e-8 - sampled[visible, :3] /= alpha[visible] - sampled[~visible] = 0.0 - output[row_start:row_end] = sampled.reshape( - (row_end - row_start, output_width, 4) - ) - return np.flipud(output).ravel() - - def perspective_quad_aspect(quad): """Estimate an initial output aspect from the visible quad edge lengths.""" import numpy as np @@ -454,22 +277,22 @@ def perspective_quad_aspect(quad): ) -def perspective_placement_bounds(quad, output_size, crop_bounds): - """Map a rectified Alpha crop back to an approximate source-image rectangle.""" +def perspective_placement_bounds(quad, output_size, trim_bounds): + """Map a rectified Alpha trim back to an approximate source-image rectangle.""" quad = validate_perspective_quad(quad) source_left = math.floor(float(quad[:, 0].min())) source_top = math.floor(float(quad[:, 1].min())) source_right = math.ceil(float(quad[:, 0].max())) source_bottom = math.ceil(float(quad[:, 1].max())) output_width, output_height = (float(value) for value in output_size) - crop_left, crop_top, crop_right, crop_bottom = crop_bounds + trim_left, trim_top, trim_right, trim_bottom = trim_bounds source_width = source_right - source_left source_height = source_bottom - source_top return ( - source_left + crop_left * source_width / output_width, - source_top + crop_top * source_height / output_height, - source_left + crop_right * source_width / output_width, - source_top + crop_bottom * source_height / output_height, + source_left + trim_left * source_width / output_width, + source_top + trim_top * source_height / output_height, + source_left + trim_right * source_width / output_width, + source_top + trim_bottom * source_height / output_height, ) @@ -479,33 +302,29 @@ def extract_perspective_pixels( quad, aspect_ratio, ): - output_size = perspective_output_size(quad, aspect_ratio) - pixels = warp_perspective_pixels( + output_size = projective_output_size(quad, aspect_ratio) + pixels = warp_projective_pixels( source_pixels, source_size, quad, output_size ) - pixels, crop_bounds = crop_pixels_to_alpha(pixels, (0, 0, *output_size)) - cropped_size = ( - crop_bounds[2] - crop_bounds[0], - crop_bounds[3] - crop_bounds[1], + pixels, trim_bounds = trim_pixels_to_alpha(pixels, (0, 0, *output_size)) + trimmed_size = ( + trim_bounds[2] - trim_bounds[0], + trim_bounds[3] - trim_bounds[1], ) placement_bounds = perspective_placement_bounds( - quad, output_size, crop_bounds + quad, output_size, trim_bounds ) - return pixels, cropped_size, placement_bounds + return pixels, trimmed_size, placement_bounds -class CropImagePerspective(bpy.types.Operator): - bl_idname = "anyimage.crop_image_perspective" - bl_label = "Crop Image Perspective" - bl_description = "Crop four image corners into a rectangular image" +class RectifyImagePerspective(bpy.types.Operator): + bl_idname = "anyimage.rectify_image_perspective" + bl_label = "Rectify Image Perspective" + bl_description = "Rectify four image corners into a rectangular image" bl_options = {"REGISTER", "UNDO"} source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) - keep_original: bpy.props.BoolProperty( - default=False, - options={"HIDDEN", "SKIP_SAVE"}, - ) quad_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) aspect_ratio: bpy.props.FloatProperty( default=1.0, @@ -577,14 +396,14 @@ class CropImagePerspective(bpy.types.Operator): def _set_point_status(self, context): remaining = 4 - len(self._points) context.workspace.status_text_set( - f"Crop Perspective: click {remaining} more corner" + f"Rectify: click {remaining} more corner" f"{'s' if remaining != 1 else ''}; click order does not matter; " "Backspace removes, RMB or Esc cancels" ) def _set_aspect_status(self, context): context.workspace.status_text_set( - f"Crop Perspective Aspect {self.aspect_ratio:.3f}: " + f"Rectify Aspect {self.aspect_ratio:.3f}: " "move mouse horizontally, LMB confirms, Backspace returns, " "RMB or Esc cancels" ) @@ -640,14 +459,11 @@ class CropImagePerspective(bpy.types.Operator): if video_total_frames(source_object.data) > 1: self.report( {"WARNING"}, - "Crop Perspective only supports a still image", + "Rectify only supports a still image", ) return {"CANCELLED"} self.source_object_name = source_object.name self.source_matrix_data = serialize_matrix(source_object.matrix_world) - self.keep_original = ( - context.scene.anyimage_settings.crop_perspective_keep_original - ) self.quad_json = "" self._phase = "POINTS" self._preview_image = None @@ -669,7 +485,7 @@ class CropImagePerspective(bpy.types.Operator): def modal(self, context, event): active_tool = active_view3d_tool_id(context) - if active_tool is not None and active_tool != CROP_PERSPECTIVE_TOOL_ID: + if active_tool is not None and active_tool != RECTIFY_TOOL_ID: self._finish(context) return {"CANCELLED"} if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS": @@ -743,7 +559,7 @@ class CropImagePerspective(bpy.types.Operator): ): self.report( {"WARNING"}, - "Perspective quad does not overlap the source image", + "Rectify quad does not overlap the source image", ) self._finish(context) return {"CANCELLED"} @@ -769,7 +585,7 @@ class CropImagePerspective(bpy.types.Operator): import numpy as np self._source_pixels = image_pixels(source_object.data) - preview_pixels = warp_perspective_pixels( + preview_pixels = warp_projective_pixels( self._source_pixels, tuple(source_object.data.size), quad, @@ -780,7 +596,7 @@ class CropImagePerspective(bpy.types.Operator): ): self.report( {"WARNING"}, - "Perspective quad contains no visible image pixels", + "Rectify quad contains no visible image pixels", ) self._finish(context) return {"CANCELLED"} @@ -806,14 +622,14 @@ class CropImagePerspective(bpy.types.Operator): return {"CANCELLED"} try: if video_total_frames(source_object.data) > 1: - raise RuntimeError("Crop Perspective only supports a still image") + raise RuntimeError("Rectify only supports a still image") quad = validate_perspective_quad(json.loads(self.quad_json)) if not perspective_quad_overlaps_image( quad, tuple(source_object.data.size) ): self.report( {"WARNING"}, - "Perspective quad does not overlap the source image", + "Rectify quad does not overlap the source image", ) return {"CANCELLED"} import numpy as np @@ -834,12 +650,10 @@ class CropImagePerspective(bpy.types.Operator): pixels, output_size, ) - place_empty_image_result( - context, + replace_empty_image( source_object, result_image, placement_bounds=placement_bounds, - keep_original=bool(getattr(self, "keep_original", False)), ) except ( json.JSONDecodeError, @@ -853,25 +667,12 @@ class CropImagePerspective(bpy.types.Operator): return {"FINISHED"} -class CropPerspectiveTool(bpy.types.WorkSpaceTool): +class RectifyTool(bpy.types.WorkSpaceTool): bl_space_type = "VIEW_3D" bl_context_mode = "OBJECT" - bl_idname = CROP_PERSPECTIVE_TOOL_ID - bl_label = "Crop Perspective" - bl_description = "Crop four image corners to a rectangular perspective" + bl_idname = RECTIFY_TOOL_ID + bl_label = "Rectify" + bl_description = "Rectify four image corners into a rectangular image" bl_icon = "ops.sculpt.line_project" - bl_operator = CropImagePerspective.bl_idname - bl_keymap = ( - ( - CropImagePerspective.bl_idname, - {"type": "LEFTMOUSE", "value": "PRESS"}, - None, - ), - ) - - def draw_settings(context, layout, _tool): - draw_crop_settings( - context, - layout, - keep_original="crop_perspective_keep_original", - ) + bl_operator = RectifyImagePerspective.bl_idname + bl_keymap = image_edit_point_keymap(RectifyImagePerspective.bl_idname) diff --git a/src/anyimage/preferences.py b/src/anyimage/preferences.py index 252346a..5199941 100644 --- a/src/anyimage/preferences.py +++ b/src/anyimage/preferences.py @@ -17,6 +17,9 @@ DEFAULT_BACKGROUND_MODEL_KEY = "BEN2_BASE" DEFAULT_MAX_CUTOUT_MESH_RESOLUTION = 1024 MIN_CUTOUT_MESH_RESOLUTION = 4 MAX_CUTOUT_MESH_RESOLUTION = 1024 +DEFAULT_MAX_FRAME_RESOLUTION = 2048 +MIN_FRAME_RESOLUTION = 64 +MAX_FRAME_RESOLUTION = 8192 ADDON_PACKAGE = __package__ DEFAULT_STORAGE_ROOT = Path.home() / ".anyimage" @@ -73,6 +76,18 @@ def configured_max_cutout_mesh_resolution(context=None): ) +def configured_max_frame_resolution(context=None): + preferences = addon_preferences(context) + value = int( + getattr( + preferences, + "max_frame_resolution", + DEFAULT_MAX_FRAME_RESOLUTION, + ) + ) + return min(max(value, MIN_FRAME_RESOLUTION), MAX_FRAME_RESOLUTION) + + def configured_max_ai_input_size(context=None): preferences = addon_preferences(context) value = int(getattr(preferences, "max_ai_input_size", DEFAULT_MAX_AI_INPUT_SIZE)) @@ -139,6 +154,15 @@ class AnyImagePreferences(bpy.types.AddonPreferences): step=64, subtype="PIXEL", ) + max_frame_resolution: bpy.props.IntProperty( + name="Maximum Frame Resolution", + description="Maximum output resolution along the longest Frame edge", + default=DEFAULT_MAX_FRAME_RESOLUTION, + min=MIN_FRAME_RESOLUTION, + max=MAX_FRAME_RESOLUTION, + step=128, + subtype="PIXEL", + ) balloon_profile_algorithm: bpy.props.EnumProperty( name="Balloon Profile", description="Algorithm used to generate o_balloon for every Cutout shape", @@ -220,6 +244,9 @@ class AnyImagePreferences(bpy.types.AddonPreferences): ) tools = self._draw_box(layout, "Tools") + tools.label(text="Frame Tool") + tools.prop(self, "max_frame_resolution") + tools.separator(type="LINE") tools.label(text="Depth & Normal") tools.prop(self, "geometry_resolution_level") tools.separator(type="LINE") diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index 2d8c6bb..68493d5 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -192,27 +192,46 @@ def upscale_model_label(model_key): return model.label if model is not None else model_key -def refine_selection_property(): - return bpy.props.BoolProperty( - name="Refine Selection", - description="Use BEN2 to refine the selection to foreground content", - default=False, +def mask_gesture_property(): + return bpy.props.EnumProperty( + name="Gesture", + description="Shape used to edit image Alpha", + items=( + ("LASSO", "Lasso", "Draw a closed freehand area"), + ("BRUSH", "Brush", "Paint with a circular brush"), + ("POLYLINE", "Polyline", "Click points to define a closed area"), + ), + default="LASSO", ) -def crop_invert_property(): - return bpy.props.BoolProperty( - name="Invert", - description="Keep the area outside the crop selection", - default=False, +def mask_mode_property(): + return bpy.props.EnumProperty( + name="Mode", + description="How the gesture changes image Alpha", + items=( + ("SET", "Set", "Keep current Alpha inside the gesture", "SELECT_SET", 0), + ("ADD", "Add", "Extend Alpha with the gesture", "SELECT_EXTEND", 1), + ( + "SUBTRACT", + "Subtract", + "Remove Alpha inside the gesture", + "SELECT_SUBTRACT", + 2, + ), + ), + default="SUBTRACT", ) -def keep_original_property(): - return bpy.props.BoolProperty( - name="Keep Original", - description="Create a crop result while preserving the source Image Empty", - default=False, +def mask_radius_property(): + return bpy.props.IntProperty( + name="Radius", + description="Brush radius in Viewport pixels", + default=25, + min=1, + soft_max=500, + subtype="PIXEL", ) @@ -256,17 +275,10 @@ def cutout_mesh_detail_property(): class AnyImageSettings(bpy.types.PropertyGroup): cutout_mesh_detail: cutout_mesh_detail_property() cutout_generate_normal: cutout_generate_normal_property() - cutout_refine_selection: refine_selection_property() cutout_inward_axis: cutout_inward_axis_property() - crop_box_refine_selection: refine_selection_property() - crop_box_invert: crop_invert_property() - crop_box_keep_original: keep_original_property() - crop_lasso_refine_selection: refine_selection_property() - crop_lasso_invert: crop_invert_property() - crop_lasso_keep_original: keep_original_property() - crop_polyline_invert: crop_invert_property() - crop_polyline_keep_original: keep_original_property() - crop_perspective_keep_original: keep_original_property() + mask_gesture: mask_gesture_property() + mask_mode: mask_mode_property() + mask_radius: mask_radius_property() input_path: bpy.props.StringProperty( name="Image or Video", description="A single image or video file used for reconstruction", diff --git a/src/anyimage/server/app.py b/src/anyimage/server/app.py index 84ebf4a..b41eb65 100644 --- a/src/anyimage/server/app.py +++ b/src/anyimage/server/app.py @@ -16,7 +16,6 @@ from .jobs.debug_predictions import ( ) from .jobs.cutout import run as _generate_cutout_artifacts from .jobs.depth_plane import run as _generate_depth_plane_geometry -from .jobs.refine_image_selection import run as _refine_image_selection from .jobs.remove_background import run as _remove_background from .jobs.upscale import run as _upscale_image from .model_manager import ModelManager @@ -41,11 +40,6 @@ def download_required_models(context, parameters): return manager.download_missing(context, parameters["models"]) -@server.job("refine-image-selection") -def refine_image_selection(context, parameters): - return _refine_image_selection(context, parameters) - - @server.job("remove-background") def remove_background(context, parameters): return _remove_background(context, parameters) diff --git a/src/anyimage/server/geometry/normal_texture.py b/src/anyimage/server/geometry/normal_texture.py index 053dca1..30b87f8 100644 --- a/src/anyimage/server/geometry/normal_texture.py +++ b/src/anyimage/server/geometry/normal_texture.py @@ -40,15 +40,9 @@ def write_camera_normal_texture( if visibility.shape != normal.shape[:2]: visibility = resized_visibility(visibility, normal.shape[:2]) normal_mode = str(normal_mode).upper() - if normal_mode == "TANGENT": + if normal_mode in {"TANGENT", "OBJECT"}: converted = normal * np.asarray((1.0, -1.0, -1.0), dtype=np.float32) empty_normal = (128, 128, 255) - elif normal_mode == "OBJECT": - converted = normal[..., (2, 0, 1)] * np.asarray( - (1.0, -1.0, -1.0), - dtype=np.float32, - ) - empty_normal = (0, 128, 128) else: raise ValueError(f"Unsupported normal mode: {normal_mode}") encoded = np.clip( diff --git a/src/anyimage/server/jobs/cutout.py b/src/anyimage/server/jobs/cutout.py index a4890be..523be25 100644 --- a/src/anyimage/server/jobs/cutout.py +++ b/src/anyimage/server/jobs/cutout.py @@ -3,33 +3,20 @@ from pathlib import Path from ..geometry.prediction_artifacts import generate_moge2_artifacts -from ..selection import refine_selection def run(context, parameters): context.progress(0.05, "Preparing Cutout Selection") context.directory.mkdir(parents=True, exist_ok=True) input_path = Path(parameters["input"]) - selection_image_path = input_path result = {} - if parameters.get("refine_selection"): - context.progress(0.2, "Refining Selection") - selection_image_path = refine_selection( - context, - input_path, - parameters["refine_model"], - parameters["device"], - context.directory / "selection.png", - ) - result["selection_image"] = selection_image_path.name - depth_mode = str(parameters.get("depth_mode", "NONE")) normal_mode = str(parameters.get("normal_mode", "NONE")) if depth_mode != "NONE" or normal_mode != "NONE": artifacts = generate_moge2_artifacts( context, parameters, - selection_image_path, + input_path, depth_mode=depth_mode, normal_mode=normal_mode, ) diff --git a/src/anyimage/server/jobs/refine_image_selection.py b/src/anyimage/server/jobs/refine_image_selection.py deleted file mode 100644 index 7271cfd..0000000 --- a/src/anyimage/server/jobs/refine_image_selection.py +++ /dev/null @@ -1,24 +0,0 @@ -import time - -from ..selection import refine_selection - - -def run(context, parameters): - """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 = refine_selection( - context, - parameters["input"], - parameters["model"], - parameters["device"], - output_directory / "image.png", - ) - 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} diff --git a/src/anyimage/server/selection/__init__.py b/src/anyimage/server/selection/__init__.py deleted file mode 100644 index 26e3776..0000000 --- a/src/anyimage/server/selection/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""AI generation for rasterized image Selections.""" - -from .refine_selection import refine_selection - -__all__ = ("refine_selection",) diff --git a/src/anyimage/server/selection/refine_selection.py b/src/anyimage/server/selection/refine_selection.py deleted file mode 100644 index cfcc0dd..0000000 --- a/src/anyimage/server/selection/refine_selection.py +++ /dev/null @@ -1,23 +0,0 @@ -from ..models.ben2 import infer_foreground - - -def refine_selection( - context, - input_path, - model, - device, - output_path, -): - """Refine foreground Alpha within one rectangular Selection context.""" - model_manager = context.resource("model_manager") - foreground, _load_ms, _inference_ms = infer_foreground( - input_path, - model_manager.directory(model), - device, - model_cache=model_manager, - cancel_check=context.check_cancelled, - preserve_input_alpha=True, - ) - context.check_cancelled() - foreground.convert("RGBA").save(output_path, format="PNG", compress_level=1) - return output_path diff --git a/tests/test_alpha_presentation.py b/tests/test_alpha_presentation.py new file mode 100644 index 0000000..649f579 --- /dev/null +++ b/tests/test_alpha_presentation.py @@ -0,0 +1,229 @@ +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +import bpy +import numpy as np +import pytest +from PIL import Image + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) + +from anyimage.common import image as images +from anyimage.operators.convert_to_plane.object import create_color_image +from anyimage.operators.cutout_tool.object import create_cropped_color_image + + +@pytest.mark.parametrize("floating", [False, True]) +def test_owned_color_images_preserve_source_and_use_premul(floating): + original = bpy.data.images.new("Alpha source", width=2, height=2, alpha=True, float_buffer=floating) + rgba = np.tile(np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (2, 2, 1)) + original.pixels.foreach_set(rgba.ravel()) + source = SimpleNamespace(data=original) + expected = images.image_rgba(original) + copied = create_color_image(source) + cropped = create_cropped_color_image(source, (0, 0, 2, 2), expected) + edited = images.create_image_edit_result(original, rgba.ravel(), (2, 2)) + try: + assert original.alpha_mode == "STRAIGHT" + np.testing.assert_allclose(images.image_rgba(original), expected) + for image in (copied, cropped, edited): + assert image.alpha_mode == "PREMUL" + np.testing.assert_allclose(images.image_rgba(image), expected, atol=1 / 255) + finally: + for image in (copied, cropped, edited, original): + bpy.data.images.remove(image) + + +@pytest.mark.parametrize("loader", [images.load_image_edit_result, images.load_color_result_image]) +def test_job_color_loader_defaults_to_premul(tmp_path, loader): + rgba = np.array([[[51, 102, 204, 64]]], dtype=np.uint8) + Image.fromarray(rgba).save(tmp_path / "image.0001.png") + original = bpy.data.images.new("Job source", width=1, height=1, alpha=True) + result = loader(SimpleNamespace(data=original), tmp_path) + try: + assert result.alpha_mode == "PREMUL" + assert result.packed_file is not None + assert original.alpha_mode == "STRAIGHT" + finally: + bpy.data.images.remove(result) + bpy.data.images.remove(original) + + +def test_failed_empty_replacement_restores_blending_and_timing(): + original = SimpleNamespace(name="Original", use_fake_user=False, users=1) + timing = SimpleNamespace(frame_start=3, frame_offset=2, frame_duration=7, use_cyclic=True) + source = SimpleNamespace(data=original, use_empty_image_alpha=False, image_user=timing) + result = SimpleNamespace(source="SEQUENCE") + + def fail_configuration(*args, **kwargs): + timing.frame_start = 99 + raise RuntimeError("Failed configuration") + + with patch.object(images, "video_total_frames", return_value=3), patch.object( + images, "configure_image_user", side_effect=fail_configuration + ): + with pytest.raises(RuntimeError, match="Failed configuration"): + images.replace_empty_image(source, result) + assert source.data is original + assert not source.use_empty_image_alpha + assert not original.use_fake_user + assert images.image_user_settings(source) == (3, 2, 7, True) + + +def test_business_pixels_ignore_premul_read_interpretation(): + original = bpy.data.images.new("Premul read source", width=1, height=1, alpha=True) + expected = np.array([0.4, 0.25, 0.1, 0.5], dtype=np.float32) + result = images.create_image_edit_result(original, expected, (1, 1)) + try: + result.reload() + interpreted = np.empty(4, dtype=np.float32) + result.pixels.foreach_get(interpreted) + assert interpreted[0] > expected[0] + np.testing.assert_allclose(images.image_pixels(result), expected, atol=1 / 255) + assert result.alpha_mode == "PREMUL" + finally: + bpy.data.images.remove(result) + bpy.data.images.remove(original) + + +@pytest.mark.parametrize("dirty", [False, True]) +def test_business_read_preserves_shared_packed_source(dirty): + original = bpy.data.images.new("Read integrity", width=1, height=1, alpha=True) + result = images.create_image_edit_result(original, np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (1, 1)) + owner = bpy.data.objects.new("Read owner", None) + owner.empty_display_type = "IMAGE" + owner.data = result + try: + result.reload() + if dirty: + result.pixels.foreach_set([0.9, 0.1, 0.3, 0.5]) + result.update() + before = np.array(result.pixels[:]) + packed = bytes(result.packed_file.data) + state = (result.alpha_mode, result.is_dirty, result.users) + count = len(bpy.data.images) + for _ in range(3): + pixels = images.image_pixels(result) + np.testing.assert_allclose(pixels, before if dirty else [0.2, 0.4, 0.8, 0.25], atol=1 / 255) + np.testing.assert_array_equal(result.pixels[:], before) + assert (result.alpha_mode, result.is_dirty, result.users) == state + assert bytes(result.packed_file.data) == packed + assert owner.data == result + assert len(bpy.data.images) == count + finally: + bpy.data.objects.remove(owner) + bpy.data.images.remove(result) + bpy.data.images.remove(original) + + +@pytest.mark.parametrize("floating", [False, True]) +def test_generated_premul_reads_current_pixels(floating): + source = bpy.data.images.new("Generated read", width=1, height=1, alpha=True, float_buffer=floating) + try: + source.alpha_mode = "PREMUL" + source.pixels.foreach_set([0.2, 0.4, 0.8, 0.25]) + before = np.array(source.pixels[:]) + np.testing.assert_array_equal(images.image_pixels(source), before) + np.testing.assert_array_equal(source.pixels[:], before) + assert source.alpha_mode == "PREMUL" + finally: + bpy.data.images.remove(source) + + +@pytest.mark.parametrize("packed", [False, True]) +@pytest.mark.parametrize("floating", [False, True]) +@pytest.mark.parametrize("dirty", [False, True]) +def test_file_read_preserves_byte_and_native_float_cache(tmp_path, packed, floating, dirty): + path = tmp_path / ("source.exr" if floating else "source.png") + if floating: + generated = bpy.data.images.new("EXR source", width=1, height=1, alpha=True, float_buffer=True) + try: + generated.pixels.foreach_set([0.2, 0.4, 0.8, 0.25]) + generated.file_format = "OPEN_EXR" + generated.filepath_raw = str(path) + generated.save() + finally: + bpy.data.images.remove(generated) + else: + Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(path) + source = bpy.data.images.load(str(path), check_existing=False) + try: + if packed: + source.pack() + source.alpha_mode = "PREMUL" + if dirty: + source.pixels.foreach_set([0.9, 0.1, 0.3, 0.5]) + source.update() + before = np.array(source.pixels[:]) + assert source.is_dirty == dirty + assert source.is_float == floating + expected = before if floating or dirty else np.array([51, 102, 204, 64]) / 255 + count = len(bpy.data.images) + np.testing.assert_allclose(images.image_pixels(source), expected, atol=1e-7) + np.testing.assert_array_equal(source.pixels[:], before) + assert source.is_dirty == dirty and source.alpha_mode == "PREMUL" + assert len(bpy.data.images) == count + finally: + bpy.data.images.remove(source) + + +def test_isolated_decode_failure_releases_copy(): + class FailedReader: + @property + def alpha_mode(self): + return "PREMUL" + + @alpha_mode.setter + def alpha_mode(self, value): + raise RuntimeError("Decode failed") + + reader = FailedReader() + source = SimpleNamespace(alpha_mode="PREMUL", is_dirty=False, is_float=False, + source="FILE", copy=lambda: reader) + with patch.object(images, "bpy", SimpleNamespace(data=SimpleNamespace(images=SimpleNamespace(remove=lambda value: removed.append(value))))): + removed = [] + with pytest.raises(RuntimeError, match="Decode failed"): + images.image_pixels(source) + assert removed == [reader] + assert source.alpha_mode == "PREMUL" + + +def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path): + from anyimage.common.selection import SelectionMask + from anyimage.operators.image_edit_tool.mask import apply_alpha_mask + from anyimage.operators.image_edit_tool import frame + + source = bpy.data.images.new("Roundtrip source", width=2, height=2, alpha=True) + rgba = np.tile(np.array([0.2, 0.4, 0.8, 1], dtype=np.float32), (2, 2, 1)) + full = SelectionMask(np.ones((2, 2)), (0, 0, 2, 2)) + result = images.create_image_edit_result(source, apply_alpha_mask(rgba, full, "SUBTRACT"), (2, 2)) + result.use_fake_user = True + name = result.name + bpy.data.images.remove(source) + path = str(tmp_path / "mask.blend") + bpy.ops.wm.save_as_mainfile(filepath=path) + bpy.ops.wm.open_mainfile(filepath=path) + result = bpy.data.images[name] + try: + restored = images.image_rgba(result) + np.testing.assert_allclose(restored[..., :3], rgba[..., :3], atol=1 / 255) + np.testing.assert_array_equal(restored[..., 3], 0) + added = images.create_image_edit_result(result, apply_alpha_mask(restored, full, "ADD"), (2, 2)) + bpy.data.images.remove(result) + result = added + for _ in range(2): + source = frame.frame_source_projection(np.eye(4), np.eye(4), np.eye(4), (-1, 1, -1, 1), (2, 2), perspective=False) + rgba_read = images.image_rgba(result) + source.update(rgba=rgba_read, pixels=images.premultiplied_rgba(images.image_pixels(result), (2, 2)), + image_size=(2, 2), active=True, name=result.name) + pixels = frame.composite_frame_pixels((source,), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2)) + next_result = images.create_image_edit_result(result, pixels, (2, 2)) + bpy.data.images.remove(result) + result = next_result + result.reload() + assert result.alpha_mode == "PREMUL" and result.packed_file is not None + np.testing.assert_allclose(images.image_rgba(result), rgba, atol=1 / 255) + finally: + bpy.data.images.remove(result) diff --git a/tests/test_blender_addon.py b/tests/test_blender_addon.py index 5e54c59..963b8e9 100644 --- a/tests/test_blender_addon.py +++ b/tests/test_blender_addon.py @@ -113,16 +113,14 @@ class BlenderAddonTest(unittest.TestCase): cls.upscale = importlib.import_module("anyimage.operators.upscale") cls.image_interaction = importlib.import_module("anyimage.common.viewport") cls.edit_mask = importlib.import_module( - "anyimage.operators.crop_tool.selection" + "anyimage.operators.image_edit_tool.mask" ) cls.image_selection = importlib.import_module("anyimage.common.selection") - cls.image_box = importlib.import_module("anyimage.operators.crop_tool.box") - cls.image_lasso = importlib.import_module("anyimage.operators.crop_tool.lasso") - cls.image_polyline = importlib.import_module( - "anyimage.operators.crop_tool.polyline" + cls.image_frame = importlib.import_module( + "anyimage.operators.image_edit_tool.frame" ) cls.image_perspective = importlib.import_module( - "anyimage.operators.crop_tool.perspective" + "anyimage.operators.image_edit_tool.rectify" ) cls.cutout_geometry = importlib.import_module( "anyimage.operators.cutout_tool.geometry" @@ -783,6 +781,16 @@ class BlenderAddonTest(unittest.TestCase): 4, ) + def test_frame_resolution_defaults_and_clamps_independently(self): + preference_module = self.anyimage.preferences + preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences + + self.assertEqual(preference_module.configured_max_frame_resolution(), 2048) + preferences.max_frame_resolution = 10000 + self.assertEqual(preference_module.configured_max_frame_resolution(), 8192) + preferences.max_frame_resolution = 1 + self.assertEqual(preference_module.configured_max_frame_resolution(), 64) + def test_preferences_draws_upscale_model_download(self): preference_module = importlib.import_module("anyimage.preferences") downloads = [] @@ -874,6 +882,7 @@ class BlenderAddonTest(unittest.TestCase): "Server", "AI", "Tools", + "Frame Tool", "Depth & Normal", "Cutout Tool", } @@ -882,6 +891,7 @@ class BlenderAddonTest(unittest.TestCase): "Server", "AI", "Tools", + "Frame Tool", "Depth & Normal", "Cutout Tool", ], @@ -891,6 +901,7 @@ class BlenderAddonTest(unittest.TestCase): self.assertIn("background_model", properties) self.assertIn("upscale_model", properties) self.assertIn("max_ai_input_size", properties) + self.assertIn("max_frame_resolution", properties) self.assertIn("max_cutout_mesh_resolution", properties) self.assertIn("balloon_profile_algorithm", properties) balloon_profile = preference_module.AnyImagePreferences.__annotations__[ @@ -993,7 +1004,10 @@ class BlenderAddonTest(unittest.TestCase): ): preferences.draw(None) - self.assertEqual(labels, ["Server", "Tools", "Depth & Normal", "Cutout Tool"]) + self.assertEqual( + labels, + ["Server", "Tools", "Frame Tool", "Depth & Normal", "Cutout Tool"], + ) self.assertEqual( operators, ["anyimage.setup_ai_environment", "anyimage.open_server_log"], @@ -1006,7 +1020,6 @@ class BlenderAddonTest(unittest.TestCase): self.assertIn(self.cutout_main.CutoutSelectionToShape, self.anyimage.CLASSES) self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane") self.assertNotIn("shadeless", operator.__annotations__) - self.assertNotIn("keep_original", operator.__annotations__) self.assertNotIn("invoke", operator.__dict__) self.assertNotIn("draw", operator.__dict__) self.assertIn("REGISTER", operator.bl_options) @@ -1051,7 +1064,6 @@ class BlenderAddonTest(unittest.TestCase): thickness=0.1, inflation=0.1, algorithm="POISSON", - keep_original=False, ) source = SimpleNamespace(data=SimpleNamespace(name="Sequence")) context = SimpleNamespace(object=source, scene=object()) @@ -1236,15 +1248,11 @@ class BlenderAddonTest(unittest.TestCase): operator = SimpleNamespace( source_object_name="Source", selection_path_json=json.dumps( - { - "points": [[1, 1], [9, 1], [9, 9], [1, 9]], - "invert": False, - } + {"points": [[1, 1], [9, 1], [9, 9], [1, 9]]} ), shape=shape, point_spacing=32, generate_normal=False, - refine_selection=False, report=lambda *_args: None, _needs_generation=lambda: False, ) @@ -1325,15 +1333,11 @@ class BlenderAddonTest(unittest.TestCase): operator = self.cutout_main.CutoutSelectionToShape() operator.source_object_name = "Source" operator.selection_path_json = json.dumps( - { - "points": [[1, 1], [7, 1], [7, 3], [1, 3]], - "invert": False, - } + {"points": [[1, 1], [7, 1], [7, 3], [1, 3]]} ) operator.shape = "DEPTH_SURFACE" operator.point_spacing = 32 operator.generate_normal = False - operator.refine_selection = False operator.report = lambda *_args: None with ( @@ -1407,15 +1411,11 @@ class BlenderAddonTest(unittest.TestCase): operator = SimpleNamespace( source_object_name="Source", selection_path_json=json.dumps( - { - "points": [[1, 1], [7, 1], [7, 3], [1, 3]], - "invert": False, - } + {"points": [[1, 1], [7, 1], [7, 3], [1, 3]]} ), shape="SURFACE", point_spacing=32, generate_normal=False, - refine_selection=False, report=lambda level, message: reports.append((level, message)), ) @@ -1462,7 +1462,6 @@ class BlenderAddonTest(unittest.TestCase): operator.resolution_level = 5 operator.max_ai_input_size = 2048 operator.generate_normal = True - operator.refine_selection = True context = SimpleNamespace( scene=SimpleNamespace(anyimage_settings=SimpleNamespace()) ) @@ -1483,9 +1482,8 @@ class BlenderAddonTest(unittest.TestCase): self.assertEqual( [call.args[0] for call in require_model.call_args_list], - [self.anyimage.properties.BEN2_MODEL_KEY, operator.model], + [operator.model], ) - self.assertTrue(parameters["refine_selection"]) self.assertEqual(parameters["depth_mode"], "VECTOR") self.assertEqual(parameters["normal_mode"], "OBJECT") self.assertNotIn("polygon", parameters) @@ -1782,7 +1780,7 @@ class BlenderAddonTest(unittest.TestCase): 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): + def test_image_edit_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, @@ -1880,7 +1878,7 @@ class BlenderAddonTest(unittest.TestCase): self.assertTrue(selection[3, 5]) - triangles = self.image_interaction.scanline_fill_triangles(path) + triangles = self.image_interaction.preview_fill_geometry(path)[0] preview_area = sum( abs( self.image_interaction._triangle_cross( @@ -1924,51 +1922,82 @@ class BlenderAddonTest(unittest.TestCase): ) def test_image_edit_tools_and_operators_are_registered(self): - operator = self.edit_mask.CropImageSelection + operator = self.edit_mask.EditImageAlpha + frame_operator = self.image_frame.FrameImages self.assertIn(operator, self.anyimage.CLASSES) - self.assertIn(self.edit_mask.RefineCropSelection, self.anyimage.CLASSES) - self.assertIn(self.edit_mask.ActivateCropTool, self.anyimage.CLASSES) - self.assertNotIn("selection_path_json", operator.__annotations__) - tool_cases = ( - (self.image_box.CropBoxTool, "BOX", "ops.sculpt.border_mask"), - (self.image_lasso.CropLassoTool, "LASSO", "ops.sculpt.lasso_mask"), - ( - self.image_polyline.CropPolylineTool, - "POLYLINE", - "ops.sculpt.polyline_mask", - ), + self.assertIn(frame_operator, self.anyimage.CLASSES) + self.assertIn( + self.image_frame.ActivateImageEditTool, + self.anyimage.CLASSES, + ) + self.assertIn( + self.image_perspective.RectifyImagePerspective, + self.anyimage.CLASSES, + ) + self.assertNotIn("selection_path_json", operator.__annotations__) + mask_tool = self.edit_mask.MaskTool + self.assertNotIn(mask_tool, self.anyimage.CLASSES) + self.assertEqual(mask_tool.bl_operator, operator.bl_idname) + self.assertEqual(operator.bl_options, {"UNDO"}) + self.assertNotIn("draw", operator.__dict__) + self.assertEqual(mask_tool.bl_icon, "ops.sculpt.lasso_mask") + self.assertNotIn("bl_options", mask_tool.__dict__) + self.assertEqual(mask_tool.bl_cursor, "PAINT_CROSS") + self.assertEqual( + mask_tool.bl_keymap[0][1]["value"], + "PRESS", + ) + self.assertEqual( + mask_tool.bl_keymap[1][1], + {"type": "LEFTMOUSE", "value": "CLICK", "shift": True}, + ) + frame_tool = self.image_frame.FrameTool + self.assertNotIn(frame_tool, self.anyimage.CLASSES) + self.assertEqual(frame_tool.bl_operator, frame_operator.bl_idname) + self.assertEqual(frame_tool.bl_icon, "ops.sculpt.border_mask") + self.assertEqual(frame_operator.bl_options, {"UNDO"}) + self.assertNotIn("bl_options", frame_tool.__dict__) + self.assertEqual(frame_tool.bl_keymap[0][1]["value"], "CLICK_DRAG") + self.assertIsNone(frame_tool.bl_keymap[0][2]) + self.assertEqual( + [entry[0] for entry in frame_tool.bl_keymap[1:]], + ["view3d.select", "view3d.select"], + ) + self.assertEqual( + frame_tool.bl_keymap[1][2], + {"properties": [("deselect_all", True)]}, + ) + self.assertEqual( + frame_tool.bl_keymap[2][2], + {"properties": [("toggle", True)]}, + ) + self.assertNotIn("draw_settings", frame_tool.__dict__) + self.assertIn("draw_settings", mask_tool.__dict__) + settings_value = SimpleNamespace( + mask_gesture="LASSO", + mask_mode="SET", + mask_radius=25, + ) + context = SimpleNamespace( + scene=SimpleNamespace(anyimage_settings=settings_value) ) - for tool, gesture, icon in tool_cases: - self.assertNotIn(tool, self.anyimage.CLASSES) - self.assertEqual(tool.bl_operator, operator.bl_idname) - self.assertEqual(tool.bl_icon, icon) - self.assertEqual(tool.bl_keymap[0][2]["properties"], [("gesture", gesture)]) - self.assertNotIn("bl_cursor", tool.__dict__) - self.assertIn("draw_settings", self.image_box.CropBoxTool.__dict__) - self.assertIn("draw_settings", self.image_lasso.CropLassoTool.__dict__) - context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=object())) drawn_settings = [] layout = SimpleNamespace( - prop=lambda _settings, name: drawn_settings.append(name) + prop=lambda _settings, name, **_options: drawn_settings.append(name) ) - with patch.object(self.image_box, "draw_crop_settings") as draw: - self.image_box.CropBoxTool.draw_settings(context, layout, None) - draw.assert_called_once_with( - context, - layout, - refine_selection="crop_box_refine_selection", - invert="crop_box_invert", - keep_original="crop_box_keep_original", - ) - with patch.object(self.image_lasso, "draw_crop_settings") as draw: - self.image_lasso.CropLassoTool.draw_settings(context, layout, None) - draw.assert_called_once_with( - context, - layout, - refine_selection="crop_lasso_refine_selection", - invert="crop_lasso_invert", - keep_original="crop_lasso_keep_original", - ) + mask_tool.draw_settings(context, layout, None) + self.assertEqual(drawn_settings, ["mask_gesture", "mask_mode"]) + settings_value.mask_gesture = "BRUSH" + drawn_settings.clear() + mask_tool.draw_settings(context, layout, None) + self.assertEqual( + drawn_settings, + ["mask_gesture", "mask_mode", "mask_radius"], + ) + settings_value.mask_gesture = "POLYLINE" + drawn_settings.clear() + mask_tool.draw_settings(context, layout, None) + self.assertEqual(drawn_settings, ["mask_gesture", "mask_mode"]) cutout_tool = importlib.import_module( "anyimage.operators.cutout_tool" ).CutoutTool @@ -1976,6 +2005,20 @@ class BlenderAddonTest(unittest.TestCase): cutout_tool.bl_icon, "ops.mesh.primitive_sphere_add_gizmo", ) + self.assertNotIn("bl_options", cutout_tool.__dict__) + self.assertEqual(cutout_tool.bl_keymap[1][1]["value"], "CLICK_DRAG") + self.assertEqual( + [entry[0] for entry in cutout_tool.bl_keymap[2:]], + ["view3d.select", "view3d.select"], + ) + self.assertEqual( + cutout_tool.bl_keymap[2][2], + {"properties": [("deselect_all", False)]}, + ) + self.assertEqual( + cutout_tool.bl_keymap[3][2], + {"properties": [("toggle", True)]}, + ) cutout_module = sys.modules[cutout_tool.__module__] self.assertNotIn( "selection_path_json", @@ -2002,30 +2045,44 @@ class BlenderAddonTest(unittest.TestCase): ): cutout_tool.draw_settings(context, layout, None) setup.assert_called_once_with(layout) - self.assertEqual(draw.call_count, 2) + self.assertEqual(draw.call_count, 1) self.assertEqual( drawn_settings, [ - "cutout_refine_selection", "cutout_generate_normal", "cutout_mesh_detail", "cutout_inward_axis", ], ) - self.assertIn("draw_settings", self.image_polyline.CropPolylineTool.__dict__) - self.assertEqual(operator.__annotations__["gesture"]["default"], "BOX") - self.assertIn("invert", operator.__annotations__) - self.assertNotIn("continuous", operator.__annotations__) - self.assertNotIn("padding", operator.__annotations__) + self.assertEqual(operator.__annotations__["gesture"]["default"], "LASSO") + self.assertEqual( + [item[:2] for item in operator.__annotations__["gesture"]["items"]], + [ + ("LASSO", "Lasso"), + ("BRUSH", "Brush"), + ("POLYLINE", "Polyline"), + ], + ) + self.assertEqual(operator.__annotations__["mode"]["default"], "SUBTRACT") + self.assertEqual(operator.__annotations__["radius"]["default"], 25) settings = self.anyimage.properties.AnyImageSettings.__annotations__ - for name in ( - "cutout_refine_selection", - "crop_box_refine_selection", - "crop_lasso_refine_selection", - ): - self.assertFalse(settings[name]["default"]) - self.assertNotIn("refine_selection", settings) - self.assertNotIn("image_edit_refine_selection", settings) + self.assertEqual(settings["mask_gesture"]["default"], "LASSO") + self.assertEqual(settings["mask_mode"]["default"], "SUBTRACT") + self.assertEqual(settings["mask_radius"]["default"], 25) + self.assertEqual( + {name for name in settings if name.startswith("mask_")}, + {"mask_gesture", "mask_mode", "mask_radius"}, + ) + self.assertEqual( + set(operator.__annotations__), + { + "source_object_name", + "source_matrix_data", + "gesture", + "mode", + "radius", + }, + ) self.assertEqual(settings["cutout_mesh_detail"]["default"], "LOW") self.assertEqual( [item[:2] for item in settings["cutout_mesh_detail"]["items"]], @@ -2038,19 +2095,7 @@ class BlenderAddonTest(unittest.TestCase): ) self.assertFalse(settings["cutout_generate_normal"]["default"]) self.assertEqual(settings["cutout_inward_axis"]["default"], "NEGATIVE_Z") - for name in ( - "crop_box_invert", - "crop_lasso_invert", - "crop_polyline_invert", - "crop_box_keep_original", - "crop_lasso_keep_original", - "crop_polyline_keep_original", - "crop_perspective_keep_original", - ): - self.assertFalse(settings[name]["default"]) - self.assertEqual( - settings["cutout_refine_selection"]["name"], "Refine Selection" - ) + self.assertFalse(any("crop" in name for name in settings)) self.assertEqual( settings["cutout_mesh_detail"]["name"], "Mesh Detail" ) @@ -2062,13 +2107,13 @@ class BlenderAddonTest(unittest.TestCase): self.assertNotIn("image_extract_padding", settings) self.assertNotIn("image_extract_remove_background", settings) - def test_image_area_tool_settings_gate_ai_options(self): + def test_cutout_tool_settings_gate_normal_generation(self): class Row: def __init__(self, events): self.events = events self.enabled = True - def prop(self, _owner, name): + def prop(self, _owner, name, **_options): self.events.append(("prop", name, self.enabled)) class Layout: @@ -2081,7 +2126,7 @@ class BlenderAddonTest(unittest.TestCase): def row(self): return Row(self.events) - def prop(self, _owner, name): + def prop(self, _owner, name, **_options): self.events.append(("prop", name, True)) context = SimpleNamespace( @@ -2094,30 +2139,6 @@ class BlenderAddonTest(unittest.TestCase): } setup = ("operator", "anyimage.setup_ai_environment", True) with patch.object(self.ai, "ai_status", return_value=missing): - for tool, setting_name in ( - (self.image_box.CropBoxTool, "crop_box_refine_selection"), - (self.image_lasso.CropLassoTool, "crop_lasso_refine_selection"), - ): - layout = Layout() - tool.draw_settings(context, layout, None) - self.assertEqual( - layout.events, - [ - setup, - ("prop", setting_name, False), - ( - "prop", - setting_name.replace("refine_selection", "invert"), - True, - ), - ( - "prop", - setting_name.replace("refine_selection", "keep_original"), - True, - ), - ], - ) - cutout_tool = importlib.import_module( "anyimage.operators.cutout_tool" ).CutoutTool @@ -2127,31 +2148,12 @@ class BlenderAddonTest(unittest.TestCase): layout.events, [ setup, - ("prop", "cutout_refine_selection", False), ("prop", "cutout_generate_normal", False), ("prop", "cutout_mesh_detail", True), ("prop", "cutout_inward_axis", True), ], ) - layout = Layout() - operator = SimpleNamespace( - gesture="BOX", - refine_selection_settings={"BOX": "crop_box_refine_selection"}, - invert_settings={"BOX": "crop_box_invert"}, - keep_original_settings={"BOX": "crop_box_keep_original"}, - ) - self.image_interaction.draw_crop_operator_settings(layout, operator) - self.assertEqual( - layout.events, - [ - setup, - ("prop", "refine_selection", False), - ("prop", "invert", True), - ("prop", "keep_original", True), - ], - ) - ready = {"environment_ready": True, "missing_models": (), "ready": True} with patch.object(self.ai, "ai_status", return_value=ready): layout = Layout() @@ -2159,75 +2161,27 @@ class BlenderAddonTest(unittest.TestCase): self.assertNotIn(setup, layout.events) self.assertTrue(all(event[2] for event in layout.events)) - def test_refine_selection_is_independent_for_each_area_tool(self): + def test_mask_freezes_scene_settings_on_invoke(self): settings = SimpleNamespace( - cutout_refine_selection=True, - crop_box_refine_selection=False, - crop_lasso_refine_selection=True, - crop_box_invert=False, - crop_lasso_invert=True, - crop_polyline_invert=False, - crop_box_keep_original=True, - crop_lasso_keep_original=False, - crop_polyline_keep_original=True, + mask_gesture="BRUSH", + mask_mode="ADD", + mask_radius=64, ) context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings)) - with patch.object(self.image_interaction, "ai_ready", return_value=True): - box = SimpleNamespace( - gesture="BOX", - refine_selection=True, - invert=True, - keep_original=False, - refine_selection_settings={"BOX": "crop_box_refine_selection"}, - invert_settings={"BOX": "crop_box_invert"}, - keep_original_settings={"BOX": "crop_box_keep_original"}, - ) - self.image_interaction.apply_crop_settings(box, context) - self.assertFalse(box.refine_selection) - self.assertFalse(box.invert) - self.assertTrue(box.keep_original) + operator = SimpleNamespace(gesture="LASSO", mode="SET", radius=25) - lasso = SimpleNamespace( - gesture="LASSO", - refine_selection=False, - invert=False, - keep_original=True, - refine_selection_settings={"LASSO": "crop_lasso_refine_selection"}, - invert_settings={"LASSO": "crop_lasso_invert"}, - keep_original_settings={"LASSO": "crop_lasso_keep_original"}, - ) - self.image_interaction.apply_crop_settings(lasso, context) - self.assertTrue(lasso.refine_selection) - self.assertTrue(lasso.invert) - self.assertFalse(lasso.keep_original) + self.edit_mask.EditImageAlpha._read_gesture_settings(operator, context) - polyline = SimpleNamespace( - gesture="POLYLINE", - refine_selection=True, - invert=True, - keep_original=False, - invert_settings={"POLYLINE": "crop_polyline_invert"}, - keep_original_settings={"POLYLINE": "crop_polyline_keep_original"}, - ) - self.image_interaction.apply_crop_settings(polyline, context) - self.assertFalse(polyline.refine_selection) - self.assertFalse(polyline.invert) - self.assertTrue(polyline.keep_original) + self.assertEqual(operator.gesture, "BRUSH") + self.assertEqual(operator.mode, "ADD") + self.assertEqual(operator.radius, 64) - cutout = SimpleNamespace( - gesture="LASSO", - active_tool_id="anyimage.cutout_lasso", - refine_selection=False, - refine_selection_settings={"LASSO": "cutout_refine_selection"}, - ) - self.image_interaction.apply_crop_settings(cutout, context) - self.assertTrue(cutout.refine_selection) - - def test_crop_f_does_not_toggle_invert_or_appear_in_status(self): + def test_mask_has_no_letter_option_shortcut(self): operator = SimpleNamespace( - gesture="BOX", - active_tool_id="", - invert=True, + gesture="LASSO", + mode="SUBTRACT", + radius=25, + active_tool_id="anyimage.mask", ) context = SimpleNamespace( workspace=SimpleNamespace(status_text_set=Mock()) @@ -2239,19 +2193,31 @@ class BlenderAddonTest(unittest.TestCase): "active_view3d_tool_id", return_value=None, ): - result = self.image_interaction.CropSelectionGesture.modal( + result = self.image_interaction.ImageGesture.modal( operator, context, event, ) - self.image_interaction.CropSelectionGesture._set_status(operator, context) + self.edit_mask.EditImageAlpha._set_status(operator, context) self.assertEqual(result, {"PASS_THROUGH"}) - self.assertTrue(operator.invert) status = context.workspace.status_text_set.call_args.args[0] - self.assertIn("Invert On", status) + self.assertIn("Subtract", status) self.assertNotIn("F", status) + def test_mask_polyline_status_explains_point_controls(self): + context = SimpleNamespace( + workspace=SimpleNamespace(status_text_set=Mock()) + ) + operator = SimpleNamespace(gesture="POLYLINE", mode="ADD", radius=25) + + self.edit_mask.EditImageAlpha._set_status(operator, context) + + status = context.workspace.status_text_set.call_args.args[0] + self.assertIn("click start, double-click, or Enter", status) + self.assertIn("Backspace removes", status) + self.assertIn("Add", status) + def test_image_edit_first_click_selects_another_image_without_editing(self): selected = [] @@ -2301,42 +2267,67 @@ class BlenderAddonTest(unittest.TestCase): self.assertFalse(should_edit) self.assertEqual(selected, []) - def test_image_edit_first_click_on_non_image_keeps_active_image(self): - selection_calls = [] + def test_image_edit_first_click_selects_another_object_without_editing(self): active = SimpleNamespace( name="Active", type="EMPTY", empty_display_type="IMAGE", data=object(), - select_set=lambda value: selection_calls.append(value), ) clicked = SimpleNamespace(name="Mesh", type="MESH", data=object()) - view_objects = SimpleNamespace(active=active) context = SimpleNamespace( active_object=active, selected_objects=(active,), - view_layer=SimpleNamespace(objects=view_objects), ) event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20) previous_ops = getattr(self.fake_bpy, "ops", None) - previous_objects = self.fake_bpy.data.objects + select_options = [] - def select(**_kwargs): + def select(**options): + select_options.append(options) context.active_object = clicked self.fake_bpy.ops = SimpleNamespace( view3d=SimpleNamespace(select=select), - object=SimpleNamespace( - select_all=lambda **_kwargs: selection_calls.append(False) - ), ) - self.fake_bpy.data.objects = {active.name: active, clicked.name: clicked} try: source, should_edit = self.image_interaction.resolve_image_edit_click( context, event, lambda *_args: None ) finally: - self.fake_bpy.data.objects = previous_objects + if previous_ops is None: + del self.fake_bpy.ops + else: + self.fake_bpy.ops = previous_ops + + self.assertIs(source, clicked) + self.assertFalse(should_edit) + self.assertEqual(select_options[0]["deselect_all"], False) + + def test_image_edit_empty_click_keeps_active_image_and_starts_editing(self): + active = SimpleNamespace( + name="Active", + type="EMPTY", + empty_display_type="IMAGE", + data=object(), + ) + context = SimpleNamespace( + active_object=active, + selected_objects=(active,), + ) + event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20) + previous_ops = getattr(self.fake_bpy, "ops", None) + select_options = [] + self.fake_bpy.ops = SimpleNamespace( + view3d=SimpleNamespace( + select=lambda **options: select_options.append(options) + ), + ) + try: + source, should_edit = self.image_interaction.resolve_image_edit_click( + context, event, lambda *_args: None + ) + finally: if previous_ops is None: del self.fake_bpy.ops else: @@ -2344,8 +2335,140 @@ class BlenderAddonTest(unittest.TestCase): self.assertIs(source, active) self.assertTrue(should_edit) - self.assertIs(view_objects.active, active) - self.assertEqual(selection_calls, [False, True]) + self.assertIs(context.active_object, active) + self.assertEqual(select_options[0]["deselect_all"], False) + + def test_mask_and_rectify_start_after_an_empty_pick(self): + source = SimpleNamespace( + name="Source", + data=SimpleNamespace(size=(8, 6)), + matrix_world="matrix", + ) + area = SimpleNamespace(as_pointer=lambda: 1, tag_redraw=Mock()) + region = SimpleNamespace(as_pointer=lambda: 2, width=100, height=80) + window_manager = SimpleNamespace( + modal_handler_add=Mock(), + event_timer_add=Mock(return_value="timer"), + ) + context = SimpleNamespace( + area=area, + region=region, + window=object(), + window_manager=window_manager, + workspace=SimpleNamespace(status_text_set=Mock()), + scene=SimpleNamespace( + anyimage_settings=SimpleNamespace( + mask_gesture="LASSO", + mask_mode="SUBTRACT", + mask_radius=25, + ) + ), + ) + event = SimpleNamespace( + mouse_region_x=12, + mouse_region_y=18, + value="PRESS", + ) + previous_space = getattr(self.fake_bpy.types, "SpaceView3D", None) + self.fake_bpy.types.SpaceView3D = SimpleNamespace( + draw_handler_add=Mock(return_value="handler") + ) + try: + with ( + patch.object( + self.image_interaction, + "resolve_image_edit_click", + return_value=(source, True), + ), + patch.object( + self.image_interaction, + "video_total_frames", + return_value=1, + ), + patch.object( + self.image_interaction, + "serialize_matrix", + return_value="serialized", + ), + ): + mask = self.edit_mask.EditImageAlpha() + mask.report = Mock() + self.assertEqual(mask.invoke(context, event), {"RUNNING_MODAL"}) + self.assertEqual(mask._path, [(12.0, 18.0)]) + + with ( + patch.object( + self.image_perspective, + "resolve_image_edit_click", + return_value=(source, True), + ), + patch.object( + self.image_perspective, + "video_total_frames", + return_value=1, + ), + patch.object( + self.image_perspective, + "serialize_matrix", + return_value="serialized", + ), + ): + rectify = self.image_perspective.RectifyImagePerspective() + rectify.report = Mock() + self.assertEqual(rectify.invoke(context, event), {"RUNNING_MODAL"}) + self.assertEqual(rectify._points, [(12.0, 18.0)]) + finally: + if previous_space is None: + del self.fake_bpy.types.SpaceView3D + else: + self.fake_bpy.types.SpaceView3D = previous_space + + def test_mask_and_rectify_cancel_without_an_active_image(self): + context = SimpleNamespace( + scene=SimpleNamespace( + anyimage_settings=SimpleNamespace( + mask_gesture="LASSO", + mask_mode="SUBTRACT", + mask_radius=25, + ) + ) + ) + event = SimpleNamespace() + + with patch.object( + self.image_interaction, + "resolve_image_edit_click", + return_value=(None, False), + ): + mask = self.edit_mask.EditImageAlpha() + mask.report = Mock() + self.assertEqual(mask.invoke(context, event), {"CANCELLED"}) + with patch.object( + self.image_perspective, + "resolve_image_edit_click", + return_value=(None, False), + ): + rectify = self.image_perspective.RectifyImagePerspective() + rectify.report = Mock() + self.assertEqual(rectify.invoke(context, event), {"CANCELLED"}) + + def test_image_edit_source_requires_active_image_to_be_selected(self): + active = SimpleNamespace( + name="Active", + type="EMPTY", + empty_display_type="IMAGE", + data=object(), + ) + context = SimpleNamespace( + active_object=active, + selected_objects=(), + ) + + with self.assertRaisesRegex( + self.image_interaction.ImageEditWarning, + "Select an active Image Empty", + ): + self.image_interaction.active_image_edit_source(context) def test_image_perspective_rectifies_identity_pixels(self): import numpy as np @@ -2353,7 +2476,7 @@ class BlenderAddonTest(unittest.TestCase): top_down = np.arange(3 * 4 * 4, dtype=np.float32).reshape((3, 4, 4)) top_down /= float(top_down.max()) top_down[:, :, 3] = 1.0 - result = self.image_perspective.warp_perspective_pixels( + result = self.image_data.warp_projective_pixels( np.flipud(top_down).ravel(), (4, 3), ((0, 0), (4, 0), (4, 3), (0, 3)), @@ -2366,6 +2489,46 @@ class BlenderAddonTest(unittest.TestCase): atol=1e-6, ) + def test_projective_sampling_keeps_outside_pixels_transparent(self): + import numpy as np + + source = np.ones((2, 2, 4), dtype=np.float32) + result = self.image_data.warp_projective_pixels( + np.flipud(source).ravel(), + (2, 2), + ((-2, 0), (2, 0), (2, 2), (-2, 2)), + (4, 2), + ) + top_down = np.flipud(np.asarray(result).reshape((2, 4, 4))) + + np.testing.assert_array_equal(top_down[:, :2, 3], 0.0) + np.testing.assert_allclose(top_down[:, 1, :3], 1.0) + np.testing.assert_allclose(top_down[:, 2:, 3], 1.0) + + def test_projective_sampling_uses_premultiplied_alpha(self): + import numpy as np + + source = np.zeros((1, 2, 4), dtype=np.float32) + source[0, 0] = (1.0, 0.0, 0.0, 1.0) + source[0, 1] = (0.0, 1.0, 0.0, 0.0) + sampled = self.image_data.sample_rgba( + self.image_data.premultiplied_rgba( + np.flipud(source).ravel(), + (2, 1), + ), + np.asarray([[[1.0, 0.5]]]), + ) + straight = self.image_data.straight_rgba(sampled) + + np.testing.assert_allclose(straight[0, 0], (1.0, 0.0, 0.0, 0.5)) + + def test_homography_rejects_degenerate_points(self): + with self.assertRaisesRegex(ValueError, "degenerate"): + self.image_data.homography_from_points( + ((0, 0), (1, 0), (2, 0), (3, 0)), + ((0, 0), (1, 0), (1, 1), (0, 1)), + ) + def test_image_perspective_preview_uses_srgb_without_changing_pixels(self): written = [] preview = SimpleNamespace( @@ -2522,7 +2685,7 @@ class BlenderAddonTest(unittest.TestCase): def test_image_mask_preview_fills_a_self_intersecting_lasso(self): polygon = ((0, 0), (4, 4), (0, 4), (4, 0)) - triangles = self.image_interaction.preview_fill_triangles(polygon) + triangles = self.image_interaction.preview_fill_geometry(polygon)[0] area = sum( abs( self.image_interaction._triangle_cross( @@ -2551,12 +2714,15 @@ class BlenderAddonTest(unittest.TestCase): ) def test_image_perspective_limits_output_pixel_count(self): - width, height = self.image_perspective.perspective_output_size( + width, height = self.image_data.projective_output_size( ((0, 0), (10000, 0), (10000, 10000), (0, 10000)), 1.0, ) - self.assertLessEqual(width * height, self.image_perspective.MAX_OUTPUT_PIXELS) + self.assertLessEqual( + width * height, + self.image_data.MAX_PROJECTIVE_OUTPUT_PIXELS, + ) def test_image_perspective_mouse_adjusts_preview_aspect_exponentially(self): adjust = self.image_perspective.aspect_ratio_from_mouse @@ -2589,7 +2755,7 @@ class BlenderAddonTest(unittest.TestCase): self.assertAlmostEqual(ratio, 2.0) def test_image_perspective_ignores_an_already_removed_preview(self): - operator = self.image_perspective.CropImagePerspective() + operator = self.image_perspective.RectifyImagePerspective() operator._preview_image = object() def remove(*_args, **_options): @@ -2610,339 +2776,1182 @@ class BlenderAddonTest(unittest.TestCase): self.assertEqual(bounds, (8.0, 18.0, 32.0, 42.0)) def test_image_perspective_tool_registration_and_properties(self): - operator = self.image_perspective.CropImagePerspective - tool = self.image_perspective.CropPerspectiveTool + operator = self.image_perspective.RectifyImagePerspective + tool = self.image_perspective.RectifyTool settings = self.anyimage.properties.AnyImageSettings.__annotations__ self.assertIn(operator, self.anyimage.CLASSES) self.assertNotIn(tool, self.anyimage.CLASSES) self.assertEqual(tool.bl_operator, operator.bl_idname) self.assertEqual(tool.bl_icon, "ops.sculpt.line_project") + self.assertNotIn("bl_options", tool.__dict__) + self.assertEqual(tool.bl_keymap[0][1]["value"], "PRESS") + self.assertEqual( + tool.bl_keymap[1][1], + {"type": "LEFTMOUSE", "value": "CLICK", "shift": True}, + ) self.assertNotIn("bl_cursor", tool.__dict__) - self.assertIn("draw_settings", tool.__dict__) - self.assertIn("keep_original", operator.__annotations__) + self.assertNotIn("draw_settings", tool.__dict__) self.assertNotIn("image_perspective_aspect_mode", settings) self.assertNotIn("image_perspective_aspect_ratio", settings) self.assertNotIn("aspect_mode", operator.__annotations__) self.assertIn("aspect_ratio", operator.__annotations__) - self.assertNotIn("refine_selection", operator.__annotations__) self.assertNotIn("continuous", operator.__annotations__) self.assertNotIn("padding", operator.__annotations__) - def test_image_box_builds_a_rectangular_screen_path(self): + def test_frame_builds_a_canonical_rectangular_screen_path(self): self.assertEqual( - self.image_interaction.box_screen_path((2, 3), (8, 9)), - ((2, 3), (8, 3), (8, 9), (2, 9)), + self.image_interaction.rectangle_screen_path((2, 3), (8, 9)), + ((2.0, 9.0), (8.0, 9.0), (8.0, 3.0), (2.0, 3.0)), ) self.assertEqual( - self.image_interaction.box_screen_path((2, 3), (3, 9)), + self.image_interaction.rectangle_screen_path((2, 3), (3, 9)), (), ) - def test_image_box_completion_submits_the_image_selection_path(self): - source = SimpleNamespace(data=SimpleNamespace(size=(64, 32))) - finished = [] - submitted = [] - operator = SimpleNamespace( - source_object_name="Source", - source_matrix_data="matrix", - gesture="BOX", - invert=False, - _screen_path=lambda: ((1, 1), (8, 1), (8, 6), (1, 6)), - _finish=lambda context: finished.append(context), - _submit_selection_path=lambda context, selection_path: ( - submitted.append((context, selection_path)) or {"FINISHED"} + def test_frame_output_size_uses_viewport_pixels(self): + frame = ( + (-50.0, 150.0), + (150.0, 150.0), + (150.0, -50.0), + (-50.0, -50.0), + ) + + self.assertEqual( + self.image_frame.frame_output_size( + frame, + (1000, 800), + 2048, + ), + (200, 200), + ) + + def test_frame_output_size_obeys_region_and_preference_limits(self): + frame = ( + (0.0, 1000.0), + (2000.0, 1000.0), + (2000.0, 0.0), + (0.0, 0.0), + ) + + self.assertEqual( + self.image_frame.frame_output_size(frame, (1000, 800), 2048), + (1000, 500), + ) + self.assertEqual( + self.image_frame.frame_output_size(frame, (4000, 2000), 500), + (500, 250), + ) + + def test_frame_requires_positive_valid_projection_overlap(self): + import numpy as np + + source = self.image_frame.frame_source_projection( + np.identity(4), + np.identity(4), + np.identity(4), + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=False, + ) + + self.assertTrue( + self.image_frame.frame_source_overlap( + source, + ((50.0, 150.0), (150.0, 150.0), (150.0, 50.0), (50.0, 50.0)), + ) + ) + self.assertFalse( + self.image_frame.frame_source_overlap( + source, + ((100.0, 100.0), (200.0, 100.0), (200.0, 0.0), (100.0, 0.0)), + ) + ) + + def test_frame_orthographic_projection_accepts_non_positive_depth(self): + import numpy as np + + matrix_world = np.identity(4) + matrix_world[2, 3] = 1.0 + source = self.image_frame.frame_source_projection( + np.identity(4), + np.identity(4), + matrix_world, + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=False, + ) + source["image_size"] = (100, 100) + points, depth, valid, normalized = self.image_frame.frame_source_coordinates( + source, + np.asarray(((25.0, 75.0),)), + ) + + np.testing.assert_allclose(points[0], (25.0, 25.0)) + np.testing.assert_allclose(normalized[0], (0.25, 0.25)) + self.assertEqual(depth[0], -1.0) + self.assertTrue(valid[0]) + self.assertTrue( + self.image_frame.frame_source_overlap( + source, + ((0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)), + ) + ) + + def test_frame_rejects_an_orthographic_plane_parallel_to_view_rays(self): + import numpy as np + + matrix_world = np.asarray( + ( + (0.0, 0.0, 1.0, 0.0), + (0.0, 1.0, 0.0, 0.0), + (-1.0, 0.0, 0.0, 0.0), + (0.0, 0.0, 0.0, 1.0), + ) + ) + + with self.assertRaisesRegex(ValueError, "edge-on"): + self.image_frame.frame_source_projection( + np.identity(4), + np.identity(4), + matrix_world, + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=False, + ) + + def test_frame_perspective_projection_samples_front_of_crossing_image(self): + import numpy as np + + projection = np.identity(4) + projection[3] = (0.0, 0.0, -1.0, 0.0) + matrix_world = np.identity(4) + matrix_world[0, 3] = 1.0 + matrix_world[2, 0] = 1.0 + matrix_world[2, 3] = 0.2 + source = self.image_frame.frame_source_projection( + projection, + np.identity(4), + matrix_world, + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=True, + ) + source["image_size"] = (100, 100) + front_points, front_depth, front_valid, _normalized = ( + self.image_frame.frame_source_coordinates( + source, + np.asarray(((75.0, 50.0),)), + ) + ) + _rear_points, rear_depth, rear_valid, _normalized = ( + self.image_frame.frame_source_coordinates( + source, + np.asarray(((-50.0, 50.0),)), + ) + ) + overlap = self.image_frame.frame_source_overlap( + source, + ((50.0, 100.0), (100.0, 100.0), (100.0, 0.0), (50.0, 0.0)), + ) + + self.assertTrue(front_valid[0]) + self.assertGreater(front_depth[0], 0.0) + self.assertGreaterEqual(front_points[0, 0], 0.0) + self.assertFalse(rear_valid[0]) + self.assertLess(rear_depth[0], 0.0) + self.assertTrue(overlap) + + _parallel_points, _parallel_depth, parallel_valid, _normalized = ( + self.image_frame.frame_source_coordinates( + source, + np.asarray(((0.0, 50.0),)), + ) + ) + self.assertFalse(parallel_valid[0]) + + def test_frame_perspective_projection_accepts_extremely_close_image(self): + import numpy as np + + projection = np.identity(4) + projection[3] = (0.0, 0.0, -1.0, 0.0) + matrix_world = np.identity(4) + matrix_world[2, 3] = -1e-14 + source = self.image_frame.frame_source_projection( + projection, + np.identity(4), + matrix_world, + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=True, + ) + source["image_size"] = (100, 100) + _points, depth, valid, _normalized = self.image_frame.frame_source_coordinates( + source, + np.asarray(((50.0, 50.0),)), + ) + + self.assertTrue(valid[0]) + self.assertEqual(depth[0], 1e-14) + + def test_frame_overlap_does_not_depend_on_output_pixel_centers(self): + import numpy as np + + source = self.image_frame.frame_source_projection( + np.identity(4), + np.identity(4), + np.identity(4), + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=False, + ) + + self.assertTrue( + self.image_frame.frame_source_overlap( + source, + ( + (99.999, 60.0), + (100.001, 60.0), + (100.001, 40.0), + (99.999, 40.0), + ), + ) + ) + + def test_frame_uses_crossing_active_overlap_for_visible_result_depth(self): + import numpy as np + + projection = np.identity(4) + projection[3] = (0.0, 0.0, -1.0, 0.0) + matrix_world = np.identity(4) + matrix_world[0, 3] = 1.0 + matrix_world[2, 0] = 1.0 + matrix_world[2, 3] = 0.2 + source = self.image_frame.frame_source_projection( + projection, + np.identity(4), + matrix_world, + (-1.0, 1.0, -1.0, 1.0), + (100, 100), + perspective=True, + ) + source.update( + { + "image_size": (100, 100), + "object": SimpleNamespace(matrix_world=matrix_world), + } + ) + frame = ((50.0, 100.0), (100.0, 100.0), (100.0, 0.0), (50.0, 0.0)) + overlap = self.image_frame.frame_source_overlap(source, frame) + depth_location = self.image_frame.frame_depth_location(source, overlap) + + self.assertLess(depth_location[2], 0.0) + + def test_frame_keeps_positive_active_origin_as_result_depth_location(self): + import numpy as np + + marker = SimpleNamespace(copy=lambda: "active-origin") + source = { + "object": SimpleNamespace( + matrix_world=SimpleNamespace(translation=marker), + ), + "origin_depth": 1e-14, + "perspective": True, + } + + self.assertEqual( + self.image_frame.frame_depth_location(source, ((0.0, 0.0),)), + "active-origin", + ) + + def test_frame_reports_missing_active_overlap_as_warning(self): + active = SimpleNamespace(name="Active") + context = SimpleNamespace( + active_object=active, + region=object(), + region_data=SimpleNamespace(view_matrix="view-matrix"), + ) + descriptor = { + "active": True, + } + + with ( + patch.object(self.image_frame, "_frame_source", return_value=descriptor), + patch.object(self.image_frame, "frame_source_overlap", return_value=()), + self.assertRaises(self.image_selection.ImageEditWarning), + ): + self.image_frame.apply_frame( + context, + (active,), + ((2, 3), (3, 3), (3, 2), (2, 2)), + ) + + def test_frame_geometry_faces_the_view_and_uses_frame_center(self): + import numpy as np + + matrix, display_size = self.image_frame.view_facing_frame_geometry( + ( + (-2.0, 1.0, 5.0), + (2.0, 1.0, 5.0), + (2.0, -1.0, 5.0), + (-2.0, -1.0, 5.0), + ), + (400, 200), + ) + + np.testing.assert_allclose(matrix[:3, 0], (1.0, 0.0, 0.0)) + np.testing.assert_allclose(matrix[:3, 1], (0.0, 1.0, 0.0)) + np.testing.assert_allclose(matrix[:3, 2], (0.0, 0.0, 1.0)) + np.testing.assert_allclose(matrix[:3, 3], (0.0, 0.0, 5.0)) + self.assertEqual(display_size, 4.0) + + def test_frame_composites_far_to_near_with_active_on_top(self): + import numpy as np + + frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)) + + def source(name, color, depth, active=False): + pixels = np.empty((2, 2, 4), dtype=np.float32) + pixels[:] = color + return { + "active": active, + "image_size": (2, 2), + "name": name, + "perspective": True, + "pixels": pixels, + "rgba": self.image_data.straight_rgba(pixels), + "region_size": (2.0, 2.0), + "screen_to_source": np.asarray( + ((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0)) + ), + "source_to_view": np.asarray( + ( + (2.0, 0.0, -1.0), + (0.0, -2.0, 1.0), + (0.0, 0.0, -depth), + (0.0, 0.0, 1.0), + ) + ), + } + + far = source("Far", (1.0, 0.0, 0.0, 1.0), 2.0) + active = source("Active", (0.0, 0.0, 0.5, 0.5), 1.0, active=True) + pixels = self.image_frame.composite_frame_pixels( + (active, far), frame, (2, 2) + ) + result = np.flipud(np.asarray(pixels).reshape((2, 2, 4))) + + np.testing.assert_allclose(result[:, :, 0], 0.5, atol=1e-6) + np.testing.assert_allclose(result[:, :, 2], 0.5, atol=1e-6) + np.testing.assert_allclose(result[:, :, 3], 1.0, atol=1e-6) + + def test_frame_puts_active_above_an_equal_depth_source(self): + import numpy as np + + frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)) + screen_to_source = np.asarray( + ((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0)) + ) + + def source(name, color, active): + pixels = np.empty((2, 2, 4), dtype=np.float32) + pixels[:] = color + return { + "active": active, + "image_size": (2, 2), + "name": name, + "perspective": True, + "pixels": pixels, + "rgba": pixels.copy(), + "region_size": (2.0, 2.0), + "screen_to_source": screen_to_source, + "source_to_view": np.asarray( + ( + (2.0, 0.0, -1.0), + (0.0, -2.0, 1.0), + (0.0, 0.0, -1.0), + (0.0, 0.0, 1.0), + ) + ), + } + + pixels = self.image_frame.composite_frame_pixels( + ( + source("Active", (0.0, 0.0, 1.0, 1.0), True), + source("Other", (1.0, 0.0, 0.0, 1.0), False), + ), + frame, + (2, 2), + ) + result = np.asarray(pixels).reshape((2, 2, 4)) + + np.testing.assert_allclose(result[:, :, 2], 1.0, atol=1e-6) + np.testing.assert_allclose(result[:, :, 0], 0.0, atol=1e-6) + + def test_frame_composites_orthographic_samples_at_negative_depth(self): + import numpy as np + + pixels = np.empty((2, 2, 4), dtype=np.float32) + pixels[:] = (0.25, 0.5, 0.75, 1.0) + source = { + "active": True, + "image_size": (2, 2), + "name": "Active", + "perspective": False, + "pixels": pixels, + "rgba": pixels.copy(), + "region_size": (2.0, 2.0), + "screen_to_source": np.asarray( + ((1.0, 0.0, 0.0), (0.0, -1.0, 1.0), (0.0, 0.0, 1.0)) + ), + "source_to_view": np.asarray( + ( + (2.0, 0.0, -1.0), + (0.0, -2.0, 1.0), + (0.0, 0.0, 1.0), + (0.0, 0.0, 1.0), + ) + ), + } + + result = self.image_frame.composite_frame_pixels( + (source,), + ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)), + (2, 2), + ) + + np.testing.assert_allclose( + np.asarray(result).reshape((2, 2, 4)), + pixels, + atol=1e-6, + ) + + def _frame_pixel_source(self, rgba, *, depth=1.0, name="Active", active=True): + import numpy as np + + rgba = np.asarray(rgba, dtype=np.float32) + height, width = rgba.shape[:2] + world = np.identity(4) + world[2, 3] = -depth + source = self.image_frame.frame_source_projection( + np.identity(4), np.identity(4), world, + (-1.0, 1.0, -1.0, 1.0), (width, height), perspective=False, + ) + source.update( + active=active, name=name, image_size=(width, height), rgba=rgba, + pixels=self.image_data.premultiplied_rgba( + np.flipud(rgba).ravel(), (width, height) ), ) - context = SimpleNamespace(region=object(), region_data=object()) - polygon = ((1, 2), (3, 4), (5, 6)) + return source + + def test_frame_preserves_masked_rgb_and_can_restore_alpha(self): + import numpy as np + + rgba = np.random.default_rng(7).random((4, 4, 4)).astype(np.float32) + mask = self.image_selection.SelectionMask(np.ones((4, 4)), (0, 0, 4, 4)) + removed = self.edit_mask.apply_alpha_mask(rgba, mask, "SUBTRACT") + source = self._frame_pixel_source(np.flipud(removed.reshape(rgba.shape))) + framed = self.image_frame.composite_frame_pixels( + (source,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4) + ) + result = np.flipud(framed.reshape(rgba.shape)) + np.testing.assert_allclose(result[..., :3], rgba[..., :3]) + np.testing.assert_array_equal(result[..., 3], 0) + restored = self.edit_mask.apply_alpha_mask(result, mask, "ADD") + restored = np.flipud(restored.reshape(rgba.shape)) + np.testing.assert_allclose(restored[..., :3], rgba[..., :3]) + np.testing.assert_array_equal(restored[..., 3], 1) + + def test_frame_hidden_rgb_uses_bottom_valid_depth_and_stable_ties(self): + import numpy as np + + blue = np.tile((0, 0, 1, 0), (2, 2, 1)) + red = np.tile((1, 0, 0, 0), (2, 2, 1)) + frame = ((0, 2), (2, 2), (2, 0), (0, 0)) + for depth in (2.0, 1.0, -1.0): + with self.subTest(depth=depth): + bottom = self._frame_pixel_source(blue, depth=depth, name="A", active=False) + top = self._frame_pixel_source(red, depth=min(depth, 1.0)) + result = self.image_frame.composite_frame_pixels((top, bottom), frame, (2, 2)) + np.testing.assert_allclose(result.reshape((2, 2, 4)), blue) + middle = self._frame_pixel_source(red, name="B", active=False) + bottom = self._frame_pixel_source(blue, name="A", active=False) + top = self._frame_pixel_source(red) + result = self.image_frame.composite_frame_pixels((middle, top, bottom), frame, (2, 2)) + np.testing.assert_allclose(result.reshape((2, 2, 4)), blue) + bottom["screen_to_source"][0, 2] += 0.5 + result = np.flipud(self.image_frame.composite_frame_pixels( + (top, bottom), frame, (2, 2) + ).reshape((2, 2, 4))) + np.testing.assert_allclose(result[:, 0], blue[:, 0]) + np.testing.assert_allclose(result[:, 1], red[:, 1]) + bottom["perspective"] = True + bottom["source_to_view"][2, 2] = 1.0 + result = self.image_frame.composite_frame_pixels((top, bottom), frame, (2, 2)) + np.testing.assert_allclose(result.reshape((2, 2, 4)), red) + + def test_frame_hidden_rgb_tracks_crossing_planes(self): + import numpy as np + + bottom = self._frame_pixel_source(np.tile((0, 0, 1, 0), (2, 2, 1)), active=False) + top = self._frame_pixel_source(np.tile((1, 0, 0, 0), (2, 2, 1))) + bottom["source_to_view"][2] = (2, 0, -2) + result = np.flipud(self.image_frame.composite_frame_pixels( + (top, bottom), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2) + ).reshape((2, 2, 4))) + np.testing.assert_allclose(result[:, 0], np.tile((0, 0, 1, 0), (2, 1))) + np.testing.assert_allclose(result[:, 1], np.tile((1, 0, 0, 0), (2, 1))) + + def test_frame_blends_rgb_over_bottom_color_with_independent_alpha(self): + import numpy as np + + for bottom_alpha in (0.0, 0.5, 1.0): + blue = np.tile((0, 0, 1, bottom_alpha), (2, 5, 1)).astype(np.float32) + red = np.tile((1, 0, 0, 0), (2, 5, 1)).astype(np.float32) + red[..., 3] = (0, 0.25, 0.5, 0.75, 1) + sources = (self._frame_pixel_source(blue, depth=2, active=False), + self._frame_pixel_source(red)) + result = np.flipud(self.image_frame.composite_frame_pixels( + sources, ((0, 2), (5, 2), (5, 0), (0, 0)), (5, 2) + ).reshape((2, 5, 4))) + alpha = red[..., 3] + bottom_alpha * (1 - red[..., 3]) + expected = np.zeros_like(red) + expected[..., 0] = red[..., 3] + expected[..., 2] = 1 - red[..., 3] + np.testing.assert_allclose(result[..., 3], alpha) + np.testing.assert_allclose(result[..., :3], expected[..., :3]) + + def test_frame_three_layers_preserve_foreground_gradient_over_white(self): + import numpy as np + + white = np.tile((1, 1, 1, 0), (2, 6, 1)).astype(np.float32) + red = np.tile((1, 0, 0, 0), (2, 6, 1)).astype(np.float32) + red[..., 3] = (0, 0.01, 0.1, 0.25, 0.5, 1) + blue = np.tile((0, 0, 1, 0.25), (2, 6, 1)).astype(np.float32) + sources = ( + self._frame_pixel_source(white, depth=3, active=False), + self._frame_pixel_source(red, depth=2, active=False), + self._frame_pixel_source(blue), + ) + result = np.flipud(self.image_frame.composite_frame_pixels( + sources, ((0, 2), (6, 2), (6, 0), (0, 0)), (6, 2) + ).reshape((2, 6, 4))) + red_alpha = red[..., 3:4] + expected = ((red[..., :3] * red_alpha + 1 - red_alpha) * 0.75 + + blue[..., :3] * 0.25) + np.testing.assert_allclose(result[..., :3], expected) + np.testing.assert_allclose(result[..., 3], 0.25 + red[..., 3] * 0.75) + + def test_frame_foreground_resampling_excludes_hidden_black(self): + import numpy as np + + white = self._frame_pixel_source( + np.tile((1, 1, 1, 0), (1, 2, 1)), depth=2, active=False + ) + foreground = self._frame_pixel_source( + np.asarray([[(1, 0, 0, 1), (0, 0, 0, 0)]]) + ) + result = self.image_frame.composite_frame_pixels( + (foreground, white), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1) + ) + np.testing.assert_allclose(result, (1, 0.5, 0.5, 0.5)) + + def test_frame_raw_sampling_and_empty_canvas_are_chunk_independent(self): + import numpy as np + + rgba = np.tile((0, 1, 0, 0), (4, 4, 1)).astype(np.float32) + rgba[1:3, 2:] = (1, 0, 0, 1) + source = self._frame_pixel_source(rgba) + frame = ((-3, 7), (7, 7), (7, -3), (-3, -3)) + results = [] + for rows in (1, 2, 256): + with patch.object(self.image_frame, "PROJECTIVE_CHUNK_ROWS", rows): + results.append(np.flipud(self.image_frame.composite_frame_pixels( + (source,), frame, (10, 10) + ).reshape((10, 10, 4)))) + for result in results: + np.testing.assert_array_equal(result, results[0]) + np.testing.assert_allclose(result[3:7, 3:7], rgba) + np.testing.assert_array_equal(result[4, 7], 0) + np.testing.assert_array_equal(result[0], 0) + transparent = np.asarray([[(1, 0, 0, 0), (0, 0, 1, 0)]], dtype=np.float32) + source = self._frame_pixel_source(transparent) + framed = self.image_frame.composite_frame_pixels( + (source,), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1) + ) + np.testing.assert_allclose(framed, (0.5, 0, 0.5, 0)) + sampled = self.image_data.sample_rgba( + transparent, np.asarray(((1, 0.5), (-1, 0.5))) + ) + np.testing.assert_allclose(sampled, ((0.5, 0, 0.5, 0), (0, 0, 0, 0))) + transparent[0, 0, 3] = 1 + source = self._frame_pixel_source(transparent) + framed = self.image_frame.composite_frame_pixels( + (source,), ((0, 1), (2, 1), (2, 0), (0, 0)), (1, 1) + ) + np.testing.assert_allclose(framed, (0.5, 0, 0.5, 0.5)) + + def test_frame_uses_selected_still_images_and_ignores_other_objects(self): + active = SimpleNamespace( + name="Active", + type="EMPTY", + empty_display_type="IMAGE", + data=SimpleNamespace(source="FILE"), + ) + other = SimpleNamespace( + name="Other", + type="EMPTY", + empty_display_type="IMAGE", + data=SimpleNamespace(source="FILE"), + ) + mesh = SimpleNamespace(name="Mesh", type="MESH") + context = SimpleNamespace( + active_object=active, + selected_objects=(mesh, active, other), + ) + + self.assertEqual( + self.image_frame.selected_frame_sources(context), + (other, active), + ) + + def test_frame_rejects_active_image_that_is_not_selected(self): + active = SimpleNamespace( + name="Active", + type="EMPTY", + empty_display_type="IMAGE", + data=SimpleNamespace(source="FILE"), + ) + other = SimpleNamespace( + name="Other", + type="EMPTY", + empty_display_type="IMAGE", + data=SimpleNamespace(source="FILE"), + ) + context = SimpleNamespace( + active_object=active, + selected_objects=(other,), + ) + + with self.assertRaisesRegex( + self.image_frame.ImageEditWarning, + "Select an active Image Empty", + ): + self.image_frame.selected_frame_sources(context) + + def test_frame_warns_when_active_is_not_an_image(self): + image = SimpleNamespace( + name="Image", + type="EMPTY", + empty_display_type="IMAGE", + data=SimpleNamespace(source="FILE"), + ) + mesh = SimpleNamespace(name="Mesh", type="MESH") + context = SimpleNamespace( + active_object=mesh, + selected_objects=(image, mesh), + area=SimpleNamespace(type="VIEW_3D"), + ) + operator = self.image_frame.FrameImages() + operator.report = Mock() + + self.assertTrue(operator.poll(context)) + self.assertEqual(operator.invoke(context, object()), {"CANCELLED"}) + operator.report.assert_called_once_with( + {"WARNING"}, + "Select an active Image Empty to use an Image tool", + ) + + def test_frame_modal_can_start_outside_source_projection(self): + active = SimpleNamespace(name="Active") + other = SimpleNamespace(name="Other") + redraw = Mock() + status = Mock() + modal_handler_add = Mock() + context = SimpleNamespace( + active_object=active, + area=SimpleNamespace(as_pointer=lambda: 1, tag_redraw=redraw), + region=SimpleNamespace(as_pointer=lambda: 2), + workspace=SimpleNamespace(status_text_set=status), + window_manager=SimpleNamespace(modal_handler_add=modal_handler_add), + ) + event = SimpleNamespace(mouse_region_x=-20, mouse_region_y=300) + space = SimpleNamespace( + draw_handler_add=Mock(return_value="handle"), + draw_handler_remove=Mock(), + ) + operator = self.image_frame.FrameImages() with ( patch.object( - self.image_interaction, "require_image_empty", return_value=source + self.image_frame, + "selected_frame_sources", + return_value=(other, active), ), - patch.object(self.image_interaction, "deserialize_matrix"), - patch.object(self.image_interaction, "image_empty_bounds"), + patch.object( + self.image_frame.bpy.types, + "SpaceView3D", + space, + create=True, + ), + ): + result = operator.invoke(context, event) + operator.cancel(context) + + self.assertEqual(result, {"RUNNING_MODAL"}) + self.assertEqual(operator._active_name, "Active") + self.assertEqual(operator._source_names, ("Other", "Active")) + self.assertEqual(operator._start, (-20.0, 300.0)) + modal_handler_add.assert_called_once_with(operator) + space.draw_handler_remove.assert_called_once_with("handle", "WINDOW") + + def test_frame_rejects_an_animated_selected_image(self): + active = SimpleNamespace( + name="Active", + type="EMPTY", + empty_display_type="IMAGE", + data=SimpleNamespace(source="FILE"), + ) + video = SimpleNamespace( + name="Video", + type="EMPTY", + empty_display_type="IMAGE", + data=SimpleNamespace(source="MOVIE", frame_duration=2), + ) + context = SimpleNamespace( + active_object=active, + selected_objects=(active, video), + ) + + with self.assertRaisesRegex(ValueError, "still images"): + self.image_frame.selected_frame_sources(context) + + def test_frame_keeps_active_object_and_removes_other_image_empties(self): + import numpy as np + + class MatrixStub: + translation = SimpleNamespace(copy=lambda: "active-origin") + + def copy(self): + return "old-matrix" + + active_image = Mock(users=1, use_fake_user=False) + other_image = Mock(users=1, use_fake_user=False) + active = SimpleNamespace( + name="Active", + data=active_image, + matrix_world=MatrixStub(), + empty_display_size=1.0, + empty_image_offset=(-0.5, -0.5), + ) + other = SimpleNamespace(name="Other", data=other_image) + context = SimpleNamespace( + active_object=active, + region=SimpleNamespace(width=100, height=100), + region_data=SimpleNamespace(view_matrix="view-matrix"), + ) + frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)) + descriptors = iter( + ( + { + "active": False, + "image_size": (2, 2), + }, + { + "active": True, + "image_size": (2, 2), + }, + ) + ) + result_image = Mock(users=1) + remove_object = Mock() + + def replace(source, result): + source.data = result + + with ( + patch.object(self.image_frame, "_frame_source", side_effect=descriptors), + patch.object( + self.image_frame, + "frame_source_overlap", + return_value=frame, + ), + patch.object(self.image_frame, "frame_output_size", return_value=(2, 2)), + patch.object( + self.image_frame, + "composite_frame_pixels", + return_value=np.zeros(16, dtype=np.float32), + ), + patch.object( + self.image_frame, + "frame_result_transform", + return_value=("new-matrix", 2.0), + ), + patch.object( + self.image_frame, + "frame_depth_location", + return_value="active-origin", + ), + patch.object( + self.image_frame, + "create_image_edit_result", + return_value=result_image, + ), + patch.object(self.image_frame, "replace_empty_image", side_effect=replace), + patch.object( + self.image_frame.bpy.data, + "objects", + SimpleNamespace(remove=remove_object), + ), + patch.object( + self.image_frame.bpy.data, + "images", + SimpleNamespace(remove=Mock()), + create=True, + ), + ): + result = self.image_frame.apply_frame( + context, + (other, active), + frame, + ) + + self.assertIs(result, active) + self.assertIs(active.data, result_image) + self.assertEqual(active.matrix_world, "new-matrix") + self.assertEqual(active.empty_display_size, 2.0) + self.assertEqual(active.empty_image_offset, (-0.5, -0.5)) + remove_object.assert_called_once_with(other, do_unlink=True) + + def test_frame_invalid_result_depth_does_not_create_or_replace_image(self): + import numpy as np + + active_image = Mock(users=1, use_fake_user=False) + active = SimpleNamespace( + name="Active", + data=active_image, + matrix_world=SimpleNamespace( + translation=SimpleNamespace(copy=lambda: "active-origin") + ), + ) + context = SimpleNamespace( + active_object=active, + region=SimpleNamespace(width=100, height=100), + region_data=SimpleNamespace(view_matrix="view-matrix"), + ) + frame = ((0.0, 2.0), (2.0, 2.0), (2.0, 0.0), (0.0, 0.0)) + descriptor = {"active": True, "image_size": (2, 2)} + create_result = Mock() + replace_result = Mock() + + with ( + patch.object(self.image_frame, "_frame_source", return_value=descriptor), + patch.object(self.image_frame, "frame_source_overlap", return_value=frame), + patch.object(self.image_frame, "frame_output_size", return_value=(2, 2)), + patch.object( + self.image_frame, + "composite_frame_pixels", + return_value=np.zeros(16, dtype=np.float32), + ), + patch.object( + self.image_frame, + "frame_depth_location", + side_effect=ValueError("The Frame result depth is invalid"), + ), + patch.object( + self.image_frame, + "create_image_edit_result", + create_result, + ), + patch.object(self.image_frame, "replace_empty_image", replace_result), + self.assertRaisesRegex(ValueError, "result depth"), + ): + self.image_frame.apply_frame(context, (active,), frame) + + create_result.assert_not_called() + replace_result.assert_not_called() + self.assertIs(active.data, active_image) + + def test_mask_alpha_modes_preserve_rgb_and_canvas(self): + import numpy as np + + source = np.zeros((2, 3, 4), dtype=np.float32) + source[:, :, :3] = np.asarray((0.2, 0.4, 0.6), dtype=np.float32) + source[:, :, 3] = np.asarray(((0.2, 0.5, 0.8), (1.0, 0.4, 0.0))) + mask = self.image_selection.SelectionMask( + np.asarray(((0.25, 1.0), (0.5, 0.75)), dtype=np.float32), + (1, 0, 3, 2), + ) + full_mask = mask.full_values((3, 2)) + expected_alpha = { + "SET": source[:, :, 3] * full_mask, + "ADD": np.maximum(source[:, :, 3], full_mask), + "SUBTRACT": source[:, :, 3] * (1.0 - full_mask), + } + + for mode, expected in expected_alpha.items(): + with self.subTest(mode=mode): + pixels = self.edit_mask.apply_alpha_mask(source, mask, mode) + result = np.flipud(pixels.reshape(source.shape)) + np.testing.assert_allclose(result[:, :, :3], source[:, :, :3]) + np.testing.assert_allclose(result[:, :, 3], expected) + + def test_mask_alpha_can_be_removed_and_added_again(self): + import numpy as np + + source = np.ones((2, 2, 4), dtype=np.float32) + mask = self.image_selection.SelectionMask( + np.ones((2, 2), dtype=np.float32), + (0, 0, 2, 2), + ) + removed = self.edit_mask.apply_alpha_mask(source, mask, "SUBTRACT") + transparent = np.flipud(removed.reshape(source.shape)) + self.assertFalse(np.any(transparent[:, :, 3])) + + added = self.edit_mask.apply_alpha_mask(transparent, mask, "ADD") + restored = np.flipud(added.reshape(source.shape)) + np.testing.assert_array_equal(restored[:, :, 3], 1.0) + + def test_mask_brush_stroke_is_circular_and_continuous(self): + import numpy as np + + single = self.edit_mask.rasterize_brush_path( + (120, 50), + ((20, 25),), + 10, + lambda path: path, + ) + stroke = self.edit_mask.rasterize_brush_path( + (120, 50), + ((20, 25), (100, 25)), + 10, + lambda path: path, + ) + single_values = single.full_values((120, 50)) + stroke_values = stroke.full_values((120, 50)) + + self.assertGreater(single_values[25, 20], 0.9) + self.assertEqual(single_values[0, 0], 0.0) + self.assertTrue(np.all(stroke_values[25, 20:101] > 0.9)) + + def test_mask_brush_clips_bounds_and_projects_screen_radius(self): + clipped = self.edit_mask.rasterize_brush_path( + (80, 40), + ((0, 0),), + 10, + lambda path: path, + ) + projected = self.edit_mask.rasterize_brush_path( + (160, 80), + ((30, 30),), + 10, + lambda path: tuple((x * 2.0, y * 0.5) for x, y in path), + ) + + self.assertEqual(clipped.bounds[:2], (0, 0)) + projected_width = projected.bounds[2] - projected.bounds[0] + projected_height = projected.bounds[3] - projected.bounds[1] + self.assertGreater(projected_width, projected_height * 3) + + def test_mask_brush_commit_uses_the_shared_viewport_footprint(self): + import numpy as np + + polygon = ((1.0, 2.0), (8.0, 2.0), (8.0, 9.0)) + local_mask = self.image_selection.SelectionMask( + np.ones((2, 2), dtype=np.float32), + (1, 1, 3, 3), + ) + + with ( + patch.object( + self.edit_mask, + "brush_footprint_polygons", + return_value=(polygon,), + ) as footprint, + patch.object( + self.edit_mask, + "rasterize_selection_union", + return_value=local_mask, + ) as rasterize, + ): + result = self.edit_mask.rasterize_brush_path( + (20, 20), + ((4.0, 5.0), (7.0, 8.0)), + 6, + lambda path: tuple((x * 2.0, y * 3.0) for x, y in path), + ) + + self.assertEqual(result.bounds, local_mask.bounds) + np.testing.assert_array_equal(result.values, local_mask.values) + footprint.assert_called_once_with(((4.0, 5.0), (7.0, 8.0)), 6) + selection_path = rasterize.call_args.args[1][0] + self.assertEqual( + selection_path.points, + ((2.0, 6.0), (16.0, 6.0), (16.0, 27.0)), + ) + self.assertTrue(rasterize.call_args.kwargs["antialias"]) + + def test_mask_brush_round_turn_stays_inside_the_radius(self): + polygons = self.image_interaction.brush_footprint_polygons( + ((10.0, 10.0), (30.0, 10.0), (15.0, 11.0)), + 5.0, + ) + bands = self.image_interaction.scanline_union_bands(polygons) + center_band = next( + band for band in bands if band[0] <= 10.0 < band[1] + ) + + self.assertTrue( + all(len(intervals) == 1 for _bottom, _top, intervals in bands if intervals) + ) + self.assertEqual(len(center_band[2]), 1) + sample_y = (center_band[0] + center_band[1]) / 2 + half_width = (25 - (sample_y - 10) ** 2) ** 0.5 + self.assertAlmostEqual(center_band[2][0][0], 10 - half_width, delta=0.03) + self.assertAlmostEqual(center_band[2][0][1], 30 + half_width, delta=0.03) + + def test_mask_brush_self_intersection_uses_non_zero_winding(self): + selection_mask = self.edit_mask.rasterize_brush_path( + (80, 80), + ((15, 15), (65, 65), (15, 65), (65, 15)), + 6, + lambda path: path, + ) + + values = selection_mask.full_values((80, 80)) + self.assertGreater(values[40, 40], 0.9) + + def test_mask_brush_path_builds_one_coverage_after_release(self): + import numpy as np + + selection_mask = self.edit_mask.rasterize_brush_path( + (80, 40), + ((10, 20), (70, 20)), + 6, + lambda path: path, + ) + + full = selection_mask.full_values((80, 40)) + self.assertTrue(np.all(full[20, 10:71] > 0.9)) + self.assertLess(selection_mask.bounds[0], 10) + self.assertGreater(selection_mask.bounds[2], 70) + + def test_mask_brush_completion_projects_and_applies_once(self): + source = SimpleNamespace(data=SimpleNamespace(size=(8, 6))) + selection_mask = object() + operator = SimpleNamespace( + _path=[(10.0, 20.0), (30.0, 40.0)], + radius=12, + _apply_mask=Mock(return_value={"FINISHED"}), + ) + context = SimpleNamespace(region="region", region_data="region_data") + + with ( + patch.object( + self.edit_mask, + "image_empty_bounds", + return_value="bounds", + ), + patch.object( + self.edit_mask, + "rasterize_brush_path", + return_value=selection_mask, + ) as rasterize, + patch.object( + self.edit_mask, + "screen_path_to_image_pixels", + return_value="projected", + ) as project, + ): + result = self.edit_mask.EditImageAlpha._complete_brush( + operator, + context, + source, + "matrix", + ) + path_projector = rasterize.call_args.args[3] + self.assertEqual(path_projector(((1.0, 2.0),)), "projected") + + self.assertEqual(result, {"FINISHED"}) + operator._apply_mask.assert_called_once_with(context, source, selection_mask) + project.assert_called_once_with( + ((1.0, 2.0),), + "region", + "region_data", + "matrix", + "bounds", + (8, 6), + simplify=False, + ) + + def test_mask_brush_drag_only_updates_the_screen_path(self): + class Operator(self.image_interaction.ImageGesture): + active_tool_id = "anyimage.mask" + gesture = "BRUSH" + + operator = Operator() + operator._path = [(10.0, 10.0)] + operator._path_samples = list(operator._path) + operator._cursor = (10.0, 10.0) + context = SimpleNamespace(area=SimpleNamespace(tag_redraw=Mock())) + event = SimpleNamespace( + type="MOUSEMOVE", + value="NOTHING", + mouse_region_x=30, + mouse_region_y=25, + ) + + with ( patch.object( self.image_interaction, - "screen_path_to_image_pixels", - return_value=polygon, + "active_view3d_tool_id", + return_value=None, ), + patch.object(self.edit_mask, "rasterize_brush_path") as rasterize, + patch.object(self.edit_mask, "image_rgba") as read_pixels, ): - result = self.image_interaction.CropSelectionGesture._complete(operator, context) + result = operator.modal(context, event) - self.assertEqual(result, {"FINISHED"}) - self.assertEqual(submitted[0][0], context) - self.assertEqual(submitted[0][1].points, polygon) - self.assertEqual(finished, [context]) + self.assertEqual(result, {"RUNNING_MODAL"}) + self.assertEqual(operator._path, [(10.0, 10.0), (30.0, 25.0)]) + rasterize.assert_not_called() + read_pixels.assert_not_called() - def test_local_image_edit_builds_one_selection(self): + def test_mask_replaces_with_a_same_sized_image(self): import numpy as np - source = SimpleNamespace(data=SimpleNamespace(size=(65536, 32768))) - source_rgba = np.ones((6, 8, 4), dtype=np.float32) - selection_path = self.image_selection.SelectionPath( - points=((1, 1), (7, 1), (7, 5), (1, 5)) - ) - selection_mask = self.image_selection.SelectionMask( - np.ones((4, 6), dtype=np.float32), - (1, 1, 7, 5), - ) - operator = SimpleNamespace( - source_object_name="Source", - refine_selection=False, - report=lambda *_args: None, - ) - - with ( - patch.object( - self.edit_mask, - "require_image_empty", - return_value=source, - ), - patch.object(self.edit_mask, "video_total_frames", return_value=1), - patch.object( - self.edit_mask, - "image_rgba", - return_value=source_rgba, - ) as read_rgba, - patch.object( - self.edit_mask, - "rasterize_selection_path", - return_value=selection_mask, - ) as rasterize, - patch.object(self.edit_mask, "_edit_image_object") as edit, - ): - result = self.edit_mask.CropImageSelection._submit_selection_path( - operator, - SimpleNamespace(), - selection_path, - ) - - self.assertEqual(result, {"FINISHED"}) - read_rgba.assert_called_once_with(source.data) - rasterize.assert_called_once() - edit.assert_called_once_with( - unittest.mock.ANY, - source, - source_rgba, - selection_mask, - keep_original=False, - ) - - def test_image_edit_applies_the_local_selection_mask(self): - import numpy as np - - source_rgba = np.ones((6, 8, 4), dtype=np.float32) - source_rgba[:, :, 0] = np.arange(8, dtype=np.float32)[None, :] - values = np.ones((4, 6), dtype=np.float32) - values[0, :] = 0.0 - values[:, 0] = 0.0 - selection_mask = self.image_selection.SelectionMask( - values, - (1, 1, 7, 5), - ) - - pixels, bounds = self.edit_mask._edit_image_mask_pixels( - source_rgba, - selection_mask, - ) - - self.assertEqual(bounds, (2, 2, 7, 5)) - rgba = np.flipud(pixels.reshape((3, 5, 4))) - 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_one_local_selection_mask(self): - import numpy as np - - source = SimpleNamespace(data=SimpleNamespace(size=(8, 6))) - source_rgba = np.ones((6, 8, 4), dtype=np.float32) - selection_path = self.image_selection.SelectionPath( - points=((1, 1), (7, 1), (7, 5), (1, 5)) - ) - selection_mask = self.image_selection.SelectionMask( - np.ones((4, 6), dtype=np.float32), - (1, 1, 7, 5), - ) - operator = SimpleNamespace( - source_object_name="Source", - refine_selection=True, - report=lambda *_args: None, - ) - refine = Mock(return_value={"FINISHED"}) - ops = SimpleNamespace( - anyimage=SimpleNamespace(refine_crop_selection=refine) - ) - - with ( - patch.object( - self.edit_mask, - "require_image_empty", - return_value=source, - ), - patch.object(self.edit_mask, "video_total_frames", return_value=1), - patch.object( - self.edit_mask, - "image_rgba", - return_value=source_rgba, - ) as read_rgba, - patch.object( - self.edit_mask, - "rasterize_selection_path", - return_value=selection_mask, - ) as rasterize, - patch.object( - self.edit_mask, - "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( - operator, - SimpleNamespace(), - selection_path, - ) - - self.assertEqual(result, {"FINISHED"}) - read_rgba.assert_called_once_with(source.data) - rasterize.assert_called_once() - prepare_input.assert_called_once_with( - source.data, - source_rgba, - selection_mask.bounds, - ) - 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 - - source_image = SimpleNamespace( - name="Poster.png", - colorspace_settings=SimpleNamespace(name="sRGB"), - alpha_mode="STRAIGHT", - ) + source_image = SimpleNamespace(size=(8, 6)) source = SimpleNamespace(data=source_image) - 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), + result_image = SimpleNamespace(users=0) + mask = self.image_selection.SelectionMask( + np.ones((2, 3), dtype=np.float32), + (1, 2, 4, 4), ) - rgba = np.ones((6, 8, 4), dtype=np.float32) - ops = SimpleNamespace( - ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"})) - ) - remove = Mock() + operator = SimpleNamespace(mode="SET", report=Mock()) + source_rgba = np.ones((6, 8, 4), dtype=np.float32) with ( - patch.object( - self.edit_mask, - "require_image_empty", - return_value=source, - ), - patch.object( - self.edit_mask, - "load_packed_rgba_image", - return_value=encoded, - ) as load_result, - patch.object( - self.edit_mask, - "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, "image_rgba", return_value=source_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), + return_value=result_image, + ) as create, + patch.object(self.edit_mask, "replace_empty_image") as replace, ): - message = operator.response(None, result) - - self.assertEqual(message, "Crop Selection refined") - load_result.assert_called_once_with( - Path(__file__), - source_image, - "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=(10, 20, 18, 26), - keep_original=True, - ) - remove.assert_called_once_with(encoded, do_unlink=True) - - def test_refined_image_mask_rejects_an_empty_intersection(self): - import numpy as np - - selection_mask = self.image_selection.SelectionMask( - np.zeros((2, 3), dtype=np.float32), - (1, 2, 4, 4), - ) - with self.assertRaisesRegex( - self.image_selection.ImageEditWarning, - "no visible image pixels", - ): - self.edit_mask._apply_local_selection_mask( - np.ones((2, 3, 4), dtype=np.float32), - selection_mask, + result = self.edit_mask.EditImageAlpha._apply_mask( + operator, + SimpleNamespace(), + source, + mask, ) - def test_cutout_ai_response_reuses_color_image_and_keeps_lasso_for_no_refine(self): + self.assertEqual(result, {"FINISHED"}) + self.assertEqual(create.call_args.args[2], (8, 6)) + replace.assert_called_once_with(source, result_image) + + def test_cutout_ai_response_reuses_bounds_color_and_lasso(self): import numpy as np source = SimpleNamespace(data=SimpleNamespace()) @@ -2960,7 +3969,6 @@ class BlenderAddonTest(unittest.TestCase): operator.shape = "SURFACE" operator.point_spacing = 32 operator.generate_normal = True - operator.refine_selection = False operator._selection_mask = selection_mask result = SimpleNamespace(value={}, file=lambda _key: Path(__file__)) @@ -2978,68 +3986,23 @@ class BlenderAddonTest(unittest.TestCase): np.ones((3, 4), dtype=np.float32), selection_mask.bounds, ), - ), + ) as color_result, patch.object(self.cutout_main, "create_cutout_shape") as create_shape, ): message = operator.response(SimpleNamespace(), result) + color_result.assert_called_once_with( + __file__, + source, + selection_mask.bounds, + ) + self.assertEqual(message, "Surface is ready") content_values = create_shape.call_args.args[4] np.testing.assert_array_equal(content_values, pure_values) self.assertEqual(create_shape.call_args.args[5], selection_mask.bounds) self.assertIs(create_shape.call_args.kwargs["color_image"], color_image) - def test_cutout_refine_keeps_textures_but_masks_the_mesh(self): - import numpy as np - - source = SimpleNamespace(data=SimpleNamespace()) - color_image = SimpleNamespace(users=0) - operator = self.cutout_main.CutoutSelectionToShape() - operator.source_object_name = "Source" - operator.selection_bounds_json = "[10,20,16,24]" - operator.input_path = __file__ - operator.shape = "SURFACE" - 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( - selection_values, - (10, 20, 16, 24), - ) - result = SimpleNamespace( - value={"selection_image": "selection.png"}, - file=lambda _key: Path(__file__), - ) - - with ( - patch.object( - self.cutout_main, - "require_image_empty", - return_value=source, - ), - patch.object( - self.cutout_main, - "_generated_color_result", - return_value=( - color_image, - np.ones((4, 6), dtype=np.float32), - (10, 20, 16, 24), - ), - ), - patch.object(self.cutout_main, "create_cutout_shape") as create_shape, - ): - operator.response(SimpleNamespace(), result) - - content_values = create_shape.call_args.args[4] - np.testing.assert_array_equal( - content_values, - selection_values, - ) - 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): segments = self.image_interaction.dashed_line_segments( ((0, 0), (10, 0)), dash_length=3.0, gap_length=2.0 @@ -3050,40 +4013,58 @@ class BlenderAddonTest(unittest.TestCase): ((0.0, 0.0), (3.0, 0.0), (5.0, 0.0), (8.0, 0.0)), ) + def test_merged_scanline_outline_omits_internal_band_edges(self): + segments = self.image_interaction.scanline_outline_segments( + ( + (0.0, 2.0, ((0.0, 8.0),)), + (2.0, 4.0, ((0.0, 8.0),)), + ) + ) + + edges = tuple(zip(segments[::2], segments[1::2])) + self.assertFalse( + any(start[1] == end[1] == 2.0 for start, end in edges) + ) + + def test_mask_brush_outline_matches_the_screen_radius(self): + polygons = self.image_interaction.brush_footprint_polygons( + ((10.0, 10.0), (30.0, 10.0)), + 5.0, + ) + vertices = tuple(point for polygon in polygons for point in polygon) + + self.assertAlmostEqual(min(point[0] for point in vertices), 5.0) + self.assertAlmostEqual(max(point[0] for point in vertices), 35.0) + self.assertAlmostEqual(min(point[1] for point in vertices), 5.0) + self.assertAlmostEqual(max(point[1] for point in vertices), 15.0) + def test_image_lasso_preview_uses_fill_and_a_closed_dashed_outline(self): polygon = ((1.0, 2.0), (8.0, 3.0), (4.0, 9.0)) operator = SimpleNamespace( gesture="LASSO", - invert=False, - _path=[polygon[0]], + mode="SET", + _path=list(polygon), _area_pointer=1, _region_pointer=2, - _fill_polygon=None, + _preview_polygon=None, _fill_triangles=(), - _outline_polygon=None, + _brush_preview=None, _outline_segments=(), - _screen_path=lambda: polygon, ) with ( patch.object(self.image_interaction, "drawing_in_region", return_value=True), patch.object( self.image_interaction, - "dashed_line_segments", - return_value=("segments",), - ) as build_outline, - patch.object( - self.image_interaction, - "preview_fill_triangles", - return_value=("triangles",), - ) as build_fill, + "preview_fill_geometry", + return_value=(("triangles",), ("segments",)), + ) as build_preview, patch.object(self.image_interaction, "draw_polygon_fill") as draw_fill, patch.object(self.image_interaction, "draw_dashed_line") as draw_outline, ): - self.image_interaction.CropSelectionGesture._draw_selection(operator) - self.image_interaction.CropSelectionGesture._draw_selection(operator) + self.image_interaction.ImageGesture._draw_selection(operator) + self.image_interaction.ImageGesture._draw_selection(operator) - build_fill.assert_called_once_with(polygon) - build_outline.assert_called_once_with((*polygon, polygon[0])) + build_preview.assert_called_once_with(polygon) self.assertEqual(draw_fill.call_count, 2) draw_fill.assert_called_with( polygon, @@ -3093,19 +4074,405 @@ class BlenderAddonTest(unittest.TestCase): self.assertEqual(draw_outline.call_count, 2) draw_outline.assert_called_with((), segments=("segments",)) + def test_mask_polyline_modal_adds_removes_and_confirms_points(self): + redraw = Mock() + context = SimpleNamespace(area=SimpleNamespace(tag_redraw=redraw)) + confirmed = Mock(return_value={"FINISHED"}) + operator = SimpleNamespace( + _path=[(10.0, 10.0)], + _cursor=(10.0, 10.0), + _confirm_polyline=confirmed, + ) + + result = self.image_interaction.ImageGesture._modal_polyline( + operator, + context, + SimpleNamespace( + type="LEFTMOUSE", + value="PRESS", + mouse_region_x=30, + mouse_region_y=10, + ), + ) + self.assertEqual(result, {"RUNNING_MODAL"}) + self.assertEqual(operator._path, [(10.0, 10.0), (30.0, 10.0)]) + + self.image_interaction.ImageGesture._modal_polyline( + operator, + context, + SimpleNamespace(type="BACK_SPACE", value="PRESS"), + ) + self.assertEqual(operator._path, [(10.0, 10.0)]) + + operator._path.extend(((30.0, 10.0), (30.0, 30.0))) + result = self.image_interaction.ImageGesture._modal_polyline( + operator, + context, + SimpleNamespace( + type="LEFTMOUSE", + value="PRESS", + mouse_region_x=12, + mouse_region_y=11, + ), + ) + self.assertEqual(result, {"FINISHED"}) + confirmed.assert_called_once_with(context) + + def test_mask_polyline_click_uses_close_radius_not_hint_distance(self): + context = SimpleNamespace(area=SimpleNamespace(tag_redraw=Mock())) + path = [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0)] + confirmed = Mock(return_value={"FINISHED"}) + operator = SimpleNamespace( + _path=list(path), + _cursor=path[-1], + _ui_scale=1.0, + _confirm_polyline=confirmed, + ) + + result = self.image_interaction.ImageGesture._modal_polyline( + operator, + context, + SimpleNamespace( + type="LEFTMOUSE", + value="PRESS", + mouse_region_x=26, + mouse_region_y=10, + ), + ) + + self.assertEqual(result, {"RUNNING_MODAL"}) + self.assertEqual(operator._path[-1], (26.0, 10.0)) + confirmed.assert_not_called() + + operator._path = list(path) + result = self.image_interaction.ImageGesture._modal_polyline( + operator, + context, + SimpleNamespace( + type="LEFTMOUSE", + value="PRESS", + mouse_region_x=25, + mouse_region_y=10, + ), + ) + + self.assertEqual(result, {"FINISHED"}) + confirmed.assert_called_once_with(context) + + def test_mask_polyline_double_click_confirms_without_an_extra_point(self): + context = SimpleNamespace() + path = [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0)] + confirmed = Mock(return_value={"FINISHED"}) + operator = SimpleNamespace( + _path=list(path), + _confirm_polyline=confirmed, + ) + + result = self.image_interaction.ImageGesture._modal_polyline( + operator, + context, + SimpleNamespace(type="LEFTMOUSE", value="DOUBLE_CLICK"), + ) + + self.assertEqual(result, {"FINISHED"}) + self.assertEqual(operator._path, path) + confirmed.assert_called_once_with(context) + + def test_mask_polyline_consecutive_presses_confirm_double_click(self): + context = SimpleNamespace(area=SimpleNamespace(tag_redraw=Mock())) + path = [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0)] + confirmed = Mock(return_value={"FINISHED"}) + operator = SimpleNamespace( + _path=list(path), + _cursor=path[-1], + _ui_scale=1.0, + _polyline_double_click_interval=0.35, + _last_polyline_click_point=None, + _last_polyline_click_time=None, + _confirm_polyline=confirmed, + ) + event = SimpleNamespace( + type="LEFTMOUSE", + value="PRESS", + mouse_region_x=50, + mouse_region_y=50, + ) + + with patch.object( + self.image_interaction.time, + "monotonic", + side_effect=(10.0, 10.2), + ): + first_result = self.image_interaction.ImageGesture._modal_polyline( + operator, context, event + ) + second_result = self.image_interaction.ImageGesture._modal_polyline( + operator, context, event + ) + + self.assertEqual(first_result, {"RUNNING_MODAL"}) + self.assertEqual(second_result, {"FINISHED"}) + self.assertEqual(operator._path, [*path, (50.0, 50.0)]) + confirmed.assert_called_once_with(context) + + def test_polyline_raw_double_click_requires_time_and_proximity(self): + is_double_click = self.image_interaction.is_polyline_double_click + + self.assertTrue(is_double_click((10, 10), 1.0, (14, 13), 1.2)) + self.assertFalse(is_double_click((10, 10), 1.0, (16, 10), 1.2)) + self.assertFalse(is_double_click((10, 10), 1.0, (10, 10), 1.4)) + + def test_mask_polyline_double_click_requires_three_points(self): + class Operator(self.image_interaction.ImageGesture): + pass + + operator = Operator() + operator._path = [(10.0, 10.0), (30.0, 10.0)] + operator._complete = Mock(return_value={"FINISHED"}) + + result = operator._modal_polyline( + SimpleNamespace(), + SimpleNamespace(type="LEFTMOUSE", value="DOUBLE_CLICK"), + ) + + self.assertEqual(result, {"RUNNING_MODAL"}) + self.assertEqual(operator._path, [(10.0, 10.0), (30.0, 10.0)]) + operator._complete.assert_not_called() + + def test_mask_polyline_enter_requires_three_points(self): + context = SimpleNamespace() + complete = Mock(return_value={"FINISHED"}) + operator = SimpleNamespace(_path=[(0, 0), (10, 0)], _complete=complete) + + self.assertEqual( + self.image_interaction.ImageGesture._confirm_polyline( + operator, context + ), + {"RUNNING_MODAL"}, + ) + operator._path.append((10, 10)) + self.assertEqual( + self.image_interaction.ImageGesture._confirm_polyline( + operator, context + ), + {"FINISHED"}, + ) + complete.assert_called_once_with(context) + + def test_mask_polyline_completion_uses_the_lasso_selection_pipeline(self): + source = SimpleNamespace(data=SimpleNamespace(size=(100, 80))) + submitted = Mock(return_value={"FINISHED"}) + operator = SimpleNamespace( + gesture="POLYLINE", + source_object_name="Source", + source_matrix_data="matrix", + _path=[(10.0, 10.0), (60.0, 10.0), (40.0, 50.0)], + _submit_selection_path=submitted, + _finish=Mock(), + ) + context = SimpleNamespace(region=object(), region_data=object()) + + with ( + patch.object( + self.image_interaction, + "require_image_empty", + return_value=source, + ), + patch.object( + self.image_interaction, + "deserialize_matrix", + return_value="matrix", + ), + patch.object( + self.image_interaction, + "image_empty_bounds", + return_value=(-0.5, 0.5, -0.5, 0.5), + ), + patch.object( + self.image_interaction, + "screen_path_to_image_pixels", + return_value=((1, 2), (8, 2), (4, 9)), + ) as project, + ): + result = self.image_interaction.ImageGesture._complete( + operator, context + ) + + self.assertEqual(result, {"FINISHED"}) + self.assertEqual( + submitted.call_args.args[1].points, + ((1.0, 2.0), (8.0, 2.0), (4.0, 9.0)), + ) + self.assertEqual(project.call_args.args[0], tuple(operator._path)) + + def test_mask_polyline_overlay_previews_cursor_and_close_target(self): + operator = SimpleNamespace( + gesture="POLYLINE", + mode="SUBTRACT", + _path=[(10.0, 10.0), (30.0, 10.0), (30.0, 30.0)], + _cursor=(12.0, 11.0), + _ui_scale=1.0, + _area_pointer=1, + _region_pointer=2, + _preview_polygon=None, + _fill_triangles=(), + _brush_preview=None, + _outline_segments=(), + ) + with ( + patch.object(self.image_interaction, "drawing_in_region", return_value=True), + patch.object( + self.image_interaction, + "preview_fill_geometry", + return_value=(("triangles",), ("segments",)), + ), + patch.object(self.image_interaction, "draw_polygon_fill") as fill, + patch.object(self.image_interaction, "draw_dashed_line"), + patch.object( + self.image_interaction, + "draw_polyline_close_indicator", + ) as close, + ): + self.image_interaction.ImageGesture._draw_selection(operator) + + polygon = (*operator._path, operator._cursor) + fill.assert_called_once_with( + polygon, + ("triangles",), + (1.0, 0.25, 0.2, 0.16), + ) + close.assert_called_once_with( + operator._path[0], + self.image_interaction.polyline_close_indicator_radius( + self.image_interaction.math.dist( + operator._cursor, + operator._path[0], + ), + 1.0, + ), + 1.0, + ) + + def test_polyline_close_indicator_matches_blender_scale_and_distance(self): + indicator = self.image_interaction.polyline_close_indicator_radius + close_radius = self.image_interaction.polyline_close_radius + + self.assertEqual(close_radius(1.0), 15.0) + self.assertEqual(indicator(0.0, 1.0), 15.0) + self.assertGreater(indicator(20.0, 1.0), indicator(120.0, 1.0)) + self.assertIsNone(indicator(225.0, 1.0)) + self.assertEqual(close_radius(2.0), 30.0) + self.assertEqual(indicator(0.0, 2.0), 30.0) + self.assertIsNotNone(indicator(899.0, 2.0)) + self.assertIsNone(indicator(900.0, 2.0)) + + def test_mask_polyline_overlay_hides_close_target_outside_hint_distance(self): + operator = SimpleNamespace( + gesture="POLYLINE", + mode="SUBTRACT", + _path=[(10.0, 10.0), (30.0, 10.0), (30.0, 30.0)], + _cursor=(236.0, 10.0), + _ui_scale=1.0, + _area_pointer=1, + _region_pointer=2, + _preview_polygon=None, + _fill_triangles=(), + _brush_preview=None, + _outline_segments=(), + ) + with ( + patch.object(self.image_interaction, "drawing_in_region", return_value=True), + patch.object( + self.image_interaction, + "preview_fill_geometry", + return_value=(("triangles",), ("segments",)), + ), + patch.object(self.image_interaction, "draw_polygon_fill"), + patch.object(self.image_interaction, "draw_dashed_line"), + patch.object( + self.image_interaction, + "draw_polyline_close_indicator", + ) as close, + ): + self.image_interaction.ImageGesture._draw_selection(operator) + + close.assert_not_called() + + def test_mask_brush_overlay_matches_lasso_fill_and_outline(self): + for mode, color in ( + ("SUBTRACT", (1.0, 0.25, 0.2, 0.16)), + ("ADD", (1.0, 1.0, 1.0, 0.16)), + ): + cache = Mock() + operator = SimpleNamespace( + gesture="BRUSH", mode=mode, radius=12, + _path=[(10.0, 10.0), (20.0, 20.0)], + _area_pointer=1, _region_pointer=2, _brush_preview=cache, + ) + with patch.object(self.image_interaction, "drawing_in_region", return_value=True): + self.image_interaction.ImageGesture._draw_selection(operator) + cache.update.assert_called_once_with(operator._path, 12) + cache.draw.assert_called_once_with(color) + + def test_mask_brush_retains_the_complete_uncommitted_path(self): + class Operator(self.image_interaction.ImageGesture): + active_tool_id = "anyimage.mask" + gesture = "BRUSH" + + operator = Operator() + operator._path = [(10.0, 10.0)] + operator._path_samples = list(operator._path) + operator._cursor = (10.0, 10.0) + context = SimpleNamespace(area=SimpleNamespace(tag_redraw=Mock())) + event = SimpleNamespace( + type="MOUSEMOVE", + value="NOTHING", + mouse_region_x=20, + mouse_region_y=25, + ) + + with patch.object( + self.image_interaction, + "active_view3d_tool_id", + return_value=None, + ): + result = operator.modal(context, event) + + self.assertEqual(result, {"RUNNING_MODAL"}) + self.assertEqual(operator._path, [(10.0, 10.0), (20.0, 25.0)]) + + def test_mask_tool_switch_ends_the_uncommitted_overlay(self): + class Operator(self.image_interaction.ImageGesture): + active_tool_id = "anyimage.mask" + gesture = "BRUSH" + + operator = Operator() + operator._finish = Mock() + + with patch.object( + self.image_interaction, + "active_view3d_tool_id", + return_value="anyimage.frame", + ): + result = operator.modal(SimpleNamespace(), SimpleNamespace()) + + self.assertEqual(result, {"CANCELLED"}) + operator._finish.assert_called_once() + def test_image_lasso_does_not_add_stationary_or_nearly_collinear_points(self): path = [(0.0, 0.0)] + samples = list(path) - self.assertFalse(self.image_interaction.append_lasso_point(path, (0.5, 0.5))) + self.assertFalse(self.image_interaction.append_lasso_point(path, (0.0, 0.0), samples)) self.assertEqual(path, [(0.0, 0.0)]) - self.assertTrue(self.image_interaction.append_lasso_point(path, (3.0, 0.0))) - self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 0.2))) + self.assertTrue(self.image_interaction.append_lasso_point(path, (3.0, 0.0), samples)) + self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 0.2), samples)) self.assertEqual(len(path), 2) self.assertEqual(path[-1], (6.0, 0.2)) - self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 4.0))) + self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 4.0), samples)) self.assertEqual(len(path), 3) - def test_image_edit_activation_defaults_to_box(self): + def test_image_edit_activation_defaults_to_frame(self): calls = [] previous_ops = getattr(self.fake_bpy, "ops", None) self.fake_bpy.ops = SimpleNamespace( @@ -3114,7 +4481,7 @@ class BlenderAddonTest(unittest.TestCase): ) ) try: - result = self.edit_mask.ActivateCropTool().execute(None) + result = self.image_frame.ActivateImageEditTool().execute(None) finally: if previous_ops is None: del self.fake_bpy.ops @@ -3124,7 +4491,7 @@ class BlenderAddonTest(unittest.TestCase): self.assertEqual(result, {"FINISHED"}) self.assertEqual( calls, - [{"name": "anyimage.crop_box", "space_type": "VIEW_3D"}], + [{"name": "anyimage.frame", "space_type": "VIEW_3D"}], ) def test_image_operators_stay_clickable_until_server_is_busy(self): @@ -3272,30 +4639,23 @@ class BlenderAddonTest(unittest.TestCase): self.registered_tools, [ ( - self.image_box.CropBoxTool, + self.image_frame.FrameTool, { "group": True, "separator": True, }, ), ( - self.image_lasso.CropLassoTool, + self.edit_mask.MaskTool, { - "after": {self.image_box.CropBoxTool.bl_idname}, + "after": {self.image_frame.FrameTool.bl_idname}, "separator": False, }, ), ( - self.image_polyline.CropPolylineTool, + self.image_perspective.RectifyTool, { - "after": {self.image_lasso.CropLassoTool.bl_idname}, - "separator": False, - }, - ), - ( - self.image_perspective.CropPerspectiveTool, - { - "after": {self.image_polyline.CropPolylineTool.bl_idname}, + "after": {self.edit_mask.MaskTool.bl_idname}, "separator": False, }, ), diff --git a/tests/test_brush_strokes.py b/tests/test_brush_strokes.py new file mode 100644 index 0000000..9566f23 --- /dev/null +++ b/tests/test_brush_strokes.py @@ -0,0 +1,132 @@ +import sys +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import Mock, patch + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) + +from anyimage.common import selection, viewport +from anyimage.operators.image_edit_tool import mask + + +def test_simplified_segments_bound_every_original_sample(): + raw = [(float(x), float(20 * np.sin(x / 15))) for x in np.arange(0, 160, 0.2)] + path, samples = [], [] + for point in raw: + viewport.append_lasso_point(path, point, samples) + assert len(path) < len(raw) / 5 + for start, end in zip(path, path[1:]): + segment = np.array(end) - start + points = np.array([point for point in raw if start[0] <= point[0] <= end[0]]) + factor = np.clip((points - start) @ segment / (segment @ segment), 0, 1) + distances = np.linalg.norm(points - (np.array(start) + factor[:, None] * segment), axis=1) + assert distances.max() <= 0.75 + 1e-10 + + +@pytest.mark.parametrize("radius", [2, 5, 25]) +@pytest.mark.parametrize("path", [ + ((40, 70), (70, 70), (70, 100)), + ((35, 35), (100, 100), (35, 100), (100, 35)), +]) +def test_brush_antialiases_the_hard_union_once(radius, path): + size = (150, 150) + hard = np.zeros(size[::-1], dtype=bool) + for polygon in viewport.brush_footprint_polygons(path, radius): + hard |= selection._rasterize_path((0, 0, *size), polygon) + result = mask.rasterize_brush_path(size, path, radius, lambda points: points) + np.testing.assert_allclose(result.full_values(size), selection._antialias_mask(hard)) + assert result.values.size < size[0] * size[1] + + +@pytest.mark.parametrize("radius", [2, 5, 25]) +def test_straight_stroke_is_independent_of_segmentation(radius): + size = (160, 160) + sparse = ((40, 70), (110, 70)) + dense = tuple((x, 70) for x in range(40, 111, 5)) + results = [mask.rasterize_brush_path(size, path, radius, lambda p: p).full_values(size) + for path in (sparse, dense)] + np.testing.assert_array_equal(*results) + + +def test_incremental_preview_matches_full_union_when_tail_moves_and_crosses(): + cache = viewport.BrushPreview() + path = [(10, 10)] + operations = [(False, (100, 100)), (True, (110, 90)), (False, (10, 90)), + (False, (110, 10)), (True, (105, 15)), (False, (10, 10))] + for replace, point in [(False, None), *operations]: + if point is not None: + if replace: + path[-1] = point + else: + path.append(point) + cache.update(path, 8) + reference = {round(bottom / cache.step): intervals + for bottom, _, intervals in viewport.scanline_union_bands( + viewport.brush_footprint_polygons(path, 8), cache.step) + if intervals} + assert cache.coverage.keys() == reference.keys() + for row in reference: + np.testing.assert_allclose(cache.coverage[row], reference[row], atol=1e-10) + + +def test_preview_reuses_batches_and_limits_updates_to_changed_rows(): + cache = viewport.BrushPreview() + path = [(10, y) for y in range(0, 800, 10)] + cache.update(path, 5) + gpu = SimpleNamespace(shader=Mock(), state=Mock()) + build = Mock(side_effect=lambda *args: Mock()) + with patch.dict(sys.modules, {"gpu": gpu, "gpu_extras.batch": SimpleNamespace(batch_for_shader=build)}): + cache.draw((1, 1, 1, 0.16)) + initial = dict(cache.batches) + builds = build.call_count + with patch.object(cache, "_polygon_rows", wraps=cache._polygon_rows) as rasterize: + cache.update(path, 5) + cache.draw((1, 1, 1, 0.16)) + rasterize.assert_not_called() + assert build.call_count == builds + path.append((10, 800)) + cache.update(path, 5) + assert rasterize.call_count == 2 + assert all(len(call.args[0]) <= 2 for call in rasterize.call_args_list) + cache.draw((1, 1, 1, 0.16)) + changed = {chunk for chunk, batches in initial.items() if cache.batches[chunk] is not batches} + assert len(changed) <= 2 + assert len(initial) > 10 + + +@pytest.mark.parametrize("exit_kind", ["ESC", "SWITCH", "ERROR", "COMPLETE"]) +def test_gesture_releases_preview_and_samples_on_every_exit(exit_kind): + gesture = viewport.ImageGesture() + gesture.gesture = "BRUSH" + gesture.active_tool_id = "anyimage.mask" + gesture._path = [(0, 0), (10, 10)] + gesture._path_samples = list(gesture._path) + gesture._brush_preview = viewport.BrushPreview() + handle = gesture._handle = object() + gesture._brush_preview.update(gesture._path, 5) + context = SimpleNamespace(workspace=Mock(), area=Mock()) + event = SimpleNamespace(type="ESC" if exit_kind == "ESC" else "LEFTMOUSE", + value="PRESS" if exit_kind == "ESC" else "RELEASE", + mouse_region_x=10, mouse_region_y=10) + gesture.report = Mock() + def complete(context): + if exit_kind == "ERROR": + raise ValueError("Failed projection") + gesture._finish(context) + return {"FINISHED"} + gesture._complete = complete + tool = "builtin.select" if exit_kind == "SWITCH" else gesture.active_tool_id + remove = Mock() + fake_bpy = SimpleNamespace(types=SimpleNamespace(SpaceView3D=SimpleNamespace(draw_handler_remove=remove))) + with patch.object(viewport, "active_view3d_tool_id", return_value=tool), patch.object(viewport, "bpy", fake_bpy): + result = gesture.modal(context, event) + gesture._finish(context) + remove.assert_called_once_with(handle, "WINDOW") + assert gesture._handle is None + assert result == ({"FINISHED"} if exit_kind == "COMPLETE" else {"CANCELLED"}) + assert gesture._path is gesture._path_samples is gesture._brush_preview is None + assert gesture._fill_triangles == gesture._outline_segments == () + assert viewport.ImageGesture()._brush_preview is None diff --git a/tests/test_cutout_geometry.py b/tests/test_cutout_geometry.py index 9c369ff..790ae26 100644 --- a/tests/test_cutout_geometry.py +++ b/tests/test_cutout_geometry.py @@ -96,12 +96,12 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes(): uv = [] camera_depth = np.empty((3, 3), dtype=np.float32) for row in range(3): - local_z = 1.0 - row + local_y = 1.0 - row for column in range(3): - local_y = column - 1.0 - vertices.append((0.0, local_y, local_z)) + local_x = column - 1.0 + vertices.append((local_x, local_y, 0.0)) uv.append((column * 0.5, 1.0 - row * 0.5)) - camera_depth[row, column] = 2.0 + 0.2 * local_y + 0.1 * local_z + camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y reference, direction = cutout_geometry.depth_balloon_projection_parameters( vertices, @@ -110,7 +110,7 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes(): np.ones((3, 3), dtype=bool), 0.5, ) - expected_direction = np.asarray((-0.1, 0.05, 1.0), dtype=np.float64) + expected_direction = np.asarray((0.1, 0.05, 1.0), dtype=np.float64) expected_direction /= np.linalg.norm(expected_direction) assert np.isclose( @@ -130,7 +130,7 @@ def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile(): uv = [] for y in range(size): for x in range(size): - vertices.append((0.0, float(x), float(size - 1 - y))) + vertices.append((float(x), float(size - 1 - y), 0.0)) uv.append((x / (size - 1), 1.0 - y / (size - 1))) reference, direction = cutout_geometry.depth_balloon_projection_parameters( diff --git a/tests/test_cutout_interaction.py b/tests/test_cutout_interaction.py index 4b9b673..68c20a5 100644 --- a/tests/test_cutout_interaction.py +++ b/tests/test_cutout_interaction.py @@ -8,7 +8,7 @@ sys.path.insert(0, str(SOURCE_ROOT)) from anyimage.common.selection import SelectionPath from anyimage.common import viewport -from anyimage.common.viewport import CropSelectionGesture +from anyimage.common.viewport import ImageGesture from anyimage.operators.cutout_tool import interaction from anyimage.operators.cutout_tool import main as cutout_main @@ -64,7 +64,6 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch): object(), source, SelectionPath(points=((0, 0), (10, 0), (0, 10))), - refine_selection=True, ) assert [call[2].shape for call in window_manager.pie.calls] == [ @@ -79,7 +78,6 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch): call[0] == "anyimage.cutout_selection_to_shape" for call in window_manager.pie.calls ) - assert not any(call[2].refine_selection for call in window_manager.pie.calls) assert not any(call[2].generate_normal for call in window_manager.pie.calls) assert 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) @@ -111,9 +109,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): source, SelectionPath( points=((0, 0), (10, 0), (0, 10)), - invert=True, ), - refine_selection=True, ) assert [call[2].shape for call in window_manager.pie.calls] == [ @@ -122,16 +118,16 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch): "DEPTH_BALLOON", "DEPTH_SURFACE", ] - assert all(call[2].refine_selection for call in window_manager.pie.calls) assert all(call[2].generate_normal for call in window_manager.pie.calls) assert len(serialized) == 1 - assert set(json.loads(serialized[0])) == {"points", "invert"} + assert set(json.loads(serialized[0])) == {"points"} assert all( call[2].selection_path_json == serialized[0] for call in window_manager.pie.calls ) assert all( - SelectionPath.from_json(call[2].selection_path_json).invert + SelectionPath.from_json(call[2].selection_path_json).points + == ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0)) for call in window_manager.pie.calls ) assert all(call[2].point_spacing == 16 for call in window_manager.pie.calls) @@ -163,7 +159,6 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa ) monkeypatch.setattr(cutout_main, "video_total_frames", lambda _image: 1) monkeypatch.setattr(cutout_main, "require_image_empty", lambda _name: source) - monkeypatch.setattr(cutout_main, "apply_crop_settings", lambda *_args: None) monkeypatch.setattr(cutout_main, "serialize_matrix", lambda _matrix: "matrix") monkeypatch.setattr( cutout_main, @@ -180,7 +175,6 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa operator = SimpleNamespace( full_image=True, - refine_selection=False, report=lambda *_args: None, ) source.matrix_world = object() @@ -192,7 +186,7 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa operator, context, SimpleNamespace(value="DOUBLE_CLICK") ) - assert issubclass(cutout_main.SelectCutoutSelection, CropSelectionGesture) + assert issubclass(cutout_main.SelectCutoutSelection, ImageGesture) assert result == {"FINISHED"} selection_path = SelectionPath.from_json(window_manager.pie.calls[0][2].selection_path_json) assert selection_path.points == ( @@ -205,16 +199,25 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa assert not hasattr(cutout_main, "pending_selection") -def test_cutout_tool_prioritizes_double_click_before_lasso_press(): +def test_cutout_tool_prioritizes_double_click_before_lasso_drag(): assert cutout_main.CutoutTool.bl_keymap[0][1]["value"] == "DOUBLE_CLICK" - assert cutout_main.CutoutTool.bl_keymap[1][1]["value"] == "PRESS" + assert cutout_main.CutoutTool.bl_keymap[1][1]["value"] == "CLICK_DRAG" + assert [entry[0] for entry in cutout_main.CutoutTool.bl_keymap[2:]] == [ + "view3d.select", + "view3d.select", + ] + assert cutout_main.CutoutTool.bl_keymap[2][2] == { + "properties": [("deselect_all", False)] + } + assert cutout_main.CutoutTool.bl_keymap[3][2] == { + "properties": [("toggle", True)] + } -def test_cutout_f_does_not_toggle_invert(monkeypatch): +def test_cutout_f_has_no_option_shortcut(monkeypatch): operator = SimpleNamespace( gesture="LASSO", active_tool_id="anyimage.cutout_lasso", - invert=False, ) monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: None) @@ -225,7 +228,6 @@ def test_cutout_f_does_not_toggle_invert(monkeypatch): ) assert result == {"PASS_THROUGH"} - assert operator.invert is False def test_cutout_status_has_no_letter_shortcut(): diff --git a/tests/test_cutout_objects.py b/tests/test_cutout_objects.py index 58c60d6..7fca6cf 100644 --- a/tests/test_cutout_objects.py +++ b/tests/test_cutout_objects.py @@ -28,6 +28,8 @@ from anyimage.operators.cutout_tool import geometry as cutout_geometry from anyimage.operators.cutout_tool import main as cutout_main from anyimage.operators.cutout_tool import object as cutout_object from anyimage.operators.cutout_tool import shape as cutout_shape +from anyimage.operators.image_edit_tool import mask as edit_mask +from anyimage.operators.image_edit_tool import frame as edit_frame from anyimage.server.geometry.depth_texture import ( write_vector_depth_texture, write_z_depth_texture, @@ -60,7 +62,7 @@ def _create_cutout_shape( depth_image=None, depth_metadata=None, selection_values=None, - inward_axis=cutout_object.CutoutInwardAxis.POSITIVE_X, + inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z, ): if bounds is None: bounds = (0, 0, *tuple(int(value) for value in source.data.size)) @@ -125,38 +127,6 @@ def _depth_metadata(intrinsics, image_size, reference_depth): } -def test_keep_original_places_and_activates_result_copy(): - bpy.ops.wm.read_factory_settings(use_empty=True) - source_image = bpy.data.images.new("Crop Source", width=8, height=4, alpha=True) - result_image = bpy.data.images.new("Crop Result", width=4, height=2, alpha=True) - source = bpy.data.objects.new("Crop Source", None) - source.empty_display_type = "IMAGE" - source.data = source_image - source.empty_display_size = 3.0 - source.empty_image_offset = (0.2, -0.1) - source.matrix_world = Matrix.Translation((1.0, 2.0, 3.0)) - bpy.context.collection.objects.link(source) - source.select_set(True) - bpy.context.view_layer.objects.active = source - - placed = image_data.place_empty_image_result( - bpy.context, - source, - result_image, - placement_bounds=(2, 1, 6, 3), - keep_original=True, - ) - - assert placed is not source - assert source.data == source_image - assert placed.data == result_image - assert placed.users_collection[:] == source.users_collection[:] - assert np.allclose(placed.matrix_world, source.matrix_world) - assert not source.select_get() - assert placed.select_get() - 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) @@ -177,6 +147,9 @@ def test_image_edit_result_uses_complete_shared_source_name(): image_data.replace_empty_image(source, result_image) assert shared.data == source_image + assert source_image.alpha_mode == "STRAIGHT" + assert result_image.alpha_mode == "PREMUL" + assert source.use_empty_image_alpha assert source.data == result_image assert result_image.name == "Poster.png.001" assert "_color" not in result_image.name @@ -202,6 +175,194 @@ def test_image_edit_result_recovers_unshared_source_name(): assert bpy.data.images.get("Poster.png") == result_image +def test_mask_frame_mask_add_preserves_packed_hidden_rgb(): + from io import BytesIO + from PIL import Image + + bpy.ops.wm.read_factory_settings(use_empty=True) + source_image = bpy.data.images.new("Frame RGB", width=4, height=4, alpha=True) + rgba = np.ones((4, 4, 4), dtype=np.float32) + rgba[..., :3] = np.arange(48).reshape((4, 4, 3)) / 47.0 + source_image.pixels.foreach_set(np.flipud(rgba).ravel()) + source_image.update() + expected = image_data.image_rgba(source_image).copy() + source = bpy.data.objects.new("Frame RGB", None) + source.empty_display_type = "IMAGE" + source.empty_display_size = 2.0 + source.empty_image_offset = (-0.5, -0.5) + source.data = source_image + bpy.context.collection.objects.link(source) + selection = SelectionMask(np.ones((4, 4), dtype=np.float32), (0, 0, 4, 4)) + operator = SimpleNamespace(mode="SUBTRACT", report=lambda *_args: None) + assert edit_mask.EditImageAlpha._apply_mask( + operator, SimpleNamespace(), source, selection + ) == {"FINISHED"} + descriptor = edit_frame._frame_source( + source, SimpleNamespace(width=4, height=4), + SimpleNamespace(perspective_matrix=Matrix.Identity(4), is_perspective=False), + Matrix.Identity(4), active=True, + ) + np.testing.assert_allclose(descriptor["rgba"][..., :3], expected[..., :3]) + np.testing.assert_array_equal(descriptor["pixels"], 0) + framed = edit_frame.composite_frame_pixels( + (descriptor,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4) + ) + result = image_data.create_image_edit_result(source.data, framed, (4, 4)) + image_data.replace_empty_image(source, result) + assert result.alpha_mode == "PREMUL" + assert source.use_empty_image_alpha + assert result.packed_file is not None + packed = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255.0 + np.testing.assert_allclose(packed[..., :3], expected[..., :3], atol=1 / 255) + np.testing.assert_array_equal(packed[..., 3], 0) + result.reload() + assert result.alpha_mode == "PREMUL" + operator.mode = "ADD" + assert edit_mask.EditImageAlpha._apply_mask( + operator, SimpleNamespace(), source, selection + ) == {"FINISHED"} + restored = image_data.image_rgba(source.data) + np.testing.assert_allclose(restored[..., :3], expected[..., :3], atol=1 / 255) + np.testing.assert_array_equal(restored[..., 3], 1) + + +def test_frame_restored_alpha_keeps_foreground_blended_into_white(): + from io import BytesIO + from PIL import Image + + bpy.ops.wm.read_factory_settings(use_empty=True) + sources = [] + for name, color, depth in (("Background", (1, 1, 1, 0), 2), + ("Foreground", (0.2, 0.4, 0.6, 1), 1)): + image = bpy.data.images.new(name, width=6, height=2, alpha=True) + rgba = np.tile(color, (2, 6, 1)).astype(np.float32) + if name == "Foreground": + rgba[..., 3] = (0, 0.01, 0.1, 0.25, 0.5, 1) + rgba[:, 0, :3] = 0 + image.pixels.foreach_set(np.flipud(rgba).ravel()) + image.update() + source = bpy.data.objects.new(name, None) + source.empty_display_type = "IMAGE" + source.empty_display_size = 2 + source.empty_image_offset = (-0.5, -0.5) + source.data = image + source.matrix_world = Matrix.Translation((0, 0, -depth)) + bpy.context.collection.objects.link(source) + sources.append(edit_frame._frame_source( + source, SimpleNamespace(width=6, height=2), + SimpleNamespace(perspective_matrix=Matrix.Identity(4), is_perspective=False), + Matrix.Identity(4), active=name == "Foreground", + )) + front = sources[1] + source_alpha = front["rgba"][..., 3:4] + expected = front["rgba"][..., :3] * source_alpha + 1 - source_alpha + # The image aspect makes its orthographic screen projection two thirds of a pixel high. + pixels = edit_frame.composite_frame_pixels( + sources, ((0, 4 / 3), (6, 4 / 3), (6, 2 / 3), (0, 2 / 3)), (6, 2) + ) + result = image_data.create_image_edit_result(front["object"].data, pixels, (6, 2)) + image_data.replace_empty_image(front["object"], result) + encoded = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255 + np.testing.assert_allclose(encoded[..., :3], expected, atol=1 / 255) + np.testing.assert_allclose(encoded[..., 3:4], source_alpha, atol=1 / 255) + selection = SelectionMask(np.ones((2, 6), dtype=np.float32), (0, 0, 6, 2)) + operator = SimpleNamespace(mode="ADD", report=lambda *_args: None) + assert edit_mask.EditImageAlpha._apply_mask( + operator, SimpleNamespace(), front["object"], selection + ) == {"FINISHED"} + restored = image_data.image_rgba(front["object"].data) + np.testing.assert_allclose(restored[..., :3], expected, atol=1 / 255) + np.testing.assert_array_equal(restored[..., 3], 1) + assert np.all(np.diff(restored[0, :, 0]) <= 0) + np.testing.assert_array_equal(restored[0, 0, :3], 1) + + +def test_brush_release_commits_one_packed_image_without_touching_shared_source(): + bpy.ops.wm.read_factory_settings(use_empty=True) + source_image = bpy.data.images.new("Mask Source", width=8, height=6, alpha=True) + source_rgba = np.ones((6, 8, 4), dtype=np.float32) + source_rgba[:, :, :3] = (0.2, 0.4, 0.6) + source_image.pixels.foreach_set(np.flipud(source_rgba).ravel()) + source_image.update() + source = bpy.data.objects.new("Mask Source", None) + source.empty_display_type = "IMAGE" + source.data = source_image + source.empty_display_size = 3.0 + source.empty_image_offset = (-0.25, -0.75) + source.matrix_world = Matrix.Translation((1.0, 2.0, 3.0)) + shared = bpy.data.objects.new("Mask Shared", None) + shared.empty_display_type = "IMAGE" + shared.data = source_image + bpy.context.collection.objects.link(source) + bpy.context.collection.objects.link(shared) + matrix = source.matrix_world.copy() + + selection_mask = SelectionMask( + np.ones((2, 3), dtype=np.float32), + (2, 1, 5, 3), + ) + operator = SimpleNamespace(mode="SUBTRACT", report=lambda *_args: None) + status = edit_mask.EditImageAlpha._apply_mask( + operator, + SimpleNamespace(), + source, + selection_mask, + ) + + result = source.data + assert status == {"FINISHED"} + assert source.data == result + assert result != source_image + assert result.packed_file is not None + assert shared.data == source_image + np.testing.assert_allclose(image_data.image_rgba(source_image), source_rgba) + result_rgba = image_data.image_rgba(result) + np.testing.assert_array_equal(result_rgba[1:3, 2:5, 3], 0.0) + assert source.matrix_world == matrix + assert source.empty_display_size == 3.0 + assert tuple(source.empty_image_offset) == (-0.25, -0.75) + + +def test_brush_drag_does_not_create_or_update_an_image(): + bpy.ops.wm.read_factory_settings(use_empty=True) + source_image = bpy.data.images.new("Mask Cancel", width=4, height=3, alpha=True) + source = bpy.data.objects.new("Mask Cancel", None) + source.empty_display_type = "IMAGE" + source.data = source_image + bpy.context.collection.objects.link(source) + image_count = len(bpy.data.images) + shared = bpy.data.objects.new("Mask Cancel shared", None) + shared.empty_display_type = "IMAGE" + shared.data = source_image + before = np.array(source_image.pixels[:]) + operator = edit_mask.ImageGesture() + operator.active_tool_id = "anyimage.mask" + operator.gesture = "BRUSH" + operator._path = [(10.0, 10.0)] + operator._path_samples = list(operator._path) + operator._cursor = (10.0, 10.0) + context = SimpleNamespace(area=SimpleNamespace(tag_redraw=lambda: None), + workspace=SimpleNamespace(status_text_set=lambda value: None)) + event = SimpleNamespace( + type="MOUSEMOVE", + value="NOTHING", + mouse_region_x=20, + mouse_region_y=25, + ) + + result = edit_mask.ImageGesture.modal(operator, context, event) + + assert result == {"RUNNING_MODAL"} + assert source.data == source_image + assert len(bpy.data.images) == image_count + event.type, event.value = "ESC", "PRESS" + assert operator.modal(context, event) == {"CANCELLED"} + assert source.data == shared.data == source_image + np.testing.assert_array_equal(source_image.pixels[:], before) + assert len(bpy.data.images) == image_count + assert operator._path is operator._path_samples is operator._brush_preview is None + + def test_object_normal_image_follows_cutout_inward_axis(): bpy.ops.wm.read_factory_settings(use_empty=True) image = bpy.data.images.new( @@ -222,7 +383,15 @@ def test_object_normal_image_follows_cutout_inward_axis(): actual = np.empty(4, dtype=np.float32) image.pixels.foreach_get(actual) - expected = np.asarray((0.4, 0.8, -0.2), dtype=np.float32) * 0.5 + 0.5 + assert np.allclose(actual[:3], encoded, atol=1e-6) + assert np.isclose(actual[3], 0.3) + + cutout_object.orient_object_normal_image( + image, + cutout_object.CutoutInwardAxis.POSITIVE_X, + ) + image.pixels.foreach_get(actual) + expected = np.asarray((-0.8, -0.2, -0.4), dtype=np.float32) * 0.5 + 0.5 assert np.allclose(actual[:3], expected, atol=1e-6) assert np.isclose(actual[3], 0.3) @@ -255,6 +424,13 @@ def test_cutout_axes_produce_the_same_world_geometry(shape): inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z, ) + for result in (positive_x, negative_z): + local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices]) + assert np.allclose(local[:, 2], 0.0, atol=1e-8) + assert np.ptp(local[:, 0]) > 0.0 + assert np.ptp(local[:, 1]) > 0.0 + assert all(polygon.normal.z > 0.99 for polygon in result.data.polygons) + def world_positions(obj): local = _evaluated_positions(obj) return np.asarray([tuple(obj.matrix_world @ Vector(point)) for point in local]) @@ -393,6 +569,29 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups(): and link.to_socket.name == "Menu" for link in cutout.links ) + axis_switch = next( + node + for node in cutout.nodes + if node.bl_idname == "GeometryNodeMenuSwitch" + and [item.name for item in node.enum_items] == ["-Z", "+X"] + ) + negative_z_link = next( + link + for link in cutout.links + if link.to_node == axis_switch and link.to_socket.name == "-Z" + ) + positive_x_link = next( + link + for link in cutout.links + if link.to_node == axis_switch and link.to_socket.name == "+X" + ) + assert positive_x_link.from_node.bl_idname == "GeometryNodeTransform" + assert any( + link.from_socket == negative_z_link.from_socket + and link.to_node == positive_x_link.from_node + and link.to_socket.name == "Geometry" + for link in cutout.links + ) child_groups = { shape: bpy.data.node_groups[name] @@ -817,7 +1016,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): 8, ) - assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) + assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices) assert len(result.data.vertices) > 4 assert len(result.data.polygons) > 1 assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes @@ -845,7 +1044,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet(): assert normal_scale_link.from_node == normal_scale_attribute assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"] assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME - inward = result.matrix_world.to_3x3() @ Vector((1.0, 0.0, 0.0)) + inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0)) assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6) @@ -991,7 +1190,7 @@ def test_depth_surface_protected_smooth_evaluates(): depth_metadata=depth_metadata, ) - assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) + assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices) assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes modifier = result.modifiers[0] assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME @@ -1030,9 +1229,9 @@ def test_depth_surface_protected_smooth_evaluates(): evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get()) baseline_mesh = evaluated.to_mesh() try: - baseline_x = np.asarray([vertex.co.x for vertex in baseline_mesh.vertices]) - baseline_min = baseline_x.min() - baseline_max = baseline_x.max() + baseline_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices]) + baseline_min = baseline_z.min() + baseline_max = baseline_z.max() finally: evaluated.to_mesh_clear() @@ -1047,9 +1246,9 @@ def test_depth_surface_protected_smooth_evaluates(): evaluated_mesh = evaluated.to_mesh() try: assert len(evaluated_mesh.polygons) > 0 - x = np.asarray([vertex.co.x for vertex in evaluated_mesh.vertices]) - assert np.isclose(x.min(), baseline_min, atol=1e-5) - assert np.isclose(x.max(), baseline_max + 1.0, atol=1e-5) + z = np.asarray([vertex.co.z for vertex in evaluated_mesh.vertices]) + assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5) + assert np.isclose(z.max(), baseline_max, atol=1e-5) finally: evaluated.to_mesh_clear() @@ -1112,9 +1311,9 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge(): positions = _evaluated_positions(result) assert np.isfinite(positions).all() - assert np.ptp(positions[:, 0]) < 1e-4 + assert np.ptp(positions[:, 0]) > 1.5 assert np.ptp(positions[:, 1]) > 1.5 - assert np.ptp(positions[:, 2]) > 1.5 + assert np.ptp(positions[:, 2]) < 1e-4 def _evaluated_positions(obj): @@ -1324,7 +1523,7 @@ def _add_cutout_modifier(obj, shape): object_data.set_modifier_input( modifier, object_data.modifier_input_identifier(group, "Inward Axis"), - 1, + 0, ) object_data.set_modifier_input( modifier, @@ -1345,26 +1544,26 @@ def _add_cutout_modifier(obj, shape): def _depth_surface_stretch_object(): - vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)] + vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)] faces = [] - for z in range(2): - for y in range(2): - a = z * 3 + y - faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4))) + for y in range(2): + for x in range(2): + a = y * 3 + x + faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) data = bpy.data.meshes.new("Depth Surface Stretch") data.from_pydata(vertices, (), faces) uv_layer = data.uv_layers.new(name="UVMap") for loop in data.loops: coordinate = data.vertices[loop.vertex_index].co - uv_layer.data[loop.index].uv = (coordinate.y * 0.5, coordinate.z * 0.5) + uv_layer.data[loop.index].uv = (coordinate.x * 0.5, coordinate.y * 0.5) obj = bpy.data.objects.new("Depth Surface Stretch", data) bpy.context.collection.objects.link(obj) modifier = _add_cutout_modifier(obj, "DEPTH_SURFACE") points = np.empty((3, 3, 3), dtype=np.float32) - for z in range(3): - for y in range(3): - points[z, y] = (-float(y), -float(z), 1.0) + for y in range(3): + for x in range(3): + points[y, x] = (float(x), -float(y), 1.0) points[0, 0, 2] = 6.0 depth_image = _depth_image(points, np.ones((3, 3), dtype=bool)) for name, value in ( @@ -1452,12 +1651,12 @@ def test_depth_surface_stretch_limit_tracks_projection_and_cleans_loose_topology def test_cutout_thickness_keeps_one_connected_shell(): bpy.ops.wm.read_factory_settings(use_empty=True) - vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)] + vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)] faces = [] - for z in range(2): - for y in range(2): - a = z * 3 + y - faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4))) + for y in range(2): + for x in range(2): + a = y * 3 + x + faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) data = bpy.data.meshes.new("Connected Cutout") data.from_pydata(vertices, (), faces) obj = bpy.data.objects.new("Connected Cutout", data) @@ -1480,20 +1679,20 @@ def test_cutout_thickness_keeps_one_connected_shell(): try: assert len(result.vertices) == 18 assert len(result.polygons) == 24 - x = np.asarray([vertex.co.x for vertex in result.vertices]) - assert np.isclose(x.min(), -0.5, atol=1e-6) - assert np.isclose(x.max(), 0.5, atol=1e-6) + z = np.asarray([vertex.co.z for vertex in result.vertices]) + assert np.isclose(z.min(), -0.5, atol=1e-6) + assert np.isclose(z.max(), 0.5, atol=1e-6) front_faces = [ - polygon for polygon in result.polygons if polygon.normal.x < -0.9 + polygon for polygon in result.polygons if polygon.normal.z > 0.9 ] - back_faces = [polygon for polygon in result.polygons if polygon.normal.x > 0.9] + back_faces = [polygon for polygon in result.polygons if polygon.normal.z < -0.9] side_faces = [ - polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1 + polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1 ] assert len(front_faces) == 8 assert len(back_faces) == 8 assert len(side_faces) == 8 - center = Vector((0.0, 1.0, 1.0)) + center = Vector((1.0, 1.0, 0.0)) for polygon in side_faces: radial = polygon.center - center assert polygon.normal.dot(radial) > 0.0 @@ -1513,7 +1712,7 @@ def test_cutout_thickness_keeps_one_connected_shell(): assert len(result.vertices) == 34 assert len(result.polygons) == 40 side_faces = [ - polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1 + polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1 ] assert len(side_faces) == 24 for polygon in side_faces: @@ -1529,11 +1728,11 @@ def test_cutout_thickness_keeps_one_connected_shell(): def test_balloon_uses_injected_profile_at_default_height(): bpy.ops.wm.read_factory_settings(use_empty=True) - vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)] + vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)] faces = [] - for z in range(2): - for y in range(2): - a = z * 3 + y + for y in range(2): + for x in range(2): + a = y * 3 + x faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) data = bpy.data.meshes.new("Balloon Profile") data.from_pydata(vertices, (), faces) @@ -1559,11 +1758,11 @@ def test_balloon_uses_injected_profile_at_default_height(): bpy.context.view_layer.update() positions = _evaluated_positions(obj) - assert np.isclose(positions[:, 0].min(), -1.0, atol=1e-6) - assert np.isclose(positions[:, 0].max(), 1.0, atol=1e-6) + assert np.isclose(positions[:, 2].min(), -1.0, atol=1e-6) + assert np.isclose(positions[:, 2].max(), 1.0, atol=1e-6) assert np.isclose( - positions[:, 0].min(), - -positions[:, 0].max(), + positions[:, 2].min(), + -positions[:, 2].max(), atol=1e-5, ) evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) @@ -1583,9 +1782,9 @@ def test_balloon_uses_injected_profile_at_default_height(): result = evaluated.to_mesh() try: assert len(result.polygons) == baseline_face_count - x = np.asarray([vertex.co.x for vertex in result.vertices]) - assert np.isclose(x.min(), -2.0, atol=1e-6) - assert np.isclose(x.max(), 2.0, atol=1e-6) + z = np.asarray([vertex.co.z for vertex in result.vertices]) + assert np.isclose(z.min(), -2.0, atol=1e-6) + assert np.isclose(z.max(), 2.0, atol=1e-6) finally: evaluated.to_mesh_clear() object_data.set_modifier_input( @@ -1597,21 +1796,21 @@ def test_balloon_uses_injected_profile_at_default_height(): bpy.context.view_layer.update() zero_positions = _evaluated_positions(obj) assert len(zero_positions) == len(vertices) - assert np.allclose(zero_positions[:, 0], 0.0, atol=1e-6) + assert np.allclose(zero_positions[:, 2], 0.0, atol=1e-6) def test_cutout_cleanup_uses_absolute_area_with_many_small_islands(): bpy.ops.wm.read_factory_settings(use_empty=True) - vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)] + vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)] faces = [] - for z in range(2): - for y in range(2): - a = z * 3 + y + for y in range(2): + for x in range(2): + a = y * 3 + x faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) for island in range(12): start = len(vertices) - y = 5.0 + island * 0.1 - vertices.extend(((0.0, y, 5.0), (0.0, y + 0.01, 5.0), (0.0, y, 5.01))) + x = 5.0 + island * 0.1 + vertices.extend(((x, 5.0, 0.0), (x + 0.01, 5.0, 0.0), (x, 5.01, 0.0))) faces.append((start, start + 1, start + 2)) data = bpy.data.meshes.new("Cleanup") data.from_pydata(vertices, (), faces) @@ -1674,7 +1873,7 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): selection_values=selection_values, ) - assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) + assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices) assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes assert "o_balloon_depth" not in result.data.attributes assert "o_depth_offset" not in result.data.attributes @@ -1733,16 +1932,16 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() positions = _evaluated_positions(result) - center_distance = np.linalg.norm(positions[:, 1:] - expected_center, axis=1) + center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1) nearest_center = center_distance <= center_distance.min() + 1e-6 - depth_at_thickness_one = positions[nearest_center, 0].min() + depth_at_thickness_one = positions[nearest_center, 2].max() object_data.set_modifier_input(modifier, thickness_id, 2.0) result.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() positions = _evaluated_positions(result) - center_distance = np.linalg.norm(positions[:, 1:] - expected_center, axis=1) + center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1) nearest_center = center_distance <= center_distance.min() + 1e-6 - depth_at_thickness_two = positions[nearest_center, 0].min() + depth_at_thickness_two = positions[nearest_center, 2].max() assert np.isclose( depth_at_thickness_one, depth_at_thickness_two, @@ -1762,8 +1961,8 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): bpy.context.view_layer.update() after_positions = _evaluated_positions(result) assert np.isclose( - (after_positions[:, 0] - before_positions[:, 0]).min(), - -0.2, + (after_positions[:, 2] - before_positions[:, 2]).max(), + 0.2, atol=1e-5, ) @@ -1797,14 +1996,14 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller(): mirrored = _evaluated_positions(result) layers = {} for x, y, z in mirrored: - layers.setdefault((round(float(y), 6), round(float(z), 6)), []).append(x) + layers.setdefault((round(float(x), 6), round(float(y), 6)), []).append(z) assert layers - for x_values in layers.values(): - if len(x_values) == 1: - assert np.isclose(x_values[0], 0.0, atol=1e-5) + for z_values in layers.values(): + if len(z_values) == 1: + assert np.isclose(z_values[0], 0.0, atol=1e-5) continue assert np.isclose( - min(x_values) + max(x_values), + min(z_values) + max(z_values), 0.0, atol=1e-5, ) @@ -1990,7 +2189,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job(): assert len(result.data.vertices) > 4 assert len(result.data.polygons) > 1 assert all(polygon.use_smooth for polygon in result.data.polygons) - assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices) + assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices) assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes source_inverse = source.matrix_world.inverted() source_x = [ diff --git a/tests/test_extension_packaging.py b/tests/test_extension_packaging.py index d6dc93f..9f1ed16 100644 --- a/tests/test_extension_packaging.py +++ b/tests/test_extension_packaging.py @@ -77,12 +77,10 @@ class ExtensionPackagingTest(unittest.TestCase): "operators/convert_to_plane/object.py", "operators/convert_to_plane/conversion.py", "operators/convert_to_plane/mesh_plane.py", - "operators/crop_tool/__init__.py", - "operators/crop_tool/selection.py", - "operators/crop_tool/box.py", - "operators/crop_tool/lasso.py", - "operators/crop_tool/polyline.py", - "operators/crop_tool/perspective.py", + "operators/image_edit_tool/__init__.py", + "operators/image_edit_tool/frame.py", + "operators/image_edit_tool/mask.py", + "operators/image_edit_tool/rectify.py", "operators/ai_setup.py", "operators/remove_background.py", ): @@ -102,8 +100,6 @@ class ExtensionPackagingTest(unittest.TestCase): self.assertIn("server/geometry/projection.py", names) self.assertIn("server/models/geometry.py", names) self.assertIn("server/jobs/remove_background.py", names) - self.assertIn("server/jobs/refine_image_selection.py", names) - self.assertIn("server/selection/refine_selection.py", names) self.assertIn("server/jobs/cutout.py", names) self.assertIn("server/jobs/depth_plane.py", names) self.assertIn("server/geometry/depth_texture.py", names) diff --git a/tests/test_frame_antialiasing.py b/tests/test_frame_antialiasing.py new file mode 100644 index 0000000..c68a4ad --- /dev/null +++ b/tests/test_frame_antialiasing.py @@ -0,0 +1,136 @@ +import sys +from pathlib import Path +from unittest.mock import patch + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) + +from anyimage.common.image import premultiplied_rgba +from anyimage.operators.image_edit_tool import frame + + +def source(rgba, *, bounds=(-1, 1, -1, 1), depth=1, slope=0, active=True): + rgba = np.asarray(rgba, dtype=np.float32) + height, width = rgba.shape[:2] + world = np.eye(4) + world[2, 3] = -depth + world[2, 0] = slope + result = frame.frame_source_projection( + np.eye(4), np.eye(4), world, bounds, (1, 1), perspective=False + ) + result.update( + rgba=rgba, pixels=premultiplied_rgba(np.flipud(rgba).ravel(), (width, height)), + image_size=(width, height), active=active, name="Active" if active else "Bottom", + ) + return result + + +def render(sources, size=(1, 1)): + return np.flipud(frame.composite_frame_pixels( + sources, ((0, 1), (1, 1), (1, 0), (0, 0)), size + ).reshape(size[1], size[0], 4)) + + +def test_white_half_coverage_does_not_mix_canvas_black(): + image = source([[[1, 1, 1, 1]]], bounds=(-1, 0, -1, 1)) + np.testing.assert_allclose(render((image,)), [[[1, 1, 1, 0.5]]]) + + +def test_uncovered_canvas_has_no_rgb_to_reveal_with_mask_add(): + from anyimage.common.selection import SelectionMask + from anyimage.operators.image_edit_tool.mask import apply_alpha_mask + + result = render((source([[[1, 1, 1, 1]]], bounds=(-0.5, 0.5, -0.5, 0.5)),), (8, 8)) + uncovered = result[..., 3] == 0 + assert np.any(uncovered) + np.testing.assert_array_equal(result[uncovered], 0) + restored = np.flipud(apply_alpha_mask( + result, SelectionMask(np.ones((8, 8)), (0, 0, 8, 8)), "ADD" + ).reshape((8, 8, 4))) + np.testing.assert_array_equal(restored[..., :3][uncovered], 0) + np.testing.assert_array_equal(restored[..., 3], 1) + + +def test_partial_foreground_over_transparent_blue(): + bottom = source([[[0, 0, 1, 0]]], depth=2, active=False) + top = source([[[1, 0, 0, 0.5]]], bounds=(-1, 0, -1, 1)) + np.testing.assert_allclose(render((bottom, top)), [[[0.25, 0, 0.75, 0.25]]]) + + +@pytest.mark.parametrize("alpha", [0, 1e-12, 1e-8, 1e-7]) +def test_near_zero_alpha_keeps_covered_rgb(alpha): + result = render((source([[[0.2, 0.4, 0.8, alpha]]]),)) + np.testing.assert_allclose(result[..., :3], [[[0.2, 0.4, 0.8]]]) + np.testing.assert_allclose(result[..., 3], 0 if alpha <= 1e-8 else alpha, atol=1e-15) + + +def test_narrow_source_missed_by_center_is_sampled(): + image = source([[[1, 1, 1, 1]]], bounds=(-0.8, -0.7, -1, 1)) + np.testing.assert_allclose(render((image,)), [[[1, 1, 1, 0.25]]]) + + +@pytest.mark.parametrize("alpha", [0, 1]) +def test_crossing_planes_resolve_per_sample_depth(alpha): + first = source([[[1, 0, 0, alpha]]], slope=1) + second = source([[[0, 0, 1, alpha]]], slope=-1, active=False) + np.testing.assert_allclose(render((first, second)), [[[0.5, 0, 0.5, alpha]]]) + + +def test_aligned_interior_keeps_checkerboard(): + rgba = np.ones((8, 8, 4), dtype=np.float32) + rgba[..., :3] = (np.indices((8, 8)).sum(axis=0) % 2)[..., None] + np.testing.assert_allclose(render((source(rgba),), (8, 8)), rgba) + + +def test_oblique_boundary_matches_reference_and_chunk_sizes(): + top = source([[[1, 0, 0, 0.5]]], bounds=(-0.7, 0.7, -0.7, 0.7)) + angle = 0.3 + world = np.eye(4) + world[:2, :2] = ((np.cos(angle), -np.sin(angle)), (np.sin(angle), np.cos(angle))) + world[2, 3] = -1 + top.update(frame.frame_source_projection( + np.eye(4), np.eye(4), world, (-0.7, 0.7, -0.7, 0.7), (1, 1), perspective=False + )) + bottom = source([[[0, 0, 1, 0]]], depth=2, active=False) + sources = (bottom, top) + size = 17 + reference = np.zeros((size, size, 4), dtype=np.float32) + for dy in (0.125, 0.375, 0.625, 0.875): + for dx in (0.125, 0.375, 0.625, 0.875): + x, y = np.meshgrid((np.arange(size) + dx) / size, 1 - (np.arange(size) + dy) / size) + sample, _ = frame._composite_frame_samples(sources, np.stack((x, y), axis=-1)) + reference += sample / 16 + for rows in (1, 3, 256): + with patch.object(frame, "PROJECTIVE_CHUNK_ROWS", rows): + np.testing.assert_allclose(render(sources, (size, size)), reference, atol=1e-6) + + +@pytest.mark.parametrize("background", [0, 1]) +def test_packed_frame_mask_add_keeps_boundary_color(background): + from io import BytesIO + import bpy + from PIL import Image + from anyimage.common.image import create_image_edit_result, image_rgba + from anyimage.common.selection import SelectionMask + from anyimage.operators.image_edit_tool.mask import apply_alpha_mask + + bottom = source([[[background, background, background, 0]]], depth=2, active=False) + top = source([[[1, 0, 0, 1]]], bounds=(-1, 0, -1, 1)) + rgba = render((bottom, top)) + original = bpy.data.images.new("Boundary source", width=1, height=1, alpha=True) + result = create_image_edit_result(original, rgba.ravel(), (1, 1)) + try: + packed = np.asarray(Image.open(BytesIO(result.packed_file.data))) / 255 + expected = (np.array([1, 0, 0]) + background) / 2 + np.testing.assert_allclose(packed[0, 0, :3], expected, atol=1 / 255) + restored = apply_alpha_mask( + image_rgba(result), SelectionMask(np.ones((1, 1)), (0, 0, 1, 1)), "ADD" + ).reshape(1, 1, 4) + np.testing.assert_allclose(restored[0, 0, :3], expected, atol=1 / 255) + assert restored[0, 0, 3] == 1 + assert result.alpha_mode == "PREMUL" + finally: + bpy.data.images.remove(original) + bpy.data.images.remove(result) diff --git a/tests/test_frame_projection.py b/tests/test_frame_projection.py new file mode 100644 index 0000000..783ace0 --- /dev/null +++ b/tests/test_frame_projection.py @@ -0,0 +1,40 @@ +import sys +from pathlib import Path + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) + +from anyimage.common.selection import clip_polygon_halfplanes +from anyimage.operators.image_edit_tool import frame + + +@pytest.mark.parametrize("focal", [1, 1.7, 2, 4]) +@pytest.mark.parametrize("angle,depth", [(30, 0.2), (0, 1e-14)]) +def test_frame_overlap_matches_finite_front_samples(focal, angle, depth): + projection = np.eye(4) + projection[0, 0] = projection[1, 1] = focal + projection[3] = (0, 0, -1, 0) + c, s = np.cos(np.radians(angle)), np.sin(np.radians(angle)) + world = np.eye(4) + world[:3, :3] = ((c, 0, s), (0, 1, 0), (-s, 0, c)) + world[2, 3] = -depth + source = frame.frame_source_projection( + projection, np.eye(4), world, (-1, 1, -1, 1), (100, 100), perspective=True + ) + source["image_size"] = (100, 100) + assert frame.frame_source_coordinates(source, np.array(((50, 50),)))[2][0] + overlap = frame.frame_source_overlap(source, ((0, 100), (100, 100), (100, 0), (0, 0))) + assert len(overlap) >= 3 + assert np.isfinite(overlap).all() + + +@pytest.mark.parametrize("scale", [1e-14, 1, 1e14]) +def test_halfplane_clipping_is_scale_invariant(scale): + square = ((0, 0), (2, 0), (2, 2), (0, 2)) + result = clip_polygon_halfplanes(square, ((-scale, 0, scale),)) + assert set(result) == {(0, 0), (1, 0), (1, 2), (0, 2)} + assert clip_polygon_halfplanes(square, ((1, 0, -3),)) == () + edge = clip_polygon_halfplanes(square, ((1, 0, -2),)) + assert all(x == 2 for x, y in edge) diff --git a/tests/test_image_premul_retention.py b/tests/test_image_premul_retention.py new file mode 100644 index 0000000..96da4cd --- /dev/null +++ b/tests/test_image_premul_retention.py @@ -0,0 +1,50 @@ +from io import BytesIO + +import bpy +import numpy as np +import pytest +from PIL import Image + + +@pytest.mark.parametrize("loaded_png", [False, True]) +@pytest.mark.parametrize("mode_before_pack", [False, True]) +def test_premul_byte_reload_requires_straight_for_original_rgb( + tmp_path, loaded_png, mode_before_pack +): + """Characterize the reload mismatch blocking automatic PREMUL defaults.""" + rgba = np.array( + [[51, 102, 204, alpha] for alpha in (0, 64, 128, 255)], dtype=np.uint8 + ).reshape(1, 4, 4) + expected = rgba.reshape(4, 4) / 255 + path = tmp_path / "source.png" + Image.fromarray(rgba).save(path) + if loaded_png: + image = bpy.data.images.load(str(path), check_existing=False) + else: + image = bpy.data.images.new("Premul probe", width=4, height=1, alpha=True) + image.pixels.foreach_set(expected.astype(np.float32).ravel()) + image.use_fake_user = True + image.alpha_mode = "STRAIGHT" + if mode_before_pack: + image.alpha_mode = "PREMUL" + image.pack() + image.alpha_mode = "PREMUL" + packed = np.asarray(Image.open(BytesIO(image.packed_file.data))) + np.testing.assert_array_equal(packed, rgba) + name = image.name + blend_path = str(tmp_path / "retention.blend") + bpy.ops.wm.save_as_mainfile(filepath=blend_path) + bpy.ops.wm.open_mainfile(filepath=blend_path) + restored = bpy.data.images[name] + assert restored.alpha_mode == "PREMUL" + interpreted = np.array(restored.pixels[:]).reshape(4, 4) + np.testing.assert_allclose(interpreted[0, :3], 1) + assert not np.allclose(interpreted, expected, atol=1 / 255) + restored.alpha_mode = "STRAIGHT" + np.testing.assert_allclose( + np.array(restored.pixels[:]).reshape(4, 4), expected, atol=1 / 255 + ) + restored.alpha_mode = "PREMUL" + np.testing.assert_allclose( + np.array(restored.pixels[:]).reshape(4, 4), interpreted, atol=1 / 255 + ) diff --git a/tests/test_mask_undo.py b/tests/test_mask_undo.py new file mode 100644 index 0000000..f497cbb --- /dev/null +++ b/tests/test_mask_undo.py @@ -0,0 +1,68 @@ +import sys +from pathlib import Path +from unittest.mock import patch + +import bpy +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parents[1] / "src")) + +from anyimage.common import image, viewport +from anyimage.operators.image_edit_tool import mask + + +@pytest.mark.parametrize("gesture", ["LASSO", "BRUSH", "POLYLINE"]) +def test_mask_commit_is_one_undo_transaction(gesture): + source = bpy.data.images.new("Undo source", width=8, height=8, alpha=True) + source.pixels.foreach_set(np.ones(8 * 8 * 4, dtype=np.float32)) + source.pack() + owner = bpy.data.objects.new("Mask undo owner", None) + owner.empty_display_type = "IMAGE" + owner.data = source + bpy.context.scene.collection.objects.link(owner) + shared = bpy.data.objects.new("Mask undo shared", None) + shared.empty_display_type = "IMAGE" + shared.data = source + bpy.context.scene.collection.objects.link(shared) + owner_name, shared_name, source_name = owner.name, shared.name, source.name + before = image.image_pixels(source) + options = mask.EditImageAlpha.bl_options + assert options == {"UNDO"} + + def execute(operator, context): + """Drive gesture completion without a window event in background bpy.""" + operator.gesture, operator.mode, operator.radius = gesture, "SUBTRACT", 2 + operator.source_object_name = owner_name + operator.source_matrix_data = viewport.serialize_matrix(owner.matrix_world) + operator._path = [(2, 2), (6, 2), (6, 6), (2, 6)] + operator._path_samples = list(operator._path) + return operator._complete(context) + + bpy.context.preferences.edit.use_global_undo = True + with patch.object(mask.EditImageAlpha, "execute", execute, create=True), patch.object( + mask.EditImageAlpha, "poll", classmethod(lambda cls, context: True) + ), patch.object(viewport, "screen_path_to_image_pixels", side_effect=lambda path, *args, **kwargs: path), patch.object( + mask, "screen_path_to_image_pixels", side_effect=lambda path, *args, **kwargs: path + ), patch.object(mask, "create_image_edit_result", wraps=mask.create_image_edit_result) as create: + bpy.utils.register_class(mask.EditImageAlpha) + try: + bpy.ops.ed.undo_push(message="Before Mask") + assert bpy.ops.anyimage.edit_image_alpha("EXEC_DEFAULT", True) == {"FINISHED"} + create.assert_called_once() + result = bpy.data.objects[owner_name].data + assert result.packed_file is not None and result.alpha_mode == "PREMUL" + assert bpy.data.objects[shared_name].data.name == source_name + np.testing.assert_array_equal(image.image_pixels(bpy.data.images[source_name]), before) + assert bpy.ops.ed.undo() == {"FINISHED"} + assert bpy.data.objects[owner_name].data.name == source_name + np.testing.assert_array_equal(image.image_pixels(bpy.data.objects[owner_name].data), before) + finally: + bpy.utils.unregister_class(mask.EditImageAlpha) + for name in (owner_name, shared_name): + obj = bpy.data.objects.get(name) + if obj is not None: + bpy.data.objects.remove(obj, do_unlink=True) + source = bpy.data.images.get(source_name) + if source is not None: + bpy.data.images.remove(source) diff --git a/tests/test_selection.py b/tests/test_selection.py index 81054f7..2878567 100644 --- a/tests/test_selection.py +++ b/tests/test_selection.py @@ -37,8 +37,6 @@ def reference_selection_mask( if antialias else mask.astype(np.float32) ) - if selection_path.invert: - mask = 1.0 - mask bounds = selection._selection_canvas_bounds( image_size, selection_path, @@ -73,45 +71,25 @@ def assert_matches_reference(image_size, selection_path, **options): np.testing.assert_array_equal(actual.values, expected.values) -def test_selection_path_json_contains_only_points_and_invert(): +def test_selection_path_json_contains_only_points(): selection_path = selection.SelectionPath( points=((1, 2), (8, 2), (4, 9)), - invert=True, ) value = selection_path.to_json() - assert set(json.loads(value)) == {"points", "invert"} + assert set(json.loads(value)) == {"points"} assert selection.SelectionPath.from_json(value) == selection_path with pytest.raises(ValueError, match="Selection Path data is invalid"): selection.SelectionPath.from_json( json.dumps( { "points": selection_path.points, - "invert": selection_path.invert, "fill_rule": "NONZERO", } ) ) -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), @@ -144,13 +122,12 @@ def test_scanline_matches_reference_for_edge_cases(): ((-8, -3), (10, 1), (23, 8), (7, 18), (-4, 10)), ) for points in paths: - for invert in (False, True): - for antialias in (False, True): - assert_matches_reference( - (18, 14), - selection.SelectionPath(points, invert=invert), - antialias=antialias, - ) + for antialias in (False, True): + assert_matches_reference( + (18, 14), + selection.SelectionPath(points), + antialias=antialias, + ) def test_scanline_matches_reference_for_random_paths(): @@ -163,13 +140,12 @@ def test_scanline_matches_reference_for_random_paths(): random.uniform((-8, -6), (40, 30), size=(point_count, 2)), ) ) - for invert in (False, True): - for antialias in (False, True): - assert_matches_reference( - (32, 24), - selection.SelectionPath(points, invert=invert), - antialias=antialias, - ) + for antialias in (False, True): + assert_matches_reference( + (32, 24), + selection.SelectionPath(points), + antialias=antialias, + ) def test_rasterizer_has_no_image_content_parameters(): @@ -181,7 +157,7 @@ def test_rasterizer_has_no_image_content_parameters(): assert "alpha_threshold" not in parameters -def test_non_inverted_antialias_uses_only_padded_local_canvas(monkeypatch): +def test_antialias_uses_only_padded_local_canvas(monkeypatch): selection_path = selection.SelectionPath( ((100, 200), (500, 200), (500, 600), (100, 600)) ) @@ -203,25 +179,6 @@ def test_non_inverted_antialias_uses_only_padded_local_canvas(monkeypatch): assert calls == [(98, 198, 502, 602)] -def test_inverted_selection_uses_full_canvas(monkeypatch): - selection_path = selection.SelectionPath( - ((100, 200), (500, 200), (500, 600), (100, 600)), - invert=True, - ) - calls = [] - rasterize = selection._rasterize_path - - def record(bounds, points): - calls.append(bounds) - return rasterize(bounds, points) - - monkeypatch.setattr(selection, "_rasterize_path", record) - - selection.rasterize_selection_path((5120, 2880), selection_path) - - assert calls == [(0, 0, 5120, 2880)] - - def test_high_resolution_lasso_allocates_one_local_scanline_table(monkeypatch): angles = np.arange(1000, dtype=np.float64) * (2.0 * np.pi / 1000.0) points = tuple( diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index 73066cb..4213c05 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -343,7 +343,6 @@ class ServerRuntimeTest(unittest.TestCase): { "download-model", "download-required-models", - "refine-image-selection", "remove-background", "upscale-image", "generate-depth-plane-geometry", @@ -547,10 +546,10 @@ class ServerRuntimeTest(unittest.TestCase): encoded, np.asarray( [[ - (0, 127, 127), + (127, 127, 255), + (255, 127, 127), (127, 0, 127), - (127, 127, 0), - (0, 128, 128), + (128, 128, 255), ]], dtype=np.uint8, ), @@ -700,46 +699,6 @@ 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" @@ -789,91 +748,3 @@ class ServerRuntimeTest(unittest.TestCase): depth_mode="VECTOR", normal_mode="OBJECT", ) - - 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") - directory = self.storage_root / "job" - context = SimpleNamespace( - directory=directory, - progress=lambda *_args: None, - check_cancelled=lambda: None, - ) - - def generate(_context, _input, _model, _device, output): - output.write_bytes(b"refined") - return output - - with patch.object(job, "refine_selection", side_effect=generate): - result = job.run( - context, - { - "input": str(input_path), - "refine_selection": True, - "refine_model": "BEN2_BASE", - "device": "cpu", - }, - ) - - 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, - { - "input": str(input_path), - "refine_selection": True, - "refine_model": "BEN2_BASE", - "device": "cpu", - "depth_mode": "Z", - "normal_mode": "TANGENT", - }, - refined_path, - depth_mode="Z", - normal_mode="TANGENT", - ) diff --git a/tools/node_assets/build_cutout.py b/tools/node_assets/build_cutout.py index 52c6fcd..4ea6719 100644 --- a/tools/node_assets/build_cutout.py +++ b/tools/node_assets/build_cutout.py @@ -6,7 +6,7 @@ from node_graph import expand_smooth from anyimage.operators.cutout_tool.shape import ( BALLOON_ATTRIBUTE_NAME, - CUTOUT_AXIS_TO_SOURCE, + CUTOUT_Z_TO_X, CUTOUT_NODE_GROUP_NAME, CUTOUT_NODE_GROUP_NAMES, CUTOUT_SHAPE_LABELS, @@ -178,20 +178,20 @@ def _extrude_layers(group, geometry, location): node = group.nodes.new("GeometryNodeExtrudeMesh") node.mode = "FACES" node.inputs["Individual"].default_value = False - node.inputs["Offset"].default_value = (1.0, 0.0, 0.0) - # Keep distinct front and back vertices before overriding their X positions. + node.inputs["Offset"].default_value = (0.0, 0.0, 1.0) + # Keep distinct front and back vertices before overriding their Z positions. node.inputs["Offset Scale"].default_value = 1.0 node.location = location group.links.new(geometry, node.inputs["Mesh"]) return node -def _offset_geometry_x(group, geometry, x_value, location): +def _offset_geometry_z(group, geometry, z_value, location): nodes = group.nodes links = group.links offset = nodes.new("ShaderNodeCombineXYZ") offset.location = location - links.new(x_value, offset.inputs["X"]) + links.new(z_value, offset.inputs["Z"]) set_position = nodes.new("GeometryNodeSetPosition") set_position.location = (location[0] + 200.0, location[1]) links.new(geometry, set_position.inputs["Geometry"]) @@ -199,15 +199,15 @@ def _offset_geometry_x(group, geometry, x_value, location): return set_position.outputs["Geometry"] -def _set_layer_x(group, geometry, extrude, front_x, back_x, location): +def _set_layer_z(group, geometry, extrude, front_z, back_z, location): nodes = group.nodes links = group.links - choose_x = nodes.new("GeometryNodeSwitch") - choose_x.input_type = "FLOAT" - choose_x.location = (location[0], location[1] - 200.0) - links.new(extrude.outputs["Top"], choose_x.inputs["Switch"]) - links.new(front_x, choose_x.inputs["False"]) - links.new(back_x, choose_x.inputs["True"]) + choose_z = nodes.new("GeometryNodeSwitch") + choose_z.input_type = "FLOAT" + choose_z.location = (location[0], location[1] - 200.0) + links.new(extrude.outputs["Top"], choose_z.inputs["Switch"]) + links.new(front_z, choose_z.inputs["False"]) + links.new(back_z, choose_z.inputs["True"]) position = nodes.new("GeometryNodeInputPosition") position.location = (location[0], location[1] + 150.0) separate = nodes.new("ShaderNodeSeparateXYZ") @@ -215,9 +215,9 @@ def _set_layer_x(group, geometry, extrude, front_x, back_x, location): links.new(position.outputs["Position"], separate.inputs["Vector"]) combine = nodes.new("ShaderNodeCombineXYZ") combine.location = (location[0] + 400.0, location[1]) - links.new(choose_x.outputs["Output"], combine.inputs["X"]) + links.new(separate.outputs["X"], combine.inputs["X"]) links.new(separate.outputs["Y"], combine.inputs["Y"]) - links.new(separate.outputs["Z"], combine.inputs["Z"]) + links.new(choose_z.outputs["Output"], combine.inputs["Z"]) set_position = nodes.new("GeometryNodeSetPosition") set_position.location = (location[0] + 600.0, location[1]) links.new(extrude.outputs["Mesh"], set_position.inputs["Geometry"]) @@ -231,11 +231,11 @@ def _set_layer_x(group, geometry, extrude, front_x, back_x, location): flip_side = nodes.new("GeometryNodeFlipFaces") flip_side.location = (location[0] + 850.0, location[1] + 400.0) links.new(side.outputs["Selection"], flip_side.inputs["Mesh"]) - front = _offset_geometry_x( - group, geometry, front_x, (location[0], location[1] - 450.0) + front = _offset_geometry_z( + group, geometry, front_z, (location[0], location[1] - 450.0) ) - back = _offset_geometry_x( - group, geometry, back_x, (location[0], location[1] - 700.0) + back = _offset_geometry_z( + group, geometry, back_z, (location[0], location[1] - 700.0) ) flip_back = nodes.new("GeometryNodeFlipFaces") flip_back.location = (location[0] + 500.0, location[1] - 700.0) @@ -254,11 +254,11 @@ def _set_layer_x(group, geometry, extrude, front_x, back_x, location): return merge.outputs["Geometry"] -def _set_segmented_layer_x( +def _set_segmented_layer_z( group, geometry, - front_x, - back_x, + front_z, + back_z, thickness, location, ): @@ -310,7 +310,7 @@ def _set_segmented_layer_x( links.new(thickness, step.inputs[0]) links.new(segment_limit.outputs[0], step.inputs[1]) - front = _offset_geometry_x(group, geometry, front_x, (x, y + 100.0)) + front = _offset_geometry_z(group, geometry, front_z, (x, y + 100.0)) repeat_output = nodes.new("GeometryNodeRepeatOutput") repeat_output.location = (x + 2380.0, y + 100.0) repeat_input = nodes.new("GeometryNodeRepeatInput") @@ -343,17 +343,17 @@ def _set_segmented_layer_x( links.new(split_extrude.outputs["Inverted"], repeat_output.inputs["Geometry"]) links.new(join_sides.outputs["Geometry"], repeat_output.inputs["Sides"]) - positioned_side = _offset_geometry_x( + positioned_side = _offset_geometry_z( group, repeat_output.outputs["Sides"], - front_x, + front_z, (x + 2450.0, y + 350.0), ) flip_side = nodes.new("GeometryNodeFlipFaces") flip_side.location = (x + 2850.0, y + 350.0) links.new(positioned_side, flip_side.inputs["Mesh"]) - back = _offset_geometry_x(group, geometry, back_x, (x + 2450.0, y - 400.0)) + back = _offset_geometry_z(group, geometry, back_z, (x + 2450.0, y - 400.0)) flip_back = nodes.new("GeometryNodeFlipFaces") flip_back.location = (x + 2850.0, y - 400.0) links.new(back, flip_back.inputs["Mesh"]) @@ -370,7 +370,7 @@ def _set_segmented_layer_x( return merge.outputs["Geometry"] -def _set_geometry_x(group, geometry, x_value, location): +def _set_geometry_z(group, geometry, z_value, location): nodes = group.nodes links = group.links position = nodes.new("GeometryNodeInputPosition") @@ -380,9 +380,9 @@ def _set_geometry_x(group, geometry, x_value, location): links.new(position.outputs["Position"], separate.inputs["Vector"]) combine = nodes.new("ShaderNodeCombineXYZ") combine.location = (location[0] + 400.0, location[1]) - links.new(x_value, combine.inputs["X"]) + links.new(separate.outputs["X"], combine.inputs["X"]) links.new(separate.outputs["Y"], combine.inputs["Y"]) - links.new(separate.outputs["Z"], combine.inputs["Z"]) + links.new(z_value, combine.inputs["Z"]) set_position = nodes.new("GeometryNodeSetPosition") set_position.location = (location[0] + 600.0, location[1]) links.new(geometry, set_position.inputs["Geometry"]) @@ -517,29 +517,19 @@ def _plane_depth(group, direction, reference_depth, location): position_components = nodes.new("ShaderNodeSeparateXYZ") position_components.location = (location[0] + 220.0, location[1] + 450.0) links.new(position.outputs["Position"], position_components.inputs["Vector"]) - direction_x = _math( - nodes, - "MULTIPLY", - (location[0] + 440.0, location[1] + 850.0), - ) - direction_x.inputs[1].default_value = -1.0 - links.new(normal_components.outputs["X"], direction_x.inputs[0]) - y_term = _math(nodes, "MULTIPLY", (location[0] + 440.0, location[1] + 500.0)) + x_term = _math(nodes, "MULTIPLY", (location[0] + 440.0, location[1] + 850.0)) + links.new(position_components.outputs["X"], x_term.inputs[0]) + links.new(normal_components.outputs["X"], x_term.inputs[1]) + y_term = _math(nodes, "MULTIPLY", (location[0] + 440.0, location[1] + 350.0)) links.new(position_components.outputs["Y"], y_term.inputs[0]) - links.new(direction_x.outputs[0], y_term.inputs[1]) - z_term = _math(nodes, "MULTIPLY", (location[0] + 440.0, location[1] + 250.0)) - links.new(position_components.outputs["Z"], z_term.inputs[0]) - links.new(normal_components.outputs["Y"], z_term.inputs[1]) + links.new(normal_components.outputs["Y"], y_term.inputs[1]) point_dot = _math(nodes, "ADD", (location[0] + 660.0, location[1] + 400.0)) - links.new(y_term.outputs[0], point_dot.inputs[0]) - links.new(z_term.outputs[0], point_dot.inputs[1]) - local_x = _math(nodes, "MULTIPLY", (location[0] + 660.0, location[1])) - local_x.inputs[1].default_value = -1.0 - links.new(normal_components.outputs["Z"], local_x.inputs[0]) + links.new(x_term.outputs[0], point_dot.inputs[0]) + links.new(y_term.outputs[0], point_dot.inputs[1]) divide = _math(nodes, "DIVIDE", (location[0] + 880.0, location[1] + 200.0)) links.new(point_dot.outputs[0], divide.inputs[0]) - links.new(local_x.outputs[0], divide.inputs[1]) - plane = _math(nodes, "SUBTRACT", (location[0] + 1100.0, location[1] + 200.0)) + links.new(normal_components.outputs["Z"], divide.inputs[1]) + plane = _math(nodes, "ADD", (location[0] + 1100.0, location[1] + 200.0)) links.new(reference_depth, plane.inputs[0]) links.new(divide.outputs[0], plane.inputs[1]) return plane.outputs[0] @@ -562,14 +552,14 @@ def _build_cutout(group, geometry, thickness): half = _math(nodes, "MULTIPLY", (-200.0, 50.0)) half.inputs[1].default_value = 0.5 links.new(thickness, half.inputs[0]) - front = _math(nodes, "MULTIPLY", (20.0, 0.0)) - front.inputs[1].default_value = -1.0 - links.new(half.outputs[0], front.inputs[0]) - shaped = _set_segmented_layer_x( + back = _math(nodes, "MULTIPLY", (20.0, 0.0)) + back.inputs[1].default_value = -1.0 + links.new(half.outputs[0], back.inputs[0]) + shaped = _set_segmented_layer_z( group, geometry, - front.outputs[0], half.outputs[0], + back.outputs[0], thickness, (250.0, 300.0), ) @@ -580,22 +570,22 @@ def _build_balloon(group, geometry, thickness): nodes = group.nodes links = group.links profile, _profile_factor = _profile_field(group, geometry, (-200.0, 0.0)) - front = _math(nodes, "MULTIPLY", (2400.0, 500.0)) - front.inputs[1].default_value = -1.0 - links.new(profile, front.inputs[0]) front_scale = _math(nodes, "MULTIPLY", (2600.0, 500.0)) - links.new(front.outputs[0], front_scale.inputs[0]) + links.new(profile, front_scale.inputs[0]) links.new(thickness, front_scale.inputs[1]) back = _math(nodes, "MULTIPLY", (2600.0, 300.0)) links.new(profile, back.inputs[0]) links.new(thickness, back.inputs[1]) + negative_back = _math(nodes, "MULTIPLY", (2800.0, 300.0)) + negative_back.inputs[1].default_value = -1.0 + links.new(back.outputs[0], negative_back.inputs[0]) extrude = _extrude_layers(group, geometry, (2400.0, 750.0)) - shaped = _set_layer_x( + shaped = _set_layer_z( group, geometry, extrude, front_scale.outputs[0], - back.outputs[0], + negative_back.outputs[0], (2850.0, 650.0), ) return _remove_zero_thickness(group, geometry, shaped, thickness, (4400.0, 650.0)) @@ -637,25 +627,23 @@ def _build_depth_balloon( scaled_depth = _math(nodes, "MULTIPLY", (2150.0, -250.0)) links.new(nonnegative_depth.outputs[0], scaled_depth.inputs[0]) links.new(depth_scale, scaled_depth.inputs[1]) - front_depth = _math(nodes, "MULTIPLY", (2350.0, -250.0)) - front_depth.inputs[1].default_value = -1.0 - links.new(scaled_depth.outputs[0], front_depth.inputs[0]) + front_depth = scaled_depth.outputs[0] base_front = _math(nodes, "MULTIPLY", (2400.0, 500.0)) links.new(profile, base_front.inputs[0]) links.new(thickness, base_front.inputs[1]) - negate_front = _math(nodes, "MULTIPLY", (2600.0, 500.0)) - negate_front.inputs[1].default_value = -1.0 - links.new(base_front.outputs[0], negate_front.inputs[0]) base_back = _math(nodes, "MULTIPLY", (2600.0, 300.0)) links.new(profile, base_back.inputs[0]) links.new(thickness, base_back.inputs[1]) + negate_back = _math(nodes, "MULTIPLY", (2800.0, 300.0)) + negate_back.inputs[1].default_value = -1.0 + links.new(base_back.outputs[0], negate_back.inputs[0]) front_mix = nodes.new("ShaderNodeMix") front_mix.data_type = "FLOAT" front_mix.clamp_factor = True front_mix.location = (2600.0, 50.0) links.new(profile_factor, front_mix.inputs["Factor"]) - links.new(negate_front.outputs[0], front_mix.inputs["A"]) - links.new(front_depth.outputs[0], front_mix.inputs["B"]) + links.new(base_front.outputs[0], front_mix.inputs["A"]) + links.new(front_depth, front_mix.inputs["B"]) mirrored_front = _math(nodes, "MULTIPLY", (2850.0, -150.0)) mirrored_front.inputs[1].default_value = -1.0 links.new(front_mix.outputs["Result"], mirrored_front.inputs[0]) @@ -663,10 +651,10 @@ def _build_depth_balloon( back_choice.input_type = "FLOAT" back_choice.location = (3050.0, 100.0) links.new(double_sided, back_choice.inputs["Switch"]) - links.new(base_back.outputs[0], back_choice.inputs["False"]) + links.new(negate_back.outputs[0], back_choice.inputs["False"]) links.new(mirrored_front.outputs[0], back_choice.inputs["True"]) extrude = _extrude_layers(group, geometry, (2400.0, 750.0)) - shaped = _set_layer_x( + shaped = _set_layer_z( group, geometry, extrude, @@ -674,7 +662,7 @@ def _build_depth_balloon( back_choice.outputs["Output"], (3300.0, 650.0), ) - front_only = _set_geometry_x( + front_only = _set_geometry_z( group, geometry, front_mix.outputs["Result"], (3500.0, 100.0) ) return _remove_zero_thickness(group, front_only, shaped, thickness, (4850.0, 600.0)) @@ -708,22 +696,19 @@ def _build_depth_surface( separate.location = (3300.0, -100.0) links.new(scaled.outputs["Vector"], separate.inputs["Vector"]) relative_depth = _math(nodes, "SUBTRACT", (3500.0, -100.0)) - links.new(separate.outputs["Z"], relative_depth.inputs[0]) - links.new(reference_depth, relative_depth.inputs[1]) + links.new(reference_depth, relative_depth.inputs[0]) + links.new(separate.outputs["Z"], relative_depth.inputs[1]) front = _math(nodes, "MULTIPLY", (3700.0, -100.0)) links.new(relative_depth.outputs[0], front.inputs[0]) links.new(depth_scale, front.inputs[1]) - negative_x = _math(nodes, "MULTIPLY", (3500.0, -350.0)) - negative_x.inputs[1].default_value = -1.0 - links.new(separate.outputs["X"], negative_x.inputs[0]) negative_y = _math(nodes, "MULTIPLY", (3500.0, -600.0)) negative_y.inputs[1].default_value = -1.0 links.new(separate.outputs["Y"], negative_y.inputs[0]) projected = nodes.new("ShaderNodeCombineXYZ") projected.location = (3700.0, -450.0) - links.new(negative_x.outputs[0], projected.inputs["Y"]) - links.new(negative_y.outputs[0], projected.inputs["Z"]) + links.new(separate.outputs["X"], projected.inputs["X"]) + links.new(negative_y.outputs[0], projected.inputs["Y"]) current_position = nodes.new("GeometryNodeInputPosition") current_position.location = (3500.0, -850.0) blend = nodes.new("ShaderNodeMix") @@ -738,9 +723,9 @@ def _build_depth_surface( links.new(blend.outputs["Result"], blended_axes.inputs["Vector"]) surface_position = nodes.new("ShaderNodeCombineXYZ") surface_position.location = (4100.0, -200.0) - links.new(front.outputs[0], surface_position.inputs["X"]) + links.new(blended_axes.outputs["X"], surface_position.inputs["X"]) links.new(blended_axes.outputs["Y"], surface_position.inputs["Y"]) - links.new(blended_axes.outputs["Z"], surface_position.inputs["Z"]) + links.new(front.outputs[0], surface_position.inputs["Z"]) project_geometry = nodes.new("GeometryNodeSetPosition") project_geometry.location = (4400.0, 400.0) links.new(geometry, project_geometry.inputs["Geometry"]) @@ -754,20 +739,20 @@ def _build_depth_surface( stretch_limit, (4700.0, 400.0), ) - zero_x = nodes.new("ShaderNodeValue") - zero_x.location = (7100.0, 100.0) - zero_x.outputs["Value"].default_value = 0.0 - geometry = _set_geometry_x( + zero_z = nodes.new("ShaderNodeValue") + zero_z.location = (7100.0, 100.0) + zero_z.outputs["Value"].default_value = 0.0 + geometry = _set_geometry_z( group, geometry, - zero_x.outputs["Value"], + zero_z.outputs["Value"], (7200.0, 400.0), ) - back = _math(nodes, "ADD", (3900.0, -100.0)) + back = _math(nodes, "SUBTRACT", (3900.0, -100.0)) links.new(front.outputs[0], back.inputs[0]) links.new(thickness, back.inputs[1]) - shaped = _set_segmented_layer_x( + shaped = _set_segmented_layer_z( group, geometry, front.outputs[0], @@ -775,7 +760,7 @@ def _build_depth_surface( thickness, (8300.0, 400.0), ) - front_only = _set_geometry_x( + front_only = _set_geometry_z( group, geometry, front.outputs[0], @@ -812,7 +797,7 @@ def build_node_group(shape): depth_scale.description = "Scale the fitted-plane-corrected metric depth" double_sided = _bool_input(group, "Double Sided", False) double_sided.description = ( - "Mirror the complete front surface across the local YZ plane" + "Mirror the complete front surface across the local XY plane" ) elif shape == "DEPTH_SURFACE": depth_scale = _float_input(group, "Depth Scale", 1.0) @@ -970,7 +955,7 @@ def build_cutout_modifier_group(shape_groups): depth_scale.description = "Scale metric image depth" double_sided = _bool_input(group, "Double Sided", False) double_sided.description = ( - "Mirror the complete Depth Balloon front surface across the local YZ plane" + "Mirror the complete Depth Balloon front surface across the local XY plane" ) iterations = _socket(group, "Smooth Iterations", "INPUT", "NodeSocketInt") @@ -1111,7 +1096,7 @@ def build_cutout_modifier_group(shape_groups): from mathutils import Matrix orient = group.nodes.new("GeometryNodeTransform") - orient.inputs["Rotation"].default_value = Matrix(CUTOUT_AXIS_TO_SOURCE).to_euler() + orient.inputs["Rotation"].default_value = Matrix(CUTOUT_Z_TO_X).to_euler() orient.location = (3300.0, 350.0) group.links.new(geometry, orient.inputs["Geometry"]) @@ -1126,8 +1111,8 @@ def build_cutout_modifier_group(shape_groups): {"Inward Axis"}, ) group.links.new(axis_controls.outputs["Inward Axis"], axis_switch.inputs["Menu"]) - group.links.new(orient.outputs["Geometry"], axis_switch.inputs["-Z"]) - group.links.new(geometry, axis_switch.inputs["+X"]) + group.links.new(geometry, axis_switch.inputs["-Z"]) + group.links.new(orient.outputs["Geometry"], axis_switch.inputs["+X"]) _shade_output(group, axis_switch.outputs["Output"], (3850.0, 550.0)) group.color_tag = "GEOMETRY" group.description = ( diff --git a/tools/node_assets/validate_cutout.py b/tools/node_assets/validate_cutout.py index 7f899d6..5ea68f8 100644 --- a/tools/node_assets/validate_cutout.py +++ b/tools/node_assets/validate_cutout.py @@ -33,18 +33,18 @@ def input_names(group): def grid_object(name): - vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)] + vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)] faces = [] - for z in range(2): - for y in range(2): - a = z * 3 + y - faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4))) + for y in range(2): + for x in range(2): + a = y * 3 + x + faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3))) data = bpy.data.meshes.new(name) data.from_pydata(vertices, (), faces) uv_layer = data.uv_layers.new(name="UVMap") for loop in data.loops: coordinate = data.vertices[loop.vertex_index].co - uv_layer.data[loop.index].uv = (coordinate.y * 0.5, coordinate.z * 0.5) + uv_layer.data[loop.index].uv = (coordinate.x * 0.5, coordinate.y * 0.5) obj = bpy.data.objects.new(name, data) bpy.context.scene.collection.objects.link(obj) return obj @@ -56,7 +56,7 @@ def add_modifier(obj, shape, thickness): modifier.node_group = group for name, value in ( ("Shape", shapes.CUTOUT_SHAPE_INDICES[shape]), - ("Inward Axis", 1), + ("Inward Axis", 0), ("Cleanup", 0.0), ("Smooth", 0.0), ("Smooth Iterations", 0), @@ -116,6 +116,23 @@ def main(): ) for axis in ("-Z", "+X") ) + negative_z_link = next( + link + for link in wrapper.links + if link.to_node == axis_switch and link.to_socket.name == "-Z" + ) + positive_x_link = next( + link + for link in wrapper.links + if link.to_node == axis_switch and link.to_socket.name == "+X" + ) + assert positive_x_link.from_node.bl_idname == "GeometryNodeTransform" + assert any( + link.from_socket == negative_z_link.from_socket + and link.to_node == positive_x_link.from_node + and link.to_socket.name == "Geometry" + for link in wrapper.links + ) child_nodes = { node.node_tree: node for node in wrapper.nodes @@ -355,7 +372,7 @@ def main(): assert len(result.vertices) == 34 assert len(result.polygons) == 40 side_faces = [ - polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1 + polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1 ] assert len(side_faces) == 24 finally: @@ -373,7 +390,7 @@ def main(): evaluated, result = evaluated_mesh(balloon) try: initial_faces = len(result.polygons) - initial_extent = max(abs(vertex.co.x) for vertex in result.vertices) + initial_extent = max(abs(vertex.co.z) for vertex in result.vertices) finally: evaluated.to_mesh_clear() set_modifier_input( @@ -386,7 +403,7 @@ def main(): evaluated, result = evaluated_mesh(balloon) try: assert len(result.polygons) == initial_faces - extent = max(abs(vertex.co.x) for vertex in result.vertices) + extent = max(abs(vertex.co.z) for vertex in result.vertices) assert abs(extent - initial_extent * 2.0) < 1e-5 finally: evaluated.to_mesh_clear() @@ -433,9 +450,9 @@ def main(): bpy.context.view_layer.update() evaluated, result = evaluated_mesh(depth_balloon) try: - x_values = [vertex.co.x for vertex in result.vertices] - assert abs(min(x_values) + 0.5) < 1e-5 - assert abs(max(x_values)) < 1e-5 + z_values = [vertex.co.z for vertex in result.vertices] + assert abs(min(z_values)) < 1e-5 + assert abs(max(z_values) - 0.5) < 1e-5 finally: evaluated.to_mesh_clear() @@ -446,9 +463,9 @@ def main(): bpy.context.view_layer.update() evaluated, result = evaluated_mesh(depth_balloon) try: - x_values = [vertex.co.x for vertex in result.vertices] - assert abs(min(x_values) + 0.5) < 1e-5 - assert abs(max(x_values)) < 1e-5 + z_values = [vertex.co.z for vertex in result.vertices] + assert abs(min(z_values)) < 1e-5 + assert abs(max(z_values) - 0.5) < 1e-5 finally: evaluated.to_mesh_clear() @@ -464,10 +481,10 @@ def main(): point_image.colorspace_settings.name = "Non-Color" point_image.alpha_mode = "CHANNEL_PACKED" pixels = [] - for z in range(3): - for y in range(3): - depth = 7.0 if (y, z) == (0, 0) else 2.0 + y * 0.25 - pixels.extend((-y * 0.5, -z * 0.5, depth, 0.0)) + for y in range(3): + for x in range(3): + depth = 7.0 if (x, y) == (0, 0) else 2.0 + x * 0.25 + pixels.extend((x * 0.5, -y * 0.5, depth, 0.0)) point_image.pixels.foreach_set(pixels) for name, value in ( ("Depth Image", point_image), @@ -494,12 +511,12 @@ def main(): coordinates, ( ( - 5.0 if (y, z) == (0, 0) else y * 0.25, + x * 0.5, y * 0.5, - z * 0.5, + -5.0 if (x, y) == (0, 0) else -x * 0.25, ) - for z in range(3) for y in range(3) + for x in range(3) ), ) )