diff --git a/README.md b/README.md
index 854b1c4..b06e515 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@ AnyImage 是一个 Blender 扩展,可以把一张照片、一段视频或一
- 在 3D View、Node Editor 与绘画模式中按 `Ctrl+V`(macOS 为 `Cmd+V`)直接粘贴并 Pack 剪贴板图片
- 不运行 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 Box / Lasso / Line / Polyline 直接清理 Image Empty;按 `F` 反选,也可用 BEN2 细化选区
- 用 Image Perspective 勾勒四个角点,把透视四边形矫正为矩形裁切图,并沿用源 Image Empty 的空间朝向
- 一键把 Image Empty 转换为立体面片:让 AI 估算深度,或直接用原图作为深度
- 把单张 Image Empty 转换为透视纹理网格,并自动匹配原对象的 Transform 与 Offset
@@ -58,27 +58,27 @@ Environment Bootstrap 使用连续的 0–100%:uv 为 1–20%、Python 为 20
- `Keep Original` 默认关闭,位于分割线下方;开启后保留源 Image Empty
4. 点击确认,稍等片刻就得到带位移的立体面片。`O DA3 Depth Plane` 与 `O MoGe2 Depth Plane` 都使用 `.color` 的 `Alpha > 0.5` 划分前景范围,范围外由 `Background Depth` 控制。
-### 套索提取图片
+### 编辑图片选区
-在 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 子菜单中。
+在 3D Viewport 左侧工具栏选择 Image Mask 工具组。Box 拖出矩形,Lasso 手绘轮廓,Line 清理有向直线的一侧,Polyline 逐点构建多边形。点击另一张 Image Empty 时只切换选择;点击当前 Active 图片、空白处或非图片物体时,才开始编辑当前图片。没有 Active 图片时会提示先选择 Image Empty,切换其他工具会取消当前绘制。
-区域外的 RGBA 会统一设为透明黑,避免后续只读取 RGB 的图像处理重新识别透明区内容。输出画布取最终 Alpha 大于 0 的最小 bounding box,再按 Padding 向四周增加指定的透明像素,默认 0 px。对象原点会移动到新画布中心,显示尺寸与图片比例同步更新,裁切后的像素仍与原图在世界空间重合。Continuous 默认关闭并原位更新;开启后源 Empty 保持 Active,每次操作持续创建一个向当前 View 稍微靠近的新结果,避免与源图重叠闪烁。Remove Background 默认关闭;开启后复用标准 Remove Background 的 BEN2 单图处理,在选区内删除背景,再计算最终 bounding box。该选项仅支持静态图片,并需要 AI Environment 与 BEN2。
+绘制中按 `F` 切换反选:默认保留选区内,反选后保留选区外。Line 按住 `Ctrl` 以 5° 吸附;Polyline 可单击起点圆环或按 `Enter` 完成。`Refine Selection` 只用于 Box / Lasso:先用 BEN2 将几何选区收紧到其中的前景,再应用反选。结果直接替换当前 Empty 的图片,按最终非零 Alpha 紧裁切,并更新显示尺寸与 Image Offset;对象 Transform 不变。透明区保留源 RGB,避免纹理过滤产生黑边。
### 四点透视矫正
-在 Image Box / Lasso 工具组中切换到 `Image Perspective`。工具使用 Sculpt Polyline Mask 图标;四个角可以按任意顺序点击,完成后会在源图片坐标中自动求出不交错的凸四边形,并以原图 X/Y 方向确定输出旋转。绘制时显示白色虚线和半透明选区面,`Backspace` 删除最近一个角点,右键或 `Esc` 取消;切换其他工具也会立即退出。首次点击遵循同一选择规则:点另一张图片只切换 Active,下一次点击才开始四点取区。Perspective 不依赖当前 View 的相机内参,因此透视和正交 View 都可以使用。
+`Image Perspective` 是 Image Mask 工具组之后的独立工具,位于该组与 Balloon Lasso 之间。四个角可以按任意顺序点击,完成后会在源图片坐标中自动求出不交错的凸四边形,并以原图 X/Y 方向确定输出旋转。绘制时显示白色虚线和半透明选区面,`Backspace` 删除最近一个角点,右键或 `Esc` 取消;切换其他工具也会立即退出。首次点击遵循同一选择规则。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 Perspective 是 deform 工具,不提供 Refine 或反选。它与其他 Image 工具共享选图、绘制样式、紧裁切和 Empty 写回逻辑。
-完整操作说明见 [Image Box、Lasso 与 Perspective](docs/image-tools.md)。
+完整操作说明见 [Image 编辑工具](docs/image-tools.md)。
### 套索生成 Balloon
-选中单张图片的 Image Empty,右键打开 `AnyImage` 子菜单并选择 `Balloon Lasso Tool`。Blender 会激活 3D Viewport 左侧工具栏底部、紧邻 Image Box / Lasso 工具组的独立 Balloon Lasso;该工具组与 Add Cube 之间保留一个间隔。Balloon 使用 Poly Build 的绿色图标、Annotate 同款铅笔光标,Tool Header 与状态栏共同提示当前工具状态,不再用黄色外框或强制修改 Shading、Overlay。Blender 的公开 Workspace Tool API 不支持按 Active Object 动态隐藏工具,因此图标在 Object Mode 中保持可见,但只有 Active Object 是 Image Empty 时才能启动 Stroke。
+选中单张图片的 Image Empty,右键打开 `AnyImage` 子菜单并选择 `Balloon Lasso Tool`。Blender 会激活 3D Viewport 左侧工具栏底部、位于独立 Image Perspective 之后的 Balloon Lasso;Image Mask 工具组与 Add Cube 之间保留一个间隔。Balloon 使用 Poly Build 的绿色图标、Annotate 同款铅笔光标,Tool Header 与状态栏共同提示当前工具状态,不再用黄色外框或强制修改 Shading、Overlay。Blender 的公开 Workspace Tool API 不支持按 Active Object 动态隐藏工具,因此图标在 Object Mode 中保持可见,但只有 Active Object 是 Image Empty 时才能启动 Stroke。
按住左键沿目标轮廓绘制,松开后生成一个带原图 UV 的封闭网格。每次拖动都是一个独立完成的顶层 `Extract Balloon` Operator,工具本身继续保持激活,下一次拖动会创建另一个 Balloon;因此 `Ctrl+Z` 只撤销最近一次生成,`Ctrl+Shift+Z` 重做。每次生成后源 Image Empty 都保持选中和 Active。未绘制时没有持续 Modal 拦截事件,视图导航和其他原生快捷键由 Blender 正常处理;绘制中可用右键或 `Esc` 取消当前轨迹。选择左侧工具栏中的其他工具即可离开 Balloon Lasso。
@@ -111,7 +111,7 @@ MoGe-2 的像素级 normal 会作为 Object Space normal 贴图接入 `O MoGe2 S
Bump 合并;网格本身不写入 custom normal,因此重拓扑降低顶点数后仍能保留细腻的法线细节。
当前此功能仅支持单张图片。
-MoGe-2 ViT-S、ViT-B、ViT-L、DA3 Small、DA3 Base 与 BEN2 全部使用 ONNX。模型执行、相机与深度恢复、
+MoGe-2 ViT-S、ViT-B、ViT-L、DA3 Small、DA3 Base、BEN2 与 Debug 使用的 Real-ESRGAN General x4v3 全部使用 ONNX。模型执行、相机与深度恢复、
点云重投影和网格生成均不依赖 PyTorch。Windows 使用 DirectML 加速;Apple M4 或更新芯片可使用 CoreML 的 `MLProgram` 模型格式,较早或无法识别的 Mac 只提供 CPU;provider 不可用时会回退到 ONNX Runtime CPU,
并在任务状态中显示实际 provider。
@@ -131,7 +131,7 @@ Layer Color 同时驱动 Principled BSDF 的 Base Color、Subsurface Radius 和
AnyImage 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并生成逐帧对应的立体面片。`.color` 与 `.depth` 按源 Empty Image 的类型载入:普通单图为 `IMAGE`,Movie/Sequence 即使只处理一帧也仍为 `SEQUENCE`。处理范围默认
跟随 Empty 上设置的 `Image Frames` 与 `Offset`,不需要额外配置。DA3 的转换任务复用同一个 ONNX
Session 逐帧推理,显存峰值不随视频帧数平方增长。Debug 只保存模型 API 的直接返回:DA3 多帧输入
-保留联合推理,MoGe-2 与 BEN2 按各自的单图 API 逐帧保存。
+保留联合推理,MoGe-2、BEN2 与 Real-ESRGAN General x4v3 按各自的单图 API 逐帧保存。General x4v3 直接输出 4× RGB `prediction*.png`,不做切片、强度混合或图片替换。
## 任务结果保存在哪里
diff --git a/docs/development.md b/docs/development.md
index 6b7bd61..404b98f 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 Box、Lasso 与 Perspective 位于 `operators/image_extract/` 功能包。模块职责、交互状态、像素管线、背景清理和 Empty 定位见 [Image 工具技术实现](image-tools-technical.md)。
+Image Box、Lasso、Line、Polyline 与 Perspective 位于 `operators/image_edit/` 功能包。模块职责、交互状态、像素管线、Refine 和 Empty 定位见 [Image 工具技术实现](image-tools-technical.md)。
## 项目结构
@@ -122,5 +122,6 @@ BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe"
- Depth Anything 3 代码采用 Apache 2.0
- BEN2 ONNX 模型采用其仓库声明的 Apache 2.0 License
+- Real-ESRGAN General x4v3 ONNX 权重采用其模型仓库声明的 BSD 3-Clause License
- AnyImage Extension 代码采用 GPL-3.0-or-later
- 模型权重许可证由所选 Hugging Face 模型仓库决定
diff --git a/docs/features.md b/docs/features.md
index 74c158f..8efc02b 100644
--- a/docs/features.md
+++ b/docs/features.md
@@ -11,7 +11,7 @@
| Depth Plane | 快速制作可调节的立体图片 | 单图、视频、序列帧 | 带颜色、深度与 Geometry Nodes 的面片 |
| Surface | 从单张图片恢复透视空间 | 单图 | 相机空间网格、材质与可选匹配相机 |
| Remove Background | 提取透明前景 | 单图、视频、序列帧 | 透明图片或透明序列 |
-| Image Lasso | 手绘提取并原位更新图片 | 静态 Image Empty | Pack 的透明裁切图 |
+| Image Mask | 用 Box、Lasso、Line 或 Polyline 直接清理图片 | 静态 Image Empty | 原位更新并 Pack 的透明裁切图 |
| Image Perspective | 四点透视矫正裁切 | 静态 Image Empty | Pack 的矩形裁切图 |
| Balloon | 沿轮廓绘制鼓起造型 | 静态 Image Empty | 带原图 UV 的封闭网格 |
| Debug | 检查模型原始输出 | 模型支持的图片或帧序列 | 保存在 Job 目录中的原始 Prediction |
@@ -30,9 +30,9 @@ Balloon 作为不依赖 AI 的基础工具单独排列;三个 AI 功能在缺
转换任务完成后,AnyImage 在一个 Undo 步骤中创建网格、图片、材质和修改器。源 Image Empty 是否保留由转换选项决定。
-Image Box、Image Lasso 与 Image Perspective 组成一个工具组,共用源图选择、`Remove Background`、`Continuous` 和 `Padding`。Box 提取矩形,Lasso 提取自由轮廓,Perspective 将任意顺序输入的四个角矫正为指定比例的矩形。结果都是 Pack 的 Image Empty,并沿用源图朝向与深度。
+Image Box、Lasso、Line 与 Polyline 组成 Mask 工具组,并共用 `EditImageMask`。四者用 `F` 切换反选;`Refine Selection` 只用于 Box / Lasso。Perspective 是位于 Mask 组与 Balloon Lasso 之间的独立工具,将任意顺序输入的四个角矫正为指定比例的矩形。结果直接更新当前 Image Empty,并 Pack 到 `.blend`。
-操作步骤见 [Image Box、Lasso 与 Perspective](image-tools.md);处理管线与模块职责见 [Image 工具技术实现](image-tools-technical.md)。
+操作步骤见 [Image 编辑工具](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。
@@ -50,7 +50,7 @@ Blender 无法整体 Pack 图片序列,因此动画结果会继续引用 Job
AI 任务在独立 Python Environment 中通过 ONNX Runtime 执行。Blender 负责界面、任务提交和结果导入,Job Server 负责模型与文件处理。
-默认 Storage Root 为 `~/.anyimage`,其中保存 Environment、模型、日志和 Job 结果。连续任务会复用已加载的模型 Session;DA3 与 MoGe-2 共享一个深度模型缓存位置,BEN2 可同时保留。
+默认 Storage Root 为 `~/.anyimage`,其中保存 Environment、模型、日志和 Job 结果。连续任务会复用已加载的模型 Session;DA3 与 MoGe-2 共享一个深度模型缓存位置,BEN2 与 Real-ESRGAN 可同时保留。
安装、下载和推理都在后台运行,并通过状态栏提供进度与取消操作。详细设计见[Job Server](server.md)。
@@ -62,6 +62,8 @@ AI 任务在独立 Python Environment 中通过 ONNX Runtime 执行。Blender
- DA3 Small、DA3 Base
- BEN2 Base
+Debug 另外提供 Real-ESRGAN General x4v3,用于直接检查 4× RGB 放大能力;它当前不属于正式图片工具。
+
首次安装会准备 MoGe-2 ViT-S Normal 与 BEN2 Base,其他模型可从 AnyImage 扩展偏好设置按需下载。
-Debug 工作流保存模型 API 的直接返回,适合验证模型、设备与输入。Depth Plane 和 Surface 则在模型结果之上继续生成 Blender 可用的贴图与几何。文件字段见[输出格式](outputs.md)。
+Debug 工作流保存模型 API 的直接返回,适合验证模型、设备与输入。General x4v3 对每张输入整图推理并保存 `prediction*.png`,不加入切片、降噪强度、Alpha 合成或 Blender 图片写回。Depth Plane 和 Surface 则在模型结果之上继续生成 Blender 可用的贴图与几何。文件字段见[输出格式](outputs.md)。
diff --git a/docs/image-tools-technical.md b/docs/image-tools-technical.md
index a66a1cc..45fb199 100644
--- a/docs/image-tools-technical.md
+++ b/docs/image-tools-technical.md
@@ -1,77 +1,84 @@
# Image 工具技术实现
-Image Box、Lasso 与 Perspective 位于 `operators/image_extract/`。三者共享源图选择、设置、图片写回、空间定位、Continuous 分层和可选背景清理。
+Image 工具位于 `operators/image_edit/`。Box、Lasso、Line 与 Polyline 共享直接编辑管线;Perspective 在工具栏中独立显示并处理 Homography,但复用源图选择、绘制和 Empty 写回。
## 模块职责
```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共享选择、设置与结果写回"]
+ Tools["box / lasso / line / polyline\nWorkspaceTool"] --> Mask["mask.py\n手势、Refine、Invert"]
+ Perspective["perspective.py\n四点、比例、Homography"]
+ Mask --> Common["common.py\n选择、绘制、裁切、Empty 写回"]
Perspective --> Common
- Common --> Job["extract-image-background\n可选 BEN2 Job"]
+ Mask --> Job["refine-image-mask\nBEN2 内容 mask"]
```
-`box.py` 与 `lasso.py` 只声明 WorkspaceTool。两者通过 `selection_shape` 复用 `ExtractImageRegion`;Perspective 保留独立 Operator,因为它还有四点输入、比例预览和 Homography。
+- `box.py`、`lasso.py`、`line.py`、`polyline.py` 只声明 WorkspaceTool。
+- `mask.py` 用 `EditImageMask` 统一四种手势;`RefineImageMask` 负责异步 BEN2 响应。
+- `perspective.py` 维护四点输入、比例预览和全分辨率重采样。
+- `common.py` 只放跨两条管线实际共用的逻辑。
## 首次点击
-`resolve_image_extract_click()` 将首次点击归为两种结果:
+`resolve_image_edit_click()` 临时调用 Viewport Select 判断点击目标:
-| 点击目标 | 结果 |
+| 点击目标 | 行为 |
| --- | --- |
-| 另一张 Image Empty | 只选择并设为 Active |
-| 当前 Active 图片、空白或非图片对象 | 使用当前 Active 图片开始取区 |
+| 另一张 Image Empty | 选择并设为 Active,本次不编辑 |
+| 当前 Active 图片、空白或非图片对象 | 恢复原选择并开始编辑当前图片 |
-这段逻辑由三个工具共用。点击非图片对象后会恢复原选择,避免触发对象移动或更换源图。
+这避免工具在无有效源图时退化为对象移动,也让用户无需切回 Select Tool。
-## 处理管线
+## Mask 管线
```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"]
+ Gesture["屏幕手势"] --> Polygon["源图片像素 polygon / mask"]
+ Polygon --> Refine{"Refine Selection?"}
+ Refine -- 否 --> Content["几何 mask"]
+ Refine -- 是 --> Server["选区外 RGBA 清零 → BEN2"]
+ Server --> Content2["BEN2 Alpha × 几何 mask"]
+ Content --> Invert{"Invert?"}
+ Content2 --> Invert
+ Invert -- 否 --> Final["final mask"]
+ Invert -- 是 --> Reverse["1 - final mask"]
+ Final --> Apply["源 Alpha × mask"]
+ Reverse --> Apply
+ Apply --> Crop["按非零 Alpha 紧裁切\n保留源 RGB"]
+ Crop --> Write["替换 Image 并更新 Empty 显示"]
```
-Box 与 Lasso 将选区外 RGBA 清为透明黑,再按非零 Alpha 的最小范围裁切。Perspective 先将四点规范为沿源图 X/Y 方向的凸四边形,再按确认的宽高比重采样;点击顺序不参与输出旋转。
+Box / Lasso 的顺序固定为 **Refine → Invert**。BEN2 只提供内容 Alpha;最终 RGB 始终来自源图。Blender 侧只修改 Alpha,不清理透明区 RGB,以避免 Viewport 纹理过滤产生黑边。Line / Polyline 强制关闭 Refine,只执行几何 mask 与 Invert。
-透视预览固定生成一次 512 × 512 临时 Image。鼠标移动只改变屏幕上的预览比例;确认后才执行全分辨率重采样。
+Line 的屏幕坐标 Y 轴与图片坐标 Y 轴方向相反,因此像素阶段会反转线段端点,使半透明预览侧与实际保留侧一致;`Ctrl` 只影响屏幕端点的 5° 吸附。零长度点击在投影前直接取消,投影结果不足两个点时也会安全终止。Polyline 接近起点时绘制闭合圆环,并支持单击起点或 `Enter` 完成;完成时显式闭合,不把当前游标位置作为额外顶点。
-## 背景清理
+## Perspective 管线
-`extract-image-background` 与标准 `remove-background` Job 共用 `remove_image_background()`。图片工具的 Job 只增加三项选区语义:
+四点先按源图片 X/Y 方向规范为凸四边形,输入顺序不参与输出方向。第四点后只生成一次 512 × 512 临时纹理;移动鼠标改变 UI 比例,确认时才进行全分辨率 Homography。
-- polygon mask;
-- 局部图片在源图中的 bounds;
-- Box / Lasso 使用的透明 Padding。
+透视重采样使用预乘 Alpha 插值,随后恢复 RGB、清理透明像素并紧裁切。输出最长边限制为 8192 px,总像素限制约 16 MP。
-Perspective 请求的 Job Padding 固定为 0。局部结果回到 Blender 后先换算为源图坐标,再执行透视矫正和最终 Padding。
+Perspective 不调用 BEN2,也不支持 Invert。它是 deform 工具,不属于 mask 清理语义。
-## Empty 定位与 Continuous
+## Empty 写回
-Box 与 Lasso 根据裁切前后的像素 bounds 换算 Empty 的中心和显示尺寸。Perspective 使用源四边形的轴对齐 bounds 近似放置结果。三者都沿用源 Empty 的矩阵,因此朝向与深度不变。
+`replace_empty_image_pixels()` 创建并 Pack 新 Image datablock,然后替换当前 Empty 的 `data`。它不复制对象,也不修改 `matrix_world`。
-Continuous 关闭时直接更新源 Empty;开启时复制对象,并沿操作开始时捕获的 View 方向偏移:
+`align_empty_display_to_bounds()` 根据源画布、旧 `plane_bounds`、结果尺寸和放置 bounds 更新:
-```text
-offset = display_size × world_scale × 0.001 × layer
-```
+- `empty_display_size`;
+- `empty_image_offset`。
-`layer` 记录在源对象的 `_anyimage_extract_layer` Custom Property 中,使连续结果依次靠近当前 View。
+Box、Lasso、Line 与 Polyline 的放置 bounds 等于最终裁切范围,因此像素与原图片平面严格对齐。Perspective 的结果比例可能不同于四边形轴对齐范围,因此只保持中心和覆盖尺度近似一致。
-## 维护约束
+## Server 边界
-- WorkspaceTool 的显示设置放在 `box.py`、`lasso.py`、`perspective.py`。
-- Box / Lasso 的共享交互放在 `region.py`,跨三个工具的行为放在 `common.py`。
-- Blender 数据只在主线程 Operator 中读写;Server 参数保持为可序列化字典。
-- 普通裁切不启动 Job Server;只有 Remove Background 需要 Environment 与 BEN2。
+普通 Mask 和 Perspective 完全在 Blender 侧执行。只有 Box / Lasso 的 **Refine Selection** 提交 `refine-image-mask`:
+
+1. 根据 polygon 创建局部透明画布;
+2. 清空选区外 RGBA;BEN2 公共入口再以输入 Alpha 遮住透明 RGB;
+3. 使用共享的 `remove_image_background()` 运行 BEN2;
+4. 再乘几何 selection mask;
+5. 返回紧裁切的 `image.png` 与源图坐标 `bounds.json`。
+
+Blender 只读取返回图片的 Alpha,并把它应用到原图 RGB。相同的 BEN2 输入清理规则也用于 Remove Background、Depth Plane、Surface 和 Balloon;正式输出使用源 RGB 与 BEN2 Alpha。Blender 数据只在主线程 Operator 中读写,Job 参数保持为可序列化字典。
diff --git a/docs/image-tools.md b/docs/image-tools.md
index 6f1ade9..e03105a 100644
--- a/docs/image-tools.md
+++ b/docs/image-tools.md
@@ -1,85 +1,82 @@
-# Image Box、Lasso 与 Perspective
+# Image 编辑工具
-这三个工具用于从 Image Empty 中提取局部图片。结果仍是 Image Empty,并自动 Pack 到 `.blend`。
-
-它们只处理静态图片。视频和序列帧请使用其他转换工具。
+Image Box、Lasso、Line、Polyline 与 Perspective 直接编辑静态 Image Empty,并把结果 Pack 到当前 `.blend`。前四个工具组成 Mask 工具组;Perspective 是紧随其后的独立工具,Balloon Lasso 位于 Perspective 之后。
## 选择工具
-| 工具 | 适合 |
+| 工具 | 用途 |
| --- | --- |
-| **Image Box** | 提取矩形区域,操作最快 |
-| **Image Lasso** | 沿物体或不规则边界自由绘制 |
-| **Image Perspective** | 将海报、屏幕、门窗等四边形内容矫正为矩形 |
+| **Image Box** | 用矩形保留或清理图片区域 |
+| **Image Lasso** | 手绘自由选区 |
+| **Image Line** | 用一条直线清理一侧 |
+| **Image Polyline** | 逐点构建精确多边形 |
+| **Image Perspective** | 将任意顺序输入的四个角矫正为矩形 |
-在 3D Viewport 左侧工具栏找到 Image 工具组。长按当前图标可以切换三个工具;设置位于 Tool Settings。
+在 3D Viewport 左侧工具栏长按 Image Mask 图标,可以在 Box、Lasso、Line 与 Polyline 之间切换。**Refine Selection** 只显示在 Box / Lasso;Line / Polyline 保持几何直线边界。Perspective 使用独立图标。
-**截图占位:工具组与 Tool Settings**
+**截图占位:Image 工具组与 Tool Settings**
-建议同时显示三个工具入口,以及 Remove Background、Continuous、Padding。
+显示包含四个工具的 Image Mask 组、独立 Perspective、Balloon Lasso,以及 Box / Lasso 的 Refine Selection。
目标文件:`images/image-tools/tool-group.png`
-## 选择源图片
+## 选择图片
-如果当前 Active 已是目标图片,可以直接开始取区。
+直接在当前 Active 图片上开始绘制。如果第一次点击的是另一张 Image Empty,这次点击只会选中它;下一次操作才开始编辑。点击空白处或非图片对象时,继续编辑当前 Active 图片,不会移动对象。
-如果要处理另一张图片,先点击它。这一次点击只负责选择;下一次点击或拖动才开始取区。点击空白处或其他类型的对象时,工具继续使用当前 Active 图片。
+没有 Active Image Empty 时,工具会提示先选择图片。切换到其他工具会立即取消正在进行的绘制。
-!!! tip
- 最简单的习惯是:先点一下目标图片确认 Active,再开始绘制。
+## 清理选区
-## 使用 Box 或 Lasso
+### Box 与 Lasso
-=== "Image Box"
+- Box:按住左键拖出矩形,松开后执行。
+- Lasso:按住左键绘制轮廓,松开后执行。
- 1. 按住左键,从区域一角拖到另一角。
- 2. 松开左键,立即生成结果。
+### Line
-=== "Image Lasso"
+从起点拖到终点,松开后保留直线左侧的内容。按住 `Ctrl` 以 5° 为单位吸附方向;需要另一侧时按 `F` 翻转。
- 1. 按住左键,沿需要保留的区域绘制。
- 2. 松开左键,立即生成结果。
+### Polyline
-选区可以越过图片边缘。只要范围内仍有可见像素,工具就会按实际内容裁切。
+逐点单击构建多边形。鼠标接近起点时会显示闭合圆环;单击圆环或按 `Enter` 完成,`Backspace` 删除最近一点。
+
+绘制过程中按 `F` 切换反选。反选是一个持续到本次操作完成的状态,不需要按住:
+
+- 关闭:保留选区内;
+- 开启:保留选区外。
+
+Box / Lasso 启用 **Refine Selection** 后,BEN2 会先把几何选区收紧到其中的前景,再应用反选。例如框住一个苹果并开启反选,最终会清理苹果本身、保留其余内容。Line / Polyline 不提供 Refine,以保持直线边界和明确的裁切方向。
+
+结果会直接替换当前 Empty 的图片,并把画布紧裁切到最终可见 Alpha。对象 Transform 不变,显示尺寸与 Image Offset 会更新,使剩余像素保持在原图片平面上的位置。透明区只修改 Alpha,保留源 RGB 供纹理过滤使用;完全透明的结果不会写入。
-**截图占位:Box 与 Lasso 取区**
+**截图占位:Polyline 与反选**
-建议用一张并排图展示矩形拖拽和自由轮廓,不需要额外结果图。
+显示半透明选区面、虚线边界,以及状态栏中的 `F toggles Invert`。
-目标文件:`images/image-tools/box-lasso.png`
+目标文件:`images/image-tools/polyline-invert.png`
-## 使用 Perspective
+## 四点透视
-1. 依次点击内容的四个角,顺序不限。
-2. 第四点完成后,Viewport 中央会显示矫正预览。
-3. 左右移动鼠标调整宽高比。
-4. 单击左键确认并生成结果。
+1. 任意顺序单击四个角点。
+2. 第四点后,在 Viewport 中央检查矫正预览。
+3. 左右移动鼠标调整宽高比;按住 `Ctrl` 可吸附到 `1:1`、`4:3`、`3:4`、`16:9`、`9:16`。
+4. 左键确认。
-调整比例时按住 `Ctrl`,可以吸附到 `1:1`、`4:3`、`3:4`、`16:9` 和 `9:16`。`Backspace` 返回上一个角点;右键或 `Esc` 取消。
+`Backspace` 返回上一步,右键或 `Esc` 取消。四点会自动排序,点击顺序不会改变输出旋转。
+
+Perspective 只执行图片 Homography 与紧裁切,不做 Refine,也不尝试从当前 View 推导真实相机。结果保持源 Empty 的 Transform,并按原四边形覆盖范围近似更新显示位置与尺寸。选区完全位于图片外或没有可见像素时不会执行。
**截图占位: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)。
+实现结构与像素规则见 [Image 工具技术实现](image-tools-technical.md)。
diff --git a/docs/index.md b/docs/index.md
index 4e9fce5..0e1db14 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -9,7 +9,7 @@ AnyImage 是一款 Blender 扩展,可以把照片、视频或序列帧转换
- 用 **Depth Plane** 快速得到可调节的浮雕式面片
- 用 **Surface** 从单张图片生成透视纹理网格
- 用 **Remove Background** 一键获得透明前景
-- 用 **Image Perspective** 将四点选区矫正为沿用源 Empty 朝向的矩形裁切图
+- 用 **Image Box / Lasso / Line / Polyline** 直接清理图片,或用 **Image Perspective** 做四点透视矫正
- 处理视频和序列帧,生成可随时间播放的 Depth Plane
- 使用 Geometry Nodes 继续调整深度、细分、重拓扑与平滑效果
diff --git a/docs/models-and-storage.md b/docs/models-and-storage.md
index 47e006a..cca8320 100644
--- a/docs/models-and-storage.md
+++ b/docs/models-and-storage.md
@@ -9,7 +9,7 @@ AnyImage 把运行环境、模型和任务结果放在 Blender 之外,多个 B
- **Ready** 表示模型文件已经就绪。
- **Download** 用于按需下载模型。
- 默认安装会准备 MoGe-2 ViT-S Normal 和 BEN2 Base。
-- DA3 与更大的 MoGe-2 模型需要时再下载即可。
+- DA3、更大的 MoGe-2,以及只供 Debug 使用的 Real-ESRGAN General x4v3 需要时再下载即可。
较大的模型不一定适合所有图片。建议先用默认模型完成构图,再针对细节与性能需求比较其他模型。
diff --git a/docs/outputs.md b/docs/outputs.md
index 0f6f1e4..bfc6ae1 100644
--- a/docs/outputs.md
+++ b/docs/outputs.md
@@ -9,11 +9,12 @@
| `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 |
+| `refine-image-mask` | `selection-input.png`、`image.png`、`bounds.json` | BEN2 细化后的局部内容 mask 与原图坐标 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 返回 |
| `debug-ben2` | `foreground*.png` | BEN2 API 返回 |
+| `debug-realesrgan-general-x4v3` | `prediction*.png` | Real-ESRGAN General x4v3 的 4× RGB API 返回 |
## NPZ 字段
@@ -23,7 +24,7 @@ Balloon Depth 的 `depth.npz` 包含相对 `depth_signal`、归一化拟合平
Surface 的 `mesh.npz` 包含 `vertices`、`faces`、`uv`、`intrinsics`、`image_size` 与 `reference_depth`,以及 Geometry Nodes 使用的网格属性。
-Debug 文件保持模型 API 的数据形态,用于比较推理结果;正式 Job 则提供 Blender 导入所需的派生数据。
+Debug 文件保持模型 API 的数据形态,用于比较推理结果;正式 Job 则提供 Blender 导入所需的派生数据。General x4v3 的浮点 RGB 返回只做 `[0, 1]` 限制和 8-bit PNG 序列化,不做额外画质处理。
## 图片序列
diff --git a/docs/remove-background.md b/docs/remove-background.md
index fe6bf76..6047378 100644
--- a/docs/remove-background.md
+++ b/docs/remove-background.md
@@ -35,4 +35,4 @@ Depth Plane 和 Surface 的转换对话框也提供 **Remove Background**:
当前正式入口不提供 BEN2 refinement 参数。可以在结果材质中继续处理 Alpha,或在外部图像软件中修整生成的颜色图。
-输入图片已有 Alpha 时,AnyImage 会先用该 Alpha 遮住 BEN2 推理输入中的透明 RGB,再将结果 Alpha 计算为“输入 Alpha × BEN2 Alpha”。因此 Image Box / Lasso 或外部工具创建的透明范围不会被去背景重新扩张;软边缘也会继续保留。
+所有 BEN2 任务都会先用输入 Alpha 遮住推理输入中的透明 RGB。正式输出只采用 BEN2 的 Alpha,颜色继续使用源 RGB;已有 Alpha 时,结果 Alpha 为“输入 Alpha × BEN2 Alpha”。因此 Image Box / Lasso 或外部工具创建的透明范围不会被去背景重新扩张,软边缘也会继续保留,同时避免透明黑参与 Viewport 纹理过滤。
diff --git a/docs/server.md b/docs/server.md
index 70f39bd..3b5c088 100644
--- a/docs/server.md
+++ b/docs/server.md
@@ -59,7 +59,9 @@ 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。
+Image Box 与 Lasso 的 `Refine Selection` 使用 `refine-image-mask` Job。它与标准 `remove-background` Job 共用单图 BEN2 函数;Server 在推理前清空 polygon 外的 RGBA,推理后再次应用几何 mask,并返回局部 Alpha 与源图 bounds。Line、Polyline 与 Perspective 不提交 Job。
+
+所有 BEN2 调用都在公共模型入口用输入 Alpha 遮住透明 RGB。Remove Background、Depth Plane、Surface 与 Image Refine 的正式结果保留源 RGB,只采用 BEN2 Alpha;Balloon 同样只消费 BEN2 Alpha。该边界既防止模型识别透明区隐藏内容,也避免把透明黑写回 Blender 后产生过滤黑边。
正式功能支持:
@@ -67,10 +69,14 @@ Balloon 的 `analyze-balloon` Job 会裁切套索输入并清零选区外 RGBA
- DA3 Small、DA3 Base
- BEN2 Base
-首次安装会下载 MoGe-2 ViT-S Normal 与 BEN2 Base。模型保存在 `models/`,可由多个 Blender 版本共享。Server 缓存 BEN2 和最近使用的一个深度模型,以控制内存占用。
+Debug 任务另外支持 Real-ESRGAN General x4v3;它只提供模型直通测试,不属于正式功能。
+
+首次安装会下载 MoGe-2 ViT-S Normal 与 BEN2 Base。Real-ESRGAN General x4v3 由扩展偏好设置按需下载。模型保存在 `models/`,可由多个 Blender 版本共享。Server 缓存 BEN2、Real-ESRGAN 和最近使用的一个深度模型,以控制内存占用。
模型下载优先使用 Hugging Face,并以 OmooLab 镜像作为回退。AnyImage 用同一个准备 Operator 封装 BlendJob 安装任务,并按状态显示 `Set Up AI Environment…` 或 `Download Required Models…`;`Use Mirror` 属性只影响 Environment Bootstrap,不改变模型下载策略。Blender UI 通过同一个状态函数检查 Environment 指纹与默认模型文件:缺 Environment 时执行完整安装,只有默认模型缺失时仅提交补齐模型任务。
+`debug-realesrgan-general-x4v3` 将每张输入的完整 RGB 动态尺寸张量直接送入 ONNX,并把 4× 返回保存为 `prediction*.png`。该 Job 不实现 tile、强度混合、Alpha 合成或 Blender 结果导入。
+
## 任务生命周期
Server 在第一次提交 Job 或手动 Start 时启动。任务进入单 Worker FIFO Queue,同一时间执行一个推理、下载或媒体任务。
diff --git a/docs/workflows.md b/docs/workflows.md
index 07d61cd..74ba53e 100644
--- a/docs/workflows.md
+++ b/docs/workflows.md
@@ -8,8 +8,8 @@ AnyImage 提供多种日常工作流。选择结果时,先考虑你需要的
| Depth Plane | 人物、产品、插画、动态背景、快速场景搭建 | 图片、视频、序列帧 | 带深度位移的平面 | 快速、可动画、Geometry Nodes 参数直观 |
| Surface | 建筑、室内、街景、需要匹配视角的单图 | 单张图片 | 透视纹理网格 | 空间感更强,可创建匹配相机 |
| Remove Background | 抠出人物或物体,继续作为 Image Empty 使用 | 图片、视频、序列帧 | 带透明 Alpha 的颜色图 | 不创建网格,保留 Empty 的位置和显示设置 |
-| Image Box / Lasso | 矩形或手绘提取局部内容并按 Alpha 范围裁切 | 静态图片 | Pack 的透明裁切图 | 支持 Padding 与 Continuous,不创建网格 |
-| Image Perspective | 海报、屏幕、门窗等四边形内容的透视矫正裁切 | 静态图片 | Pack 的矩形图片 | 四点任意顺序,共用 Remove Background、Padding 与 Continuous |
+| Image Box / Lasso / Line / Polyline | 用几何选区直接清理图片 | 静态图片 | 原位更新的透明裁切图 | `F` 反选,可用 BEN2 细化选区 |
+| Image Perspective | 海报、屏幕、门窗等四边形内容的透视矫正裁切 | 静态图片 | 原位更新的矩形图片 | 四点任意顺序,交互调整比例 |
## 什么时候选择 Mesh Plane
@@ -37,4 +37,4 @@ Surface 会根据模型估算的相机和深度构建透视网格。相较 Depth
## 什么时候使用 Image Perspective
-当图片中的矩形物体因为透视变成四边形,并且希望得到一张正视矩形裁切图时,使用 3D Viewport 工具栏中的 **Image Perspective**。它只把四点当作图片裁切与 Homography 输入,不假设当前 View 的焦距与原图拍摄相机一致。四点顺序不影响输出方向;结果 Empty 保持源图朝向和深度,并按源图中的选区范围近似放置。需要持续提取多个区域时,可开启与 Image Box / Lasso 共用的 Continuous。
+当图片中的矩形物体因为透视变成四边形,并且希望得到一张正视矩形裁切图时,使用 3D Viewport 工具栏中的 **Image Perspective**。它只把四点当作图片裁切与 Homography 输入,不假设当前 View 的焦距与原图拍摄相机一致。四点顺序不影响输出方向;结果 Empty 保持源图 Transform,并按源图中的选区范围近似更新显示位置与尺寸。
diff --git a/mkdocs.yml b/mkdocs.yml
index 06ca7cc..f513eb5 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -15,7 +15,7 @@ nav:
- 粘贴剪贴板图片: clipboard-image.md
- 使用指南:
- 选择转换方式: workflows.md
- - Image Box、Lasso 与 Perspective: image-tools.md
+ - Image 编辑工具: image-tools.md
- Mesh Plane: mesh-plane.md
- Depth Plane: guide-depth-plane.md
- Surface: guide-surface.md
diff --git a/src/anyimage/__init__.py b/src/anyimage/__init__.py
index e601577..212e44c 100644
--- a/src/anyimage/__init__.py
+++ b/src/anyimage/__init__.py
@@ -6,7 +6,13 @@ 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_extract import ImageBoxTool, ImageLassoTool, ImagePerspectiveTool
+from .operators.image_edit import (
+ ImageBoxTool,
+ ImageLassoTool,
+ ImageLineTool,
+ ImagePerspectiveTool,
+ ImagePolylineTool,
+)
from .preferences import AnyImagePreferences
from .properties import AnyImageSettings
from .menu import AnyImageImageMenu, draw_image_context_menu
@@ -15,6 +21,7 @@ from .panel import (
Da3OutputDebugPanel,
DebugPanel,
Moge2ApiDebugPanel,
+ RealesrganGeneralX4v3DebugPanel,
ServerPanel,
)
@@ -30,6 +37,7 @@ CLASSES = (
Moge2ApiDebugPanel,
Da3OutputDebugPanel,
Ben2RemovalDebugPanel,
+ RealesrganGeneralX4v3DebugPanel,
)
@@ -47,10 +55,19 @@ def register():
separator=False,
)
bpy.utils.register_tool(
- ImagePerspectiveTool,
+ ImageLineTool,
after={ImageLassoTool.bl_idname},
separator=False,
)
+ bpy.utils.register_tool(
+ ImagePolylineTool,
+ after={ImageLineTool.bl_idname},
+ separator=False,
+ )
+ bpy.utils.register_tool(
+ ImagePerspectiveTool,
+ separator=False,
+ )
bpy.utils.register_tool(BalloonTool, separator=False)
bpy.types.Scene.anyimage_settings = bpy.props.PointerProperty(
type=AnyImageSettings,
@@ -66,6 +83,8 @@ def unregister():
clipboard_image.unregister_keymaps()
bpy.utils.unregister_tool(BalloonTool)
bpy.utils.unregister_tool(ImagePerspectiveTool)
+ bpy.utils.unregister_tool(ImagePolylineTool)
+ bpy.utils.unregister_tool(ImageLineTool)
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/model_catalog.py b/src/anyimage/model_catalog.py
index 4e5260b..f63f039 100644
--- a/src/anyimage/model_catalog.py
+++ b/src/anyimage/model_catalog.py
@@ -89,6 +89,30 @@ BEN2_MODEL = ModelSpec(
)
+REALESRGAN_MODEL = ModelSpec(
+ key="REALESRGAN_GENERAL_X4V3",
+ label="Real-ESRGAN General x4v3",
+ directory_name="realesr-general-x4v3",
+ repository="jonathanst29/tinier-upscale-models",
+ license_name="BSD 3-Clause",
+ description="Fast general-purpose 4x image upscaler using ONNX Runtime",
+ supports_gs=False,
+ download_patterns=("realesr-general-x4v3.onnx",),
+ ready_patterns=("realesr-general-x4v3.onnx",),
+ r2_directory="realesr-general-x4v3",
+ r2_files=((
+ "realesr-general-x4v3.onnx",
+ 4866396,
+ "924ebad6532777303582d4ce7811849b88869231a3ae7093e0f21200249df8d5",
+ ),),
+)
+
+
+UPSCALE_MODELS = {
+ REALESRGAN_MODEL.key: REALESRGAN_MODEL,
+}
+
+
MOGE2_MODELS = {
"MOGE2_VITS_NORMAL": ModelSpec(
key="MOGE2_VITS_NORMAL",
@@ -146,6 +170,7 @@ MOGE2_MODEL = MOGE2_MODELS["MOGE2_VITS_NORMAL"]
DOWNLOADABLE_MODELS = {
**MODELS,
BEN2_MODEL.key: BEN2_MODEL,
+ **UPSCALE_MODELS,
**MOGE2_MODELS,
}
MODEL_NUMBERS = {
@@ -181,8 +206,14 @@ def model_record(model, ready=False):
"license": model.license_name,
"supports_gs": model.supports_gs,
"enabled": model.enabled,
- "family": "moge2" if model.key.startswith("MOGE2_") else (
- "ben2" if model.key == BEN2_MODEL.key else "da3"
+ "family": (
+ "moge2"
+ if model.key.startswith("MOGE2_")
+ else "ben2"
+ if model.key == BEN2_MODEL.key
+ else "upscale"
+ if model.key in UPSCALE_MODELS
+ else "da3"
),
"ready": bool(ready),
}
diff --git a/src/anyimage/operators/__init__.py b/src/anyimage/operators/__init__.py
index 4bb6373..274e9a5 100644
--- a/src/anyimage/operators/__init__.py
+++ b/src/anyimage/operators/__init__.py
@@ -4,8 +4,13 @@ from .balloon import (
ExtractBalloon,
AnalyzeBalloon,
)
-from .debug import DebugBen2, DebugDa3, DebugMoge2
-from .image_extract import CLASSES as IMAGE_EXTRACT_CLASSES
+from .debug import (
+ DebugBen2,
+ DebugDa3,
+ DebugMoge2,
+ DebugRealesrganGeneralX4v3,
+)
+from .image_edit import CLASSES as IMAGE_EDIT_CLASSES
from .depth_plane import (
ConvertToDepthPlane,
ExtractOriginalFrames,
@@ -32,6 +37,7 @@ CLASSES = (
DebugDa3,
DebugMoge2,
DebugBen2,
+ DebugRealesrganGeneralX4v3,
GenerateDa3Depth,
GenerateMoge2Depth,
GenerateDa3Surface,
@@ -40,7 +46,7 @@ CLASSES = (
GenerateOriginalBackground,
ExtractOriginalFrames,
ClearModels,
- *IMAGE_EXTRACT_CLASSES,
+ *IMAGE_EDIT_CLASSES,
ExtractBalloon,
AnalyzeBalloon,
ActivateBalloonTool,
diff --git a/src/anyimage/operators/debug.py b/src/anyimage/operators/debug.py
index c943f6e..238e290 100644
--- a/src/anyimage/operators/debug.py
+++ b/src/anyimage/operators/debug.py
@@ -1,7 +1,10 @@
import bpy
from ..runtime import JobOperatorBase
-from ..properties import BEN2_MODEL_KEY
+from ..properties import (
+ BEN2_MODEL_KEY,
+ REALESRGAN_MODEL_KEY,
+)
from .common import (
background_input_path,
da3_parameters,
@@ -89,3 +92,27 @@ class DebugBen2(JobOperatorBase, bpy.types.Operator):
def response(self, _context, result):
open_job_directory(result.directory)
return f"Removed background to {result.directory}"
+
+
+class DebugRealesrganGeneralX4v3(JobOperatorBase, bpy.types.Operator):
+ bl_idname = "anyimage.debug_realesrgan_general_x4v3"
+ bl_label = "Debug Real-ESRGAN General x4v3"
+ job_type = "debug-realesrgan-general-x4v3"
+ starting_message = "Starting Real-ESRGAN General x4v3 inference"
+
+ def request(self, context):
+ require_environment()
+ settings = context.scene.anyimage_settings
+ require_model(REALESRGAN_MODEL_KEY)
+ return {
+ "input": str(input_path(settings.input_path)),
+ "model": REALESRGAN_MODEL_KEY,
+ "device": settings.device.lower(),
+ "video_frames": settings.video_frames,
+ "start_frame": 1,
+ }
+
+ def response(self, _context, result):
+ open_job_directory(result.directory)
+ return f"Saved Real-ESRGAN API prediction to {result.directory}"
+
diff --git a/src/anyimage/operators/image_edit/__init__.py b/src/anyimage/operators/image_edit/__init__.py
new file mode 100644
index 0000000..706ffd8
--- /dev/null
+++ b/src/anyimage/operators/image_edit/__init__.py
@@ -0,0 +1,23 @@
+from .box import ImageBoxTool
+from .lasso import ImageLassoTool
+from .line import ImageLineTool
+from .mask import ActivateImageEditTool, EditImageMask, RefineImageMask
+from .perspective import EditImagePerspective, ImagePerspectiveTool
+from .polyline import ImagePolylineTool
+
+
+CLASSES = (
+ EditImageMask,
+ RefineImageMask,
+ ActivateImageEditTool,
+ EditImagePerspective,
+)
+
+__all__ = (
+ "CLASSES",
+ "ImageBoxTool",
+ "ImageLassoTool",
+ "ImageLineTool",
+ "ImagePolylineTool",
+ "ImagePerspectiveTool",
+)
diff --git a/src/anyimage/operators/image_extract/box.py b/src/anyimage/operators/image_edit/box.py
similarity index 51%
rename from src/anyimage/operators/image_extract/box.py
rename to src/anyimage/operators/image_edit/box.py
index 98ed8b1..3bef843 100644
--- a/src/anyimage/operators/image_extract/box.py
+++ b/src/anyimage/operators/image_edit/box.py
@@ -1,7 +1,7 @@
import bpy
-from .common import draw_image_extract_settings
-from .region import ExtractImageRegion
+from .common import draw_image_mask_settings
+from .mask import EditImageMask
class ImageBoxTool(bpy.types.WorkSpaceTool):
@@ -9,16 +9,16 @@ class ImageBoxTool(bpy.types.WorkSpaceTool):
bl_context_mode = "OBJECT"
bl_idname = "anyimage.image_box"
bl_label = "Image Box"
- bl_description = "Extract a boxed region from an Image Empty"
+ bl_description = "Edit an Image Empty with a rectangular mask"
bl_icon = "ops.sculpt.border_mask"
- bl_operator = ExtractImageRegion.bl_idname
+ bl_operator = EditImageMask.bl_idname
bl_keymap = (
(
- ExtractImageRegion.bl_idname,
+ EditImageMask.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
- {"properties": [("selection_shape", "BOX")]},
+ {"properties": [("gesture", "BOX")]},
),
)
def draw_settings(context, layout, _tool):
- draw_image_extract_settings(context, layout)
+ draw_image_mask_settings(context, layout)
diff --git a/src/anyimage/operators/image_extract/common.py b/src/anyimage/operators/image_edit/common.py
similarity index 54%
rename from src/anyimage/operators/image_extract/common.py
rename to src/anyimage/operators/image_edit/common.py
index 6077753..40aa73b 100644
--- a/src/anyimage/operators/image_extract/common.py
+++ b/src/anyimage/operators/image_edit/common.py
@@ -8,19 +8,12 @@ from ...properties import (
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):
+def image_edit_poll(context):
return context.area is not None and context.area.type == "VIEW_3D"
@@ -29,12 +22,15 @@ def _restore_selection(context, active_object, selected_objects):
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:
+ 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."""
+def resolve_image_edit_click(context, event, report):
+ """Resolve the first click into source selection or image editing."""
active_object = context.active_object
selected_objects = tuple(context.selected_objects)
try:
@@ -57,23 +53,16 @@ def resolve_image_extract_click(context, event, report):
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 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 polygon_bounds(polygon):
@@ -93,12 +82,10 @@ def polygon_bounds(polygon):
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."""
+def crop_pixels_to_alpha(pixels, bounds):
+ """Crop Blender-order RGBA pixels to their nonzero Alpha bounds."""
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
@@ -107,65 +94,61 @@ def crop_pixels_to_alpha(pixels, bounds, padding=0):
)
visible_y, visible_x = np.nonzero(rgba[:, :, 3] > 0.0)
if not len(visible_x):
- raise ValueError("The selection contains no visible image pixels")
+ raise ValueError("The edit 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
+ output = rgba[content_top:content_bottom, content_left:content_right].copy()
return np.flipud(output).ravel(), (
- left + content_left - padding,
- top + content_top - padding,
- left + content_right + padding,
- top + content_bottom + padding,
+ left + content_left,
+ top + content_top,
+ left + content_right,
+ top + content_bottom,
)
-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
+def align_empty_display_to_bounds(
+ source_object, source_size, output_size, placement_bounds
+):
+ """Place a new image canvas without changing the Empty transform."""
+ source_width, source_height = (float(value) for value in source_size)
+ output_width, output_height = (float(value) for value in output_size)
+ left, top, right, bottom = (float(value) for value in placement_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,
+ desired_x_min = x_min + left * local_width / source_width
+ desired_x_max = x_min + right * local_width / source_width
+ desired_y_max = y_max - top * local_height / source_height
+ desired_y_min = y_max - bottom * local_height / source_height
+ desired_width = desired_x_max - desired_x_min
+ desired_height = desired_y_max - desired_y_min
+ display_size = max(desired_width, desired_height)
+ aspect = output_width / output_height
+ if aspect >= 1.0:
+ display_width = display_size
+ display_height = display_size / aspect
+ else:
+ display_width = display_size * aspect
+ display_height = display_size
+ center_x = (desired_x_min + desired_x_max) * 0.5
+ center_y = (desired_y_min + desired_y_max) * 0.5
+ source_object.empty_display_size = display_size
+ source_object.empty_image_offset = (
+ center_x / display_width - 0.5,
+ center_y / display_height - 0.5,
)
- 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_object, pixels, output_size, placement_bounds, suffix=".edit"
):
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")
+ raise ValueError("The edited image dimensions must be positive")
image = bpy.data.images.new(
f"{image_base_name(source_image)}{suffix}",
width=output_width,
@@ -178,8 +161,11 @@ def replace_empty_image_pixels(
image.pixels.foreach_set(pixels)
image.pack()
image.update()
- align_empty_to_crop(
- source_object, matrix_world, source_size, placement_bounds
+ align_empty_display_to_bounds(
+ source_object,
+ source_size,
+ (output_width, output_height),
+ placement_bounds,
)
source_object.data = image
except Exception:
@@ -188,59 +174,7 @@ def replace_empty_image_pixels(
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):
+def image_mask_refine_request(input_path, selection_data):
require_environment()
require_model(BEN2_MODEL_KEY)
polygon = json.loads(selection_data)
@@ -248,28 +182,29 @@ def image_extract_background_request(input_path, selection_data, padding):
return {
"input": str(input_path),
"polygon": polygon,
- "padding": padding,
"model": BEN2_MODEL_KEY,
"device": production_device().lower(),
}
-def apply_image_extract_settings(operator, context):
+def apply_image_mask_settings(operator, context):
settings = context.scene.anyimage_settings
- operator.remove_background = (
- ai_ready() and settings.image_extract_remove_background
+ operator.refine_selection = (
+ operator.gesture in {"BOX", "LASSO"}
+ and ai_ready()
+ and settings.image_edit_refine_selection
)
- operator.continuous = settings.image_extract_continuous
- operator.padding = settings.image_extract_padding
-def draw_image_extract_operator_settings(layout, operator):
+def draw_image_mask_operator_settings(layout, operator):
+ if operator.gesture not in {"BOX", "LASSO"}:
+ layout.prop(operator, "invert")
+ return
status = ai_status()
row = layout.row()
row.enabled = status["ready"]
- row.prop(operator, "remove_background")
- layout.prop(operator, "continuous")
- layout.prop(operator, "padding")
+ row.prop(operator, "refine_selection")
+ layout.prop(operator, "invert")
if not status["ready"]:
layout.operator(
"anyimage.setup_ai_environment",
@@ -278,14 +213,12 @@ def draw_image_extract_operator_settings(layout, operator):
)
-def draw_image_extract_settings(context, layout):
+def draw_image_mask_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")
+ row.prop(settings, "image_edit_refine_selection")
if not status["ready"]:
layout.operator(
"anyimage.setup_ai_environment",
@@ -347,3 +280,27 @@ def draw_dashed_line(vertices, color=(1.0, 1.0, 1.0, 0.9)):
shader.uniform_float("color", color)
batch.draw(shader)
gpu.state.blend_set("NONE")
+
+
+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, color=(1.0, 1.0, 1.0, 0.16)):
+ 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", color)
+ batch.draw(shader)
+ gpu.state.blend_set("NONE")
diff --git a/src/anyimage/operators/image_extract/lasso.py b/src/anyimage/operators/image_edit/lasso.py
similarity index 51%
rename from src/anyimage/operators/image_extract/lasso.py
rename to src/anyimage/operators/image_edit/lasso.py
index 35c7961..39b1986 100644
--- a/src/anyimage/operators/image_extract/lasso.py
+++ b/src/anyimage/operators/image_edit/lasso.py
@@ -1,7 +1,7 @@
import bpy
-from .common import draw_image_extract_settings
-from .region import ExtractImageRegion
+from .common import draw_image_mask_settings
+from .mask import EditImageMask
class ImageLassoTool(bpy.types.WorkSpaceTool):
@@ -9,16 +9,16 @@ class ImageLassoTool(bpy.types.WorkSpaceTool):
bl_context_mode = "OBJECT"
bl_idname = "anyimage.image_lasso"
bl_label = "Image Lasso"
- bl_description = "Extract a lassoed region from an Image Empty"
+ bl_description = "Edit an Image Empty with a freehand mask"
bl_icon = "ops.sculpt.lasso_mask"
- bl_operator = ExtractImageRegion.bl_idname
+ bl_operator = EditImageMask.bl_idname
bl_keymap = (
(
- ExtractImageRegion.bl_idname,
+ EditImageMask.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
- {"properties": [("selection_shape", "LASSO")]},
+ {"properties": [("gesture", "LASSO")]},
),
)
def draw_settings(context, layout, _tool):
- draw_image_extract_settings(context, layout)
+ draw_image_mask_settings(context, layout)
diff --git a/src/anyimage/operators/image_edit/line.py b/src/anyimage/operators/image_edit/line.py
new file mode 100644
index 0000000..c111d84
--- /dev/null
+++ b/src/anyimage/operators/image_edit/line.py
@@ -0,0 +1,20 @@
+import bpy
+
+from .mask import EditImageMask
+
+
+class ImageLineTool(bpy.types.WorkSpaceTool):
+ bl_space_type = "VIEW_3D"
+ bl_context_mode = "OBJECT"
+ bl_idname = "anyimage.image_line"
+ bl_label = "Image Line"
+ bl_description = "Edit one side of an Image Empty with a line"
+ bl_icon = "ops.sculpt.line_mask"
+ bl_operator = EditImageMask.bl_idname
+ bl_keymap = (
+ (
+ EditImageMask.bl_idname,
+ {"type": "LEFTMOUSE", "value": "PRESS"},
+ {"properties": [("gesture", "LINE")]},
+ ),
+ )
diff --git a/src/anyimage/operators/image_edit/mask.py b/src/anyimage/operators/image_edit/mask.py
new file mode 100644
index 0000000..1b98a51
--- /dev/null
+++ b/src/anyimage/operators/image_edit/mask.py
@@ -0,0 +1,583 @@
+import json
+import math
+
+import bpy
+
+from ...properties import (
+ image_edit_refine_selection_property as refine_selection_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, push_undo
+from ..image import (
+ is_image_empty,
+ plane_bounds,
+ prepare_image_input,
+ remove_temporary_input,
+ video_total_frames,
+)
+from .common import (
+ active_view3d_tool_id,
+ apply_image_mask_settings,
+ crop_pixels_to_alpha,
+ draw_dashed_line,
+ draw_image_mask_operator_settings,
+ draw_polygon_fill,
+ image_edit_poll,
+ image_mask_refine_request,
+ polygon_bounds,
+ replace_empty_image_pixels,
+ resolve_image_edit_click,
+)
+
+
+GESTURE_TOOL_IDS = {
+ "BOX": "anyimage.image_box",
+ "LASSO": "anyimage.image_lasso",
+ "LINE": "anyimage.image_line",
+ "POLYLINE": "anyimage.image_polyline",
+}
+LINE_SNAP_RADIANS = math.radians(5.0)
+POLYLINE_CLOSE_RADIUS = 8.0
+
+
+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 polygon_mask(image_size, polygon):
+ import numpy as np
+
+ width, height = (int(value) for value in image_size)
+ if width <= 0 or height <= 0:
+ raise ValueError("The source image has no readable dimensions")
+ polygon_bounds(polygon)
+ y, x = np.indices((height, width), dtype=np.float64)
+ centers = np.column_stack((x.ravel() + 0.5, y.ravel() + 0.5))
+ return points_inside_polygon(centers, polygon).reshape((height, width)).astype(
+ np.float32
+ )
+
+
+def refined_mask_from_pixels(mask_pixels, mask_size, bounds, source_size):
+ import numpy as np
+
+ mask_width, mask_height = (int(value) for value in mask_size)
+ source_width, source_height = (int(value) for value in source_size)
+ local = np.flipud(
+ np.asarray(mask_pixels, dtype=np.float32).reshape(
+ (mask_height, mask_width, 4)
+ )
+ )[:, :, 3]
+ result = np.zeros((source_height, source_width), dtype=np.float32)
+ left, top, right, bottom = (int(value) for value in bounds)
+ 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:
+ return result
+ result[source_top:source_bottom, source_left:source_right] = local[
+ source_top - top : source_bottom - top,
+ source_left - left : source_right - left,
+ ]
+ return result
+
+
+def edit_image_mask_pixels(source_pixels, source_size, selection_mask, invert=False):
+ """Apply a mask while preserving source RGB, then tightly crop the canvas."""
+ import numpy as np
+
+ width, height = (int(value) for value in source_size)
+ rgba = np.flipud(
+ np.asarray(source_pixels, dtype=np.float32).reshape((height, width, 4))
+ ).copy()
+ mask = np.asarray(selection_mask, dtype=np.float32)
+ if mask.shape != (height, width):
+ raise ValueError("The refined selection does not match the source image")
+ mask = np.clip(mask, 0.0, 1.0)
+ if invert:
+ mask = 1.0 - mask
+ rgba[:, :, 3] *= mask
+ return crop_pixels_to_alpha(np.flipud(rgba).ravel(), (0, 0, width, height))
+
+
+def edit_image_object(source_object, selection_mask, invert=False):
+ import numpy as np
+
+ image = source_object.data
+ source_size = tuple(image.size)
+ source_pixels = np.empty(len(image.pixels), dtype=np.float32)
+ image.pixels.foreach_get(source_pixels)
+ pixels, crop_bounds = edit_image_mask_pixels(
+ source_pixels, source_size, selection_mask, invert
+ )
+ left, top, right, bottom = crop_bounds
+ return replace_empty_image_pixels(
+ source_object,
+ pixels,
+ (right - left, bottom - top),
+ crop_bounds,
+ suffix=".mask",
+ )
+
+
+def gesture_property():
+ return bpy.props.EnumProperty(
+ items=(
+ ("BOX", "Box", "Edit with a rectangular gesture"),
+ ("LASSO", "Lasso", "Edit with a freehand gesture"),
+ ("LINE", "Line", "Edit one side of a line"),
+ ("POLYLINE", "Polyline", "Edit with a clicked polygon"),
+ ),
+ default="BOX",
+ options={"HIDDEN", "SKIP_SAVE"},
+ )
+
+
+def box_screen_path(start, end):
+ x0, y0 = start
+ x1, y1 = end
+ if abs(x1 - x0) < 2.0 or abs(y1 - y0) < 2.0:
+ return ()
+ return ((x0, y0), (x1, y0), (x1, y1), (x0, y1))
+
+
+def snapped_line_endpoint(start, end, snap):
+ if not snap:
+ return tuple(float(value) for value in end)
+ delta_x = float(end[0]) - float(start[0])
+ delta_y = float(end[1]) - float(start[1])
+ length = math.hypot(delta_x, delta_y)
+ if length <= 0.0:
+ return tuple(float(value) for value in end)
+ angle = round(math.atan2(delta_y, delta_x) / LINE_SNAP_RADIANS)
+ angle *= LINE_SNAP_RADIANS
+ return (
+ float(start[0]) + math.cos(angle) * length,
+ float(start[1]) + math.sin(angle) * length,
+ )
+
+
+def line_half_plane_polygon(start, end, rectangle_size):
+ """Clip a rectangle to the left side of one directed line."""
+ width, height = (float(value) for value in rectangle_size)
+ if math.dist(start, end) < 2.0:
+ return ()
+ vertices = [(0.0, 0.0), (width, 0.0), (width, height), (0.0, height)]
+
+ def side(point):
+ return (end[0] - start[0]) * (point[1] - start[1]) - (
+ end[1] - start[1]
+ ) * (point[0] - start[0])
+
+ result = []
+ previous = vertices[-1]
+ previous_side = side(previous)
+ for current in vertices:
+ current_side = side(current)
+ if (current_side >= 0.0) != (previous_side >= 0.0):
+ factor = previous_side / (previous_side - current_side)
+ result.append(
+ (
+ previous[0] + (current[0] - previous[0]) * factor,
+ previous[1] + (current[1] - previous[1]) * factor,
+ )
+ )
+ if current_side >= 0.0:
+ result.append(current)
+ previous = current
+ previous_side = current_side
+ return tuple(result)
+
+
+def image_line_half_plane_polygon(line, image_size):
+ """Keep the image-space half-plane shown by the screen-space preview."""
+ if len(line) != 2:
+ raise ValueError("Drag a longer line across the image plane")
+ return line_half_plane_polygon(line[1], line[0], image_size)
+
+
+class EditImageMask(bpy.types.Operator):
+ bl_idname = "anyimage.edit_image_mask"
+ bl_label = "Edit Image Mask"
+ bl_description = "Edit and tightly crop an Image Empty with a gesture"
+ bl_options = {"REGISTER", "UNDO"}
+
+ source_object_name: hidden_string_property()
+ source_matrix_data: hidden_string_property()
+ selection_data: hidden_string_property()
+ refine_selection: refine_selection_property()
+ invert: bpy.props.BoolProperty(
+ name="Invert",
+ description="Keep the opposite side of the refined gesture mask",
+ default=False,
+ options={"SKIP_SAVE"},
+ )
+ gesture: gesture_property()
+
+ _path = None
+ _cursor = None
+ _handle = None
+ _area_pointer = 0
+ _region_pointer = 0
+ _region_size = (0, 0)
+
+ def draw(self, _context):
+ draw_image_mask_operator_settings(self.layout, self)
+
+ @classmethod
+ def poll(cls, context):
+ return image_edit_poll(context)
+
+ def _screen_polygon(self):
+ if self.gesture == "BOX":
+ return box_screen_path(self._path[0], self._cursor)
+ if self.gesture == "LASSO":
+ return tuple(simplify_screen_path(self._path))
+ if self.gesture == "LINE":
+ return line_half_plane_polygon(
+ self._path[0], self._cursor, self._region_size
+ )
+ vertices = list(self._path)
+ if self._cursor is not None:
+ vertices.append(self._cursor)
+ return tuple(vertices)
+
+ def _draw_selection(self):
+ if not self._path or not drawing_in_region(
+ self._area_pointer, self._region_pointer
+ ):
+ return
+ polygon = self._screen_polygon()
+ if len(polygon) >= 3:
+ draw_polygon_fill(
+ polygon,
+ (1.0, 0.25, 0.2, 0.16)
+ if self.invert
+ else (1.0, 1.0, 1.0, 0.16),
+ )
+ if self.gesture == "LINE":
+ draw_dashed_line((self._path[0], self._cursor))
+ return
+ if len(polygon) > 1:
+ draw_dashed_line((*polygon, polygon[0]))
+ if (
+ self.gesture == "POLYLINE"
+ and len(self._path) >= 3
+ and self._cursor is not None
+ and math.dist(self._cursor, self._path[0]) <= POLYLINE_CLOSE_RADIUS
+ ):
+ self._draw_polyline_close_indicator()
+
+ def _draw_polyline_close_indicator(self):
+ import gpu
+ from gpu_extras.batch import batch_for_shader
+
+ center_x, center_y = self._path[0]
+ vertices = tuple(
+ (
+ center_x + math.cos(index * math.tau / 24.0) * POLYLINE_CLOSE_RADIUS,
+ center_y + math.sin(index * math.tau / 24.0) * POLYLINE_CLOSE_RADIUS,
+ )
+ for index in range(25)
+ )
+ shader = gpu.shader.from_builtin("UNIFORM_COLOR")
+ batch = batch_for_shader(shader, "LINE_STRIP", {"pos": vertices})
+ gpu.state.blend_set("ALPHA")
+ gpu.state.line_width_set(2.0)
+ shader.bind()
+ shader.uniform_float("color", (1.0, 1.0, 1.0, 0.95))
+ batch.draw(shader)
+ gpu.state.line_width_set(1.0)
+ gpu.state.blend_set("NONE")
+
+ def _set_status(self, context):
+ invert = "On" if self.invert else "Off"
+ if self.gesture == "POLYLINE":
+ action = "click points; click start or Enter to finish; Backspace removes"
+ elif self.gesture == "LINE":
+ action = "drag line; Ctrl snaps 5°; release to edit"
+ else:
+ action = "drag and release to edit"
+ context.workspace.status_text_set(
+ f"Image {self.gesture.title()}: {action}; F toggles Invert ({invert}); "
+ "RMB or Esc cancels"
+ )
+
+ def _finish(self, context):
+ if self._handle is not None:
+ bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW")
+ self._handle = None
+ 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_edit = resolve_image_edit_click(
+ context, event, self.report
+ )
+ if not should_edit:
+ 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_mask_settings(self, context)
+ self.invert = False
+ self.source_object_name = source_object.name
+ self.source_matrix_data = serialize_matrix(source_object.matrix_world)
+ self.selection_data = ""
+ point = (float(event.mouse_region_x), float(event.mouse_region_y))
+ self._path = [point]
+ self._cursor = point
+ self._area_pointer = context.area.as_pointer()
+ self._region_pointer = context.region.as_pointer()
+ self._region_size = (context.region.width, context.region.height)
+ self._handle = bpy.types.SpaceView3D.draw_handler_add(
+ self._draw_selection, (), "WINDOW", "POST_PIXEL"
+ )
+ self._set_status(context)
+ context.window_manager.modal_handler_add(self)
+ context.area.tag_redraw()
+ return {"RUNNING_MODAL"}
+
+ def _complete(self, context):
+ 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")
+ matrix = deserialize_matrix(self.source_matrix_data)
+ if self.gesture == "LINE":
+ line = screen_path_to_image_pixels(
+ (self._path[0], self._cursor),
+ context.region,
+ context.region_data,
+ matrix,
+ plane_bounds(source_object),
+ tuple(source_object.data.size),
+ )
+ polygon = image_line_half_plane_polygon(
+ line, tuple(source_object.data.size)
+ )
+ else:
+ path = self._screen_polygon()
+ if len(path) < 3:
+ raise ValueError("Draw a larger image region")
+ polygon = screen_path_to_image_pixels(
+ path,
+ context.region,
+ context.region_data,
+ matrix,
+ plane_bounds(source_object),
+ tuple(source_object.data.size),
+ )
+ polygon_bounds(polygon)
+ self.selection_data = json.dumps(polygon, separators=(",", ":"))
+ self._finish(context)
+ return self.execute(context)
+
+ def modal(self, context, event):
+ active_tool = active_view3d_tool_id(context)
+ if active_tool is not None and active_tool != GESTURE_TOOL_IDS[self.gesture]:
+ self._finish(context)
+ return {"CANCELLED"}
+ if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
+ self._finish(context)
+ return {"CANCELLED"}
+ if event.type == "F" and event.value == "PRESS":
+ self.invert = not self.invert
+ self._set_status(context)
+ context.area.tag_redraw()
+ return {"RUNNING_MODAL"}
+ if self.gesture == "POLYLINE":
+ return self._modal_polyline(context, event)
+ if event.type == "MOUSEMOVE":
+ point = (float(event.mouse_region_x), float(event.mouse_region_y))
+ if self.gesture == "LASSO":
+ self._path.append(point)
+ self._cursor = point
+ elif self.gesture == "LINE":
+ self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
+ else:
+ self._cursor = point
+ context.area.tag_redraw()
+ return {"RUNNING_MODAL"}
+ if event.type == "LEFTMOUSE" and event.value == "RELEASE":
+ point = (float(event.mouse_region_x), float(event.mouse_region_y))
+ if self.gesture == "LASSO":
+ self._path.append(point)
+ self._cursor = point
+ elif self.gesture == "LINE":
+ self._cursor = snapped_line_endpoint(self._path[0], point, event.ctrl)
+ if math.dist(self._path[0], self._cursor) < 2.0:
+ self._finish(context)
+ return {"CANCELLED"}
+ else:
+ self._cursor = point
+ try:
+ return self._complete(context)
+ except (RuntimeError, TypeError, ValueError) as error:
+ self.report({"ERROR"}, str(error))
+ self._finish(context)
+ return {"CANCELLED"}
+ return {"PASS_THROUGH"}
+
+ def _modal_polyline(self, context, event):
+ if event.type == "BACK_SPACE" and event.value == "PRESS":
+ if len(self._path) > 1:
+ self._path.pop()
+ context.area.tag_redraw()
+ return {"RUNNING_MODAL"}
+ if event.type in {"RET", "NUMPAD_ENTER"} and event.value == "PRESS":
+ return self._confirm_polyline(context)
+ if event.type == "MOUSEMOVE":
+ self._cursor = (
+ float(event.mouse_region_x),
+ float(event.mouse_region_y),
+ )
+ context.area.tag_redraw()
+ return {"RUNNING_MODAL"}
+ if event.type == "LEFTMOUSE" and event.value == "PRESS":
+ point = (float(event.mouse_region_x), float(event.mouse_region_y))
+ if (
+ len(self._path) >= 3
+ and math.dist(point, self._path[0]) <= POLYLINE_CLOSE_RADIUS
+ ):
+ return self._confirm_polyline(context)
+ if math.dist(point, self._path[-1]) >= 2.0:
+ self._path.append(point)
+ context.area.tag_redraw()
+ return {"RUNNING_MODAL"}
+ return {"PASS_THROUGH"}
+
+ def _confirm_polyline(self, context):
+ self._cursor = self._path[0]
+ try:
+ return self._complete(context)
+ except (RuntimeError, TypeError, ValueError) as error:
+ self.report({"ERROR"}, str(error))
+ self._finish(context)
+ return {"CANCELLED"}
+
+ 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.refine_selection:
+ source_path, delete_input = prepare_image_input(
+ source_object.data, context.scene
+ )
+ result = bpy.ops.anyimage.refine_image_mask(
+ input_path=str(source_path),
+ delete_input=delete_input,
+ source_object_name=self.source_object_name,
+ selection_data=self.selection_data,
+ invert=self.invert,
+ )
+ if "CANCELLED" in result:
+ if delete_input:
+ remove_temporary_input(source_path)
+ return {"CANCELLED"}
+ return {"FINISHED"}
+ edit_image_object(
+ source_object,
+ polygon_mask(tuple(source_object.data.size), polygon),
+ self.invert,
+ )
+ except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
+ self.report({"ERROR"}, str(error))
+ return {"CANCELLED"}
+ return {"FINISHED"}
+
+
+class RefineImageMask(JobOperatorBase, bpy.types.Operator):
+ bl_idname = "anyimage.refine_image_mask"
+ bl_label = "Refine Image Mask"
+ job_type = "refine-image-mask"
+ starting_message = "Refining image selection"
+
+ input_path: bpy.props.StringProperty(options=HIDDEN)
+ delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
+ source_object_name: hidden_string_property()
+ selection_data: hidden_string_property()
+ invert: bpy.props.BoolProperty(default=False, options=HIDDEN)
+
+ def request(self, _context):
+ return image_mask_refine_request(self.input_path, self.selection_data)
+
+ 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
+
+ bounds = tuple(json.loads(result.file("bounds").read_text(encoding="utf-8")))
+ refined_image = bpy.data.images.load(
+ str(result.file("image")), check_existing=False
+ )
+ try:
+ refined_pixels = np.empty(len(refined_image.pixels), dtype=np.float32)
+ refined_image.pixels.foreach_get(refined_pixels)
+ mask = refined_mask_from_pixels(
+ refined_pixels,
+ tuple(refined_image.size),
+ bounds,
+ tuple(source_object.data.size),
+ )
+ finally:
+ bpy.data.images.remove(refined_image, do_unlink=True)
+ edit_image_object(source_object, mask, self.invert)
+ push_undo("Edit Image Mask")
+ return "Image selection refined"
+
+ def cleanup(self):
+ cleanup_temporary_input(self.input_path, self.delete_input)
+
+
+class ActivateImageEditTool(bpy.types.Operator):
+ bl_idname = "anyimage.activate_image_edit_tool"
+ bl_label = "Image Edit Tool"
+ bl_description = "Activate the Image Box tool in the 3D Viewport"
+ bl_options = {"INTERNAL"}
+
+ @classmethod
+ def poll(cls, context):
+ return image_edit_poll(context)
+
+ def execute(self, _context):
+ return bpy.ops.wm.tool_set_by_id(
+ name="anyimage.image_box", space_type="VIEW_3D"
+ )
diff --git a/src/anyimage/operators/image_extract/perspective.py b/src/anyimage/operators/image_edit/perspective.py
similarity index 80%
rename from src/anyimage/operators/image_extract/perspective.py
rename to src/anyimage/operators/image_edit/perspective.py
index a620072..9e40b91 100644
--- a/src/anyimage/operators/image_extract/perspective.py
+++ b/src/anyimage/operators/image_edit/perspective.py
@@ -3,7 +3,6 @@ import math
import bpy
-from ...runtime import JobOperatorBase
from ..balloon.operator import (
deserialize_matrix,
drawing_in_region,
@@ -11,33 +10,21 @@ from ..balloon.operator import (
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,
+ active_view3d_tool_id,
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,
+ draw_polygon_fill,
+ image_edit_poll,
polygon_bounds,
replace_empty_image_pixels,
- select_image_extract_result,
+ resolve_image_edit_click,
)
@@ -189,42 +176,6 @@ def perspective_quad_overlaps_image(points, image_size):
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
@@ -506,70 +457,46 @@ def extract_perspective_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
- )
+ pixels, crop_bounds = crop_pixels_to_alpha(pixels, (0, 0, *output_size))
cropped_size = (
crop_bounds[2] - crop_bounds[0],
crop_bounds[3] - crop_bounds[1],
)
placement_bounds = perspective_placement_bounds(
- polygon if placement_polygon is None else placement_polygon,
- output_size,
- crop_bounds,
+ 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
+ return replace_empty_image_pixels(
+ source_object,
+ pixels,
+ output_size,
+ placement_bounds,
+ suffix=".perspective",
+ )
-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"
+class EditImagePerspective(bpy.types.Operator):
+ bl_idname = "anyimage.edit_image_perspective"
+ bl_label = "Edit Image Perspective"
+ bl_description = "Deform four image corners into a rectangular image"
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,
@@ -591,12 +518,9 @@ class ExtractImagePerspective(bpy.types.Operator):
_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)
+ return image_edit_poll(context)
def _draw_preview(self):
if self._preview_image is None:
@@ -688,10 +612,10 @@ class ExtractImagePerspective(bpy.types.Operator):
self._finish(context)
def invoke(self, context, event):
- source_object, should_extract = resolve_image_extract_click(
+ source_object, should_edit = resolve_image_edit_click(
context, event, self.report
)
- if not should_extract:
+ if not should_edit:
return {"FINISHED"} if source_object is not None else {"CANCELLED"}
if source_object is None:
return {"CANCELLED"}
@@ -701,10 +625,8 @@ class ExtractImagePerspective(bpy.types.Operator):
"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
@@ -874,26 +796,6 @@ class ExtractImagePerspective(bpy.types.Operator):
"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
@@ -909,17 +811,12 @@ class ExtractImagePerspective(bpy.types.Operator):
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,
@@ -933,88 +830,18 @@ class ExtractImagePerspective(bpy.types.Operator):
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_icon = "ops.sculpt.line_project"
+ bl_operator = EditImagePerspective.bl_idname
bl_keymap = (
(
- ExtractImagePerspective.bl_idname,
+ EditImagePerspective.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_edit/polyline.py b/src/anyimage/operators/image_edit/polyline.py
new file mode 100644
index 0000000..01bafdc
--- /dev/null
+++ b/src/anyimage/operators/image_edit/polyline.py
@@ -0,0 +1,20 @@
+import bpy
+
+from .mask import EditImageMask
+
+
+class ImagePolylineTool(bpy.types.WorkSpaceTool):
+ bl_space_type = "VIEW_3D"
+ bl_context_mode = "OBJECT"
+ bl_idname = "anyimage.image_polyline"
+ bl_label = "Image Polyline"
+ bl_description = "Edit an Image Empty with a clicked polygon mask"
+ bl_icon = "ops.sculpt.polyline_mask"
+ bl_operator = EditImageMask.bl_idname
+ bl_keymap = (
+ (
+ EditImageMask.bl_idname,
+ {"type": "LEFTMOUSE", "value": "PRESS"},
+ {"properties": [("gesture", "POLYLINE")]},
+ ),
+ )
diff --git a/src/anyimage/operators/image_extract/__init__.py b/src/anyimage/operators/image_extract/__init__.py
deleted file mode 100644
index a555df5..0000000
--- a/src/anyimage/operators/image_extract/__init__.py
+++ /dev/null
@@ -1,33 +0,0 @@
-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/region.py b/src/anyimage/operators/image_extract/region.py
deleted file mode 100644
index 3e1ff89..0000000
--- a/src/anyimage/operators/image_extract/region.py
+++ /dev/null
@@ -1,378 +0,0 @@
-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/panel.py b/src/anyimage/panel.py
index 1cbb3cb..1d3ba2c 100644
--- a/src/anyimage/panel.py
+++ b/src/anyimage/panel.py
@@ -1,7 +1,11 @@
import bpy
from .runtime import runtime
-from .properties import BEN2_MODEL_KEY, model_ready
+from .properties import (
+ BEN2_MODEL_KEY,
+ REALESRGAN_MODEL_KEY,
+ model_ready,
+)
class ServerPanel(bpy.types.Panel):
bl_label = "Job Server"
@@ -221,3 +225,45 @@ class Ben2RemovalDebugPanel(bpy.types.Panel):
text="Remove Background",
icon="IMAGE_ALPHA",
)
+
+
+class RealesrganGeneralX4v3DebugPanel(bpy.types.Panel):
+ bl_label = "Real-ESRGAN General x4v3"
+ bl_idname = "ANYIMAGE_PT_debug_realesrgan_general_x4v3"
+ bl_parent_id = DebugPanel.bl_idname
+ bl_space_type = "VIEW_3D"
+ bl_region_type = "UI"
+ bl_category = "AnyImage"
+ bl_options = {"DEFAULT_CLOSED"}
+ bl_order = 3
+
+ def draw(self, context):
+ layout = self.layout
+ settings = context.scene.anyimage_settings
+ layout.prop(settings, "input_path")
+ input_path = bpy.path.abspath(settings.input_path)
+ if input_path.lower().endswith(
+ (
+ ".mp4",
+ ".mov",
+ ".avi",
+ ".mkv",
+ ".webm",
+ ".m4v",
+ ".mts",
+ ".mpg",
+ ".mpeg",
+ )
+ ):
+ layout.prop(settings, "video_frames")
+ run_row = layout.row()
+ run_row.enabled = (
+ not runtime.server_busy()
+ and model_ready(REALESRGAN_MODEL_KEY)
+ )
+ run_row.operator(
+ "anyimage.debug_realesrgan_general_x4v3",
+ text="Run General x4v3 API",
+ icon="EXPORT",
+ )
+
diff --git a/src/anyimage/preferences.py b/src/anyimage/preferences.py
index 31dfc66..476f8e7 100644
--- a/src/anyimage/preferences.py
+++ b/src/anyimage/preferences.py
@@ -2,7 +2,6 @@ import bpy
from .runtime import DEFAULT_STORAGE_ROOT, runtime
from .properties import (
- BEN2_MODEL_KEY,
ai_setup_label,
ai_status,
device_enum_items,
@@ -61,18 +60,12 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
layout.separator(type="LINE")
layout.label(text="Models")
models = model_catalog()
- for family in ("moge2", "da3"):
+ for family in ("moge2", "da3", "ben2", "upscale"):
for model in models:
if model.get("family") == family and (
family != "da3" or model.get("enabled")
):
self._draw_model(layout, model)
- ben2 = next(
- (model for model in models if model.get("key") == BEN2_MODEL_KEY),
- None,
- )
- if ben2:
- self._draw_model(layout, ben2)
layout.separator()
layout.operator(
"anyimage.open_server_log",
diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py
index fcad599..82bcb27 100644
--- a/src/anyimage/properties.py
+++ b/src/anyimage/properties.py
@@ -11,6 +11,7 @@ from . import model_catalog as shared_model_catalog
BEN2_MODEL_KEY = "BEN2_BASE"
+REALESRGAN_MODEL_KEY = "REALESRGAN_GENERAL_X4V3"
DEFAULT_DA3_MODEL_KEY = "BASE"
DEFAULT_MOGE2_MODEL_KEY = "MOGE2_VITS_NORMAL"
REQUIRED_MODEL_KEYS = (DEFAULT_MOGE2_MODEL_KEY, BEN2_MODEL_KEY)
@@ -280,37 +281,14 @@ def balloon_contour_analysis_property():
)
-def image_extract_remove_background_property():
+def image_edit_refine_selection_property():
return bpy.props.BoolProperty(
- name="Remove Background",
- description=(
- "Use BEN2 to remove the background inside the selected image region"
- ),
+ name="Refine Selection",
+ description="Use BEN2 to fit the gesture mask to foreground content",
default=False,
)
-def image_extract_continuous_property():
- return bpy.props.BoolProperty(
- name="Continuous",
- description=(
- "Keep the source Image Empty active and continuously create extracted copies"
- ),
- default=False,
- )
-
-
-def image_extract_padding_property():
- return bpy.props.IntProperty(
- name="Padding",
- description="Transparent padding in pixels around the visible Alpha bounds",
- default=0,
- min=0,
- soft_max=256,
- subtype="PIXEL",
- )
-
-
def balloon_depth_analysis_property():
return bpy.props.BoolProperty(
name="Depth",
@@ -337,9 +315,7 @@ def balloon_radial_zoom_property(scale):
class AnyImageSettings(bpy.types.PropertyGroup):
- image_extract_remove_background: image_extract_remove_background_property()
- image_extract_continuous: image_extract_continuous_property()
- image_extract_padding: image_extract_padding_property()
+ image_edit_refine_selection: image_edit_refine_selection_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 268e044..3b933f5 100644
--- a/src/anyimage/server/__init__.py
+++ b/src/anyimage/server/__init__.py
@@ -9,8 +9,9 @@ from .jobs.balloon import run as _analyze_balloon
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.debug_realesrgan import run as _debug_realesrgan
from .jobs.frames import run as _extract_frames
-from .jobs.image_extract import run as _extract_image_background
+from .jobs.image_mask import run as _refine_image_mask
from .model_manager import ModelManager, ModelSessionCache
@@ -39,9 +40,9 @@ def analyze_balloon(context, parameters):
return _analyze_balloon(context, parameters)
-@server.job("extract-image-background")
-def extract_image_background(context, parameters):
- return _extract_image_background(context, parameters)
+@server.job("refine-image-mask")
+def refine_image_mask(context, parameters):
+ return _refine_image_mask(context, parameters)
@server.job("remove-background")
@@ -79,4 +80,11 @@ def debug_ben2(context, parameters):
return _debug_ben2(context, parameters)
+@server.job("debug-realesrgan-general-x4v3")
+def debug_realesrgan(context, parameters):
+ return _debug_realesrgan(context, parameters)
+
+
+
+
__all__ = ("ModelManager", "ModelSessionCache", "server")
diff --git a/src/anyimage/server/jobs/debug_realesrgan.py b/src/anyimage/server/jobs/debug_realesrgan.py
new file mode 100644
index 0000000..f7111c6
--- /dev/null
+++ b/src/anyimage/server/jobs/debug_realesrgan.py
@@ -0,0 +1,26 @@
+from ..media import copy_frames
+from ..models.realesrgan import infer_one
+from .common import input_frames
+
+
+def run(context, parameters):
+ context.progress(0.02, "Preparing Real-ESRGAN API input")
+ context.directory.mkdir(parents=True, exist_ok=True)
+ with input_frames(context, parameters) as images:
+ for index, image in enumerate(images):
+ context.check_cancelled()
+ prediction = infer_one(
+ context.resource("model_manager"),
+ parameters,
+ image,
+ )
+ suffix = "" if len(images) == 1 else f".{index + 1:04d}"
+ prediction.save(
+ context.directory / f"prediction{suffix}.png",
+ format="PNG",
+ compress_level=1,
+ )
+ copy_frames(images, context.directory)
+ context.check_cancelled()
+ context.progress(1.0, "Saved raw Real-ESRGAN API prediction")
+ return {"directory": "."}
diff --git a/src/anyimage/server/jobs/image_extract.py b/src/anyimage/server/jobs/image_mask.py
similarity index 84%
rename from src/anyimage/server/jobs/image_extract.py
rename to src/anyimage/server/jobs/image_mask.py
index 466366d..946c5e0 100644
--- a/src/anyimage/server/jobs/image_extract.py
+++ b/src/anyimage/server/jobs/image_mask.py
@@ -67,13 +67,11 @@ def apply_selection_mask(foreground, selection_mask):
return Image.fromarray(pixels, mode="RGBA")
-def crop_to_alpha(image, bounds, padding=0):
- """Crop an RGBA image to nonzero Alpha and expand it by pixel padding."""
+def crop_to_alpha(image, bounds):
+ """Crop an RGBA mask image to its nonzero Alpha bounds."""
import numpy as np
from PIL import Image
- if padding < 0:
- raise ValueError("Padding must not be negative")
pixels = np.asarray(image.convert("RGBA"), dtype=np.uint8)
visible_y, visible_x = np.nonzero(pixels[:, :, 3] > 0)
if not len(visible_x):
@@ -85,23 +83,23 @@ def crop_to_alpha(image, bounds, padding=0):
output = Image.new(
"RGBA",
(
- content_right - content_left + padding * 2,
- content_bottom - content_top + padding * 2,
+ content_right - content_left,
+ content_bottom - content_top,
),
(0, 0, 0, 0),
)
- output.paste(image, (padding - content_left, padding - content_top))
+ output.paste(image, (-content_left, -content_top))
left, top, _right, _bottom = bounds
return output, (
- left + content_left - padding,
- top + content_top - padding,
- left + content_right + padding,
- top + content_bottom + padding,
+ left + content_left,
+ top + content_top,
+ left + content_right,
+ top + content_bottom,
)
def run(context, parameters):
- """Remove the background from an image selection and return source bounds."""
+ """Refine a gesture mask with BEN2 and return its source-image bounds."""
started = time.perf_counter()
context.progress(0.05, "Preparing image selection")
context.check_cancelled()
@@ -124,15 +122,13 @@ def run(context, parameters):
context.check_cancelled()
context.progress(0.9, "Applying selection mask")
output, bounds = crop_to_alpha(
- apply_selection_mask(foreground, selection_mask),
- bounds,
- int(parameters.get("padding", 0)),
+ apply_selection_mask(foreground, selection_mask), bounds
)
output_path = output_directory / "image.png"
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 background removal finished in {elapsed_ms:.0f} ms"
+ message = f"Image selection refined in {elapsed_ms:.0f} ms"
context.progress(1.0, message)
return {"image": output_path.name, "bounds": bounds_path.name}
diff --git a/src/anyimage/server/model_catalog.py b/src/anyimage/server/model_catalog.py
index 4e5260b..f63f039 100644
--- a/src/anyimage/server/model_catalog.py
+++ b/src/anyimage/server/model_catalog.py
@@ -89,6 +89,30 @@ BEN2_MODEL = ModelSpec(
)
+REALESRGAN_MODEL = ModelSpec(
+ key="REALESRGAN_GENERAL_X4V3",
+ label="Real-ESRGAN General x4v3",
+ directory_name="realesr-general-x4v3",
+ repository="jonathanst29/tinier-upscale-models",
+ license_name="BSD 3-Clause",
+ description="Fast general-purpose 4x image upscaler using ONNX Runtime",
+ supports_gs=False,
+ download_patterns=("realesr-general-x4v3.onnx",),
+ ready_patterns=("realesr-general-x4v3.onnx",),
+ r2_directory="realesr-general-x4v3",
+ r2_files=((
+ "realesr-general-x4v3.onnx",
+ 4866396,
+ "924ebad6532777303582d4ce7811849b88869231a3ae7093e0f21200249df8d5",
+ ),),
+)
+
+
+UPSCALE_MODELS = {
+ REALESRGAN_MODEL.key: REALESRGAN_MODEL,
+}
+
+
MOGE2_MODELS = {
"MOGE2_VITS_NORMAL": ModelSpec(
key="MOGE2_VITS_NORMAL",
@@ -146,6 +170,7 @@ MOGE2_MODEL = MOGE2_MODELS["MOGE2_VITS_NORMAL"]
DOWNLOADABLE_MODELS = {
**MODELS,
BEN2_MODEL.key: BEN2_MODEL,
+ **UPSCALE_MODELS,
**MOGE2_MODELS,
}
MODEL_NUMBERS = {
@@ -181,8 +206,14 @@ def model_record(model, ready=False):
"license": model.license_name,
"supports_gs": model.supports_gs,
"enabled": model.enabled,
- "family": "moge2" if model.key.startswith("MOGE2_") else (
- "ben2" if model.key == BEN2_MODEL.key else "da3"
+ "family": (
+ "moge2"
+ if model.key.startswith("MOGE2_")
+ else "ben2"
+ if model.key == BEN2_MODEL.key
+ else "upscale"
+ if model.key in UPSCALE_MODELS
+ else "da3"
),
"ready": bool(ready),
}
diff --git a/src/anyimage/server/model_manager.py b/src/anyimage/server/model_manager.py
index 53aa533..20a081a 100644
--- a/src/anyimage/server/model_manager.py
+++ b/src/anyimage/server/model_manager.py
@@ -17,6 +17,8 @@ class ModelSessionCache:
self.da3_key = None
self.moge_model = None
self.moge_key = None
+ self.realesrgan_model = None
+ self.realesrgan_key = None
def get_ben2(self, model_dir, device):
requested_device = getattr(device, "type", device)
@@ -68,6 +70,18 @@ class ModelSessionCache:
self.moge_key = key
return model
+ def get_realesrgan(self, model_dir, requested_device):
+ from .models.onnx_realesrgan import create_session
+
+ key = (str(Path(model_dir).resolve()), requested_device, "onnx")
+ if self.realesrgan_model is not None and self.realesrgan_key == key:
+ return self.realesrgan_model
+ self.release_realesrgan()
+ model = create_session(model_dir, requested_device)
+ self.realesrgan_model = model
+ self.realesrgan_key = key
+ return model
+
def release_ben2(self):
self.ben2_model = None
self.ben2_key = None
@@ -83,13 +97,20 @@ class ModelSessionCache:
self.moge_key = None
self._release_runtime_cache()
+ def release_realesrgan(self):
+ self.realesrgan_model = None
+ self.realesrgan_key = None
+ self._release_runtime_cache()
+
def close(self):
self.ben2_model = None
self.da3_model = None
self.moge_model = None
+ self.realesrgan_model = None
self.ben2_key = None
self.da3_key = None
self.moge_key = None
+ self.realesrgan_key = None
self._release_runtime_cache()
def snapshot(self):
@@ -97,6 +118,11 @@ class ModelSessionCache:
"ben2": Path(self.ben2_key[0]).name if self.ben2_key else "",
"da3": Path(self.da3_key[0]).name if self.da3_key else "",
"moge2": Path(self.moge_key[0]).name if self.moge_key else "",
+ "realesrgan": (
+ Path(self.realesrgan_key[0]).name
+ if self.realesrgan_key
+ else ""
+ ),
}
def _release_runtime_cache(self):
@@ -143,6 +169,9 @@ class ModelManager:
def get_moge2(self, model_dir, device):
return self.sessions.get_moge2(model_dir, device)
+ def get_realesrgan(self, model_dir, device):
+ return self.sessions.get_realesrgan(model_dir, device)
+
def snapshot(self):
return {"loaded": self.sessions.snapshot()}
diff --git a/src/anyimage/server/models/__init__.py b/src/anyimage/server/models/__init__.py
index 0cc87e6..3309119 100644
--- a/src/anyimage/server/models/__init__.py
+++ b/src/anyimage/server/models/__init__.py
@@ -1,8 +1,14 @@
-from . import ben2, da3, moge2
+from . import ben2, da3, moge2, realesrgan
def geometry_adapter(model_key):
return moge2 if model_key.startswith("MOGE2_") else da3
-__all__ = ("ben2", "da3", "moge2", "geometry_adapter")
+__all__ = (
+ "ben2",
+ "da3",
+ "moge2",
+ "realesrgan",
+ "geometry_adapter",
+)
diff --git a/src/anyimage/server/models/ben2.py b/src/anyimage/server/models/ben2.py
index 8449cf5..52a409a 100644
--- a/src/anyimage/server/models/ben2.py
+++ b/src/anyimage/server/models/ben2.py
@@ -32,15 +32,12 @@ def infer_foreground(
check_cancelled(cancel_check)
with Image.open(input_path) as opened_image:
source_rgba = opened_image.convert("RGBA")
- if preserve_input_alpha:
- image = Image.new("RGB", source_rgba.size, (0, 0, 0))
- image.paste(
- source_rgba.convert("RGB"),
- (0, 0),
- source_rgba.getchannel("A"),
- )
- else:
- image = source_rgba.convert("RGB")
+ image = Image.new("RGB", source_rgba.size, (0, 0, 0))
+ image.paste(
+ source_rgba.convert("RGB"),
+ (0, 0),
+ source_rgba.getchannel("A"),
+ )
inference_started = time.perf_counter()
foreground = infer(model, image)
inference_ms = (time.perf_counter() - inference_started) * 1000.0
diff --git a/src/anyimage/server/models/onnx_realesrgan.py b/src/anyimage/server/models/onnx_realesrgan.py
new file mode 100644
index 0000000..b10cce3
--- /dev/null
+++ b/src/anyimage/server/models/onnx_realesrgan.py
@@ -0,0 +1,51 @@
+"""Real-ESRGAN General x4v3 ONNX inference."""
+
+from pathlib import Path
+
+try:
+ from .onnx_runtime import create_session as create_runtime_session
+except ImportError:
+ from onnx_runtime import create_session as create_runtime_session
+
+
+ONNX_FILENAME = "realesr-general-x4v3.onnx"
+SCALE = 4
+
+
+def create_session(model_directory, requested_device="auto"):
+ path = Path(model_directory) / ONNX_FILENAME
+ return create_runtime_session(path, requested_device)
+
+
+def preprocess(image):
+ import numpy as np
+
+ image_array = np.asarray(image.convert("RGB"), dtype=np.float32) / 255.0
+ return np.ascontiguousarray(image_array.transpose(2, 0, 1)[None])
+
+
+def infer(session, image):
+ import numpy as np
+ from PIL import Image
+
+ input_tensor = preprocess(image)
+ input_name = session.get_inputs()[0].name
+ output_name = session.get_outputs()[0].name
+ prediction = np.asarray(
+ session.run([output_name], {input_name: input_tensor})[0],
+ dtype=np.float32,
+ )
+ expected_shape = (
+ 1,
+ 3,
+ input_tensor.shape[2] * SCALE,
+ input_tensor.shape[3] * SCALE,
+ )
+ if prediction.shape != expected_shape:
+ raise RuntimeError(
+ "Real-ESRGAN General x4v3 returned an unexpected output shape: "
+ f"{prediction.shape}"
+ )
+ pixels = np.clip(prediction[0].transpose(1, 2, 0), 0.0, 1.0)
+ pixels = np.rint(pixels * 255.0).astype(np.uint8)
+ return Image.fromarray(pixels, mode="RGB")
diff --git a/src/anyimage/server/models/realesrgan.py b/src/anyimage/server/models/realesrgan.py
new file mode 100644
index 0000000..34feb53
--- /dev/null
+++ b/src/anyimage/server/models/realesrgan.py
@@ -0,0 +1,12 @@
+def infer_one(model_manager, parameters, image_path):
+ from PIL import Image
+
+ from .onnx_realesrgan import infer
+
+ model_directory = model_manager.directory(parameters["model"])
+ model = model_manager.get_realesrgan(
+ model_directory,
+ parameters["device"],
+ )
+ with Image.open(image_path) as opened:
+ return infer(model, opened)
diff --git a/sync_models.py b/sync_models.py
index d9d1aff..1a6b3e9 100644
--- a/sync_models.py
+++ b/sync_models.py
@@ -22,11 +22,11 @@ class ModelFile:
size: int
sha256: str
sources: tuple[str, ...]
+ filename: str = "model.onnx"
@property
def destination(self):
- filename = "model_fp16.onnx" if self.folder.endswith("/onnx") else "model.onnx"
- return MODELS_DIR / self.folder / filename
+ return MODELS_DIR / self.folder / self.filename
def hf_url(repository, filename):
@@ -58,6 +58,23 @@ MODEL_FILES = (
hf_url("onnx-community/BEN2-ONNX", "onnx/model_fp16.onnx"),
r2_url("BEN2-ONNX", "onnx/model_fp16.onnx"),
),
+ "model_fp16.onnx",
+ ),
+ ModelFile(
+ "realesr-general-x4v3",
+ 4866396,
+ "924ebad6532777303582d4ce7811849b88869231a3ae7093e0f21200249df8d5",
+ (
+ hf_url(
+ "jonathanst29/tinier-upscale-models",
+ "realesr-general-x4v3.onnx",
+ ),
+ r2_url(
+ "realesr-general-x4v3",
+ "realesr-general-x4v3.onnx",
+ ),
+ ),
+ "realesr-general-x4v3.onnx",
),
ModelFile(
"moge-2-vits-normal-onnx",
diff --git a/tests/test_addon.py b/tests/test_addon.py
index f8790a3..11150ba 100644
--- a/tests/test_addon.py
+++ b/tests/test_addon.py
@@ -87,6 +87,7 @@ class AddonTest(unittest.TestCase):
cls.depth_operator = importlib.import_module(
"anyimage.operators.depth_plane.operator"
)
+ cls.debug = importlib.import_module("anyimage.operators.debug")
cls.depth_build = importlib.import_module(
"anyimage.operators.depth_plane.build"
)
@@ -95,20 +96,26 @@ class AddonTest(unittest.TestCase):
)
cls.image_background = importlib.import_module("anyimage.operators.background")
cls.image_mesh = importlib.import_module("anyimage.operators.surface")
- cls.image_extract_common = importlib.import_module(
- "anyimage.operators.image_extract.common"
+ cls.image_edit_common = importlib.import_module(
+ "anyimage.operators.image_edit.common"
)
- cls.image_region = importlib.import_module(
- "anyimage.operators.image_extract.region"
+ cls.image_mask = importlib.import_module(
+ "anyimage.operators.image_edit.mask"
)
cls.image_box = importlib.import_module(
- "anyimage.operators.image_extract.box"
+ "anyimage.operators.image_edit.box"
)
cls.image_lasso = importlib.import_module(
- "anyimage.operators.image_extract.lasso"
+ "anyimage.operators.image_edit.lasso"
+ )
+ cls.image_line = importlib.import_module(
+ "anyimage.operators.image_edit.line"
+ )
+ cls.image_polyline = importlib.import_module(
+ "anyimage.operators.image_edit.polyline"
)
cls.image_perspective = importlib.import_module(
- "anyimage.operators.image_extract.perspective"
+ "anyimage.operators.image_edit.perspective"
)
cls.balloon = importlib.import_module("anyimage.operators.balloon.operator")
cls.balloon_geometry = importlib.import_module(
@@ -253,6 +260,57 @@ class AddonTest(unittest.TestCase):
self.assertIn(self.job_runtime.JobOperatorBase, operator.__mro__)
self.assertIn(self.fake_bpy.types.Operator, operator.__mro__)
+ def test_realesrgan_debug_operator_is_a_direct_model_request(self):
+ operator_type = self.debug.DebugRealesrganGeneralX4v3
+ self.assertIn(operator_type, self.addon.CLASSES)
+ self.assertEqual(
+ operator_type.bl_idname,
+ "anyimage.debug_realesrgan_general_x4v3",
+ )
+ self.assertEqual(
+ operator_type.job_type,
+ "debug-realesrgan-general-x4v3",
+ )
+
+ with tempfile.TemporaryDirectory() as directory:
+ image_path = Path(directory) / "input.png"
+ image_path.touch()
+ settings = SimpleNamespace(
+ input_path=str(image_path),
+ device="CPU",
+ video_frames=3,
+ )
+ context = SimpleNamespace(
+ scene=SimpleNamespace(anyimage_settings=settings)
+ )
+ with (
+ patch.object(self.debug, "require_environment"),
+ patch.object(self.debug, "require_model") as require_model,
+ ):
+ request = operator_type().request(context)
+
+ require_model.assert_called_once_with(
+ self.addon.properties.REALESRGAN_MODEL_KEY
+ )
+ self.assertEqual(
+ request,
+ {
+ "input": str(image_path),
+ "model": "REALESRGAN_GENERAL_X4V3",
+ "device": "cpu",
+ "video_frames": 3,
+ "start_frame": 1,
+ },
+ )
+ self.assertNotIn("tile", request)
+ self.assertNotIn("strength", request)
+
+ panel = importlib.import_module("anyimage.panel")
+ self.assertIn(
+ panel.RealesrganGeneralX4v3DebugPanel,
+ self.addon.CLASSES,
+ )
+
def test_default_request_sends_every_operator_property(self):
class Example(self.job_runtime.JobOperatorBase):
value: object()
@@ -614,6 +672,62 @@ class AddonTest(unittest.TestCase):
panel = importlib.import_module("anyimage.panel")
self.assertFalse(hasattr(panel, "ModelsPanel"))
+ def test_preferences_draws_upscale_model_download(self):
+ preference_module = importlib.import_module("anyimage.preferences")
+ downloads = []
+
+ class Layout:
+ enabled = True
+
+ def prop(self, *_args, **_options):
+ return None
+
+ def separator(self, **_options):
+ return None
+
+ def label(self, **_options):
+ return None
+
+ def row(self, **_options):
+ return self
+
+ def operator(self, identifier, **_options):
+ operator = SimpleNamespace(model="")
+ if identifier == "anyimage.download_model":
+ downloads.append(operator)
+ return operator
+
+ preferences = preference_module.AnyImagePreferences()
+ preferences.layout = Layout()
+ catalog = ({
+ "key": "REALESRGAN_GENERAL_X4V3",
+ "label": "Real-ESRGAN General x4v3",
+ "family": "upscale",
+ "enabled": True,
+ "ready": False,
+ },)
+ with (
+ patch.object(
+ preference_module,
+ "ai_status",
+ return_value={
+ "environment_ready": True,
+ "missing_models": (),
+ "ready": True,
+ },
+ ),
+ patch.object(
+ preference_module,
+ "model_catalog",
+ return_value=catalog,
+ ),
+ patch.object(preference_module.runtime, "server_busy", return_value=False),
+ ):
+ preferences.draw(None)
+
+ self.assertEqual(len(downloads), 1)
+ self.assertEqual(downloads[0].model, "REALESRGAN_GENERAL_X4V3")
+
def test_mesh_plane_operator_is_registered_with_material_options(self):
operator = self.mesh_plane.ConvertToMeshPlane
self.assertIn(operator, self.addon.CLASSES)
@@ -793,141 +907,213 @@ 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_region_clears_rgba_outside_the_selection(self):
+ def test_image_mask_preserves_source_rgb_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_region.extract_region_pixels(
+ selection = self.image_mask.polygon_mask(
+ (3, 3), ((0, 0), (3, 0), (0, 3))
+ )
+ pixels, bounds = self.image_mask.edit_image_mask_pixels(
source,
(3, 3),
- ((0, 0), (3, 0), (0, 3)),
+ selection,
)
- result = np.flipud(np.asarray(pixels).reshape((3, 3, 4)))
+ width = bounds[2] - bounds[0]
+ height = bounds[3] - bounds[1]
+ result = np.flipud(np.asarray(pixels).reshape((height, width, 4)))
- self.assertEqual(bounds, (0, 0, 3, 3))
- np.testing.assert_allclose(result[2, 2], (0.0, 0.0, 0.0, 0.0))
+ self.assertEqual(bounds, (0, 0, 2, 2))
+ np.testing.assert_allclose(result[1, 1], (0.2, 0.4, 0.6, 0.0))
self.assertEqual(float(result[0, 0, 3]), 1.0)
- def test_image_region_clears_hidden_rgb_from_source_transparency(self):
+ def test_image_mask_preserves_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_region.extract_region_pixels(
+ pixels, _bounds = self.image_mask.edit_image_mask_pixels(
np.flipud(top_down).ravel(),
(2, 2),
- ((0, 0), (2, 0), (2, 2), (0, 2)),
+ np.ones((2, 2), dtype=np.float32),
)
result = np.flipud(np.asarray(pixels).reshape((2, 2, 4)))
- np.testing.assert_allclose(result[0, 0], (0.0, 0.0, 0.0, 0.0))
+ np.testing.assert_allclose(result[0, 0], (0.2, 0.4, 0.6, 0.0))
- def test_image_region_keeps_out_of_image_canvas_transparent(self):
+ def test_image_mask_invert_runs_after_refinement(self):
import numpy as np
- top_down = np.ones((2, 2, 4), dtype=np.float32)
- pixels, bounds = self.image_region.extract_region_pixels(
+ top_down = np.full((2, 2, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32)
+ refined = np.asarray(((1.0, 0.25), (0.0, 1.0)), dtype=np.float32)
+ pixels, bounds = self.image_mask.edit_image_mask_pixels(
np.flipud(top_down).ravel(),
(2, 2),
- ((-1, -1), (2, -1), (2, 2), (-1, 2)),
+ refined,
+ invert=True,
)
- result = np.flipud(np.asarray(pixels).reshape((3, 3, 4)))
+ result = np.flipud(np.asarray(pixels).reshape((2, 2, 4)))
- self.assertEqual(bounds, (-1, -1, 2, 2))
+ self.assertEqual(bounds, (0, 0, 2, 2))
self.assertEqual(float(result[0, 0, 3]), 0.0)
- self.assertEqual(float(result[1, 1, 3]), 1.0)
+ self.assertAlmostEqual(float(result[0, 1, 3]), 0.75)
+ np.testing.assert_allclose(result[0, 0], (0.2, 0.4, 0.6, 0.0))
- def test_image_region_rejects_a_fully_out_of_image_region(self):
+ def test_image_mask_rejects_a_fully_transparent_result(self):
import numpy as np
- with self.assertRaisesRegex(ValueError, "does not overlap"):
- self.image_region.extract_region_pixels(
+ with self.assertRaisesRegex(ValueError, "no visible"):
+ self.image_mask.edit_image_mask_pixels(
np.ones(2 * 2 * 4, dtype=np.float32),
(2, 2),
- ((3, 3), (4, 3), (4, 4), (3, 4)),
+ np.zeros((2, 2), dtype=np.float32),
)
- def test_image_region_crops_to_visible_alpha_with_pixel_padding(self):
+ def test_image_mask_crops_to_visible_alpha_without_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_extract_common.crop_pixels_to_alpha(
+ rgba = np.full((4, 5, 4), (0.8, 0.7, 0.6, 0.0), dtype=np.float32)
+ rgba[1, 2] = (0.2, 0.4, 0.6, 1.0)
+ rgba[2, 3] = (0.2, 0.4, 0.6, 1.0)
+ pixels, bounds = self.image_edit_common.crop_pixels_to_alpha(
np.flipud(rgba).ravel(),
(10, 20, 15, 24),
- padding=1,
)
- result = np.flipud(np.asarray(pixels).reshape((4, 4, 4)))
+ result = np.flipud(np.asarray(pixels).reshape((2, 2, 4)))
- self.assertEqual(bounds, (11, 20, 15, 24))
- self.assertEqual(result.shape, (4, 4, 4))
- 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))
+ self.assertEqual(bounds, (12, 21, 14, 23))
+ self.assertEqual(result.shape, (2, 2, 4))
+ np.testing.assert_allclose(result[0, 1], (0.8, 0.7, 0.6, 0.0))
- def test_image_extract_tools_and_operators_are_registered(self):
+ def test_image_mask_restores_refined_alpha_to_source_coordinates(self):
+ import numpy as np
+
+ top_down = np.zeros((2, 2, 4), dtype=np.float32)
+ top_down[:, :, 3] = ((1.0, 0.5), (0.0, 1.0))
+ mask = self.image_mask.refined_mask_from_pixels(
+ np.flipud(top_down).ravel(),
+ (2, 2),
+ (1, 1, 3, 3),
+ (4, 4),
+ )
+
+ expected = np.zeros((4, 4), dtype=np.float32)
+ expected[1:3, 1:3] = top_down[:, :, 3]
+ np.testing.assert_allclose(mask, expected)
+
+ def test_image_edit_aligns_canvas_without_changing_object_transform(self):
+ matrix = object()
+ image_object = SimpleNamespace(
+ matrix_world=matrix,
+ empty_display_size=4.0,
+ empty_image_offset=(0.0, 0.0),
+ )
+ with patch.object(
+ self.image_edit_common,
+ "plane_bounds",
+ return_value=(-2.0, 2.0, -1.0, 1.0),
+ ):
+ self.image_edit_common.align_empty_display_to_bounds(
+ image_object,
+ (100, 50),
+ (50, 25),
+ (50, 0, 100, 25),
+ )
+
+ self.assertIs(image_object.matrix_world, matrix)
+ self.assertEqual(image_object.empty_display_size, 2.0)
+ self.assertEqual(image_object.empty_image_offset, (0.0, 0.0))
+
+ def test_image_edit_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
+ operator = self.image_mask.EditImageMask
+ self.assertIn(operator, self.addon.CLASSES)
+ self.assertIn(self.image_mask.RefineImageMask, self.addon.CLASSES)
+ self.assertIn(self.image_mask.ActivateImageEditTool, self.addon.CLASSES)
+ tool_cases = (
+ (self.image_box.ImageBoxTool, "BOX", "ops.sculpt.border_mask"),
+ (self.image_lasso.ImageLassoTool, "LASSO", "ops.sculpt.lasso_mask"),
+ (self.image_line.ImageLineTool, "LINE", "ops.sculpt.line_mask"),
+ (
+ self.image_polyline.ImagePolylineTool,
+ "POLYLINE",
+ "ops.sculpt.polyline_mask",
+ ),
)
- self.assertIn(self.image_region.ActivateImageExtractTool, self.addon.CLASSES)
- self.assertNotIn(self.image_lasso.ImageLassoTool, self.addon.CLASSES)
- self.assertNotIn(self.image_box.ImageBoxTool, self.addon.CLASSES)
- self.assertEqual(
- self.image_lasso.ImageLassoTool.bl_operator,
- self.image_region.ExtractImageRegion.bl_idname,
+ for tool, gesture, icon in tool_cases:
+ self.assertNotIn(tool, self.addon.CLASSES)
+ self.assertEqual(tool.bl_operator, operator.bl_idname)
+ self.assertEqual(tool.bl_icon, icon)
+ self.assertEqual(
+ tool.bl_keymap[0][2]["properties"], [("gesture", gesture)]
+ )
+ self.assertNotIn("bl_cursor", tool.__dict__)
+ self.assertIn("draw_settings", self.image_box.ImageBoxTool.__dict__)
+ self.assertIn("draw_settings", self.image_lasso.ImageLassoTool.__dict__)
+ self.assertNotIn("draw_settings", self.image_line.ImageLineTool.__dict__)
+ self.assertNotIn(
+ "draw_settings", self.image_polyline.ImagePolylineTool.__dict__
)
- self.assertEqual(
- self.image_lasso.ImageLassoTool.bl_icon,
- "ops.sculpt.lasso_mask",
- )
- self.assertEqual(
- self.image_box.ImageBoxTool.bl_icon,
- "ops.sculpt.border_mask",
- )
- self.assertEqual(
- 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_box.ImageBoxTool.__dict__)
- self.assertNotIn("bl_cursor", self.image_lasso.ImageLassoTool.__dict__)
- self.assertEqual(
- self.image_region.ExtractImageRegion.__annotations__["selection_shape"][
- "default"
- ],
- "BOX",
- )
- 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)
+ self.assertEqual(operator.__annotations__["gesture"]["default"], "BOX")
+ self.assertIn("invert", operator.__annotations__)
+ self.assertNotIn("continuous", operator.__annotations__)
+ self.assertNotIn("padding", operator.__annotations__)
settings = self.addon.properties.AnyImageSettings.__annotations__
- 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.assertFalse(settings["image_edit_refine_selection"]["default"])
+ self.assertNotIn("image_extract_continuous", settings)
+ self.assertNotIn("image_extract_padding", settings)
+ self.assertNotIn("image_extract_remove_background", settings)
+ source_picker = inspect.getsource(self.image_edit_common.resolve_image_edit_click)
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)
+ modal_source = inspect.getsource(operator.modal)
+ self.assertIn('event.type == "F"', modal_source)
+ self.assertIn("self.invert = not self.invert", modal_source)
- def test_image_extract_first_click_selects_another_image_without_extracting(self):
+ def test_image_line_and_polyline_disable_refine_selection(self):
+ settings = SimpleNamespace(image_edit_refine_selection=True)
+ context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings))
+ with patch.object(self.image_edit_common, "ai_ready", return_value=True):
+ for gesture in ("LINE", "POLYLINE"):
+ operator = SimpleNamespace(
+ gesture=gesture,
+ refine_selection=True,
+ )
+ self.image_edit_common.apply_image_mask_settings(operator, context)
+ self.assertFalse(operator.refine_selection)
+
+ operator = SimpleNamespace(gesture="BOX", refine_selection=False)
+ self.image_edit_common.apply_image_mask_settings(operator, context)
+ self.assertTrue(operator.refine_selection)
+
+ def test_image_line_click_is_cancelled_before_projection(self):
+ import inspect
+
+ modal_source = inspect.getsource(self.image_mask.EditImageMask.modal)
+ projection_source = inspect.getsource(
+ self.image_mask.image_line_half_plane_polygon
+ )
+ self.assertIn("math.dist(self._path[0], self._cursor) < 2.0", modal_source)
+ self.assertIn("if len(line) != 2", projection_source)
+
+ def test_image_polyline_closes_from_start_or_enter(self):
+ import inspect
+
+ modal_source = inspect.getsource(
+ self.image_mask.EditImageMask._modal_polyline
+ )
+ draw_source = inspect.getsource(
+ self.image_mask.EditImageMask._draw_selection
+ )
+ self.assertIn("return self._confirm_polyline(context)", modal_source)
+ self.assertNotIn("DOUBLE_CLICK", modal_source)
+ self.assertIn('event.type in {"RET", "NUMPAD_ENTER"}', modal_source)
+ self.assertIn("_draw_polyline_close_indicator()", draw_source)
+
+ def test_image_edit_first_click_selects_another_image_without_editing(self):
selected = []
def image(name):
@@ -962,8 +1148,8 @@ class AddonTest(unittest.TestCase):
clicked.name: clicked,
}
try:
- source, should_extract = (
- self.image_extract_common.resolve_image_extract_click(
+ source, should_edit = (
+ self.image_edit_common.resolve_image_edit_click(
context, event, lambda *_args: None
)
)
@@ -975,10 +1161,10 @@ class AddonTest(unittest.TestCase):
self.fake_bpy.ops = previous_ops
self.assertIs(source, clicked)
- self.assertFalse(should_extract)
+ self.assertFalse(should_edit)
self.assertEqual(selected, [])
- def test_image_extract_first_click_on_non_image_keeps_active_image(self):
+ def test_image_edit_first_click_on_non_image_keeps_active_image(self):
selection_calls = []
active = SimpleNamespace(
name="Active",
@@ -1009,8 +1195,8 @@ class AddonTest(unittest.TestCase):
)
self.fake_bpy.data.objects = {active.name: active, clicked.name: clicked}
try:
- source, should_extract = (
- self.image_extract_common.resolve_image_extract_click(
+ source, should_edit = (
+ self.image_edit_common.resolve_image_edit_click(
context, event, lambda *_args: None
)
)
@@ -1022,7 +1208,7 @@ class AddonTest(unittest.TestCase):
self.fake_bpy.ops = previous_ops
self.assertIs(source, active)
- self.assertTrue(should_extract)
+ self.assertTrue(should_edit)
self.assertIs(view_objects.active, active)
self.assertEqual(selection_calls, [False, True])
@@ -1045,7 +1231,7 @@ class AddonTest(unittest.TestCase):
atol=1e-6,
)
- def test_image_perspective_crops_alpha_and_applies_padding(self):
+ def test_image_perspective_tightly_crops_alpha(self):
import numpy as np
top_down = np.zeros((4, 4, 4), dtype=np.float32)
@@ -1056,16 +1242,13 @@ class AddonTest(unittest.TestCase):
(4, 4),
((0, 0), (4, 0), (4, 4), (0, 4)),
1.0,
- 1,
)
)
- result = np.flipud(np.asarray(pixels).reshape((4, 4, 4)))
+ result = np.flipud(np.asarray(pixels).reshape((2, 2, 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))
+ self.assertEqual(output_size, (2, 2))
+ self.assertEqual(placement_bounds, (1.0, 1.0, 3.0, 3.0))
+ self.assertTrue(np.all(result[:, :, 3] == 1.0))
def test_image_perspective_rejects_non_convex_corners(self):
with self.assertRaisesRegex(ValueError, "convex"):
@@ -1100,7 +1283,7 @@ class AddonTest(unittest.TestCase):
def test_image_perspective_builds_polyline_mask_fill_triangles(self):
self.assertEqual(
- self.image_perspective.triangle_fan_vertices(
+ self.image_edit_common.triangle_fan_vertices(
((0, 0), (2, 0), (2, 2), (0, 2))
),
(
@@ -1153,7 +1336,7 @@ class AddonTest(unittest.TestCase):
self.assertAlmostEqual(ratio, 2.0)
- def test_image_perspective_maps_padding_to_source_plane_bounds(self):
+ def test_image_perspective_maps_crop_to_source_plane_bounds(self):
bounds = self.image_perspective.perspective_placement_bounds(
((10, 20), (30, 20), (30, 40), (10, 40)),
(100, 50),
@@ -1162,39 +1345,29 @@ class AddonTest(unittest.TestCase):
self.assertEqual(bounds, (8.0, 18.0, 32.0, 42.0))
- def test_image_perspective_tool_uses_four_clicks_and_shared_style(self):
+ def test_image_perspective_tool_uses_four_clicks_and_deform_behavior(self):
import inspect
- operator = self.image_perspective.ExtractImagePerspective
+ operator = self.image_perspective.EditImagePerspective
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.assertEqual(tool.bl_icon, "ops.sculpt.line_project")
self.assertNotIn("bl_cursor", tool.__dict__)
- self.assertIn("draw_settings", tool.__dict__)
+ self.assertNotIn("draw_settings", tool.__dict__)
self.assertNotIn("image_perspective_aspect_mode", settings)
self.assertNotIn("image_perspective_aspect_ratio", settings)
self.assertNotIn("aspect_mode", operator.__annotations__)
self.assertIn("aspect_ratio", operator.__annotations__)
- self.assertIn("remove_background", operator.__annotations__)
- self.assertIn("continuous", operator.__annotations__)
- self.assertIn("padding", operator.__annotations__)
+ self.assertNotIn("refine_selection", operator.__annotations__)
+ self.assertNotIn("continuous", operator.__annotations__)
+ self.assertNotIn("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)
@@ -1208,17 +1381,9 @@ class AddonTest(unittest.TestCase):
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("resolve_image_edit_click(", invoke_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)
@@ -1230,16 +1395,16 @@ class AddonTest(unittest.TestCase):
def test_image_box_builds_a_rectangular_screen_path(self):
self.assertEqual(
- self.image_region.box_screen_path(((2, 3), (8, 9))),
+ self.image_mask.box_screen_path((2, 3), (8, 9)),
((2, 3), (8, 3), (8, 9), (2, 9)),
)
self.assertEqual(
- self.image_region.box_screen_path(((2, 3), (3, 9))),
+ self.image_mask.box_screen_path((2, 3), (3, 9)),
(),
)
- def test_image_extract_selection_uses_white_dashed_segments(self):
- segments = self.image_extract_common.dashed_line_segments(
+ def test_image_edit_selection_uses_white_dashed_segments(self):
+ segments = self.image_edit_common.dashed_line_segments(
((0, 0), (10, 0)), dash_length=3.0, gap_length=2.0
)
@@ -1248,22 +1413,29 @@ class AddonTest(unittest.TestCase):
((0.0, 0.0), (3.0, 0.0), (5.0, 0.0), (8.0, 0.0)),
)
- def test_image_extract_activation_defaults_to_box(self):
+ def test_image_edit_activation_defaults_to_box(self):
import inspect
- source = inspect.getsource(self.image_region.ActivateImageExtractTool.execute)
+ source = inspect.getsource(self.image_mask.ActivateImageEditTool.execute)
self.assertIn('name="anyimage.image_box"', source)
- def test_continuous_keeps_the_source_empty_selected(self):
- import inspect
+ def test_image_line_snaps_and_keeps_the_previewed_half_plane(self):
+ import math
- execute_source = inspect.getsource(self.image_region.ExtractImageRegion.execute)
- response_source = inspect.getsource(
- self.image_region.RemoveImageRegionBackground.response
+ endpoint = self.image_mask.snapped_line_endpoint((0, 0), (10, 1), True)
+ self.assertAlmostEqual(math.degrees(math.atan2(endpoint[1], endpoint[0])), 5.0)
+ preview = self.image_mask.line_half_plane_polygon(
+ (0, 5), (10, 5), (10, 10)
+ )
+ image = self.image_mask.image_line_half_plane_polygon(
+ ((0, 5), (10, 5)), (10, 10)
+ )
+ self.assertEqual(
+ self.image_edit_common.polygon_bounds(preview), (0, 5, 10, 10)
+ )
+ self.assertEqual(
+ self.image_edit_common.polygon_bounds(image), (0, 0, 10, 5)
)
- expected = "select_image_extract_result("
- self.assertIn(expected, execute_source)
- self.assertIn(expected, response_source)
def test_balloon_interior_points_have_uniform_spacing(self):
import numpy as np
@@ -2160,12 +2332,25 @@ class AddonTest(unittest.TestCase):
},
),
(
- self.image_perspective.ImagePerspectiveTool,
+ self.image_line.ImageLineTool,
{
"after": {self.image_lasso.ImageLassoTool.bl_idname},
"separator": False,
},
),
+ (
+ self.image_polyline.ImagePolylineTool,
+ {
+ "after": {self.image_line.ImageLineTool.bl_idname},
+ "separator": False,
+ },
+ ),
+ (
+ self.image_perspective.ImagePerspectiveTool,
+ {
+ "separator": False,
+ },
+ ),
(
self.balloon.BalloonTool,
{
diff --git a/tests/test_models.py b/tests/test_models.py
index a2b07a7..b692c51 100644
--- a/tests/test_models.py
+++ b/tests/test_models.py
@@ -89,6 +89,35 @@ class ModelTest(unittest.TestCase):
"BEN2-ONNX",
)
+ def test_realesrgan_general_x4v3_uses_verified_onnx_artifact(self):
+ model = self.models.get_downloadable_model(
+ "REALESRGAN_GENERAL_X4V3"
+ )
+
+ self.assertEqual(
+ model.repository,
+ "jonathanst29/tinier-upscale-models",
+ )
+ self.assertEqual(model.license_name, "BSD 3-Clause")
+ self.assertEqual(
+ model.download_patterns,
+ ("realesr-general-x4v3.onnx",),
+ )
+ self.assertEqual(model.ready_patterns, model.download_patterns)
+ self.assertEqual(model.r2_directory, "realesr-general-x4v3")
+ self.assertEqual(
+ model.r2_files,
+ ((
+ "realesr-general-x4v3.onnx",
+ 4866396,
+ "924ebad6532777303582d4ce7811849b88869231a3ae7093e0f21200249df8d5",
+ ),),
+ )
+ self.assertEqual(
+ self.models.model_record(model)["family"],
+ "upscale",
+ )
+
def test_enabled_da3_models_use_onnx_and_r2_checksums(self):
for model in self.models.MODELS.values():
if not model.enabled:
diff --git a/tests/test_pack.py b/tests/test_pack.py
index 3b0f41f..1d922cc 100644
--- a/tests/test_pack.py
+++ b/tests/test_pack.py
@@ -68,14 +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_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/image_edit/__init__.py", names)
+ self.assertIn("operators/image_edit/common.py", names)
+ self.assertIn("operators/image_edit/mask.py", names)
+ self.assertIn("operators/image_edit/box.py", names)
+ self.assertIn("operators/image_edit/lasso.py", names)
+ self.assertIn("operators/image_edit/line.py", names)
+ self.assertIn("operators/image_edit/polyline.py", names)
+ self.assertIn("operators/image_edit/perspective.py", names)
self.assertIn("operators/surface.py", names)
self.assertIn(
"assets/O_AnyImage.blend",
@@ -86,8 +86,7 @@ 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_extract.py", names)
- self.assertNotIn("server/jobs/image_lasso.py", names)
+ self.assertIn("server/jobs/image_mask.py", names)
self.assertIn("server/models/ben2.py", names)
self.assertIn("server/models/da3.py", names)
self.assertIn("server/models/moge2.py", names)
@@ -96,6 +95,7 @@ class PackTest(unittest.TestCase):
self.assertIn("server/jobs/debug_da3.py", names)
self.assertIn("server/jobs/debug_moge2.py", names)
self.assertIn("server/jobs/debug_ben2.py", names)
+ self.assertIn("server/jobs/debug_realesrgan.py", names)
self.assertNotIn("server/processing.py", names)
self.assertNotIn("server/progress.py", names)
self.assertIn("model_catalog.py", names)
@@ -103,6 +103,8 @@ class PackTest(unittest.TestCase):
self.assertIn("server/models/onnx_ben2.py", names)
self.assertIn("server/models/onnx_da3.py", names)
self.assertIn("server/models/onnx_moge2.py", names)
+ self.assertIn("server/models/onnx_realesrgan.py", names)
+ self.assertIn("server/models/realesrgan.py", names)
self.assertNotIn("server/da3.py", names)
self.assertNotIn("server/moge2.py", names)
self.assertIn("server/__init__.py", names)
diff --git a/tests/test_realesrgan_onnx.py b/tests/test_realesrgan_onnx.py
new file mode 100644
index 0000000..262dbbf
--- /dev/null
+++ b/tests/test_realesrgan_onnx.py
@@ -0,0 +1,75 @@
+import sys
+import unittest
+from pathlib import Path
+from types import SimpleNamespace
+
+import numpy as np
+from PIL import Image
+
+
+SOURCE_DIR = (
+ Path(__file__).resolve().parents[1]
+ / "src"
+ / "anyimage"
+ / "server"
+ / "models"
+)
+sys.path.insert(0, str(SOURCE_DIR))
+
+import onnx_realesrgan
+
+
+class FakeSession:
+ def get_inputs(self):
+ return [SimpleNamespace(name="input")]
+
+ def get_outputs(self):
+ return [SimpleNamespace(name="output")]
+
+ def run(self, output_names, feeds):
+ self.output_names = output_names
+ self.feeds = feeds
+ source = feeds["input"]
+ return [np.repeat(np.repeat(source, 4, axis=2), 4, axis=3)]
+
+
+class RealesrganOnnxTest(unittest.TestCase):
+ def test_preprocess_matches_dynamic_rgb_layout(self):
+ image = Image.new("RGB", (7, 5), (64, 128, 192))
+
+ result = onnx_realesrgan.preprocess(image)
+
+ self.assertEqual(result.shape, (1, 3, 5, 7))
+ self.assertEqual(result.dtype, np.float32)
+ self.assertTrue(result.flags.c_contiguous)
+ np.testing.assert_allclose(
+ result[0, :, 0, 0],
+ np.array((64, 128, 192), dtype=np.float32) / 255.0,
+ )
+
+ def test_infer_returns_direct_four_x_rgb_prediction(self):
+ session = FakeSession()
+ image = Image.new("RGB", (7, 5), (10, 20, 30))
+
+ result = onnx_realesrgan.infer(session, image)
+
+ self.assertEqual(result.mode, "RGB")
+ self.assertEqual(result.size, (28, 20))
+ self.assertEqual(session.output_names, ["output"])
+ self.assertEqual(session.feeds["input"].shape, (1, 3, 5, 7))
+ self.assertEqual(result.getpixel((0, 0)), (10, 20, 30))
+
+ def test_infer_rejects_non_four_x_output(self):
+ class InvalidSession(FakeSession):
+ def run(self, _output_names, feeds):
+ return [feeds["input"]]
+
+ with self.assertRaisesRegex(RuntimeError, "unexpected output shape"):
+ onnx_realesrgan.infer(
+ InvalidSession(),
+ Image.new("RGB", (7, 5)),
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_server.py b/tests/test_server.py
index d0a738a..771525f 100644
--- a/tests/test_server.py
+++ b/tests/test_server.py
@@ -79,7 +79,14 @@ class ServerTest(unittest.TestCase):
snapshot = manager.snapshot()
self.assertEqual(
snapshot,
- {"loaded": {"ben2": "", "da3": "", "moge2": ""}},
+ {
+ "loaded": {
+ "ben2": "",
+ "da3": "",
+ "moge2": "",
+ "realesrgan": "",
+ }
+ },
)
self.assertIn("server.model_catalog", sys.modules)
self.assertNotIn("model_catalog", sys.modules)
@@ -94,6 +101,8 @@ class ServerTest(unittest.TestCase):
cache.da3_key = None
cache.moge_model = None
cache.moge_key = None
+ cache.realesrgan_model = None
+ cache.realesrgan_key = None
return cache
def create_server(self):
@@ -184,6 +193,25 @@ class ServerTest(unittest.TestCase):
self.assertIsNone(cache.da3_model)
self.assertIsNotNone(cache.ben2_model)
+ def test_realesrgan_model_is_reused_for_matching_runtime_key(self):
+ loads = []
+ realesrgan = ModuleType("server.models.onnx_realesrgan")
+ realesrgan.create_session = lambda model_dir, device: (
+ loads.append((model_dir, device)) or object()
+ )
+ cache = self.create_cache()
+
+ with patch.dict(
+ sys.modules,
+ {"server.models.onnx_realesrgan": realesrgan},
+ ):
+ first = cache.get_realesrgan("model", "cpu")
+ second = cache.get_realesrgan("model", "cpu")
+
+ self.assertIs(first, second)
+ self.assertEqual(loads, [("model", "cpu")])
+ self.assertEqual(cache.snapshot()["realesrgan"], "model")
+
def test_dedicated_server_runs_only_one_job_at_a_time(self):
server = self.server_module.JobServer("Test", storage_root=self.storage_root)
first_started = threading.Event()
@@ -220,7 +248,7 @@ class ServerTest(unittest.TestCase):
"download-model",
"download-required-models",
"analyze-balloon",
- "extract-image-background",
+ "refine-image-mask",
"remove-background",
"extract-frames",
"generate-depth",
@@ -228,6 +256,7 @@ class ServerTest(unittest.TestCase):
"debug-da3",
"debug-moge2",
"debug-ben2",
+ "debug-realesrgan-general-x4v3",
},
)
@@ -401,16 +430,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_extract_canvas_clears_rgba_outside_the_selection(self):
+ def test_image_mask_canvas_clears_rgba_outside_the_selection(self):
import numpy as np
from PIL import Image
- image_extract = sys.modules["server.jobs.image_extract"]
+ image_mask = sys.modules["server.jobs.image_mask"]
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_extract.create_selection_canvas(
+ canvas, _selection, bounds = image_mask.create_selection_canvas(
source_path,
((-1, -1), (2, -1), (-1, 2)),
)
@@ -421,25 +450,22 @@ 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_extract_crops_to_alpha_with_pixel_padding(self):
+ def test_image_mask_crops_tightly_to_alpha(self):
import numpy as np
from PIL import Image
- image_extract = sys.modules["server.jobs.image_extract"]
+ image_mask = sys.modules["server.jobs.image_mask"]
pixels = np.zeros((4, 5, 4), dtype=np.uint8)
pixels[1:3, 2:4] = (20, 40, 60, 255)
- result, bounds = image_extract.crop_to_alpha(
+ result, bounds = image_mask.crop_to_alpha(
Image.fromarray(pixels, mode="RGBA"),
(10, 20, 15, 24),
- padding=1,
)
result = np.asarray(result)
- self.assertEqual(bounds, (11, 20, 15, 24))
- self.assertEqual(result.shape, (4, 4, 4))
- self.assertTrue(np.all(result[[0, -1], :, 3] == 0))
- self.assertTrue(np.all(result[:, [0, -1], 3] == 0))
- self.assertTrue(np.all(result[1:3, 1:3, 3] == 255))
+ self.assertEqual(bounds, (12, 21, 14, 23))
+ self.assertEqual(result.shape, (2, 2, 4))
+ self.assertTrue(np.all(result[:, :, 3] == 255))
def test_balloon_job_sends_the_masked_crop_to_ben2(self):
import numpy as np
diff --git a/tests/test_worker.py b/tests/test_worker.py
index 57ade11..f8d9dfc 100644
--- a/tests/test_worker.py
+++ b/tests/test_worker.py
@@ -115,6 +115,7 @@ class WorkerTest(unittest.TestCase):
cls.ben2 = load_server_module("models.ben2")
cls.da3_model = load_server_module("models.da3")
cls.moge2_model = load_server_module("models.moge2")
+ cls.realesrgan_model = load_server_module("models.realesrgan")
cls.surface_outputs = load_server_module("outputs.surface")
cls.debug_da3_outputs = load_server_module("outputs.debug_da3")
cls.debug_moge2_outputs = load_server_module("outputs.debug_moge2")
@@ -124,6 +125,9 @@ class WorkerTest(unittest.TestCase):
cls.debug_da3_job = load_server_module("jobs.debug_da3")
cls.debug_moge2_job = load_server_module("jobs.debug_moge2")
cls.debug_ben2_job = load_server_module("jobs.debug_ben2")
+ cls.debug_realesrgan_job = load_server_module(
+ "jobs.debug_realesrgan"
+ )
cls.frames_job = load_server_module("jobs.frames")
cls.foreground_job = load_server_module("jobs.foreground")
cls.download = load_server_module("model_download")
@@ -608,13 +612,24 @@ class WorkerTest(unittest.TestCase):
"cpu",
preserve_input_alpha=True,
)
+ raw_result, _load_ms, _inference_ms = self.ben2.infer_foreground(
+ input_path,
+ "model",
+ "cpu",
+ )
inference_pixels = received[0]
result_pixels = np.asarray(result.convert("RGBA"), dtype=np.uint8)
self.assertEqual(tuple(inference_pixels[0, 0]), (0, 0, 0))
self.assertEqual(tuple(inference_pixels[0, 1]), (0, 128, 0))
+ self.assertEqual(tuple(received[1][0, 0]), (0, 0, 0))
+ self.assertEqual(tuple(received[1][0, 1]), (0, 128, 0))
self.assertEqual(tuple(result_pixels[0, 0]), (255, 0, 0, 0))
self.assertEqual(tuple(result_pixels[0, 1]), (0, 255, 0, 64))
+ self.assertEqual(
+ tuple(np.asarray(raw_result.convert("RGBA"))[0, 0]),
+ (10, 20, 30, 128),
+ )
def test_ben2_background_removal_processes_each_frame(self):
inference_options = []
@@ -874,6 +889,39 @@ class WorkerTest(unittest.TestCase):
(context.directory / "foreground.png").read_bytes(),
b"foreground",
)
+
+ def test_realesrgan_debug_saves_each_direct_api_prediction(self):
+ from PIL import Image
+
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ frames = root / "frames"
+ frames.mkdir()
+ (frames / "a.png").touch()
+ (frames / "b.png").touch()
+ context = FakeJobContext(root / "result", "debug-realesrgan")
+ with patch.object(
+ self.debug_realesrgan_job,
+ "infer_one",
+ return_value=Image.new("RGB", (8, 8), (10, 20, 30)),
+ ) as infer_one:
+ self.debug_realesrgan_job.run(
+ context,
+ {
+ "input": str(frames),
+ "model": "REALESRGAN_GENERAL_X4V3",
+ "video_frames": 2,
+ "device": "cpu",
+ },
+ )
+
+ self.assertEqual(infer_one.call_count, 2)
+ predictions = sorted(context.directory.glob("prediction.*.png"))
+ self.assertEqual(len(predictions), 2)
+ with Image.open(predictions[0]) as prediction:
+ self.assertEqual(prediction.size, (8, 8))
+ self.assertEqual(prediction.mode, "RGB")
+
def test_da3_removes_background_after_depth_using_original_input(self):
order = []
inference_inputs = []