From 988332548e65a09a5762e385de53c8121e53f98a Mon Sep 17 00:00:00 2001 From: nan Date: Sat, 22 Aug 2026 09:49:00 +0800 Subject: [PATCH] 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. --- README.md | 48 +- docs/clipboard-image.md | 43 + docs/depth-plane.md | 2 +- docs/development.md | 7 +- docs/faq.md | 13 +- docs/features.md | 17 +- docs/guide-surface.md | 2 +- docs/index.md | 4 +- docs/installation.md | 23 +- docs/mesh-plane.md | 24 + docs/models-and-storage.md | 4 +- docs/remove-background.md | 2 + docs/server.md | 2 +- docs/surface.md | 2 +- docs/workflows.md | 10 +- mkdocs.yml | 2 + src/anyimage/__init__.py | 20 +- src/anyimage/assets/O_AnyImage.blend | 4 +- src/anyimage/blender_manifest.toml | 2 +- src/anyimage/clipboard_image/__init__.py | 209 ++++ src/anyimage/clipboard_image/actions.py | 512 ++++++++++ src/anyimage/clipboard_image/clipboard.py | 360 +++++++ src/anyimage/menu.py | 63 +- src/anyimage/operators/__init__.py | 20 +- src/anyimage/operators/background.py | 15 +- src/anyimage/operators/balloon/operator.py | 48 +- src/anyimage/operators/common.py | 8 +- .../operators/depth_plane/operator.py | 12 +- src/anyimage/operators/image_lasso.py | 635 +++++++++++++ src/anyimage/operators/material.py | 4 + src/anyimage/operators/mesh_plane.py | 228 +++++ src/anyimage/operators/models.py | 120 ++- src/anyimage/operators/surface.py | 7 +- src/anyimage/panel.py | 67 +- src/anyimage/preferences.py | 71 +- src/anyimage/properties.py | 70 +- src/anyimage/server/__init__.py | 6 + src/anyimage/server/jobs/background.py | 2 + src/anyimage/server/jobs/foreground.py | 2 + src/anyimage/server/jobs/image_lasso.py | 151 +++ src/anyimage/server/models/ben2.py | 26 +- tests/test_addon.py | 489 ++++++++-- tests/test_clipboard_image.py | 892 ++++++++++++++++++ tests/test_pack.py | 6 + tests/test_server.py | 41 + tests/test_worker.py | 58 +- tools/build_geometry_nodes_asset.py | 3 + tools/mesh_plane_asset.py | 251 +++++ tools/validate_mesh_plane_asset.py | 103 ++ 49 files changed, 4419 insertions(+), 291 deletions(-) create mode 100644 docs/clipboard-image.md create mode 100644 docs/mesh-plane.md create mode 100644 src/anyimage/clipboard_image/__init__.py create mode 100644 src/anyimage/clipboard_image/actions.py create mode 100644 src/anyimage/clipboard_image/clipboard.py create mode 100644 src/anyimage/operators/image_lasso.py create mode 100644 src/anyimage/operators/mesh_plane.py create mode 100644 src/anyimage/server/jobs/image_lasso.py create mode 100644 tests/test_clipboard_image.py create mode 100644 tools/mesh_plane_asset.py create mode 100644 tools/validate_mesh_plane_asset.py diff --git a/README.md b/README.md index e2848ee..2a69a46 100644 --- a/README.md +++ b/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 diff --git a/docs/clipboard-image.md b/docs/clipboard-image.md new file mode 100644 index 0000000..8550625 --- /dev/null +++ b/docs/clipboard-image.md @@ -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 的其他粘贴行为。 diff --git a/docs/depth-plane.md b/docs/depth-plane.md index c8ddb3a..c0290e3 100644 --- a/docs/depth-plane.md +++ b/docs/depth-plane.md @@ -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 的多帧联合推理属于另一条工作流。 diff --git a/docs/development.md b/docs/development.md index 3b7c83c..c76626b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/docs/faq.md b/docs/faq.md index e1098a3..1a56e00 100644 --- a/docs/faq.md +++ b/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。 diff --git a/docs/features.md b/docs/features.md index 34dde88..5981b35 100644 --- a/docs/features.md +++ b/docs/features.md @@ -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)。 diff --git a/docs/guide-surface.md b/docs/guide-surface.md index 368a899..88bb766 100644 --- a/docs/guide-surface.md +++ b/docs/guide-surface.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…** 可以直接打开对应设置。 ## 转换选项 diff --git a/docs/index.md b/docs/index.md index eaf0506..cd9998f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -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 的基本工作方式。 diff --git a/docs/installation.md b/docs/installation.md index ab8c6d2..b9095d3 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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,并显示安装按钮。 ## 下一步 diff --git a/docs/mesh-plane.md b/docs/mesh-plane.md new file mode 100644 index 0000000..6efb743 --- /dev/null +++ b/docs/mesh-plane.md @@ -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` 修改器中调整。 diff --git a/docs/models-and-storage.md b/docs/models-and-storage.md index ae53e52..47e006a 100644 --- a/docs/models-and-storage.md +++ b/docs/models-and-storage.md @@ -2,9 +2,9 @@ AnyImage 把运行环境、模型和任务结果放在 Blender 之外,多个 Blender 版本可以共享同一套数据。 -## Models 面板 +## Models 设置 -打开 3D View 侧栏中的 **AnyImage > Models**,可以查看和下载模型。 +打开 **Edit > Preferences > Extensions > AnyImage**,可以查看和下载模型。右键菜单中的 **AI Environment Settings…** 会直接打开这里。 - **Ready** 表示模型文件已经就绪。 - **Download** 用于按需下载模型。 diff --git a/docs/remove-background.md b/docs/remove-background.md index d23654b..fe6bf76 100644 --- a/docs/remove-background.md +++ b/docs/remove-background.md @@ -34,3 +34,5 @@ Depth Plane 和 Surface 的转换对话框也提供 **Remove Background**: ### 透明边缘需要调整 当前正式入口不提供 BEN2 refinement 参数。可以在结果材质中继续处理 Alpha,或在外部图像软件中修整生成的颜色图。 + +输入图片已有 Alpha 时,AnyImage 会先用该 Alpha 遮住 BEN2 推理输入中的透明 RGB,再将结果 Alpha 计算为“输入 Alpha × BEN2 Alpha”。因此 Image Box / Lasso 或外部工具创建的透明范围不会被去背景重新扩张;软边缘也会继续保留。 diff --git a/docs/server.md b/docs/server.md index fbc6303..dae9fea 100644 --- a/docs/server.md +++ b/docs/server.md @@ -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 时执行完整安装,只有默认模型缺失时仅提交补齐模型任务。 ## 任务生命周期 diff --git a/docs/surface.md b/docs/surface.md index d81359c..f264b06 100644 --- a/docs/surface.md +++ b/docs/surface.md @@ -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 不会恢复已删除的面。 ## 源网格 diff --git a/docs/workflows.md b/docs/workflows.md index 4df2306..affa25d 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index 94e4cf6..7b82fdf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 diff --git a/src/anyimage/__init__.py b/src/anyimage/__init__.py index e8f26e8..d94f6ea 100644 --- a/src/anyimage/__init__.py +++ b/src/anyimage/__init__.py @@ -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"): diff --git a/src/anyimage/assets/O_AnyImage.blend b/src/anyimage/assets/O_AnyImage.blend index 2a0e527..34f8346 100644 --- a/src/anyimage/assets/O_AnyImage.blend +++ b/src/anyimage/assets/O_AnyImage.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:520d734ae68927fbaaa353469f6c5e88347649958d927adbae170e306a0ce5dc -size 1404917 +oid sha256:18eb3d8e67a608adaacdd2a2175f8e9bc806b5b699980b0989f53d39d7f0f4a1 +size 1567146 diff --git a/src/anyimage/blender_manifest.toml b/src/anyimage/blender_manifest.toml index b7e37e4..aa176ee 100644 --- a/src/anyimage/blender_manifest.toml +++ b/src/anyimage/blender_manifest.toml @@ -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 = [ diff --git a/src/anyimage/clipboard_image/__init__.py b/src/anyimage/clipboard_image/__init__.py new file mode 100644 index 0000000..f5e0536 --- /dev/null +++ b/src/anyimage/clipboard_image/__init__.py @@ -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() diff --git a/src/anyimage/clipboard_image/actions.py b/src/anyimage/clipboard_image/actions.py new file mode 100644 index 0000000..394c283 --- /dev/null +++ b/src/anyimage/clipboard_image/actions.py @@ -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() diff --git a/src/anyimage/clipboard_image/clipboard.py b/src/anyimage/clipboard_image/clipboard.py new file mode 100644 index 0000000..6f3dc59 --- /dev/null +++ b/src/anyimage/clipboard_image/clipboard.py @@ -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("= 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("= 124: + profile_offset = struct.unpack_from(" 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) diff --git a/src/anyimage/menu.py b/src/anyimage/menu.py index 964c26f..ce7631f 100644 --- a/src/anyimage/menu.py +++ b/src/anyimage/menu.py @@ -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): diff --git a/src/anyimage/operators/__init__.py b/src/anyimage/operators/__init__.py index e10c12d..f1fc735 100644 --- a/src/anyimage/operators/__init__.py +++ b/src/anyimage/operators/__init__.py @@ -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, ) diff --git a/src/anyimage/operators/background.py b/src/anyimage/operators/background.py index f8e9211..0a5c4fc 100644 --- a/src/anyimage/operators/background.py +++ b/src/anyimage/operators/background.py @@ -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 diff --git a/src/anyimage/operators/balloon/operator.py b/src/anyimage/operators/balloon/operator.py index d6c94c8..f88eea8 100644 --- a/src/anyimage/operators/balloon/operator.py +++ b/src/anyimage/operators/balloon/operator.py @@ -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", + ) diff --git a/src/anyimage/operators/common.py b/src/anyimage/operators/common.py index 0510705..d7f41bb 100644 --- a/src/anyimage/operators/common.py +++ b/src/anyimage/operators/common.py @@ -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: diff --git a/src/anyimage/operators/depth_plane/operator.py b/src/anyimage/operators/depth_plane/operator.py index d6352fa..20f8537 100644 --- a/src/anyimage/operators/depth_plane/operator.py +++ b/src/anyimage/operators/depth_plane/operator.py @@ -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): diff --git a/src/anyimage/operators/image_lasso.py b/src/anyimage/operators/image_lasso.py new file mode 100644 index 0000000..b784ca3 --- /dev/null +++ b/src/anyimage/operators/image_lasso.py @@ -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", + ) diff --git a/src/anyimage/operators/material.py b/src/anyimage/operators/material.py index 0e9810b..a1a230d 100644 --- a/src/anyimage/operators/material.py +++ b/src/anyimage/operators/material.py @@ -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: diff --git a/src/anyimage/operators/mesh_plane.py b/src/anyimage/operators/mesh_plane.py new file mode 100644 index 0000000..68f5188 --- /dev/null +++ b/src/anyimage/operators/mesh_plane.py @@ -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"} diff --git a/src/anyimage/operators/models.py b/src/anyimage/operators/models.py index cb0d889..4da3826 100644 --- a/src/anyimage/operators/models.py +++ b/src/anyimage/operators/models.py @@ -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): diff --git a/src/anyimage/operators/surface.py b/src/anyimage/operators/surface.py index 1b1914e..6a8f90e 100644 --- a/src/anyimage/operators/surface.py +++ b/src/anyimage/operators/surface.py @@ -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): diff --git a/src/anyimage/panel.py b/src/anyimage/panel.py index b80752c..1cbb3cb 100644 --- a/src/anyimage/panel.py +++ b/src/anyimage/panel.py @@ -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" diff --git a/src/anyimage/preferences.py b/src/anyimage/preferences.py index 941e176..31dfc66 100644 --- a/src/anyimage/preferences.py +++ b/src/anyimage/preferences.py @@ -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"] diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index bcac99a..b8c104a 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -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() diff --git a/src/anyimage/server/__init__.py b/src/anyimage/server/__init__.py index 4eb10da..0dfe9a6 100644 --- a/src/anyimage/server/__init__.py +++ b/src/anyimage/server/__init__.py @@ -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) diff --git a/src/anyimage/server/jobs/background.py b/src/anyimage/server/jobs/background.py index c74e8b3..725eb62 100644 --- a/src/anyimage/server/jobs/background.py +++ b/src/anyimage/server/jobs/background.py @@ -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: diff --git a/src/anyimage/server/jobs/foreground.py b/src/anyimage/server/jobs/foreground.py index 3ccfdfa..540ee41 100644 --- a/src/anyimage/server/jobs/foreground.py +++ b/src/anyimage/server/jobs/foreground.py @@ -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( diff --git a/src/anyimage/server/jobs/image_lasso.py b/src/anyimage/server/jobs/image_lasso.py new file mode 100644 index 0000000..adbabdd --- /dev/null +++ b/src/anyimage/server/jobs/image_lasso.py @@ -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} diff --git a/src/anyimage/server/models/ben2.py b/src/anyimage/server/models/ben2.py index 7767eff..8449cf5 100644 --- a/src/anyimage/server/models/ben2.py +++ b/src/anyimage/server/models/ben2.py @@ -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( diff --git a/tests/test_addon.py b/tests/test_addon.py index d18ae62..dcd63ca 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -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) diff --git a/tests/test_clipboard_image.py b/tests/test_clipboard_image.py new file mode 100644 index 0000000..e598388 --- /dev/null +++ b/tests/test_clipboard_image.py @@ -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( + "= (5, 0, 0): + assert group.library is not None + + +if __name__ == "__main__": + main()