Add tests for clipboard image functionality and mesh plane asset creation

- Implemented a comprehensive test suite for the clipboard image module, covering various functionalities including operator registration, context handling, and image processing.
- Created a new mesh plane asset with customizable parameters and integrated it into the geometry node system.
- Developed a validation script to ensure the correct behavior of the mesh plane asset, including input handling and output verification.
This commit is contained in:
2026-08-22 09:49:00 +08:00
parent af5603045c
commit 988332548e
49 changed files with 4419 additions and 291 deletions

View File

@@ -8,12 +8,15 @@ 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 Empty 转换为立体面片:让 AI 估算深度,或直接用原图作为深度
- 把单张 Image Empty 转换为透视纹理网格,并自动匹配原对象的 Transform 与 Offset
- 一键去除图片背景,生成透明前景图
- 支持视频与序列帧输入,逐帧处理,动画也能得到逐帧对应的立体面片
- 全部操作都在右键菜单里完成,不需要写任何代码
- 主要转换操作集中在右键菜单,剪贴板图片可直接用系统粘贴快捷键导入
## 安装
@@ -22,22 +25,26 @@ AnyImage 是一个 Blender 扩展,可以把一张照片、一段视频或一
3. 点击 `Install from Disk`,选择下载的压缩包
4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机
最快的 DirectML provider;Apple M4 或更新芯片可选 MPS/CoreML,其他 Mac 只提供 CPU
5. 打开 3D 视图右侧的 `AnyImage` 面板:使用官方源点击 `Install Environment`,使用国内镜像点击 `Install Environment (Mirror)`
5. 选中 Image Empty,在右键 `AnyImage` 子菜单中点击 `Set Up AI Environment…`;也可以从扩展偏好设置进入
扩展不内置 uv 或 AI 运行库。首次使用会先下载并校验固定版本的 uv,随后自动安装轻量的 ONNX Runtime 环境,再下载 `MoGe-2 ViT-S Normal` 和
`BEN2 Base`;不安装 PyTorch,也不下载各模型的 Python 源码包。其他 AI 模型可在
`Models` panel 中按需下载,请保持网络畅通,首次安装需要一些时间。
Environment Bootstrap 使用连续的 0–100%:uv 为 1–20%、Python 为 20–50%、packages 为 50–98%,最终验证为 98–100%。三个阶段显示真实的 `1/3`、`2/3`、`3/3`,下载占各阶段的 84–87%;开始与最终验证不显示编号,单阶段状态不显示 `1/1`。官方按钮固定使用 GitHub、uv 默认 Python 下载源和 PyPI;镜像按钮固定使用 CNB uv Release、CNB Python 解释器镜像和清华 PyPI,两种方式都不竞速或自动切换。MoGe-2 与 BEN2 属于 post-install,各自使用独立进度,不计入 Environment 的这轮 0–100%,其模型下载源逻辑不受按钮选择影响。
任一必需模型在两个来源都下载失败时,`Install Environment` 会明确报错;Environment 本身仍会保留。
选中 Image Empty 后,右键 `AnyImage` 子菜单会在缺少 `MoGe-2 ViT-S Normal` 或 `BEN2 Base`
时显示 `Download Required Models`,一次补齐缺失项,不会重复下载已经就绪的模型。
Environment 未安装时,同一位置显示两个安装按钮。Environment 或必需模型未就绪时,
菜单仍显示 `Convert to Depth Plane`、`Convert to Surface` 和 `Remove Background`,但三个入口都会保持禁用。
`Balloon Mesh (Lasso)` 默认是 Blender 本地几何功能,在上述状态下仍可使用;开启 `Contour` 或 `Depth` 后,才需要 Environment 与对应的 BEN2 或 MoGe-2 模型。
这些 UI 状态直接检查 Storage Root 的 `models/` 文件,不会仅因打开菜单或面板而启动 Job Server。
扩展不内置 uv 或 AI 运行库。`Set Up AI Environment…` 会先说明下载内容、约 600 MB 的下载量、约 1 GB 的磁盘占用和预计等待时间,并可勾选 `Use Mirror`。确认后会下载并校验固定版本的 uv,自动安装轻量的 ONNX Runtime 环境,再下载 `MoGe-2 ViT-S Normal` 和 `BEN2 Base`;不安装 PyTorch,也不下载各模型的 Python 源码包。`Use Mirror` 只切换 uv、Python 和 Python 包的下载源,模型仍使用自己的下载源与备用镜像规则。
Environment Bootstrap 使用连续的 0–100%:uv 为 1–20%、Python 为 20–50%、packages 为 50–98%,最终验证为 98–100%。三个阶段显示真实的 `1/3`、`2/3`、`3/3`,下载占各阶段的 84–87%;开始与最终验证不显示编号,单阶段状态不显示 `1/1`。MoGe-2 与 BEN2 属于 post-install,各自使用独立进度,不计入 Environment 的这轮 0–100%。任一必需模型在两个来源都下载失败时,安装任务会明确报错;Environment 本身仍会保留。
选中 Image Empty 后,右键 `AnyImage` 子菜单依次显示 Balloon、三个 AI 功能和环境入口。Environment 或必需模型未就绪时,点击 `Convert to Depth Plane…`、`Convert to Surface…` 或 `Remove Background` 会打开对应准备对话框:Environment 未安装时入口显示 `Set Up AI Environment…`,安装 Environment 与默认模型;Environment 已安装但默认模型缺失时入口显示 `Download Required Models…`,只补齐缺失模型。全部就绪后,菜单入口改为 `AI Environment Settings…`,并打开 AnyImage 扩展偏好设置。
其他 AI 模型统一在 AnyImage 扩展偏好设置中按需下载。`Balloon Lasso Tool` 的本地几何功能始终可用;AI 未就绪时,只禁用 Tool Settings 中的 `Contour`、`Depth` 与 Depth Model,并在同处根据状态显示 `Set Up AI Environment…` 或 `Download Required Models…`。共享的 AI 状态检测直接检查 Environment 和 Storage Root 的 `models/` 文件,不会仅因打开菜单或设置而启动 Job Server。
## 快速上手
### 粘贴剪贴板图片
复制图片后,在 3D View、Shader Editor、Compositor 或 Geometry Nodes 中按 `Ctrl+V`;macOS 使用 `Cmd+V`。AnyImage 会根据当前上下文创建 Reference Image、带材质的 Plane、图片节点或笔刷 Texture,并将图片 Pack 到当前 `.blend`。完整说明见[粘贴剪贴板图片](docs/clipboard-image.md)。
### 转换为普通图片平面
选中 Image Empty 后,右键选择 `AnyImage > Convert to Mesh Plane`。该操作不运行 AI,会创建带图片材质和 `O Mesh Plane` Geometry Nodes 修改器的基础四边形。`Thickness` 与 `Subdivide` 都在修改器中实时生成;`Subdivide` 使用二分级别(`3` 为短边 `8` 段),`Thickness = 0` 时结果为真正的单面。操作会立即执行;创建后可在 Last Operation 中调整 `Shadeless` 和 `Keep Original`,调整时保持源图片的世界位置、旋转与缩放。完整说明见 [Mesh Plane](docs/mesh-plane.md)。
### 转换为立体面片
1. 在 3D 视图中选中一个 Image Empty(空物体,显示方式为 Image)
@@ -50,9 +57,15 @@ Environment 未安装时,同一位置显示两个安装按钮。Environment
- `Keep Original` 默认关闭,位于分割线下方;开启后保留源 Image Empty
4. 点击确认,稍等片刻就得到带位移的立体面片。`O DA3 Depth Plane` 与 `O MoGe2 Depth Plane` 都使用 `.color` 的 `Alpha > 0.5` 划分前景范围,范围外由 `Background Depth` 控制。
### 套索提取图片
选中静态图片的 Image Empty,在 3D Viewport 左侧工具栏选择 Image Box / Lasso 工具组。默认的 Image Box 拖出矩形区域;长按工具按钮可切换 Image Lasso 并绘制自由轮廓。两者使用默认鼠标光标,并以类似 Select Tool 的白色虚线显示选区,分别采用 Sculpt Mode 的 Box Mask 与 Lasso Mask 图标,共用相同设置。该工具组与 Balloon Lasso 相邻,并通过一个间隔与前方的 Add Cube 分开。松开左键后会把同一个 Empty 原位更新为新图片并 Pack 到 .blend。Image Box / Lasso 与 Balloon Lasso 都只放在工具栏,不出现在右键 AnyImage 子菜单中。
区域外的 RGBA 会统一设为透明黑,避免后续只读取 RGB 的图像处理重新识别透明区内容。输出画布取最终 Alpha 大于 0 的最小 bounding box,再按 Padding 向四周增加指定的透明像素,默认 0 px。对象原点会移动到新画布中心,显示尺寸与图片比例同步更新,裁切后的像素仍与原图在世界空间重合。Keep Original 默认关闭;开启后保留源 Empty,并创建承载裁切图的副本。Contour 默认关闭;开启后 BEN2 会在 lasso 内进一步细化 Alpha,再计算最终 bounding box。Contour 仅支持静态图片,并需要 AI Environment 与 BEN2。
### 套索生成 Balloon
选中单张图片的 Image Empty,右键打开 `AnyImage` 子菜单并选择 `Balloon Lasso Tool`。Blender 会激活 3D Viewport 左侧工具栏底部、紧邻 Add Cube、且不增加分隔空隙的独立 Balloon Lasso,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 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。
按住左键沿目标轮廓绘制,松开后生成一个带原图 UV 的封闭网格。每次拖动都是一个独立完成的顶层 `Extract Balloon` Operator,工具本身继续保持激活,下一次拖动会创建另一个 Balloon;因此 `Ctrl+Z` 只撤销最近一次生成,`Ctrl+Shift+Z` 重做。每次生成后源 Image Empty 都保持选中和 Active。未绘制时没有持续 Modal 拦截事件,视图导航和其他原生快捷键由 Blender 正常处理;绘制中可用右键或 `Esc` 取消当前轨迹。选择左侧工具栏中的其他工具即可离开 Balloon Lasso。
@@ -113,10 +126,9 @@ Session 逐帧推理,显存峰值不随视频帧数平方增长。Debug 只保
## 常见问题
- 从 0.12.0–0.12.5 升级到 0.12.6 或更高版本后,请重新执行一次 `Install Environment`,重建不再单独安装 BlendJob 的 Server Environment
- 首次使用需要下载模型,如果下载慢或失败,可在 Image Empty 的 `AnyImage` 右键菜单点击
`Download Required Models`,也可在 `Models` panel 里逐项重试
- 安装 Environment 时下载状态会显示平均速度;网络适合海外源时选择 `Install Environment`,国内网络选择 `Install Environment (Mirror)`。每个按钮固定使用对应来源,失败时不会自动切换
- 从 0.12.0–0.12.5 升级到 0.12.6 或更高版本后,请重新执行一次 `Set Up AI Environment…`,重建不再单独安装 BlendJob 的 Server Environment
- 首次使用需要下载模型;Environment 已安装但默认模型下载慢或失败时,点击 `Download Required Models…` 可补齐缺失项,也可在 AnyImage 扩展偏好设置中逐项重试
- 安装 Environment 时下载状态会显示平均速度;网络适合国内镜像时在确认对话框勾选 `Use Mirror`,否则保持关闭。选择的来源固定用于 uv、Python 和 Python 包,失败时不会自动切换
- 默认模型下载会在 Hugging Face 与 OmooLab 镜像之间自动回退;某一来源暂时断线不会阻止后续模型尝试另一来源
- 模型直链建连超过 10 秒,或连接后连续 60 秒没有收到数据,会切换到下一来源;不限制总下载时长
- 找不到右键入口时,请确认选中了 Empty,且显示方式为 Image

43
docs/clipboard-image.md Normal file
View File

@@ -0,0 +1,43 @@
# 粘贴剪贴板图片
复制图片后,直接在 Blender 当前编辑器按 `CTRL + V`;macOS 使用 `CMD + V`。AnyImage 会根据上下文决定如何使用图片,并将它 Pack 到当前 `.blend`。
## 支持的上下文
| 上下文 | 结果 |
| --- | --- |
| 3D View / Object Mode | 创建 Reference Image 或带材质的 Plane |
| Sculpt | 设置为笔刷 Texture |
| Vertex Paint | 设置为笔刷 Texture |
| Texture Paint | 设置为笔刷 Texture |
| Shader Editor | 创建 Image Texture 节点 |
| Compositor | 创建 Image 节点 |
| Geometry Nodes | 创建 Image Texture 节点 |
## 在 3D View 中
图片会放在鼠标所在的屏幕位置,并使用当前 3D Cursor 作为视图深度参考。默认导入为 `Reference Image`。
如果在操作面板中切换为 `Plane`,会创建基础四边形和 `O Mesh Plane` Geometry Nodes 修改器。操作面板只保留:
- **Import As**:在 Reference Image 与 Plane 之间切换
- **Shadeless**:让图片不受场景灯光影响
`Thickness` 与 `Subdivide` 直接在 Geometry Nodes 修改器中调整,不作为粘贴参数。
## 在 Node Editor 中
节点创建在鼠标位置。不同节点编辑器会选择对应的图片节点类型,不需要先保存临时图片再手动浏览文件。
## 与 Blender 原生粘贴共存
如果刚刚使用 `CTRL + C` 复制了 Blender 内部数据,随后按 `CTRL + V` 时会优先让 Blender 处理原生粘贴。只有剪贴板中存在新的图片数据时,图片粘贴功能才接管操作。
## 平台说明
- Windows:直接支持
- macOS:使用 `CMD + V`
- Linux Wayland:需要 `wl-clipboard`
- Linux X11:需要 `xclip`
剪贴板中没有可用图片时,操作会放行,不阻断 Blender 的其他粘贴行为。

View File

@@ -29,7 +29,7 @@ Server 为每帧准备颜色、深度和可选前景 Alpha。Blender 导入这
| MoGe-2 | Metric depth | 法线、Mask、相机内参 | 更稳定的尺度与表面法线 |
| Original | 原图亮度 | 可选 BEN2 Alpha | 已准备好高度图的素材 |
DA3 和 MoGe-2 都以未经抠图的 RGB 估计深度。启用 Remove Background 后,BEN2 Alpha 用于颜色透明度和前景 Mask,深度本身保持模型输出。
DA3 和 MoGe-2 都以未经抠图的 RGB 估计深度。启用 Remove Background 后,BEN2 只分析输入 Alpha 内的可见 RGB,颜色 Alpha 使用“输入 Alpha × BEN2 Alpha”,并作为前景 Mask;深度本身保持模型输出。
视频与序列帧逐帧推理并复用同一个模型 Session,因此显存主要由单帧分辨率决定。DA3 Debug 的多帧联合推理属于另一条工作流。

View File

@@ -21,6 +21,7 @@ Blender 交互层管理 UI、RNA 状态、任务参数和结果导入。Runtime
| 位置 | 内容 |
| --- | --- |
| `__init__.py` | Blender 类型、菜单与 Runtime 注册 |
| `clipboard_image/` | 剪贴板读取、上下文粘贴与快捷键 |
| `menu.py`、`panel.py` | 用户入口 |
| `preferences.py`、`properties.py` | 扩展配置与 Scene 状态 |
| `runtime.py` | JobRuntime 配置 |
@@ -32,10 +33,12 @@ Blender 交互层管理 UI、RNA 状态、任务参数和结果导入。Runtime
| `server/media/` | 媒体输入 |
| `assets/O_AnyImage.blend` | Geometry Nodes 与材质资产 |
Depth Plane、Surface、Background 和 Balloon 按功能组织。一个功能的入口、请求和 Blender 回写放在一起;跨功能的图片、材质和参数处理放在 `operators/common.py`、`operators/image.py` 与 `operators/material.py`。
Mesh Plane、Depth Plane、Surface、Background 和 Balloon 按功能组织。一个功能的入口、请求和 Blender 回写放在一起;跨功能的图片、材质和参数处理放在 `operators/common.py`、`operators/image.py` 与 `operators/material.py`。
Server 的模型模块返回 Prediction,几何模块处理数组与网格,输出模块写文件。Blender 数据只在 Operator Response 的主线程阶段创建。
Image Lasso 的基础像素裁切在 Blender 主线程完成;启用 Contour 时,Server Job 计算 BEN2 Alpha,并按最终非零 Alpha 范围与像素 Padding 输出图片,Operator Response 负责 Pack 新图、可选复制源 Empty 并更新结果对象的空间范围。
## 项目结构
```text
@@ -51,6 +54,7 @@ Server 的模型模块返回 Prediction,几何模块处理数组与网格,
│ ├── panel.py
│ ├── preferences.py
│ ├── properties.py
│ ├── clipboard_image/
│ ├── operators/
│ ├── server/
│ ├── assets/
@@ -105,6 +109,7 @@ BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe"
"$BLENDER_BIN" --background --python tools/build_geometry_nodes_asset.py
"$BLENDER_BIN" --background --python tools/validate_depth_plane_asset.py
"$BLENDER_BIN" --background --python tools/validate_mesh_plane_asset.py
"$BLENDER_BIN" --background --python tools/validate_geometry_nodes_asset.py
"$BLENDER_BIN" --background --python tools/validate_balloon_asset.py
"$BLENDER_BIN" --background --python tools/validate_moge_depth_plane.py

View File

@@ -8,21 +8,20 @@ AnyImage 菜单只会在选中 Image Empty 时出现。确认对象类型是 Emp
通常有以下原因:
- Environment 尚未安装。
- 默认必需模型尚未就绪。
- 另一个 AnyImage 任务正在运行。
- Surface 的输入不是单张图片。
- 当前对象不是 Image Empty。
如果子菜单显示 **Install Environment** 或 **Download Required Models**,先完成对应操作。
Environment 或默认模型缺失不会再让三个 AI 功能变灰;点击功能会根据状态打开 **Set Up AI Environment…** 或 **Download Required Models…**。Balloon 的 **Contour**、**Depth** 与 Depth Model 会在 AI 未就绪时单独禁用,同一位置提供安装按钮。
## Environment 安装失败
- 海外源访问不稳定时尝试 **Install Environment (Mirror)**。
- 海外源访问不稳定时重新打开 **Set Up AI Environment…**,勾选 **Use Mirror**。
- 检查 Blender 的 **Online Access** 是否开启。
- 确认 Storage Root 有足够空间且可写。
- 确认 Storage Root 至少有约 1 GB 可用空间且可写。
- 查看 Storage Root 下的 `install.log`。
安装模型失败不会删除已经成功创建的 Environment。可以稍后使用 **Download Required Models** 或 Models 面板重试。
安装模型失败不会删除已经成功创建的 Environment。可以稍后使用 **Download Required Models…** 补齐默认模型,或在 AnyImage 扩展偏好设置中逐项重试。
## 模型下载很慢或失败
@@ -54,4 +53,4 @@ AnyImage 菜单只会在选中 Image Empty 时出现。确认对象类型是 Emp
## 从旧版本升级后功能不可用
如果 Environment 声明或依赖发生变化,请重新执行一次 **Install Environment**。从 0.12.0–0.12.5 升级到 0.12.6 或更高版本时需要重建 Environment。
如果 Environment 声明或依赖发生变化,请重新执行一次 **Set Up AI Environment…**。从 0.12.0–0.12.5 升级到 0.12.6 或更高版本时需要重建 Environment。

View File

@@ -6,9 +6,12 @@
| 工作流 | 适合 | 输入 | 结果 |
| --- | --- | --- | --- |
| Clipboard Image | 无需先另存即可使用系统剪贴板图片 | 剪贴板中的静态图片 | Reference Image、Plane、图片节点或笔刷 Texture |
| Mesh Plane | 不需要深度的图片卡片与背景板 | 单图、视频、序列帧 | 带图片材质和 Geometry Nodes 的普通平面 |
| Depth Plane | 快速制作可调节的立体图片 | 单图、视频、序列帧 | 带颜色、深度与 Geometry Nodes 的面片 |
| Surface | 从单张图片恢复透视空间 | 单图 | 相机空间网格、材质与可选匹配相机 |
| Remove Background | 提取透明前景 | 单图、视频、序列帧 | 透明图片或透明序列 |
| Image Lasso | 手绘提取并原位更新图片 | 静态 Image Empty | Pack 的透明裁切图 |
| Balloon | 沿轮廓绘制鼓起造型 | 静态 Image Empty | 带原图 UV 的封闭网格 |
| Debug | 检查模型原始输出 | 模型支持的图片或帧序列 | 保存在 Job 目录中的原始 Prediction |
@@ -16,11 +19,19 @@ Depth Plane 与 Surface 可使用 DA3 或 MoGe-2。DA3 提供相对深度和相
## Blender 交互
主要功能位于 Image Empty 的 `AnyImage` 右键菜单。菜单会显示 Environment 和必需模型的就绪状态,并在缺少内容时提供安装或下载入口。
复制图片后,在支持的编辑器中按 `Ctrl+V`(macOS 为 `Cmd+V`)即可粘贴;图片会 Pack 到当前 `.blend`。3D View 的 Object Mode 默认创建 Reference Image,也可在 Redo 面板切换为 Plane;Shader Editor、Compositor 与 Geometry Nodes 创建对应图片节点,Sculpt、Vertex Paint 与 Texture Paint 则设置笔刷 Texture。详见[粘贴剪贴板图片](clipboard-image.md)。
主要功能位于 Image Empty 的 `AnyImage` 右键菜单。
`Convert to Mesh Plane` 位于 `Convert to Depth Plane` 上方,不需要 AI Environment。它生成基础四边形,并由 `O Mesh Plane` 修改器实时处理 `Thickness` 与 `Subdivide`;`Thickness` 为 `0` 时保持单面。操作会立即执行;创建后可在 Last Operation 中调整 `Shadeless` 与 `Keep Original`。
Balloon 作为不依赖 AI 的基础工具单独排列;三个 AI 功能在缺少 Environment 或默认模型时仍可点击:缺 Environment 时转入 `Set Up AI Environment…`,只缺默认模型时转入 `Download Required Models…`。全部就绪后,菜单底部显示 `AI Environment Settings…`,用于打开 AnyImage 扩展偏好设置。
转换任务完成后,AnyImage 在一个 Undo 步骤中创建网格、图片、材质和修改器。源 Image Empty 是否保留由转换选项决定。
Balloon 默认在 Blender 内部生成,无需 Environment 或模型。Tool Settings 提供模式、厚度、采样距离、材质复用、`Contour`、`Depth` 与 MoGe-2 `Depth Model`。Depth 会把物理距离相对化并稳健拟合初始基面,保留未截断的正负起伏。Base Plane 子 Empty 的旋转定义基面朝向,沿自身局部 Z 移动定义 middle level。
Image Box 与 Image Lasso 合并为一个工具组,分别使用 Sculpt Mode 的 Box Mask 与 Lasso Mask 图标;默认显示 Box,长按按钮可切换 Lasso。绘制时使用默认鼠标光标,并以类似 Select Tool 的白色虚线显示选区。该工具组与 Balloon Lasso 相邻,并通过一个工具栏间隔与前方的 Add Cube 分开;两组工具只位于 3D Viewport 左侧工具栏,不显示在右键 AnyImage 子菜单。它们将选区外 RGBA 清为透明黑,再以最终 Alpha 大于 0 的最小 bounding box 作为输出画布;Padding 使用整数像素向四周扩展透明边距,默认 0 px。结果会 Pack 到 .blend,同一个 Empty 的原点移动到新画布中心,显示尺寸同步调整,使裁切内容在世界空间保持重合。Keep Original 默认关闭;开启后保留源 Empty 的 Active 与选择状态,并创建未选中的裁切副本。可选 Contour 使用 BEN2 的软 Alpha 进一步收紧选区,并在分析后计算最终画布范围。
Balloon 默认在 Blender 内部生成,无需 Environment 或模型。Tool Settings 提供模式、厚度、采样距离、材质复用、`Contour`、`Depth` 与 MoGe-2 `Depth Model`;AI 未就绪时只禁用这三个控件,并在 Tool Settings 中显示安装按钮。Depth 会把物理距离相对化并稳健拟合初始基面,保留未截断的正负起伏。Base Plane 子 Empty 的旋转定义基面朝向,沿自身局部 Z 移动定义 middle level。
`O Balloon Shape` 计算 `修正厚度 = abs(原 X) × Thickness`,并将修正深度校准为 `(0.5 × 相对基面深度 + 原始 Balloon 最大半厚度) × Depth Strength`。固定的原始半厚度不乘 `Thickness`,因此 Thickness 与 Depth Strength 分别只控制各自分支。节点以归一化原 X 距离经 Smoother Step 得到中心程度,再插值厚度与深度。`Double Sided Depth` 默认关闭,背面保持 Balloon 厚度。MoGe normal 会输出为原图尺寸的 tangent-space 纹理,并通过 `O Balloon Layer` 的 `Normal Strength`(默认 `0.5`)补偿网格无法表达的细节。
@@ -48,6 +59,6 @@ AI 任务在独立 Python Environment 中通过 ONNX Runtime 执行。Blender
- DA3 Small、DA3 Base
- BEN2 Base
首次安装会准备 MoGe-2 ViT-S Normal 与 BEN2 Base,其他模型可从 Models 面板按需下载。
首次安装会准备 MoGe-2 ViT-S Normal 与 BEN2 Base,其他模型可从 AnyImage 扩展偏好设置按需下载。
Debug 工作流保存模型 API 的直接返回,适合验证模型、设备与输入。Depth Plane 和 Surface 则在模型结果之上继续生成 Blender 可用的贴图与几何。文件字段见[输出格式](outputs.md)。

View File

@@ -16,7 +16,7 @@ Surface 从单张图片构建带透视关系的纹理网格。它适合建筑、
默认的 **MoGe-2 ViT-S Normal** 适合多数图片。MoGe-2 使用 **Resolution Level**,DA3 使用 **Resolution Scale**。提高分辨率会增加耗时和内存占用,建议先用默认值判断整体结构。
如果某个模型名称后显示 **(Missing)**,请先在 3D View 的 **AnyImage > Models** 面板下载它。
如果某个模型名称后显示 **(Missing)**,请在 AnyImage 扩展偏好设置中下载它。右键菜单底部的 **AI Environment Settings…** 可以直接打开对应设置。
## 转换选项

View File

@@ -4,6 +4,8 @@
AnyImage 是一款 Blender 扩展,可以把照片、视频或序列帧转换为带真实深度变化的立体面片。你只需导入图片、选中 Image Empty,然后从右键菜单选择需要的结果。
- 按 **Ctrl+V**(macOS 为 **Cmd+V**)把剪贴板图片直接粘贴到当前编辑器
- 用 **Mesh Plane** 无需 AI 地创建可调厚度和细分的图片网格
- 用 **Depth Plane** 快速得到可调节的浮雕式面片
- 用 **Surface** 从单张图片生成透视纹理网格
- 用 **Remove Background** 一键获得透明前景
@@ -48,7 +50,7 @@ AnyImage 是一款 Blender 扩展,可以把照片、视频或序列帧转换
- Blender 4.5 或更高版本
- Windows x64、macOS Apple Silicon 或 Linux x64
- 首次安装环境与模型时需要网络连接
- 正式使用前需要安装独立 Environment,以及默认的 MoGe-2 和 BEN2 模型
- Depth Plane、Surface、Remove Background 与 Balloon 的 AI 增强需要独立 Environment,以及默认的 MoGe-2 和 BEN2 模型;Balloon 基础功能无需安装
!!! tip
不确定从哪里开始时,先使用默认设置将一张人物或物体照片转换为 Depth Plane。它速度快、参数直观,也最适合熟悉 AnyImage 的基本工作方式。

View File

@@ -24,29 +24,30 @@ AnyImage 的安装分为两步:先安装 Blender Extension,再安装独立
如果准备把 Storage Root 放到其他磁盘,建议在首次安装 Environment 之前修改。更换目录后,旧目录中的环境和模型不会自动移动。
## 安装 Environment
## 配置 AI Environment
打开 3D View,按 `N` 显示侧栏并切换到 **AnyImage** 页签。在 **Job Server** 面板中选择一个安装入口:
选中 Image Empty,在右键 **AnyImage** 子菜单中点击 **Set Up AI Environment…**;也可以在 AnyImage 扩展偏好设置中点击同名按钮。
- **Install Environment**:使用官方源,适合能稳定访问 GitHub、Python 下载源和 PyPI 的网络。
- **Install Environment (Mirror)**:使用国内镜像下载 uv、Python 和 Python 包。
确认安装前,对话框会说明以下内容:
安装程序会依次准备 uv、独立 Python 和运行包。Environment 完成后,AnyImage 还会下载日常功能需要的 **MoGe-2 ViT-S Normal** 与 **BEN2 Base**。
- 安装独立 Python、ONNX Runtime 与支持包,并下载 **MoGe-2 ViT-S Normal** 和 **BEN2 Base**。
- 默认模型合计约 360 MB;连同 Environment 预计需要约 600 MB 下载量和约 1 GB 磁盘空间。
- 根据网络速度,首次安装通常需要 5–20 分钟。
- **Use Mirror** 只让 uv、Python 和 Python 包使用国内镜像;模型下载源保持不变。
!!! note
两个安装按钮只影响 Environment 的下载源。模型会按自己的下载源与备用镜像规则获取。
安装过程中请保持 Blender 打开。状态栏会显示当前阶段、进度和下载速度。
安装过程中请保持 Blender 打开。状态栏会显示当前阶段、进度和下载速度。首次安装耗时取决于网络与磁盘速度。
如果 Environment 已安装但任一默认模型缺失,入口会改为 **Download Required Models…**;对话框列出缺失模型和下载体积,确认后只下载缺失模型,不会重装 Environment 或重复下载已就绪内容。
## 确认安装完成
完成后可以用以下方式确认:
- **Job Server** 不再显示 “Environment is not installed”。
- 展开 **Models** 面板,MoGe-2 ViT-S Normal 和 BEN2 Base 显示 **Ready**。
- 选中 Image Empty 后,右键菜单中的 **Convert to Depth Plane**、**Convert to Surface** 和 **Remove Background** 可以点击。
- AnyImage 扩展偏好设置中的 MoGe-2 ViT-S Normal 和 BEN2 Base 显示 **Ready**。
- 选中 Image Empty 后,右键菜单底部显示 **AI Environment Settings…**。
如果 Environment 已安装但默认模型缺失,Image Empty 的 **AnyImage** 右键子菜单会显示 **Download Required Models**。点击一次即可补齐缺失项。
**Convert to Depth Plane…**、**Convert to Surface…** 和 **Remove Background** 在 AI 未就绪时仍可点击,点击后会根据状态转入 **Set Up AI Environment…** 或 **Download Required Models…**。Balloon 的本地套索与几何生成功能不要求安装 AI Environment;未就绪时,Tool Settings 只会禁用 **Contour**、**Depth** 与 Depth Model,并显示安装按钮。
## 下一步

24
docs/mesh-plane.md Normal file
View File

@@ -0,0 +1,24 @@
# Mesh Plane
Mesh Plane 把 Image Empty 转换为带图片材质的普通网格平面,不运行 AI,也不生成深度置换。它适合广告牌、贴图卡片、背景板,以及只需要图片比例和透明材质的场景。
## 创建 Mesh Plane
1. 在 3D View 中选中 Image Empty。
2. 右键选择 **AnyImage > Convert to Mesh Plane**,结果会立即创建,不弹出对话框。
3. 创建后在 **Last Operation** 中按需调整 **Shadeless** 与 **Keep Original**;重执行会保持源图片的世界位置、旋转与缩放。
结果会继承源对象的 Transform、图片显示尺寸、比例和 Offset。转换不需要 AI Environment 或模型。
## Geometry Nodes
结果使用 `O Mesh Plane` Geometry Nodes 修改器:
- **Thickness**:沿平面法线增加居中厚度;设为 `0` 时输出真正的单面。
- **Subdivide**:使用二分级别控制图片短边段数,即 `2^Subdivide`;例如 `3` 会把短边分为 `8` 段。长边按图片比例增加分段,厚度方向也会按表面单元尺寸增加分层,尽量保持接近方形的网格。
原始 Mesh 数据始终只有一个四边形;厚度和细分由修改器实时生成,因此可以随时调整,不需要重新转换。
## 与 Ctrl+V Plane 一致
在 3D View 中用 `Ctrl+V`(macOS 为 `Cmd+V`)粘贴剪贴板图片并选择 **Plane** 时,也会创建同样的基础四边形和 `O Mesh Plane` 修改器。Redo 面板只保留 **Import As** 与 **Shadeless**;**Thickness** 和 **Subdivide** 直接在 `O Mesh Plane` 修改器中调整。

View File

@@ -2,9 +2,9 @@
AnyImage 把运行环境、模型和任务结果放在 Blender 之外,多个 Blender 版本可以共享同一套数据。
## Models 面板
## Models 设置
打开 3D View 侧栏中的 **AnyImage > Models**,可以查看和下载模型。
打开 **Edit > Preferences > Extensions > AnyImage**,可以查看和下载模型。右键菜单中的 **AI Environment Settings…** 会直接打开这里。
- **Ready** 表示模型文件已经就绪。
- **Download** 用于按需下载模型。

View File

@@ -34,3 +34,5 @@ Depth Plane 和 Surface 的转换对话框也提供 **Remove Background**:
### 透明边缘需要调整
当前正式入口不提供 BEN2 refinement 参数。可以在结果材质中继续处理 Alpha,或在外部图像软件中修整生成的颜色图。
输入图片已有 Alpha 时,AnyImage 会先用该 Alpha 遮住 BEN2 推理输入中的透明 RGB,再将结果 Alpha 计算为“输入 Alpha × BEN2 Alpha”。因此 Image Box / Lasso 或外部工具创建的透明范围不会被去背景重新扩张;软边缘也会继续保留。

View File

@@ -67,7 +67,7 @@ Balloon 的 `analyze-balloon` Job 会裁切套索输入并清零选区外 RGBA
首次安装会下载 MoGe-2 ViT-S Normal 与 BEN2 Base。模型保存在 `models/`,可由多个 Blender 版本共享。Server 缓存 BEN2 和最近使用的一个深度模型,以控制内存占用。
模型下载优先使用 Hugging Face,并以 OmooLab 镜像作为回退。Environment 安装则提供官方源和镜像源两个明确入口;它们只影响 Environment Bootstrap,不改变模型下载策略。
模型下载优先使用 Hugging Face,并以 OmooLab 镜像作为回退。AnyImage 用同一个准备 Operator 封装 BlendJob 安装任务,并按状态显示 `Set Up AI Environment…` 或 `Download Required Models…`;`Use Mirror` 属性只影响 Environment Bootstrap,不改变模型下载策略。Blender UI 通过同一个状态函数检查 Environment 指纹与默认模型文件:缺 Environment 时执行完整安装,只有默认模型缺失时仅提交补齐模型任务。
## 任务生命周期

View File

@@ -24,7 +24,7 @@ flowchart LR
| DA3 | Depth、confidence、intrinsics | 原图颜色 |
| MoGe-2 | Metric depth、mask、intrinsics | 原图颜色与 normal |
两种模型共享同一 Surface Builder 和网格文件格式。启用 Remove Background 时,BEN2 Alpha 会在建网格阶段移除背景三角面;这是几何裁剪,转换后关闭材质 Mask 不会恢复已删除的面。
两种模型共享同一 Surface Builder 和网格文件格式。启用 Remove Background 时,输入 Alpha 与 BEN2 Alpha 相乘后的结果会在建网格阶段移除背景三角面;这是几何裁剪,转换后关闭材质 Mask 不会恢复已删除的面。
## 源网格

View File

@@ -1,12 +1,20 @@
# 选择转换方式
AnyImage 提供三种日常工作流。选择结果时,先考虑你需要的是可调节的“深度面片”、带透视结构的“空间网格”,还是只需要透明前景。
AnyImage 提供多种日常工作流。选择结果时,先考虑你需要的是普通图片平面、可调节的“深度面片”、带透视结构的“空间网格”,还是只需要透明前景。
| 工作流 | 适合场景 | 输入 | 结果 | 主要优点 |
| --- | --- | --- | --- | --- |
| Mesh Plane | 广告牌、图片卡片、背景板、不需要 AI 深度的素材 | 图片、视频、序列帧 | 带材质和 Geometry Nodes 的普通平面 | 无需 AI,Thickness 与 Subdivide 可随时调整 |
| Depth Plane | 人物、产品、插画、动态背景、快速场景搭建 | 图片、视频、序列帧 | 带深度位移的平面 | 快速、可动画、Geometry Nodes 参数直观 |
| Surface | 建筑、室内、街景、需要匹配视角的单图 | 单张图片 | 透视纹理网格 | 空间感更强,可创建匹配相机 |
| Remove Background | 抠出人物或物体,继续作为 Image Empty 使用 | 图片、视频、序列帧 | 带透明 Alpha 的颜色图 | 不创建网格,保留 Empty 的位置和显示设置 |
| Image Box / Lasso | 矩形或手绘提取局部内容并按 Alpha 范围裁切 | 静态图片 | Pack 的透明裁切图 | 工具组切换,支持像素 Padding 与 Keep Original,不创建网格 |
## 什么时候选择 Mesh Plane
只需要图片网格、不希望估算或使用深度时,选择 **Convert to Mesh Plane**。它保留图片比例与对象 Transform,并把 Thickness 和 Subdivide 留在 Geometry Nodes 中;Thickness 为 0 时是单面。
[阅读 Mesh Plane 指南](mesh-plane.md)
## 什么时候选择 Depth Plane

View File

@@ -12,8 +12,10 @@ nav:
- 开始使用:
- 安装与首次配置: installation.md
- 五分钟上手: quick-start.md
- 粘贴剪贴板图片: clipboard-image.md
- 使用指南:
- 选择转换方式: workflows.md
- Mesh Plane: mesh-plane.md
- Depth Plane: guide-depth-plane.md
- Surface: guide-surface.md
- 去除背景: remove-background.md

View File

@@ -1,8 +1,12 @@
import bpy
from . import clipboard_image
from .clipboard_image import CLASSES as CLIPBOARD_CLASSES
from .runtime import runtime
from .operators import CLASSES as OPERATOR_CLASSES
from .operators.balloon import BalloonTool
from .operators.image_lasso import ImageBoxTool, ImageLassoTool
from .preferences import AnyImagePreferences
from .properties import AnyImageSettings
from .menu import AnyImageImageMenu, draw_image_context_menu
@@ -10,7 +14,6 @@ from .panel import (
Ben2RemovalDebugPanel,
Da3OutputDebugPanel,
DebugPanel,
ModelsPanel,
Moge2ApiDebugPanel,
ServerPanel,
)
@@ -19,10 +22,10 @@ from .panel import (
CLASSES = (
AnyImagePreferences,
AnyImageSettings,
*CLIPBOARD_CLASSES,
*OPERATOR_CLASSES,
AnyImageImageMenu,
ServerPanel,
ModelsPanel,
DebugPanel,
Moge2ApiDebugPanel,
Da3OutputDebugPanel,
@@ -34,20 +37,31 @@ def register():
for class_type in CLASSES:
bpy.utils.register_class(class_type)
bpy.utils.register_tool(
BalloonTool,
ImageBoxTool,
separator=True,
group=True,
)
bpy.utils.register_tool(
ImageLassoTool,
after={ImageBoxTool.bl_idname},
separator=False,
)
bpy.utils.register_tool(BalloonTool, separator=False)
bpy.types.Scene.anyimage_settings = bpy.props.PointerProperty(
type=AnyImageSettings,
)
bpy.types.VIEW3D_MT_object_context_menu.append(draw_image_context_menu)
bpy.types.OUTLINER_MT_object.append(draw_image_context_menu)
clipboard_image.register_keymaps()
runtime.register()
def unregister():
runtime.unregister()
clipboard_image.unregister_keymaps()
bpy.utils.unregister_tool(BalloonTool)
bpy.utils.unregister_tool(ImageLassoTool)
bpy.utils.unregister_tool(ImageBoxTool)
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
if hasattr(bpy.types.Scene, "anyimage_settings"):

Binary file not shown.

View File

@@ -14,7 +14,7 @@ wheels = ["./wheels/blendjob-0.1.13-py3-none-any.whl"]
[permissions]
network = "Access the local Job Server and download environment dependencies and model weights"
files = "Read source images and store environment data"
files = "Read source and clipboard images, and store environment data"
[build]
paths_exclude_pattern = [

View File

@@ -0,0 +1,209 @@
import sys
import bpy
from .actions import (
PasteTargetError,
acquire_packed_image,
paste_image,
paste_location,
target_for_context,
)
from .clipboard import (
ClipboardImageError,
ClipboardImageUnavailable,
clipboard_change_token,
clipboard_image_supported,
read_clipboard_image,
)
_keymap_items = []
_native_copy_token = None
class TrackNativeCopy(bpy.types.Operator):
bl_idname = "anyimage.track_native_copy"
bl_label = "Track Native Copy"
bl_description = "Preserve Blender's native copy and paste priority"
bl_options = {"INTERNAL"}
def execute(self, _context):
global _native_copy_token
_native_copy_token = clipboard_change_token()
return {"PASS_THROUGH"}
class PasteClipboardImage(bpy.types.Operator):
bl_idname = "anyimage.paste_clipboard_image"
bl_label = "Paste Clipboard Image"
bl_description = "Paste and pack the clipboard image for the current editor"
bl_options = {"REGISTER", "UNDO"}
import_as: bpy.props.EnumProperty(
name="Import As",
description="Choose the 3D View representation for the image",
items=(
(
"PLANE",
"Plane",
"Create a mesh plane with an image material",
),
(
"REFERENCE",
"Reference Image",
"Create an image Empty like dragging an image into Blender",
),
),
default="REFERENCE",
)
shadeless: bpy.props.BoolProperty(
name="Shadeless",
description=(
"Use an Emission shader so the image is unaffected by scene lighting"
),
default=False,
)
paste_target: bpy.props.StringProperty(
options={"HIDDEN", "SKIP_SAVE"},
)
location: bpy.props.FloatVectorProperty(
size=3,
options={"HIDDEN", "SKIP_SAVE"},
)
location_set: bpy.props.BoolProperty(
default=False,
options={"HIDDEN", "SKIP_SAVE"},
)
@classmethod
def poll(cls, context):
if not clipboard_image_supported():
return False
return target_for_context(context) is not None
def execute(self, context):
return self._paste(context)
def invoke(self, context, event):
self.paste_target = target_for_context(context) or ""
location = paste_location(context, event)
if location is not None:
values = tuple(location)
self.location = (*values, 0.0)[:3]
self.location_set = True
return self._paste(context, event)
def draw(self, _context):
if self.paste_target != "PLANE":
return
self.layout.prop(self, "import_as")
if self.import_as == "PLANE":
self.layout.prop(self, "shadeless")
def _paste(self, context, event=None):
if _should_defer_to_native_paste():
return {"PASS_THROUGH"}
if not self.paste_target:
self.paste_target = target_for_context(context) or ""
try:
image_data, suffix = read_clipboard_image()
except ClipboardImageUnavailable:
return {"PASS_THROUGH"}
except ClipboardImageError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
image = None
image_reused = False
try:
image, image_reused = acquire_packed_image(image_data, suffix)
location = None
if self.location_set:
location = self.location
if self.paste_target == "NODE":
location = location[:2]
paste_image(
context,
image,
event,
location=location,
import_as=self.import_as,
shadeless=self.shadeless,
)
except (OSError, RuntimeError, PasteTargetError) as error:
if image is not None and not image_reused and image.users == 0:
bpy.data.images.remove(image)
self.report({"ERROR"}, f"Unable to paste clipboard image: {error}")
return {"CANCELLED"}
self.report({"INFO"}, f"Pasted and packed {image.name}")
return {"FINISHED"}
CLASSES = (TrackNativeCopy, PasteClipboardImage)
def register_keymaps():
_register_keymaps()
def unregister_keymaps():
global _native_copy_token
_unregister_keymaps()
_native_copy_token = None
def _should_defer_to_native_paste():
if _native_copy_token is None:
return False
return clipboard_change_token() == _native_copy_token
def _register_keymaps():
if not clipboard_image_supported():
return
window_manager = getattr(bpy.context, "window_manager", None)
keyconfigs = getattr(window_manager, "keyconfigs", None)
addon_keyconfig = getattr(keyconfigs, "addon", None)
if addon_keyconfig is None:
return
keymap_definitions = (
("3D View", "VIEW_3D"),
("Node Editor", "NODE_EDITOR"),
)
for keymap_name, space_type in keymap_definitions:
keymap = addon_keyconfig.keymaps.new(
name=keymap_name,
space_type=space_type,
)
modifiers = (
{"oskey": True}
if sys.platform == "darwin"
else {"ctrl": True}
)
keymap_item = keymap.keymap_items.new(
PasteClipboardImage.bl_idname,
"V",
"PRESS",
**modifiers,
)
_keymap_items.append((keymap, keymap_item))
copy_keymap_item = keymap.keymap_items.new(
TrackNativeCopy.bl_idname,
"C",
"PRESS",
**modifiers,
)
_keymap_items.append((keymap, copy_keymap_item))
def _unregister_keymaps():
for keymap, keymap_item in _keymap_items:
keymap.keymap_items.remove(keymap_item)
_keymap_items.clear()

View File

@@ -0,0 +1,512 @@
import hashlib
import os
import tempfile
import bpy
CLIPBOARD_HASH_PROPERTY = "anyimage_clipboard_sha256"
CLIPBOARD_TEXTURE_PROPERTY = "anyimage_clipboard_texture"
DEFAULT_STENCIL_SIZE = 256.0
NODE_HEADER_HEIGHT = 20.0
NODE_TYPES = {
"CompositorNodeTree": "CompositorNodeImage",
"ShaderNodeTree": "ShaderNodeTexImage",
"GeometryNodeTree": "GeometryNodeImageTexture",
}
WORLD_TEXTURE_NODE_TYPE = "ShaderNodeTexEnvironment"
BRUSH_TEXTURE_MODES = {
"SCULPT",
"PAINT_TEXTURE",
"PAINT_VERTEX",
}
WHITE_BRUSH_COLOR_MODES = {
"PAINT_TEXTURE",
"PAINT_VERTEX",
}
PAINT_SETTINGS_BY_MODE = {
"SCULPT": ("sculpt",),
"PAINT_TEXTURE": ("image_paint",),
"PAINT_VERTEX": ("vertex_paint",),
"PAINT_WEIGHT": ("weight_paint",),
"PAINT_GREASE_PENCIL": ("grease_pencil_paint", "gpencil_paint"),
"VERTEX_GREASE_PENCIL": ("gpencil_vertex_paint",),
"SCULPT_GREASE_PENCIL": ("grease_pencil_sculpt", "gpencil_sculpt_paint"),
"WEIGHT_GREASE_PENCIL": ("grease_pencil_weight", "gpencil_weight_paint"),
"SCULPT_CURVES": ("curves_sculpt",),
}
class PasteTargetError(RuntimeError):
pass
def target_for_context(context):
area = getattr(context, "area", None)
area_type = getattr(area, "type", None)
if area_type == "VIEW_3D":
if getattr(context, "mode", "OBJECT") in BRUSH_TEXTURE_MODES:
return "TOOL_TEXTURE"
return "PLANE"
if area_type != "NODE_EDITOR":
return None
tree_type = getattr(getattr(context, "space_data", None), "tree_type", None)
if tree_type in NODE_TYPES:
return "NODE"
return None
def acquire_packed_image(image_data, suffix):
image_hash = hashlib.sha256(image_data).hexdigest()
existing_image = _find_packed_clipboard_image(image_hash)
if existing_image is not None:
return existing_image, True
file_descriptor, temporary_path = tempfile.mkstemp(
prefix="blender-clipboard-",
suffix=suffix,
)
image = None
try:
with os.fdopen(file_descriptor, "wb") as temporary_file:
temporary_file.write(image_data)
image = bpy.data.images.load(temporary_path, check_existing=False)
image.name = "Clipboard Image"
image.pack()
image[CLIPBOARD_HASH_PROPERTY] = image_hash
return image, False
except (OSError, RuntimeError):
if image is not None:
bpy.data.images.remove(image)
raise
finally:
try:
os.unlink(temporary_path)
except FileNotFoundError:
pass
def _find_packed_clipboard_image(image_hash):
for image in bpy.data.images:
if image.get(CLIPBOARD_HASH_PROPERTY) != image_hash:
continue
if _is_image_packed(image):
return image
return None
def _is_image_packed(image):
if getattr(image, "packed_file", None) is not None:
return True
return bool(getattr(image, "packed_files", ()))
def paste_image(
context,
image,
event=None,
import_as="REFERENCE",
shadeless=False,
location=None,
):
target = target_for_context(context)
if target == "PLANE":
if location is None:
location = paste_location(context, event)
if import_as == "REFERENCE":
return add_reference_image(context, image, location=location)
return add_image_plane(
context,
image,
location=location,
shadeless=shadeless,
)
if target == "NODE":
return add_image_node(context, image, event, location=location)
if target == "TOOL_TEXTURE":
return set_tool_texture(context, image)
raise PasteTargetError("Paste images from a 3D View or Node Editor")
def add_image_plane(
context,
image,
shadeless=False,
location=None,
):
if getattr(context, "mode", "OBJECT") != "OBJECT":
raise PasteTargetError("Switch to Object Mode before pasting an image plane")
width, height = image.size
if width <= 0 or height <= 0:
raise PasteTargetError("The clipboard image has invalid dimensions")
from ..operators.mesh_plane import create_mesh_plane_object
half_width, half_height = _plane_half_dimensions(width, height)
material = _create_image_material(image, shadeless)
plane = create_mesh_plane_object(
context,
image.name,
(-half_width, half_width, -half_height, half_height),
material,
)
_place_in_view(context, plane, location)
_select_only(context, plane)
return plane
def add_reference_image(context, image, location=None):
if getattr(context, "mode", "OBJECT") != "OBJECT":
raise PasteTargetError(
"Switch to Object Mode before pasting a reference image"
)
width, height = image.size
if width <= 0 or height <= 0:
raise PasteTargetError("The clipboard image has invalid dimensions")
reference = bpy.data.objects.new(image.name, None)
reference.empty_display_type = "IMAGE"
reference.data = image
reference.empty_display_size = 2.0
context.collection.objects.link(reference)
_place_in_view(context, reference, location)
_select_only(context, reference)
return reference
def _place_in_view(context, target_object, location=None):
if location is None:
location = context.scene.cursor.location
target_object.location = location
region_data = getattr(context, "region_data", None)
if region_data is not None:
target_object.rotation_euler = region_data.view_rotation.to_euler()
def _plane_half_dimensions(width, height):
if width >= height:
return 1.0, height / width
return width / height, 1.0
def _create_image_material(image, shadeless=False):
material = bpy.data.materials.new(name=f"{image.name} Material")
material.use_nodes = True
if hasattr(material, "surface_render_method"):
material.surface_render_method = "DITHERED"
nodes = material.node_tree.nodes
nodes.clear()
image_node = nodes.new("ShaderNodeTexImage")
image_node.image = image
image_node.location = (-620.0, 80.0)
output_node = nodes.new("ShaderNodeOutputMaterial")
output_node.location = (220.0, 80.0)
links = material.node_tree.links
if shadeless:
_create_shadeless_nodes(nodes, links, image_node, output_node)
return material
shader_node = nodes.new("ShaderNodeBsdfPrincipled")
shader_node.location = (-160.0, 80.0)
links.new(image_node.outputs["Color"], shader_node.inputs["Base Color"])
links.new(image_node.outputs["Alpha"], shader_node.inputs["Alpha"])
links.new(shader_node.outputs["BSDF"], output_node.inputs["Surface"])
return material
def _create_shadeless_nodes(nodes, links, image_node, output_node):
transparent_node = nodes.new("ShaderNodeBsdfTransparent")
transparent_node.location = (-360.0, -100.0)
emission_node = nodes.new("ShaderNodeEmission")
emission_node.location = (-360.0, 120.0)
mix_node = nodes.new("ShaderNodeMixShader")
mix_node.location = (-80.0, 80.0)
links.new(image_node.outputs["Color"], emission_node.inputs["Color"])
links.new(image_node.outputs["Alpha"], mix_node.inputs[0])
links.new(transparent_node.outputs["BSDF"], mix_node.inputs[1])
links.new(emission_node.outputs["Emission"], mix_node.inputs[2])
links.new(mix_node.outputs["Shader"], output_node.inputs["Surface"])
def _select_only(context, plane):
for selected_object in getattr(context, "selected_objects", ()):
selected_object.select_set(False)
plane.select_set(True)
context.view_layer.objects.active = plane
def add_image_node(context, image, event=None, location=None):
space = context.space_data
node_tree = getattr(space, "edit_tree", None)
if node_tree is None:
node_tree = getattr(space, "node_tree", None)
if node_tree is None:
raise PasteTargetError("The Node Editor has no editable node tree")
node_type = _node_type_for_space(space)
if node_type is None:
raise PasteTargetError(f"Unsupported node tree: {space.tree_type}")
node = node_tree.nodes.new(node_type)
_assign_node_image(node, image)
if location is None:
location = _node_location(context, event)
node.location = location
_select_only_node(node_tree, node)
return node
def _node_type_for_space(space):
if _is_world_shader_space(space):
return WORLD_TEXTURE_NODE_TYPE
return NODE_TYPES.get(getattr(space, "tree_type", None))
def _is_world_shader_space(space):
if getattr(space, "tree_type", None) != "ShaderNodeTree":
return False
if getattr(space, "shader_type", None) == "WORLD":
return True
owner_id = getattr(space, "id", None)
owner_rna = getattr(owner_id, "bl_rna", None)
return getattr(owner_rna, "identifier", None) == "World"
def _assign_node_image(node, image):
if hasattr(node, "image"):
node.image = image
return
# Geometry Nodes 的 Image Texture 把图片存在 Image socket,而非 node.image。
image_input = node.inputs.get("Image")
if image_input is None or not hasattr(image_input, "default_value"):
raise PasteTargetError("The image node has no assignable image input")
image_input.default_value = image
def _node_location(context, event):
if event is not None:
region = getattr(context, "region", None)
view2d = getattr(region, "view2d", None)
if view2d is not None:
view_x, view_y = view2d.region_to_view(
event.mouse_region_x,
event.mouse_region_y,
)
system = getattr(getattr(context, "preferences", None), "system", None)
ui_scale = float(getattr(system, "ui_scale", 1.0) or 1.0)
return (
(view_x - NODE_HEADER_HEIGHT * 1.5) / ui_scale,
(view_y + NODE_HEADER_HEIGHT * 0.5) / ui_scale,
)
cursor_location = getattr(context.space_data, "cursor_location", None)
if cursor_location is not None:
return cursor_location
return (0.0, 0.0)
def paste_location(context, event=None):
target = target_for_context(context)
if target == "PLANE":
return _view3d_location(context, event)
if target == "NODE":
return _node_location(context, event)
return None
def _view3d_location(context, event=None):
cursor_location = context.scene.cursor.location
if event is None:
return cursor_location.copy()
region = getattr(context, "region", None)
region_data = getattr(context, "region_data", None)
if region is None or region_data is None:
return cursor_location.copy()
from bpy_extras.view3d_utils import region_2d_to_location_3d
return region_2d_to_location_3d(
region,
region_data,
(event.mouse_region_x, event.mouse_region_y),
cursor_location,
)
def _select_only_node(node_tree, node):
for existing_node in node_tree.nodes:
existing_node.select = False
node.select = True
node_tree.nodes.active = node
def set_tool_texture(context, image):
brush = _active_brush(context)
if brush is None:
raise PasteTargetError("The active tool does not provide an editable brush")
brush = _ensure_editable_brush(context, brush)
texture, is_new = _find_or_create_image_texture(image)
try:
brush.texture = texture
brush.texture_slot.texture = texture
brush.texture_slot.map_mode = "STENCIL"
brush.stencil_dimension = _stencil_dimensions(
image.size,
brush.stencil_dimension,
)
if brush.texture != texture or brush.texture_slot.texture != texture:
raise PasteTargetError("Blender did not bind the texture to the active brush")
_set_unbiased_paint_color(context, brush)
brush.update_tag()
except (AttributeError, TypeError, RuntimeError) as error:
if is_new:
bpy.data.textures.remove(texture)
raise PasteTargetError(
f"The active brush could not use the image texture: {error}"
) from error
_redraw_area(context)
return texture
def _set_unbiased_paint_color(context, brush):
if getattr(context, "mode", "") not in WHITE_BRUSH_COLOR_MODES:
return
white = (1.0, 1.0, 1.0)
brush.color = white
tool_settings = getattr(context, "tool_settings", None)
if tool_settings is None:
scene = getattr(context, "scene", None)
tool_settings = getattr(scene, "tool_settings", None)
unified_settings = None
mode = getattr(context, "mode", "")
for settings_name in PAINT_SETTINGS_BY_MODE.get(mode, ()):
paint_settings = getattr(tool_settings, settings_name, None)
unified_settings = getattr(
paint_settings,
"unified_paint_settings",
None,
)
if unified_settings is not None:
break
if unified_settings is None:
unified_settings = getattr(tool_settings, "unified_paint_settings", None)
if unified_settings is not None:
unified_settings.color = white
def _ensure_editable_brush(context, brush):
brush_library = getattr(brush, "library", None)
brush_is_editable = getattr(brush, "is_editable", True)
if brush_library is None and brush_is_editable:
return brush
if brush_library is not None:
return _copy_and_activate_library_brush(context, brush)
make_local = getattr(brush, "make_local", None)
if make_local is None:
raise PasteTargetError("The active brush is read-only and cannot be localized")
# Brush Asset 可能来自外部库;先本地化,才能引用当前 .blend 中的 Texture。
try:
local_brush = make_local()
except RuntimeError as error:
raise PasteTargetError(
f"Unable to make the active brush local: {error}"
) from error
if local_brush is None:
local_brush = brush
if getattr(local_brush, "library", None) is not None:
raise PasteTargetError("The active brush still belongs to an external library")
if not getattr(local_brush, "is_editable", False):
raise PasteTargetError("The localized brush is still read-only")
return local_brush
def _copy_and_activate_library_brush(context, brush):
try:
result = bpy.ops.brush.asset_save_as(
name=f"{brush.name} Clipboard",
asset_library_reference="LOCAL",
)
except (AttributeError, RuntimeError) as error:
raise PasteTargetError(
f"Unable to create a local copy of the active Brush Asset: {error}"
) from error
if result != {"FINISHED"}:
raise PasteTargetError("Unable to activate the local Brush Asset copy")
active_brush = _active_brush(context)
if active_brush is None or getattr(active_brush, "library", None) is not None:
raise PasteTargetError("Blender did not activate a local Brush Asset copy")
return active_brush
def _find_or_create_image_texture(image):
for texture in bpy.data.textures:
if not texture.get(CLIPBOARD_TEXTURE_PROPERTY, False):
continue
if texture.image == image:
return texture, False
texture = bpy.data.textures.new(name=f"{image.name} Texture", type="IMAGE")
texture.image = image
texture[CLIPBOARD_TEXTURE_PROPERTY] = True
return texture, True
def _stencil_dimensions(image_size, current_dimensions):
width, height = image_size
if width <= 0 or height <= 0:
raise PasteTargetError("The clipboard image has invalid dimensions")
stencil_size = max(current_dimensions, default=DEFAULT_STENCIL_SIZE)
if stencil_size <= 0:
stencil_size = DEFAULT_STENCIL_SIZE
if width >= height:
return stencil_size, stencil_size * height / width
return stencil_size * width / height, stencil_size
def _active_brush(context):
mode = getattr(context, "mode", "")
tool_settings = getattr(context, "tool_settings", None)
if tool_settings is None:
scene = getattr(context, "scene", None)
tool_settings = getattr(scene, "tool_settings", None)
for settings_name in PAINT_SETTINGS_BY_MODE.get(mode, ()):
paint_settings = getattr(tool_settings, settings_name, None)
brush = getattr(paint_settings, "brush", None)
if brush is not None:
return brush
return getattr(context, "brush", None)
def _redraw_area(context):
area = getattr(context, "area", None)
tag_redraw = getattr(area, "tag_redraw", None)
if tag_redraw is not None:
tag_redraw()

View File

@@ -0,0 +1,360 @@
import ctypes
import json
import os
import shutil
import struct
import subprocess
import sys
import tempfile
from ctypes import wintypes
from pathlib import Path
CF_DIB = 8
CF_DIBV5 = 17
MAX_CLIPBOARD_IMAGE_SIZE = 512 * 1024 * 1024
CLIPBOARD_COMMAND_TIMEOUT = 10
LINUX_IMAGE_FORMATS = (
("image/png", ".png"),
("image/jpeg", ".jpg"),
("image/tiff", ".tiff"),
("image/bmp", ".bmp"),
)
class ClipboardImageError(RuntimeError):
pass
class ClipboardImageUnavailable(ClipboardImageError):
pass
def dib_to_bmp(dib_data):
if len(dib_data) < 12:
raise ClipboardImageError("Clipboard DIB data is incomplete")
header_size = struct.unpack_from("<I", dib_data)[0]
if header_size == 12:
pixel_offset = _core_dib_pixel_offset(dib_data)
elif header_size >= 40:
pixel_offset = _info_dib_pixel_offset(dib_data, header_size)
else:
raise ClipboardImageError(
f"Unsupported clipboard DIB header size: {header_size}"
)
# Windows 剪贴板保存的是无 BITMAPFILEHEADER 的 DIB,Blender 需要完整 BMP。
file_size = 14 + len(dib_data)
file_header = struct.pack(
"<2sIHHI",
b"BM",
file_size,
0,
0,
14 + pixel_offset,
)
return file_header + dib_data
def _core_dib_pixel_offset(dib_data):
if len(dib_data) < 12:
raise ClipboardImageError("Clipboard DIB core header is incomplete")
bit_count = struct.unpack_from("<H", dib_data, 10)[0]
color_count = 1 << bit_count if bit_count <= 8 else 0
pixel_offset = 12 + color_count * 3
_validate_pixel_offset(dib_data, pixel_offset)
return pixel_offset
def _info_dib_pixel_offset(dib_data, header_size):
if len(dib_data) < header_size or len(dib_data) < 40:
raise ClipboardImageError("Clipboard DIB info header is incomplete")
bit_count = struct.unpack_from("<H", dib_data, 14)[0]
compression = struct.unpack_from("<I", dib_data, 16)[0]
colors_used = struct.unpack_from("<I", dib_data, 32)[0]
color_count = colors_used
if color_count == 0 and bit_count <= 8:
color_count = 1 << bit_count
mask_size = 0
if header_size == 40 and compression == 3:
mask_size = 12
if header_size == 40 and compression == 6:
mask_size = 16
pixel_offset = header_size + mask_size + color_count * 4
if header_size >= 124:
profile_offset = struct.unpack_from("<I", dib_data, 112)[0]
profile_size = struct.unpack_from("<I", dib_data, 116)[0]
if profile_offset and profile_size:
pixel_offset = max(pixel_offset, profile_offset + profile_size)
_validate_pixel_offset(dib_data, pixel_offset)
return pixel_offset
def _validate_pixel_offset(dib_data, pixel_offset):
if pixel_offset > len(dib_data):
raise ClipboardImageError("Clipboard DIB pixel offset is out of range")
def read_clipboard_image():
if sys.platform == "win32":
return _read_windows_clipboard_image()
if sys.platform == "darwin":
return _read_macos_clipboard_image()
if sys.platform.startswith("linux"):
return _read_linux_clipboard_image()
raise ClipboardImageUnavailable("Clipboard image paste is unavailable")
def clipboard_image_supported():
return (
sys.platform == "win32"
or sys.platform == "darwin"
or sys.platform.startswith("linux")
)
def clipboard_change_token():
if sys.platform != "win32":
return None
user32 = ctypes.WinDLL("user32", use_last_error=True)
user32.GetClipboardSequenceNumber.argtypes = []
user32.GetClipboardSequenceNumber.restype = wintypes.DWORD
return user32.GetClipboardSequenceNumber()
def _read_windows_clipboard_image():
user32 = ctypes.WinDLL("user32", use_last_error=True)
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
_configure_windows_api(user32, kernel32)
png_format = user32.RegisterClipboardFormatW("PNG")
formats = (
(png_format, ".png", False),
(CF_DIBV5, ".bmp", True),
(CF_DIB, ".bmp", True),
)
if not user32.OpenClipboard(None):
error_code = ctypes.get_last_error()
raise ClipboardImageError(
f"Unable to open the Windows clipboard (error {error_code})"
)
try:
for clipboard_format, suffix, is_dib in formats:
if not clipboard_format:
continue
if not user32.IsClipboardFormatAvailable(clipboard_format):
continue
image_data = _read_windows_clipboard_data(
user32,
kernel32,
clipboard_format,
)
if is_dib:
image_data = dib_to_bmp(image_data)
return image_data, suffix
finally:
user32.CloseClipboard()
raise ClipboardImageUnavailable("The clipboard does not contain an image")
def _read_macos_clipboard_image():
osascript = shutil.which("osascript")
if osascript is None:
raise ClipboardImageError("macOS clipboard access requires osascript")
file_descriptor, temporary_name = tempfile.mkstemp(
prefix="blender-clipboard-",
suffix=".png",
)
os.close(file_descriptor)
output_file = Path(temporary_name)
output_file.unlink()
try:
_export_macos_clipboard_image(osascript, output_file)
image_data = _read_image_file(output_file)
finally:
output_file.unlink(missing_ok=True)
if image_data is None:
raise ClipboardImageUnavailable("The clipboard does not contain an image")
return image_data, ".png"
def _export_macos_clipboard_image(osascript, output_file):
output_path = json.dumps(str(output_file))
script = f"""
ObjC.import('AppKit');
const pasteboard = $.NSPasteboard.generalPasteboard;
const image = $.NSImage.alloc.initWithPasteboard(pasteboard);
if (image) {{
const bitmap = $.NSBitmapImageRep.imageRepWithData(image.TIFFRepresentation);
if (!bitmap) {{
throw new Error('Unable to create a bitmap from the clipboard image');
}}
const png = bitmap.representationUsingTypeProperties(
$.NSBitmapImageFileTypePNG,
$({{}})
);
if (!png || !png.writeToFileAtomically($({output_path}), true)) {{
throw new Error('Unable to export the clipboard image as PNG');
}}
}}
"""
try:
result = subprocess.run(
[osascript, "-l", "JavaScript", "-e", script],
capture_output=True,
check=False,
timeout=CLIPBOARD_COMMAND_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired) as error:
raise ClipboardImageError(
f"Unable to access the macOS clipboard: {error}"
) from error
if result.returncode == 0:
return
error_text = result.stderr.decode(errors="replace").strip()
raise ClipboardImageError(
error_text or "Unable to access the macOS clipboard"
)
def _read_linux_clipboard_image():
commands = []
wl_paste = shutil.which("wl-paste")
if wl_paste is not None:
commands.append(
lambda mime_type: [
wl_paste,
"--no-newline",
"--type",
mime_type,
]
)
xclip = shutil.which("xclip")
if xclip is not None:
commands.append(
lambda mime_type: [
xclip,
"-selection",
"clipboard",
"-t",
mime_type,
"-o",
]
)
for command_for_type in commands:
for mime_type, suffix in LINUX_IMAGE_FORMATS:
image_data = _run_image_command(
command_for_type(mime_type),
)
if image_data is not None:
return image_data, suffix
if not commands:
raise ClipboardImageUnavailable(
"Install wl-clipboard or xclip to paste clipboard images on Linux"
)
raise ClipboardImageUnavailable("The clipboard does not contain an image")
def _run_image_command(command):
file_descriptor, temporary_name = tempfile.mkstemp(
prefix="blender-clipboard-",
)
output_file = Path(temporary_name)
try:
with os.fdopen(file_descriptor, "wb") as image_file:
try:
result = subprocess.run(
command,
stdout=image_file,
stderr=subprocess.PIPE,
check=False,
timeout=CLIPBOARD_COMMAND_TIMEOUT,
)
except (OSError, subprocess.TimeoutExpired) as error:
raise ClipboardImageError(
f"Unable to access the Linux clipboard: {error}"
) from error
if result.returncode != 0:
return None
return _read_image_file(output_file)
finally:
output_file.unlink(missing_ok=True)
def _read_image_file(image_file):
try:
image_size = image_file.stat().st_size
except FileNotFoundError:
return None
if image_size <= 0:
return None
if image_size > MAX_CLIPBOARD_IMAGE_SIZE:
raise ClipboardImageError("Clipboard image is larger than 512 MiB")
return image_file.read_bytes()
def _configure_windows_api(user32, kernel32):
user32.OpenClipboard.argtypes = [wintypes.HWND]
user32.OpenClipboard.restype = wintypes.BOOL
user32.CloseClipboard.argtypes = []
user32.CloseClipboard.restype = wintypes.BOOL
user32.IsClipboardFormatAvailable.argtypes = [wintypes.UINT]
user32.IsClipboardFormatAvailable.restype = wintypes.BOOL
user32.GetClipboardData.argtypes = [wintypes.UINT]
user32.GetClipboardData.restype = wintypes.HANDLE
user32.RegisterClipboardFormatW.argtypes = [wintypes.LPCWSTR]
user32.RegisterClipboardFormatW.restype = wintypes.UINT
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
kernel32.GlobalLock.restype = ctypes.c_void_p
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
kernel32.GlobalUnlock.restype = wintypes.BOOL
kernel32.GlobalSize.argtypes = [wintypes.HGLOBAL]
kernel32.GlobalSize.restype = ctypes.c_size_t
def _read_windows_clipboard_data(user32, kernel32, clipboard_format):
data_handle = user32.GetClipboardData(clipboard_format)
if not data_handle:
error_code = ctypes.get_last_error()
raise ClipboardImageError(
f"Unable to read clipboard image data (error {error_code})"
)
data_size = kernel32.GlobalSize(data_handle)
if data_size <= 0:
raise ClipboardImageError("Clipboard image data is empty")
if data_size > MAX_CLIPBOARD_IMAGE_SIZE:
raise ClipboardImageError("Clipboard image is larger than 512 MiB")
data_pointer = kernel32.GlobalLock(data_handle)
if not data_pointer:
error_code = ctypes.get_last_error()
raise ClipboardImageError(
f"Unable to lock clipboard image data (error {error_code})"
)
try:
return ctypes.string_at(data_pointer, data_size)
finally:
kernel32.GlobalUnlock(data_handle)

View File

@@ -8,61 +8,44 @@ class AnyImageImageMenu(bpy.types.Menu):
bl_label = "AnyImage"
def draw(self, _context):
from .runtime import runtime
from .properties import required_models_ready
from .properties import ai_setup_label, ai_status
layout = self.layout
layout.operator_context = "INVOKE_DEFAULT"
layout.operator(
"anyimage.activate_balloon_tool",
text="Balloon Lasso Tool",
icon="MOD_SKIN",
"anyimage.convert_to_mesh_plane",
text="Convert to Mesh Plane…",
icon="MESH_PLANE",
)
layout.separator()
environment_ready = runtime.environment_ready()
if not environment_ready:
layout.operator(
"anyimage.install_environment",
text="Install Environment",
icon="IMPORT",
)
layout.operator(
"anyimage.install_environment_mirror",
text="Install Environment (Mirror)",
icon="IMPORT",
)
models_ready = required_models_ready()
if environment_ready and not models_ready:
layout.operator(
"anyimage.download_required_models",
text="Download Required Models",
icon="IMPORT",
)
features_ready = environment_ready and models_ready
if not features_ready:
layout.separator()
depth_plane_row = layout.row()
depth_plane_row.enabled = features_ready
depth_plane_row.operator(
layout.operator(
"anyimage.convert_to_depth_plane",
text="Convert to Depth Plane",
text="Convert to Depth Plane…",
icon="MOD_DISPLACE",
)
surface_row = layout.row()
surface_row.enabled = features_ready
surface_row.operator(
layout.operator(
"anyimage.convert_to_surface",
text="Convert to Surface",
text="Convert to Surface…",
icon="MESH_GRID",
)
layout.separator()
background_row = layout.row()
background_row.enabled = features_ready
background_row.operator(
layout.operator(
"anyimage.remove_image_background",
text="Remove Background",
icon="IMAGE_ALPHA",
)
layout.separator()
status = ai_status()
if status["ready"]:
layout.operator(
"anyimage.open_ai_environment_settings",
text="AI Environment Settings…",
icon="PREFERENCES",
)
else:
layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)
def draw_image_context_menu(self, context):

View File

@@ -5,6 +5,11 @@ from .balloon import (
AnalyzeBalloon,
)
from .debug import DebugBen2, DebugDa3, DebugMoge2
from .image_lasso import (
ActivateImageLassoTool,
AnalyzeImageLasso,
ExtractImageLasso,
)
from .depth_plane import (
ConvertToDepthPlane,
ExtractOriginalFrames,
@@ -12,11 +17,20 @@ from .depth_plane import (
GenerateMoge2Depth,
GenerateOriginalBackground,
)
from .models import ClearModels, DownloadModel, DownloadRequiredModels
from .models import (
ClearModels,
DownloadModel,
DownloadRequiredModels,
OpenAIEnvironmentSettings,
SetupAIEnvironment,
)
from .mesh_plane import ConvertToMeshPlane
from .surface import ConvertToSurface, GenerateDa3Surface, GenerateMoge2Surface
CLASSES = (
SetupAIEnvironment,
OpenAIEnvironmentSettings,
DownloadModel,
DownloadRequiredModels,
DebugDa3,
@@ -30,10 +44,14 @@ CLASSES = (
GenerateOriginalBackground,
ExtractOriginalFrames,
ClearModels,
ExtractImageLasso,
AnalyzeImageLasso,
ActivateImageLassoTool,
ExtractBalloon,
AnalyzeBalloon,
ActivateBalloonTool,
RemoveImageBackground,
ConvertToMeshPlane,
ConvertToDepthPlane,
ConvertToSurface,
)

View File

@@ -6,6 +6,7 @@ from .common import (
HIDDEN,
background_input_path,
cleanup_temporary_input,
invoke_ai_setup_if_needed,
production_device,
push_undo,
require_environment,
@@ -31,14 +32,14 @@ class RemoveImageBackground(bpy.types.Operator):
@classmethod
def poll(cls, context):
from ..runtime import runtime
from ..properties import required_models_ready
return (
is_image_empty(context.object)
and runtime.environment_ready()
and not runtime.server_busy()
and required_models_ready()
)
return is_image_empty(context.object) and not runtime.server_busy()
def invoke(self, _context, _event):
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
return self.execute(_context)
def execute(self, context):
source_object = context.object

View File

@@ -13,6 +13,9 @@ from .depth import (
from .geometry import INFLATION, POINT_SPACING, triangulate_balloon
from ...properties import (
BEN2_MODEL_KEY,
ai_ready,
ai_setup_label,
ai_status,
balloon_contour_analysis_property as contour_analysis_property,
balloon_depth_analysis_property as depth_analysis_property,
balloon_depth_model_property as depth_model_property,
@@ -500,10 +503,24 @@ class ExtractBalloon(bpy.types.Operator):
layout.prop(self, "thickness")
layout.prop(self, "resolution", text="Resolution (m)")
layout.prop(self, "reuse_material")
layout.prop(self, "contour_analysis")
layout.prop(self, "depth_analysis")
status = ai_status()
ready = status["ready"]
contour_row = layout.row()
contour_row.enabled = ready
contour_row.prop(self, "contour_analysis")
depth_row = layout.row()
depth_row.enabled = ready
depth_row.prop(self, "depth_analysis")
if self.depth_analysis:
layout.prop(self, "depth_model")
model_row = layout.row()
model_row.enabled = ready
model_row.prop(self, "depth_model")
if not ready:
layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)
@classmethod
def poll(cls, context):
@@ -541,8 +558,9 @@ class ExtractBalloon(bpy.types.Operator):
self.thickness = settings.balloon_thickness
self.resolution = settings.balloon_resolution
self.reuse_material = settings.balloon_reuse_material
self.contour_analysis = settings.balloon_contour_analysis
self.depth_analysis = settings.balloon_depth_analysis
ready = ai_ready()
self.contour_analysis = ready and settings.balloon_contour_analysis
self.depth_analysis = ready and settings.balloon_depth_analysis
self.depth_model = settings.balloon_depth_model
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
@@ -835,7 +853,21 @@ class BalloonTool(bpy.types.WorkSpaceTool):
layout.prop(settings, "balloon_thickness")
layout.prop(settings, "balloon_resolution", text="Resolution (m)")
layout.prop(settings, "balloon_reuse_material")
layout.prop(settings, "balloon_contour_analysis")
layout.prop(settings, "balloon_depth_analysis")
status = ai_status()
ready = status["ready"]
contour_row = layout.row()
contour_row.enabled = ready
contour_row.prop(settings, "balloon_contour_analysis")
depth_row = layout.row()
depth_row.enabled = ready
depth_row.prop(settings, "balloon_depth_analysis")
if settings.balloon_depth_analysis:
layout.prop(settings, "balloon_depth_model", text="Depth Model")
model_row = layout.row()
model_row.enabled = ready
model_row.prop(settings, "balloon_depth_model", text="Depth Model")
if not ready:
layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)

View File

@@ -5,7 +5,7 @@ import bpy
from .image import extract_frame_range
from ..runtime import runtime
from ..properties import model_ready, model_record
from ..properties import ai_ready, model_ready, model_record
BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
@@ -49,6 +49,12 @@ def require_environment():
raise RuntimeError("Install the AnyImage Environment first")
def invoke_ai_setup_if_needed():
if ai_ready():
return None
return bpy.ops.anyimage.setup_ai_environment("INVOKE_DEFAULT")
def require_model(model_key):
model = model_record(model_key)
if model is None:

View File

@@ -8,6 +8,7 @@ from ..common import (
cleanup_temporary_input,
da3_parameters,
input_path,
invoke_ai_setup_if_needed,
moge2_parameters,
push_undo,
require_environment,
@@ -74,16 +75,13 @@ class ConvertToDepthPlane(bpy.types.Operator):
@classmethod
def poll(cls, context):
from ...runtime import runtime
from ...properties import required_models_ready
return (
is_image_empty(context.object)
and runtime.environment_ready()
and not runtime.server_busy()
and required_models_ready()
)
return is_image_empty(context.object) and not runtime.server_busy()
def invoke(self, context, _event):
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
return context.window_manager.invoke_props_dialog(self, width=340)
def draw(self, context):

View File

@@ -0,0 +1,635 @@
import json
import math
import bpy
from ..properties import (
BEN2_MODEL_KEY,
ai_ready,
ai_setup_label,
ai_status,
image_lasso_contour_analysis_property as contour_analysis_property,
image_lasso_keep_original_property as keep_original_property,
image_lasso_padding_property as padding_property,
)
from ..runtime import JobOperatorBase
from .balloon.operator import (
deserialize_matrix,
drawing_in_region,
hidden_string_property,
screen_path_to_image_pixels,
serialize_matrix,
simplify_screen_path,
)
from .common import (
HIDDEN,
cleanup_temporary_input,
production_device,
push_undo,
require_environment,
require_model,
)
from .image import (
image_base_name,
is_image_empty,
plane_bounds,
prepare_image_input,
remove_temporary_input,
video_total_frames,
)
def lasso_bounds(polygon):
import numpy as np
points = np.asarray(polygon, dtype=np.float64)
if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2:
raise ValueError("The lasso does not define a valid image region")
if not np.isfinite(points).all():
raise ValueError("The lasso contains invalid image coordinates")
left = int(math.floor(float(points[:, 0].min())))
top = int(math.floor(float(points[:, 1].min())))
right = int(math.ceil(float(points[:, 0].max())))
bottom = int(math.ceil(float(points[:, 1].max())))
if right <= left or bottom <= top:
raise ValueError("The lasso does not define a valid image region")
return left, top, right, bottom
def points_inside_polygon(points, polygon):
import numpy as np
points = np.asarray(points, dtype=np.float64)
polygon = np.asarray(polygon, dtype=np.float64)
inside = np.zeros(len(points), dtype=bool)
previous = polygon[-1]
for current in polygon:
crosses = (current[1] > points[:, 1]) != (previous[1] > points[:, 1])
edge_x = (
(previous[0] - current[0])
* (points[:, 1] - current[1])
/ (previous[1] - current[1] + 1e-300)
+ current[0]
)
inside ^= crosses & (points[:, 0] < edge_x)
previous = current
return inside
def extract_lasso_pixels(source_pixels, source_size, polygon):
"""Return Blender-order RGBA pixels and the unbounded lasso rectangle."""
import numpy as np
source_width, source_height = (int(value) for value in source_size)
if source_width <= 0 or source_height <= 0:
raise ValueError("The source image has no readable dimensions")
source = np.asarray(source_pixels, dtype=np.float32)
if source.size != source_width * source_height * 4:
raise ValueError("The source image pixels do not match its dimensions")
source = np.flipud(source.reshape((source_height, source_width, 4)))
left, top, right, bottom = lasso_bounds(polygon)
width = right - left
height = bottom - top
result = np.zeros((height, width, 4), dtype=np.float32)
source_left = max(left, 0)
source_top = max(top, 0)
source_right = min(right, source_width)
source_bottom = min(bottom, source_height)
if source_right <= source_left or source_bottom <= source_top:
raise ValueError("The lasso does not overlap the source image")
result[
source_top - top : source_bottom - top,
source_left - left : source_right - left,
] = source[source_top:source_bottom, source_left:source_right]
y, x = np.indices((height, width), dtype=np.float64)
centers = np.column_stack(
((x.ravel() + left + 0.5), (y.ravel() + top + 0.5))
)
selected = points_inside_polygon(centers, polygon).reshape((height, width))
result[~selected] = 0.0
result[result[:, :, 3] <= 0.0] = 0.0
return np.flipud(result).ravel(), (left, top, right, bottom)
def crop_pixels_to_alpha(pixels, bounds, padding=0):
"""Crop Blender-order RGBA pixels to nonzero Alpha plus pixel padding."""
import numpy as np
if padding < 0:
raise ValueError("Padding must not be negative")
left, top, right, bottom = bounds
width = right - left
height = bottom - top
rgba = np.flipud(
np.asarray(pixels, dtype=np.float32).reshape((height, width, 4))
)
visible_y, visible_x = np.nonzero(rgba[:, :, 3] > 0.0)
if not len(visible_x):
raise ValueError("The lasso contains no visible image pixels")
content_left = int(visible_x.min())
content_top = int(visible_y.min())
content_right = int(visible_x.max()) + 1
content_bottom = int(visible_y.max()) + 1
output_width = content_right - content_left + padding * 2
output_height = content_bottom - content_top + padding * 2
output = np.zeros((output_height, output_width, 4), dtype=np.float32)
output[
padding : padding + content_bottom - content_top,
padding : padding + content_right - content_left,
] = rgba[content_top:content_bottom, content_left:content_right]
output[output[:, :, 3] <= 0.0] = 0.0
return np.flipud(output).ravel(), (
left + content_left - padding,
top + content_top - padding,
left + content_right + padding,
top + content_bottom + padding,
)
def align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds):
from mathutils import Matrix
source_width, source_height = source_size
left, top, right, bottom = crop_bounds
x_min, x_max, y_min, y_max = plane_bounds(source_object)
local_width = x_max - x_min
local_height = y_max - y_min
crop_x_min = x_min + left * local_width / source_width
crop_x_max = x_min + right * local_width / source_width
crop_y_max = y_max - top * local_height / source_height
crop_y_min = y_max - bottom * local_height / source_height
center = (
(crop_x_min + crop_x_max) * 0.5,
(crop_y_min + crop_y_max) * 0.5,
0.0,
)
source_object.matrix_world = matrix_world @ Matrix.Translation(center)
source_object.empty_display_size = max(
crop_x_max - crop_x_min,
crop_y_max - crop_y_min,
)
source_object.empty_image_offset = (-0.5, -0.5)
def replace_empty_image(source_object, matrix_world, pixels, crop_bounds):
source_image = source_object.data
source_size = tuple(source_image.size)
left, top, right, bottom = crop_bounds
image = bpy.data.images.new(
f"{image_base_name(source_image)}.extract",
width=right - left,
height=bottom - top,
alpha=True,
)
try:
image.colorspace_settings.name = source_image.colorspace_settings.name
image.alpha_mode = "STRAIGHT"
image.pixels.foreach_set(pixels)
image.pack()
image.update()
align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds)
source_object.data = image
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def replace_empty_image_from_path(source_object, matrix_world, path, crop_bounds):
source_size = tuple(source_object.data.size)
colorspace = source_object.data.colorspace_settings.name
try:
image = bpy.data.images.load(str(path), check_existing=False)
except (OSError, RuntimeError) as error:
raise RuntimeError("Unable to load the extracted image") from error
try:
image.name = f"{image_base_name(source_object.data)}.extract"
image.colorspace_settings.name = colorspace
image.alpha_mode = "STRAIGHT"
image.pack()
align_empty_to_crop(source_object, matrix_world, source_size, crop_bounds)
source_object.data = image
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def image_lasso_target(context, source_object, keep_original):
if not keep_original:
return source_object
target = source_object.copy()
collection = (
source_object.users_collection[0]
if source_object.users_collection
else context.collection
)
collection.objects.link(target)
return target
def select_image_lasso_result(context, target):
bpy.ops.object.select_all(action="DESELECT")
target.select_set(True)
context.view_layer.objects.active = target
def selection_shape_property():
return bpy.props.EnumProperty(
items=(
("LASSO", "Lasso", "Draw a freehand image extraction region"),
("BOX", "Box", "Draw a rectangular image extraction region"),
),
default="BOX",
options={"HIDDEN", "SKIP_SAVE"},
)
def box_screen_path(path):
if len(path) < 2:
return ()
x0, y0 = path[0]
x1, y1 = path[-1]
if abs(x1 - x0) < 2.0 or abs(y1 - y0) < 2.0:
return ()
return ((x0, y0), (x1, y0), (x1, y1), (x0, y1))
def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0):
if dash_length <= 0.0 or gap_length < 0.0:
raise ValueError("Dash length must be positive and gap length cannot be negative")
pattern_length = dash_length + gap_length
segments = []
distance_offset = 0.0
for start, end in zip(vertices, vertices[1:]):
start_x, start_y = (float(value) for value in start)
end_x, end_y = (float(value) for value in end)
segment_length = math.hypot(end_x - start_x, end_y - start_y)
if segment_length <= 0.0:
continue
direction_x = (end_x - start_x) / segment_length
direction_y = (end_y - start_y) / segment_length
position = 0.0
while position < segment_length:
phase = (distance_offset + position) % pattern_length
if phase < dash_length:
run_length = min(dash_length - phase, segment_length - position)
segments.extend(
(
(
start_x + direction_x * position,
start_y + direction_y * position,
),
(
start_x + direction_x * (position + run_length),
start_y + direction_y * (position + run_length),
),
)
)
else:
run_length = min(pattern_length - phase, segment_length - position)
position += run_length
distance_offset += segment_length
return tuple(segments)
def draw_dashed_line(vertices):
import gpu
from gpu_extras.batch import batch_for_shader
segments = dashed_line_segments(vertices)
if not segments:
return
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
batch = batch_for_shader(shader, "LINES", {"pos": segments})
gpu.state.blend_set("ALPHA")
gpu.state.line_width_set(1.0)
shader.bind()
shader.uniform_float("color", (1.0, 1.0, 1.0, 0.9))
batch.draw(shader)
gpu.state.blend_set("NONE")
class ExtractImageLasso(bpy.types.Operator):
bl_idname = "anyimage.extract_image_lasso"
bl_label = "Extract Image"
bl_description = "Replace an Image Empty with its lassoed image region"
bl_options = {"REGISTER", "UNDO"}
source_object_name: hidden_string_property()
source_matrix_data: hidden_string_property()
selection_data: hidden_string_property()
contour_analysis: contour_analysis_property()
keep_original: keep_original_property()
padding: padding_property()
selection_shape: selection_shape_property()
_path = None
_handle = None
_area_pointer = 0
_region_pointer = 0
def draw(self, _context):
status = ai_status()
row = self.layout.row()
row.enabled = status["ready"]
row.prop(self, "contour_analysis")
self.layout.prop(self, "keep_original")
self.layout.prop(self, "padding")
if not status["ready"]:
self.layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)
@classmethod
def poll(cls, context):
return (
context.area is not None
and context.area.type == "VIEW_3D"
and is_image_empty(context.active_object)
)
def _draw_selection(self):
if not self._path or not drawing_in_region(
self._area_pointer, self._region_pointer
):
return
vertices = (
list(box_screen_path(self._path))
if self.selection_shape == "BOX"
else list(self._path)
)
if len(vertices) > 2:
vertices.append(vertices[0])
draw_dashed_line(vertices)
def _finish(self, context):
if self._handle is not None:
bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW")
self._handle = None
context.workspace.status_text_set(None)
if context.area is not None:
context.area.tag_redraw()
def cancel(self, context):
self._finish(context)
def invoke(self, context, event):
source_object = context.active_object
self.contour_analysis = (
ai_ready() and context.scene.anyimage_settings.image_lasso_contour_analysis
)
self.keep_original = context.scene.anyimage_settings.image_lasso_keep_original
self.padding = context.scene.anyimage_settings.image_lasso_padding
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
self.selection_data = ""
self._path = [(event.mouse_region_x, event.mouse_region_y)]
self._area_pointer = context.area.as_pointer()
self._region_pointer = context.region.as_pointer()
self._handle = bpy.types.SpaceView3D.draw_handler_add(
self._draw_selection, (), "WINDOW", "POST_PIXEL"
)
context.workspace.status_text_set(
f"Image {self.selection_shape.title()}: release LMB to extract, "
"RMB or Esc to cancel"
)
context.window_manager.modal_handler_add(self)
context.area.tag_redraw()
return {"RUNNING_MODAL"}
def modal(self, context, event):
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
self._finish(context)
return {"CANCELLED"}
if event.type == "MOUSEMOVE":
self._path.append((event.mouse_region_x, event.mouse_region_y))
context.area.tag_redraw()
return {"RUNNING_MODAL"}
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
self._path.append((event.mouse_region_x, event.mouse_region_y))
path = (
list(box_screen_path(self._path))
if self.selection_shape == "BOX"
else simplify_screen_path(self._path)
)
if len(path) < 3:
self.report({"WARNING"}, "Draw a larger image region")
self._finish(context)
return {"CANCELLED"}
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
self.report({"ERROR"}, "The source Image Empty is no longer available")
self._finish(context)
return {"CANCELLED"}
try:
matrix_world = deserialize_matrix(self.source_matrix_data)
polygon = screen_path_to_image_pixels(
path,
context.region,
context.region_data,
matrix_world,
plane_bounds(source_object),
tuple(source_object.data.size),
)
self.selection_data = json.dumps(polygon, separators=(",", ":"))
except (RuntimeError, TypeError, ValueError) as error:
self.report({"ERROR"}, str(error))
self._finish(context)
return {"CANCELLED"}
self._finish(context)
return self.execute(context)
return {"PASS_THROUGH"}
def execute(self, context):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
self.report({"ERROR"}, "The source Image Empty is no longer available")
return {"CANCELLED"}
try:
polygon = json.loads(self.selection_data)
crop_bounds = lasso_bounds(polygon)
if video_total_frames(source_object.data) > 1:
raise RuntimeError("Image Lasso only supports a still image")
if self.contour_analysis:
source_path, delete_input = prepare_image_input(
source_object.data, context.scene
)
result = bpy.ops.anyimage.analyze_image_lasso(
input_path=str(source_path),
delete_input=delete_input,
source_object_name=self.source_object_name,
source_matrix_data=self.source_matrix_data,
selection_data=self.selection_data,
keep_original=self.keep_original,
padding=self.padding,
)
if "CANCELLED" in result:
if delete_input:
remove_temporary_input(source_path)
return {"CANCELLED"}
return {"FINISHED"}
import numpy as np
image = source_object.data
source_pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(source_pixels)
pixels, crop_bounds = extract_lasso_pixels(
source_pixels, tuple(image.size), polygon
)
pixels, crop_bounds = crop_pixels_to_alpha(
pixels, crop_bounds, self.padding
)
target = image_lasso_target(context, source_object, self.keep_original)
try:
replace_empty_image(
target,
deserialize_matrix(self.source_matrix_data),
pixels,
crop_bounds,
)
except Exception:
if target is not source_object:
bpy.data.objects.remove(target, do_unlink=True)
raise
select_image_lasso_result(
context,
source_object if self.keep_original else target,
)
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
return {"FINISHED"}
class AnalyzeImageLasso(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.analyze_image_lasso"
bl_label = "Analyze Image Lasso"
job_type = "analyze-image-lasso"
starting_message = "Analyzing image contour"
input_path: bpy.props.StringProperty(options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
source_object_name: hidden_string_property()
source_matrix_data: hidden_string_property()
selection_data: hidden_string_property()
keep_original: keep_original_property()
padding: padding_property()
def request(self, _context):
require_environment()
require_model(BEN2_MODEL_KEY)
polygon = json.loads(self.selection_data)
lasso_bounds(polygon)
return {
"input": str(self.input_path),
"polygon": polygon,
"padding": self.padding,
"model": BEN2_MODEL_KEY,
"device": production_device().lower(),
}
def response(self, context, result):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty is no longer available")
crop_bounds = tuple(
json.loads(result.file("bounds").read_text(encoding="utf-8"))
)
target = image_lasso_target(context, source_object, self.keep_original)
try:
replace_empty_image_from_path(
target,
deserialize_matrix(self.source_matrix_data),
result.file("image"),
crop_bounds,
)
except Exception:
if target is not source_object:
bpy.data.objects.remove(target, do_unlink=True)
raise
select_image_lasso_result(
context,
source_object if self.keep_original else target,
)
push_undo("Extract Image")
return "Contoured image extraction is ready"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
class ActivateImageLassoTool(bpy.types.Operator):
bl_idname = "anyimage.activate_image_lasso_tool"
bl_label = "Image Extract Tool"
bl_description = "Activate the Image Box tool in the 3D Viewport"
bl_options = {"INTERNAL"}
@classmethod
def poll(cls, context):
return ExtractImageLasso.poll(context)
def execute(self, _context):
return bpy.ops.wm.tool_set_by_id(
name=ImageBoxTool.bl_idname, space_type="VIEW_3D"
)
class ImageBoxTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = "anyimage.image_box"
bl_label = "Image Box"
bl_description = "Replace an Image Empty with one boxed image region"
bl_icon = "ops.sculpt.border_mask"
bl_operator = ExtractImageLasso.bl_idname
bl_keymap = (
(
ExtractImageLasso.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("selection_shape", "BOX")]},
),
)
def draw_settings(context, layout, _tool):
draw_image_extract_settings(context, layout)
class ImageLassoTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = "anyimage.image_lasso"
bl_label = "Image Lasso"
bl_description = "Replace an Image Empty with one lassoed image region"
bl_icon = "ops.sculpt.lasso_mask"
bl_operator = ExtractImageLasso.bl_idname
bl_keymap = (
(
ExtractImageLasso.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("selection_shape", "LASSO")]},
),
)
def draw_settings(context, layout, _tool):
draw_image_extract_settings(context, layout)
def draw_image_extract_settings(context, layout):
settings = context.scene.anyimage_settings
status = ai_status()
row = layout.row()
row.enabled = status["ready"]
row.prop(settings, "image_lasso_contour_analysis")
layout.prop(settings, "image_lasso_keep_original")
layout.prop(settings, "image_lasso_padding")
if not status["ready"]:
layout.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)

View File

@@ -10,6 +10,7 @@ ORIGINAL_DISPLACEMENT_MIDLEVEL = 0.0
BUMP_BASE_DISTANCE = 0.001
DEPTH_PLANE_NODE_GROUP_NAME = "O DA3 Depth Plane"
MOGE_DEPTH_PLANE_NODE_GROUP_NAME = "O MoGe2 Depth Plane"
MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane"
NODE_ASSET_NAME = "O_AnyImage.blend"
MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement"
BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth"
@@ -647,6 +648,9 @@ def load_shadeless_node_group():
def load_depth_plane_node_group(group_name=DEPTH_PLANE_NODE_GROUP_NAME):
return load_asset_node_group(group_name)
def load_mesh_plane_node_group():
return load_asset_node_group(MESH_PLANE_NODE_GROUP_NAME)
def modifier_input_identifier(node_group, name):
for item in node_group.interface.items_tree:

View File

@@ -0,0 +1,228 @@
import bpy
from .image import (
image_user_settings,
is_image_empty,
material_data_name,
plane_bounds,
)
from .material import (
create_material,
load_mesh_plane_node_group,
modifier_input_identifier,
set_modifier_input,
)
MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane"
def is_mesh_plane_object(candidate):
if candidate is None or getattr(candidate, "type", None) != "MESH":
return False
return any(
getattr(getattr(modifier, "node_group", None), "name", None)
== MESH_PLANE_NODE_GROUP_NAME
for modifier in getattr(candidate, "modifiers", ())
)
def flatten_matrix(matrix):
return tuple(
float(matrix[row][column]) for row in range(4) for column in range(4)
)
def restore_matrix(values):
from mathutils import Matrix
return Matrix(
tuple(
tuple(values[row * 4 + column] for column in range(4))
for row in range(4)
)
)
def create_mesh_plane_mesh(name, bounds):
x_min, x_max, y_min, y_max = bounds
if x_max <= x_min or y_max <= y_min:
raise ValueError("The plane has invalid dimensions")
mesh = bpy.data.meshes.new(name)
mesh.from_pydata(
(
(x_min, y_min, 0.0),
(x_max, y_min, 0.0),
(x_max, y_max, 0.0),
(x_min, y_max, 0.0),
),
(),
((0, 1, 2, 3),),
)
mesh.update()
uv_layer = mesh.uv_layers.new(name="UVMap")
for loop, uv in zip(
mesh.loops,
((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)),
):
uv_layer.data[loop.index].uv = uv
return mesh
def add_mesh_plane_processing(
plane_object,
material,
subdivisions=0,
thickness=0.0,
):
node_group = load_mesh_plane_node_group()
modifier = plane_object.modifiers.new(
name=MESH_PLANE_NODE_GROUP_NAME,
type="NODES",
)
modifier.node_group = node_group
if hasattr(modifier, "show_group_selector"):
modifier.show_group_selector = False
for name, value in (
("Subdivide", max(0, int(subdivisions))),
("Thickness", max(0.0, float(thickness))),
("Material", material),
):
identifier = modifier_input_identifier(node_group, name)
set_modifier_input(modifier, identifier, value)
return modifier
def create_mesh_plane_object(
context,
name,
bounds,
material,
matrix_world=None,
subdivisions=0,
thickness=0.0,
):
mesh = create_mesh_plane_mesh(name, bounds)
mesh.materials.append(material)
plane = bpy.data.objects.new(name, mesh)
context.collection.objects.link(plane)
if matrix_world is not None:
plane.matrix_world = matrix_world.copy()
add_mesh_plane_processing(
plane,
material,
subdivisions=subdivisions,
thickness=thickness,
)
return plane
def finish_mesh_plane(context, source_object, plane_object, keep_original):
source_name = source_object.name
for selected in context.selected_objects:
selected.select_set(False)
plane_object.select_set(True)
context.view_layer.objects.active = plane_object
if not keep_original:
bpy.data.objects.remove(source_object, do_unlink=True)
plane_object.name = source_name
return plane_object
def build_mesh_plane(
context,
source_object,
keep_original,
shadeless=False,
matrix_world=None,
):
frame_start, frame_offset, frame_duration, use_cyclic = image_user_settings(
source_object
)
material = create_material(
material_data_name(source_object),
source_object.data,
abs(float(source_object.scale.z)),
shadeless=shadeless,
frame_start=frame_start,
color_frame_offset=frame_offset,
color_frame_duration=frame_duration,
use_cyclic=use_cyclic,
scene=context.scene,
)
plane = create_mesh_plane_object(
context,
source_object.name,
plane_bounds(source_object),
material,
matrix_world=(
source_object.matrix_world if matrix_world is None else matrix_world
),
)
return finish_mesh_plane(context, source_object, plane, keep_original)
class ConvertToMeshPlane(bpy.types.Operator):
bl_idname = "anyimage.convert_to_mesh_plane"
bl_label = "Convert to Mesh Plane"
bl_description = "Convert an image to an editable mesh plane without AI depth"
bl_options = {"REGISTER", "UNDO"}
shadeless: bpy.props.BoolProperty(
name="Shadeless",
description="Display the source color without scene lighting",
default=False,
)
keep_original: bpy.props.BoolProperty(
name="Keep Original",
description="Keep the source Image Empty after conversion",
default=False,
)
source_object_name: bpy.props.StringProperty(options={"HIDDEN"})
source_matrix_world: bpy.props.FloatVectorProperty(
size=16,
default=(
1.0, 0.0, 0.0, 0.0,
0.0, 1.0, 0.0, 0.0,
0.0, 0.0, 1.0, 0.0,
0.0, 0.0, 0.0, 1.0,
),
options={"HIDDEN"},
)
@classmethod
def poll(cls, context):
return is_image_empty(context.object) or is_mesh_plane_object(context.object)
def capture_source_state(self, source_object):
self.source_object_name = source_object.name
self.source_matrix_world = flatten_matrix(source_object.matrix_world)
def invoke(self, context, _event):
self.capture_source_state(context.object)
return self.execute(context)
def execute(self, context):
try:
source_object = context.object
if not self.source_object_name:
if not is_image_empty(source_object):
raise ValueError("Select an Image Empty to convert")
self.capture_source_state(source_object)
stored_source = bpy.data.objects.get(self.source_object_name)
if is_image_empty(stored_source):
source_object = stored_source
elif not is_image_empty(source_object):
raise ValueError("The source Image Empty is no longer available")
build_mesh_plane(
context,
source_object,
keep_original=self.keep_original,
shadeless=self.shadeless,
matrix_world=restore_matrix(self.source_matrix_world),
)
except (RuntimeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
return {"FINISHED"}

View File

@@ -1,8 +1,124 @@
import bpy
from ..runtime import JobOperatorBase, runtime
from ..properties import REQUIRED_MODEL_KEYS, model_record
from .common import HIDDEN, require_environment
from ..properties import (
REQUIRED_MODEL_KEYS,
ai_status,
model_download_size,
model_record,
)
from .common import ADDON_PACKAGE, HIDDEN, require_environment
def format_download_size(size):
return f"{size / 1_000_000:.0f} MB"
class SetupAIEnvironment(bpy.types.Operator):
bl_idname = "anyimage.setup_ai_environment"
bl_label = "Set Up AI Environment"
bl_description = "Install the environment and required AI models"
use_mirror: bpy.props.BoolProperty(
name="Use Mirror",
description=(
"Use the China mirrors for uv, Python and Python packages; "
"model download sources are unchanged"
),
default=False,
)
@classmethod
def poll(cls, _context):
return not runtime.server_busy()
def invoke(self, context, _event):
return context.window_manager.invoke_props_dialog(self, width=440)
def draw(self, _context):
layout = self.layout
status = ai_status()
if not status["environment_ready"]:
required_size = sum(
model_download_size(model_key)
for model_key in REQUIRED_MODEL_KEYS
)
layout.label(
text="AnyImage AI features run in an isolated Python environment."
)
layout.label(
text="This installs Python, ONNX Runtime and supporting packages."
)
layout.label(
text=(
"It also downloads MoGe-2 ViT-S Normal and BEN2 Base "
f"({format_download_size(required_size)})."
)
)
layout.label(
text="Allow about 600 MB of downloads and 1 GB of disk space."
)
layout.label(
text=(
"Installation usually takes 5–20 minutes, "
"depending on network speed."
)
)
layout.separator(type="LINE")
layout.prop(self, "use_mirror")
return
if status["ready"]:
layout.label(text="The AI Environment is ready.", icon="CHECKMARK")
return
labels = []
for model_key in status["missing_models"]:
model = model_record(model_key)
labels.append(model["label"] if model else model_key)
missing_size = sum(
model_download_size(model_key)
for model_key in status["missing_models"]
)
layout.label(
text="The AI Environment is already installed.",
icon="CHECKMARK",
)
layout.label(text=f"Required models to download: {', '.join(labels)}")
layout.label(text=f"Download size: {format_download_size(missing_size)}")
layout.label(text="The download usually takes 2–10 minutes.")
def execute(self, _context):
status = ai_status()
if status["ready"]:
return {"FINISHED"}
if status["environment_ready"]:
return bpy.ops.anyimage.download_required_models("INVOKE_DEFAULT")
installer = (
bpy.ops.anyimage.install_environment_mirror
if self.use_mirror
else bpy.ops.anyimage.install_environment
)
return installer("INVOKE_DEFAULT")
class OpenAIEnvironmentSettings(bpy.types.Operator):
bl_idname = "anyimage.open_ai_environment_settings"
bl_label = "AI Environment Settings"
bl_description = "Open the AnyImage add-on preferences"
def execute(self, context):
result = bpy.ops.screen.userpref_show("INVOKE_DEFAULT", section="ADDONS")
if "FINISHED" not in result:
return {"CANCELLED"}
for window in context.window_manager.windows:
for area in window.screen.areas:
if area.type != "PREFERENCES":
continue
with context.temp_override(window=window, area=area):
bpy.ops.preferences.addon_show(module=ADDON_PACKAGE)
return {"FINISHED"}
return {"FINISHED"}
class DownloadModel(JobOperatorBase, bpy.types.Operator):

View File

@@ -9,6 +9,7 @@ from .common import (
cleanup_temporary_input,
da3_parameters,
input_path,
invoke_ai_setup_if_needed,
moge2_parameters,
push_undo,
require_environment,
@@ -548,17 +549,17 @@ class ConvertToSurface(bpy.types.Operator):
@classmethod
def poll(cls, context):
from ..runtime import runtime
from ..properties import required_models_ready
return (
is_image_empty(context.object)
and video_total_frames(context.object.data) == 1
and runtime.environment_ready()
and not runtime.server_busy()
and required_models_ready()
)
def invoke(self, context, _event):
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
return context.window_manager.invoke_props_dialog(self, width=360)
def draw(self, context):

View File

@@ -1,7 +1,7 @@
import bpy
from .runtime import runtime
from .properties import BEN2_MODEL_KEY, model_catalog, model_ready
from .properties import BEN2_MODEL_KEY, model_ready
class ServerPanel(bpy.types.Panel):
bl_label = "Job Server"
@@ -19,18 +19,11 @@ class ServerPanel(bpy.types.Panel):
text="Environment is not installed",
icon="ERROR",
)
install_row = layout.row()
install_row.enabled = not running
install_row.operator(
"anyimage.install_environment",
text="Install Environment",
icon="IMPORT",
)
mirror_row = layout.row()
mirror_row.enabled = not running
mirror_row.operator(
"anyimage.install_environment_mirror",
text="Install Environment (Mirror)",
setup_row = layout.row()
setup_row.enabled = not running
setup_row.operator(
"anyimage.setup_ai_environment",
text="Set Up AI Environment…",
icon="IMPORT",
)
return
@@ -109,54 +102,6 @@ class ServerPanel(bpy.types.Panel):
)
class ModelsPanel(bpy.types.Panel):
bl_label = "Models"
bl_idname = "ANYIMAGE_PT_models"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "AnyImage"
bl_options = {"DEFAULT_CLOSED"}
bl_order = 1
@classmethod
def poll(cls, _context):
return runtime.environment_ready()
def draw(self, _context):
layout = self.layout
running = runtime.server_busy()
models = model_catalog()
for model in models:
if model.get("family") == "moge2":
self._draw_model(layout, model, running)
for model in models:
if model.get("family") == "da3" and model.get("enabled"):
self._draw_model(layout, model, running)
layout.separator(type="LINE")
ben2 = next(
(model for model in models if model.get("key") == BEN2_MODEL_KEY),
None,
)
if ben2:
self._draw_model(layout, ben2, running)
def _draw_model(self, layout, model, running):
row = layout.row(align=True)
row.label(text=model["label"])
if model.get("ready"):
row.label(text="Ready", icon="CHECKMARK")
else:
download_row = row.row(align=True)
download_row.enabled = runtime.environment_ready() and not running
operator = download_row.operator(
"anyimage.download_model",
text="Download",
icon="IMPORT",
)
operator.model = model["key"]
class DebugPanel(bpy.types.Panel):
bl_label = "Debug"
bl_idname = "ANYIMAGE_PT_debug"

View File

@@ -1,7 +1,13 @@
import bpy
from .runtime import DEFAULT_STORAGE_ROOT, runtime
from .properties import device_enum_items
from .properties import (
BEN2_MODEL_KEY,
ai_setup_label,
ai_status,
device_enum_items,
model_catalog,
)
class AnyImagePreferences(bpy.types.AddonPreferences):
@@ -31,27 +37,60 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
layout.prop(self, "storage_root")
layout.prop(self, "device")
layout.separator()
status = ai_status()
layout.label(text=f"Environment: {runtime.environment_directory()}")
if runtime.environment_ready():
layout.label(text="Environment is installed", icon="CHECKMARK")
if not status["environment_ready"]:
layout.label(text="Environment is not installed", icon="ERROR")
setup_row = layout.row()
setup_row.enabled = not runtime.server_busy()
setup_row.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)
else:
install_row = layout.row()
install_row.enabled = runtime.active_job is None
install_row.operator(
"anyimage.install_environment",
text="Install Environment",
icon="IMPORT",
)
mirror_row = layout.row()
mirror_row.enabled = runtime.active_job is None
mirror_row.operator(
"anyimage.install_environment_mirror",
text="Install Environment (Mirror)",
icon="IMPORT",
layout.label(text="Environment is installed", icon="CHECKMARK")
if not status["ready"]:
required_row = layout.row()
required_row.enabled = not runtime.server_busy()
required_row.operator(
"anyimage.setup_ai_environment",
text=ai_setup_label(status),
icon="IMPORT",
)
layout.separator(type="LINE")
layout.label(text="Models")
models = model_catalog()
for family in ("moge2", "da3"):
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",
text="Open Server Log",
icon="TEXT",
)
def _draw_model(self, layout, model):
row = layout.row(align=True)
row.label(text=model["label"])
if model.get("ready"):
row.label(text="Ready", icon="CHECKMARK")
return
download_row = row.row(align=True)
download_row.enabled = not runtime.server_busy()
operator = download_row.operator(
"anyimage.download_model",
text="Download",
icon="IMPORT",
)
operator.model = model["key"]

View File

@@ -104,7 +104,43 @@ def model_ready(model_key):
def required_models_ready():
return all(model_ready(model_key) for model_key in REQUIRED_MODEL_KEYS)
return not ai_status()["missing_models"]
def ai_status():
environment_ready = runtime.environment_ready()
ready_models = {
model["key"] for model in model_catalog() if model.get("ready")
}
missing_models = tuple(
model_key
for model_key in REQUIRED_MODEL_KEYS
if model_key not in ready_models
)
return {
"environment_ready": environment_ready,
"missing_models": missing_models,
"ready": environment_ready and not missing_models,
}
def ai_ready():
return ai_status()["ready"]
def ai_setup_label(status=None):
if status is None:
status = ai_status()
return (
"Download Required Models…"
if status["environment_ready"]
else "Set Up AI Environment…"
)
def model_download_size(model_key):
model = shared_model_catalog.get_downloadable_model(model_key)
return sum(size for _path, size, _checksum in model.r2_files)
def model_catalog():
@@ -244,6 +280,35 @@ def balloon_contour_analysis_property():
)
def image_lasso_contour_analysis_property():
return bpy.props.BoolProperty(
name="Contour",
description=(
"Use BEN2 to refine the lasso Alpha while preserving the source colors"
),
default=False,
)
def image_lasso_keep_original_property():
return bpy.props.BoolProperty(
name="Keep Original",
description="Keep the source Image Empty and create a cropped copy",
default=False,
)
def image_lasso_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",
@@ -270,6 +335,9 @@ def balloon_radial_zoom_property(scale):
class AnyImageSettings(bpy.types.PropertyGroup):
image_lasso_contour_analysis: image_lasso_contour_analysis_property()
image_lasso_keep_original: image_lasso_keep_original_property()
image_lasso_padding: image_lasso_padding_property()
balloon_mode: balloon_mode_property()
balloon_thickness: balloon_thickness_property()
balloon_resolution: balloon_resolution_property()

View File

@@ -10,6 +10,7 @@ from .jobs.debug_ben2 import run as _debug_ben2
from .jobs.debug_da3 import run as _debug_da3
from .jobs.debug_moge2 import run as _debug_moge2
from .jobs.frames import run as _extract_frames
from .jobs.image_lasso import run as _analyze_image_lasso
from .model_manager import ModelManager, ModelSessionCache
@@ -38,6 +39,11 @@ def analyze_balloon(context, parameters):
return _analyze_balloon(context, parameters)
@server.job("analyze-image-lasso")
def analyze_image_lasso(context, parameters):
return _analyze_image_lasso(context, parameters)
@server.job("remove-background")
def remove_background(context, parameters):
return _remove_background(context, parameters)

View File

@@ -36,6 +36,7 @@ def run(context, parameters):
device,
model_cache=model_manager,
cancel_check=context.check_cancelled,
preserve_input_alpha=True,
)
context.check_cancelled()
context.progress(0.92, "Writing transparent PNG")
@@ -59,6 +60,7 @@ def run(context, parameters):
model_cache=model_manager,
cancel_check=context.check_cancelled,
output_prefix="color",
preserve_input_alpha=True,
)
finally:
if temporary_directory is not None:

View File

@@ -23,6 +23,7 @@ def write_optional(
device,
model_cache=model_manager,
cancel_check=context.check_cancelled,
preserve_input_alpha=True,
)
path = context.directory / "color.0001.png"
foreground.convert("RGBA").save(
@@ -43,6 +44,7 @@ def write_optional(
model_cache=model_manager,
cancel_check=context.check_cancelled,
output_prefix="color",
preserve_input_alpha=True,
)
paths = sorted(context.directory.glob("color.*.png"))
print(

View File

@@ -0,0 +1,151 @@
import json
import math
import time
from ..models.ben2 import infer_foreground
def lasso_bounds(polygon):
import numpy as np
points = np.asarray(polygon, dtype=np.float64)
if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2:
raise ValueError("The lasso does not define a valid image region")
if not np.isfinite(points).all():
raise ValueError("The lasso contains invalid image coordinates")
left = int(math.floor(float(points[:, 0].min())))
top = int(math.floor(float(points[:, 1].min())))
right = int(math.ceil(float(points[:, 0].max())))
bottom = int(math.ceil(float(points[:, 1].max())))
if right <= left or bottom <= top:
raise ValueError("The lasso does not define a valid image region")
return left, top, right, bottom
def create_lasso_canvas(input_path, polygon):
"""Create an unbounded lasso canvas with transparent black outside."""
import numpy as np
from PIL import Image, ImageDraw
with Image.open(input_path) as opened_image:
source = opened_image.convert("RGBA")
left, top, right, bottom = lasso_bounds(polygon)
if right <= 0 or bottom <= 0 or left >= source.width or top >= source.height:
raise ValueError("The lasso does not overlap the source image")
canvas = Image.new("RGBA", (right - left, bottom - top), (0, 0, 0, 0))
canvas.paste(source, (-left, -top))
relative = [(float(x) - left, float(y) - top) for x, y in polygon]
selection = Image.new("L", canvas.size, 0)
ImageDraw.Draw(selection).polygon(relative, fill=255)
selection_mask = np.asarray(selection, dtype=np.uint8) > 0
pixels = np.asarray(canvas, dtype=np.uint8).copy()
pixels[~selection_mask] = 0
return Image.fromarray(pixels, mode="RGBA"), selection_mask, (
left,
top,
right,
bottom,
)
def apply_contour_alpha(canvas, selection_mask, foreground):
"""Keep source RGB and combine source, lasso, and BEN2 alpha."""
import numpy as np
from PIL import Image
source = np.asarray(canvas.convert("RGBA"), dtype=np.uint8).copy()
alpha = np.asarray(foreground.convert("RGBA").getchannel("A"), dtype=np.uint8)
if alpha.shape != selection_mask.shape:
alpha = np.asarray(
Image.fromarray(alpha, mode="L").resize(
canvas.size, Image.Resampling.BILINEAR
),
dtype=np.uint8,
)
source[:, :, 3] = np.where(
selection_mask,
np.minimum(source[:, :, 3], alpha),
0,
).astype(np.uint8)
source[source[:, :, 3] == 0, :3] = 0
return Image.fromarray(source, mode="RGBA")
def crop_to_alpha(image, bounds, padding=0):
"""Crop an RGBA image to nonzero Alpha and expand it by pixel padding."""
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):
raise ValueError("The lasso contains no visible image pixels")
content_left = int(visible_x.min())
content_top = int(visible_y.min())
content_right = int(visible_x.max()) + 1
content_bottom = int(visible_y.max()) + 1
output = Image.new(
"RGBA",
(
content_right - content_left + padding * 2,
content_bottom - content_top + padding * 2,
),
(0, 0, 0, 0),
)
output.paste(image, (padding - content_left, padding - 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,
)
def run(context, parameters):
"""Use BEN2 to refine an Image Lasso without changing its canvas bounds."""
started = time.perf_counter()
context.progress(0.05, "Preparing image lasso")
context.check_cancelled()
output_directory = context.directory
output_directory.mkdir(parents=True, exist_ok=True)
canvas, selection_mask, bounds = create_lasso_canvas(
parameters["input"], parameters["polygon"]
)
lasso_path = output_directory / "lasso-input.png"
import numpy as np
from PIL import Image
analysis_pixels = np.asarray(canvas, dtype=np.uint8).copy()
analysis_pixels[~selection_mask] = 0
Image.fromarray(analysis_pixels, mode="RGBA").save(
lasso_path, format="PNG", compress_level=1
)
model_manager = context.resource("model_manager")
context.progress(0.2, f"Loading BEN2 for {parameters['device'].upper()}")
foreground, _load_ms, _inference_ms = infer_foreground(
lasso_path,
model_manager.directory(parameters["model"]),
parameters["device"],
model_cache=model_manager,
cancel_check=context.check_cancelled,
)
context.check_cancelled()
context.progress(0.9, "Applying contour alpha")
output, bounds = crop_to_alpha(
apply_contour_alpha(canvas, selection_mask, foreground),
bounds,
int(parameters.get("padding", 0)),
)
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 contour finished in {elapsed_ms:.0f} ms"
context.progress(1.0, message)
return {"image": output_path.name, "bounds": bounds_path.name}

View File

@@ -10,8 +10,9 @@ def infer_foreground(
device,
model_cache=None,
cancel_check=None,
preserve_input_alpha=False,
):
from PIL import Image
from PIL import Image, ImageChops
input_path = Path(input_path)
check_cancelled(cancel_check)
@@ -30,10 +31,29 @@ def infer_foreground(
check_cancelled(cancel_check)
with Image.open(input_path) as opened_image:
image = opened_image.convert("RGB")
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")
inference_started = time.perf_counter()
foreground = infer(model, image)
inference_ms = (time.perf_counter() - inference_started) * 1000.0
if preserve_input_alpha:
foreground_alpha = foreground.convert("RGBA").getchannel("A")
source_alpha = source_rgba.getchannel("A")
if source_alpha.size != foreground_alpha.size:
source_alpha = source_alpha.resize(
foreground_alpha.size,
Image.Resampling.BILINEAR,
)
source_rgba.putalpha(ImageChops.multiply(source_alpha, foreground_alpha))
foreground = source_rgba
check_cancelled(cancel_check)
return foreground, load_ms, inference_ms
@@ -49,6 +69,7 @@ def infer_foreground_frames(
model_cache=None,
cancel_check=None,
output_prefix="foreground",
preserve_input_alpha=False,
):
"""Run BEN2 on every frame and write numbered transparent PNG files."""
total_load_ms = 0.0
@@ -65,6 +86,7 @@ def infer_foreground_frames(
device,
model_cache=model_cache,
cancel_check=cancel_check,
preserve_input_alpha=preserve_input_alpha,
)
output_path = output_directory / f"{output_prefix}.{index + 1:04d}.png"
foreground.convert("RGBA").save(

View File

@@ -90,8 +90,12 @@ class AddonTest(unittest.TestCase):
cls.depth_build = importlib.import_module(
"anyimage.operators.depth_plane.build"
)
cls.mesh_plane = importlib.import_module(
"anyimage.operators.mesh_plane"
)
cls.image_background = importlib.import_module("anyimage.operators.background")
cls.image_mesh = importlib.import_module("anyimage.operators.surface")
cls.image_lasso = importlib.import_module("anyimage.operators.image_lasso")
cls.balloon = importlib.import_module("anyimage.operators.balloon.operator")
cls.balloon_geometry = importlib.import_module(
"anyimage.operators.balloon.geometry"
@@ -514,65 +518,230 @@ class AddonTest(unittest.TestCase):
{"models": ["MOGE2_VITS_NORMAL", "BEN2_BASE"]},
)
def test_image_menu_offers_required_model_repair_when_missing(self):
calls = []
class Layout:
operator_context = ""
enabled = True
def __init__(self, enabled=True):
self.enabled = enabled
def row(self):
return Layout(self.enabled)
def operator(self, identifier, **_options):
calls.append((identifier, self.enabled))
return SimpleNamespace()
def separator(self):
calls.append(("separator", self.enabled))
menu = self.addon.AnyImageImageMenu()
menu.layout = Layout()
def test_ai_status_checks_environment_and_required_model_files(self):
properties = self.addon.properties
with (
patch.object(self.addon.runtime, "environment_ready", return_value=True),
patch.object(
self.addon.runtime,
"environment_ready",
return_value=True,
),
patch(
"anyimage.properties.required_models_ready",
return_value=False,
properties,
"model_catalog",
return_value=(
{"key": "MOGE2_VITS_NORMAL", "ready": True},
{"key": "BEN2_BASE", "ready": False},
),
),
):
menu.draw(None)
status = properties.ai_status()
self.assertTrue(status["environment_ready"])
self.assertEqual(status["missing_models"], ("BEN2_BASE",))
self.assertFalse(status["ready"])
self.assertEqual(properties.ai_setup_label(status), "Download Required Models…")
self.assertEqual(
calls[0],
("anyimage.activate_balloon_tool", True),
with (
patch.object(self.addon.runtime, "environment_ready", return_value=False),
patch.object(
properties,
"model_catalog",
return_value=tuple(
{"key": key, "ready": True}
for key in properties.REQUIRED_MODEL_KEYS
),
),
):
status = properties.ai_status()
self.assertFalse(status["ready"])
self.assertEqual(properties.ai_setup_label(status), "Set Up AI Environment…")
def test_setup_ai_environment_selects_the_needed_install_step(self):
calls = []
ops = SimpleNamespace(
anyimage=SimpleNamespace(
install_environment=lambda *_args: calls.append("official")
or {"FINISHED"},
install_environment_mirror=lambda *_args: calls.append("mirror")
or {"FINISHED"},
download_required_models=lambda *_args: calls.append("models")
or {"FINISHED"},
)
)
operator = self.model_operators.SetupAIEnvironment()
with patch.object(self.fake_bpy, "ops", ops, create=True):
operator.use_mirror = True
with patch.object(
self.model_operators,
"ai_status",
return_value={
"environment_ready": False,
"missing_models": ("MOGE2_VITS_NORMAL", "BEN2_BASE"),
"ready": False,
},
):
self.assertEqual(operator.execute(None), {"FINISHED"})
with patch.object(
self.model_operators,
"ai_status",
return_value={
"environment_ready": True,
"missing_models": ("BEN2_BASE",),
"ready": False,
},
):
self.assertEqual(operator.execute(None), {"FINISHED"})
self.assertEqual(calls, ["mirror", "models"])
def test_ai_environment_ui_types_are_registered(self):
self.assertIn(self.model_operators.SetupAIEnvironment, self.addon.CLASSES)
self.assertIn(
("anyimage.download_required_models", True),
calls,
self.model_operators.OpenAIEnvironmentSettings,
self.addon.CLASSES,
)
self.assertIn(("anyimage.convert_to_depth_plane", False), calls)
self.assertIn(("anyimage.convert_to_surface", False), calls)
self.assertIn(("anyimage.remove_image_background", False), calls)
panel = importlib.import_module("anyimage.panel")
self.assertFalse(hasattr(panel, "ModelsPanel"))
def test_image_menu_keeps_disabled_features_when_environment_is_missing(self):
def test_mesh_plane_operator_is_registered_with_material_options(self):
operator = self.mesh_plane.ConvertToMeshPlane
self.assertIn(operator, self.addon.CLASSES)
self.assertEqual(operator.bl_idname, "anyimage.convert_to_mesh_plane")
self.assertFalse(operator.__annotations__["shadeless"]["default"])
self.assertFalse(operator.__annotations__["keep_original"]["default"])
self.assertIn("invoke", operator.__dict__)
self.assertNotIn("draw", operator.__dict__)
self.assertIn("REGISTER", operator.bl_options)
self.assertIn("UNDO", operator.bl_options)
image_empty = SimpleNamespace(
type="EMPTY",
empty_display_type="IMAGE",
data=object(),
)
mesh_plane = SimpleNamespace(
type="MESH",
modifiers=[
SimpleNamespace(node_group=SimpleNamespace(name="O Mesh Plane"))
],
)
plain_mesh = SimpleNamespace(type="MESH", modifiers=[])
self.assertTrue(operator.poll(SimpleNamespace(object=image_empty)))
self.assertTrue(operator.poll(SimpleNamespace(object=mesh_plane)))
self.assertFalse(operator.poll(SimpleNamespace(object=plain_mesh)))
def test_mesh_plane_redo_keeps_the_original_world_matrix(self):
operator = self.mesh_plane.ConvertToMeshPlane()
operator.shadeless = False
operator.keep_original = True
operator.source_object_name = ""
original_matrix = (
(1.0, 0.0, 0.0, 3.0),
(0.0, 1.0, 0.0, -2.0),
(0.0, 0.0, 1.0, 7.0),
(0.0, 0.0, 0.0, 1.0),
)
source = SimpleNamespace(
name="Source Image",
type="EMPTY",
empty_display_type="IMAGE",
data=object(),
matrix_world=original_matrix,
)
context = SimpleNamespace(object=source)
matrices = []
def capture_build(*_args, **kwargs):
matrices.append(kwargs["matrix_world"])
with (
patch.object(
self.mesh_plane.bpy.data.objects,
"get",
return_value=source,
),
patch.object(
self.mesh_plane,
"restore_matrix",
side_effect=lambda values: tuple(values),
),
patch.object(
self.mesh_plane,
"build_mesh_plane",
side_effect=capture_build,
),
):
self.assertEqual(operator.invoke(context, None), {"FINISHED"})
source.matrix_world = (
(1.0, 0.0, 0.0, 0.0),
(0.0, 1.0, 0.0, 0.0),
(0.0, 0.0, 1.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
self.assertEqual(operator.execute(context), {"FINISHED"})
expected = self.mesh_plane.flatten_matrix(original_matrix)
self.assertEqual(matrices, [expected, expected])
def test_mesh_plane_uses_a_single_quad_and_geometry_nodes(self):
import inspect
mesh_source = inspect.getsource(self.mesh_plane.create_mesh_plane_mesh)
processing_source = inspect.getsource(
self.mesh_plane.add_mesh_plane_processing
)
self.assertIn("((0, 1, 2, 3),)", mesh_source)
self.assertIn('("Subdivide", max(0, int(subdivisions)))', processing_source)
self.assertIn('("Thickness", max(0.0, float(thickness)))', processing_source)
self.assertIn('("Material", material)', processing_source)
def test_image_menu_keeps_ai_features_clickable_and_offers_models(self):
calls = []
labels = {}
class Layout:
operator_context = ""
enabled = True
def __init__(self, enabled=True):
self.enabled = enabled
def operator(self, identifier, **options):
calls.append((identifier, self.enabled))
labels[identifier] = options.get("text")
return SimpleNamespace()
def row(self):
return Layout(self.enabled)
def separator(self):
calls.append(("separator", self.enabled))
menu = self.addon.AnyImageImageMenu()
menu.layout = Layout()
with patch(
"anyimage.properties.ai_status",
return_value={
"environment_ready": True,
"missing_models": ("BEN2_BASE",),
"ready": False,
},
):
menu.draw(None)
self.assertEqual(
calls,
[
("anyimage.convert_to_mesh_plane", True),
("anyimage.convert_to_depth_plane", True),
("anyimage.convert_to_surface", True),
("anyimage.remove_image_background", True),
("separator", True),
("anyimage.setup_ai_environment", True),
],
)
self.assertEqual(
labels["anyimage.setup_ai_environment"],
"Download Required Models…",
)
def test_image_menu_opens_environment_settings_when_ai_is_ready(self):
calls = []
class Layout:
operator_context = ""
enabled = True
def operator(self, identifier, **_options):
calls.append((identifier, self.enabled))
@@ -583,29 +752,18 @@ class AddonTest(unittest.TestCase):
menu = self.addon.AnyImageImageMenu()
menu.layout = Layout()
with (
patch.object(
self.addon.runtime,
"environment_ready",
return_value=False,
),
patch(
"anyimage.properties.required_models_ready",
return_value=False,
),
with patch(
"anyimage.properties.ai_status",
return_value={
"environment_ready": True,
"missing_models": (),
"ready": True,
},
):
menu.draw(None)
self.assertEqual(
calls[0],
("anyimage.activate_balloon_tool", True),
)
self.assertIn(("anyimage.install_environment", True), calls)
self.assertIn(("anyimage.install_environment_mirror", True), calls)
self.assertNotIn(("anyimage.download_required_models", True), calls)
self.assertIn(("anyimage.convert_to_depth_plane", False), calls)
self.assertIn(("anyimage.convert_to_surface", False), calls)
self.assertIn(("anyimage.remove_image_background", False), calls)
self.assertIn(("anyimage.open_ai_environment_settings", True), calls)
self.assertNotIn(("anyimage.setup_ai_environment", True), calls)
def test_balloon_contour_is_smooth_and_evenly_resampled(self):
import numpy as np
@@ -621,6 +779,157 @@ class AddonTest(unittest.TestCase):
self.assertGreater(self.balloon_geometry.polygon_area(contour), 0.0)
self.assertLess(float(lengths.std() / lengths.mean()), 0.15)
def test_image_lasso_clears_rgba_outside_the_selection(self):
import numpy as np
top_down = np.full((3, 3, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32)
source = np.flipud(top_down).ravel()
pixels, bounds = self.image_lasso.extract_lasso_pixels(
source,
(3, 3),
((0, 0), (3, 0), (0, 3)),
)
result = np.flipud(np.asarray(pixels).reshape((3, 3, 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(float(result[0, 0, 3]), 1.0)
def test_image_lasso_clears_hidden_rgb_from_source_transparency(self):
import numpy as np
top_down = np.full((2, 2, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32)
top_down[0, 0, 3] = 0.0
pixels, _bounds = self.image_lasso.extract_lasso_pixels(
np.flipud(top_down).ravel(),
(2, 2),
((0, 0), (2, 0), (2, 2), (0, 2)),
)
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))
def test_image_lasso_keeps_out_of_image_canvas_transparent(self):
import numpy as np
top_down = np.ones((2, 2, 4), dtype=np.float32)
pixels, bounds = self.image_lasso.extract_lasso_pixels(
np.flipud(top_down).ravel(),
(2, 2),
((-1, -1), (2, -1), (2, 2), (-1, 2)),
)
result = np.flipud(np.asarray(pixels).reshape((3, 3, 4)))
self.assertEqual(bounds, (-1, -1, 2, 2))
self.assertEqual(float(result[0, 0, 3]), 0.0)
self.assertEqual(float(result[1, 1, 3]), 1.0)
def test_image_lasso_rejects_a_fully_out_of_image_region(self):
import numpy as np
with self.assertRaisesRegex(ValueError, "does not overlap"):
self.image_lasso.extract_lasso_pixels(
np.ones(2 * 2 * 4, dtype=np.float32),
(2, 2),
((3, 3), (4, 3), (4, 4), (3, 4)),
)
def test_image_lasso_crops_to_visible_alpha_with_pixel_padding(self):
import numpy as np
rgba = np.zeros((4, 5, 4), dtype=np.float32)
rgba[1:3, 2:4] = (0.2, 0.4, 0.6, 1.0)
pixels, bounds = self.image_lasso.crop_pixels_to_alpha(
np.flipud(rgba).ravel(),
(10, 20, 15, 24),
padding=1,
)
result = np.flipud(np.asarray(pixels).reshape((4, 4, 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))
def test_image_lasso_tool_and_operators_are_registered(self):
self.assertIn(self.image_lasso.ExtractImageLasso, self.addon.CLASSES)
self.assertIn(self.image_lasso.AnalyzeImageLasso, self.addon.CLASSES)
self.assertIn(self.image_lasso.ActivateImageLassoTool, self.addon.CLASSES)
self.assertNotIn(self.image_lasso.ImageLassoTool, self.addon.CLASSES)
self.assertNotIn(self.image_lasso.ImageBoxTool, self.addon.CLASSES)
self.assertEqual(
self.image_lasso.ImageLassoTool.bl_operator,
self.image_lasso.ExtractImageLasso.bl_idname,
)
self.assertEqual(
self.image_lasso.ImageLassoTool.bl_icon,
"ops.sculpt.lasso_mask",
)
self.assertEqual(
self.image_lasso.ImageBoxTool.bl_icon,
"ops.sculpt.border_mask",
)
self.assertEqual(
self.image_lasso.ImageBoxTool.bl_keymap[0][2]["properties"],
[("selection_shape", "BOX")],
)
self.assertEqual(
self.image_lasso.ImageLassoTool.bl_keymap[0][2]["properties"],
[("selection_shape", "LASSO")],
)
self.assertNotIn("bl_cursor", self.image_lasso.ImageBoxTool.__dict__)
self.assertNotIn("bl_cursor", self.image_lasso.ImageLassoTool.__dict__)
self.assertEqual(
self.image_lasso.ExtractImageLasso.__annotations__["selection_shape"][
"default"
],
"BOX",
)
operator = self.image_lasso.ExtractImageLasso
self.assertIn("keep_original", operator.__annotations__)
self.assertFalse(operator.__annotations__["keep_original"]["default"])
self.assertIn("padding", operator.__annotations__)
self.assertEqual(operator.__annotations__["padding"]["default"], 0)
settings = self.addon.properties.AnyImageSettings.__annotations__
self.assertFalse(settings["image_lasso_keep_original"]["default"])
self.assertEqual(settings["image_lasso_padding"]["default"], 0)
def test_image_box_builds_a_rectangular_screen_path(self):
self.assertEqual(
self.image_lasso.box_screen_path(((2, 3), (8, 9))),
((2, 3), (8, 3), (8, 9), (2, 9)),
)
self.assertEqual(
self.image_lasso.box_screen_path(((2, 3), (3, 9))),
(),
)
def test_image_extract_selection_uses_white_dashed_segments(self):
segments = self.image_lasso.dashed_line_segments(
((0, 0), (10, 0)), dash_length=3.0, gap_length=2.0
)
self.assertEqual(
segments,
((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):
import inspect
source = inspect.getsource(self.image_lasso.ActivateImageLassoTool.execute)
self.assertIn("name=ImageBoxTool.bl_idname", source)
def test_keep_original_restores_the_source_empty_selection(self):
import inspect
execute_source = inspect.getsource(self.image_lasso.ExtractImageLasso.execute)
response_source = inspect.getsource(self.image_lasso.AnalyzeImageLasso.response)
expected = "source_object if self.keep_original else target"
self.assertIn(expected, execute_source)
self.assertIn(expected, response_source)
def test_balloon_interior_points_have_uniform_spacing(self):
import numpy as np
@@ -909,6 +1218,10 @@ class AddonTest(unittest.TestCase):
response_source = inspect.getsource(self.balloon.AnalyzeBalloon.response)
tool_source = inspect.getsource(self.balloon.BalloonTool.draw_settings)
self.assertIn("bpy.ops.anyimage.analyze_balloon", execute_source)
self.assertIn("ready = ai_ready()", invoke_source)
self.assertIn("status = ai_status()", tool_source)
self.assertIn('"anyimage.setup_ai_environment"', tool_source)
self.assertIn("ai_setup_label(status)", tool_source)
self.assertIn("prepare_image_input(", execute_source)
self.assertIn(
"settings.balloon_contour_analysis",
@@ -1360,7 +1673,7 @@ class AddonTest(unittest.TestCase):
self.assertIn(expected, original_depth_source)
self.assertIn(expected, depth_source)
def test_image_operators_require_environment_and_required_models(self):
def test_image_operators_stay_clickable_until_server_is_busy(self):
context = SimpleNamespace(
object=SimpleNamespace(data=SimpleNamespace()),
)
@@ -1370,31 +1683,13 @@ class AddonTest(unittest.TestCase):
(self.image_background, self.image_background.RemoveImageBackground),
)
states = (
(False, True, False),
(True, False, False),
(True, True, True),
)
for environment_ready, models_ready, expected in states:
for busy, expected in ((False, True), (True, False)):
with (
self.subTest(
environment_ready=environment_ready,
models_ready=models_ready,
expected=expected,
),
patch.object(
self.addon.runtime,
"environment_ready",
return_value=environment_ready,
),
self.subTest(busy=busy),
patch.object(
self.addon.runtime,
"server_busy",
return_value=False,
),
patch(
"anyimage.properties.required_models_ready",
return_value=models_ready,
return_value=busy,
),
):
for module, operator in operators:
@@ -1500,6 +1795,14 @@ class AddonTest(unittest.TestCase):
self.assertEqual(attribute, "server")
def test_runtime_registers_system_operators_and_status_bar(self):
self.assertIn(
self.addon.clipboard_image.PasteClipboardImage,
self.addon.CLASSES,
)
self.assertIn(
self.addon.clipboard_image.TrackNativeCopy,
self.addon.CLASSES,
)
self.addon.register()
system_classes = self.addon.runtime.operator_classes()
@@ -1507,12 +1810,26 @@ class AddonTest(unittest.TestCase):
self.assertEqual(
self.registered_tools,
[
(
self.image_lasso.ImageBoxTool,
{
"group": True,
"separator": True,
},
),
(
self.image_lasso.ImageLassoTool,
{
"after": {self.image_lasso.ImageBoxTool.bl_idname},
"separator": False,
},
),
(
self.balloon.BalloonTool,
{
"separator": False,
},
)
),
],
)
self.assertEqual(len(self.status_bar_draws), 1)

View File

@@ -0,0 +1,892 @@
import importlib
import struct
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import Mock, patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
class ClipboardImageTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.temporary_directory = tempfile.TemporaryDirectory()
cls.remove_anyimage_modules()
fake_bpy = ModuleType("bpy")
fake_bpy.app = SimpleNamespace(version=(5, 1, 0))
fake_bpy.props = SimpleNamespace(
BoolProperty=lambda **options: options,
CollectionProperty=lambda **options: options,
EnumProperty=lambda **options: options,
FloatProperty=lambda **options: options,
FloatVectorProperty=lambda **options: options,
IntProperty=lambda **options: options,
PointerProperty=lambda **options: options,
StringProperty=lambda **options: options,
)
fake_bpy.types = SimpleNamespace(
GizmoGroup=object,
Image=object,
Menu=object,
Operator=object,
Panel=object,
PropertyGroup=object,
Scene=object,
TOPBAR_MT_file_defaults=SimpleNamespace(),
)
fake_bpy.utils = SimpleNamespace()
fake_bpy.context = SimpleNamespace(
window_manager=SimpleNamespace(
keyconfigs=SimpleNamespace(addon=None),
)
)
fake_bpy.data = SimpleNamespace()
sys.modules["bpy"] = fake_bpy
cls.fake_bpy = fake_bpy
cls.addon = ModuleType("anyimage")
cls.addon.__path__ = [str(PROJECT_ROOT / "src" / "anyimage")]
sys.modules["anyimage"] = cls.addon
cls.addon.clipboard_image = importlib.import_module(
"anyimage.clipboard_image"
)
cls.actions = cls.addon.clipboard_image.actions
cls.clipboard = cls.addon.clipboard_image.clipboard
@classmethod
def tearDownClass(cls):
sys.modules.pop("bpy", None)
cls.remove_anyimage_modules()
cls.temporary_directory.cleanup()
def test_operators_use_anyimage_prefix_and_plain_properties(self):
clipboard_image = self.addon.clipboard_image
self.assertEqual(
clipboard_image.PasteClipboardImage.bl_idname,
"anyimage.paste_clipboard_image",
)
self.assertEqual(
clipboard_image.TrackNativeCopy.bl_idname,
"anyimage.track_native_copy",
)
for name in clipboard_image.PasteClipboardImage.__annotations__:
self.assertFalse(name.startswith("anyimage_"), name)
self.assertFalse(name.startswith("o_"), name)
def test_registers_and_removes_clipboard_shortcuts(self):
class KeyMapItems:
def __init__(self):
self.items = []
def new(self, operator_id, event_type, value, **modifiers):
item = SimpleNamespace(
idname=operator_id,
type=event_type,
value=value,
**modifiers,
)
self.items.append(item)
return item
def remove(self, item):
self.items.remove(item)
class KeyMaps:
def __init__(self):
self.items = []
def new(self, name, space_type):
keymap = SimpleNamespace(
name=name,
space_type=space_type,
keymap_items=KeyMapItems(),
)
self.items.append(keymap)
return keymap
keymaps = KeyMaps()
window_manager = SimpleNamespace(
keyconfigs=SimpleNamespace(
addon=SimpleNamespace(keymaps=keymaps),
),
)
clipboard_image = self.addon.clipboard_image
with patch.object(
self.fake_bpy.context,
"window_manager",
window_manager,
):
clipboard_image.register_keymaps()
items = [
item
for keymap in keymaps.items
for item in keymap.keymap_items.items
]
self.assertEqual(
[item.idname for item in items],
[
"anyimage.paste_clipboard_image",
"anyimage.track_native_copy",
"anyimage.paste_clipboard_image",
"anyimage.track_native_copy",
],
)
modifier = "oskey" if sys.platform == "darwin" else "ctrl"
self.assertTrue(all(getattr(item, modifier) for item in items))
clipboard_image.unregister_keymaps()
self.assertTrue(
all(not keymap.keymap_items.items for keymap in keymaps.items)
)
def test_routes_supported_contexts(self):
contexts = (
(self.make_context("VIEW_3D"), "PLANE"),
(
self.make_context("VIEW_3D", mode="SCULPT"),
"TOOL_TEXTURE",
),
(
self.make_context("VIEW_3D", mode="PAINT_VERTEX"),
"TOOL_TEXTURE",
),
(
self.make_context("VIEW_3D", mode="PAINT_TEXTURE"),
"TOOL_TEXTURE",
),
(
self.make_context(
"NODE_EDITOR",
tree_type="ShaderNodeTree",
),
"NODE",
),
(
self.make_context(
"NODE_EDITOR",
tree_type="CompositorNodeTree",
),
"NODE",
),
(
self.make_context(
"NODE_EDITOR",
tree_type="GeometryNodeTree",
),
"NODE",
),
)
for context, expected_target in contexts:
with self.subTest(expected_target=expected_target):
self.assertEqual(
self.actions.target_for_context(context),
expected_target,
)
def test_rejects_unsupported_node_tree(self):
context = self.make_context(
"NODE_EDITOR",
tree_type="TextureNodeTree",
)
self.assertIsNone(self.actions.target_for_context(context))
def test_world_shader_uses_environment_texture(self):
world_space = SimpleNamespace(
tree_type="ShaderNodeTree",
shader_type="WORLD",
)
material_space = SimpleNamespace(
tree_type="ShaderNodeTree",
shader_type="OBJECT",
)
self.assertEqual(
self.actions._node_type_for_space(world_space),
"ShaderNodeTexEnvironment",
)
self.assertEqual(
self.actions._node_type_for_space(material_space),
"ShaderNodeTexImage",
)
def test_pinned_world_tree_uses_environment_texture(self):
world_space = SimpleNamespace(
tree_type="ShaderNodeTree",
shader_type="OBJECT",
id=SimpleNamespace(
bl_rna=SimpleNamespace(identifier="World"),
),
)
self.assertEqual(
self.actions._node_type_for_space(world_space),
"ShaderNodeTexEnvironment",
)
def test_node_location_matches_native_ui_scale_and_anchor_offset(self):
view2d = SimpleNamespace(
region_to_view=Mock(return_value=(200.0, 100.0)),
)
context = SimpleNamespace(
region=SimpleNamespace(view2d=view2d),
preferences=SimpleNamespace(
system=SimpleNamespace(ui_scale=2.0),
),
space_data=SimpleNamespace(cursor_location=(1.0, 2.0)),
)
event = SimpleNamespace(mouse_region_x=80, mouse_region_y=60)
location = self.actions._node_location(context, event)
self.assertEqual(location, (85.0, 55.0))
view2d.region_to_view.assert_called_once_with(80, 60)
def test_node_location_without_event_uses_node_cursor(self):
context = SimpleNamespace(
space_data=SimpleNamespace(cursor_location=(12.0, -8.0)),
)
self.assertEqual(
self.actions._node_location(context, None),
(12.0, -8.0),
)
def test_view3d_location_projects_mouse_at_cursor_depth(self):
projected_location = (4.0, 5.0, 6.0)
project = Mock(return_value=projected_location)
bpy_extras = ModuleType("bpy_extras")
view3d_utils = ModuleType("bpy_extras.view3d_utils")
view3d_utils.region_2d_to_location_3d = project
bpy_extras.view3d_utils = view3d_utils
cursor_location = Mock()
context = SimpleNamespace(
scene=SimpleNamespace(
cursor=SimpleNamespace(location=cursor_location),
),
region=object(),
region_data=object(),
)
event = SimpleNamespace(mouse_region_x=120, mouse_region_y=75)
with patch.dict(
sys.modules,
{
"bpy_extras": bpy_extras,
"bpy_extras.view3d_utils": view3d_utils,
},
):
result = self.actions._view3d_location(context, event)
self.assertIs(result, projected_location)
project.assert_called_once_with(
context.region,
context.region_data,
(120, 75),
cursor_location,
)
def test_view3d_location_without_event_copies_cursor_location(self):
copied_location = object()
cursor_location = SimpleNamespace(
copy=Mock(return_value=copied_location),
)
context = SimpleNamespace(
scene=SimpleNamespace(
cursor=SimpleNamespace(location=cursor_location),
),
)
result = self.actions._view3d_location(context)
self.assertIs(result, copied_location)
cursor_location.copy.assert_called_once_with()
def test_preserves_image_aspect_ratio_for_plane(self):
self.assertEqual(
self.actions._plane_half_dimensions(1920, 1080),
(1.0, 0.5625),
)
self.assertEqual(
self.actions._plane_half_dimensions(800, 1200),
(2 / 3, 1.0),
)
def test_plane_creation_delegates_to_mesh_plane_geometry_nodes(self):
import inspect
source = inspect.getsource(self.actions.add_image_plane)
self.assertIn("create_mesh_plane_object", source)
self.assertNotIn("_plane_grid", source)
self.assertNotIn("_block_grid", source)
def test_plane_import_defaults_to_reference_image(self):
options = (
self.addon.clipboard_image.PasteClipboardImage
.__annotations__["import_as"]
)
self.assertEqual(options["default"], "REFERENCE")
self.assertEqual(
[item[0] for item in options["items"]],
["PLANE", "REFERENCE"],
)
def test_plane_shadeless_is_disabled_by_default(self):
options = (
self.addon.clipboard_image.PasteClipboardImage
.__annotations__["shadeless"]
)
self.assertFalse(options["default"])
annotations = self.addon.clipboard_image.PasteClipboardImage.__annotations__
self.assertNotIn("subdivisions", annotations)
self.assertNotIn("thickness", annotations)
self.assertNotIn("unshaded", annotations)
def test_native_copy_takes_priority_while_clipboard_is_unchanged(self):
clipboard_image = self.addon.clipboard_image
original_token = clipboard_image._native_copy_token
try:
clipboard_image._native_copy_token = 41
with patch.object(
clipboard_image,
"clipboard_change_token",
return_value=41,
):
self.assertTrue(clipboard_image._should_defer_to_native_paste())
finally:
clipboard_image._native_copy_token = original_token
def test_new_system_clipboard_content_restores_image_paste(self):
clipboard_image = self.addon.clipboard_image
original_token = clipboard_image._native_copy_token
try:
clipboard_image._native_copy_token = 41
with patch.object(
clipboard_image,
"clipboard_change_token",
return_value=42,
):
self.assertFalse(clipboard_image._should_defer_to_native_paste())
finally:
clipboard_image._native_copy_token = original_token
def test_native_copy_tracker_passes_through(self):
clipboard_image = self.addon.clipboard_image
original_token = clipboard_image._native_copy_token
try:
with patch.object(
clipboard_image,
"clipboard_change_token",
return_value=73,
):
result = clipboard_image.TrackNativeCopy().execute(None)
self.assertEqual(result, {"PASS_THROUGH"})
self.assertEqual(clipboard_image._native_copy_token, 73)
finally:
clipboard_image._native_copy_token = original_token
def test_shadeless_nodes_mix_emission_with_transparency(self):
created_nodes = {}
def create_node(node_type):
node = SimpleNamespace(location=None, inputs={}, outputs={})
if node_type == "ShaderNodeBsdfTransparent":
node.outputs["BSDF"] = object()
elif node_type == "ShaderNodeEmission":
node.inputs["Color"] = object()
node.outputs["Emission"] = object()
else:
node.inputs = [object(), object(), object()]
node.outputs["Shader"] = object()
created_nodes[node_type] = node
return node
nodes = SimpleNamespace(new=Mock(side_effect=create_node))
links = SimpleNamespace(new=Mock())
image_node = SimpleNamespace(
outputs={"Color": object(), "Alpha": object()},
)
output_node = SimpleNamespace(inputs={"Surface": object()})
self.actions._create_shadeless_nodes(
nodes,
links,
image_node,
output_node,
)
mix_node = created_nodes["ShaderNodeMixShader"]
links.new.assert_any_call(image_node.outputs["Alpha"], mix_node.inputs[0])
links.new.assert_any_call(
created_nodes["ShaderNodeEmission"].outputs["Emission"],
mix_node.inputs[2],
)
self.assertEqual(links.new.call_count, 5)
def test_reference_import_routes_to_image_empty(self):
context = self.make_context("VIEW_3D")
image = object()
expected_reference = object()
expected_location = (1.0, 2.0, 3.0)
original_add_reference_image = self.actions.add_reference_image
self.actions.add_reference_image = (
lambda received_context, received_image, location=None: expected_reference
if (
received_context is context
and received_image is image
and location == expected_location
)
else None
)
try:
result = self.actions.paste_image(
context,
image,
location=expected_location,
import_as="REFERENCE",
)
finally:
self.actions.add_reference_image = original_add_reference_image
self.assertIs(result, expected_reference)
def test_only_import_as_and_shadeless_are_drawn_for_plane_paste(self):
operator = self.addon.clipboard_image.PasteClipboardImage()
drawn_properties = []
operator.layout = SimpleNamespace(
prop=lambda owner, name: drawn_properties.append((owner, name)),
)
operator.paste_target = "PLANE"
operator.import_as = "PLANE"
operator.draw(None)
operator.import_as = "REFERENCE"
operator.draw(None)
operator.paste_target = "NODE"
operator.draw(None)
self.assertEqual(
drawn_properties,
[
(operator, "import_as"),
(operator, "shadeless"),
(operator, "import_as"),
],
)
def test_redo_reuses_captured_node_location(self):
operator = self.addon.clipboard_image.PasteClipboardImage()
operator.paste_target = "NODE"
operator.location = (10.0, 20.0, 0.0)
operator.location_set = True
operator.import_as = "REFERENCE"
operator.shadeless = False
operator.report = Mock()
context = self.make_context(
"NODE_EDITOR",
tree_type="ShaderNodeTree",
)
image = SimpleNamespace(name="Clipboard Image")
clipboard_image = self.addon.clipboard_image
with (
patch.object(
clipboard_image,
"_should_defer_to_native_paste",
return_value=False,
),
patch.object(
clipboard_image,
"read_clipboard_image",
return_value=(b"image", ".png"),
),
patch.object(
clipboard_image,
"acquire_packed_image",
return_value=(image, True),
),
patch.object(clipboard_image, "paste_image") as paste_image,
):
result = operator.execute(context)
self.assertEqual(result, {"FINISHED"})
paste_image.assert_called_once_with(
context,
image,
None,
location=(10.0, 20.0),
import_as="REFERENCE",
shadeless=False,
)
def test_image_plane_requires_object_mode(self):
context = SimpleNamespace(mode="EDIT_MESH")
image = SimpleNamespace(size=(100, 100))
with self.assertRaisesRegex(
self.actions.PasteTargetError,
"Object Mode",
):
self.actions.add_image_plane(context, image)
def test_stencil_dimensions_preserve_scale_and_image_aspect(self):
self.assertEqual(
self.actions._stencil_dimensions(
(1920, 1080),
(400.0, 200.0),
),
(400.0, 225.0),
)
self.assertEqual(
self.actions._stencil_dimensions(
(800, 1200),
(128.0, 256.0),
),
(256.0 * 2 / 3, 256.0),
)
def test_finds_existing_packed_clipboard_image(self):
matching_image = FakeImage(
self.actions.CLIPBOARD_HASH_PROPERTY,
"matching-hash",
packed=True,
)
unpacked_image = FakeImage(
self.actions.CLIPBOARD_HASH_PROPERTY,
"unpacked-hash",
packed=False,
)
original_data = self.actions.bpy.data
self.actions.bpy.data = SimpleNamespace(
images=[matching_image, unpacked_image],
)
try:
self.assertIs(
self.actions._find_packed_clipboard_image("matching-hash"),
matching_image,
)
self.assertIsNone(
self.actions._find_packed_clipboard_image("unpacked-hash")
)
finally:
self.actions.bpy.data = original_data
def test_sets_and_reuses_stencil_brush_texture(self):
textures = FakeTextures()
original_data = self.actions.bpy.data
self.actions.bpy.data = SimpleNamespace(textures=textures)
brush = SimpleNamespace(
is_editable=True,
texture=None,
texture_slot=SimpleNamespace(
texture=None,
map_mode="VIEW_PLANE",
),
stencil_dimension=(256.0, 256.0),
update_tag=lambda: None,
)
context = SimpleNamespace(brush=brush)
image = SimpleNamespace(name="Clipboard Image", size=(1920, 1080))
try:
first_texture = self.actions.set_tool_texture(context, image)
second_texture = self.actions.set_tool_texture(context, image)
finally:
self.actions.bpy.data = original_data
self.assertIs(first_texture, second_texture)
self.assertEqual(len(textures), 1)
self.assertIs(brush.texture, first_texture)
self.assertIs(brush.texture_slot.texture, first_texture)
self.assertEqual(brush.texture_slot.map_mode, "STENCIL")
self.assertEqual(brush.stencil_dimension, (256.0, 144.0))
def test_localizes_read_only_active_brush(self):
local_brush = SimpleNamespace(is_editable=True, library=None)
linked_brush = SimpleNamespace(
is_editable=False,
library=None,
make_local=lambda: local_brush,
)
self.assertIs(
self.actions._ensure_editable_brush(
SimpleNamespace(),
linked_brush,
),
local_brush,
)
def test_copies_and_activates_runtime_library_brush(self):
local_brush = SimpleNamespace(
name="Paint Hard Clipboard",
is_editable=True,
library=None,
)
library_brush = SimpleNamespace(
name="Paint Hard",
is_editable=True,
library=object(),
)
paint_settings = SimpleNamespace(brush=library_brush)
context = SimpleNamespace(
mode="SCULPT",
tool_settings=SimpleNamespace(sculpt=paint_settings),
)
original_ops = getattr(self.actions.bpy, "ops", None)
def save_brush_as(**options):
self.assertEqual(
options["asset_library_reference"],
"LOCAL",
)
self.assertEqual(options["name"], "Paint Hard Clipboard")
paint_settings.brush = local_brush
return {"FINISHED"}
self.actions.bpy.ops = SimpleNamespace(
brush=SimpleNamespace(asset_save_as=save_brush_as),
)
try:
result = self.actions._ensure_editable_brush(
context,
library_brush,
)
finally:
if original_ops is None:
del self.actions.bpy.ops
else:
self.actions.bpy.ops = original_ops
self.assertIs(result, local_brush)
def test_active_brush_prefers_current_mode_settings(self):
correct_brush = object()
context = SimpleNamespace(
brush=object(),
mode="SCULPT",
tool_settings=SimpleNamespace(
sculpt=SimpleNamespace(brush=correct_brush),
),
)
self.assertIs(self.actions._active_brush(context), correct_brush)
def test_vertex_and_texture_paint_use_unbiased_white(self):
for mode in ("PAINT_VERTEX", "PAINT_TEXTURE"):
with self.subTest(mode=mode):
brush = SimpleNamespace(color=(0.2, 0.4, 0.8))
unified_settings = SimpleNamespace(color=(0.8, 0.4, 0.2))
context = SimpleNamespace(
mode=mode,
tool_settings=SimpleNamespace(
**{
self.actions.PAINT_SETTINGS_BY_MODE[mode][0]:
SimpleNamespace(
unified_paint_settings=unified_settings,
)
},
),
)
self.actions._set_unbiased_paint_color(context, brush)
self.assertEqual(brush.color, (1.0, 1.0, 1.0))
self.assertEqual(
unified_settings.color,
(1.0, 1.0, 1.0),
)
def test_sculpt_keeps_brush_color(self):
brush = SimpleNamespace(color=(0.2, 0.4, 0.8))
self.actions._set_unbiased_paint_color(
SimpleNamespace(mode="SCULPT"),
brush,
)
self.assertEqual(brush.color, (0.2, 0.4, 0.8))
def test_wraps_info_dib_as_bmp(self):
dib_header = struct.pack(
"<IiiHHIIiiII",
40,
1,
1,
1,
24,
0,
4,
0,
0,
0,
0,
)
dib_data = dib_header + b"\x00\x00\xff\x00"
bmp_data = self.clipboard.dib_to_bmp(dib_data)
signature, file_size, _, _, pixel_offset = struct.unpack_from(
"<2sIHHI",
bmp_data,
)
self.assertEqual(signature, b"BM")
self.assertEqual(file_size, len(bmp_data))
self.assertEqual(pixel_offset, 54)
self.assertEqual(bmp_data[14:], dib_data)
def test_rejects_truncated_dib(self):
with self.assertRaises(self.clipboard.ClipboardImageError):
self.clipboard.dib_to_bmp(b"\x28\x00\x00\x00")
def test_reads_macos_clipboard_image_as_png(self):
def export_image(_osascript, output_file):
output_file.write_bytes(b"png image")
with patch.object(
self.clipboard.shutil,
"which",
return_value="/usr/bin/osascript",
), patch.object(
self.clipboard,
"_export_macos_clipboard_image",
side_effect=export_image,
):
image_data, suffix = self.clipboard._read_macos_clipboard_image()
self.assertEqual(image_data, b"png image")
self.assertEqual(suffix, ".png")
def test_macos_clipboard_without_image_is_unavailable(self):
with patch.object(
self.clipboard.shutil,
"which",
return_value="/usr/bin/osascript",
), patch.object(
self.clipboard,
"_export_macos_clipboard_image",
):
with self.assertRaises(self.clipboard.ClipboardImageUnavailable):
self.clipboard._read_macos_clipboard_image()
def test_linux_clipboard_prefers_wayland_png(self):
def find_command(command_name):
if command_name == "wl-paste":
return "/usr/bin/wl-paste"
return None
with patch.object(
self.clipboard.shutil,
"which",
side_effect=find_command,
), patch.object(
self.clipboard,
"_run_image_command",
return_value=b"png image",
) as run_command:
image_data, suffix = self.clipboard._read_linux_clipboard_image()
self.assertEqual(image_data, b"png image")
self.assertEqual(suffix, ".png")
run_command.assert_called_once_with(
[
"/usr/bin/wl-paste",
"--no-newline",
"--type",
"image/png",
]
)
def test_linux_clipboard_falls_back_to_xclip(self):
def find_command(command_name):
if command_name == "xclip":
return "/usr/bin/xclip"
return None
with patch.object(
self.clipboard.shutil,
"which",
side_effect=find_command,
), patch.object(
self.clipboard,
"_run_image_command",
return_value=b"png image",
) as run_command:
image_data, suffix = self.clipboard._read_linux_clipboard_image()
self.assertEqual(image_data, b"png image")
self.assertEqual(suffix, ".png")
run_command.assert_called_once_with(
[
"/usr/bin/xclip",
"-selection",
"clipboard",
"-t",
"image/png",
"-o",
]
)
def test_linux_clipboard_requires_supported_command(self):
with patch.object(
self.clipboard.shutil,
"which",
return_value=None,
):
with self.assertRaises(self.clipboard.ClipboardImageUnavailable):
self.clipboard._read_linux_clipboard_image()
@staticmethod
def make_context(area_type, mode="OBJECT", **space_options):
return SimpleNamespace(
area=SimpleNamespace(type=area_type),
mode=mode,
region=SimpleNamespace(type="WINDOW"),
space_data=SimpleNamespace(**space_options),
)
@staticmethod
def remove_anyimage_modules():
for module_name in tuple(sys.modules):
if module_name == "anyimage" or module_name.startswith("anyimage."):
sys.modules.pop(module_name)
class FakeImage:
def __init__(self, property_name, image_hash, packed):
self.properties = {property_name: image_hash}
self.packed_file = object() if packed else None
self.packed_files = ()
def get(self, property_name, default=None):
return self.properties.get(property_name, default)
class FakeTexture(dict):
def __init__(self, name, texture_type):
super().__init__()
self.name = name
self.type = texture_type
self.image = None
class FakeTextures(list):
def new(self, name, type):
texture = FakeTexture(name, type)
self.append(texture)
return texture
def remove(self, texture):
super().remove(texture)
if __name__ == "__main__":
unittest.main()

View File

@@ -60,10 +60,15 @@ class PackTest(unittest.TestCase):
)
self.assertIn("__init__.py", names)
self.assertIn("clipboard_image/__init__.py", names)
self.assertIn("clipboard_image/actions.py", names)
self.assertIn("clipboard_image/clipboard.py", names)
self.assertIn("blender_manifest.toml", names)
self.assertIn("operators/depth_plane/operator.py", names)
self.assertIn("operators/mesh_plane.py", names)
self.assertIn("operators/image.py", names)
self.assertIn("operators/balloon/operator.py", names)
self.assertIn("operators/image_lasso.py", names)
self.assertIn("operators/surface.py", names)
self.assertIn(
"assets/O_AnyImage.blend",
@@ -74,6 +79,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_lasso.py", names)
self.assertIn("server/models/ben2.py", names)
self.assertIn("server/models/da3.py", names)
self.assertIn("server/models/moge2.py", names)

View File

@@ -220,6 +220,7 @@ class ServerTest(unittest.TestCase):
"download-model",
"download-required-models",
"analyze-balloon",
"analyze-image-lasso",
"remove-background",
"extract-frames",
"generate-depth",
@@ -400,6 +401,46 @@ class ServerTest(unittest.TestCase):
self.assertEqual(tuple(result[0, 0]), (20, 40, 60, 64))
self.assertEqual(tuple(result[3, 3]), (0, 0, 0, 0))
def test_image_lasso_canvas_clears_rgba_outside_the_selection(self):
import numpy as np
from PIL import Image
image_lasso = sys.modules["server.jobs.image_lasso"]
source_path = self.storage_root / "source.png"
pixels = np.full((2, 2, 4), (20, 40, 60, 255), dtype=np.uint8)
Image.fromarray(pixels, mode="RGBA").save(source_path)
canvas, _selection, bounds = image_lasso.create_lasso_canvas(
source_path,
((-1, -1), (2, -1), (-1, 2)),
)
result = np.asarray(canvas)
self.assertEqual(bounds, (-1, -1, 2, 2))
self.assertEqual(result.shape, (3, 3, 4))
self.assertEqual(tuple(result[2, 2]), (0, 0, 0, 0))
self.assertEqual(tuple(result[1, 1]), (20, 40, 60, 255))
def test_image_lasso_crops_to_alpha_with_pixel_padding(self):
import numpy as np
from PIL import Image
image_lasso = sys.modules["server.jobs.image_lasso"]
pixels = np.zeros((4, 5, 4), dtype=np.uint8)
pixels[1:3, 2:4] = (20, 40, 60, 255)
result, bounds = image_lasso.crop_to_alpha(
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))
def test_balloon_job_sends_the_masked_crop_to_ben2(self):
import numpy as np
from PIL import Image

View File

@@ -506,6 +506,9 @@ class WorkerTest(unittest.TestCase):
def putalpha(self, _alpha):
return None
def paste(self, *_args):
return None
def save(self, path, **options):
save_options.append(options)
Path(path).touch()
@@ -540,7 +543,13 @@ class WorkerTest(unittest.TestCase):
inference_options.append((image, {})) or FakeImage()
)
fake_pil = ModuleType("PIL")
fake_pil.Image = SimpleNamespace(open=lambda _path: FakeImage())
fake_pil.Image = SimpleNamespace(
open=lambda _path: FakeImage(),
new=lambda *_args, **_kwargs: FakeImage(),
)
fake_pil.ImageChops = SimpleNamespace(
multiply=lambda _source, _foreground: FakeImage()
)
with tempfile.TemporaryDirectory() as directory, patch.dict(
sys.modules,
@@ -571,6 +580,42 @@ class WorkerTest(unittest.TestCase):
)
self.assertIn("Background removed in", context.status["message"])
def test_ben2_preserves_input_alpha_and_hides_transparent_rgb(self):
from PIL import Image
onnx_ben2 = load_server_module("models.onnx_ben2")
source_pixels = np.asarray(
(((255, 0, 0, 0), (0, 255, 0, 128)),),
dtype=np.uint8,
)
model_result = Image.new("RGBA", (2, 1), (10, 20, 30, 128))
received = []
def infer(_model, image):
received.append(np.asarray(image.convert("RGB"), dtype=np.uint8))
return model_result
with tempfile.TemporaryDirectory() as directory:
input_path = Path(directory) / "input.png"
Image.fromarray(source_pixels, mode="RGBA").save(input_path)
with (
patch.object(onnx_ben2, "create_session", return_value=object()),
patch.object(onnx_ben2, "infer", side_effect=infer),
):
result, _load_ms, _inference_ms = self.ben2.infer_foreground(
input_path,
"model",
"cpu",
preserve_input_alpha=True,
)
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(result_pixels[0, 0]), (255, 0, 0, 0))
self.assertEqual(tuple(result_pixels[0, 1]), (0, 255, 0, 64))
def test_ben2_background_removal_processes_each_frame(self):
inference_options = []
@@ -593,6 +638,9 @@ class WorkerTest(unittest.TestCase):
def putalpha(self, _alpha):
return None
def paste(self, *_args):
return None
def save(self, path, **options):
Path(path).touch()
@@ -624,7 +672,13 @@ class WorkerTest(unittest.TestCase):
inference_options.append((image, {})) or FakeImage()
)
fake_pil = ModuleType("PIL")
fake_pil.Image = SimpleNamespace(open=lambda _path: FakeImage())
fake_pil.Image = SimpleNamespace(
open=lambda _path: FakeImage(),
new=lambda *_args, **_kwargs: FakeImage(),
)
fake_pil.ImageChops = SimpleNamespace(
multiply=lambda _source, _foreground: FakeImage()
)
with tempfile.TemporaryDirectory() as directory, patch.dict(
sys.modules,

View File

@@ -2,6 +2,8 @@ from pathlib import Path
import bpy
from mesh_plane_asset import build_mesh_plane_group
GROUP_NAME = "O DA3 Surface"
MOGE_SURFACE_GROUP_NAME = "O MoGe2 Surface"
@@ -1436,6 +1438,7 @@ def main():
discontinuity_default=50.0,
)
configure_moge_surface_group(moge_surface_group)
build_mesh_plane_group()
build_depth_plane_group()
build_moge_depth_plane_group()
build_shadeless_group()

251
tools/mesh_plane_asset.py Normal file
View File

@@ -0,0 +1,251 @@
import bpy
MESH_PLANE_GROUP_NAME = "O Mesh Plane"
def new_socket(interface, name, in_out, socket_type, parent=None):
socket = interface.new_socket(
name=name,
in_out=in_out,
socket_type=socket_type,
parent=parent,
)
if in_out == "INPUT" and hasattr(socket, "structure_type"):
socket.structure_type = "SINGLE"
return socket
def new_group_input(nodes, location, visible_names):
node = nodes.new("NodeGroupInput")
node.location = location
visible = set(visible_names)
for output in node.outputs:
output.hide = output.name not in visible
return node
def build_mesh_plane_group():
group = bpy.data.node_groups.new(MESH_PLANE_GROUP_NAME, "GeometryNodeTree")
group.is_modifier = True
interface = group.interface
geometry = new_socket(interface, "Geometry", "INPUT", "NodeSocketGeometry")
geometry.structure_type = "SINGLE"
subdivide = new_socket(interface, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.default_value = 0
subdivide.min_value = 0
subdivide.max_value = 10
thickness = new_socket(interface, "Thickness", "INPUT", "NodeSocketFloat")
thickness.default_value = 0.0
thickness.min_value = 0.0
thickness.max_value = 10000.0
thickness.subtype = "DISTANCE"
data_panel = interface.new_panel(name="Data", default_closed=True)
material = new_socket(
interface,
"Material",
"INPUT",
"NodeSocketMaterial",
parent=data_panel,
)
material.hide_in_modifier = True
new_socket(interface, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes = group.nodes
links = group.links
geometry_input = new_group_input(nodes, (-1800.0, 500.0), {"Geometry"})
subdivide_input = new_group_input(nodes, (-1800.0, 0.0), {"Subdivide"})
thickness_input = new_group_input(nodes, (100.0, -250.0), {"Thickness"})
material_input = new_group_input(nodes, (900.0, 0.0), {"Material"})
output = nodes.new("NodeGroupOutput")
output.location = (1350.0, 500.0)
bounds = nodes.new("GeometryNodeBoundBox")
bounds.location = (-1600.0, 500.0)
links.new(geometry_input.outputs["Geometry"], bounds.inputs["Geometry"])
dimensions = nodes.new("ShaderNodeVectorMath")
dimensions.operation = "SUBTRACT"
dimensions.location = (-1400.0, 500.0)
links.new(bounds.outputs["Max"], dimensions.inputs[0])
links.new(bounds.outputs["Min"], dimensions.inputs[1])
separate_dimensions = nodes.new("ShaderNodeSeparateXYZ")
separate_dimensions.location = (-1200.0, 500.0)
links.new(dimensions.outputs["Vector"], separate_dimensions.inputs[0])
short_segments = nodes.new("ShaderNodeMath")
short_segments.operation = "POWER"
short_segments.inputs[0].default_value = 2.0
short_segments.location = (-1400.0, 0.0)
links.new(subdivide_input.outputs["Subdivide"], short_segments.inputs[1])
vertex_counts = []
for index, (axis, other_axis) in enumerate((("X", "Y"), ("Y", "X"))):
y = 300.0 - index * 300.0
ratio = nodes.new("ShaderNodeMath")
ratio.operation = "DIVIDE"
ratio.location = (-1000.0, y)
links.new(separate_dimensions.outputs[axis], ratio.inputs[0])
links.new(separate_dimensions.outputs[other_axis], ratio.inputs[1])
at_least_one = nodes.new("ShaderNodeMath")
at_least_one.operation = "MAXIMUM"
at_least_one.inputs[1].default_value = 1.0
at_least_one.location = (-800.0, y)
links.new(ratio.outputs[0], at_least_one.inputs[0])
scaled_segments = nodes.new("ShaderNodeMath")
scaled_segments.operation = "MULTIPLY"
scaled_segments.location = (-600.0, y)
links.new(short_segments.outputs[0], scaled_segments.inputs[0])
links.new(at_least_one.outputs[0], scaled_segments.inputs[1])
rounded_segments = nodes.new("ShaderNodeMath")
rounded_segments.operation = "ROUND"
rounded_segments.location = (-400.0, y)
links.new(scaled_segments.outputs[0], rounded_segments.inputs[0])
vertices = nodes.new("ShaderNodeMath")
vertices.operation = "ADD"
vertices.inputs[1].default_value = 1.0
vertices.location = (-200.0, y)
links.new(rounded_segments.outputs[0], vertices.inputs[0])
vertex_counts.append(vertices.outputs[0])
grid = nodes.new("GeometryNodeMeshGrid")
grid.location = (50.0, 500.0)
links.new(separate_dimensions.outputs["X"], grid.inputs["Size X"])
links.new(separate_dimensions.outputs["Y"], grid.inputs["Size Y"])
links.new(vertex_counts[0], grid.inputs["Vertices X"])
links.new(vertex_counts[1], grid.inputs["Vertices Y"])
position = nodes.new("GeometryNodeInputPosition")
position.location = (-200.0, 900.0)
separate_position = nodes.new("ShaderNodeSeparateXYZ")
separate_position.location = (0.0, 900.0)
links.new(position.outputs["Position"], separate_position.inputs[0])
uv_components = []
for index, axis in enumerate(("X", "Y")):
y = 900.0 - index * 180.0
normalize = nodes.new("ShaderNodeMath")
normalize.operation = "DIVIDE"
normalize.location = (200.0, y)
links.new(separate_position.outputs[axis], normalize.inputs[0])
links.new(separate_dimensions.outputs[axis], normalize.inputs[1])
offset_uv = nodes.new("ShaderNodeMath")
offset_uv.operation = "ADD"
offset_uv.inputs[1].default_value = 0.5
offset_uv.location = (400.0, y)
links.new(normalize.outputs[0], offset_uv.inputs[0])
uv_components.append(offset_uv.outputs[0])
uv_coordinates = nodes.new("ShaderNodeCombineXYZ")
uv_coordinates.location = (600.0, 800.0)
links.new(uv_components[0], uv_coordinates.inputs["X"])
links.new(uv_components[1], uv_coordinates.inputs["Y"])
store_grid_uv = nodes.new("GeometryNodeStoreNamedAttribute")
store_grid_uv.data_type = "FLOAT2"
store_grid_uv.domain = "CORNER"
store_grid_uv.inputs["Name"].default_value = "UVMap"
store_grid_uv.location = (250.0, 500.0)
links.new(grid.outputs["Mesh"], store_grid_uv.inputs["Geometry"])
links.new(uv_coordinates.outputs["Vector"], store_grid_uv.inputs["Value"])
center_sum = nodes.new("ShaderNodeVectorMath")
center_sum.operation = "ADD"
center_sum.location = (-200.0, 1150.0)
links.new(bounds.outputs["Min"], center_sum.inputs[0])
links.new(bounds.outputs["Max"], center_sum.inputs[1])
center = nodes.new("ShaderNodeVectorMath")
center.operation = "SCALE"
center.inputs[3].default_value = 0.5
center.location = (50.0, 1150.0)
links.new(center_sum.outputs["Vector"], center.inputs[0])
place_grid = nodes.new("GeometryNodeTransform")
place_grid.location = (500.0, 500.0)
links.new(store_grid_uv.outputs["Geometry"], place_grid.inputs["Geometry"])
links.new(center.outputs["Vector"], place_grid.inputs["Translation"])
cell_sizes = []
for index, axis in enumerate(("X", "Y")):
y = 100.0 - index * 180.0
segments = nodes.new("ShaderNodeMath")
segments.operation = "SUBTRACT"
segments.inputs[1].default_value = 1.0
segments.location = (-100.0, y)
links.new(vertex_counts[index], segments.inputs[0])
cell_size = nodes.new("ShaderNodeMath")
cell_size.operation = "DIVIDE"
cell_size.location = (100.0, y)
links.new(separate_dimensions.outputs[axis], cell_size.inputs[0])
links.new(segments.outputs[0], cell_size.inputs[1])
cell_sizes.append(cell_size.outputs[0])
cell_area = nodes.new("ShaderNodeMath")
cell_area.operation = "MULTIPLY"
cell_area.location = (300.0, 100.0)
links.new(cell_sizes[0], cell_area.inputs[0])
links.new(cell_sizes[1], cell_area.inputs[1])
target_step = nodes.new("ShaderNodeMath")
target_step.operation = "SQRT"
target_step.location = (500.0, 100.0)
links.new(cell_area.outputs[0], target_step.inputs[0])
depth_ratio = nodes.new("ShaderNodeMath")
depth_ratio.operation = "DIVIDE"
depth_ratio.location = (300.0, -100.0)
links.new(thickness_input.outputs["Thickness"], depth_ratio.inputs[0])
links.new(target_step.outputs[0], depth_ratio.inputs[1])
rounded_depth = nodes.new("ShaderNodeMath")
rounded_depth.operation = "ROUND"
rounded_depth.location = (500.0, -100.0)
links.new(depth_ratio.outputs[0], rounded_depth.inputs[0])
depth_segments = nodes.new("ShaderNodeMath")
depth_segments.operation = "MAXIMUM"
depth_segments.inputs[1].default_value = 1.0
depth_segments.location = (700.0, -100.0)
links.new(rounded_depth.outputs[0], depth_segments.inputs[0])
depth_vertices = nodes.new("ShaderNodeMath")
depth_vertices.operation = "ADD"
depth_vertices.inputs[1].default_value = 1.0
depth_vertices.location = (900.0, -100.0)
links.new(depth_segments.outputs[0], depth_vertices.inputs[0])
cube_size = nodes.new("ShaderNodeCombineXYZ")
cube_size.location = (700.0, 250.0)
links.new(separate_dimensions.outputs["X"], cube_size.inputs["X"])
links.new(separate_dimensions.outputs["Y"], cube_size.inputs["Y"])
links.new(thickness_input.outputs["Thickness"], cube_size.inputs["Z"])
cube = nodes.new("GeometryNodeMeshCube")
cube.location = (900.0, 250.0)
links.new(cube_size.outputs["Vector"], cube.inputs["Size"])
links.new(vertex_counts[0], cube.inputs["Vertices X"])
links.new(vertex_counts[1], cube.inputs["Vertices Y"])
links.new(depth_vertices.outputs[0], cube.inputs["Vertices Z"])
store_cube_uv = nodes.new("GeometryNodeStoreNamedAttribute")
store_cube_uv.data_type = "FLOAT2"
store_cube_uv.domain = "CORNER"
store_cube_uv.inputs["Name"].default_value = "UVMap"
store_cube_uv.location = (1100.0, 250.0)
links.new(cube.outputs["Mesh"], store_cube_uv.inputs["Geometry"])
links.new(uv_coordinates.outputs["Vector"], store_cube_uv.inputs["Value"])
place_cube = nodes.new("GeometryNodeTransform")
place_cube.location = (1300.0, 250.0)
links.new(store_cube_uv.outputs["Geometry"], place_cube.inputs["Geometry"])
links.new(center.outputs["Vector"], place_cube.inputs["Translation"])
has_thickness = nodes.new("FunctionNodeCompare")
has_thickness.data_type = "FLOAT"
has_thickness.operation = "GREATER_THAN"
has_thickness.location = (450.0, -350.0)
links.new(thickness_input.outputs["Thickness"], has_thickness.inputs[0])
geometry_switch = nodes.new("GeometryNodeSwitch")
geometry_switch.input_type = "GEOMETRY"
geometry_switch.location = (750.0, 500.0)
links.new(has_thickness.outputs["Result"], geometry_switch.inputs["Switch"])
links.new(place_grid.outputs["Geometry"], geometry_switch.inputs["False"])
links.new(place_cube.outputs["Geometry"], geometry_switch.inputs["True"])
set_material = nodes.new("GeometryNodeSetMaterial")
set_material.location = (1050.0, 500.0)
links.new(geometry_switch.outputs["Output"], set_material.inputs["Geometry"])
links.new(material_input.outputs["Material"], set_material.inputs["Material"])
links.new(set_material.outputs["Geometry"], output.inputs["Geometry"])
group.color_tag = "GEOMETRY"
group.description = (
"Build an aspect-adaptive image plane and add optional centered thickness."
)
group.use_fake_user = True
return group

View File

@@ -0,0 +1,103 @@
from pathlib import Path
import sys
import bpy
PROJECT_ROOT = Path(__file__).parents[1]
sys.path.insert(0, str(PROJECT_ROOT / "src"))
from anyimage.operators.material import (
get_modifier_input,
modifier_input_identifier,
set_modifier_input,
)
from anyimage.operators.mesh_plane import (
add_mesh_plane_processing,
create_mesh_plane_mesh,
)
def evaluated_mesh(obj):
bpy.context.view_layer.update()
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
return evaluated, evaluated.to_mesh()
def main():
bpy.ops.wm.read_factory_settings(use_empty=True)
mesh = create_mesh_plane_mesh("Mesh Plane", (-1.0, 1.0, -0.5, 0.5))
assert len(mesh.polygons) == 1
material = bpy.data.materials.new("Mesh Plane Material")
obj = bpy.data.objects.new("Mesh Plane", mesh)
bpy.context.scene.collection.objects.link(obj)
modifier = add_mesh_plane_processing(obj, material)
group = modifier.node_group
assert group.name == "O Mesh Plane"
assert group.is_modifier
inputs = {
item.name: item
for item in group.interface.items_tree
if item.item_type == "SOCKET" and item.in_out == "INPUT"
}
assert list(inputs) == ["Geometry", "Subdivide", "Thickness", "Material"]
assert inputs["Subdivide"].default_value == 0
assert inputs["Subdivide"].max_value == 10
assert inputs["Thickness"].default_value == 0.0
assert inputs["Material"].hide_in_modifier
assert get_modifier_input(modifier, inputs["Material"].identifier) == material
evaluated, result = evaluated_mesh(obj)
try:
assert len(result.polygons) == 2
assert len(result.vertices) == 6
assert "UVMap" in result.attributes
uv_values = {
tuple(round(value, 6) for value in loop.uv)
for loop in result.uv_layers["UVMap"].data
}
assert uv_values == {
(0.0, 0.0),
(0.5, 0.0),
(1.0, 0.0),
(0.0, 1.0),
(0.5, 1.0),
(1.0, 1.0),
}
assert all(abs(vertex.co.z) < 1e-6 for vertex in result.vertices)
finally:
evaluated.to_mesh_clear()
set_modifier_input(modifier, modifier_input_identifier(group, "Subdivide"), 3)
obj.update_tag(refresh={"DATA"})
evaluated, result = evaluated_mesh(obj)
try:
assert len(result.polygons) == 128
assert len(result.vertices) == 153
finally:
evaluated.to_mesh_clear()
set_modifier_input(modifier, modifier_input_identifier(group, "Subdivide"), 2)
set_modifier_input(modifier, modifier_input_identifier(group, "Thickness"), 1.2)
obj.update_tag(refresh={"DATA"})
evaluated, result = evaluated_mesh(obj)
try:
assert len(result.polygons) == 184
z_values = [vertex.co.z for vertex in result.vertices]
assert abs(min(z_values) + 0.6) < 1e-6
assert abs(max(z_values) - 0.6) < 1e-6
assert len({round(value, 6) for value in z_values}) == 6
edge_users = {edge.key: 0 for edge in result.edges}
for polygon in result.polygons:
for edge_key in polygon.edge_keys:
edge_users[edge_key] += 1
assert all(users == 2 for users in edge_users.values())
finally:
evaluated.to_mesh_clear()
if bpy.app.version >= (5, 0, 0):
assert group.library is not None
if __name__ == "__main__":
main()