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:
48
README.md
48
README.md
@@ -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
43
docs/clipboard-image.md
Normal 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 的其他粘贴行为。
|
||||
@@ -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 的多帧联合推理属于另一条工作流。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
13
docs/faq.md
13
docs/faq.md
@@ -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。
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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…** 可以直接打开对应设置。
|
||||
|
||||
## 转换选项
|
||||
|
||||
|
||||
@@ -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 的基本工作方式。
|
||||
|
||||
@@ -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
24
docs/mesh-plane.md
Normal 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` 修改器中调整。
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
AnyImage 把运行环境、模型和任务结果放在 Blender 之外,多个 Blender 版本可以共享同一套数据。
|
||||
|
||||
## Models 面板
|
||||
## Models 设置
|
||||
|
||||
打开 3D View 侧栏中的 **AnyImage > Models**,可以查看和下载模型。
|
||||
打开 **Edit > Preferences > Extensions > AnyImage**,可以查看和下载模型。右键菜单中的 **AI Environment Settings…** 会直接打开这里。
|
||||
|
||||
- **Ready** 表示模型文件已经就绪。
|
||||
- **Download** 用于按需下载模型。
|
||||
|
||||
@@ -34,3 +34,5 @@ Depth Plane 和 Surface 的转换对话框也提供 **Remove Background**:
|
||||
### 透明边缘需要调整
|
||||
|
||||
当前正式入口不提供 BEN2 refinement 参数。可以在结果材质中继续处理 Alpha,或在外部图像软件中修整生成的颜色图。
|
||||
|
||||
输入图片已有 Alpha 时,AnyImage 会先用该 Alpha 遮住 BEN2 推理输入中的透明 RGB,再将结果 Alpha 计算为“输入 Alpha × BEN2 Alpha”。因此 Image Box / Lasso 或外部工具创建的透明范围不会被去背景重新扩张;软边缘也会继续保留。
|
||||
|
||||
@@ -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 时执行完整安装,只有默认模型缺失时仅提交补齐模型任务。
|
||||
|
||||
## 任务生命周期
|
||||
|
||||
|
||||
@@ -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 不会恢复已删除的面。
|
||||
|
||||
## 源网格
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
@@ -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 = [
|
||||
|
||||
209
src/anyimage/clipboard_image/__init__.py
Normal file
209
src/anyimage/clipboard_image/__init__.py
Normal 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()
|
||||
512
src/anyimage/clipboard_image/actions.py
Normal file
512
src/anyimage/clipboard_image/actions.py
Normal 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()
|
||||
360
src/anyimage/clipboard_image/clipboard.py
Normal file
360
src/anyimage/clipboard_image/clipboard.py
Normal 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)
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
635
src/anyimage/operators/image_lasso.py
Normal file
635
src/anyimage/operators/image_lasso.py
Normal 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",
|
||||
)
|
||||
@@ -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:
|
||||
|
||||
228
src/anyimage/operators/mesh_plane.py
Normal file
228
src/anyimage/operators/mesh_plane.py
Normal 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"}
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
151
src/anyimage/server/jobs/image_lasso.py
Normal file
151
src/anyimage/server/jobs/image_lasso.py
Normal 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}
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
892
tests/test_clipboard_image.py
Normal file
892
tests/test_clipboard_image.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
251
tools/mesh_plane_asset.py
Normal 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
|
||||
103
tools/validate_mesh_plane_asset.py
Normal file
103
tools/validate_mesh_plane_asset.py
Normal 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()
|
||||
Reference in New Issue
Block a user