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,