diff --git a/README.md b/README.md index 2a69a46..854b1c4 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ AnyImage 是一个 Blender 扩展,可以把一张照片、一段视频或一 - 不运行 AI,把 Image Empty 转换为可实时调整 Thickness 与 Subdivide 的 Mesh Plane - 从 Image Empty 套索提取不规则区域,通过 Poisson 或 Teddy / Chordal Axis 生成封闭 Balloon,并可用 BEN2 分析轮廓、MoGe-2 添加可调深度;源 Empty 保持不变 - 用 Image Box / Lasso 原位提取 Image Empty 内容,按矩形或手绘范围裁切并 Pack 新图,也可用 BEN2 进一步收紧 Alpha +- 用 Image Perspective 勾勒四个角点,把透视四边形矫正为矩形裁切图,并沿用源 Image Empty 的空间朝向 - 一键把 Image Empty 转换为立体面片:让 AI 估算深度,或直接用原图作为深度 - 把单张 Image Empty 转换为透视纹理网格,并自动匹配原对象的 Transform 与 Offset - 一键去除图片背景,生成透明前景图 @@ -59,9 +60,21 @@ Environment Bootstrap 使用连续的 0–100%:uv 为 1–20%、Python 为 20 ### 套索提取图片 -选中静态图片的 Image Empty,在 3D Viewport 左侧工具栏选择 Image Box / Lasso 工具组。默认的 Image Box 拖出矩形区域;长按工具按钮可切换 Image Lasso 并绘制自由轮廓。两者使用默认鼠标光标,并以类似 Select Tool 的白色虚线显示选区,分别采用 Sculpt Mode 的 Box Mask 与 Lasso Mask 图标,共用相同设置。该工具组与 Balloon Lasso 相邻,并通过一个间隔与前方的 Add Cube 分开。松开左键后会把同一个 Empty 原位更新为新图片并 Pack 到 .blend。Image Box / Lasso 与 Balloon Lasso 都只放在工具栏,不出现在右键 AnyImage 子菜单中。 +在 3D Viewport 左侧工具栏选择 Image Box / Lasso 工具组。默认的 Image Box 拖出矩形区域;长按工具按钮可切换 Image Lasso 并绘制自由轮廓。点击另一张 Image Empty 时只切换选择,不会把这次点击当作取区起点;点击当前 Active 图片、空白处或非图片物体时,才以当前 Active 图片开始取区,非图片物体不会被移动或抢走 Active。当前没有 Active 图片时会提示先点击 Image Empty。两者使用默认鼠标光标和类似 Select Tool 的白色虚线,分别采用 Sculpt Mode 的 Box Mask 与 Lasso Mask 图标,共用相同设置。该工具组与 Balloon Lasso 相邻,并通过一个间隔与前方的 Add Cube 分开。Image Box / Lasso 与 Balloon Lasso 都只放在工具栏,不出现在右键 AnyImage 子菜单中。 -区域外的 RGBA 会统一设为透明黑,避免后续只读取 RGB 的图像处理重新识别透明区内容。输出画布取最终 Alpha 大于 0 的最小 bounding box,再按 Padding 向四周增加指定的透明像素,默认 0 px。对象原点会移动到新画布中心,显示尺寸与图片比例同步更新,裁切后的像素仍与原图在世界空间重合。Keep Original 默认关闭;开启后保留源 Empty,并创建承载裁切图的副本。Contour 默认关闭;开启后 BEN2 会在 lasso 内进一步细化 Alpha,再计算最终 bounding box。Contour 仅支持静态图片,并需要 AI Environment 与 BEN2。 +区域外的 RGBA 会统一设为透明黑,避免后续只读取 RGB 的图像处理重新识别透明区内容。输出画布取最终 Alpha 大于 0 的最小 bounding box,再按 Padding 向四周增加指定的透明像素,默认 0 px。对象原点会移动到新画布中心,显示尺寸与图片比例同步更新,裁切后的像素仍与原图在世界空间重合。Continuous 默认关闭并原位更新;开启后源 Empty 保持 Active,每次操作持续创建一个向当前 View 稍微靠近的新结果,避免与源图重叠闪烁。Remove Background 默认关闭;开启后复用标准 Remove Background 的 BEN2 单图处理,在选区内删除背景,再计算最终 bounding box。该选项仅支持静态图片,并需要 AI Environment 与 BEN2。 + +### 四点透视矫正 + +在 Image Box / Lasso 工具组中切换到 `Image Perspective`。工具使用 Sculpt Polyline Mask 图标;四个角可以按任意顺序点击,完成后会在源图片坐标中自动求出不交错的凸四边形,并以原图 X/Y 方向确定输出旋转。绘制时显示白色虚线和半透明选区面,`Backspace` 删除最近一个角点,右键或 `Esc` 取消;切换其他工具也会立即退出。首次点击遵循同一选择规则:点另一张图片只切换 Active,下一次点击才开始四点取区。Perspective 不依赖当前 View 的相机内参,因此透视和正交 View 都可以使用。 + +第四个角点按下时会在 Viewport 中央显示带蓝色边框的矫正图片预览,中央显示当前比例,下方显示鼠标操作提示;鼠标同时移到 Viewport 中心,为左右两个方向保留相同的调节空间。水平移动连续调整宽高比,按住 `Ctrl` 吸附到 `1:1`、`4:3`、`3:4`、`16:9`、`9:16`,下一次左键确认并创建;`Backspace` 返回第三点,右键或 `Esc` 取消。初始比例取四边形两组对边的平均长度比。预览使用一次生成的 512 × 512 临时图片,移动鼠标时只改变 UI 显示比例。 + +确认后,工具把选区透视矫正为矩形图片,按最终非零 Alpha 范围裁切并 Pack 到 `.blend`。结果 Empty 保持源图的朝向和深度,以四边形在源图上的 bounding box 近似确定中心与显示范围,不再尝试从当前 View 反解真实空间矩形。四边形完全位于源图范围外,或矫正结果没有可见 Alpha 像素时,只显示警告且不创建结果。输出最长边不超过 8192 px,总像素不超过约 16 MP。 + +Image Perspective 与 Image Box / Lasso 共用 Tool Settings。`Padding` 在矫正后的 Alpha 裁切外增加透明像素;`Continuous` 控制原位更新或连续创建分层副本;`Remove Background` 默认关闭,开启后先复用标准 BEN2 去背景处理,再执行相同的透视矫正、裁切和 Pack 流程。 + +完整操作说明见 [Image Box、Lasso 与 Perspective](docs/image-tools.md)。 ### 套索生成 Balloon diff --git a/docs/development.md b/docs/development.md index c76626b..6b7bd61 100644 --- a/docs/development.md +++ b/docs/development.md @@ -37,7 +37,7 @@ Mesh Plane、Depth Plane、Surface、Background 和 Balloon 按功能组织。 Server 的模型模块返回 Prediction,几何模块处理数组与网格,输出模块写文件。Blender 数据只在 Operator Response 的主线程阶段创建。 -Image Lasso 的基础像素裁切在 Blender 主线程完成;启用 Contour 时,Server Job 计算 BEN2 Alpha,并按最终非零 Alpha 范围与像素 Padding 输出图片,Operator Response 负责 Pack 新图、可选复制源 Empty 并更新结果对象的空间范围。 +Image Box、Lasso 与 Perspective 位于 `operators/image_extract/` 功能包。模块职责、交互状态、像素管线、背景清理和 Empty 定位见 [Image 工具技术实现](image-tools-technical.md)。 ## 项目结构 diff --git a/docs/features.md b/docs/features.md index 5981b35..74c158f 100644 --- a/docs/features.md +++ b/docs/features.md @@ -12,6 +12,7 @@ | Surface | 从单张图片恢复透视空间 | 单图 | 相机空间网格、材质与可选匹配相机 | | Remove Background | 提取透明前景 | 单图、视频、序列帧 | 透明图片或透明序列 | | Image Lasso | 手绘提取并原位更新图片 | 静态 Image Empty | Pack 的透明裁切图 | +| Image Perspective | 四点透视矫正裁切 | 静态 Image Empty | Pack 的矩形裁切图 | | Balloon | 沿轮廓绘制鼓起造型 | 静态 Image Empty | 带原图 UV 的封闭网格 | | Debug | 检查模型原始输出 | 模型支持的图片或帧序列 | 保存在 Job 目录中的原始 Prediction | @@ -29,7 +30,9 @@ Balloon 作为不依赖 AI 的基础工具单独排列;三个 AI 功能在缺 转换任务完成后,AnyImage 在一个 Undo 步骤中创建网格、图片、材质和修改器。源 Image Empty 是否保留由转换选项决定。 -Image Box 与 Image Lasso 合并为一个工具组,分别使用 Sculpt Mode 的 Box Mask 与 Lasso Mask 图标;默认显示 Box,长按按钮可切换 Lasso。绘制时使用默认鼠标光标,并以类似 Select Tool 的白色虚线显示选区。该工具组与 Balloon Lasso 相邻,并通过一个工具栏间隔与前方的 Add Cube 分开;两组工具只位于 3D Viewport 左侧工具栏,不显示在右键 AnyImage 子菜单。它们将选区外 RGBA 清为透明黑,再以最终 Alpha 大于 0 的最小 bounding box 作为输出画布;Padding 使用整数像素向四周扩展透明边距,默认 0 px。结果会 Pack 到 .blend,同一个 Empty 的原点移动到新画布中心,显示尺寸同步调整,使裁切内容在世界空间保持重合。Keep Original 默认关闭;开启后保留源 Empty 的 Active 与选择状态,并创建未选中的裁切副本。可选 Contour 使用 BEN2 的软 Alpha 进一步收紧选区,并在分析后计算最终画布范围。 +Image Box、Image Lasso 与 Image Perspective 组成一个工具组,共用源图选择、`Remove Background`、`Continuous` 和 `Padding`。Box 提取矩形,Lasso 提取自由轮廓,Perspective 将任意顺序输入的四个角矫正为指定比例的矩形。结果都是 Pack 的 Image Empty,并沿用源图朝向与深度。 + +操作步骤见 [Image Box、Lasso 与 Perspective](image-tools.md);处理管线与模块职责见 [Image 工具技术实现](image-tools-technical.md)。 Balloon 默认在 Blender 内部生成,无需 Environment 或模型。Tool Settings 提供模式、厚度、采样距离、材质复用、`Contour`、`Depth` 与 MoGe-2 `Depth Model`;AI 未就绪时只禁用这三个控件,并在 Tool Settings 中显示安装按钮。Depth 会把物理距离相对化并稳健拟合初始基面,保留未截断的正负起伏。Base Plane 子 Empty 的旋转定义基面朝向,沿自身局部 Z 移动定义 middle level。 diff --git a/docs/image-tools-technical.md b/docs/image-tools-technical.md new file mode 100644 index 0000000..a66a1cc --- /dev/null +++ b/docs/image-tools-technical.md @@ -0,0 +1,77 @@ +# Image 工具技术实现 + +Image Box、Lasso 与 Perspective 位于 `operators/image_extract/`。三者共享源图选择、设置、图片写回、空间定位、Continuous 分层和可选背景清理。 + +## 模块职责 + +```mermaid +flowchart LR + Box["box.py\nWorkspaceTool"] --> Region["region.py\nBox / Lasso Operator"] + Lasso["lasso.py\nWorkspaceTool"] --> Region + Perspective["perspective.py\n四点与透视矫正"] + Region --> Common["common.py\n共享选择、设置与结果写回"] + Perspective --> Common + Common --> Job["extract-image-background\n可选 BEN2 Job"] +``` + +`box.py` 与 `lasso.py` 只声明 WorkspaceTool。两者通过 `selection_shape` 复用 `ExtractImageRegion`;Perspective 保留独立 Operator,因为它还有四点输入、比例预览和 Homography。 + +## 首次点击 + +`resolve_image_extract_click()` 将首次点击归为两种结果: + +| 点击目标 | 结果 | +| --- | --- | +| 另一张 Image Empty | 只选择并设为 Active | +| 当前 Active 图片、空白或非图片对象 | 使用当前 Active 图片开始取区 | + +这段逻辑由三个工具共用。点击非图片对象后会恢复原选择,避免触发对象移动或更换源图。 + +## 处理管线 + +```mermaid +flowchart TD + Input["屏幕选区"] --> Pixels["转换到源图片像素坐标"] + Pixels --> Background{"Remove Background?"} + Background -- 否 --> Local["Blender 内处理 RGBA"] + Background -- 是 --> Server["选区画布 → BEN2 → 局部图片"] + Local --> Tool{"工具类型"} + Server --> Tool + Tool -- Box / Lasso --> Crop["Alpha bounds + Padding"] + Tool -- Perspective --> Warp["预乘 Alpha Homography"] + Warp --> Crop + Crop --> Result["创建并 Pack Image\n定位 Empty"] +``` + +Box 与 Lasso 将选区外 RGBA 清为透明黑,再按非零 Alpha 的最小范围裁切。Perspective 先将四点规范为沿源图 X/Y 方向的凸四边形,再按确认的宽高比重采样;点击顺序不参与输出旋转。 + +透视预览固定生成一次 512 × 512 临时 Image。鼠标移动只改变屏幕上的预览比例;确认后才执行全分辨率重采样。 + +## 背景清理 + +`extract-image-background` 与标准 `remove-background` Job 共用 `remove_image_background()`。图片工具的 Job 只增加三项选区语义: + +- polygon mask; +- 局部图片在源图中的 bounds; +- Box / Lasso 使用的透明 Padding。 + +Perspective 请求的 Job Padding 固定为 0。局部结果回到 Blender 后先换算为源图坐标,再执行透视矫正和最终 Padding。 + +## Empty 定位与 Continuous + +Box 与 Lasso 根据裁切前后的像素 bounds 换算 Empty 的中心和显示尺寸。Perspective 使用源四边形的轴对齐 bounds 近似放置结果。三者都沿用源 Empty 的矩阵,因此朝向与深度不变。 + +Continuous 关闭时直接更新源 Empty;开启时复制对象,并沿操作开始时捕获的 View 方向偏移: + +```text +offset = display_size × world_scale × 0.001 × layer +``` + +`layer` 记录在源对象的 `_anyimage_extract_layer` Custom Property 中,使连续结果依次靠近当前 View。 + +## 维护约束 + +- WorkspaceTool 的显示设置放在 `box.py`、`lasso.py`、`perspective.py`。 +- Box / Lasso 的共享交互放在 `region.py`,跨三个工具的行为放在 `common.py`。 +- Blender 数据只在主线程 Operator 中读写;Server 参数保持为可序列化字典。 +- 普通裁切不启动 Job Server;只有 Remove Background 需要 Environment 与 BEN2。 diff --git a/docs/image-tools.md b/docs/image-tools.md new file mode 100644 index 0000000..6f1ade9 --- /dev/null +++ b/docs/image-tools.md @@ -0,0 +1,85 @@ +# Image Box、Lasso 与 Perspective + +这三个工具用于从 Image Empty 中提取局部图片。结果仍是 Image Empty,并自动 Pack 到 `.blend`。 + +它们只处理静态图片。视频和序列帧请使用其他转换工具。 + +## 选择工具 + +| 工具 | 适合 | +| --- | --- | +| **Image Box** | 提取矩形区域,操作最快 | +| **Image Lasso** | 沿物体或不规则边界自由绘制 | +| **Image Perspective** | 将海报、屏幕、门窗等四边形内容矫正为矩形 | + +在 3D Viewport 左侧工具栏找到 Image 工具组。长按当前图标可以切换三个工具;设置位于 Tool Settings。 + +
+**截图占位:工具组与 Tool Settings** + +建议同时显示三个工具入口,以及 Remove Background、Continuous、Padding。 + +目标文件:`images/image-tools/tool-group.png` +
+ +## 选择源图片 + +如果当前 Active 已是目标图片,可以直接开始取区。 + +如果要处理另一张图片,先点击它。这一次点击只负责选择;下一次点击或拖动才开始取区。点击空白处或其他类型的对象时,工具继续使用当前 Active 图片。 + +!!! tip + 最简单的习惯是:先点一下目标图片确认 Active,再开始绘制。 + +## 使用 Box 或 Lasso + +=== "Image Box" + + 1. 按住左键,从区域一角拖到另一角。 + 2. 松开左键,立即生成结果。 + +=== "Image Lasso" + + 1. 按住左键,沿需要保留的区域绘制。 + 2. 松开左键,立即生成结果。 + +选区可以越过图片边缘。只要范围内仍有可见像素,工具就会按实际内容裁切。 + +
+**截图占位:Box 与 Lasso 取区** + +建议用一张并排图展示矩形拖拽和自由轮廓,不需要额外结果图。 + +目标文件:`images/image-tools/box-lasso.png` +
+ +## 使用 Perspective + +1. 依次点击内容的四个角,顺序不限。 +2. 第四点完成后,Viewport 中央会显示矫正预览。 +3. 左右移动鼠标调整宽高比。 +4. 单击左键确认并生成结果。 + +调整比例时按住 `Ctrl`,可以吸附到 `1:1`、`4:3`、`3:4`、`16:9` 和 `9:16`。`Backspace` 返回上一个角点;右键或 `Esc` 取消。 + +
+**截图占位:Perspective 比例确认** + +建议截取中央图片预览、蓝色边框、比例文字和底部操作提示。 + +目标文件:`images/image-tools/perspective-aspect.png` +
+ +## 结果设置 + +- **Remove Background**:使用 BEN2 删除选区内的背景,再按可见内容裁切。首次使用需要先完成 AI Environment 与 BEN2 配置。 +- **Continuous**:保留源图片,连续创建新结果。新图片会向当前 View 稍微靠近,减少重叠闪烁。 +- **Padding**:在可见内容四周增加透明像素,单位为 px。 + +Continuous 关闭时,结果会原位替换当前 Image Empty;开启时,源图片保持 Active,便于继续提取其他区域。 + +## Perspective 的结果位置 + +Perspective 会矫正图片内容,但不会尝试还原真实相机或空间平面。结果沿用源图片的朝向和深度,并大致放在原四边形覆盖的区域。 + +需要了解交互状态、像素处理和模块职责时,请阅读 [Image 工具技术实现](image-tools-technical.md)。 diff --git a/docs/index.md b/docs/index.md index cd9998f..4e9fce5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,6 +9,7 @@ AnyImage 是一款 Blender 扩展,可以把照片、视频或序列帧转换 - 用 **Depth Plane** 快速得到可调节的浮雕式面片 - 用 **Surface** 从单张图片生成透视纹理网格 - 用 **Remove Background** 一键获得透明前景 +- 用 **Image Perspective** 将四点选区矫正为沿用源 Empty 朝向的矩形裁切图 - 处理视频和序列帧,生成可随时间播放的 Depth Plane - 使用 Geometry Nodes 继续调整深度、细分、重拓扑与平滑效果 diff --git a/docs/outputs.md b/docs/outputs.md index 5fcf7cd..0f6f1e4 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -9,6 +9,7 @@ | `generate-depth` | `results.npz`、深度与颜色序列 | Depth Plane 所需深度、相机和贴图 | | `generate-surface` | `mesh.npz`、`normal.png`、颜色层 | Surface 网格、相机与材质输入 | | `remove-background` | `color.0001.png` 起 | 带 Alpha 的颜色层 | +| `extract-image-background` | `selection-input.png`、`image.png`、`bounds.json` | 图片工具选区内去背景后的局部图片与原图坐标 bounds | | `analyze-balloon` | `lasso-input.png`,按选项输出 `foreground.png`、`contour.json`、`depth.npz`、`normal.png` | 套索约束输入、BEN2 轮廓与 MoGe-2 深度、法线 | | `debug-da3` | `prediction.npz` | DA3 API 返回 | | `debug-moge2` | `prediction*.npz` | MoGe-2 API 返回 | diff --git a/docs/server.md b/docs/server.md index dae9fea..70f39bd 100644 --- a/docs/server.md +++ b/docs/server.md @@ -59,6 +59,8 @@ Environment 使用 Python 3.12、ONNX Runtime、NumPy、Pillow、PyAV、trimesh Balloon 的 `analyze-balloon` Job 会裁切套索输入并清零选区外 RGBA。`Contour` 用 BEN2 回传最大连通轮廓;`Depth` 用 MoGe-2 对有效相机距离稳健拟合二维基面,并把物理距离换算为不受绝对拍摄距离影响、未截断的相对深度信号。Job 同时把 MoGe normal 转换为 tangent-space 纹理,铺回原图尺寸并以中性法线填充无效区域。Blender 主线程负责采样深度属性、创建 Base Plane Empty、组装 `O Balloon Shape` 修改器与 `O Balloon Layer` 材质。 +图片 Box、Lasso 与 Perspective 的可选 `Remove Background` 使用 `extract-image-background` Job。它与标准 `remove-background` Job 共用单图 BEN2 去背景函数,再叠加图片工具专属的 polygon mask、局部 bounds 与透明 Padding。 + 正式功能支持: - MoGe-2 ViT-S、ViT-B、ViT-L Normal diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css index 238c5fe..0606ec9 100644 --- a/docs/stylesheets/extra.css +++ b/docs/stylesheets/extra.css @@ -11,3 +11,17 @@ .md-typeset .grid.cards > ul > li { border-radius: 0.5rem; } + +.md-typeset .screenshot-placeholder { + margin: 1rem 0; + padding: 2.5rem 1.25rem; + border: 2px dashed var(--md-default-fg-color--lighter); + border-radius: 0.5rem; + color: var(--md-default-fg-color--light); + text-align: center; + background: var(--md-code-bg-color); +} + +.md-typeset .screenshot-placeholder p { + margin: 0.35rem 0; +} diff --git a/docs/workflows.md b/docs/workflows.md index affa25d..07d61cd 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -8,7 +8,8 @@ AnyImage 提供多种日常工作流。选择结果时,先考虑你需要的 | Depth Plane | 人物、产品、插画、动态背景、快速场景搭建 | 图片、视频、序列帧 | 带深度位移的平面 | 快速、可动画、Geometry Nodes 参数直观 | | Surface | 建筑、室内、街景、需要匹配视角的单图 | 单张图片 | 透视纹理网格 | 空间感更强,可创建匹配相机 | | Remove Background | 抠出人物或物体,继续作为 Image Empty 使用 | 图片、视频、序列帧 | 带透明 Alpha 的颜色图 | 不创建网格,保留 Empty 的位置和显示设置 | -| Image Box / Lasso | 矩形或手绘提取局部内容并按 Alpha 范围裁切 | 静态图片 | Pack 的透明裁切图 | 工具组切换,支持像素 Padding 与 Keep Original,不创建网格 | +| Image Box / Lasso | 矩形或手绘提取局部内容并按 Alpha 范围裁切 | 静态图片 | Pack 的透明裁切图 | 支持 Padding 与 Continuous,不创建网格 | +| Image Perspective | 海报、屏幕、门窗等四边形内容的透视矫正裁切 | 静态图片 | Pack 的矩形图片 | 四点任意顺序,共用 Remove Background、Padding 与 Continuous | ## 什么时候选择 Mesh Plane @@ -33,3 +34,7 @@ Surface 会根据模型估算的相机和深度构建透视网格。相较 Depth 如果只想把 Image Empty 换成透明前景图,不需要任何几何结构,直接选择 **Remove Background**。对象的 Transform、显示尺寸和 Offset 会保留。 [阅读去除背景指南](remove-background.md) + +## 什么时候使用 Image Perspective + +当图片中的矩形物体因为透视变成四边形,并且希望得到一张正视矩形裁切图时,使用 3D Viewport 工具栏中的 **Image Perspective**。它只把四点当作图片裁切与 Homography 输入,不假设当前 View 的焦距与原图拍摄相机一致。四点顺序不影响输出方向;结果 Empty 保持源图朝向和深度,并按源图中的选区范围近似放置。需要持续提取多个区域时,可开启与 Image Box / Lasso 共用的 Continuous。 diff --git a/mkdocs.yml b/mkdocs.yml index 7b82fdf..06ca7cc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,7 @@ nav: - 粘贴剪贴板图片: clipboard-image.md - 使用指南: - 选择转换方式: workflows.md + - Image Box、Lasso 与 Perspective: image-tools.md - Mesh Plane: mesh-plane.md - Depth Plane: guide-depth-plane.md - Surface: guide-surface.md @@ -24,6 +25,7 @@ nav: - 常见问题: faq.md - 参考与开发: - 功能参考: features.md + - Image 工具技术实现: image-tools-technical.md - Depth Plane 实现: depth-plane.md - Surface 实现: surface.md - 输出格式: outputs.md @@ -83,6 +85,10 @@ markdown_extensions: - md_in_html - admonition - pymdownx.details - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true diff --git a/src/anyimage/__init__.py b/src/anyimage/__init__.py index d94f6ea..e601577 100644 --- a/src/anyimage/__init__.py +++ b/src/anyimage/__init__.py @@ -6,7 +6,7 @@ from .clipboard_image import CLASSES as CLIPBOARD_CLASSES from .runtime import runtime from .operators import CLASSES as OPERATOR_CLASSES from .operators.balloon import BalloonTool -from .operators.image_lasso import ImageBoxTool, ImageLassoTool +from .operators.image_extract import ImageBoxTool, ImageLassoTool, ImagePerspectiveTool from .preferences import AnyImagePreferences from .properties import AnyImageSettings from .menu import AnyImageImageMenu, draw_image_context_menu @@ -46,6 +46,11 @@ def register(): after={ImageBoxTool.bl_idname}, separator=False, ) + bpy.utils.register_tool( + ImagePerspectiveTool, + after={ImageLassoTool.bl_idname}, + separator=False, + ) bpy.utils.register_tool(BalloonTool, separator=False) bpy.types.Scene.anyimage_settings = bpy.props.PointerProperty( type=AnyImageSettings, @@ -60,6 +65,7 @@ def unregister(): runtime.unregister() clipboard_image.unregister_keymaps() bpy.utils.unregister_tool(BalloonTool) + bpy.utils.unregister_tool(ImagePerspectiveTool) bpy.utils.unregister_tool(ImageLassoTool) bpy.utils.unregister_tool(ImageBoxTool) bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu) diff --git a/src/anyimage/operators/__init__.py b/src/anyimage/operators/__init__.py index f1fc735..4bb6373 100644 --- a/src/anyimage/operators/__init__.py +++ b/src/anyimage/operators/__init__.py @@ -5,11 +5,7 @@ from .balloon import ( AnalyzeBalloon, ) from .debug import DebugBen2, DebugDa3, DebugMoge2 -from .image_lasso import ( - ActivateImageLassoTool, - AnalyzeImageLasso, - ExtractImageLasso, -) +from .image_extract import CLASSES as IMAGE_EXTRACT_CLASSES from .depth_plane import ( ConvertToDepthPlane, ExtractOriginalFrames, @@ -44,9 +40,7 @@ CLASSES = ( GenerateOriginalBackground, ExtractOriginalFrames, ClearModels, - ExtractImageLasso, - AnalyzeImageLasso, - ActivateImageLassoTool, + *IMAGE_EXTRACT_CLASSES, ExtractBalloon, AnalyzeBalloon, ActivateBalloonTool, diff --git a/src/anyimage/operators/image_extract/__init__.py b/src/anyimage/operators/image_extract/__init__.py new file mode 100644 index 0000000..a555df5 --- /dev/null +++ b/src/anyimage/operators/image_extract/__init__.py @@ -0,0 +1,33 @@ +from .box import ImageBoxTool +from .lasso import ImageLassoTool +from .perspective import ( + ExtractImagePerspective, + ImagePerspectiveTool, + RemoveImagePerspectiveBackground, +) +from .region import ( + ActivateImageExtractTool, + ExtractImageRegion, + RemoveImageRegionBackground, +) + + +CLASSES = ( + ExtractImageRegion, + RemoveImageRegionBackground, + ActivateImageExtractTool, + ExtractImagePerspective, + RemoveImagePerspectiveBackground, +) + + +TOOLS = (ImageBoxTool, ImageLassoTool, ImagePerspectiveTool) + + +__all__ = ( + "CLASSES", + "TOOLS", + "ImageBoxTool", + "ImageLassoTool", + "ImagePerspectiveTool", +) diff --git a/src/anyimage/operators/image_extract/box.py b/src/anyimage/operators/image_extract/box.py new file mode 100644 index 0000000..98ed8b1 --- /dev/null +++ b/src/anyimage/operators/image_extract/box.py @@ -0,0 +1,24 @@ +import bpy + +from .common import draw_image_extract_settings +from .region import ExtractImageRegion + + +class ImageBoxTool(bpy.types.WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = "anyimage.image_box" + bl_label = "Image Box" + bl_description = "Extract a boxed region from an Image Empty" + bl_icon = "ops.sculpt.border_mask" + bl_operator = ExtractImageRegion.bl_idname + bl_keymap = ( + ( + ExtractImageRegion.bl_idname, + {"type": "LEFTMOUSE", "value": "PRESS"}, + {"properties": [("selection_shape", "BOX")]}, + ), + ) + + def draw_settings(context, layout, _tool): + draw_image_extract_settings(context, layout) diff --git a/src/anyimage/operators/image_extract/common.py b/src/anyimage/operators/image_extract/common.py new file mode 100644 index 0000000..6077753 --- /dev/null +++ b/src/anyimage/operators/image_extract/common.py @@ -0,0 +1,349 @@ +import json +import math + +import bpy + +from ...properties import ( + BEN2_MODEL_KEY, + ai_ready, + ai_setup_label, + ai_status, + image_extract_continuous_property as continuous_property, + image_extract_remove_background_property as remove_background_property, + image_extract_padding_property as padding_property, +) +from ..common import production_device, require_environment, require_model +from ..image import image_base_name, is_image_empty, plane_bounds + + +EXTRACT_LAYER_PROPERTY = "_anyimage_extract_layer" +EXTRACT_LAYER_STEP = 0.001 + + +def image_extract_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_extract_click(context, event, report): + """Resolve the first click into source selection or extraction start.""" + 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), + extend=False, + deselect_all=False, + ) + except RuntimeError: + pass + clicked_object = context.active_object + if is_image_empty(clicked_object) and 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 an Image tool") + return None, False + + +def capture_view_direction(context): + from mathutils import Vector + + direction = ( + context.region_data.view_matrix.inverted().to_3x3() + @ Vector((0.0, 0.0, 1.0)) + ).normalized() + return json.dumps(tuple(direction), separators=(",", ":")) + + +def decode_view_direction(data): + from mathutils import Vector + + direction = Vector(json.loads(data)) + if direction.length <= 1e-8: + raise ValueError("The stored View direction is invalid") + return direction.normalized() + + +def polygon_bounds(polygon): + import numpy as np + + points = np.asarray(polygon, dtype=np.float64) + if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2: + raise ValueError("The selection does not define a valid image region") + if not np.isfinite(points).all(): + raise ValueError("The selection contains invalid image coordinates") + left = int(math.floor(float(points[:, 0].min()))) + top = int(math.floor(float(points[:, 1].min()))) + right = int(math.ceil(float(points[:, 0].max()))) + bottom = int(math.ceil(float(points[:, 1].max()))) + if right <= left or bottom <= top: + raise ValueError("The selection does not define a valid image region") + return left, top, right, bottom + + +def crop_pixels_to_alpha(pixels, bounds, padding=0): + """Crop Blender-order RGBA pixels to nonzero Alpha plus pixel padding.""" + import numpy as np + + if padding < 0: + raise ValueError("Padding must not be negative") + left, top, right, bottom = bounds + width = right - left + height = bottom - top + rgba = np.flipud( + np.asarray(pixels, dtype=np.float32).reshape((height, width, 4)) + ) + visible_y, visible_x = np.nonzero(rgba[:, :, 3] > 0.0) + if not len(visible_x): + raise ValueError("The selection contains no visible image pixels") + content_left = int(visible_x.min()) + content_top = int(visible_y.min()) + content_right = int(visible_x.max()) + 1 + content_bottom = int(visible_y.max()) + 1 + output_width = content_right - content_left + padding * 2 + output_height = content_bottom - content_top + padding * 2 + output = np.zeros((output_height, output_width, 4), dtype=np.float32) + output[ + padding : padding + content_bottom - content_top, + padding : padding + content_right - content_left, + ] = rgba[content_top:content_bottom, content_left:content_right] + output[output[:, :, 3] <= 0.0] = 0.0 + return np.flipud(output).ravel(), ( + left + content_left - padding, + top + content_top - padding, + left + content_right + padding, + top + content_bottom + padding, + ) + + +def align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds): + from mathutils import Matrix + + source_width, source_height = source_size + left, top, right, bottom = crop_bounds + x_min, x_max, y_min, y_max = plane_bounds(source_object) + local_width = x_max - x_min + local_height = y_max - y_min + crop_x_min = x_min + left * local_width / source_width + crop_x_max = x_min + right * local_width / source_width + crop_y_max = y_max - top * local_height / source_height + crop_y_min = y_max - bottom * local_height / source_height + center = ( + (crop_x_min + crop_x_max) * 0.5, + (crop_y_min + crop_y_max) * 0.5, + 0.0, + ) + source_object.matrix_world = matrix_world @ Matrix.Translation(center) + source_object.empty_display_size = max( + crop_x_max - crop_x_min, + crop_y_max - crop_y_min, + ) + source_object.empty_image_offset = (-0.5, -0.5) + + +def replace_empty_image_pixels( + source_object, + matrix_world, + pixels, + output_size, + placement_bounds, + suffix=".extract", +): + source_image = source_object.data + source_size = tuple(source_image.size) + output_width, output_height = (int(value) for value in output_size) + if output_width <= 0 or output_height <= 0: + raise ValueError("The extracted image dimensions must be positive") + image = bpy.data.images.new( + f"{image_base_name(source_image)}{suffix}", + width=output_width, + height=output_height, + alpha=True, + ) + try: + image.colorspace_settings.name = source_image.colorspace_settings.name + image.alpha_mode = "STRAIGHT" + image.pixels.foreach_set(pixels) + image.pack() + image.update() + align_empty_to_crop( + source_object, matrix_world, source_size, placement_bounds + ) + source_object.data = image + except Exception: + bpy.data.images.remove(image, do_unlink=True) + raise + return image + + +def replace_empty_image_from_path(source_object, matrix_world, path, crop_bounds): + source_size = tuple(source_object.data.size) + colorspace = source_object.data.colorspace_settings.name + try: + image = bpy.data.images.load(str(path), check_existing=False) + except (OSError, RuntimeError) as error: + raise RuntimeError("Unable to load the extracted image") from error + try: + image.name = f"{image_base_name(source_object.data)}.extract" + image.colorspace_settings.name = colorspace + image.alpha_mode = "STRAIGHT" + image.pack() + align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds) + source_object.data = image + except Exception: + bpy.data.images.remove(image, do_unlink=True) + raise + return image + + +def image_extract_target(context, source_object, continuous): + if not continuous: + return source_object, 0 + layer = int(source_object.get(EXTRACT_LAYER_PROPERTY, 0)) + 1 + source_object[EXTRACT_LAYER_PROPERTY] = layer + target = source_object.copy() + target[EXTRACT_LAYER_PROPERTY] = layer + collection = ( + source_object.users_collection[0] + if source_object.users_collection + else context.collection + ) + collection.objects.link(target) + return target, layer + + +def offset_continuous_result(target, view_direction_data, layer): + if layer <= 0: + return + direction = decode_view_direction(view_direction_data) + world_scale = max(abs(value) for value in target.matrix_world.to_scale()) + distance = target.empty_display_size * world_scale * EXTRACT_LAYER_STEP * layer + target.matrix_world.translation += direction * distance + + +def select_image_extract_result(context, source_object, target, continuous): + bpy.ops.object.select_all(action="DESELECT") + selected = source_object if continuous else target + selected.select_set(True) + context.view_layer.objects.active = selected + + +def image_extract_background_request(input_path, selection_data, padding): + require_environment() + require_model(BEN2_MODEL_KEY) + polygon = json.loads(selection_data) + polygon_bounds(polygon) + return { + "input": str(input_path), + "polygon": polygon, + "padding": padding, + "model": BEN2_MODEL_KEY, + "device": production_device().lower(), + } + + +def apply_image_extract_settings(operator, context): + settings = context.scene.anyimage_settings + operator.remove_background = ( + ai_ready() and settings.image_extract_remove_background + ) + operator.continuous = settings.image_extract_continuous + operator.padding = settings.image_extract_padding + + +def draw_image_extract_operator_settings(layout, operator): + status = ai_status() + row = layout.row() + row.enabled = status["ready"] + row.prop(operator, "remove_background") + layout.prop(operator, "continuous") + layout.prop(operator, "padding") + if not status["ready"]: + layout.operator( + "anyimage.setup_ai_environment", + text=ai_setup_label(status), + icon="IMPORT", + ) + + +def draw_image_extract_settings(context, layout): + settings = context.scene.anyimage_settings + status = ai_status() + row = layout.row() + row.enabled = status["ready"] + row.prop(settings, "image_extract_remove_background") + layout.prop(settings, "image_extract_continuous") + layout.prop(settings, "image_extract_padding") + if not status["ready"]: + layout.operator( + "anyimage.setup_ai_environment", + text=ai_setup_label(status), + icon="IMPORT", + ) + + +def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.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 + 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) + segment_length = math.hypot(end_x - start_x, end_y - start_y) + if segment_length <= 0.0: + continue + direction_x = (end_x - start_x) / segment_length + direction_y = (end_y - start_y) / segment_length + position = 0.0 + while position < segment_length: + phase = (distance_offset + position) % pattern_length + if phase < dash_length: + run_length = min(dash_length - phase, segment_length - position) + segments.extend( + ( + ( + start_x + direction_x * position, + start_y + direction_y * position, + ), + ( + start_x + direction_x * (position + run_length), + start_y + direction_y * (position + run_length), + ), + ) + ) + else: + run_length = min(pattern_length - phase, segment_length - position) + position += run_length + distance_offset += segment_length + return tuple(segments) + + +def draw_dashed_line(vertices, color=(1.0, 1.0, 1.0, 0.9)): + import gpu + from gpu_extras.batch import batch_for_shader + + segments = dashed_line_segments(vertices) + if not segments: + return + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + batch = batch_for_shader(shader, "LINES", {"pos": segments}) + gpu.state.blend_set("ALPHA") + gpu.state.line_width_set(1.0) + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + gpu.state.blend_set("NONE") diff --git a/src/anyimage/operators/image_extract/lasso.py b/src/anyimage/operators/image_extract/lasso.py new file mode 100644 index 0000000..35c7961 --- /dev/null +++ b/src/anyimage/operators/image_extract/lasso.py @@ -0,0 +1,24 @@ +import bpy + +from .common import draw_image_extract_settings +from .region import ExtractImageRegion + + +class ImageLassoTool(bpy.types.WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = "anyimage.image_lasso" + bl_label = "Image Lasso" + bl_description = "Extract a lassoed region from an Image Empty" + bl_icon = "ops.sculpt.lasso_mask" + bl_operator = ExtractImageRegion.bl_idname + bl_keymap = ( + ( + ExtractImageRegion.bl_idname, + {"type": "LEFTMOUSE", "value": "PRESS"}, + {"properties": [("selection_shape", "LASSO")]}, + ), + ) + + def draw_settings(context, layout, _tool): + draw_image_extract_settings(context, layout) diff --git a/src/anyimage/operators/image_extract/perspective.py b/src/anyimage/operators/image_extract/perspective.py new file mode 100644 index 0000000..a620072 --- /dev/null +++ b/src/anyimage/operators/image_extract/perspective.py @@ -0,0 +1,1020 @@ +import json +import math + +import bpy + +from ...runtime import JobOperatorBase +from ..balloon.operator import ( + deserialize_matrix, + drawing_in_region, + hidden_string_property, + screen_path_to_image_pixels, + serialize_matrix, +) +from ..common import cleanup_temporary_input, push_undo +from ..image import ( + image_base_name, + is_image_empty, + plane_bounds, + prepare_image_input, + remove_temporary_input, + video_total_frames, +) +from .common import ( + apply_image_extract_settings, + capture_view_direction, + continuous_property, + image_extract_background_request, + crop_pixels_to_alpha, + draw_dashed_line, + draw_image_extract_operator_settings, + draw_image_extract_settings, + remove_background_property, + resolve_image_extract_click, + image_extract_poll, + image_extract_target, + offset_continuous_result, + padding_property, + polygon_bounds, + replace_empty_image_pixels, + select_image_extract_result, +) + + +MIN_QUAD_AREA = 16.0 +MAX_OUTPUT_DIMENSION = 8192 +MAX_OUTPUT_PIXELS = 16_777_216 +WARP_CHUNK_ROWS = 256 +IMAGE_PERSPECTIVE_TOOL_ID = "anyimage.image_perspective" +PREVIEW_TEXTURE_SIZE = 512 +ASPECT_PIXELS_PER_DOUBLE = 240.0 +MIN_INTERACTIVE_ASPECT = 0.05 +MAX_INTERACTIVE_ASPECT = 20.0 +ASPECT_PRESETS = ( + ("9 : 16", 9.0 / 16.0), + ("3 : 4", 3.0 / 4.0), + ("1 : 1", 1.0), + ("4 : 3", 4.0 / 3.0), + ("16 : 9", 16.0 / 9.0), +) + + +def validate_perspective_quad(points, min_area=1e-6): + """Return four convex corners without changing their correspondence.""" + import numpy as np + + 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") + 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") + 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") + area = 0.5 * abs( + float( + np.dot(quad[:, 0], np.roll(quad[:, 1], -1)) + - np.dot(quad[:, 1], np.roll(quad[:, 0], -1)) + ) + ) + if area < min_area: + raise ValueError("Draw a larger perspective region") + return quad + + +def canonical_perspective_quad(points, min_area=1e-6): + """Order four unordered points as source-oriented top-left through bottom-left.""" + import numpy as np + + 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") + 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") + 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) + candidates = [] + for winding in (ring, ring[::-1]): + for start in range(4): + candidate = np.roll(winding, -start, axis=0) + top = candidate[1] - candidate[0] + left = candidate[3] - candidate[0] + score = top[0] / np.linalg.norm(top) + left[1] / np.linalg.norm(left) + candidates.append((float(score), -candidate[0, 1], -candidate[0, 0], candidate)) + return max(candidates, key=lambda item: item[:3])[3] + + +def preview_polygon(points): + """Return a non-crossing preview hull for the current unordered clicks.""" + import numpy as np + + points = np.asarray(points, dtype=np.float64) + if len(points) < 3: + return points + center = points.mean(axis=0) + return points[np.argsort(np.arctan2(points[:, 1] - center[1], points[:, 0] - center[0]))] + + +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)) + if clipped.ndim != 2 or len(clipped) < 3: + return False + area = 0.5 * abs( + float( + np.dot(clipped[:, 0], np.roll(clipped[:, 1], -1)) + - np.dot(clipped[:, 1], np.roll(clipped[:, 0], -1)) + ) + ) + return area > 1e-6 + + +def triangle_fan_vertices(vertices): + vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices) + triangles = [] + for index in range(1, len(vertices) - 1): + triangles.extend((vertices[0], vertices[index], vertices[index + 1])) + return tuple(triangles) + + +def draw_polygon_fill(vertices): + import gpu + from gpu_extras.batch import batch_for_shader + + triangles = triangle_fan_vertices(vertices) + if not triangles: + return + shader = gpu.shader.from_builtin("UNIFORM_COLOR") + batch = batch_for_shader(shader, "TRIS", {"pos": triangles}) + gpu.state.blend_set("ALPHA") + shader.bind() + shader.uniform_float("color", (1.0, 1.0, 1.0, 0.16)) + batch.draw(shader) + gpu.state.blend_set("NONE") + + +def active_view3d_tool_id(context): + tools = getattr(getattr(context, "workspace", None), "tools", None) + from_mode = getattr(tools, "from_space_view3d_mode", None) + if not callable(from_mode): + return None + try: + tool = from_mode(getattr(context, "mode", "OBJECT"), create=False) + except (RuntimeError, TypeError): + return None + return getattr(tool, "idname", None) + + +def aspect_ratio_from_mouse(initial_ratio, horizontal_delta): + ratio = initial_ratio * math.pow( + 2.0, float(horizontal_delta) / ASPECT_PIXELS_PER_DOUBLE + ) + return min(max(ratio, MIN_INTERACTIVE_ASPECT), MAX_INTERACTIVE_ASPECT) + + +def snapped_aspect_ratio(aspect_ratio): + label, ratio = min( + ASPECT_PRESETS, + key=lambda preset: abs(math.log(aspect_ratio / preset[1])), + ) + return ratio, label + + +def aspect_ratio_label(aspect_ratio, preset_label=None): + if preset_label is not None: + return preset_label + if aspect_ratio >= 1.0: + return f"{aspect_ratio:.2f} : 1" + return f"1 : {1.0 / aspect_ratio:.2f}" + + +def preview_draw_bounds(region_size, aspect_ratio): + region_width, region_height = (float(value) for value in region_size) + available_width = min(region_width * 0.65, 640.0) + available_height = min(region_height * 0.65, 640.0) + if aspect_ratio >= available_width / available_height: + width = available_width + height = width / aspect_ratio + else: + height = available_height + width = height * aspect_ratio + return ( + (region_width - width) * 0.5, + (region_height - height) * 0.5, + width, + height, + ) + + +def draw_preview_frame(bounds): + import gpu + from gpu_extras.batch import batch_for_shader + + left, bottom, width, height = bounds + vertices = ( + (left, bottom), + (left + width, bottom), + (left + width, bottom + height), + (left, bottom + height), + (left, bottom), + ) + 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", (0.12, 0.52, 1.0, 1.0)) + batch.draw(shader) + gpu.state.line_width_set(1.0) + gpu.state.blend_set("NONE") + + +def draw_centered_text(text, center_x, baseline_y, size, color): + import blf + + font_id = 0 + blf.size(font_id, size) + width, _height = blf.dimensions(font_id, text) + x = center_x - width * 0.5 + blf.position(font_id, x + 1.0, baseline_y - 1.0, 0.0) + blf.color(font_id, 0.0, 0.0, 0.0, 0.9) + blf.draw(font_id, text) + blf.position(font_id, x, baseline_y, 0.0) + blf.color(font_id, *color) + blf.draw(font_id, text) + + +def draw_aspect_hud(bounds, aspect_ratio, preset_label=None): + left, bottom, width, height = bounds + center_x = left + width * 0.5 + draw_centered_text( + aspect_ratio_label(aspect_ratio, preset_label), + center_x, + bottom + height * 0.5 - 10.0, + 22, + (0.25, 0.65, 1.0, 1.0), + ) + draw_centered_text( + "Move horizontally • Ctrl snap • LMB confirm • Backspace edit", + center_x, + bottom - 26.0, + 13, + (0.85, 0.9, 1.0, 1.0), + ) + + +def create_perspective_preview_image(source_image, pixels): + image = bpy.data.images.new( + f"{image_base_name(source_image)}.perspective-preview", + width=PREVIEW_TEXTURE_SIZE, + height=PREVIEW_TEXTURE_SIZE, + alpha=True, + ) + try: + image.colorspace_settings.name = source_image.colorspace_settings.name + image.alpha_mode = "STRAIGHT" + image.pixels.foreach_set(pixels) + image.update() + except Exception: + bpy.data.images.remove(image, do_unlink=True) + raise + return image + + +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 region 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 + + quad = validate_perspective_quad(quad) + width = 0.5 * ( + np.linalg.norm(quad[1] - quad[0]) + np.linalg.norm(quad[2] - quad[3]) + ) + height = 0.5 * ( + np.linalg.norm(quad[2] - quad[1]) + np.linalg.norm(quad[3] - quad[0]) + ) + return min( + max(float(width / max(height, 1e-8)), MIN_INTERACTIVE_ASPECT), + MAX_INTERACTIVE_ASPECT, + ) + + +def perspective_placement_bounds(polygon, output_size, crop_bounds): + """Map a rectified Alpha crop back to an approximate source-image rectangle.""" + source_left, source_top, source_right, source_bottom = polygon_bounds(polygon) + output_width, output_height = (float(value) for value in output_size) + crop_left, crop_top, crop_right, crop_bottom = crop_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, + ) + + +def extract_perspective_pixels( + source_pixels, + source_size, + polygon, + aspect_ratio, + padding, + placement_polygon=None, +): + output_size = perspective_output_size(polygon, aspect_ratio) + pixels = warp_perspective_pixels( + source_pixels, source_size, polygon, output_size + ) + pixels, crop_bounds = crop_pixels_to_alpha( + pixels, (0, 0, *output_size), padding + ) + cropped_size = ( + crop_bounds[2] - crop_bounds[0], + crop_bounds[3] - crop_bounds[1], + ) + placement_bounds = perspective_placement_bounds( + polygon if placement_polygon is None else placement_polygon, + output_size, + crop_bounds, + ) + return pixels, cropped_size, placement_bounds + + +def create_perspective_result( + context, + source_object, + source_matrix, + pixels, + output_size, + placement_bounds, + continuous, + view_direction_data, +): + target, layer = image_extract_target(context, source_object, continuous) + try: + replace_empty_image_pixels( + target, + source_matrix, + pixels, + output_size, + placement_bounds, + suffix=".perspective", + ) + offset_continuous_result(target, view_direction_data, layer) + except Exception: + if target is not source_object: + bpy.data.objects.remove(target, do_unlink=True) + raise + select_image_extract_result(context, source_object, target, continuous) + return target + + +class ExtractImagePerspective(bpy.types.Operator): + bl_idname = "anyimage.extract_image_perspective" + bl_label = "Extract Perspective Image" + bl_description = "Rectify four image corners while staying on the source plane" + bl_options = {"REGISTER", "UNDO"} + + source_object_name: hidden_string_property() + source_matrix_data: hidden_string_property() + view_direction_data: hidden_string_property() + selection_data: hidden_string_property() + remove_background: remove_background_property() + continuous: continuous_property() + padding: padding_property() + aspect_ratio: bpy.props.FloatProperty( + default=1.0, + min=MIN_INTERACTIVE_ASPECT, + max=MAX_INTERACTIVE_ASPECT, + options={"HIDDEN", "SKIP_SAVE"}, + ) + + _points = None + _cursor = None + _handle = None + _timer = None + _phase = "POINTS" + _preview_image = None + _source_pixels = None + _initial_aspect_ratio = 1.0 + _raw_aspect_ratio = 1.0 + _aspect_preset_label = None + _aspect_anchor_x = 0.0 + _area_pointer = 0 + _region_pointer = 0 + + def draw(self, _context): + draw_image_extract_operator_settings(self.layout, self) + + @classmethod + def poll(cls, context): + return image_extract_poll(context) + + def _draw_preview(self): + if self._preview_image is None: + return + import gpu + from gpu_extras.presets import draw_texture_2d + + region = bpy.context.region + left, bottom, width, height = preview_draw_bounds( + (region.width, region.height), self.aspect_ratio + ) + bounds = (left, bottom, width, height) + texture = gpu.texture.from_image(self._preview_image) + gpu.state.blend_set("ALPHA") + try: + draw_texture_2d(texture, (left, bottom), width, height) + finally: + gpu.state.blend_set("NONE") + draw_preview_frame(bounds) + draw_aspect_hud( + bounds, self.aspect_ratio, self._aspect_preset_label + ) + + def _draw_overlay(self): + if not self._points or not drawing_in_region( + self._area_pointer, self._region_pointer + ): + return + if self._phase == "ASPECT": + self._draw_preview() + return + vertices = list(self._points) + if self._cursor is not None and len(vertices) < 4: + vertices.append(self._cursor) + vertices = list(preview_polygon(vertices)) + draw_polygon_fill(vertices) + if len(vertices) > 2: + vertices.append(vertices[0]) + draw_dashed_line(vertices) + + def _set_point_status(self, context): + remaining = 4 - len(self._points) + context.workspace.status_text_set( + f"Image Perspective: 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"Image Perspective Aspect {self.aspect_ratio:.3f}: " + "move mouse horizontally, LMB confirms, Backspace returns, " + "RMB or Esc cancels" + ) + + def _update_aspect(self, context, event): + self._raw_aspect_ratio = aspect_ratio_from_mouse( + self._initial_aspect_ratio, + float(event.mouse_x) - self._aspect_anchor_x, + ) + if event.ctrl: + self.aspect_ratio, self._aspect_preset_label = snapped_aspect_ratio( + self._raw_aspect_ratio + ) + else: + self.aspect_ratio = self._raw_aspect_ratio + self._aspect_preset_label = None + self._set_aspect_status(context) + context.area.tag_redraw() + + def _remove_preview(self): + if self._preview_image is not None: + bpy.data.images.remove(self._preview_image, do_unlink=True) + self._preview_image = None + + def _finish(self, context): + if self._handle is not None: + bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW") + self._handle = None + if self._timer is not None: + context.window_manager.event_timer_remove(self._timer) + self._timer = None + self._remove_preview() + 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): + source_object, should_extract = resolve_image_extract_click( + context, event, self.report + ) + if not should_extract: + return {"FINISHED"} if source_object is not None else {"CANCELLED"} + if source_object is None: + return {"CANCELLED"} + if video_total_frames(source_object.data) > 1: + self.report( + {"WARNING"}, + "Image Perspective only supports a still image", + ) + return {"CANCELLED"} + apply_image_extract_settings(self, context) + self.source_object_name = source_object.name + self.source_matrix_data = serialize_matrix(source_object.matrix_world) + self.view_direction_data = capture_view_direction(context) + self.selection_data = "" + self._phase = "POINTS" + self._preview_image = None + self._source_pixels = None + self._points = [(float(event.mouse_region_x), float(event.mouse_region_y))] + self._cursor = self._points[0] + 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" + ) + self._timer = context.window_manager.event_timer_add( + 0.1, window=context.window + ) + self._set_point_status(context) + 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 != IMAGE_PERSPECTIVE_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 == "BACK_SPACE" and event.value == "PRESS": + if self._phase == "ASPECT": + self._remove_preview() + self._source_pixels = None + self._phase = "POINTS" + self._points.pop() + self._cursor = self._points[-1] + self._set_point_status(context) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + if len(self._points) > 1: + self._points.pop() + self._set_point_status(context) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + if ( + self._phase == "ASPECT" + and event.type in {"LEFT_CTRL", "RIGHT_CTRL"} + and event.value in {"PRESS", "RELEASE"} + ): + self._update_aspect(context, event) + return {"RUNNING_MODAL"} + if event.type == "MOUSEMOVE": + if self._phase == "ASPECT": + self._update_aspect(context, event) + return {"RUNNING_MODAL"} + if not drawing_in_region(self._area_pointer, self._region_pointer): + return {"PASS_THROUGH"} + 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 == "PRESS": + if not drawing_in_region(self._area_pointer, self._region_pointer): + return {"PASS_THROUGH"} + if self._phase == "ASPECT": + self._finish(context) + return self.execute(context) + point = (float(event.mouse_region_x), float(event.mouse_region_y)) + if math.dist(point, self._points[-1]) < 2.0: + return {"RUNNING_MODAL"} + self._points.append(point) + if len(self._points) < 4: + self._set_point_status(context) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + source_object = bpy.data.objects.get(self.source_object_name) + if not is_image_empty(source_object): + self.report({"ERROR"}, "The source Image Empty is no longer available") + self._finish(context) + return {"CANCELLED"} + try: + canonical_perspective_quad(self._points, MIN_QUAD_AREA) + matrix_world = deserialize_matrix(self.source_matrix_data) + polygon = screen_path_to_image_pixels( + self._points, + context.region, + context.region_data, + matrix_world, + plane_bounds(source_object), + tuple(source_object.data.size), + ) + polygon = canonical_perspective_quad(polygon) + if not perspective_quad_overlaps_image( + polygon, tuple(source_object.data.size) + ): + self.report( + {"WARNING"}, + "Perspective region does not overlap the source image", + ) + self._finish(context) + return {"CANCELLED"} + self.selection_data = json.dumps( + polygon.tolist(), separators=(",", ":") + ) + self.aspect_ratio = perspective_quad_aspect(polygon) + self._initial_aspect_ratio = self.aspect_ratio + self._raw_aspect_ratio = self.aspect_ratio + self._aspect_preset_label = None + center_x = ( + float(event.mouse_x) + - float(event.mouse_region_x) + + context.region.width * 0.5 + ) + center_y = ( + float(event.mouse_y) + - float(event.mouse_region_y) + + context.region.height * 0.5 + ) + context.window.cursor_warp(round(center_x), round(center_y)) + self._aspect_anchor_x = center_x + import numpy as np + + self._source_pixels = np.empty( + len(source_object.data.pixels), dtype=np.float32 + ) + source_object.data.pixels.foreach_get(self._source_pixels) + preview_pixels = warp_perspective_pixels( + self._source_pixels, + tuple(source_object.data.size), + polygon, + (PREVIEW_TEXTURE_SIZE, PREVIEW_TEXTURE_SIZE), + ) + if not np.any( + np.asarray(preview_pixels).reshape((-1, 4))[:, 3] > 0.0 + ): + self.report( + {"WARNING"}, + "Perspective region contains no visible image pixels", + ) + self._finish(context) + return {"CANCELLED"} + self._preview_image = create_perspective_preview_image( + source_object.data, preview_pixels + ) + self._phase = "ASPECT" + self._cursor = None + except (RuntimeError, TypeError, ValueError) as error: + self.report({"ERROR"}, str(error)) + self._finish(context) + return {"CANCELLED"} + self._set_aspect_status(context) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + return {"PASS_THROUGH"} + + def execute(self, context): + source_object = bpy.data.objects.get(self.source_object_name) + if not is_image_empty(source_object): + self.report({"ERROR"}, "The source Image Empty is no longer available") + return {"CANCELLED"} + try: + if video_total_frames(source_object.data) > 1: + raise RuntimeError("Image Perspective only supports a still image") + polygon = validate_perspective_quad(json.loads(self.selection_data)) + if not perspective_quad_overlaps_image( + polygon, tuple(source_object.data.size) + ): + self.report( + {"WARNING"}, + "Perspective region does not overlap the source image", + ) + return {"CANCELLED"} + if self.remove_background: + source_path, delete_input = prepare_image_input( + source_object.data, context.scene + ) + result = bpy.ops.anyimage.remove_image_perspective_background( + input_path=str(source_path), + delete_input=delete_input, + source_object_name=self.source_object_name, + source_matrix_data=self.source_matrix_data, + view_direction_data=self.view_direction_data, + selection_data=self.selection_data, + aspect_ratio=self.aspect_ratio, + continuous=self.continuous, + padding=self.padding, + ) + if "CANCELLED" in result: + if delete_input: + remove_temporary_input(source_path) + return {"CANCELLED"} + return {"FINISHED"} + import numpy as np + + source_pixels = self._source_pixels + if source_pixels is None or len(source_pixels) != len( + source_object.data.pixels + ): + source_pixels = np.empty( + len(source_object.data.pixels), dtype=np.float32 + ) + source_object.data.pixels.foreach_get(source_pixels) + pixels, output_size, placement_bounds = extract_perspective_pixels( + source_pixels, + tuple(source_object.data.size), + polygon, + self.aspect_ratio, + self.padding, + ) + create_perspective_result( + context, + source_object, + deserialize_matrix(self.source_matrix_data), + pixels, + output_size, + placement_bounds, + self.continuous, + self.view_direction_data, + ) + except ( + json.JSONDecodeError, + OverflowError, + RuntimeError, + TypeError, + ValueError, + ) as error: + self.report({"ERROR"}, str(error)) + return {"CANCELLED"} + return {"FINISHED"} + + +class RemoveImagePerspectiveBackground(JobOperatorBase, bpy.types.Operator): + bl_idname = "anyimage.remove_image_perspective_background" + bl_label = "Remove Perspective Image Background" + job_type = "extract-image-background" + starting_message = "Removing perspective image background" + + input_path: bpy.props.StringProperty(options={"HIDDEN"}) + delete_input: bpy.props.BoolProperty(default=False, options={"HIDDEN"}) + source_object_name: hidden_string_property() + source_matrix_data: hidden_string_property() + view_direction_data: hidden_string_property() + selection_data: hidden_string_property() + aspect_ratio: bpy.props.FloatProperty(options={"HIDDEN"}) + continuous: continuous_property() + padding: padding_property() + + def request(self, _context): + return image_extract_background_request( + self.input_path, self.selection_data, 0 + ) + + def response(self, context, result): + source_object = bpy.data.objects.get(self.source_object_name) + if not is_image_empty(source_object): + raise RuntimeError("The source Image Empty is no longer available") + import numpy as np + + polygon = validate_perspective_quad(json.loads(self.selection_data)) + background_bounds = tuple( + json.loads(result.file("bounds").read_text(encoding="utf-8")) + ) + background_image = bpy.data.images.load( + str(result.file("image")), check_existing=False + ) + try: + background_pixels = np.empty( + len(background_image.pixels), dtype=np.float32 + ) + background_image.pixels.foreach_get(background_pixels) + local_polygon = polygon - np.asarray(background_bounds[:2]) + pixels, output_size, placement_bounds = extract_perspective_pixels( + background_pixels, + tuple(background_image.size), + local_polygon, + self.aspect_ratio, + self.padding, + placement_polygon=polygon, + ) + finally: + bpy.data.images.remove(background_image, do_unlink=True) + create_perspective_result( + context, + source_object, + deserialize_matrix(self.source_matrix_data), + pixels, + output_size, + placement_bounds, + self.continuous, + self.view_direction_data, + ) + push_undo("Extract Perspective Image") + return "Perspective extraction with background removal is ready" + + def cleanup(self): + cleanup_temporary_input(self.input_path, self.delete_input) + + +class ImagePerspectiveTool(bpy.types.WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = IMAGE_PERSPECTIVE_TOOL_ID + bl_label = "Image Perspective" + bl_description = "Rectify four image corners on the source Image Empty plane" + bl_icon = "ops.sculpt.polyline_mask" + bl_operator = ExtractImagePerspective.bl_idname + bl_keymap = ( + ( + ExtractImagePerspective.bl_idname, + {"type": "LEFTMOUSE", "value": "PRESS"}, + None, + ), + ) + + def draw_settings(context, layout, _tool): + draw_image_extract_settings(context, layout) diff --git a/src/anyimage/operators/image_extract/region.py b/src/anyimage/operators/image_extract/region.py new file mode 100644 index 0000000..3e1ff89 --- /dev/null +++ b/src/anyimage/operators/image_extract/region.py @@ -0,0 +1,378 @@ +import json + +import bpy + +from ...runtime import JobOperatorBase +from ..balloon.operator import ( + deserialize_matrix, + drawing_in_region, + hidden_string_property, + screen_path_to_image_pixels, + serialize_matrix, + simplify_screen_path, +) +from ..common import HIDDEN, cleanup_temporary_input, push_undo +from ..image import ( + is_image_empty, + plane_bounds, + prepare_image_input, + remove_temporary_input, + video_total_frames, +) +from .common import ( + apply_image_extract_settings, + capture_view_direction, + continuous_property, + image_extract_background_request, + crop_pixels_to_alpha, + draw_dashed_line, + draw_image_extract_operator_settings, + remove_background_property, + resolve_image_extract_click, + image_extract_poll, + image_extract_target, + offset_continuous_result, + padding_property, + polygon_bounds, + replace_empty_image_from_path, + replace_empty_image_pixels, + select_image_extract_result, +) + + +def points_inside_polygon(points, polygon): + import numpy as np + + points = np.asarray(points, dtype=np.float64) + polygon = np.asarray(polygon, dtype=np.float64) + inside = np.zeros(len(points), dtype=bool) + previous = polygon[-1] + for current in polygon: + crosses = (current[1] > points[:, 1]) != (previous[1] > points[:, 1]) + edge_x = ( + (previous[0] - current[0]) + * (points[:, 1] - current[1]) + / (previous[1] - current[1] + 1e-300) + + current[0] + ) + inside ^= crosses & (points[:, 0] < edge_x) + previous = current + return inside + + +def extract_region_pixels(source_pixels, source_size, polygon): + """Return Blender-order RGBA pixels and the unbounded selection rectangle.""" + import numpy as np + + source_width, source_height = (int(value) for value in source_size) + if source_width <= 0 or source_height <= 0: + raise ValueError("The source image has no readable dimensions") + 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))) + left, top, right, bottom = polygon_bounds(polygon) + width = right - left + height = bottom - top + result = np.zeros((height, width, 4), dtype=np.float32) + + source_left = max(left, 0) + source_top = max(top, 0) + source_right = min(right, source_width) + source_bottom = min(bottom, source_height) + if source_right <= source_left or source_bottom <= source_top: + raise ValueError("The selection does not overlap the source image") + result[ + source_top - top : source_bottom - top, + source_left - left : source_right - left, + ] = source[source_top:source_bottom, source_left:source_right] + + y, x = np.indices((height, width), dtype=np.float64) + centers = np.column_stack( + ((x.ravel() + left + 0.5), (y.ravel() + top + 0.5)) + ) + selected = points_inside_polygon(centers, polygon).reshape((height, width)) + result[~selected] = 0.0 + result[result[:, :, 3] <= 0.0] = 0.0 + return np.flipud(result).ravel(), (left, top, right, bottom) + + +def selection_shape_property(): + return bpy.props.EnumProperty( + items=( + ("LASSO", "Lasso", "Draw a freehand image extraction region"), + ("BOX", "Box", "Draw a rectangular image extraction region"), + ), + default="BOX", + options={"HIDDEN", "SKIP_SAVE"}, + ) + + +def box_screen_path(path): + if len(path) < 2: + return () + x0, y0 = path[0] + x1, y1 = path[-1] + if abs(x1 - x0) < 2.0 or abs(y1 - y0) < 2.0: + return () + return ((x0, y0), (x1, y0), (x1, y1), (x0, y1)) + + +class ExtractImageRegion(bpy.types.Operator): + bl_idname = "anyimage.extract_image_region" + bl_label = "Extract Image Region" + bl_description = "Extract a boxed or lassoed region from an Image Empty" + bl_options = {"REGISTER", "UNDO"} + + source_object_name: hidden_string_property() + source_matrix_data: hidden_string_property() + view_direction_data: hidden_string_property() + selection_data: hidden_string_property() + remove_background: remove_background_property() + continuous: continuous_property() + padding: padding_property() + selection_shape: selection_shape_property() + + _path = None + _handle = None + _area_pointer = 0 + _region_pointer = 0 + + def draw(self, _context): + draw_image_extract_operator_settings(self.layout, self) + + @classmethod + def poll(cls, context): + return image_extract_poll(context) + + def _draw_selection(self): + if not self._path or not drawing_in_region( + self._area_pointer, self._region_pointer + ): + return + vertices = ( + list(box_screen_path(self._path)) + if self.selection_shape == "BOX" + else list(self._path) + ) + if len(vertices) > 2: + vertices.append(vertices[0]) + draw_dashed_line(vertices) + + 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): + source_object, should_extract = resolve_image_extract_click( + context, event, self.report + ) + if not should_extract: + return {"FINISHED"} if source_object is not None else {"CANCELLED"} + if source_object is None: + return {"CANCELLED"} + if video_total_frames(source_object.data) > 1: + self.report({"WARNING"}, "Image tools only support a still image") + return {"CANCELLED"} + apply_image_extract_settings(self, context) + self.source_object_name = source_object.name + self.source_matrix_data = serialize_matrix(source_object.matrix_world) + self.view_direction_data = capture_view_direction(context) + self.selection_data = "" + self._path = [(event.mouse_region_x, event.mouse_region_y)] + self._area_pointer = context.area.as_pointer() + self._region_pointer = context.region.as_pointer() + self._handle = bpy.types.SpaceView3D.draw_handler_add( + self._draw_selection, (), "WINDOW", "POST_PIXEL" + ) + context.workspace.status_text_set( + f"Image {self.selection_shape.title()}: release LMB to extract, " + "RMB or Esc to cancel" + ) + context.window_manager.modal_handler_add(self) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + + def modal(self, context, event): + if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS": + self._finish(context) + return {"CANCELLED"} + if event.type == "MOUSEMOVE": + self._path.append((event.mouse_region_x, event.mouse_region_y)) + context.area.tag_redraw() + return {"RUNNING_MODAL"} + if event.type == "LEFTMOUSE" and event.value == "RELEASE": + self._path.append((event.mouse_region_x, event.mouse_region_y)) + path = ( + list(box_screen_path(self._path)) + if self.selection_shape == "BOX" + else simplify_screen_path(self._path) + ) + if len(path) < 3: + self.report({"WARNING"}, "Draw a larger image region") + self._finish(context) + return {"CANCELLED"} + source_object = bpy.data.objects.get(self.source_object_name) + if not is_image_empty(source_object): + self.report({"ERROR"}, "The source Image Empty is no longer available") + self._finish(context) + return {"CANCELLED"} + try: + polygon = screen_path_to_image_pixels( + path, + context.region, + context.region_data, + deserialize_matrix(self.source_matrix_data), + plane_bounds(source_object), + tuple(source_object.data.size), + ) + self.selection_data = json.dumps(polygon, separators=(",", ":")) + except (RuntimeError, TypeError, ValueError) as error: + self.report({"ERROR"}, str(error)) + self._finish(context) + return {"CANCELLED"} + self._finish(context) + return self.execute(context) + return {"PASS_THROUGH"} + + def execute(self, context): + source_object = bpy.data.objects.get(self.source_object_name) + if not is_image_empty(source_object): + self.report({"ERROR"}, "The source Image Empty is no longer available") + return {"CANCELLED"} + try: + polygon = json.loads(self.selection_data) + polygon_bounds(polygon) + if video_total_frames(source_object.data) > 1: + raise RuntimeError("Image tools only support a still image") + if self.remove_background: + source_path, delete_input = prepare_image_input( + source_object.data, context.scene + ) + result = bpy.ops.anyimage.remove_image_region_background( + input_path=str(source_path), + delete_input=delete_input, + source_object_name=self.source_object_name, + source_matrix_data=self.source_matrix_data, + view_direction_data=self.view_direction_data, + selection_data=self.selection_data, + continuous=self.continuous, + padding=self.padding, + ) + if "CANCELLED" in result: + if delete_input: + remove_temporary_input(source_path) + return {"CANCELLED"} + return {"FINISHED"} + import numpy as np + + image = source_object.data + source_pixels = np.empty(len(image.pixels), dtype=np.float32) + image.pixels.foreach_get(source_pixels) + pixels, crop_bounds = extract_region_pixels( + source_pixels, tuple(image.size), polygon + ) + pixels, crop_bounds = crop_pixels_to_alpha( + pixels, crop_bounds, self.padding + ) + target, layer = image_extract_target( + context, source_object, self.continuous + ) + try: + left, top, right, bottom = crop_bounds + replace_empty_image_pixels( + target, + deserialize_matrix(self.source_matrix_data), + pixels, + (right - left, bottom - top), + crop_bounds, + ) + offset_continuous_result( + target, self.view_direction_data, layer + ) + except Exception: + if target is not source_object: + bpy.data.objects.remove(target, do_unlink=True) + raise + select_image_extract_result( + context, source_object, target, self.continuous + ) + except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error: + self.report({"ERROR"}, str(error)) + return {"CANCELLED"} + return {"FINISHED"} + + +class RemoveImageRegionBackground(JobOperatorBase, bpy.types.Operator): + bl_idname = "anyimage.remove_image_region_background" + bl_label = "Remove Image Region Background" + job_type = "extract-image-background" + starting_message = "Removing image region background" + + input_path: bpy.props.StringProperty(options=HIDDEN) + delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) + source_object_name: hidden_string_property() + source_matrix_data: hidden_string_property() + view_direction_data: hidden_string_property() + selection_data: hidden_string_property() + continuous: continuous_property() + padding: padding_property() + + def request(self, _context): + return image_extract_background_request( + self.input_path, self.selection_data, self.padding + ) + + def response(self, context, result): + source_object = bpy.data.objects.get(self.source_object_name) + if not is_image_empty(source_object): + raise RuntimeError("The source Image Empty is no longer available") + crop_bounds = tuple( + json.loads(result.file("bounds").read_text(encoding="utf-8")) + ) + target, layer = image_extract_target( + context, source_object, self.continuous + ) + try: + replace_empty_image_from_path( + target, + deserialize_matrix(self.source_matrix_data), + result.file("image"), + crop_bounds, + ) + offset_continuous_result(target, self.view_direction_data, layer) + except Exception: + if target is not source_object: + bpy.data.objects.remove(target, do_unlink=True) + raise + select_image_extract_result( + context, source_object, target, self.continuous + ) + push_undo("Extract Image Region") + return "Image extraction with background removal is ready" + + def cleanup(self): + cleanup_temporary_input(self.input_path, self.delete_input) + + +class ActivateImageExtractTool(bpy.types.Operator): + bl_idname = "anyimage.activate_image_extract_tool" + bl_label = "Image Extract Tool" + bl_description = "Activate the Image Box tool in the 3D Viewport" + bl_options = {"INTERNAL"} + + @classmethod + def poll(cls, context): + return image_extract_poll(context) + + def execute(self, _context): + return bpy.ops.wm.tool_set_by_id( + name="anyimage.image_box", space_type="VIEW_3D" + ) diff --git a/src/anyimage/operators/image_lasso.py b/src/anyimage/operators/image_lasso.py deleted file mode 100644 index b784ca3..0000000 --- a/src/anyimage/operators/image_lasso.py +++ /dev/null @@ -1,635 +0,0 @@ -import json -import math - -import bpy - -from ..properties import ( - BEN2_MODEL_KEY, - ai_ready, - ai_setup_label, - ai_status, - image_lasso_contour_analysis_property as contour_analysis_property, - image_lasso_keep_original_property as keep_original_property, - image_lasso_padding_property as padding_property, -) -from ..runtime import JobOperatorBase -from .balloon.operator import ( - deserialize_matrix, - drawing_in_region, - hidden_string_property, - screen_path_to_image_pixels, - serialize_matrix, - simplify_screen_path, -) -from .common import ( - HIDDEN, - cleanup_temporary_input, - production_device, - push_undo, - require_environment, - require_model, -) -from .image import ( - image_base_name, - is_image_empty, - plane_bounds, - prepare_image_input, - remove_temporary_input, - video_total_frames, -) - - -def lasso_bounds(polygon): - import numpy as np - - points = np.asarray(polygon, dtype=np.float64) - if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2: - raise ValueError("The lasso does not define a valid image region") - if not np.isfinite(points).all(): - raise ValueError("The lasso contains invalid image coordinates") - left = int(math.floor(float(points[:, 0].min()))) - top = int(math.floor(float(points[:, 1].min()))) - right = int(math.ceil(float(points[:, 0].max()))) - bottom = int(math.ceil(float(points[:, 1].max()))) - if right <= left or bottom <= top: - raise ValueError("The lasso does not define a valid image region") - return left, top, right, bottom - - -def points_inside_polygon(points, polygon): - import numpy as np - - points = np.asarray(points, dtype=np.float64) - polygon = np.asarray(polygon, dtype=np.float64) - inside = np.zeros(len(points), dtype=bool) - previous = polygon[-1] - for current in polygon: - crosses = (current[1] > points[:, 1]) != (previous[1] > points[:, 1]) - edge_x = ( - (previous[0] - current[0]) - * (points[:, 1] - current[1]) - / (previous[1] - current[1] + 1e-300) - + current[0] - ) - inside ^= crosses & (points[:, 0] < edge_x) - previous = current - return inside - - -def extract_lasso_pixels(source_pixels, source_size, polygon): - """Return Blender-order RGBA pixels and the unbounded lasso rectangle.""" - import numpy as np - - source_width, source_height = (int(value) for value in source_size) - if source_width <= 0 or source_height <= 0: - raise ValueError("The source image has no readable dimensions") - 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))) - left, top, right, bottom = lasso_bounds(polygon) - width = right - left - height = bottom - top - result = np.zeros((height, width, 4), dtype=np.float32) - - source_left = max(left, 0) - source_top = max(top, 0) - source_right = min(right, source_width) - source_bottom = min(bottom, source_height) - if source_right <= source_left or source_bottom <= source_top: - raise ValueError("The lasso does not overlap the source image") - result[ - source_top - top : source_bottom - top, - source_left - left : source_right - left, - ] = source[source_top:source_bottom, source_left:source_right] - - y, x = np.indices((height, width), dtype=np.float64) - centers = np.column_stack( - ((x.ravel() + left + 0.5), (y.ravel() + top + 0.5)) - ) - selected = points_inside_polygon(centers, polygon).reshape((height, width)) - result[~selected] = 0.0 - result[result[:, :, 3] <= 0.0] = 0.0 - return np.flipud(result).ravel(), (left, top, right, bottom) - - -def crop_pixels_to_alpha(pixels, bounds, padding=0): - """Crop Blender-order RGBA pixels to nonzero Alpha plus pixel padding.""" - import numpy as np - - if padding < 0: - raise ValueError("Padding must not be negative") - left, top, right, bottom = bounds - width = right - left - height = bottom - top - rgba = np.flipud( - np.asarray(pixels, dtype=np.float32).reshape((height, width, 4)) - ) - visible_y, visible_x = np.nonzero(rgba[:, :, 3] > 0.0) - if not len(visible_x): - raise ValueError("The lasso contains no visible image pixels") - content_left = int(visible_x.min()) - content_top = int(visible_y.min()) - content_right = int(visible_x.max()) + 1 - content_bottom = int(visible_y.max()) + 1 - output_width = content_right - content_left + padding * 2 - output_height = content_bottom - content_top + padding * 2 - output = np.zeros((output_height, output_width, 4), dtype=np.float32) - output[ - padding : padding + content_bottom - content_top, - padding : padding + content_right - content_left, - ] = rgba[content_top:content_bottom, content_left:content_right] - output[output[:, :, 3] <= 0.0] = 0.0 - return np.flipud(output).ravel(), ( - left + content_left - padding, - top + content_top - padding, - left + content_right + padding, - top + content_bottom + padding, - ) - - -def align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds): - from mathutils import Matrix - - source_width, source_height = source_size - left, top, right, bottom = crop_bounds - x_min, x_max, y_min, y_max = plane_bounds(source_object) - local_width = x_max - x_min - local_height = y_max - y_min - crop_x_min = x_min + left * local_width / source_width - crop_x_max = x_min + right * local_width / source_width - crop_y_max = y_max - top * local_height / source_height - crop_y_min = y_max - bottom * local_height / source_height - center = ( - (crop_x_min + crop_x_max) * 0.5, - (crop_y_min + crop_y_max) * 0.5, - 0.0, - ) - source_object.matrix_world = matrix_world @ Matrix.Translation(center) - source_object.empty_display_size = max( - crop_x_max - crop_x_min, - crop_y_max - crop_y_min, - ) - source_object.empty_image_offset = (-0.5, -0.5) - - -def replace_empty_image(source_object, matrix_world, pixels, crop_bounds): - source_image = source_object.data - source_size = tuple(source_image.size) - left, top, right, bottom = crop_bounds - image = bpy.data.images.new( - f"{image_base_name(source_image)}.extract", - width=right - left, - height=bottom - top, - alpha=True, - ) - try: - image.colorspace_settings.name = source_image.colorspace_settings.name - image.alpha_mode = "STRAIGHT" - image.pixels.foreach_set(pixels) - image.pack() - image.update() - align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds) - source_object.data = image - except Exception: - bpy.data.images.remove(image, do_unlink=True) - raise - return image - - -def replace_empty_image_from_path(source_object, matrix_world, path, crop_bounds): - source_size = tuple(source_object.data.size) - colorspace = source_object.data.colorspace_settings.name - try: - image = bpy.data.images.load(str(path), check_existing=False) - except (OSError, RuntimeError) as error: - raise RuntimeError("Unable to load the extracted image") from error - try: - image.name = f"{image_base_name(source_object.data)}.extract" - image.colorspace_settings.name = colorspace - image.alpha_mode = "STRAIGHT" - image.pack() - align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds) - source_object.data = image - except Exception: - bpy.data.images.remove(image, do_unlink=True) - raise - return image - - -def image_lasso_target(context, source_object, keep_original): - if not keep_original: - return source_object - target = source_object.copy() - collection = ( - source_object.users_collection[0] - if source_object.users_collection - else context.collection - ) - collection.objects.link(target) - return target - - -def select_image_lasso_result(context, target): - bpy.ops.object.select_all(action="DESELECT") - target.select_set(True) - context.view_layer.objects.active = target - - -def selection_shape_property(): - return bpy.props.EnumProperty( - items=( - ("LASSO", "Lasso", "Draw a freehand image extraction region"), - ("BOX", "Box", "Draw a rectangular image extraction region"), - ), - default="BOX", - options={"HIDDEN", "SKIP_SAVE"}, - ) - - -def box_screen_path(path): - if len(path) < 2: - return () - x0, y0 = path[0] - x1, y1 = path[-1] - if abs(x1 - x0) < 2.0 or abs(y1 - y0) < 2.0: - return () - return ((x0, y0), (x1, y0), (x1, y1), (x0, y1)) - - -def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.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 - 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) - segment_length = math.hypot(end_x - start_x, end_y - start_y) - if segment_length <= 0.0: - continue - direction_x = (end_x - start_x) / segment_length - direction_y = (end_y - start_y) / segment_length - position = 0.0 - while position < segment_length: - phase = (distance_offset + position) % pattern_length - if phase < dash_length: - run_length = min(dash_length - phase, segment_length - position) - segments.extend( - ( - ( - start_x + direction_x * position, - start_y + direction_y * position, - ), - ( - start_x + direction_x * (position + run_length), - start_y + direction_y * (position + run_length), - ), - ) - ) - else: - run_length = min(pattern_length - phase, segment_length - position) - position += run_length - distance_offset += segment_length - return tuple(segments) - - -def draw_dashed_line(vertices): - import gpu - from gpu_extras.batch import batch_for_shader - - segments = dashed_line_segments(vertices) - if not segments: - return - shader = gpu.shader.from_builtin("UNIFORM_COLOR") - batch = batch_for_shader(shader, "LINES", {"pos": segments}) - gpu.state.blend_set("ALPHA") - gpu.state.line_width_set(1.0) - shader.bind() - shader.uniform_float("color", (1.0, 1.0, 1.0, 0.9)) - batch.draw(shader) - gpu.state.blend_set("NONE") - - -class ExtractImageLasso(bpy.types.Operator): - bl_idname = "anyimage.extract_image_lasso" - bl_label = "Extract Image" - bl_description = "Replace an Image Empty with its lassoed image region" - bl_options = {"REGISTER", "UNDO"} - - source_object_name: hidden_string_property() - source_matrix_data: hidden_string_property() - selection_data: hidden_string_property() - contour_analysis: contour_analysis_property() - keep_original: keep_original_property() - padding: padding_property() - selection_shape: selection_shape_property() - - _path = None - _handle = None - _area_pointer = 0 - _region_pointer = 0 - - def draw(self, _context): - status = ai_status() - row = self.layout.row() - row.enabled = status["ready"] - row.prop(self, "contour_analysis") - self.layout.prop(self, "keep_original") - self.layout.prop(self, "padding") - if not status["ready"]: - self.layout.operator( - "anyimage.setup_ai_environment", - text=ai_setup_label(status), - icon="IMPORT", - ) - - @classmethod - def poll(cls, context): - return ( - context.area is not None - and context.area.type == "VIEW_3D" - and is_image_empty(context.active_object) - ) - - def _draw_selection(self): - if not self._path or not drawing_in_region( - self._area_pointer, self._region_pointer - ): - return - vertices = ( - list(box_screen_path(self._path)) - if self.selection_shape == "BOX" - else list(self._path) - ) - if len(vertices) > 2: - vertices.append(vertices[0]) - draw_dashed_line(vertices) - - 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): - source_object = context.active_object - self.contour_analysis = ( - ai_ready() and context.scene.anyimage_settings.image_lasso_contour_analysis - ) - self.keep_original = context.scene.anyimage_settings.image_lasso_keep_original - self.padding = context.scene.anyimage_settings.image_lasso_padding - self.source_object_name = source_object.name - self.source_matrix_data = serialize_matrix(source_object.matrix_world) - self.selection_data = "" - self._path = [(event.mouse_region_x, event.mouse_region_y)] - self._area_pointer = context.area.as_pointer() - self._region_pointer = context.region.as_pointer() - self._handle = bpy.types.SpaceView3D.draw_handler_add( - self._draw_selection, (), "WINDOW", "POST_PIXEL" - ) - context.workspace.status_text_set( - f"Image {self.selection_shape.title()}: release LMB to extract, " - "RMB or Esc to cancel" - ) - context.window_manager.modal_handler_add(self) - context.area.tag_redraw() - return {"RUNNING_MODAL"} - - def modal(self, context, event): - if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS": - self._finish(context) - return {"CANCELLED"} - if event.type == "MOUSEMOVE": - self._path.append((event.mouse_region_x, event.mouse_region_y)) - context.area.tag_redraw() - return {"RUNNING_MODAL"} - if event.type == "LEFTMOUSE" and event.value == "RELEASE": - self._path.append((event.mouse_region_x, event.mouse_region_y)) - path = ( - list(box_screen_path(self._path)) - if self.selection_shape == "BOX" - else simplify_screen_path(self._path) - ) - if len(path) < 3: - self.report({"WARNING"}, "Draw a larger image region") - self._finish(context) - return {"CANCELLED"} - source_object = bpy.data.objects.get(self.source_object_name) - if not is_image_empty(source_object): - self.report({"ERROR"}, "The source Image Empty is no longer available") - self._finish(context) - return {"CANCELLED"} - try: - matrix_world = deserialize_matrix(self.source_matrix_data) - polygon = screen_path_to_image_pixels( - path, - context.region, - context.region_data, - matrix_world, - plane_bounds(source_object), - tuple(source_object.data.size), - ) - self.selection_data = json.dumps(polygon, separators=(",", ":")) - except (RuntimeError, TypeError, ValueError) as error: - self.report({"ERROR"}, str(error)) - self._finish(context) - return {"CANCELLED"} - self._finish(context) - return self.execute(context) - return {"PASS_THROUGH"} - - def execute(self, context): - source_object = bpy.data.objects.get(self.source_object_name) - if not is_image_empty(source_object): - self.report({"ERROR"}, "The source Image Empty is no longer available") - return {"CANCELLED"} - try: - polygon = json.loads(self.selection_data) - crop_bounds = lasso_bounds(polygon) - if video_total_frames(source_object.data) > 1: - raise RuntimeError("Image Lasso only supports a still image") - if self.contour_analysis: - source_path, delete_input = prepare_image_input( - source_object.data, context.scene - ) - result = bpy.ops.anyimage.analyze_image_lasso( - input_path=str(source_path), - delete_input=delete_input, - source_object_name=self.source_object_name, - source_matrix_data=self.source_matrix_data, - selection_data=self.selection_data, - keep_original=self.keep_original, - padding=self.padding, - ) - if "CANCELLED" in result: - if delete_input: - remove_temporary_input(source_path) - return {"CANCELLED"} - return {"FINISHED"} - import numpy as np - - image = source_object.data - source_pixels = np.empty(len(image.pixels), dtype=np.float32) - image.pixels.foreach_get(source_pixels) - pixels, crop_bounds = extract_lasso_pixels( - source_pixels, tuple(image.size), polygon - ) - pixels, crop_bounds = crop_pixels_to_alpha( - pixels, crop_bounds, self.padding - ) - target = image_lasso_target(context, source_object, self.keep_original) - try: - replace_empty_image( - target, - deserialize_matrix(self.source_matrix_data), - pixels, - crop_bounds, - ) - except Exception: - if target is not source_object: - bpy.data.objects.remove(target, do_unlink=True) - raise - select_image_lasso_result( - context, - source_object if self.keep_original else target, - ) - except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error: - self.report({"ERROR"}, str(error)) - return {"CANCELLED"} - return {"FINISHED"} - - -class AnalyzeImageLasso(JobOperatorBase, bpy.types.Operator): - bl_idname = "anyimage.analyze_image_lasso" - bl_label = "Analyze Image Lasso" - job_type = "analyze-image-lasso" - starting_message = "Analyzing image contour" - - input_path: bpy.props.StringProperty(options=HIDDEN) - delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN) - source_object_name: hidden_string_property() - source_matrix_data: hidden_string_property() - selection_data: hidden_string_property() - keep_original: keep_original_property() - padding: padding_property() - - def request(self, _context): - require_environment() - require_model(BEN2_MODEL_KEY) - polygon = json.loads(self.selection_data) - lasso_bounds(polygon) - return { - "input": str(self.input_path), - "polygon": polygon, - "padding": self.padding, - "model": BEN2_MODEL_KEY, - "device": production_device().lower(), - } - - def response(self, context, result): - source_object = bpy.data.objects.get(self.source_object_name) - if not is_image_empty(source_object): - raise RuntimeError("The source Image Empty is no longer available") - crop_bounds = tuple( - json.loads(result.file("bounds").read_text(encoding="utf-8")) - ) - target = image_lasso_target(context, source_object, self.keep_original) - try: - replace_empty_image_from_path( - target, - deserialize_matrix(self.source_matrix_data), - result.file("image"), - crop_bounds, - ) - except Exception: - if target is not source_object: - bpy.data.objects.remove(target, do_unlink=True) - raise - select_image_lasso_result( - context, - source_object if self.keep_original else target, - ) - push_undo("Extract Image") - return "Contoured image extraction is ready" - - def cleanup(self): - cleanup_temporary_input(self.input_path, self.delete_input) - - -class ActivateImageLassoTool(bpy.types.Operator): - bl_idname = "anyimage.activate_image_lasso_tool" - bl_label = "Image Extract Tool" - bl_description = "Activate the Image Box tool in the 3D Viewport" - bl_options = {"INTERNAL"} - - @classmethod - def poll(cls, context): - return ExtractImageLasso.poll(context) - - def execute(self, _context): - return bpy.ops.wm.tool_set_by_id( - name=ImageBoxTool.bl_idname, space_type="VIEW_3D" - ) - - -class ImageBoxTool(bpy.types.WorkSpaceTool): - bl_space_type = "VIEW_3D" - bl_context_mode = "OBJECT" - bl_idname = "anyimage.image_box" - bl_label = "Image Box" - bl_description = "Replace an Image Empty with one boxed image region" - bl_icon = "ops.sculpt.border_mask" - bl_operator = ExtractImageLasso.bl_idname - bl_keymap = ( - ( - ExtractImageLasso.bl_idname, - {"type": "LEFTMOUSE", "value": "PRESS"}, - {"properties": [("selection_shape", "BOX")]}, - ), - ) - - def draw_settings(context, layout, _tool): - draw_image_extract_settings(context, layout) - - -class ImageLassoTool(bpy.types.WorkSpaceTool): - bl_space_type = "VIEW_3D" - bl_context_mode = "OBJECT" - bl_idname = "anyimage.image_lasso" - bl_label = "Image Lasso" - bl_description = "Replace an Image Empty with one lassoed image region" - bl_icon = "ops.sculpt.lasso_mask" - bl_operator = ExtractImageLasso.bl_idname - bl_keymap = ( - ( - ExtractImageLasso.bl_idname, - {"type": "LEFTMOUSE", "value": "PRESS"}, - {"properties": [("selection_shape", "LASSO")]}, - ), - ) - - def draw_settings(context, layout, _tool): - draw_image_extract_settings(context, layout) - - -def draw_image_extract_settings(context, layout): - settings = context.scene.anyimage_settings - status = ai_status() - row = layout.row() - row.enabled = status["ready"] - row.prop(settings, "image_lasso_contour_analysis") - layout.prop(settings, "image_lasso_keep_original") - layout.prop(settings, "image_lasso_padding") - if not status["ready"]: - layout.operator( - "anyimage.setup_ai_environment", - text=ai_setup_label(status), - icon="IMPORT", - ) diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index b8c104a..fcad599 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -280,25 +280,27 @@ def balloon_contour_analysis_property(): ) -def image_lasso_contour_analysis_property(): +def image_extract_remove_background_property(): return bpy.props.BoolProperty( - name="Contour", + name="Remove Background", description=( - "Use BEN2 to refine the lasso Alpha while preserving the source colors" + "Use BEN2 to remove the background inside the selected image region" ), default=False, ) -def image_lasso_keep_original_property(): +def image_extract_continuous_property(): return bpy.props.BoolProperty( - name="Keep Original", - description="Keep the source Image Empty and create a cropped copy", + name="Continuous", + description=( + "Keep the source Image Empty active and continuously create extracted copies" + ), default=False, ) -def image_lasso_padding_property(): +def image_extract_padding_property(): return bpy.props.IntProperty( name="Padding", description="Transparent padding in pixels around the visible Alpha bounds", @@ -335,9 +337,9 @@ def balloon_radial_zoom_property(scale): class AnyImageSettings(bpy.types.PropertyGroup): - image_lasso_contour_analysis: image_lasso_contour_analysis_property() - image_lasso_keep_original: image_lasso_keep_original_property() - image_lasso_padding: image_lasso_padding_property() + image_extract_remove_background: image_extract_remove_background_property() + image_extract_continuous: image_extract_continuous_property() + image_extract_padding: image_extract_padding_property() balloon_mode: balloon_mode_property() balloon_thickness: balloon_thickness_property() balloon_resolution: balloon_resolution_property() diff --git a/src/anyimage/server/__init__.py b/src/anyimage/server/__init__.py index 0dfe9a6..268e044 100644 --- a/src/anyimage/server/__init__.py +++ b/src/anyimage/server/__init__.py @@ -10,7 +10,7 @@ from .jobs.debug_ben2 import run as _debug_ben2 from .jobs.debug_da3 import run as _debug_da3 from .jobs.debug_moge2 import run as _debug_moge2 from .jobs.frames import run as _extract_frames -from .jobs.image_lasso import run as _analyze_image_lasso +from .jobs.image_extract import run as _extract_image_background from .model_manager import ModelManager, ModelSessionCache @@ -39,9 +39,9 @@ def analyze_balloon(context, parameters): return _analyze_balloon(context, parameters) -@server.job("analyze-image-lasso") -def analyze_image_lasso(context, parameters): - return _analyze_image_lasso(context, parameters) +@server.job("extract-image-background") +def extract_image_background(context, parameters): + return _extract_image_background(context, parameters) @server.job("remove-background") diff --git a/src/anyimage/server/jobs/background.py b/src/anyimage/server/jobs/background.py index 725eb62..893a284 100644 --- a/src/anyimage/server/jobs/background.py +++ b/src/anyimage/server/jobs/background.py @@ -5,6 +5,20 @@ from ..media import prepare_background_input from ..models.ben2 import infer_foreground, infer_foreground_frames +def remove_image_background( + input_path, model_directory, device, model_manager, cancel_check +): + """Remove one image background with the shared BEN2 configuration.""" + return infer_foreground( + input_path, + model_directory, + device, + model_cache=model_manager, + cancel_check=cancel_check, + preserve_input_alpha=True, + ) + + def run(context, parameters): """Run BEN2 and write transparent foreground images into the Job directory.""" total_started = time.perf_counter() @@ -30,13 +44,12 @@ def run(context, parameters): context.progress(0.12, f"Loading BEN2 for {device.upper()}") if len(images) == 1: context.progress(0.35, "Removing background") - foreground, load_ms, inference_ms = infer_foreground( + foreground, load_ms, inference_ms = remove_image_background( images[0], model_directory, device, - model_cache=model_manager, - cancel_check=context.check_cancelled, - preserve_input_alpha=True, + model_manager, + context.check_cancelled, ) context.check_cancelled() context.progress(0.92, "Writing transparent PNG") diff --git a/src/anyimage/server/jobs/image_lasso.py b/src/anyimage/server/jobs/image_extract.py similarity index 60% rename from src/anyimage/server/jobs/image_lasso.py rename to src/anyimage/server/jobs/image_extract.py index adbabdd..466366d 100644 --- a/src/anyimage/server/jobs/image_lasso.py +++ b/src/anyimage/server/jobs/image_extract.py @@ -2,36 +2,36 @@ import json import math import time -from ..models.ben2 import infer_foreground +from .background import remove_image_background -def lasso_bounds(polygon): +def polygon_bounds(polygon): import numpy as np points = np.asarray(polygon, dtype=np.float64) if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2: - raise ValueError("The lasso does not define a valid image region") + raise ValueError("The selection does not define a valid image region") if not np.isfinite(points).all(): - raise ValueError("The lasso contains invalid image coordinates") + raise ValueError("The selection contains invalid image coordinates") left = int(math.floor(float(points[:, 0].min()))) top = int(math.floor(float(points[:, 1].min()))) right = int(math.ceil(float(points[:, 0].max()))) bottom = int(math.ceil(float(points[:, 1].max()))) if right <= left or bottom <= top: - raise ValueError("The lasso does not define a valid image region") + raise ValueError("The selection does not define a valid image region") return left, top, right, bottom -def create_lasso_canvas(input_path, polygon): - """Create an unbounded lasso canvas with transparent black outside.""" +def create_selection_canvas(input_path, polygon): + """Create an unbounded selection canvas with transparent black outside.""" import numpy as np from PIL import Image, ImageDraw with Image.open(input_path) as opened_image: source = opened_image.convert("RGBA") - left, top, right, bottom = lasso_bounds(polygon) + left, top, right, bottom = polygon_bounds(polygon) if right <= 0 or bottom <= 0 or left >= source.width or top >= source.height: - raise ValueError("The lasso does not overlap the source image") + raise ValueError("The selection does not overlap the source image") canvas = Image.new("RGBA", (right - left, bottom - top), (0, 0, 0, 0)) canvas.paste(source, (-left, -top)) relative = [(float(x) - left, float(y) - top) for x, y in polygon] @@ -48,27 +48,23 @@ def create_lasso_canvas(input_path, polygon): ) -def apply_contour_alpha(canvas, selection_mask, foreground): - """Keep source RGB and combine source, lasso, and BEN2 alpha.""" +def apply_selection_mask(foreground, selection_mask): + """Clear pixels outside the source selection after background removal.""" import numpy as np from PIL import Image - source = np.asarray(canvas.convert("RGBA"), dtype=np.uint8).copy() - alpha = np.asarray(foreground.convert("RGBA").getchannel("A"), dtype=np.uint8) - if alpha.shape != selection_mask.shape: - alpha = np.asarray( - Image.fromarray(alpha, mode="L").resize( - canvas.size, Image.Resampling.BILINEAR + pixels = np.asarray(foreground.convert("RGBA"), dtype=np.uint8).copy() + if pixels.shape[:2] != selection_mask.shape: + pixels = np.asarray( + foreground.convert("RGBA").resize( + (selection_mask.shape[1], selection_mask.shape[0]), + Image.Resampling.BILINEAR, ), dtype=np.uint8, ) - source[:, :, 3] = np.where( - selection_mask, - np.minimum(source[:, :, 3], alpha), - 0, - ).astype(np.uint8) - source[source[:, :, 3] == 0, :3] = 0 - return Image.fromarray(source, mode="RGBA") + pixels[~selection_mask] = 0 + pixels[pixels[:, :, 3] == 0, :3] = 0 + return Image.fromarray(pixels, mode="RGBA") def crop_to_alpha(image, bounds, padding=0): @@ -81,7 +77,7 @@ def crop_to_alpha(image, bounds, padding=0): pixels = np.asarray(image.convert("RGBA"), dtype=np.uint8) visible_y, visible_x = np.nonzero(pixels[:, :, 3] > 0) if not len(visible_x): - raise ValueError("The lasso contains no visible image pixels") + raise ValueError("The selection contains no visible image pixels") content_left = int(visible_x.min()) content_top = int(visible_y.min()) content_right = int(visible_x.max()) + 1 @@ -105,47 +101,38 @@ def crop_to_alpha(image, bounds, padding=0): def run(context, parameters): - """Use BEN2 to refine an Image Lasso without changing its canvas bounds.""" + """Remove the background from an image selection and return source bounds.""" started = time.perf_counter() - context.progress(0.05, "Preparing image lasso") + context.progress(0.05, "Preparing image selection") context.check_cancelled() output_directory = context.directory output_directory.mkdir(parents=True, exist_ok=True) - canvas, selection_mask, bounds = create_lasso_canvas( + canvas, selection_mask, bounds = create_selection_canvas( parameters["input"], parameters["polygon"] ) - lasso_path = output_directory / "lasso-input.png" - import numpy as np - from PIL import Image - - analysis_pixels = np.asarray(canvas, dtype=np.uint8).copy() - analysis_pixels[~selection_mask] = 0 - Image.fromarray(analysis_pixels, mode="RGBA").save( - lasso_path, format="PNG", compress_level=1 - ) + selection_path = output_directory / "selection-input.png" + canvas.save(selection_path, format="PNG", compress_level=1) model_manager = context.resource("model_manager") context.progress(0.2, f"Loading BEN2 for {parameters['device'].upper()}") - foreground, _load_ms, _inference_ms = infer_foreground( - lasso_path, + foreground, _load_ms, _inference_ms = remove_image_background( + selection_path, model_manager.directory(parameters["model"]), parameters["device"], - model_cache=model_manager, - cancel_check=context.check_cancelled, + model_manager, + context.check_cancelled, ) context.check_cancelled() - context.progress(0.9, "Applying contour alpha") + context.progress(0.9, "Applying selection mask") output, bounds = crop_to_alpha( - apply_contour_alpha(canvas, selection_mask, foreground), + apply_selection_mask(foreground, selection_mask), bounds, int(parameters.get("padding", 0)), ) output_path = output_directory / "image.png" - output.save( - output_path, format="PNG", compress_level=1 - ) + output.save(output_path, format="PNG", compress_level=1) bounds_path = output_directory / "bounds.json" bounds_path.write_text(json.dumps(bounds, separators=(",", ":")), encoding="utf-8") elapsed_ms = (time.perf_counter() - started) * 1000.0 - message = f"Image contour finished in {elapsed_ms:.0f} ms" + message = f"Image background removal finished in {elapsed_ms:.0f} ms" context.progress(1.0, message) return {"image": output_path.name, "bounds": bounds_path.name} diff --git a/tests/test_addon.py b/tests/test_addon.py index dcd63ca..f8790a3 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -95,7 +95,21 @@ class AddonTest(unittest.TestCase): ) cls.image_background = importlib.import_module("anyimage.operators.background") cls.image_mesh = importlib.import_module("anyimage.operators.surface") - cls.image_lasso = importlib.import_module("anyimage.operators.image_lasso") + cls.image_extract_common = importlib.import_module( + "anyimage.operators.image_extract.common" + ) + cls.image_region = importlib.import_module( + "anyimage.operators.image_extract.region" + ) + cls.image_box = importlib.import_module( + "anyimage.operators.image_extract.box" + ) + cls.image_lasso = importlib.import_module( + "anyimage.operators.image_extract.lasso" + ) + cls.image_perspective = importlib.import_module( + "anyimage.operators.image_extract.perspective" + ) cls.balloon = importlib.import_module("anyimage.operators.balloon.operator") cls.balloon_geometry = importlib.import_module( "anyimage.operators.balloon.geometry" @@ -779,12 +793,12 @@ class AddonTest(unittest.TestCase): self.assertGreater(self.balloon_geometry.polygon_area(contour), 0.0) self.assertLess(float(lengths.std() / lengths.mean()), 0.15) - def test_image_lasso_clears_rgba_outside_the_selection(self): + def test_image_region_clears_rgba_outside_the_selection(self): import numpy as np top_down = np.full((3, 3, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32) source = np.flipud(top_down).ravel() - pixels, bounds = self.image_lasso.extract_lasso_pixels( + pixels, bounds = self.image_region.extract_region_pixels( source, (3, 3), ((0, 0), (3, 0), (0, 3)), @@ -795,12 +809,12 @@ class AddonTest(unittest.TestCase): np.testing.assert_allclose(result[2, 2], (0.0, 0.0, 0.0, 0.0)) self.assertEqual(float(result[0, 0, 3]), 1.0) - def test_image_lasso_clears_hidden_rgb_from_source_transparency(self): + def test_image_region_clears_hidden_rgb_from_source_transparency(self): import numpy as np top_down = np.full((2, 2, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32) top_down[0, 0, 3] = 0.0 - pixels, _bounds = self.image_lasso.extract_lasso_pixels( + pixels, _bounds = self.image_region.extract_region_pixels( np.flipud(top_down).ravel(), (2, 2), ((0, 0), (2, 0), (2, 2), (0, 2)), @@ -809,11 +823,11 @@ class AddonTest(unittest.TestCase): np.testing.assert_allclose(result[0, 0], (0.0, 0.0, 0.0, 0.0)) - def test_image_lasso_keeps_out_of_image_canvas_transparent(self): + def test_image_region_keeps_out_of_image_canvas_transparent(self): import numpy as np top_down = np.ones((2, 2, 4), dtype=np.float32) - pixels, bounds = self.image_lasso.extract_lasso_pixels( + pixels, bounds = self.image_region.extract_region_pixels( np.flipud(top_down).ravel(), (2, 2), ((-1, -1), (2, -1), (2, 2), (-1, 2)), @@ -824,22 +838,22 @@ class AddonTest(unittest.TestCase): self.assertEqual(float(result[0, 0, 3]), 0.0) self.assertEqual(float(result[1, 1, 3]), 1.0) - def test_image_lasso_rejects_a_fully_out_of_image_region(self): + def test_image_region_rejects_a_fully_out_of_image_region(self): import numpy as np with self.assertRaisesRegex(ValueError, "does not overlap"): - self.image_lasso.extract_lasso_pixels( + self.image_region.extract_region_pixels( np.ones(2 * 2 * 4, dtype=np.float32), (2, 2), ((3, 3), (4, 3), (4, 4), (3, 4)), ) - def test_image_lasso_crops_to_visible_alpha_with_pixel_padding(self): + def test_image_region_crops_to_visible_alpha_with_pixel_padding(self): import numpy as np rgba = np.zeros((4, 5, 4), dtype=np.float32) rgba[1:3, 2:4] = (0.2, 0.4, 0.6, 1.0) - pixels, bounds = self.image_lasso.crop_pixels_to_alpha( + pixels, bounds = self.image_extract_common.crop_pixels_to_alpha( np.flipud(rgba).ravel(), (10, 20, 15, 24), padding=1, @@ -852,61 +866,380 @@ class AddonTest(unittest.TestCase): self.assertTrue(np.all(result[:, [0, -1], 3] == 0.0)) self.assertTrue(np.all(result[1:3, 1:3, 3] == 1.0)) - def test_image_lasso_tool_and_operators_are_registered(self): - self.assertIn(self.image_lasso.ExtractImageLasso, self.addon.CLASSES) - self.assertIn(self.image_lasso.AnalyzeImageLasso, self.addon.CLASSES) - self.assertIn(self.image_lasso.ActivateImageLassoTool, self.addon.CLASSES) + def test_image_extract_tools_and_operators_are_registered(self): + import inspect + + self.assertIn(self.image_region.ExtractImageRegion, self.addon.CLASSES) + self.assertIn( + self.image_region.RemoveImageRegionBackground, self.addon.CLASSES + ) + self.assertIn(self.image_region.ActivateImageExtractTool, self.addon.CLASSES) self.assertNotIn(self.image_lasso.ImageLassoTool, self.addon.CLASSES) - self.assertNotIn(self.image_lasso.ImageBoxTool, self.addon.CLASSES) + self.assertNotIn(self.image_box.ImageBoxTool, self.addon.CLASSES) self.assertEqual( self.image_lasso.ImageLassoTool.bl_operator, - self.image_lasso.ExtractImageLasso.bl_idname, + self.image_region.ExtractImageRegion.bl_idname, ) self.assertEqual( self.image_lasso.ImageLassoTool.bl_icon, "ops.sculpt.lasso_mask", ) self.assertEqual( - self.image_lasso.ImageBoxTool.bl_icon, + self.image_box.ImageBoxTool.bl_icon, "ops.sculpt.border_mask", ) self.assertEqual( - self.image_lasso.ImageBoxTool.bl_keymap[0][2]["properties"], + self.image_box.ImageBoxTool.bl_keymap[0][2]["properties"], [("selection_shape", "BOX")], ) self.assertEqual( self.image_lasso.ImageLassoTool.bl_keymap[0][2]["properties"], [("selection_shape", "LASSO")], ) - self.assertNotIn("bl_cursor", self.image_lasso.ImageBoxTool.__dict__) + self.assertNotIn("bl_cursor", self.image_box.ImageBoxTool.__dict__) self.assertNotIn("bl_cursor", self.image_lasso.ImageLassoTool.__dict__) self.assertEqual( - self.image_lasso.ExtractImageLasso.__annotations__["selection_shape"][ + self.image_region.ExtractImageRegion.__annotations__["selection_shape"][ "default" ], "BOX", ) - operator = self.image_lasso.ExtractImageLasso - self.assertIn("keep_original", operator.__annotations__) - self.assertFalse(operator.__annotations__["keep_original"]["default"]) + operator = self.image_region.ExtractImageRegion + self.assertIn("continuous", operator.__annotations__) + self.assertFalse(operator.__annotations__["continuous"]["default"]) self.assertIn("padding", operator.__annotations__) self.assertEqual(operator.__annotations__["padding"]["default"], 0) settings = self.addon.properties.AnyImageSettings.__annotations__ - self.assertFalse(settings["image_lasso_keep_original"]["default"]) - self.assertEqual(settings["image_lasso_padding"]["default"], 0) + self.assertFalse(settings["image_extract_continuous"]["default"]) + self.assertEqual(settings["image_extract_padding"]["default"], 0) + self.assertEqual( + settings["image_extract_remove_background"]["name"], + "Remove Background", + ) + source_picker = inspect.getsource( + self.image_extract_common.resolve_image_extract_click + ) + continuous_offset = inspect.getsource( + self.image_extract_common.offset_continuous_result + ) + self.assertIn("bpy.ops.view3d.select(", source_picker) + self.assertIn("Click an Image Empty", source_picker) + self.assertIn("_restore_selection(", source_picker) + self.assertIn("EXTRACT_LAYER_STEP * layer", continuous_offset) + + def test_image_extract_first_click_selects_another_image_without_extracting(self): + selected = [] + + def image(name): + return SimpleNamespace( + name=name, + type="EMPTY", + empty_display_type="IMAGE", + data=object(), + select_set=lambda value: selected.append((name, value)), + ) + + active = image("Active") + clicked = image("Clicked") + context = SimpleNamespace( + active_object=active, + selected_objects=(active,), + view_layer=SimpleNamespace(objects=SimpleNamespace(active=active)), + ) + 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 + + def select(**_kwargs): + context.active_object = clicked + + self.fake_bpy.ops = SimpleNamespace( + view3d=SimpleNamespace(select=select), + object=SimpleNamespace(select_all=lambda **_kwargs: None), + ) + self.fake_bpy.data.objects = { + active.name: active, + clicked.name: clicked, + } + try: + source, should_extract = ( + self.image_extract_common.resolve_image_extract_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_extract) + self.assertEqual(selected, []) + + def test_image_extract_first_click_on_non_image_keeps_active_image(self): + selection_calls = [] + 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 + + def select(**_kwargs): + 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_extract = ( + self.image_extract_common.resolve_image_extract_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, active) + self.assertTrue(should_extract) + self.assertIs(view_objects.active, active) + self.assertEqual(selection_calls, [False, True]) + + def test_image_perspective_rectifies_identity_pixels(self): + import numpy as np + + 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( + np.flipud(top_down).ravel(), + (4, 3), + ((0, 0), (4, 0), (4, 3), (0, 3)), + (4, 3), + ) + + np.testing.assert_allclose( + np.flipud(np.asarray(result).reshape((3, 4, 4))), + top_down, + atol=1e-6, + ) + + def test_image_perspective_crops_alpha_and_applies_padding(self): + import numpy as np + + top_down = np.zeros((4, 4, 4), dtype=np.float32) + top_down[1:3, 1:3] = (0.2, 0.4, 0.6, 1.0) + pixels, output_size, placement_bounds = ( + self.image_perspective.extract_perspective_pixels( + np.flipud(top_down).ravel(), + (4, 4), + ((0, 0), (4, 0), (4, 4), (0, 4)), + 1.0, + 1, + ) + ) + result = np.flipud(np.asarray(pixels).reshape((4, 4, 4))) + + self.assertEqual(output_size, (4, 4)) + self.assertEqual(placement_bounds, (0.0, 0.0, 4.0, 4.0)) + self.assertTrue(np.all(result[[0, -1], :, 3] == 0.0)) + self.assertTrue(np.all(result[:, [0, -1], 3] == 0.0)) + self.assertTrue(np.all(result[1:3, 1:3, 3] == 1.0)) + + def test_image_perspective_rejects_non_convex_corners(self): + with self.assertRaisesRegex(ValueError, "convex"): + self.image_perspective.validate_perspective_quad( + ((0, 0), (10, 0), (4, 4), (0, 10)) + ) + + def test_image_perspective_corner_order_is_click_order_independent(self): + import itertools + import numpy as np + + corners = ((10, 20), (80, 15), (90, 70), (5, 75)) + ordered = self.image_perspective.canonical_perspective_quad(corners) + for permutation in itertools.permutations(corners): + np.testing.assert_allclose( + self.image_perspective.canonical_perspective_quad(permutation), + ordered, + ) + + def test_image_perspective_detects_source_image_overlap(self): + overlaps = self.image_perspective.perspective_quad_overlaps_image + + self.assertFalse( + overlaps(((20, 20), (30, 20), (30, 30), (20, 30)), (10, 10)) + ) + self.assertTrue( + overlaps(((5, 5), (15, 5), (15, 15), (5, 15)), (10, 10)) + ) + self.assertTrue( + overlaps(((-5, -5), (15, -5), (15, 15), (-5, 15)), (10, 10)) + ) + + def test_image_perspective_builds_polyline_mask_fill_triangles(self): + self.assertEqual( + self.image_perspective.triangle_fan_vertices( + ((0, 0), (2, 0), (2, 2), (0, 2)) + ), + ( + (0.0, 0.0), + (2.0, 0.0), + (2.0, 2.0), + (0.0, 0.0), + (2.0, 2.0), + (0.0, 2.0), + ), + ) + + def test_image_perspective_limits_output_pixel_count(self): + width, height = self.image_perspective.perspective_output_size( + ((0, 0), (10000, 0), (10000, 10000), (0, 10000)), + 1.0, + ) + + self.assertLessEqual( + width * height, self.image_perspective.MAX_OUTPUT_PIXELS + ) + + def test_image_perspective_mouse_adjusts_preview_aspect_exponentially(self): + adjust = self.image_perspective.aspect_ratio_from_mouse + + self.assertAlmostEqual(adjust(1.5, 240.0), 3.0) + self.assertAlmostEqual(adjust(1.5, -240.0), 0.75) + self.assertEqual( + adjust(1.0, 100000.0), + self.image_perspective.MAX_INTERACTIVE_ASPECT, + ) + self.assertEqual( + self.image_perspective.snapped_aspect_ratio(1.7), + (16.0 / 9.0, "16 : 9"), + ) + + def test_image_perspective_preview_is_centered_at_requested_aspect(self): + left, bottom, width, height = self.image_perspective.preview_draw_bounds( + (1000, 800), 2.0 + ) + + self.assertAlmostEqual(width / height, 2.0) + self.assertAlmostEqual(left + width * 0.5, 500.0) + self.assertAlmostEqual(bottom + height * 0.5, 400.0) + + def test_image_perspective_uses_quad_edges_for_initial_aspect(self): + ratio = self.image_perspective.perspective_quad_aspect( + ((0, 0), (8, 0), (8, 4), (0, 4)) + ) + + self.assertAlmostEqual(ratio, 2.0) + + def test_image_perspective_maps_padding_to_source_plane_bounds(self): + bounds = self.image_perspective.perspective_placement_bounds( + ((10, 20), (30, 20), (30, 40), (10, 40)), + (100, 50), + (-10, -5, 110, 55), + ) + + self.assertEqual(bounds, (8.0, 18.0, 32.0, 42.0)) + + def test_image_perspective_tool_uses_four_clicks_and_shared_style(self): + import inspect + + operator = self.image_perspective.ExtractImagePerspective + tool = self.image_perspective.ImagePerspectiveTool + settings = self.addon.properties.AnyImageSettings.__annotations__ + + self.assertIn(operator, self.addon.CLASSES) + self.assertIn( + self.image_perspective.RemoveImagePerspectiveBackground, + self.addon.CLASSES, + ) + self.assertNotIn(tool, self.addon.CLASSES) + self.assertEqual(tool.bl_operator, operator.bl_idname) + self.assertEqual(tool.bl_icon, "ops.sculpt.polyline_mask") + self.assertNotIn("bl_cursor", tool.__dict__) + self.assertIn("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.assertIn("remove_background", operator.__annotations__) + self.assertIn("continuous", operator.__annotations__) + self.assertIn("padding", operator.__annotations__) + modal_source = inspect.getsource(operator.modal) + invoke_source = inspect.getsource(operator.invoke) + execute_source = inspect.getsource(operator.execute) + background_request_source = inspect.getsource( + self.image_perspective.RemoveImagePerspectiveBackground.request + ) + background_response_source = inspect.getsource( + self.image_perspective.RemoveImagePerspectiveBackground.response + ) + draw_source = inspect.getsource(operator._draw_overlay) + preview_source = inspect.getsource(operator._draw_preview) + finish_source = inspect.getsource(operator._finish) + self.assertIn("if len(self._points) < 4", modal_source) + self.assertIn("return self.execute(context)", modal_source) + self.assertIn('self._phase = "ASPECT"', modal_source) + self.assertIn("create_perspective_preview_image(", modal_source) + self.assertIn("self._update_aspect(context, event)", modal_source) + self.assertIn("context.window.cursor_warp(", modal_source) + self.assertIn("perspective_quad_aspect(polygon)", modal_source) + self.assertIn("active_view3d_tool_id(context)", modal_source) + self.assertIn("drawing_in_region(", modal_source) + self.assertNotIn("context.region_data.is_perspective", invoke_source) + self.assertIn("resolve_image_extract_click(", invoke_source) + self.assertIn("apply_image_extract_settings(self, context)", invoke_source) + self.assertIn( + "bpy.ops.anyimage.remove_image_perspective_background(", + execute_source, + ) + self.assertIn("extract_perspective_pixels(", execute_source) + self.assertIn("create_perspective_result(", execute_source) + self.assertIn("image_extract_background_request(", background_request_source) + self.assertIn("placement_polygon=polygon", background_response_source) + self.assertIn("self.padding", background_response_source) + self.assertIn("draw_polygon_fill(vertices)", draw_source) + self.assertIn("self._draw_preview()", draw_source) + self.assertIn("draw_texture_2d(", preview_source) + self.assertIn("draw_preview_frame(", preview_source) + self.assertIn("draw_aspect_hud(", preview_source) + self.assertIn("event_timer_remove", finish_source) + self.assertIn("self._remove_preview()", finish_source) + self.assertNotIn('"RET"', modal_source) def test_image_box_builds_a_rectangular_screen_path(self): self.assertEqual( - self.image_lasso.box_screen_path(((2, 3), (8, 9))), + self.image_region.box_screen_path(((2, 3), (8, 9))), ((2, 3), (8, 3), (8, 9), (2, 9)), ) self.assertEqual( - self.image_lasso.box_screen_path(((2, 3), (3, 9))), + self.image_region.box_screen_path(((2, 3), (3, 9))), (), ) def test_image_extract_selection_uses_white_dashed_segments(self): - segments = self.image_lasso.dashed_line_segments( + segments = self.image_extract_common.dashed_line_segments( ((0, 0), (10, 0)), dash_length=3.0, gap_length=2.0 ) @@ -918,15 +1251,17 @@ class AddonTest(unittest.TestCase): def test_image_extract_activation_defaults_to_box(self): import inspect - source = inspect.getsource(self.image_lasso.ActivateImageLassoTool.execute) - self.assertIn("name=ImageBoxTool.bl_idname", source) + source = inspect.getsource(self.image_region.ActivateImageExtractTool.execute) + self.assertIn('name="anyimage.image_box"', source) - def test_keep_original_restores_the_source_empty_selection(self): + def test_continuous_keeps_the_source_empty_selected(self): import inspect - execute_source = inspect.getsource(self.image_lasso.ExtractImageLasso.execute) - response_source = inspect.getsource(self.image_lasso.AnalyzeImageLasso.response) - expected = "source_object if self.keep_original else target" + execute_source = inspect.getsource(self.image_region.ExtractImageRegion.execute) + response_source = inspect.getsource( + self.image_region.RemoveImageRegionBackground.response + ) + expected = "select_image_extract_result(" self.assertIn(expected, execute_source) self.assertIn(expected, response_source) @@ -1811,7 +2146,7 @@ class AddonTest(unittest.TestCase): self.registered_tools, [ ( - self.image_lasso.ImageBoxTool, + self.image_box.ImageBoxTool, { "group": True, "separator": True, @@ -1820,7 +2155,14 @@ class AddonTest(unittest.TestCase): ( self.image_lasso.ImageLassoTool, { - "after": {self.image_lasso.ImageBoxTool.bl_idname}, + "after": {self.image_box.ImageBoxTool.bl_idname}, + "separator": False, + }, + ), + ( + self.image_perspective.ImagePerspectiveTool, + { + "after": {self.image_lasso.ImageLassoTool.bl_idname}, "separator": False, }, ), diff --git a/tests/test_pack.py b/tests/test_pack.py index 3c4f1a7..3b0f41f 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -68,7 +68,14 @@ class PackTest(unittest.TestCase): self.assertIn("operators/mesh_plane.py", names) self.assertIn("operators/image.py", names) self.assertIn("operators/balloon/operator.py", names) - self.assertIn("operators/image_lasso.py", names) + self.assertIn("operators/image_extract/__init__.py", names) + self.assertIn("operators/image_extract/common.py", names) + self.assertIn("operators/image_extract/region.py", names) + self.assertIn("operators/image_extract/box.py", names) + self.assertIn("operators/image_extract/lasso.py", names) + self.assertIn("operators/image_extract/perspective.py", names) + self.assertNotIn("operators/image_lasso.py", names) + self.assertNotIn("operators/image_perspective.py", names) self.assertIn("operators/surface.py", names) self.assertIn( "assets/O_AnyImage.blend", @@ -79,7 +86,8 @@ class PackTest(unittest.TestCase): self.assertIn("server/geometry/grid_mesh.py", names) self.assertIn("server/geometry/surface.py", names) self.assertIn("server/jobs/background.py", names) - self.assertIn("server/jobs/image_lasso.py", names) + self.assertIn("server/jobs/image_extract.py", names) + self.assertNotIn("server/jobs/image_lasso.py", names) self.assertIn("server/models/ben2.py", names) self.assertIn("server/models/da3.py", names) self.assertIn("server/models/moge2.py", names) diff --git a/tests/test_server.py b/tests/test_server.py index a0a1618..d0a738a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -220,7 +220,7 @@ class ServerTest(unittest.TestCase): "download-model", "download-required-models", "analyze-balloon", - "analyze-image-lasso", + "extract-image-background", "remove-background", "extract-frames", "generate-depth", @@ -401,16 +401,16 @@ class ServerTest(unittest.TestCase): self.assertEqual(tuple(result[0, 0]), (20, 40, 60, 64)) self.assertEqual(tuple(result[3, 3]), (0, 0, 0, 0)) - def test_image_lasso_canvas_clears_rgba_outside_the_selection(self): + def test_image_extract_canvas_clears_rgba_outside_the_selection(self): import numpy as np from PIL import Image - image_lasso = sys.modules["server.jobs.image_lasso"] + image_extract = sys.modules["server.jobs.image_extract"] source_path = self.storage_root / "source.png" pixels = np.full((2, 2, 4), (20, 40, 60, 255), dtype=np.uint8) Image.fromarray(pixels, mode="RGBA").save(source_path) - canvas, _selection, bounds = image_lasso.create_lasso_canvas( + canvas, _selection, bounds = image_extract.create_selection_canvas( source_path, ((-1, -1), (2, -1), (-1, 2)), ) @@ -421,14 +421,14 @@ class ServerTest(unittest.TestCase): self.assertEqual(tuple(result[2, 2]), (0, 0, 0, 0)) self.assertEqual(tuple(result[1, 1]), (20, 40, 60, 255)) - def test_image_lasso_crops_to_alpha_with_pixel_padding(self): + def test_image_extract_crops_to_alpha_with_pixel_padding(self): import numpy as np from PIL import Image - image_lasso = sys.modules["server.jobs.image_lasso"] + image_extract = sys.modules["server.jobs.image_extract"] pixels = np.zeros((4, 5, 4), dtype=np.uint8) pixels[1:3, 2:4] = (20, 40, 60, 255) - result, bounds = image_lasso.crop_to_alpha( + result, bounds = image_extract.crop_to_alpha( Image.fromarray(pixels, mode="RGBA"), (10, 20, 15, 24), padding=1,