Add new node asset scripts for geometry and shader operations

- Implemented `build_mesh_plane.py` to create a geometry node group for mesh planes with customizable parameters.
- Created `build_shadeless.py` for a shader node group that displays color without scene lighting while preserving shadows and reflections.
- Added `validate.py` to validate the integrity of the newly created node groups and their inputs.
- Introduced `validate_cutout.py` to ensure the functionality of cutout node groups and their dependencies.
- Developed `validate_mesh_plane.py` to test the mesh plane creation and its modifier settings.
This commit is contained in:
2026-08-30 00:17:29 +08:00
parent 7d39fe8c5e
commit 32bcd45d7b
168 changed files with 7213 additions and 13121 deletions

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

View File

@@ -3,34 +3,28 @@
## 总则
- 说明性内容(提交说明、文档、注释、Mermaid 等)以中文为主,专有名词和约定俗成的表达可使用英文
- 动手前先读 `docs/index.md`;程序边界、调用流程和模块职责见 `docs/development.md#程序设计`
- 动手前先读 `docs/index.md`;程序边界、调用流程和模块职责见 `docs/internals/index.md` 与 `docs/internals/architecture.md`
- 修改代码、依赖或执行方式后,同步更新 `README.md` 与对应主题文档,保持文档、测试和实现一致
- 优先选择依赖少、直接、易验证的实现;保留工作区中与当前任务无关的已有改动
## 项目结构
## 内部实现文档
```text
.
├── README.md # 用户入口与快速上手
├── docs/ # 按功能拆分的设计与开发文档
├── pack.py # 构建各平台 Blender Extension ZIP
├── sync_models.py # 准备并同步 ONNX 模型
├── src/anyimage/
│ ├── __init__.py # Blender 注册入口
│ ├── operators.py # Job Operator 与结果导入
│ ├── panel.py # 3D View 面板
│ ├── preferences.py # Extension Preferences
│ ├── properties.py # Scene 设置与动态枚举
│ ├── job_runtime.py # BlendJob Runtime 配置
│ ├── blender/ # Depth Plane、Surface 的 Blender 侧实现
│ ├── server/ # Job、模型、ONNX 推理与输出实现
│ ├── assets/O_AnyImage.blend # Geometry Nodes 与材质资产
│ └── wheels/ # 随扩展分发的 BlendJob wheel
├── tools/ # Blender 资产构建与验证脚本
└── tests/ # Python 自动化测试
```
- [内部实现总览](docs/internals/index.md):内部实现文档的统一入口
- [程序架构](docs/internals/architecture.md):进程边界、职责与调用主链
- [Operator 实现索引](docs/internals/operators/index.md):源码功能与实现页的对应关系
文档按主题定位:功能与使用 `docs/features.md`、Depth Plane `docs/depth-plane.md`、Surface `docs/surface.md`、输出格式 `docs/outputs.md`、Job Server `docs/server.md`、程序设计与开发 `docs/development.md`。
修改完整工作流时同步更新对应实现页。`operators/common/` 不单独建立页面,共享边界写入程序架构。
## 模块与目录边界
- 目录结构应能直接表达业务模块和职责;优先按用户可理解的功能或稳定能力组织,不按模糊的数据类型、临时步骤或界面摆放机械分组
- 一个模块只承担一个可描述的职责。功能出现多个相对独立的交互、算法或构建阶段时再建立子目录,并让同一抽象层的实现并列
- 公共层来自已经存在的复用,不来自对未来的猜测。没有真实共享消费者的代码留在所属模块;抽取后只暴露消费者需要的最小接口
- 模块边界优先服从运行进程、依赖方向、数据所有权和生命周期。只在一侧处理的数据不为形式上的“共享”跨越边界;确因加载机制产生的例外应在架构文档中说明
- 编排层只组合步骤和定义公开协议,原子能力放在所属领域模块。编排模块之间不互相导入实现,共享逻辑应下沉
- 公开任务与界面入口不要求一一对应;边界由可复用计算、输入输出协议和失败语义决定。名称必须描述实际动作与产物,不暗示未承担的职责
- 没有生产消费者的入口、兼容层和辅助协议应删除,不为测试便利单独保留
- 移动或拆分模块时同步更新注册、导入、打包、测试和文档,不留下旧路径转发层
## 常见命令
@@ -53,27 +47,51 @@ uv run python pack.py
uv lock --refresh
```
资产脚本必须由 Blender 执行;具体入口与验证范围见 `docs/development.md#资产构建与验证`,不要用普通 Python 解释器运行。
资产脚本必须由 Blender 执行;具体入口与验证范围见 [节点资产](docs/internals/node-assets.md),不要用普通 Python 解释器运行。
`tools/` 中的脚本按维护对象分组,并统一使用能够区分“构建”和“验证”等动作的命名。每个脚本都应有明确入口、文档或测试消费者;没有实际用途的脚本应删除。
## 命名规范
- 变量、函数、Docstring 使用英文;同一概念在代码和文档中使用统一表达
- 名称应说明业务职责,并与用户概念、公开协议和界面术语保持一致;不要使用脱离上下文后无法判断用途的宽泛名称
- 文件和目录名应反映职责层级:集合层使用复数,单个功能或动作使用单数或动宾结构;同一层级保持一致
- Python 类使用大驼峰,不添加项目名前缀。Operator 使用动宾结构,如 `ConvertToDepthPlane`、`RemoveImageBackground`;Menu、Panel、AddonPreferences、PropertyGroup 分别使用实际类型后缀,如 `AnyImageImageMenu`、`ServerPanel`、`AnyImagePreferences`、`AnyImageSettings`
- `bl_idname` 与挂载到公共 Blender RNA 类型的 Property 使用 `anyimage` 项目前缀:Operator 如 `anyimage.convert_to_depth_plane`,Menu 如 `ANYIMAGE_MT_image_context`,Panel 如 `ANYIMAGE_PT_server`,公共 Property 如 `Scene.anyimage_settings`
- Operator 自身 Property 不加项目前缀,使用业务名称,如 `input_path`、`process_res`、`resolution_level`
- 扩展包名使用 Manifest 中的真实名称、版本和平台,例如 `AnyImage.v0.11.2.windows-x64.zip`;节点组名称沿用资产中的 `O DA3 Depth Plane`、`O MoGe2 Surface` 等既有名称
- 常量应表达领域约束、共享数值或稳定映射,不为字面量增加没有额外语义的同名别名;需要类型约束时使用集中映射或 Enum
- 同一发布单元的版本只保留一个事实来源。只有存在独立兼容周期和实际消费者时,才引入额外版本或迁移状态
## 代码规范
- 避免不必要的嵌套,优先使用提前返回和小而明确的函数
- 只为特殊、非常规或存在兼容性原因的实现写注释,不复述代码本身
- 删除功能时同时删除无效参数、分支、测试和文档,不保留没有实际消费者的兼容层
- Blender 侧不载入 AI 依赖:UI、Operator 和结果导入放在 `src/anyimage/` 或 `src/anyimage/blender/`,模型下载、ONNX 推理和输出放在 `src/anyimage/server/`
- Blender 侧不载入 AI 依赖:UI、Operator 和结果导入放在 `src/anyimage/` 或 `src/anyimage/operators/`,模型下载、ONNX 推理和输出放在 `src/anyimage/server/`
- 扩展级设置及其读取入口统一放在 `preferences.py`;使用 `addon_preferences()` 读取,不把 Blender Add-on Preferences 的定位、默认值或路径解析放进专门管理 BlendJob 的 `runtime.py`
- Job 参数使用可序列化的普通字典;Blender 数据只能在主线程的 Operator 响应阶段读写
- 新增或修改 Blender 类型时同步检查 `src/anyimage/__init__.py` 的注册与逆序注销,并补充对应测试
- 依赖版本、wheel 或 Manifest 改变时同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试
## 执行流程与复用
- 按数据依赖安排步骤:先完成会改变后续输入或分析范围的处理,再执行依赖该结果的计算
- 对相同输入和参数的昂贵确定性计算只执行一次,从同一中间结果派生多个产物
- 实现路径由当前功能的输入范围和质量要求决定,不为无实际用途的全局兼容选项增加分支
- 名称、参数和进度文案应反映当前阶段的真实职责,并明确跨进程流程中每一侧负责的最终操作
- 临时资源的创建与清理由同一所有者管理;共享输入解析和生命周期逻辑,不在各消费者中重复实现
## 清理、测试与重构验证
- 删除前先用 `rg` 查找生产调用方、注册入口、测试、文档和打包清单。只有注册或测试、没有真实功能消费者的入口视为孤立代码,应连同协议一起删除
- 测试应保护当前功能、公开协议、关键算法和已发生过的回归;不要为了保留旧文件结构、无消费者兼容层或实现细节而写测试
- 删除功能时删除对应测试;移动原子能力时把测试迁到新的职责模块。测试名称也要使用当前业务术语,不能继续出现已废弃的模块名或 Job ID
- 打包测试应验证最终产物包含必要文件且不包含已删除结构;文件移动后同步更新
- 结构重构完成后至少执行旧名称与旧路径搜索、差异检查、完整测试和文档构建;涉及 Blender 注册、运行时行为或节点资产时,再使用目标 Blender 验证
- 不混用开发环境、独立运行环境和 Blender 自带 Python;每类验证使用项目声明的对应解释器
- 重构应减少模块、分支、协议和重复计算的数量。新抽象只有在职责稳定或存在真实复用时才成立
## Blender 节点图规范
- 不修改节点的 `name`,也不使用 `label` 覆盖节点标题;保留 Blender 为节点类型生成的默认名称和外观,避免读图时无法判断节点原型。用途应通过节点类型、参数、空间布局和必要的少量注释表达

189
README.md
View File

@@ -1,164 +1,71 @@
# AnyImage
AnyImage 是一个 Blender 扩展,可以把一张照片、一段视频或一组序列帧快速变成有真实深度的立体面片。
AnyImage 是一款 Blender Extension,用于把图片带入可编辑的 2.5D 工作流:粘贴和清理图片、创建普通 Plane 或 Depth Plane、从套索生成 Cutout Shape,并通过独立 AI Environment 去除背景或进行 4× 高清放大。
完整使用说明请从[文档首页](docs/index.md)开始;首次使用请依次阅读
[安装与首次配置](docs/installation.md)和[五分钟上手](docs/quick-start.md)。
完整说明从[文档首页](docs/index.md)开始;首次使用请阅读[安装与首次配置](docs/getting-started/installation.md)和[五分钟上手](docs/getting-started/quick-start.md)。
## 主要功能
## 它能做什么
- 在 3D View、Node Editor 与绘画模式中用 `Ctrl+V`(macOS 为 `Cmd+V`)粘贴并 Pack 剪贴板图片
- 将 Image Empty 转换为带 `O Mesh Plane` Geometry Nodes 的普通 Plane
- 使用 MoGe-2 或 DA3 将静态图转换为可调 Depth Plane
- 用 Cutout 套索在图片可见 Alpha 内生成 Surface、Balloon、Depth Balloon 或 Depth Surface,并通过统一的 `O Image Cutout` 修改器切换 Shape、按创建分辨率清理碎岛和调整表面
- 用 Image Box、Lasso、Line、Polyline 和 Perspective 清理或矫正静态图片
- 用 BEN2 为单图、视频或序列帧去除背景
- 用三档 ONNX 模型将单图、视频或序列帧放大 4×
- 在 3D View、Node Editor 与绘画模式中按 `Ctrl+V`(macOS 为 `Cmd+V`)直接粘贴并 Pack 剪贴板图片
- 不运行 AI,把 Image Empty 转换为可实时调整 Thickness 与 Subdivide 的 Mesh Plane
- 用 Cutout 套索从 Image Empty 生成 Surface、Balloon、Depth Balloon 或 Depth Surface;四种结果共用单面基础薄片、局部方向与清理逻辑
- 用 Image Box / Lasso / Line / Polyline 直接清理 Image Empty;按 `F` 反选,也可用 BEN2 细化选区
- Image 编辑会直接更新未共享的图片;图片被多个 Image Empty 共用时,只为当前 Empty 分离编辑结果
- 用 Image Perspective 勾勒四个角点,把透视四边形矫正为矩形裁切图,并沿用源 Image Empty 的空间朝向
- 一键把 Image Empty 转换为立体面片:让 AI 估算深度,或直接用原图作为深度
- 把单张 Image Empty 转换为透视纹理网格,并自动匹配原对象的 Transform 与 Offset
- 一键去除图片背景,成成透明前景图
- 用三档 ONNX 模型将图片、视频或序列帧逐帧放大 4 倍
- 支持视频与序列帧输入,逐帧处理,动画也能得到逐帧对应的立体面片
- 主要转换操作集中在右键菜单,剪贴板图片可直接用系统粘贴快捷键导入
AI 推理运行在 Blender 之外的独立 Python 3.12 Environment 中。Blender 进程只负责 UI、任务提交和结果导入,不加载 ONNX Runtime 或模型权重。
全局 **Maximum AI Input Size** 默认限制 Upscale、Depth 与 Normal 分析的长边为 2048 px;超过时等比缩放分析输入,但不会降低颜色贴图分辨率。Cutout 在套索裁切后应用该限制,并以同一分析尺度计算网格密度。
## 安装
1. 下载对应系统的扩展压缩包
2. 打开 Blender,进入 `Preferences > Extensions`
3. 点击 `Install from Disk`,选择下载的压缩包
4. 在扩展偏好设置中确认 Storage Root 与 Device(保持默认即可);Device 默认取本机
最快的 DirectML provider;Apple M4 或更新芯片可选 MPS/CoreML,其他 Mac 只提供 CPU
5. 选中 Image Empty,在右键 `AnyImage` 子菜单中点击 `Set Up AI Environment…`;也可以从扩展偏好设置进入
1. 下载与系统匹配的扩展包:
- `AnyImage.v<version>.windows-x64.zip`
- `AnyImage.v<version>.macos-arm64.zip`
- `AnyImage.v<version>.linux-x64.zip`
2. 在 Blender 打开 **Edit > Preferences > Extensions**,选择 **Install from Disk** 并直接安装 ZIP。
3. 选中 Image Empty,在右键 **AnyImage** 菜单中点击 **Set Up AI Environment…**。
4. 安装完成后,在扩展 Preferences 中确认默认 MoGe-2、BEN2 与 Real-ESRGAN WDN 模型显示 Ready。
扩展不内置 uv 或 AI 运行库。`Set Up AI Environment…` 会先说明下载内容、约 600 MB 的下载量、约 1 GB 的磁盘占用和预计等待时间,并可勾选 `Use Mirror`。确认后会下载并校验固定版本的 uv,自动安装轻量的 ONNX Runtime 环境,再下载 `MoGe-2 ViT-S Normal`、`BEN2 Base` 和默认高清模型 `Real-ESRGAN General WDN x4v3`;不安装 PyTorch,也不下载各模型的 Python 源码包。`Use Mirror` 只切换 uv、Python 和 Python 包的下载源,模型仍使用自己的下载源与备用镜像规则。
普通 Plane、基础 Image 编辑,以及不带 Refine / Normal 的 Cutout Surface 与 Balloon 无需 AI Environment。Cutout 的 Depth 与 Normal 统一使用 MoGe-2;两者同时启用时共享同一次 Prediction,不会重复运行模型。
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` 会显示在右键菜单最上方。Mesh / Depth / Surface 转换为一组,Remove Background 与 Upscale 作为图片处理归为下一组,环境入口位于最后。Environment 或必需模型未就绪时,点击 `Convert to Depth Plane…`、`Convert to Surface…`、`Remove Background` 或 `Upscale` 会打开对应准备对话框:Environment 未安装时入口显示 `Set Up AI Environment…`,安装 Environment 与默认模型;Environment 已安装但默认模型缺失时入口显示 `Download Required Models…`,只补齐缺失模型。全部就绪后,菜单入口改为 `AI Environment Settings…`,并打开 AnyImage 扩展偏好设置。
选中 Image Empty 后,右键 **AnyImage** 菜单提供:
扩展偏好设置先显示 Environment 状态与 Diagnostics,再依次提供默认展开、可折叠的 Depth & Normal、Remove Background、Upscale 和 Cutout 功能区。Depth Model 按三档 MoGe-2、两档 DA3 排列;选择 DA3 时会提示该模型不能生成 normal。可选模型的状态与下载按钮位于对应模型选择项下方,BEN2 则归在 Remove Background。共享的 AI 状态检测直接检查 Environment 和 Storage Root 的 `models/` 文件,不会仅因打开菜单或设置而启动 Job Server。
- **Convert to Plane**:同步创建普通图片网格;
- **Convert to Depth Plane**:使用 Preferences 中选择的深度模型处理静态图;
- **Remove Background**:生成透明图片或序列;
- **Upscale**:使用默认高清模型生成 4× 结果。
## 快速上手
3D Viewport 工具栏提供 Image Mask、Perspective 与 Cutout。工作流对比和当前输入范围见[选择工作流](docs/guides/workflows.md)。
### 粘贴剪贴板图片
## 源码结构
复制图片后,在 3D View、Shader Editor、Compositor 或 Geometry Nodes 中按 `Ctrl+V`;macOS 使用 `Cmd+V`。AnyImage 会根据当前上下文创建 Reference Image、带材质的 Plane、图片节点或笔刷 Texture,并将图片 Pack 到当前 `.blend`。完整说明见[粘贴剪贴板图片](docs/clipboard-image.md)。
```text
src/anyimage/
├── operators/ # Blender 交互、任务提交与结果导入
├── server/ # 无 Blender 依赖的 Job、媒体、选区、几何与模型能力
├── assets/ # Geometry Nodes 与 Shader 资产
├── runtime.py # BlendJob Runtime 配置
└── __init__.py # Blender 注册入口
```
### 转换为普通图片平面
程序边界、功能文件结构和调用流程见[内部实现总览](docs/internals/index.md)。文档按开始使用、功能指南、参考和内部实现分层,不在 README 重复内部算法细节。
选中 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)。
## 开发
### 转换为立体面片
仓库开发环境使用 CPython 3.11:
1. 在 3D 视图中选中一个 Image Empty(空物体,显示方式为 Image)
2. 右键打开 `AnyImage` 子菜单,选择 `Convert to Depth Plane`
3. 在对话框中选择:
- `Depth from`:`Original` 直接用原图,`DA3` 使用现有 DA3 流程,`MoGe-2` 成成 metric depth、normal 与有效区域 mask
- `Resolution Scale` 使用 `1–100%`,按原图长边计算 Process Res;结果会限制在 `252–1008` 并自动对齐到 14 的倍数
- `Remove Background` 默认关闭;开启后在转换期间使用 BEN2 删除背景
- `Shadeless` 使用 `O Shadeless` 节点组显示不受场景灯光影响的贴图色色
- `Keep Original` 默认关闭,位于分割线下方;开启后保留源 Image Empty
4. 点击确认,稍等片刻就得到带位移的立体面片。`O DA3 Depth Plane` 与 `O MoGe2 Depth Plane` 都使用色色图片的 `Alpha > 0.5` 划分前景范围,范围外由 `Background Depth` 控制。
```bash
uv sync
uv run pytest
uv run python pack.py
```
### 编辑图片选区
节点资产脚本必须由 Blender 执行,具体命令和验证范围见[节点资产](docs/internals/node-assets.md)。完整开发、打包与 CI 说明见[开发与发布](docs/internals/development.md)。
在 3D Viewport 左侧工具栏选择 Image Mask 工具组。Box 拖出矩形,Lasso 手绘轮廓,Line 清理有向直线的一侧,Polyline 逐点构建多边形。点击另一张 Image Empty 时只切换选择;点击当前 Active 图片、空白处或非图片物体时,才开始编辑当前图片。没有 Active 图片时会提示先选择 Image Empty,切换其他工具会取消当前绘制。
## 许可证
Lasso 绘制时与 Box、Line、Polyline 一样显示白色虚线轮廓和淡灰半透明填充;凹形选区不显示内部投射线,自交选区则按最终操作的填充规则显示交叠区域。Cutout 预览与最终几何都使用非零环绕规则:同方向重叠保留,反方向抵消成孔。Lasso 只有在鼠标移动至少 2 px 时才采点,停留不会持续增加点数;近似共线的连续点会在线合并,填充和虚线段也只在轨迹变化时重算。绘制中按 `F` 切换反选:默认保留选区内,反选后保留选区外。Line 按住 `Ctrl` 以 5° 吸附;Polyline 可单击起点圆环或按 `Enter` 完成。`Refine Selection` 只用于 Box / Lasso:先用 BEN2 将几何选区收紧到其中的前景,再应用反选。结果直接替换当前 Empty 的图片,按最终非零 Alpha 紧裁切,并更新显示尺寸与 Image Offset;对象 Transform 不变。透明区保留源 RGB,避免纹理过滤产成黑边。未形成有效区域的单击会静默结束;有效选区没有可见像素时只显示警告,不修改图片。
### 四点透视矫正
`Image Perspective` 是紧随 Image Mask 工具组之后的独立工具。四个角可以按任意顺序点击,完成后会在源图片坐标中自动求出不交错的凸四边形,并以原图 X/Y 方向确定输出旋转。绘制时显示白色虚线和半透明选区面,`Backspace` 删除最近一个角点,右键或 `Esc` 取消;切换其他工具也会立即退出。首次点击遵循同一选择规则。Perspective 不依赖当前 View 的相机内参,因此透视和正交 View 都可以使用。
第四个角点按下时会在 Viewport 中央显示带蓝色边框的矫正图片预览,中央显示当前比例,下方显示鼠标操作提示;鼠标同时移到 Viewport 中心,为左右两个方向保留相同的调节空间。水平移动连续调整宽高比,按住 `Ctrl` 吸附到 `1:1`、`4:3`、`3:4`、`16:9`、`9:16`,下一次左键确认并创建;`Backspace` 返回第三点,右键或 `Esc` 取消。初始比例取四边形两组对边的平均长度比。预览使用一次成成的 512 × 512 临时图片,移动鼠标时只改变 UI 显示比例。
确认后,工具把选区透视矫正为矩形图片,按最终非零 Alpha 范围裁切并 Pack 到 `.blend`。结果 Empty 保持源图的朝向和深度,以四边形在源图上的 bounding box 近似确定中心与显示范围,不再尝试从当前 View 反解真实空间矩形。四边形完全位于源图范围外,或矫正结果没有可见 Alpha 像素时,只显示警告且不创建结果。输出最长边不超过 8192 px,总像素不超过约 16 MP。
Image Perspective 是 deform 工具,不提供 Refine 或反选。它与其他 Image 工具共享选图、绘制样式、紧裁切和 Empty 写回逻辑。
完整操作说明见 [Image 编辑工具](docs/image-tools.md)。
### Cutout 基础面与四种形状
在 Object Mode 的 3D Viewport 左侧工具栏选择 `Cutout`,沿 Image Empty 绘制后可从四个方向选择 Surface、Balloon、Depth Balloon 或 Depth Surface;双击会选择完整图片。`Point Spacing` 以完整原图像素为单位控制规则三角格点间距,裁切范围大小不会改变密度。未启用 `Refine Selection` 时,几何只跟随套索;启用后才使用 BEN2 Alpha 收紧选区。`Normal` 默认关闭;启用后才通过 MoGe-2 生成 normal 纹理。未启用 Refine 且关闭 Normal 时,Surface 与 Balloon 完全在 Blender 内生成,不启动 BlendJob。
四种结果的源网格统一为真正的单面薄片,并分别使用 `O Image Surface`、`O Image Balloon`、`O Image Depth Balloon`、`O Image Depth Surface` 四个独立 Geometry Nodes。局部纸面统一位于 YZ 平面,局部 `+X` 朝纸面内侧,局部 `-X` 是外侧和深度箭头方向;对象原点统一位于裁切图片中心,对象矩阵补偿后薄片仍落在源 Image Empty 的原位置。`Thickness = 0` 时保留单面;Surface 与 Depth Surface 的 Thickness 是场景距离,Balloon 与 Depth Balloon 的 Thickness 是 `o_balloon` 高度倍率,默认 `1` 保留算法原值。加厚按整片连通区域处理,内部三角边不生成侧壁。Surface 与 Depth Surface 会以边界三角边的中位长度为目标宽度,沿 Thickness 方向自动增加侧壁分段,使侧壁面片尽量接近正方形;Balloon 与 Depth Balloon 不增加这些分段线。
四个修改器都有默认折叠的 `Options` 面板,`Cleanup` 统一放在其中。节点按 Mesh Island 累计离散面面积,并以基础面三角形的中位面积自动估算 Point Spacing 尺度;默认 `1` 会删除面积小于约一个点间距平方的孤立小面,因此无需在 Cutout 创建后写入绝对场景单位阈值。Depth Balloon 的 `Base Plane`,以及 Depth Surface 的 `Depth Continuity`、`Smooth` 也收纳在该面板。
四种 Cutout 形态都会在创建基础网格时计算 Balloon profile,并统一写入 `FLOAT/POINT` 属性 `o_balloon`;不再写入 `o_boundary`。AnyImage 扩展 Preferences 的 **Balloon Profile** 可选择 Poisson 或 Teddy,两个 Balloon 节点组只读取属性,不再自行估算轮廓。两个 Depth 形状都从内部打包的浮点深度图片采样,不把深度写成点属性;内部深度 Image datablock 统一使用 `_depth.exr` 后缀,`Depth Image` socket 在修改器中隐藏。
Depth Balloon 的 Base Plane 位于当前裁切后网格的中心,而不是原图中心。默认朝向由选区内有效 MoGe 深度进行稳健平面回归;细长选区会保留长轴趋势,同时抑制缺少空间支撑的短轴倾角。Single Arrow 沿局部 `-X` 指向实际深度方向;沿箭头移动的场景距离与前表面的同向位移严格一致。Geometry Nodes 会先用 Base Plane 校正深度,再让 `Depth Scale` 乘整个校正结果;因此 `Depth Scale = 0` 时 Base Plane 和深度图都不再改变深度分支。`Double Sided` 默认关闭,使背面保持 Balloon 厚度,开启后则把受 Base Plane 调整后的最终前表面关于对象局部 YZ 平面完整镜像;Base Plane 只定义正面的朝向与基线,不会移动对称中心。Depth Surface 先按深度图片位移和断层剔除,再清理新产生的小岛、加厚并执行展开的 O Smooth 结构;`Iterations` 默认 `1`,`Depth Continuity` 默认 `0`。
Surface、Balloon 与 Depth Balloon 的 MoGe normal 会依据最终网格 TBN 烘成 tangent-space 纹理;Depth Surface 直接使用适配新局部轴向的 object-space normal。完整说明见 [Cutout](docs/cutout.md)。
### 去除背景
选中 Image Empty 后,右键打开 `AnyImage` 子菜单并选择 `Remove Background`,完成后 Empty 会自动
换成透明前景图。
### 高清放大
选中 Image Empty 后,在右键 `AnyImage` 菜单中点击 `Upscale`。它使用扩展偏好设置中选择的模型进行 4× 放大,支持单图、Movie 和 Image Sequence,并保留源 Alpha。
[阅读高清放大指南](docs/upscale.md)
### 转换为透视网格
选中单张图片的 Image Empty,右键打开 `AnyImage` 子菜单并选择 `Convert to Surface`。可选择 MoGe-2
ViT-S、ViT-B、ViT-L 或 DA3 Small、Base,其中 MoGe-2 ViT-S 为默认模型。AnyImage 会按模型输出的
相机内参与三维点建立透视网格,并匹配原 Empty 的可见尺寸、Offset 与 Transform。Server 通过统一的
Surface 输出流程构建 DA3 与 MoGe-2 源网格:MoGe-2 使用深度断层 mask,DA3 保留 confidence 与边拉伸
数据。两者再由独立的 Geometry Nodes
修改器完成剔面、快速重拓扑、Relax 与 UV 重投;顶层 `Iterations` 可设为 `0`。开启 Remove Background
时,BEN2 Alpha 大于等于 `0.5` 才视为前景;三角形三个顶点必须全部为前景,任一顶点 Alpha 小于 `0.5`
都会删除该面并且不再交给 Geometry Nodes;Surface 材质使用成成的色色图片。匹配相机默认不创建;开启后会作为 Surface 的子级。
MoGe-2 的像素级 normal 会作为 object-space normal 贴图接入 `O Image Layer`,并与原图
Bump 合并;网格本身不写入 custom normal,因此重拓扑降低顶点数后仍能保留细腻的法线细节。
当前此功能仅支持单张图片。
MoGe-2 ViT-S、ViT-B、ViT-L、DA3 Small、DA3 Base、BEN2 与 Upscale 使用的 Real-ESRGAN General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus 全部使用 ONNX。模型执行、相机与深度恢复、
点云重投影和网格成成均不依赖 PyTorch。Windows 使用 DirectML 加速;Apple M4 或更新芯片可使用 CoreML 的 `MLProgram` 模型格式,较早或无法识别的 Mac 只提供 CPU;provider 不可用时会回退到 ONNX Runtime CPU,
并在任务状态中显示实际 provider。
转换对话框提供模型与 `Create Matching Camera`、`Remove Background`、`Shadeless`;DA3 显示
`Resolution Scale`,MoGe-2 显示 `Resolution Level`,
以及分割线下方的 `Keep Original`。
界面会显示计算所得的实际 Process Res。剔除和重拓扑参数直接在
Geometry Nodes 修改器中调整;`Shadeless` 与 Depth Plane 共用 `O Shadeless` 材质节点组。色色贴图始终使用统一的图片转换产物;Remove Background 只决定是否由 BEN2 修改 Alpha。色色贴图会按当前场景的 View Transform 选择对应输入色彩空间:AgX、ACES 1.3、ACES 2.0、Filmic、Khronos PBR Neutral 分别使用 `AgX Base sRGB`、`ACES 1.3 sRGB`、`ACES 2.0 sRGB`、`Filmic sRGB`、`Khronos PBR Neutral sRGB`;无法匹配当前 View Transform 时回退 `sRGB`。普通材质的
Bump Scale 会在材质组内乘以 `0.001`,并按透视表面的深度空间缩放。Surface 与 Depth Plane 的
Layer Color 同时驱动 Principled BSDF 的 Base Color、Subsurface Radius 和 Emission Color,Layer Normal
同时驱动 Normal 与 Coat Normal;Subsurface、Emission、Coat 的其他设置保持默认。Surface attributes
统一使用 `o_` 前缀;Server 准备过程显示
`Preparing surface data n/6`,可看到当前阶段与总步骤;Surface 与 Depth 单帧模型推理都不显示多余的 `1/1`。
### 视频与序列帧
生成的颜色、深度和法线 Image datablock 分别使用 `_color.png`、`_depth.exr`、`_normal.png` 和 `_upscale.png` 后缀,让 Blender 保持对真实图片格式的识别。
AnyImage 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并成成逐帧对应的立体面片。`_color.png` 与 `_depth.exr` Image datablock 按源 Empty Image 的类型载入:普通单图为 `IMAGE`,Movie/Sequence 即使只处理一帧也仍为 `SEQUENCE`。处理范围默认
跟随 Empty 上设置的 `Image Frames` 与 `Offset`,不需要额外配置。DA3 的转换任务复用同一个 ONNX
Session 逐帧推理,显存峰值不随视频帧数平方增长。Upscale 也会逐帧成成受输出上限约束的 4× 色色序列并写回 Empty。Debug 只保存模型 API 的直接返回:DA3 多帧输入
保留联合推理,MoGe-2、BEN2 与三档高清模型按各自的单图 API 逐帧保存。高清 Debug 输出 4× RGB `prediction*.png`,不做强度混合或图片替换;三个模型都由适配层自动分块,因此接受任意图片宽高。
## 任务结果保存在哪里
任务结果保存在 Runtime Storage Root 的 `jobs/` 下,默认是 `~/.anyimage/jobs/`。Job Server 接受任务时自动成成 opaque Job ID,并创建同名文件夹;Blender 不预先申请 ID 或目录。Packed Image 和序列帧的临时输入由 Blender 在任务结束后清理,Server 产物直接写入该 Job 目录。普通图片层使用 `image.0001.*` 起的文件名,导入后沿用源 Image 名称并由 Blender 自动添加 `.001`;Upscale 单独使用 `upscale.0001.png` 和 `_upscale.png` 数据块后缀。视频或序列帧成成的面片会引用其中的序列帧,请保留对应文件夹,不要随意删除。
每个任务目录中的 `job.log` 保存任务细节和完整 traceback;Storage Root 下的 `server.log` 只保留带时间、Job ID 与 Job Type 的成命周期主线,以及 Server 自身异常。
## 常见问题
- 首次使用需要下载模型;Environment 已安装但默认模型下载慢或失败时,点击 `Download Required Models…` 可补齐缺失项,也可在 AnyImage 扩展偏好设置中逐项重试
- 安装 Environment 时下载状态会显示平均速度;网络适合国内镜像时在确认对话框勾选 `Use Mirror`,否则保持关闭。选择的来源固定用于 uv、Python 和 Python 包,失败时不会自动切换
- 默认模型下载会在 Hugging Face 与 OmooLab 镜像之间自动回退;某一来源暂时断线不会阻止后续模型尝试另一来源
- 模型直链建连超过 10 秒,或连接后连续 60 秒没有收到数据,会切换到下一来源;不限制总下载时长
- 找不到右键入口时,请确认选中了 Empty,且显示方式为 Image
- 动画面片不动:检查 Empty 的 `Image Frames` 是否大于 1,并确认播放场景帧
## 技术文档
Blender 侧只负责界面、任务提交与结果导入,AI 与文件输出由独立 Job Server 执行。源码将插件级 `runtime.py`、`menu.py`、`panel.py` 保持在顶层,具体功能与共享图片、材质能力统一放在 `operators/`;每种任务的结果回写与对应 Operator 放在一起。详细功能见
[文档索引](docs/index.md),程序设计、调用流程、项目结构与开发命令见
[程序设计、开发与许可证](docs/development.md)。CI 在 `main` push 与 Pull Request 上运行测试,仅由 `v*` tag 构建并发布扩展包;文档在 `main` 的文档相关文件变化时部署。
AnyImage Extension 代码采用 GPL-3.0-or-later。模型权重使用各自上游许可证。

View File

@@ -1,36 +0,0 @@
# 视频与序列帧
AnyImage 可以逐帧处理视频和序列帧,并生成随场景时间播放的 Depth Plane 或透明 Image Empty。
## 支持的工作流
- **Convert to Depth Plane**:支持视频和序列帧。
- **Remove Background**:支持视频和序列帧。
- **Convert to Surface**:当前只支持单张图片。
## 设置处理范围
处理范围来自 Image Empty 自身的图片设置:
- **Image Frames** 决定处理的帧数。
- **Offset** 决定从源素材的哪个位置开始。
- **Start** 决定结果从场景的哪一帧开始播放。
- **Cyclic** 会保留到生成的颜色与深度序列中。
显式把 Image Frames 设为 `1` 时,AnyImage 只处理一帧。范围超过视频长度时,AnyImage 会把窗口收回有效范围内。
## 性能建议
- 先用较少帧数和较低分辨率测试效果。
- DA3 和 MoGe-2 的 Depth Plane 会逐帧推理并复用模型 Session,显存峰值主要由单帧分辨率决定。
- 视频越长,处理时间和任务目录占用越大。
- 任务运行期间可以从 Blender 状态栏查看进度并取消。
## 保存和移动工程
Blender 无法把整组动画图片序列完整打包进 `.blend`。AnyImage 因此让动画结果直接引用 Storage Root 的 Job 文件夹。
!!! danger
删除、移动或清理仍被工程引用的 Job 文件夹,会导致动画颜色或深度序列丢失。
需要把工程移动到另一台机器时,请连同对应 Job 文件夹一起复制,并在 Blender 中重新定位缺失文件。也可以先把需要长期保留的序列复制到项目自己的素材目录,再用 Blender 的外部数据工具重新映射路径。

View File

@@ -1,37 +0,0 @@
# Cutout
Cutout 用套索从 Image Empty 中选择范围,再从四个方向选择生成结果:左侧 **Surface**、右侧 **Balloon**、下方 **Depth Balloon**、上方 **Depth Surface**。双击图片会把完整画面作为选区。
## 绘制选区
在 Object Mode 的 3D Viewport 左侧工具栏选择 **Cutout**,按住左键绘制,松开后选择形状。绘制时按 `F` 切换反选,右键或 `Esc` 取消当前轨迹。
自交轨迹使用非零环绕填充规则。沿相同方向围住的区域即使重叠多次也会保留;只有反方向环绕使计数抵消时才形成孔洞。因此交错部分不会再被当成洞扣除,同时仍能表达中间的反向内环孔洞。普通选区可以越过 Image Empty 边框,框外轨迹仍会生成网格;选区必须与原图中的可见像素相交才有效。反选仍限制在原图范围,避免把框外无限区域截成任意矩形。Cutout 的 **Refine Selection** 独立保存,不与 Image Box 或 Image Lasso 联动。未启用时,选区边缘会做轻量抗锯齿;启用后,BEN2 先在同一个几何选区内计算前景 Alpha,再应用反选。
## 基础面密度
**Point Spacing** 表示基础面相邻采样点大约对应多少源图片像素,可设为 `4`–`32`,默认 `32`。数值越小,规则三角格点越密,后续形状能表达的细节越多,同时网格点数和约束三角化成本也越高。点间距始终根据完整源图和 Image Empty 尺寸换算;局部裁切只改变位置、UV 与贴图范围,不会重新归一化网格密度。最长边的采样级别限制在 `4`–`1024`。
基础面先从最终 Alpha 提取外边界与孔洞,再以接近等边三角形的六角格点填充内部。轮廓按目标间距重采样和平滑,边界点仍作为约束参与 Delaunay,因此内部点保持均匀,外缘不会退化成规则栅格的阶梯锯齿。
四种结果都启用平滑着色。Surface 与 Depth Surface 的修改器也在输出阶段显式设置 Smooth Shading;Balloon 与 Depth Balloon 在创建源网格时设置。
Surface 的基础三角面统一按图片正面逆时针排列,Thickness 沿图片平面法线向前后各偏移一半;背面与轮廓侧壁使用相反绕序,所有封闭面的法线都朝向几何外部。Depth Surface 则保持正面深度表面不动,只沿法线反方向挤出背面。Surface 与 Depth Surface 的 Thickness 是 Blender 场景中的实际距离;其侧壁分段数按 `ceil(Thickness / 边界三角边中位长度)` 自动计算,并限制在最多 128 段,使大多数侧壁单元尽量接近正方形。分段只发生在连接前后表面的侧壁环带。Balloon 与 Depth Balloon 的 Thickness 是 `o_balloon` 高度倍率,`1` 保持算法原值,`0` 保持单面薄片;这两种形态不加入自适应分段,因此分段线不会改变 Balloon profile。
四个形状节点组统一采用从左到右的几何主线;参数使用靠近消费者的局部 Group Input,未连接 socket 全部隐藏。Cleanup、profile、深度采样、Base Plane、分段计数和平滑等字段分支放在主线上下,并在就近节点汇入;Repeat Zone 的输入、内部处理和输出保持清楚的包裹顺序。节点不使用自定义 label 或彩色 Frame,也不允许反向连线和节点重叠。`Surface` 对应节点组名称为 `O Image Surface`;载入旧资产时会把 `O Image Cutout` 原位迁移为新名称。
Depth Surface 的 `O Image Depth Surface` 按“深度 → 断层剔除 → 向后厚度 → 零厚度清理 → 展开的 O Smooth 结构 → 平滑着色”从左到右排列,使内置平滑与在完整输出后连接 `O Smooth` 获得相同的输入几何。平滑段把 O Essentials 参考资产的 Repeat Zone、两条不同求值域的边界判断、边界采样与法线一致性权重节点直接放入主节点图,每轮重新计算后再移动点,不再嵌套 `O Smooth` 节点组;内部固定按 `Pin Sharp = 1`、`Pin Boundary = 1` 计算,不暴露这两个参数,`Iterations` 默认 `1`、最小可设为 `0`。Depth Continuity 使用与 `O MoGe2 Surface` 相同的百分比阈值曲线,默认 `0`,对应最宽松的断层阈值;提高数值会逐步剔除更多过远的深度跳变面。
两个 Depth 节点组的内部浮点深度 Image datablock 统一使用源图片基础名称加 `_depth.exr` 后缀,不再使用旧 `.depth` 名称。四个节点组都提供默认折叠的 `Options` 面板,并把 `Cleanup` 收纳其中。`O Image Depth Balloon` 的 `Base Plane` 也位于 Options;`O Image Depth Surface` 的 `Depth Continuity` 与 `Smooth` 同样位于 Options。Depth Balloon 先计算相对 Base Plane 的校正深度,再用 `Depth Scale` 乘整个校正结果;设为 `0` 时,Base Plane 的位置和旋转以及深度图都不再改变深度分支。开启 `Double Sided` 后,Base Plane 只影响正面的朝向与基线,背面再由最终正面关于对象局部 YZ 平面(`X = 0`)镜像得到;Base Plane 不会改变前后对称中心。
Base Plane 的默认朝向使用选区内有效 MoGe 深度进行四轮稳健平面回归。回归在图像坐标的主轴和短轴上分别评估空间支撑;当细长结构的短轴方差过小时,会限制该方向的反演增益,避免少量横向深度噪声被放大成极端倾角,同时保留长轴上有充分样本支持的深度趋势。Blender 将归一化平面换算到裁切网格的本地坐标时执行同样的稳定处理。
## 计算说明
未启用 **Refine Selection** 时,几何范围只使用抗锯齿套索 mask,原图 Alpha 不参与轮廓;原图 Alpha 只用于确认选区内至少存在可见像素,并继续控制材质透明度。无论是否启用 **Normal**,Job 都会为真实出框的套索建立可带负坐标的扩展选区画布,因此图片边框外的几何不会被原图尺寸裁掉。套索或 Refine 的最终 Alpha 会确定紧凑矩形裁剪范围,并以 `0.5` 为几何边界:小于 `0.5` 的区域不建面。Color 贴图使用当前 Image Empty 在同一矩形内的原始像素,不乘套索或 Refine Alpha,只保留图片本身已有的 Alpha,图片外像素保持透明。深度和法线分析使用裁剪后的局部分析图:最终选区内保留当前图片的原始 RGB 与有效 Alpha,图片外和选区外 RGBA 全部置零。若当前图片来自 Upscale,材质、深度和法线都直接使用高清图所选区域,不再回溯放大前原图。Color 与 normal 允许保持裁剪后的非正方形尺寸;Blender 按裁剪 bounds 调整局部平面范围和 UV,使结果仍落在源 Image Empty 的原位置。关闭 **Normal** 时,Surface 与 Balloon 直接在 Blender 内处理,不准备临时输入、不启动 BlendJob;启用 **Normal** 后才提交 Job。MoGe 返回的相机轴法线先按源 Image Empty 的对象坐标编码。Depth Surface 原样保留这张 object-space 贴图;Surface、Balloon 与 Depth Balloon 则在最终几何节点结果可用后,使用成品网格的 UV、平滑角法线和切线基底重新烘成 tangent-space 贴图。启用 Refine 时,Job 先计算 BEN2 Alpha 并返回裁剪后的几何 mask。Depth 类型始终通过 Job 取得深度,但也只在启用 **Normal** 时写入 normal。四种形状的网格都在 Blender 主线程构建,不再通过 `contour.json` 传递边界。
最终 Alpha 先由 NumPy 批量找出四向边界,只沿实际周长追踪轮廓,并按包含关系把孔洞归入外轮廓。未 Refine 时直接复用套索 mask;只有真实出框时才建立扩展几何画布。启用 Refine 后完全使用原图画布内的最终 AI Alpha,所有框外套索区域都会被裁掉。格点准备使用批量数组生成;点内判断按 Y 范围分桶,只比较当前扫描带可能相交的轮廓边;边界安全距离也只检查每条边附近的少量格点。短轮廓会自动使用不大于周长八分之一的边界采样间距,避免有效小区域因当前 `Point Spacing` 较大而消失;内部格点仍严格使用用户设置的 `Point Spacing`。UV 与封边边界使用 Blender `foreach_*` 和 NumPy 批量写入,不逐元素遍历。Pillow 只会加速占比较小的 Alpha 模糊和缩放,因此 Blender 本地路径保持纯 NumPy,不新增 wheel。最终约束 Delaunay 和网格创建仍随实际点数增长,极密网格应按成品需要使用。
Cutout 当前只支持静态图片。**Refine Selection**、Depth 与 normal 分析需要已配置的 AI Environment 和对应模型。
Surface、Balloon、Depth Balloon、Depth Surface 以及普通 Surface 的图片材质共用 `O Image Layer`。节点组始终提供 Normal 输入和 Object Space 开关;没有 normal 纹理时使用 `[0.5, 0.5, 1.0, 1.0]` 中性默认值。这个开关只选择 Blender Normal Map 节点的解码空间,不承担 Object-to-Tangent 转换:三种 tangent-space 结果在接入材质前已经完成像素烘制,Depth Surface 才打开 Object Space 并直接使用 MoGe 原图。

View File

@@ -1,66 +0,0 @@
# Depth Plane
Depth Plane 把图片映射为带深度位移的面片,适合快速获得可调节的立体效果。操作方法见[Depth Plane 使用指南](guide-depth-plane.md)。
## 数据流
```mermaid
flowchart LR
Input["Image Empty"] --> Depth{"深度来源"}
Depth --> DA3["DA3"]
Depth --> MoGe["MoGe-2"]
Depth --> Original["Original"]
DA3 --> Layers["颜色层 + 深度层"]
MoGe --> Layers
Original --> Layers
Layers --> Import["Blender 导入"]
Import --> Mesh["基础面片"]
Import --> Nodes["Geometry Nodes"]
Import --> Material["材质"]
```
Server 为每帧准备颜色、深度和可选前景 Alpha。Blender 导入这些数据,创建基础面片,并用 Geometry Nodes 与材质分别完成几何位移和表面细节。
## 深度来源
| 来源 | 深度含义 | 额外数据 | 适合 |
| --- | --- | --- | --- |
| DA3 | 相对深度 | 相机内参与置信度 | 通用单图与动画 |
| MoGe-2 | Metric depth | 法线、Mask、相机内参 | 更稳定的尺度与表面法线 |
| Original | 原图亮度 | 可选 BEN2 Alpha | 已准备好高度图的素材 |
DA3 和 MoGe-2 都以未经抠图的 RGB 估计深度。启用 Remove Background 后,BEN2 只分析输入 Alpha 内的可见 RGB,颜色 Alpha 使用“输入 Alpha × BEN2 Alpha”,并作为前景 Mask;深度本身保持模型输出。
视频与序列帧逐帧推理并复用同一个模型 Session,因此显存主要由单帧分辨率决定。DA3 Debug 的多帧联合推理属于另一条工作流。
## Blender 结果
转换会创建以下数据:
- 与源 Image Empty 对齐的面片
- 使用 `_depth.exr` 与 `_color.png` 后缀的 Image datablock
- 使用 `O DA3 Depth Plane` 或 `O MoGe2 Depth Plane` 的 Geometry Nodes 修改器
- 连接颜色、Alpha、Bump 与材质位移的材质
Geometry Nodes 提供深度范围、细分、重拓扑、平滑、背景深度和位移强度等控制。深度 Mask 在几何和材质路径中使用相同阈值,以保持轮廓一致。
材质与图片名称以源 Image datablock 为基名。生成贴图将业务后缀放在真实文件扩展名前,避免 Blender 将其误判为源图数据块。对象 Transform、显示尺寸和 Offset 会转换为面片的空间位置与比例。
## 单图与动画文件
单图深度使用 OpenEXR Pack 到 `.blend`,以保留浮点范围。Packed Image 会先导出临时输入,任务结束后清理,源 Image 保持不变。
动画使用 Job 目录中的外部序列:
```text
<job-id>/
├── image.0001.jpg/png
├── depth.0001.exr
└── results.npz
```
MoGe-2 动画的深度序列使用 `moge_depth.0001.exr`。这些文件是 `.blend` 的运行时依赖,详见[动画与序列帧](animation.md)。
## 适用范围
Depth Plane 是固定平面上的深度位移,不是完整的逐像素 camera-space 重建。它强调可编辑性和动画支持;需要透视网格时使用 [Surface](surface.md)。

View File

@@ -1,135 +0,0 @@
# 程序设计、开发与许可证
## 程序设计
AnyImage 分为 Blender 交互层、BlendJob Runtime 和 Job Server 计算层。
```mermaid
flowchart LR
UI["菜单 / Panel"] --> Operator["Feature Operator"]
Operator --> Runtime["JobRuntime"]
Runtime --> Job["Server Job"]
Job --> Files["Job Directory"]
Files --> Response["Operator Response"]
Response --> Scene["Blender Scene"]
```
Blender 交互层管理 UI、RNA 状态、任务参数和结果导入。Runtime 管理 Environment、Server、队列、进度与取消。Server Job 组合媒体、模型、几何和输出模块。完整运行设计见 [Job Server](server.md)。
### 代码组织
| 位置 | 内容 |
| --- | --- |
| `__init__.py` | Blender 类型、菜单与 Runtime 注册 |
| `clipboard_image/` | 剪贴板读取、上下文粘贴与快捷键 |
| `menu.py`、`panel.py` | 用户入口 |
| `preferences.py`、`properties.py` | 扩展配置与 Scene 状态 |
| `runtime.py` | JobRuntime 配置 |
| `operators/` | Blender 功能与结果导入 |
| `server/jobs/` | Server 工作流 |
| `server/models/` | ONNX 模型适配 |
| `server/geometry/` | 纯数据几何处理 |
| `server/outputs/` | 文件序列化 |
| `server/media/` | 媒体输入 |
| `assets/O_AnyImage.blend` | Geometry Nodes 与材质资产 |
Mesh Plane、Depth Plane、Surface、Background 和 Balloon 按功能组织。一个功能的入口、请求和 Blender 回写放在一起;跨功能的图片、材质和参数处理放在 `operators/common.py`、`operators/image.py` 与 `operators/material.py`。
Server 的模型模块返回 Prediction,几何模块处理数组与网格,输出模块写文件。Blender 数据只在 Operator Response 的主线程阶段创建。Cutout 与 Balloon 在未启用 Refine 和 `Normal` 时不提交 Job:Operator 直接在内存中合成最终 Alpha,并调用 `server/geometry/cutout.py` 的纯数据轮廓函数后创建网格。Refine、Normal 与 Depth Job 只返回最终图片 Alpha 和被选中的 AI 数组或纹理;`normal_analysis` 为假时不请求 MoGe-2 normal 模型,也不写入 `normal.png`。外轮廓、孔洞及基础面始终在 Blender 主线程生成,不使用 `contour.json` 作为中间结果。Cutout 的 Point Spacing 由完整源图尺寸换算到平面空间;裁切 bounds 只参与轮廓坐标、局部平面和 UV 映射。
扩展级设置统一通过 `preferences.addon_preferences()` 读取,不归属 BlendJob Runtime;Storage Root 路径回调也由 Preferences 模块提供,Runtime 只消费回调结果。扩展包标识直接使用根模块中的 `__package__`,以兼容 Blender 实际的 `bl_ext.<repository>.anyimage` 命名空间;不得按第一个点截断,否则会错误查找 `bl_ext` 并静默回退默认值。Preferences 的设备枚举和面板数据按需导入,避免循环依赖。
正式 Upscale 使用仅具备 `UNDO` 的 Job Operator 完成输入准备、任务提交和结果回写,不注册 Last Operation 重做面板。每次 `execute()` 都从 Preferences 读取默认模型与输出长边上限,再写入隐藏的可序列化 Operator 参数。高清结果只通过 `Image.anyimage_upscale_source` 保存原始 Image 引用;Operator 自身仅以隐藏的 `StringProperty` 保存原图名称,并在主线程响应时从 `bpy.data.images` 重新解析,避免保留已被移除的 RNA 指针。再次执行时从原图引用准备输入并替换当前高清数据块;无其他用户的旧高清结果会在新结果配置成功后移除,因此同一 Empty 只保留一个高清版本。模型只处理 RGB;源图 Alpha 经过同样的输入尺寸限制后独立放大并合并回结果。
Blender 侧需要落盘的 Packed Image 与浮点深度 EXR 都通过 `Image.save(save_copy=True)` 导出,不调用 `save_render`,避免把 Scene Render 的 View Transform、Look 或 Exposure 耦合到模型输入和 Non-Color 数据;颜色结果载入后继承源 Image 的 Color Space。Server 中的 `PIL.Image.save()` 只负责普通文件编码,不属于 Blender Render 色彩管理。
Image Box、Lasso、Line、Polyline 与 Perspective 位于 `operators/image_edit/` 功能包。Cutout、Image Box 与 Image Lasso 分别读取 `Scene.anyimage_settings.cutout_refine_selection`、`image_box_refine_selection` 与 `image_lasso_refine_selection`,三个工具的 Refine 状态互不联动;Line 与 Polyline 在公共设置入口中强制关闭 Refine。模块职责、交互状态、像素管线、Refine 和 Empty 定位见 [Image 工具技术实现](image-tools-technical.md)。
## 项目结构
```text
.
├── README.md
├── docs/
├── pack.py
├── sync_models.py
├── src/anyimage/
│ ├── blender_manifest.toml
│ ├── runtime.py
│ ├── menu.py
│ ├── panel.py
│ ├── preferences.py
│ ├── properties.py
│ ├── clipboard_image/
│ ├── operators/
│ ├── server/
│ ├── assets/
│ └── wheels/
├── tests/
└── tools/
```
## 开发命令
在项目根目录使用 Git Bash:
```bash
uv sync
uv run pytest
uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v
uv run python pack.py
```
`pack.py` 为 Windows x64、macOS Apple Silicon 与 Linux x64 构建扩展 ZIP 到 `dist/`。下载项目依赖时会把 `src/anyimage/wheels/` 作为优先可用的 wheel 来源,因此尚未发布到 PyPI 的本地 BlendJob 构建也能进入三个平台包。依赖、wheel 或 Manifest 改变后,应同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 与打包测试。
CI 分为三条流程:
- `test.yml`:在 push、Pull Request 与手动触发时运行测试
- `build.yml`:响应 `v*` tag,测试后构建并上传 Release
- `docs.yml`:文档变化后构建并部署站点
## 文档
本地预览和严格构建:
```bash
uv run --only-group docs mkdocs serve
uv run --only-group docs mkdocs build
```
版本化发布可使用 Mike:
```bash
uv run --only-group docs mike deploy <version> -p
uv run --only-group docs mike set-default <version> -p
```
自动部署将 MkDocs 站点发布到专用 Cloudflare Worker。仓库需要配置 `CLOUDFLARE_ACCOUNT_ID`、`CLOUDFLARE_API_TOKEN` 和 `CLOUDFLARE_WORKER_NAME`。Worker 路由对应 `docs.omoolab.xyz/anyimage/*`。
## 资产构建与验证
`tools/` 中的资产脚本依赖 `bpy`,应由 Blender 执行:
```bash
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_cutout_asset.py
"$BLENDER_BIN" --background --python tools/validate_moge_depth_plane.py
"$BLENDER_BIN" --background --python tools/validate_moge_surface.py
```
修改节点组、材质组或资产接口后,运行对应验证脚本和完整测试。 Cutout 四个形状节点组还会验证所有连线从左向右、节点之间保留可读间距、局部 Group Input 未使用输出隐藏,以及 Repeat Zone 数量和几何求值不变。Python、文档或打包逻辑变化无需重建 `.blend` 资产。
## 上游与许可证
- Depth Anything 3 代码采用 Apache 2.0
- BEN2 ONNX 模型采用其仓库声明的 Apache 2.0 License
- Real-ESRGAN General WDN x4v3 与 Real-ESRGAN x4plus ONNX 权重采用其模型仓库声明的 BSD 3-Clause License
- SAFMN Real x4 ONNX 权重采用其模型仓库声明的 Apache 2.0 License
- 模型目录显式声明可用的 Hugging Face 仓库;未声明时只同步经过大小与 SHA-256 校验的 R2 制品
- AnyImage Extension 代码采用 GPL-3.0-or-later
- 模型权重许可证由所选 Hugging Face 模型仓库决定

View File

@@ -1,76 +0,0 @@
# 功能参考
本页概览 AnyImage 的能力。安装步骤见[安装与首次配置](installation.md),实际操作见[工作流选择](workflows.md)与各使用指南。
## 工作流
| 工作流 | 适合 | 输入 | 结果 |
| --- | --- | --- | --- |
| Clipboard Image | 无需先另存即可使用系统剪贴板图片 | 剪贴板中的静态图片 | Reference Image、Plane、图片节点或笔刷 Texture |
| Mesh Plane | 不需要深度的图片卡片与背景板 | 单图、视频、序列帧 | 带图片材质和 Geometry Nodes 的普通平面 |
| Depth Plane | 快速制作可调节的立体图片 | 单图、视频、序列帧 | 带颜色、深度与 Geometry Nodes 的面片 |
| Surface | 从单张图片恢复透视空间 | 单图 | 相机空间网格、材质与可选匹配相机 |
| Remove Background | 提取透明前景 | 单图、视频、序列帧 | 透明图片或透明序列 |
| Upscale | 在安全输出上限内增加图片像素 | 单图、视频、序列帧 | 三档模型生成的高清图片或序列 |
| Image Mask | 用 Box、Lasso、Line 或 Polyline 直接清理图片 | 静态 Image Empty | 原位更新并 Pack 的透明裁切图 |
| Image Perspective | 四点透视矫正裁切 | 静态 Image Empty | Pack 的矩形裁切图 |
| Cutout | 套索选择区域并生成四种网格形状 | 静态 Image Empty | 带孔洞与原图 UV 的规则三角基础面 |
| Balloon | 沿轮廓绘制鼓起造型 | 静态 Image Empty | 带原图 UV 的封闭网格 |
| Debug | 检查模型原始输出 | 模型支持的图片或帧序列 | 保存在 Job 目录中的原始 Prediction |
Depth Plane 与 Surface 可使用 DA3 或 MoGe-2。DA3 提供相对深度和相机信息;MoGe-2 还提供 metric depth、法线与有效区域。BEN2 为去背景和可选前景处理提供 Alpha。
## Blender 交互
复制图片后,在支持的编辑器中按 `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` 右键菜单;AnyImage 使用 prepend 注册,显示在右键菜单最上方。
`Convert to Mesh Plane` 位于 `Convert to Depth Plane` 上方,不需要 AI Environment。它生成基础四边形,并由 `O Mesh Plane` 修改器实时处理 `Thickness` 与 `Subdivide`;`Thickness` 为 `0` 时保持单面。操作会立即执行;创建后可在 Last Operation 中调整 `Shadeless` 与 `Keep Original`。
Cutout 复用 Image Lasso 的反选与 Refine 流程,但自交选区使用非零环绕填充:同方向重叠保留,反方向内环形成孔洞。基础面以均匀六角格点填充平滑约束轮廓,`Point Spacing` 以完整源图像素为单位控制内部密度,不随局部裁切范围重新归一化;短轮廓会自适应边界采样以免有效小区域消失。未启用 Refine 的 Surface 与 Balloon 只按套索建面,不把原图 Alpha 计入几何;同时关闭 `Normal` 时在 Blender 内完成遮罩、轮廓和建面,不启动 BlendJob。套索或 Refine Alpha 以 `0.5` 为几何剔除阈值,并确定紧凑矩形裁剪范围。材质 Color 使用当前 Empty Image 在该范围内的原始像素,不乘选择 Alpha,只保留图片原有 Alpha;MoGe 深度与法线则使用同一裁剪范围的局部分析图,选区内保留当前图片 RGB,范围外 RGBA 置零。Upscale 图片直接使用高清图的所选区域进行材质与 AI 分析。裁剪贴图可以是非正方形,局部平面范围和 UV 会映射回源图对应区域。`Normal` 默认关闭,启用后才用 MoGe-2 写入 normal;Depth Surface 保留 MoGe 原生 object-space 像素,Surface、Balloon 与 Depth Balloon 根据成品网格 TBN 烘成 tangent-space 像素。Depth 类型始终使用 Job 生成深度,但同样由该选项决定是否写入 normal。Refine 启用后由 BEN2 返回裁剪后的最终 Alpha,Blender 再从该 Alpha 建面。详细操作与计算边界见 [Cutout](cutout.md)。
`O Image Depth Surface` 的平滑阶段直接展开 O Essentials `O Smooth` 的 Repeat Zone 与双域边界判断结构,不嵌套组节点;内部固定按 `Pin Sharp = 1`、`Pin Boundary = 1` 计算,不暴露这两个参数,`Iterations` 默认 `1`、最小可设为 `0`。`Depth Continuity` 默认 `0`;Thickness 固定正面,只向背面挤出。
`Depth Plane` 与普通 `Surface` 的推理仍可沿用 Upscale 保存的原图引用;Cutout 四种形状则以当前高清 Image 的所选区域作为 Color、深度和法线输入。
转换任务完成后,AnyImage 在一个 Undo 步骤中创建网格、图片、材质和修改器。源 Image Empty 是否保留由转换选项决定。
Image Box、Lasso、Line 与 Polyline 组成 Mask 工具组,并共用 `EditImageMask`。四者用 `F` 切换反选;`Refine Selection` 只用于 Box / Lasso。
Perspective 是紧随 Mask 工具组之后的独立工具,将任意顺序输入的四个角矫正为指定比例的矩形。结果直接更新当前 Image Empty,并 Pack 到 `.blend`。
操作步骤见 [Image 编辑工具](image-tools.md);处理管线与模块职责见 [Image 工具技术实现](image-tools-technical.md)。
Cutout 工具从四个方向生成 Surface、Balloon、Depth Balloon 与 Depth Surface。四种结果使用相同的单面基础薄片、局部 YZ 纸面方向和裁切中心原点,并统一写入 `o_balloon` 点属性;Preferences 可在 Poisson 与 Teddy 两种 Balloon Profile 算法之间选择。
四个 Geometry Nodes 修改器都提供默认折叠的 `Options` 面板,并把 `Cleanup` 收纳其中。Depth Balloon 额外把 `Base Plane` 放入 Options;Depth Surface 把 `Depth Continuity` 与 `Smooth` 放入 Options。Depth Balloon 使用 `Depth Scale` 控制相对基面的深度;`Double Sided` 开启后,先让 Base Plane 决定正面的朝向与基线,再把最终正面关于对象局部 YZ 平面完整镜像;Base Plane 不会改变对称中心。
## 动画
Depth Plane、Remove Background 和 Upscale 支持 Movie 与 Image Sequence。它们按 Image Empty 的帧范围处理素材,并生成与场景帧对应的颜色或深度序列。
Blender 无法整体 Pack 图片序列,因此动画结果会继续引用 Job 目录。保存或移动 `.blend` 时,应同时保留这些外部文件。详见[动画与序列帧](animation.md)。
## 运行环境
AI 任务在独立 Python Environment 中通过 ONNX Runtime 执行。Blender 负责界面、任务提交和结果导入,Job Server 负责模型与文件处理。
默认 Storage Root 为 `~/.anyimage`,其中保存 Environment、模型、日志和 Job 结果。连续使用同一模型会复用已加载的 Session;切换到另一模型族时释放旧 Session,使 Server 最多保留当前使用的一套模型。
安装、下载和推理都在后台运行,并通过状态栏提供进度与取消操作。详细设计见[Job Server](server.md)。
## 模型与调试
正式功能使用以下 ONNX 模型:
- MoGe-2 ViT-S、ViT-B、ViT-L Normal
- DA3 Small、DA3 Base
- BEN2 Base
- Real-ESRGAN General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus
Upscale 提供 Real-ESRGAN General WDN x4v3、SAFMN Real x4 和 Real-ESRGAN x4plus 三档 4× RGB 模型。默认 WDN 偏速度和保守细节,SAFMN 居中,x4plus 的细节更明显;三者也保留对应 Debug 入口用于模型直通比较。正式功能会独立放大并合并源 Alpha,默认把输出长边限制在 4096 px,可在 Preferences 提高到 8192 px,并始终受约 32 MP 硬上限约束。
首次安装会准备 MoGe-2 ViT-S Normal、BEN2 Base 与 Real-ESRGAN General WDN x4v3,其他模型可从 AnyImage 扩展偏好设置按需下载。
Debug 工作流保存模型 API 的直接返回,适合验证模型、设备与输入。三档高清模型对每张输入保存 `prediction*.png`,不加入降噪强度、Alpha 合成或 Blender 图片写回。三个模型统一由适配层以 256×256、16 像素上下文执行重叠分块,因而都接受任意图片宽高。Depth Plane 和 Surface 则在模型结果之上继续生成 Blender 可用的贴图与几何。文件字段见[输出格式](outputs.md)。

View File

@@ -25,9 +25,9 @@ AnyImage 的扩展偏好设置按功能分组。Environment 区包含:
Environment 检测后显示 Diagnostics。Depth & Normal、Remove Background、Upscale 与 Cutout 是默认展开的可折叠功能区,各自包含对应设置和模型状态。其中:
- **Default Upscale Model**:Upscale 使用的模型,默认是 General WDN x4v3。
- **Maximum Upscale Size**:Upscale 输出长边上限,默认 4096 px,最高 8192 px。
- **Maximum AI Input Size**:Upscale、Depth 与 Normal 分析输入的长边上限,默认 2048 px;只缩放分析输入,不降低颜色贴图分辨率。
Depth Model 依次列出三档 MoGe-2 与两档 DA3;选择 DA3 时会提示该模型不能生成 normal。可选模型的 **Ready** 状态或 **Download** 按钮直接位于模型选择项下方;BEN2 等固定模型则显示在所属功能区。Cutout 区只保留 Balloon Profile。
Depth Plane 的 Depth Model 依次列出三档 MoGe-2 与两档 DA3;选择 DA3 时会提示该模型不能生成 normal。Cutout 固定使用 MoGe-2:全局选择 MoGe-2 时沿用对应档位,选择 DA3 时回退默认 MoGe-2。可选模型的 **Ready** 状态或 **Download** 按钮直接位于模型选择项下方;BEN2 等固定模型则显示在所属功能区。Cutout 区只保留 Balloon Profile。
如果准备把 Storage Root 放到其他磁盘,建议在首次安装 Environment 之前修改。更换目录后,旧目录中的环境和模型不会自动移动。
@@ -54,7 +54,7 @@ Depth Model 依次列出三档 MoGe-2 与两档 DA3;选择 DA3 时会提示该
- AnyImage 扩展偏好设置中的 MoGe-2 ViT-S Normal、BEN2 Base 和 Real-ESRGAN General WDN x4v3 显示 **Ready**。
- 选中 Image Empty 后,右键菜单底部显示 **AI Environment Settings…**。
**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,并显示安装按钮。
**Convert to Depth Plane**、**Remove Background** 和 **Upscale** 在 AI 未就绪时仍可点击,点击后会根据状态转入 **Set Up AI Environment…** 或 **Download Required Models…**。普通 Plane、Image 编辑,以及未启用 Refine / Normal 的 Cutout Surface 与 Balloon 不要求安装 AI Environment。
## 下一步

View File

@@ -0,0 +1,40 @@
# 五分钟上手
这个流程会把一张静态图片转换为可继续编辑的 Depth Plane。
## 1. 导入图片
在 3D View 中选择 **Add > Image > Reference**,导入一张主体清晰、前后关系明确的图片。Blender 会创建一个 Image Empty。
## 2. 检查深度设置
打开 **Edit > Preferences > Extensions > AnyImage**。第一次使用建议保持默认:
- **Mesh Depth Model**:MoGe-2 ViT-S Normal
- **MoGe Resolution Level**:5
- **Device**:使用自动列出的首选设备
这些是扩展级默认值,转换时不会再弹出参数对话框。
## 3. 开始转换
选中 Image Empty,在 3D View 中右键选择 **AnyImage > Convert to Depth Plane**。如果环境或必需模型尚未就绪,AnyImage 会先引导完成安装或下载。
任务运行时,状态栏会显示准备输入、模型推理和结果导入进度。转换完成后,源 Image Empty 会被新的网格对象替代。
## 4. 调整结果
打开对象的 `O Image Depth Plane` Geometry Nodes 修改器:
- **Subdivide** 控制网格密度;
- **Thickness** 增加背面和侧壁,`0` 保持单面;
- **Depth Amount** 控制深度位移强度。
建议先用较低 Subdivide 调整 Depth Amount,再按成品需要增加细分。
## 接下来可以尝试
- 只需要普通图片卡片:[Plane](../guides/mesh-plane.md)
- 先获得透明前景:[去除背景](../guides/remove-background.md)
- 从局部选择生成透视表面:[Cutout](../guides/cutout.md)
- 了解深度数据如何进入节点:[Convert to Plane 实现原理](../internals/operators/convert-to-plane.md)

View File

@@ -1,57 +0,0 @@
# Depth Plane
Depth Plane 将图片转换为带位移的立体面片。它适合快速场景搭建、2.5D 合成、立体插画以及视频素材。
## 创建 Depth Plane
1. 选中 Image Empty。
2. 在 3D View 中右键,选择 **AnyImage > Convert to Depth Plane**。
3. 选择深度来源和需要的选项。
4. 确认后等待任务完成。
## 选择 Depth From
### MoGe-2
默认推荐。MoGe-2 会估算尺度一致的深度、法线和有效区域,通常适合单张图片和逐帧动画。模型越大,细节潜力和资源占用通常越高。
- **Resolution Level** 越高,处理更细致,也会更慢并占用更多内存。
- 第一次使用建议保持默认值 `5`。
### DA3
适合尝试不同的深度估算结果。DA3 使用 **Resolution Scale** 控制处理尺寸;界面会显示实际 Process Res。
- 较低比例适合预览和显存有限的设备。
- 较高比例可能保留更多细节,但不保证每张图片都更好。
### Original
直接把原图亮度作为深度来源,不运行深度估算模型。它适合已经准备好灰度高度图或手绘深度图的情况。
## 转换选项
- **Remove Background**:转换期间使用 BEN2 去除背景,让 Alpha 同时控制可见区域和背景深度。
- **Shadeless**:让贴图颜色不受场景灯光影响,适合插画、合成和需要保持原图观感的场景。
- **Keep Original**:保留源 Image Empty。默认关闭,转换成功后由新网格替代原对象。
## 调整结果
生成对象的 Geometry Nodes 修改器提供以下常用参数:
- **Displacement**:`Geometry` 直接移动网格;`Material` 保持平面并使用材质置换。
- **Subdivision**:控制网格密度。数值越高,深度细节越多,场景也越重。
- **Background Depth**:设置背景区域的位置。
- **Depth Scale**:控制深度强度;负值可以反转前后关系。
调整时建议先降低 Subdivision 提高交互速度,确定 Depth Scale 后再提高细分。
## 动画输入
Depth Plane 支持视频和序列帧。处理范围跟随 Image Empty 的 **Image Frames** 与 **Offset**,生成后的颜色和深度会随场景帧播放。
动画结果会引用任务目录中的图片序列。移动工程或清理磁盘前,请先阅读[视频与序列帧](animation.md)和[模型、设备与存储](models-and-storage.md)。
## 进一步了解
需要查看节点、数据字段、色彩空间和位移计算方式时,请阅读[Depth Plane 实现参考](depth-plane.md)。

View File

@@ -1,46 +0,0 @@
# Surface
Surface 从单张图片构建带透视关系的纹理网格。它适合建筑、室内、街景和需要匹配原始观察视角的画面。
!!! warning
Surface 当前只支持单张图片。视频和序列帧请使用 [Depth Plane](guide-depth-plane.md)。
## 创建 Surface
1. 选中只包含一帧的 Image Empty。
2. 在 3D View 中右键,选择 **AnyImage > Convert to Surface**。
3. 选择模型和转换选项。
4. 确认后等待网格、材质和 Geometry Nodes 修改器创建完成。
## 模型与分辨率
默认的 **MoGe-2 ViT-S Normal** 适合多数图片。MoGe-2 使用 **Resolution Level**,DA3 使用 **Resolution Scale**。提高分辨率会增加耗时和内存占用,建议先用默认值判断整体结构。
如果某个模型名称后显示 **(Missing)**,请在 AnyImage 扩展偏好设置中下载它。右键菜单底部的 **AI Environment Settings…** 可以直接打开对应设置。
## 转换选项
- **Create Matching Camera**:创建与图片投影相匹配的透视相机。相机会成为 Surface 的子级,移动 Surface 时仍保持对应关系。
- **Remove Background**:在生成网格前删除背景三角面。转换后关闭节点中的 Mask 也不会恢复这些面。
- **Shadeless**:让贴图颜色不受场景灯光影响。
- **Keep Original**:保留源 Image Empty。
## 调整网格
生成对象的 Geometry Nodes 修改器负责剔除深度断层、合并网格、修复拓扑、平滑和重新投影 UV。常用参数包括:
- **Iterations**:控制重拓扑迭代次数。设为 `0` 可以跳过迭代,便于观察原始结构或提高速度。
- **Resolution**:控制合并后的网格密度。值越大,结果越简化。
- **Smooth**:控制表面平滑程度。
- **Depth Continuity**:剔除深度断层处的面。提高数值会更积极地清理边缘。
- **Confidence**:DA3 Surface 可用,用于过滤低置信度区域。
先调整 Culling 让主体边缘干净,再调整 Resolution 和 Smooth,通常更容易获得稳定结果。
## 匹配相机的使用方式
开启 **Create Matching Camera** 后,切换到生成的相机视图即可重新对齐原图构图。Surface 与相机存在父子关系,建议把二者作为一个整体移动或缩放。
## 进一步了解
需要查看源网格属性、重拓扑过程、相机匹配和材质法线实现时,请阅读[Surface 实现参考](surface.md)。

33
docs/guides/animation.md Normal file
View File

@@ -0,0 +1,33 @@
# 视频与序列帧
AnyImage 对 Movie 与 Image Sequence 提供三类工作流:直接转换为普通 Plane、逐帧去除背景、逐帧高清放大。Depth Plane 与 Cutout 当前只支持静态图片。
## 支持范围
| 工作流 | 视频 / 序列帧 | 结果 |
| --- | --- | --- |
| Convert to Plane | 支持 | 保留源 Image 的播放设置 |
| Remove Background | 支持 | 透明 PNG 序列 |
| Upscale | 支持 | 4× PNG 序列 |
| Convert to Depth Plane | 不支持 | 仅静态图片 |
| Cutout / Image Tools | 不支持 | 仅静态图片 |
## 处理范围
逐帧 Job 从 Image Empty 的图片设置读取:
- **Image Frames**:处理帧数;
- **Offset**:源素材起始偏移;
- **Start**:结果在场景中的播放起点;
- **Cyclic**:是否循环播放。
显式把 Image Frames 设为 `1` 时,只处理一帧。Remove Background 与 Upscale 复用同一模型 Session 逐帧执行,内存峰值主要取决于单帧尺寸。
## 保存工程
Blender 无法把整组图片序列完整 Pack 到 `.blend`。Remove Background 与 Upscale 的动画结果会继续引用 Storage Root 中的 Job 目录。
!!! danger
删除或移动仍被工程引用的 Job 目录,会导致动画序列丢失。
长期保存时,可以连同对应 Job 目录一起复制工程,或先把序列移动到项目素材目录,再用 Blender 的外部数据工具重新定位路径。

59
docs/guides/cutout.md Normal file
View File

@@ -0,0 +1,59 @@
# Cutout
Cutout 用套索从一张 Image Empty 中选择区域,再生成 Surface、Balloon、Depth Balloon 或 Depth Surface。它适合从图片局部制作薄片、鼓包或带透视深度的网格。
## 创建
1. 在 Object Mode 的 3D Viewport 左侧工具栏选择 **Cutout**。
2. 沿 Image Empty 按住左键绘制;双击图片可选择完整画面。
3. 松开左键后,从饼菜单选择 Shape。
绘制时按 `F` 切换反选,右键或 `Esc` 取消。Cutout 使用非零环绕填充:反向绘制的内环可以形成孔洞。
## 选择 Shape
| Shape | 结果特点 | 是否需要深度模型 |
| --- | --- | --- |
| Surface | 保持平面轮廓,可增加真实厚度 | 否 |
| Balloon | 按轮廓平滑鼓起 | 否 |
| Depth Balloon | 在 Balloon 上加入图片深度高度 | 是,固定使用 MoGe-2 |
| Depth Surface | 使用相机点形成透视表面 | 是,使用 Preferences 中的 Mesh Depth Model |
四种结果都保留源 Image Empty,并在同一位置创建新网格。它们共享选区轮廓、UV、材质和基础三角网格,因此可以从同一张图多次生成不同 Shape 进行比较。
无论是否启用 Refine Selection,套索超出图片的部分都不会生成网格,源图中 `Alpha <= 0.1` 的像素也始终从几何中排除。双击选择完整画面时同样只为 `Alpha > 0.1` 的区域生成网格。
## 工具设置
- **Point Spacing**:相邻网格点大约对应多少源图像素。数值越小,网格越密,轮廓和变形细节越多。
- **Normal**:使用 MoGe-2 生成法线贴图。默认关闭。
- **Refine Selection**:用 BEN2 把几何套索收紧到其中的前景内容。默认关闭。
Surface 与 Balloon 在 Normal、Refine 都关闭时完全在 Blender 中生成,不需要 AI Environment。开启任一 AI 选项,或选择 Depth Shape 时,会提交后台任务。
Point Spacing 先按完整源图换算基础密度;选区裁切后的长边若超过全局 **Maximum AI Input Size**,网格密度会按分析图的等比缩放比例降低。颜色贴图始终保留原裁切分辨率,不会使用限制后的临时分析图。建议先用默认 `32` 观察形状;轮廓需要更细时再逐步减小。
## 调整修改器
四个 Shape 都使用同一个 `O Image Cutout` Geometry Nodes 修改器。创建时 Shape 菜单已经设为饼菜单所选类型;切换 Shape 后,Blender 只显示该类型实际使用的输入。常用参数包括:
- **Thickness**:Surface / Depth Surface 使用场景距离;Balloon / Depth Balloon 使用鼓起高度倍率。
- **Cleanup**:绝对面积阈值;创建时根据 Point Spacing 和图片平面尺寸自动写入,面积小于该值的 Mesh Island 会在生成任意 Shape 前移除。
- **Smooth Iterations**:位于外层,控制平滑次数,默认 `0`;**Smooth** 位于 Options 中,控制每次平滑的混合强度。四种 Shape 都可使用。
- **Depth Scale**:Depth Shape 的深度强度;`0` 回到基础平面或 Balloon。
两个 Depth Shape 共用 **Reference Depth**;Depth Balloon 还提供 **Depth Direction**。插件自动写入的 Depth Image 与 Uniform Scale 位于折叠的 Data 面板,一般无需手动修改。
Balloon 的轮廓高度算法由扩展 Preferences 中的 **Balloon Profile** 控制:Poisson 更平滑,Teddy 更强调轮廓骨架。修改默认值只影响之后创建的 Shape。
## Refine 与 Normal
Refine Selection 只在“套索与源图可见 Alpha 的交集”内收紧用于建网格的最终选择,不把 AI 生成的颜色写回原图。材质继续使用当前 Image Empty 的源颜色和原 Alpha。
Normal 关闭时使用几何法线;开启后,Surface、Balloon 与 Depth Balloon 使用适配成品网格的 Tangent Space 法线,Depth Surface 使用 Object Space 法线。Cutout 始终使用 MoGe-2;Depth 与 Normal 同时需要时共享同一次 Prediction,不会重复运行模型。
## 当前范围
Cutout 只支持静态图片。图片工具创建的透明结果和 Upscale 结果都可以继续作为输入;Cutout 始终以当前 Image Empty 的实际图片为颜色来源。
轮廓、深度场、节点资产与法线空间的实现见 [Cutout Tool 实现原理](../internals/operators/cutout-tool.md)。

View File

@@ -0,0 +1,42 @@
# Depth Plane
Depth Plane 将一张静态图片转换为带深度位移的网格,适合快速场景搭建、2.5D 合成和立体插画。
## 创建
1. 在 3D View 中选中 Image Empty。
2. 右键选择 **AnyImage > Convert to Depth Plane**。
3. 等待状态栏提示任务完成。
转换不会弹出设置对话框。深度模型和分辨率使用 **Edit > Preferences > Extensions > AnyImage > Depth & Normal** 中的默认值。
## 选择模型
### MoGe-2
默认推荐。MoGe-2 估算尺度一致的深度与相机信息,使用 **MoGe Resolution Level** 控制处理精度。第一次使用建议保持 `5`。
### DA3
DA3 提供另一种深度结果,使用 **DA3 Resolution Scale** 按原图长边计算处理尺寸。比例越高,通常会保留更多细节,也会增加耗时和内存占用。
切换模型后,如果对应文件尚未下载,先在同一设置区点击 **Download**。
## 调整结果
生成对象使用运行时创建的 `O Image Depth Plane` Geometry Nodes 修改器:
- **Subdivide**:控制基础平面细分级别;
- **Thickness**:增加背面和侧壁,`0` 输出单面;
- **Depth Amount**:控制图片深度位移强度。
结果颜色来自当前 Image Empty,深度由所选模型分析。转换会以一个 Undo 步骤替换源 Empty;如果需要保留原对象,请在转换前复制一份。
## 当前输入范围
Depth Plane 当前只支持静态图片。视频与序列帧可以转换为普通 [Plane](mesh-plane.md),或使用 [Remove Background / Upscale](animation.md) 逐帧处理。
实现中的深度场、坐标锚定和节点流程见 [Convert to Plane 实现原理](../internals/operators/convert-to-plane.md)。
## 分析分辨率与颜色
Depth 输入还受 Environment 区的 **Maximum AI Input Size** 约束,默认最大长边为 `2048 px`。超过时只会等比缩放模型分析输入;结果材质仍使用原始颜色图片,不会改用限制后的临时图。

View File

@@ -9,7 +9,6 @@ AnyImage 菜单只会在选中 Image Empty 时出现。确认对象类型是 Emp
通常有以下原因:
- 另一个 AnyImage 任务正在运行。
- Surface 的输入不是单张图片。
- 当前对象不是 Image Empty。
Environment 或默认模型缺失不会让四个 AI 功能变灰;点击功能会根据状态打开 **Set Up AI Environment…** 或 **Download Required Models…**。Balloon 的 **Contour**、**Depth** 与 Depth Model 会在 AI 未就绪时单独禁用,同一位置提供安装按钮。
@@ -33,17 +32,14 @@ Environment 或默认模型缺失不会让四个 AI 功能变灰;点击功能
## Upscale 很卡或提示内存不足
三个高清模型都使用分块推理,单次 ONNX 调用不会把完整图片送进 provider;但完整输出数组、PNG 编码和 Blender 图片载入仍取决于最终分辨率。正式 Upscale 默认将输出长边限制为 4096 px,可在 Preferences 中调整 **Maximum Upscale Size**,最高 8192 px,并始终限制在约 32 MP。DirectML 或其他 provider 无法分配资源时,任务会提示降低该值或改用 CPU,而不会显示底层的 UTF-8 解码异常。
对已经高清的结果再次执行不会继续放大该结果。AnyImage 只保留原始 Image 引用,并从原图重新运行所选模型;开始和完成时会在状态栏说明。
三个高清模型都使用分块推理,但完整输出数组、PNG 编码和 Blender 图片载入仍取决于输入与 4× 结果尺寸。Environment 区的 **Maximum AI Input Size** 默认限制输入长边为 `2048 px`;可以降低该值,或改用 CPU。DirectML 或其他 provider 无法分配资源时,任务也会提示降低这个全局值。
## Depth Plane 太平或深度方向反了
在 Geometry Nodes 修改器中调整 **Depth Scale**。增大绝对值会加强深度,使用负值可以反转前后关系。
## Surface 边缘破碎或细节太多
## Cutout Depth Surface 边缘破碎或细节太多
先调整 **Depth Continuity** 清理断层,再提高 **Resolution** 简化网格,并用 **Smooth** 稳定表面。必要时尝试另一个模型或降低输入分辨率。
先增大 **Point Spacing** 降低基础网格密度,再调整 **Cleanup** 清理小块,并用 **Smooth** 稳定表面。必要时降低模型分辨率。
## 动画面片不播放

View File

@@ -79,4 +79,4 @@ Perspective 只执行图片 Homography 与紧裁切,不做 Refine,也不尝
目标文件:`images/image-tools/perspective-aspect.png`
</div>
实现结构与像素规则见 [Image 工具技术实现](image-tools-technical.md)。
实现结构与像素规则见 [Image Tool 实现原理](../internals/operators/image-tool.md)。

27
docs/guides/mesh-plane.md Normal file
View File

@@ -0,0 +1,27 @@
# Plane
Plane 把 Image Empty 转换为带图片材质的普通网格,不运行 AI,也不生成深度估算。它适合广告牌、图片卡片、背景板,以及视频或序列贴图。
## 创建
1. 在 3D View 中选中 Image Empty。
2. 右键选择 **AnyImage > Convert to Plane**。
3. 在 Last Operation 中按需调整 **Subdivide**。
转换会立即创建结果并替换源 Empty。新对象继承原对象的 Transform、图片显示比例和 Offset;需要保留源 Empty 时,请先复制对象。
## Geometry Nodes
结果使用 `O Mesh Plane` 修改器:
- **Subdivide** 使用二分级别控制网格密度,`3` 表示短边约 `8` 段;
- **Thickness** 沿平面法线增加厚度,`0` 输出真正的单面;
- **Material** 由 AnyImage 自动连接。
原始 Mesh 数据只有一个四边形,细分和厚度都由修改器实时生成,可以在转换后继续调整。
## 剪贴板 Plane
在 3D View 中粘贴剪贴板图片,并在 Last Operation 把 **Import As** 改为 **Plane**,也会创建同样的基础网格和 `O Mesh Plane` 修改器。该入口还可选择 **Shadeless**;Image Empty 的 Convert to Plane 入口不提供 Shadeless 选项。
模块与节点分工见 [Convert to Plane 实现原理](../internals/operators/convert-to-plane.md)。

View File

@@ -18,9 +18,12 @@ AnyImage 把运行环境、模型和任务结果放在 Blender 之外,多个 B
较大的模型不一定适合所有图片。建议先用默认模型完成构图,再针对细节与性能需求比较其他模型。
## 全局 AI 输入尺寸
Environment 区的 **Maximum AI Input Size** 控制 Upscale、Depth 与 Normal 分析输入的最大长边,默认 `2048 px`,可在 `256–8192 px` 之间调整。超过上限的输入会保持宽高比缩小;颜色贴图仍使用原始分辨率。Cutout 在完成套索裁切后再应用上限,因此整张原图很大但选区较小时不会被无谓缩小。
## Device
扩展偏好设置中的 **Device** 控制 Depth Plane、Surface、Remove Background 和 Upscale 等正式任务。
扩展偏好设置中的 **Device** 控制 Depth Plane、Cutout 的 AI 增强、Remove Background 和 Upscale 等正式任务。
- Windows 通常优先使用 DirectML。
- Apple Silicon macOS 通常优先使用 CoreML。
@@ -30,7 +33,7 @@ Debug 面板中的 **Debug Device** 只影响 Debug 任务,不会覆盖正式
## Session 缓存
连续使用同一模型和 Device 时,Server 会复用已经加载的 ONNX Session。切换到 BEN2、深度或高清等另一模型族时,旧 Session 会被释放,因此 Server 最多保留当前使用的一套模型;如果高清推理遇到 provider 资源错误,也会立即卸载该 Session。这样保留连续操作的速度,同时避免多个模型长期占用显存或系统内存。
连续使用同一模型和 Device 时,Server 会复用已经加载的 ONNX Session。缓存分为 BEN2、几何模型和 Upscale 三个族,每个族只保留最近使用的一套;同族切换模型或 Device 时释放旧 Session。如果高清推理遇到 provider 资源错误,也会立即卸载该 Session。
## Storage Root

View File

@@ -0,0 +1,23 @@
# 去除背景
Remove Background 使用 BEN2 为 Image Empty 生成透明前景,不创建网格。
## 使用
1. 选中 Image Empty。
2. 在 3D View 中右键选择 **AnyImage > Remove Background**。
3. 等待状态栏提示任务完成。
结果替换当前 Empty 的图片,对象 Transform、显示尺寸、Offset 和播放设置保持不变。单图结果会 Pack 到 `.blend`;视频或序列帧结果继续引用 Job 目录。
## Alpha 处理
推理前,AnyImage 会用输入 Alpha 遮住透明区域中的 RGB。结果颜色仍来自源图,只采用 BEN2 生成的前景 Alpha;源图已有透明度时,两者会相乘。因此 Image Mask 或外部软件已经清理的范围不会被重新扩张。
当前入口不提供 BEN2 的额外细化参数。需要手动控制范围时,可以先使用 [Image Box / Lasso](image-tools.md),也可以在结果材质中继续调整 Alpha。
## 图片序列
Remove Background 支持单图、Movie 和 Image Sequence。处理帧数、偏移和播放起点见[视频与序列帧](animation.md)。
任务与结果替换流程见 [Remove Background 实现原理](../internals/operators/remove-background.md)。

27
docs/guides/upscale.md Normal file
View File

@@ -0,0 +1,27 @@
# 高清放大
Upscale 用 ONNX 模型把 Image Empty 的图片放大 4 倍,不创建网格。它支持单图、Movie 和 Image Sequence,并保留源 Alpha。
## 使用
1. 在 AnyImage 扩展偏好设置中选择 **Default Upscale Model**。
2. 按需要设置 Environment 区的 **Maximum AI Input Size**;默认 `2048 px`,最高 `8192 px`。
3. 选中 Image Empty,在右键 **AnyImage** 菜单中点击 **Upscale**。
输入长边超过全局上限时,会先保持宽高比缩小,再执行 4× 放大。因此默认设置下结果长边最多为 `8192 px`。完成后,当前 Empty 会改用名称以 `_upscale.png` 结尾的结果图片。
## 模型选择
- **Real-ESRGAN General WDN x4v3**:默认模型,速度快,细节处理较保守。
- **SAFMN Real x4**:速度与细节较均衡。
- **Real-ESRGAN x4plus**:生成的细节更明显。
后两项可在扩展偏好设置中按需下载。
对高清结果再次执行 Upscale 时,AnyImage 会从最初的原图重新生成并替换当前结果,不会连续放大已有高清图。
把高清结果继续转换为 Depth Plane 时,颜色材质可以继续使用当前高清图,分析输入则遵守全局上限;Cutout 同样只缩放裁切后的分析输入,不会把限制后的临时图用作 Color。
如果显存或内存不足,请降低 **Maximum AI Input Size**,或改用 CPU。
分块推理、Alpha 合并与原图引用见 [Upscale 实现原理](../internals/operators/upscale.md)。

41
docs/guides/workflows.md Normal file
View File

@@ -0,0 +1,41 @@
# 选择工作流
先确定需要的是图片数据、普通平面、整图深度,还是局部网格。
| 工作流 | 输入 | 结果 | AI |
| --- | --- | --- | --- |
| Clipboard Image | 剪贴板静态图 | Reference、Plane、节点或笔刷纹理 | 不需要 |
| Convert to Plane | 单图、Movie、Sequence | 带材质的普通网格 | 不需要 |
| Convert to Depth Plane | 静态图 | 带深度位移的整图面片 | DA3 或 MoGe-2 |
| Cutout | 静态图局部选区 | 四种局部 Shape | 按 Shape 与选项决定 |
| Remove Background | 单图、Movie、Sequence | 透明图片或序列 | BEN2 |
| Upscale | 单图、Movie、Sequence | 4× 图片或序列 | Upscale 模型 |
| Image Mask / Perspective | 静态图 | 原位更新的裁切或矫正图 | Refine 时使用 BEN2 |
## 普通 Plane
只需要图片卡片、背景板或视频贴图时,选择 **Convert to Plane**。它不分析深度,Thickness 与 Subdivide 可以在 Geometry Nodes 中随时调整。
[阅读 Plane 指南](mesh-plane.md)
## Depth Plane
需要把整张静态图快速变成可调浮雕时,选择 **Convert to Depth Plane**。深度模型与分辨率在扩展 Preferences 中设置。
[阅读 Depth Plane 指南](depth-plane.md)
## Cutout
只需要图片局部,或希望按轮廓生成 Balloon / Depth Surface 时,使用工具栏中的 **Cutout**。普通 Surface 与 Balloon 可以完全在 Blender 内完成,Refine、Normal 与 Depth Shape 按需调用 AI。
[阅读 Cutout 指南](cutout.md)
## 图片处理
只想得到透明前景,不需要网格时使用 [Remove Background](remove-background.md)。需要增加像素尺寸时使用 [Upscale](upscale.md)。两者都支持 Movie 和 Image Sequence。
## Image 编辑
Box、Lasso、Line 和 Polyline 适合直接清理 Alpha;Perspective 适合把图片中的四边形内容矫正为矩形。结果都会写回当前 Image Empty,并保持对象 Transform。
[阅读 Image 编辑工具](image-tools.md)

View File

@@ -1,90 +0,0 @@
# Image 工具技术实现
Image 工具位于 `operators/image_edit/`。Box、Lasso、Line 与 Polyline 共享直接编辑管线;Perspective 在工具栏中独立显示并处理 Homography,但复用源图选择、绘制和 Empty 写回。
## 模块职责
```mermaid
flowchart LR
Tools["box / lasso / line / polyline\nWorkspaceTool"] --> Mask["mask.py\n手势、Refine、Invert"]
Perspective["perspective.py\n四点、比例、Homography"]
Mask --> Common["common.py\n选择、绘制、裁切、Empty 写回"]
Perspective --> Common
Mask --> Job["refine-image-mask\nBEN2 内容 mask"]
```
- `box.py`、`lasso.py`、`line.py`、`polyline.py` 只声明 WorkspaceTool。
- `mask.py` 用 `EditImageMask` 统一四种手势;`RefineImageMask` 负责异步 BEN2 响应。
- `perspective.py` 维护四点输入、比例预览和全分辨率重采样。
- `common.py` 只放跨两条管线实际共用的逻辑。
## 首次点击
`resolve_image_edit_click()` 临时调用 Viewport Select 判断点击目标:
| 点击目标 | 行为 |
| --- | --- |
| 另一张 Image Empty | 选择并设为 Active,本次不编辑 |
| 当前 Active 图片、空白或非图片对象 | 恢复原选择并开始编辑当前图片 |
这避免工具在无有效源图时退化为对象移动,也让用户无需切回 Select Tool。
## Mask 管线
```mermaid
flowchart TD
Gesture["屏幕手势"] --> Polygon["源图片像素 polygon / mask"]
Polygon --> Refine{"Refine Selection?"}
Refine -- 否 --> Content["几何 mask"]
Refine -- 是 --> Server["选区外 RGBA 清零 → BEN2"]
Server --> Content2["BEN2 Alpha × 几何 mask"]
Content --> Invert{"Invert?"}
Content2 --> Invert
Invert -- 否 --> Final["final mask"]
Invert -- 是 --> Reverse["1 - final mask"]
Final --> Apply["源 Alpha × mask"]
Reverse --> Apply
Apply --> Crop["按非零 Alpha 紧裁切\n保留源 RGB"]
Crop --> Write["替换 Image 并更新 Empty 显示"]
```
Cutout、Box 与 Lasso 分别使用 `Scene.anyimage_settings` 上的独立 Refine 属性,工具之间不传递状态。Box / Lasso 的顺序固定为 **Refine → Invert**。BEN2 只提供内容 Alpha;最终 RGB 始终来自源图。Blender 侧只修改 Alpha,不清理透明区 RGB,以避免 Viewport 纹理过滤产生黑边。Line / Polyline 调用公共设置入口时强制关闭 Refine,只执行几何 mask 与 Invert。
未形成有效区域的单击直接返回 `CANCELLED`,不报告消息;最终 Alpha 全空时,Operator 报告 `WARNING` 并保持源图不变。其他数据、状态或运行时异常仍报告 `ERROR`。
Box、Lasso、Line 与 Polyline 的屏幕预览统一绘制白色虚线轮廓和淡灰半透明填充。简单选区使用 Blender 的凹多边形三角剖分,不使用从首点展开的 Triangle Fan;通过屏幕网格快速检测到自交后改用 2 px 扫描填充,并读取 Operator 的最终填充规则。Image Mask 使用偶奇规则,Cutout 使用与最终几何一致的非零环绕规则。Lasso 只在游标相对末点移动至少 2 px 时采点,停留产生的重复 `MOUSEMOVE` 不增加轨迹;新点与前一线段的偏差不超过 0.75 px 时直接替换末点,在线消除近似共线点。填充三角形和闭合虚线段都按当前屏幕 polygon 缓存,额外重绘不重复计算。松开鼠标后使用这条已简化的闭合 polygon 生成几何 mask。
Line 的屏幕坐标 Y 轴与图片坐标 Y 轴方向相反,因此像素阶段会反转线段端点,使半透明预览侧与实际保留侧一致;`Ctrl` 只影响屏幕端点的 5° 吸附。零长度点击在投影前直接取消,投影结果不足两个点时也会安全终止。Polyline 接近起点时绘制闭合圆环,并支持单击起点或 `Enter` 完成;完成时显式闭合,不把当前游标位置作为额外顶点。
## Perspective 管线
四点先按源图片 X/Y 方向规范为凸四边形,输入顺序不参与输出方向。第四点后只生成一次 512 × 512 临时纹理;移动鼠标改变 UI 比例,确认时才进行全分辨率 Homography。
预览引用在删除前先从 Operator 清空;若 Undo 或 Blender 已移除临时 Image,清理会忽略失效的 RNA 引用,因此切换工具和重复结束都安全。
透视重采样使用预乘 Alpha 插值,随后恢复 RGB、清理透明像素并紧裁切。输出最长边限制为 8192 px,总像素限制约 16 MP。
Perspective 不调用 BEN2,也不支持 Invert。它是 deform 工具,不属于 mask 清理语义。
## Empty 写回
`replace_empty_image_pixels()` 先创建并验证完整结果,再根据引用源 Image 的 Image Empty 数量选择提交方式:只有一个引用时,把原 Image 的用户重映射到结果、删除旧 datablock 并恢复原名,最终不留下额外图片;两个或更多引用时,只替换当前 Empty 的 `data`,其他 Empty 继续使用原图。共享图片的新结果沿用源 Image 基础名,不附加 `.mask`、`.perspective` 或其他工具后缀;同名冲突交给 Blender 自动添加 `.001`。两种方式都不复制对象,也不修改 `matrix_world`。
`empty_display_alignment()` 在写入像素前,根据源画布、旧 `plane_bounds`、结果尺寸和放置 bounds 计算显示参数;写回函数随后更新:
- `empty_display_size`;
- `empty_image_offset`。
Box、Lasso、Line 与 Polyline 的放置 bounds 等于最终裁切范围,因此像素与原图片平面严格对齐。Perspective 的结果比例可能不同于四边形轴对齐范围,因此只保持中心和覆盖尺度近似一致。
## Server 边界
普通 Mask 和 Perspective 完全在 Blender 侧执行。只有 Box / Lasso 的 **Refine Selection** 提交 `refine-image-mask`:
1. 根据 polygon 创建局部透明画布;
2. 清空选区外 RGBA;BEN2 公共入口再以输入 Alpha 遮住透明 RGB;
3. 使用共享的 `remove_image_background()` 运行 BEN2;
4. 再乘几何 selection mask;
5. 返回紧裁切的 `image.png` 与源图坐标 `bounds.json`。
Blender 只读取返回图片的 Alpha,并把它应用到原图 RGB。相同的 BEN2 输入清理规则也用于 Remove Background、Depth Plane、Surface 和 Balloon;正式输出使用源 RGB 与 BEN2 Alpha。Blender 数据只在主线程 Operator 中读写,Job 参数保持为可序列化字典。

View File

@@ -2,20 +2,17 @@
把图片里的空间感带进 Blender。
AnyImage 是一款 Blender 扩展,可以把照片、视频或序列帧转换为带真实深度变化的立体面片。你只需导入图片、选中 Image Empty,然后从右键菜单选择需要的结果。
AnyImage 是一款 Blender 扩展,可以粘贴和编辑图片、创建普通 Plane 或 Depth Plane、从局部选区生成 Cutout Shape,并在独立 AI Environment 中完成去背景与高清放大。
- 按 **Ctrl+V**(macOS 为 **Cmd+V**)把剪贴板图片直接粘贴到当前编辑器
- 用 **Mesh Plane** 无需 AI 地创建可调厚度和细分的图片网格
- 用 **Depth Plane** 快速得到可调节的浮雕式面片
- 用 **Surface** 从单张图片生成透视纹理网格
- 用 **Remove Background** 一键获得透明前景
- 用 **Upscale** 选择三档 ONNX 模型进行 4× 高清放大
- 用 **Image Box / Lasso / Line / Polyline** 直接清理图片,或用 **Image Perspective** 做四点透视矫正
- 处理视频和序列帧,生成可随时间播放的 Depth Plane
- 使用 Geometry Nodes 继续调整深度、细分、重拓扑与平滑效果
- 按 **Ctrl+V**(macOS 为 **Cmd+V**)把剪贴板图片粘贴到当前编辑器
- 用 **Plane** 创建可调厚度和细分的图片网格
- 用 **Depth Plane** 把静态图片转换为可调节的立体面片
- 用 **Cutout** 从套索选区生成 Surface、Balloon 与两种 Depth Shape
- 用 **Image Tools / Perspective** 直接清理或矫正 Image Empty
- 用 **Remove Background / Upscale** 处理单图、视频和序列帧
!!! info "运行环境与 Blender 分离"
AnyImage 的 AI 计算运行在独立环境中,不会把 ONNX Runtime 等计算依赖装进 Blender Python。首次配置完成后,日常操作都可以从 Image Empty 的右键菜单完成。
AI 计算运行在独立 Environment 中,不会把 ONNX Runtime 等依赖安装进 Blender Python。Blender 只负责界面、任务提交和结果导入。
## 从这里开始
@@ -23,27 +20,27 @@ AnyImage 是一款 Blender 扩展,可以把照片、视频或序列帧转换
- **安装 AnyImage**
下载与你的系统匹配的扩展包,完成环境和默认模型配置。
下载与你的系统匹配的扩展包,完成 Environment 与默认模型配置。
[阅读安装指南](installation.md)
[阅读安装指南](getting-started/installation.md)
- **五分钟上手**
从导入一张图片开始,完成第一次 Depth Plane 转换。
从导入一张静态图片开始,创建第一个 Depth Plane。
[开始第一个作品](quick-start.md)
[开始第一个作品](getting-started/quick-start.md)
- **选择合适的结果**
- **选择工作流**
了解 Depth Plane、Surface、去背景和高清放大之间的区别。
对比 Plane、Depth Plane、Cutout、去背景、高清放大和图片编辑。
[查看工作流对比](workflows.md)
[查看工作流](guides/workflows.md)
- **遇到问题?**
- **了解实现**
检查模型下载、菜单状态、动画文件和运行日志。
从项目主干、进程边界和功能模块了解内部实现。
[查看常见问题](faq.md)
[阅读内部实现总览](internals/index.md)
</div>
@@ -51,8 +48,7 @@ AnyImage 是一款 Blender 扩展,可以把照片、视频或序列帧转换
- Blender 4.5 或更高版本
- Windows x64、macOS Apple Silicon 或 Linux x64
- 首次安装环境与模型时需要网络连接
- Depth Plane、Surface、Remove Background、Upscale 与 Balloon 的 AI 增强需要独立 Environment,以及默认的 MoGe-2、BEN2 和 Real-ESRGAN General WDN x4v3 模型;Balloon 基础功能无需安装
- 安装 Environment 与模型时需要网络连接
- AI 功能需要对应模型;普通 Plane、基础 Image 编辑与不带 AI 选项的 Cutout Surface / Balloon 可离线使用
!!! tip
不确定从哪里开始时,先使用默认设置将一张人物或物体照片转换为 Depth Plane。它速度快、参数直观,也最适合熟悉 AnyImage 的基本工作方式。
遇到安装、模型或任务问题时,查看[常见问题](guides/faq.md)。

View File

@@ -0,0 +1,65 @@
# 程序架构
AnyImage 由 Blender 交互层、BlendJob Runtime 和独立 Job Server 三部分组成。这个分层让 Blender Python 不需要加载 ONNX Runtime、Pillow 或模型权重,同时保证所有 Blender 数据仍在主线程创建和修改。
## 对应文件
```text
src/anyimage/
├── __init__.py # 类型、工具、菜单与 Runtime 注册
├── menu.py # Image Empty 上下文入口
├── panel.py # Server 与 Debug 面板
├── preferences.py # Storage Root、Device 与功能默认值
├── properties.py # Scene 状态、模型状态与动态枚举
├── runtime.py # Environment 和 JobRuntime
├── operators/ # Blender 侧业务入口
└── server/ # 独立计算端
```
## 职责边界
| 层次 | 负责 | 不负责 |
| --- | --- | --- |
| Blender 交互层 | UI、手势、RNA 状态、输入准备、结果导入、Undo | 在 Blender 进程加载 AI 依赖 |
| BlendJob Runtime | Environment、Server 生命周期、队列、进度、取消、Job 目录 | AnyImage 的图像与几何业务 |
| Job Server | 媒体解码、ONNX 推理、数组派生、文件输出 | 创建 Blender 对象或访问 `bpy` |
Operator 向 Server 发送普通字典。Server 返回文件名和 Job 目录;Operator 的 `response()` 再读取这些文件,并在 Blender 主线程中创建 Image、Mesh、Material 和 Geometry Nodes Modifier。
## 注册与状态
```mermaid
flowchart TD
Register["anyimage.register()"] --> Types["注册 Preferences / PropertyGroup / Operator / Panel"]
Types --> Tools["注册 Image Tools 与 Cutout WorkspaceTool"]
Tools --> RNA["挂载 Scene 与 Image Property"]
RNA --> Menu["挂载上下文菜单和剪贴板快捷键"]
Menu --> Runtime["注册 JobRuntime"]
```
`__init__.py` 按依赖顺序注册,并在注销时逆序清理。扩展级设置统一由 `preferences.addon_preferences()` 读取;`runtime.py` 只消费 `configured_storage_root` 回调,不持有 Blender 偏好设置的定位逻辑。交互中的临时状态和 Debug 参数保存在 `Scene.anyimage_settings`,Upscale 的原图引用保存在 `Image.anyimage_upscale_source`。 全局 `max_ai_input_size` 也在 `preferences.py` 定义与读取,由 Operator 冻结到每次 Job 请求。
## 同步与异步路径
无需 AI 或媒体处理的功能直接在 Blender 中完成,例如 Plane、普通 Image Mask、Perspective,以及未启用 Refine/Normal 的 Surface 和 Balloon。需要模型或独立文件处理时,功能 Operator 派生自 `JobOperatorBase`:
```mermaid
sequenceDiagram
participant User as 用户
participant Op as Blender Operator
participant RT as JobRuntime
participant Job as Server Job
User->>Op: 执行功能
Op->>Op: 准备文件与普通字典
Op->>RT: 提交 Job
RT->>Job: 排队执行
Job-->>RT: 写入产物并返回索引
RT-->>Op: 主线程 response
Op->>Op: 导入数据并提交 Undo
```
这条边界也决定了模块依赖方向:`server/` 不导入 `bpy` 或 `operators/`;Blender 侧可以读取 Server 产物,但不会复用 Server 中的 Blender 无关算法来创建 RNA 数据。
## 共享能力
`operators/common/` 保存多个功能真实复用的能力:图片输入与命名、像素写回、手势状态、Mask 运算、Image Empty 坐标、材质、节点资产、法线烘焙和 Job 参数辅助。共享模块只承载跨功能概念;完整工作流仍留在各自功能包中,以便从用户入口追踪到实现。

View File

@@ -0,0 +1,81 @@
# 开发与发布
AnyImage 的仓库环境用于测试 Blender 侧代码、Server 纯 Python 模块、Manifest 和打包逻辑。AI Runtime 使用另一套 Python 3.12 Environment,不应与仓库开发环境混为一谈。
## 对应文件
```text
.
├── pyproject.toml
├── uv.lock
├── .python-version
├── pack.py
├── sync_models.py
├── src/anyimage/blender_manifest.toml
├── tests/
├── tools/node_assets/
└── .github/workflows/
├── test.yml
├── build.yml
└── docs.yml
```
## 开发环境
仓库固定使用 CPython 3.11,因为开发依赖 `bpy==4.5.3` 对应这一解释器版本。项目根目录执行:
```bash
uv sync
uv run pytest
```
快速检查注册、Manifest 和打包时使用:
```bash
uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v
```
测试按边界组织:`test_addon.py` 以 Blender Stub 验证注册和 Operator 流程;`test_mesh.py`、`test_image.py` 等验证可脱离 Blender 的算法;`test_server.py`、`test_models.py` 和各 ONNX 测试覆盖计算端;`test_pack.py` 与 `test_manifest.py` 固定发布包结构。
## 扩展打包
```bash
uv run python pack.py
```
`pack.py` 为 `windows-x64`、`macos-arm64` 和 `linux-x64` 分别解析依赖 wheel,生成平台专属 Manifest,再把 `src/anyimage/` 与 wheel 写入 `dist/`:
```text
dist/
├── AnyImage.v<version>.windows-x64.zip
├── AnyImage.v<version>.macos-arm64.zip
└── AnyImage.v<version>.linux-x64.zip
```
本地 `src/anyimage/wheels/` 是 wheel 解析的优先来源,用于携带尚未发布的 BlendJob 构建。依赖、wheel 或 Manifest 变化后,需要同时检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 和打包测试;本地 wheel 内容变化后运行 `uv lock --refresh`。
## 文档
```bash
uv run --only-group docs mkdocs serve
uv run --only-group docs mkdocs build --strict
```
站点导航由 `mkdocs.yml` 显式维护,因此移动页面时必须同时更新导航和相对链接。Mike 仍可用于本地版本化发布;CI 则直接构建严格模式站点并部署到 Cloudflare Worker。
## CI
```mermaid
flowchart TD
Main["push main / Pull Request"] --> Test["uv run --frozen pytest"]
Tag["push v* tag"] --> Release["测试 → pack.py → Release ZIP"]
Docs["main 上文档变更"] --> Site["mkdocs --strict → Cloudflare"]
```
`test.yml` 在 main push、Pull Request 与手动触发时运行完整测试。`build.yml` 只响应 `v*` tag,测试通过后构建并上传三个平台包。`docs.yml` 只在文档相关路径变化或手动触发时部署站点。
## 许可证
AnyImage Extension 代码采用 GPL-3.0-or-later,完整文本见仓库 `LICENSE`。模型权重保持各自上游许可证;模型目录声明下载来源、文件校验和许可信息,技术文档不复制可能变化的制品清单。
Geometry Nodes 资产必须由 Blender 构建和验证,见[节点资产](node-assets.md)。

48
docs/internals/index.md Normal file
View File

@@ -0,0 +1,48 @@
# 内部实现总览
AnyImage 的实现围绕一条清晰边界展开:Blender 进程负责交互与数据创建,独立 Job Server 负责模型推理和文件处理。两侧通过 BlendJob 传递可序列化参数与 Job 目录中的产物,不跨进程传递 Blender RNA 数据。
```mermaid
flowchart TD
Input["Image Empty / 手势"] --> Operator["Blender Operator"]
Operator --> Choice{"需要独立计算?"}
Choice -- "否" --> Build["Blender 主线程构建"]
Choice -- "是" --> Runtime["BlendJob Runtime"]
Runtime --> Server["Job Server"]
Server --> Files["Job 产物"]
Files --> Build
Build --> Result["Image / Mesh / Material / Modifier"]
```
## 项目主干
总览只展示稳定边界;各功能包的具体文件在对应实现页中展开。
```text
.
├── src/anyimage/
│ ├── __init__.py # Blender 注册入口
│ ├── menu.py # Image Empty 右键菜单
│ ├── preferences.py # 扩展级配置
│ ├── properties.py # Scene 状态与模型枚举
│ ├── runtime.py # BlendJob Runtime 配置
│ ├── operators/ # 交互、任务提交与结果导入
│ ├── server/ # 无 Blender 依赖的计算端
│ ├── assets/ # 随扩展分发的节点资产
│ └── wheels/ # 随扩展分发的 BlendJob wheel
├── tools/ # Blender 资产构建与验证
├── tests/ # Python 自动化测试
├── pack.py # 多平台扩展打包
└── sync_models.py # ONNX 模型同步
```
## 文档分层
- [程序架构](architecture.md):注册、配置、线程和模块边界。
- [Job Runtime 与 Server](runtime.md):任务生命周期、媒体输入和模型 Session。
- [Operator 实现索引](operators/index.md):按 `src/anyimage/operators/` 中的完整功能拆分实现页。
- [Job 产物](outputs.md):文件用途和 Blender 导入关系。
- [开发与发布](development.md):本地环境、测试、打包和 CI。
- [节点资产](node-assets.md):Geometry Nodes 的构建、加载与验证。
内部实现文档描述模块之间的职责和主要数据流。函数级分支、参数校验与异常处理以源码和测试为准,避免文档重复实现细节。

View File

@@ -0,0 +1,58 @@
# 节点资产
AnyImage 将稳定、可复用的 Geometry Nodes 和 Shader 节点组构建到 `O_AnyImage.blend`,运行时按名称加载。Depth Plane 节点图是例外:它由 Python 在当前 `.blend` 中按需创建。
## 对应文件
```text
src/anyimage/
├── assets/O_AnyImage.blend
└── operators/common/
├── node_asset.py # 资产路径、加载与 Modifier 输入
└── node_graph.py # 共享节点图构造函数
tools/node_assets/
├── build.py # 统一构建入口
├── build_mesh_plane.py
├── build_shadeless.py
├── build_cutout.py
├── validate.py # 资产清单与基础接口
├── validate_mesh_plane.py
└── validate_cutout.py
```
## 构建与加载
```mermaid
flowchart TD
Scripts["tools/node_assets/build_*.py"] --> Build["Blender 后台构建"]
Build --> Blend["assets/O_AnyImage.blend"]
Blend --> Loader["operators/common/node_asset.py"]
Loader --> Modifier["运行时 Node Group / Modifier"]
```
使用 Blender 而不是普通 Python 执行:
```bash
BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe"
"$BLENDER_BIN" --background --python tools/node_assets/build.py
"$BLENDER_BIN" --background --python tools/node_assets/validate.py
"$BLENDER_BIN" --background --python tools/node_assets/validate_mesh_plane.py
"$BLENDER_BIN" --background --python tools/node_assets/validate_cutout.py
```
`build.py` 从空 Factory 文件开始,依次构建 `O Mesh Plane`、`O Shadeless`、四个 Cutout 内部节点组与公开修改器节点组 `O Image Cutout`,再保存唯一资产文件。`O Image Cutout` 用 Menu Switch 包装四个内部组;运行时 Loader 只加载这个稳定入口,Blender 4.5 根据 Shape 菜单依赖只显示当前分支实际使用的输入。
## 验证范围
基础验证检查发布资产清单、节点树类型、Modifier 标记、接口顺序和 Fake User。专项验证继续检查:
- Mesh Plane 的接口、细分、Thickness 和几何求值;
- `O Image Cutout` 的公开接口、Shape 菜单和四个内部组的依赖关系;
- 节点不重叠、主要连线保持从左向右;
- 局部 Group Input 未使用输出隐藏;
- Cleanup 只在四条分支之前执行,Smooth 只在菜单选择之后执行;
- Repeat Zone 数量和四种 Shape 的关键几何求值稳定。
节点资产接口变化时,应运行全部四个 Blender 入口和完整 Python 测试。只修改 Python、Markdown 或打包逻辑时无需重建 `.blend`。Blender 自动生成的 `.blend1` 等备份不属于发布资产。

View File

@@ -0,0 +1,36 @@
# AI 环境管理
本页对应 `src/anyimage/operators/ai_setup.py`。该模块把环境安装、必需模型下载和模型缓存清理组织成 Blender 可调用的 Operator。
## 对应文件
```text
src/anyimage/
├── operators/ai_setup.py # 环境与模型管理入口
├── preferences.py # 环境位置和扩展级设置
├── properties.py # 环境状态与模型目录
├── runtime.py # BlendJob Runtime 访问入口
└── server/
├── app.py # 下载 Job 注册
├── model_catalog.py # 模型元数据
├── model_download.py # 模型文件下载
└── model_manager.py # 模型资源与 Session 缓存
```
## 主流程
```mermaid
flowchart TD
Entry["Set Up AI Environment"] --> Status["读取环境与必需模型状态"]
Status --> Ready{"是否全部就绪?"}
Ready -- "是" --> Done["结束"]
Ready -- "否" --> Environment{"运行环境是否就绪?"}
Environment -- "否" --> Install["安装隔离 Python 环境"]
Environment -- "是" --> Download["下载缺失的必需模型"]
Install --> Download
Download --> Done
```
`SetupAIEnvironment` 是统一入口:首次使用时选择普通源或镜像安装隔离环境;环境已存在时,只补齐必需模型。单模型下载和必需模型批量下载都通过 Job Server 执行,因此状态、错误与进度沿用同一套 Runtime 通道。
`OpenAIEnvironmentSettings` 将用户带到扩展首选项。`ClearModels` 清除 Runtime 中的 `model_manager` 资源,使已加载的模型 Session 释放;它不负责删除磁盘上的模型文件。

View File

@@ -0,0 +1,34 @@
# 剪贴板图片
剪贴板功能把操作系统中的图片转换为 Packed Blender Image,再根据当前编辑器创建对象、节点或画笔纹理。它完全运行在 Blender 进程中,不使用 Job Server。
## 对应文件
```text
operators/clipboard_image/
├── __init__.py # Operator、原生复制优先级与快捷键
├── clipboard.py # Windows、macOS、Linux 剪贴板读取
└── actions.py # Image 复用、Pack 与各编辑器落点
```
## 处理流程
```mermaid
flowchart TD
Paste["Ctrl/Cmd + V"] --> Priority{"刚执行过 Blender Copy?"}
Priority -- "是" --> Native["交回原生 Paste"]
Priority -- "否" --> Read["读取系统图片"]
Read --> Image["按内容哈希复用或创建 Packed Image"]
Image --> Target{"当前编辑器"}
Target --> View["Reference / Plane"]
Target --> Node["Image Node"]
Target --> Paint["Brush Texture"]
```
`TrackNativeCopy` 在相同快捷键下记录系统剪贴板变化标记。如果剪贴板没有在 Blender Copy 之后变化,Paste Operator 返回 `PASS_THROUGH`,让 Blender 原生对象或节点粘贴优先执行。
`clipboard.py` 封装平台差异:Windows 读取 DIB/DIBV5,macOS 与 Linux 调用平台提供的图片导出命令。模块最终都返回编码后的图片字节和扩展名,后续流程不再关心来源平台。
`actions.py` 以 SHA-256 标记 Packed Image,相同内容可复用已有数据块。3D View 中的 Reference 和 Plane 使用鼠标位置或视图落点;Plane 复用 `convert_to_plane/mesh_plane.py` 的基础网格与 `O Mesh Plane` 资产。Node Editor 根据树类型选择对应 Image 节点,绘制模式则为当前画笔配置 Texture。
快捷键只注册到 3D View 与 Node Editor;编辑器内部的具体支持范围由 `target_for_context()` 决定。创建成功后图片始终 Pack 到当前 `.blend`,避免依赖临时剪贴板文件。

View File

@@ -0,0 +1,58 @@
# Plane 转换
Plane 转换包含两条路径:普通 Plane 在 Blender 内同步完成;Depth Plane 先通过 `prepare-geometry-data` Job 取得深度场,再回到 Blender 构建面片。两者共享图片命名、材质与基础四边形能力,但使用不同的 Geometry Nodes。
## 对应文件
```text
operators/convert_to_plane/
├── __init__.py # 对外导出
├── conversion.py # 两个入口 Operator 与 Depth Job
├── build.py # 颜色、深度场、材质和对象导入
├── mesh_plane.py # 基础四边形与 O Mesh Plane 修改器
└── nodes.py # 运行时 O Image Depth Plane 节点图
server/
├── jobs/prepare_geometry_data.py
├── models/da3.py
├── models/moge2.py
└── geometry/projection.py
```
## 总体流程
```mermaid
flowchart TD
Empty["Image Empty"] --> Kind{"转换类型"}
Kind -- "Plane" --> Copy["复制 Image 与材质"]
Copy --> Quad["创建四边形 + O Mesh Plane"]
Kind -- "Depth Plane" --> Input["准备颜色与分析输入"]
Input --> Job["prepare-geometry-data"]
Job --> Field["image.0001 + field.npz"]
Field --> Anchor["按 Empty 范围锚定相机点"]
Anchor --> Depth["创建深度 Image + 材质 + 节点修改器"]
Quad --> Replace["替换源 Empty"]
Depth --> Replace
```
## 普通 Plane
`ConvertToPlane` 复制当前 Image 数据,保留 Movie / Sequence 的 Image User 设置,创建与 Image Empty 显示范围一致的四边形,再加载内置 `O Mesh Plane` 节点组。原始 Mesh 始终只有一个四边形;`Subdivide`、`Thickness` 和材质赋值由修改器执行。
对象继承源 Empty 的 `matrix_world`。转换完成后删除源 Empty,并让新 Plane 沿用对象名称。剪贴板的 Plane 导入复用同一构建函数,因此两种入口得到一致的基础网格与修改器。
## Depth Plane
`ConvertToDepthPlane` 当前只接受静态图片。深度模型和分辨率来自扩展 Preferences:MoGe-2 使用 Resolution Level,DA3 使用按原图尺寸换算并对齐到模型 Patch 的处理分辨率。
`GenerateDepthPlane` 把颜色输入、分析输入和模型参数提交给 `prepare-geometry-data`。Server 将模型结果统一为 `GeometryFrame`,写出包含相机点、深度、内参、有效区域和参考深度的 `field.npz`。这一步不创建网格。 `max_input_size` 同时传入 Job;Server 通过 `media/resolution.py` 在模型推理前限制分析图长边,默认 `2048`。颜色输入仍由未缩放的原图复制,受限分析图不会成为材质 Color。
Blender 响应阶段执行三件事:
1. 根据 Image Empty 的平面范围、相机内参和参考深度,把相机点等比锚定到对象局部空间。
2. 将锚定点的局部 Z 写入 Packed 浮点 `_depth.exr` Image,并创建颜色材质。
3. 创建基础四边形和运行时 `O Image Depth Plane` 节点组,以 UV 采样深度并移动正面。
Depth 节点组先复用 `O Mesh Plane` 完成细分与材质,再生成背面厚度、限制正面向内穿透,最后在 Thickness 为零时删除背面和侧面。`Depth Amount` 控制位移强度;`Depth Image` 与 `Material` 是由插件填入的内部输入。
与 Cutout 的 Depth Surface 不同,Depth Plane 只使用锚定后的 Z 高度,不在 Geometry Nodes 中重建完整相机空间 XYZ。它强调一张图上的可编辑浮雕,而不是局部透视表面。

View File

@@ -0,0 +1,80 @@
# Cutout
Cutout 从一条图片空间套索生成规则三角基础面,再交给统一的 `O Image Cutout` Geometry Nodes 修改器形成 Surface、Balloon、Depth Balloon 或 Depth Surface。公开节点组用 Shape 菜单路由到四个原有内部节点组;轮廓和 Mesh 始终由 Blender 主线程创建,Server 只在需要时准备 Refine、深度或法线数据。
## 对应文件
```text
operators/cutout_tool/
├── selection_tool.py # 套索交互、设置与 Shape 饼菜单
├── shape_conversion.py # 本地 / Job 路径选择和任务参数
├── build_shapes.py # 裁切贴图、Mesh、材质与修改器
├── shape_types.py # Shape 与节点组映射
├── mask_regions.py # Alpha 外轮廓与孔洞
├── planar_mesh.py # 规则采样与约束三角化
├── balloon_profile.py # Poisson / Teddy profile
├── depth_calibration.py # Reference 与 Direction
└── node_assets.py # Cutout 节点资产加载
server/
├── jobs/prepare_geometry_data.py # Cutout / Depth Plane 数据编排
├── selection/
│ ├── polygon_mask.py # Polygon 栅格化与选区裁切
│ └── cutout_images.py # Refine 与 Cutout 局部图片
└── geometry/normal_texture.py # 相机轴法线编码
```
## 路径选择
```mermaid
flowchart TD
Lasso["套索 + Shape"] --> Refine{"Refine Selection?"}
Refine -- "是" --> Ben2["BEN2 × 1"]
Refine -- "否" --> Selection["套索 Selection"]
Ben2 --> Selection
Selection --> NeedMoge{"需要 Depth 或 Normal?"}
NeedMoge -- "否" --> Mesh["轮廓 → 规则点 → 约束三角化"]
NeedMoge -- "是" --> Moge["MoGe-2 × 1\\n同一 Prediction"]
Moge --> Data["Depth Field / Camera Normal"]
Data --> Mesh
Mesh --> Profile["计算 o_balloon"]
Profile --> Nodes["加载 O Image Cutout 并设置 Shape"]
Nodes --> Bake["按需烘焙最终 Tangent Normal"]
```
Cutout 只接受静态图片。Refine Selection 是网格生成前唯一可选的选区分析:开启时执行一次 BEN2,关闭时直接使用套索 Selection。Surface 与 Balloon 在不需要 Normal 时不运行 MoGe-2;两个 Depth Shape 和任意开启 Normal 的 Shape 都统一使用 MoGe-2。Depth 与 Normal 同时启用时只执行一次推理,同一个 Prediction 同时提供相机点、深度和图片空间法线。
Cutout 不使用 DA3。若全局 Depth Model 当前选择 DA3,Cutout 会回退到默认 MoGe-2;选择 MoGe-2 档位时则沿用该档位和 Resolution Level。DA3 仍可用于独立的 Depth Plane 流程。
## 选择与局部数据
Cutout 复用 `ImageMaskGesture` 的采点和屏幕预览,并复用 `operators/common/image_mask.py` 的可见选区语义:最终几何范围始终是套索、图片边界与源图 `Alpha > 0.1` 区域的交集。套索采用 NONZERO 填充规则,使反向内环可以表达孔洞。`Point Spacing` 先根据完整源图最长边换算为基础 `mesh_detail`;获得裁切 Selection 后,再按 `min(1, max_input_size / crop_long_edge)` 缩放有效 `mesh_detail`。因此只有裁切图超过全局上限时网格才会变稀,Color Image 仍使用原始裁切尺寸。
Job 路径将数据拆成三层:源颜色、最终选择和只供模型分析的 RGBA。写出最终选择前会再次以 `Alpha > 0.1` 与源图求交,因此未 Refine、Refine 和双击全图遵循相同边界。`bounds.json` 把可见选区的局部矩形映射回源图,Blender 据此恢复局部平面范围和 UV。未走 Job 时,`build_shapes.py` 在内存中完成相同的求交、裁切与坐标换算。
## 基础网格
最终 Alpha 先转为带包含关系的外轮廓与孔洞。`planar_mesh.py` 在内部生成近似六角排列的规则点,并用轮廓作为约束进行 Delaunay 三角化。边界按目标间距重采样,短轮廓会提高边界采样密度,内部点仍保持用户设置的间距。
四种 Shape 共享同一张单面基础网格、UV 和 `o_balloon` 点属性。创建阶段以目标 Point Spacing 的平方作为一个采样单元面积,再除以 Image Empty 图片平面从局部到世界的面积缩放,得到修改器局部坐标中的 Cleanup 阈值。这个值只取决于工具分辨率和图片尺寸/变换,不受碎片数量或碎面面积分布影响。Balloon Profile 由 Preferences 选择 Poisson 或 Teddy;节点组只消费计算后的属性,不再重复推导轮廓。
## 统一修改器与四种 Shape
创建结果时始终添加 `O Image Cutout`,并按饼菜单选择写入 Shape。节点组先统一执行 Cleanup,再由 Menu Switch 仅求值当前 Shape 的内部节点组,最后统一执行 Smooth;因此四种 Shape 都可使用 Cleanup、Smooth 和 Smooth Iterations。各分支独有输入直接连接到对应内部组,Blender 4.5 会在修改器中隐藏当前 Shape 不使用的输入。Cleanup 直接比较 Mesh Island 总面积与创建时写入的绝对面积阈值,不再统计当前网格面的中位面积。
| Shape | 内部节点组 | 主要几何来源 |
| --- | --- | --- |
| Surface | `O Image Surface` | 基础薄片与可调 Thickness |
| Balloon | `O Image Balloon` | `o_balloon` 鼓起高度 |
| Depth Balloon | `O Image Depth Balloon` | Balloon profile + 相对基准面的非负深度高度 |
| Depth Surface | `O Image Depth Surface` | MoGe-2 相机点的完整投影 |
四个内部节点组不再标记为 Modifier,也不再各自执行 Cleanup。原本只在 `O Image Depth Surface` 内的 Smooth 后处理已移到公开节点组;统一节点组的 Smooth 默认值为 `1`;外层 `Smooth Iterations` 默认值为 `0`,只有用户增加迭代次数后才执行平滑。两个 Depth 内部节点组也统一使用 `Reference Depth`,总节点组只暴露一个共用输入。
两个 Depth 形状把相机点 RGB 写入 Packed 浮点 `_depth.exr`,并以隐藏的 `Uniform Scale` 匹配 Image Empty 尺寸。未启用 Refine 时 Reference 使用固定值,Depth Balloon Direction 使用默认轴向;启用 Refine 后,`calibration_valid` 限制在模型有效区域与最终选择的交集,Reference 使用其深度第 95 百分位,Depth Balloon 还拟合局部基准方向。
Depth Balloon 保留图片平面 XY,只使用相机 Z 形成向外高度;Depth Surface 将完整 XYZ 映射到 Cutout 局部轴,因此能表达透视位移。两者都只采样深度 Image 的 RGB,Alpha 仅保存有效性和深度跳变信息。
## 法线空间
MoGe-2 在 Job 阶段输出图片空间的相机轴法线预测;这一步不依赖 Blender 网格。Depth Surface 在导入时将它重排到 Cutout 对象轴并以 Object Space 使用;Surface、Balloon 与 Depth Balloon 则先创建基础网格并完成 Geometry Nodes 求值,再根据成品网格 UV、法线和切线基底烘成 Tangent Space。也就是说,网格前产生的是模型 Prediction,最终法线贴图的空间转换仍发生在网格生成之后。

View File

@@ -0,0 +1,27 @@
# Debug Operator
本页对应 `src/anyimage/operators/debug.py`。这些 Operator 为开发者直接验证 DA3、MoGe-2、BEN2 和 Upscale 的 Server 接口,不参与面向用户的对象创建流程。
## 对应文件
```text
src/anyimage/
├── operators/debug.py # 调试请求与结果目录打开
├── panel.py # Debug 面板入口
└── server/
├── jobs/debug_predictions.py # 四种公开 Debug Job 的共享编排
└── outputs/ # 调试产物写出
```
## 主流程
```mermaid
flowchart TD
Panel["Debug 面板"] --> Operator["选择模型调试 Operator"]
Operator --> Request["从 Scene 设置组装请求"]
Request --> Job["提交对应 debug Job"]
Job --> Output["写出原始预测产物"]
Output --> Directory["打开 Job 目录"]
```
调试入口读取 Scene 中的输入路径、设备与模型参数,直接调用对应 Server Job。任务完成后只打开 Job 目录供检查,不把结果导入为 Blender Image、Mesh 或节点资产。因此它适合验证模型、设备和输出格式,不代表正式功能的用户流程。

View File

@@ -0,0 +1,46 @@
# Image Tool
`operators/image_tool/` 对应工具栏中的 Box、Lasso、Line 与 Polyline。四个 WorkspaceTool 共用一个 `EditImageMask` Operator,把屏幕手势转换为图片 Alpha;只有 Box 与 Lasso 可以用 BEN2 细化选择。
## 对应文件
```text
operators/image_tool/
├── __init__.py # 类型清单与对外导出
├── box.py # Box WorkspaceTool
├── lasso.py # Lasso WorkspaceTool
├── line.py # Line WorkspaceTool
├── polyline.py # Polyline WorkspaceTool
└── edit_mask.py # 本地编辑与 Refine Job 响应
```
手势、坐标和像素能力由 `operators/common/` 提供,四点矫正则属于独立的 [`operators/perspective_tool/`](perspective-tool.md)。
## 编辑流程
```mermaid
flowchart TD
Tool["Box / Lasso / Line / Polyline"] --> Gesture["ImageMaskGesture"]
Gesture --> Polygon["图片像素 Polygon"]
Polygon --> Refine{"Refine Selection?"}
Refine -- "否" --> Mask["本地几何 Mask"]
Refine -- "是" --> Job["refine-image-mask"]
Job --> Mask
Mask --> Invert["可选反选"]
Invert --> Alpha["源 Alpha × 最终 Mask"]
Alpha --> Crop["紧裁切并写回 Empty"]
```
`EditImageMask` 只接受静态图片。未启用 Refine 时,Polygon 在 Blender 侧直接栅格化;启用后,Operator 准备单图输入并提交 Job,响应阶段读取局部 Alpha 与源图 `bounds`,再映射回完整画布。反选始终在最终 Mask 上执行。
Box 和 Lasso 分别保存自己的 Refine 状态。Line 与 Polyline 强制关闭 Refine,只使用几何 Mask;Line 将有向线扩展为半平面,Polyline 显式闭合用户确认的顶点。
## 手势与预览
四个工具使用相同的白色虚线轮廓和半透明填充。Lasso 按移动距离采点,并在线合并近似共线点;普通凹多边形使用三角剖分,自交路径使用 EVEN_ODD 扫描填充。预览缓存只在轨迹变化时更新。
首次点击通过公共选择逻辑决定是切换 Active Image Empty,还是开始编辑当前图片。这样工具不需要替代 Blender 的普通对象选择。
## 写回结果
最终像素保留源 RGB,只修改 Alpha,再按非零 Alpha 紧裁切。公共写回函数只替换当前 Empty 的 Image,并更新 `empty_display_size` 与 `empty_image_offset`,因此剩余像素继续对齐原图片平面,对象 `matrix_world` 不变。

View File

@@ -0,0 +1,18 @@
# Operator 实现索引
`src/anyimage/operators/` 负责 Blender 侧交互、Job 请求组装和结果导入。内部实现按完整功能包或顶层业务模块拆页;只提供共享能力的 `common/` 不单独成页,其职责统一放在[程序架构](../architecture.md)中说明。
| Operator 路径 | 实现页 | 主要职责 |
| --- | --- | --- |
| `operators/__init__.py` | 本页 | 汇总 Blender 类型注册顺序 |
| `operators/ai_setup.py` | [AI 环境管理](ai-setup.md) | 环境安装、模型下载与缓存释放 |
| `operators/clipboard_image/` | [Clipboard Image](clipboard-image.md) | 系统剪贴板读取、粘贴与快捷键 |
| `operators/convert_to_plane/` | [Convert to Plane](convert-to-plane.md) | Mesh Plane 与 Depth Plane 创建 |
| `operators/cutout_tool/` | [Cutout Tool](cutout-tool.md) | 区域选择、轮廓生成与 Shape 构建 |
| `operators/debug.py` | [Debug Operator](debug.md) | 模型接口与原始产物验证 |
| `operators/image_tool/` | [Image Tool](image-tool.md) | Box、Lasso、Line 与 Polyline 编辑 |
| `operators/perspective_tool/` | [Perspective Tool](perspective-tool.md) | 四点透视框与透视校正 |
| `operators/remove_background.py` | [Remove Background](remove-background.md) | BEN2 去背与结果替换 |
| `operators/upscale.py` | [Upscale](upscale.md) | 4× 高清与原图引用维护 |
`operators/__init__.py` 只聚合注册类,不承载独立业务流程。具体类的注册和逆序注销由扩展入口统一管理,整体边界见[程序架构](../architecture.md)。

View File

@@ -0,0 +1,32 @@
# Perspective Tool
`operators/perspective_tool/` 对应独立的 Image Perspective WorkspaceTool。它采集四个图片角点,把透视四边形重采样为指定宽高比的矩形,不使用 Job Server。
## 对应文件
```text
operators/perspective_tool/
├── __init__.py # 类型清单与对外导出
└── edit_perspective.py # 四点交互、预览、Homography 与写回
```
图片选择、裁切和 Empty 写回复用 `operators/common/` 的基础能力,不与 Image Mask Operator 共享业务状态。
## 矫正流程
```mermaid
flowchart TD
Click["任意顺序的四个角点"] --> Quad["规范为凸四边形"]
Quad --> Preview["生成一次 512 × 512 预览"]
Preview --> Ratio["鼠标调整目标宽高比"]
Ratio --> Size["计算受限输出尺寸"]
Size --> Warp["全分辨率 Homography"]
Warp --> Crop["按可见 Alpha 紧裁切"]
Crop --> Write["Pack 并写回当前 Empty"]
```
四点输入顺序不决定输出方向;规范化阶段依据源图 X/Y 方向得到不交错的凸四边形。第四点之后只创建一次低分辨率纹理,交互阶段改变 UI 宽高比,确认时才执行全分辨率重采样。
Homography 使用预乘 Alpha 插值,避免透明边缘混入黑色。输出最长边限制为 8192 px,总像素限制为 16,777,216,并按 256 行分块计算,控制中间数组大小。
Perspective 是 deform 工具,不提供 Refine 或反选。结果比例可能不同于原四边形的轴对齐范围,因此 Empty 写回保持中心和覆盖尺度近似一致,而不是假设当前 3D View 相机等于图片拍摄相机。

View File

@@ -0,0 +1,31 @@
# Remove Background
本页对应 `src/anyimage/operators/remove_background.py`。该模块负责从 Image Empty 准备媒体输入、提交 BEN2 去背任务,并用结果 Image 替换当前图像;模型推理留在 Server 进程。
## 对应文件
```text
src/anyimage/
├── operators/
│ ├── remove_background.py # Blender 入口、任务参数与结果替换
│ └── common/image.py # 媒体输入准备与结果 Image 创建
└── server/
├── jobs/remove_background.py # 单图与多帧任务编排
└── models/ben2.py # BEN2 前景适配
```
## 主流程
```mermaid
flowchart TD
Empty["Image Empty"] --> Prepare["准备图片、视频或序列输入"]
Prepare --> Operator["RunBackgroundRemoval"]
Operator --> Job["remove-background Job"]
Job --> Frames["生成 PNG 结果序列"]
Frames --> Import["创建结果 Image"]
Import --> Replace["替换 Empty.data"]
```
Packed 图片或内存中修改过的单图会先导出为临时输入;文件图片、视频和序列则沿用可读取的源路径。多帧输入由统一媒体层展开,结果保持连续帧命名,导入后继续沿用源 Image User 的帧起点、偏移与循环设置。
BEN2 推理前使用输入 Alpha 屏蔽透明区域,模型输出的前景 Alpha 再与源 Alpha 合成,RGB 保持为源图内容。响应阶段只替换当前 Empty 的 Image 数据;当旧 Image 没有其他用户和 Fake User 时,旧数据块会被清理并由结果继承名称。

View File

@@ -0,0 +1,36 @@
# Upscale
本页对应 `src/anyimage/operators/upscale.py`。该模块读取扩展首选项中的模型与全局 AI 输入长边上限,提交 4× 高清任务,并维护高清结果到原始 Image 的引用。
## 对应文件
```text
src/anyimage/
├── operators/
│ ├── upscale.py # Blender 入口、任务参数与结果替换
│ └── common/image.py # 媒体输入准备与结果 Image 创建
└── server/
├── jobs/upscale.py # 单图与多帧任务编排
├── media/resolution.py # 共用长边限制与等比缩放
├── models/upscale.py # 输入约束与 Alpha 合并
└── models/onnx_upscale.py # 分块 4× ONNX 推理
```
## 主流程
```mermaid
flowchart TD
Empty["Image Empty"] --> Source["解析原始 Image"]
Source --> Prepare["准备媒体输入"]
Prepare --> Job["upscale-image Job"]
Job --> Limit["按全局输入长边上限等比缩放"]
Limit --> RGB["分块执行 4× RGB 推理"]
Limit --> Alpha["独立缩放 Alpha"]
RGB --> Merge["合并 RGBA 结果"]
Alpha --> Merge
Merge --> Replace["导入并替换当前 Image"]
```
模型以 256×256 分块处理 RGB,并带入相邻上下文以减弱块边界;Alpha 按同一受限输入尺寸单独缩放,最后合并为 RGBA。`max_input_size` 来自全局 **Maximum AI Input Size**,默认 `2048`,只约束送入模型的长边;4× 输出长边因此默认为最多 `8192`。输入尺寸不超过上限时不会发生预缩放。
结果以 `_upscale.png` 结尾,并通过 `Image.anyimage_upscale_source` 保存原图引用。再次对高清结果执行时,Operator 会从原图重新生成,而不是继续放大上一次输出。模型与全局输入上限在每次执行时从 Preferences 读取,再写入可序列化的隐藏属性交给 Job。Provider 资源错误会释放当前 Upscale Session,并提示降低全局输入上限或改用 CPU。

72
docs/internals/outputs.md Normal file
View File

@@ -0,0 +1,72 @@
# Job 产物
Job 目录是 Server 与 Blender Operator 的交换边界。Server 写入普通图片、JSON 或 NPZ;Blender 只根据 Job 返回值读取需要的文件,并创建最终数据块。
## 产物流向
```mermaid
flowchart TD
Input["媒体输入"] --> Job["Server Job"]
Job --> Color["PNG 颜色 / Alpha"]
Job --> Field["NPZ 深度场"]
Job --> Meta["JSON bounds"]
Color --> Import["Blender response()"]
Field --> Import
Meta --> Import
Import --> Data["Image / Mesh / Material"]
```
## 正式 Job
| Job | 主要文件 | 消费方 |
| --- | --- | --- |
| `remove-background` | `image.0001.png` 起 | 替换 Image Empty 的颜色数据 |
| `upscale-image` | `upscale.0001.png` 起 | 创建 `_upscale.png` Image 并记录原图引用 |
| `refine-image-mask` | `selection-input.png`、`image.png`、`bounds.json` | Image Box / Lasso 读取 Alpha 与原图坐标 |
| `prepare-geometry-data`:Depth Plane | `image.0001.*`、`field.npz` | 创建颜色图、深度图、材质和 Plane |
| `prepare-geometry-data`:Cutout | `image.0001.png`、`selection.png`、`analysis.png`、`bounds.json`,以及按形状生成的 `field.npz` / `normal.png` | Blender 主线程构建局部网格与贴图 |
`prepare-geometry-data` 不输出 Blender 网格。它只准备颜色、选区和模型数组;轮廓提取、约束三角化、Geometry Nodes Modifier 与法线烘焙都在 Blender 响应阶段完成。
## 深度场
`field.npz` 是 Depth Plane、Depth Balloon 与 Depth Surface 共用的模型中间结果:
| 字段 | 含义 |
| --- | --- |
| `points` | 每个模型像素的相机空间 XYZ |
| `depth` | 相机深度 |
| `intrinsics` | 像素坐标下的 3×3 相机内参 |
| `valid` | 模型与数值检查后的有效区域 |
| `calibration_valid` | 用于结果定位或深度基准的区域;Cutout 时与选择相交 |
| `image_size` | 深度场宽高 |
| `reference_depth` | 校准区域中的参考深度 |
Server 保留模型空间数据,不提前换算 Blender 单位。Depth Plane 在导入时根据 Image Empty 平面范围和相机内参锚定点场;Cutout 深度形状则计算 `Uniform Scale` 和局部坐标,再把相机点写入 Packed 浮点 Image 供 Geometry Nodes 采样。
## Cutout 三类图片
Cutout 把“显示内容”“几何范围”和“模型分析”分开:
- `image.0001.png` 保存裁切矩形中的源颜色与原 Alpha,用作材质。
- `selection.png` 保存最终选择 Alpha,用于轮廓与网格。
- `analysis.png` 只保留选择内的有效 RGBA,供深度或法线模型分析。
`bounds.json` 把裁切矩形映射回源图像素坐标。这样 Server 可以只处理局部数据,Blender 仍能恢复局部平面范围、UV 和世界位置。
## Debug 产物
Debug Job 保留模型适配层的直接输出,不执行正式功能的 Blender 导入:
| Job | 文件 |
| --- | --- |
| `debug-da3` | `prediction.npz` |
| `debug-moge2` | `prediction*.npz` |
| `debug-ben2` | `foreground*.png` |
| `debug-upscale` | `prediction*.png` |
这些文件用于验证模型、Device 和适配层。正式 Job 会继续派生选择、Alpha、深度场或可导入图片,因此两者的文件结构有意不同。
## 单图与序列
正式颜色序列从 `image.0001.*` 开始,Upscale 使用独立的 `upscale.0001.png` 前缀。Remove Background 与 Upscale 的动画结果继续引用 Job 目录;单图结果通常在 Blender 导入后 Pack 到 `.blend`。不要清理仍被工程引用的序列目录。

86
docs/internals/runtime.md Normal file
View File

@@ -0,0 +1,86 @@
# Job Runtime 与 Server
AnyImage 使用 BlendJob 管理独立 Python Environment、Server 进程和任务队列。Blender 侧只配置 Runtime 与提交参数;`server/` 是不依赖 `bpy` 的计算包,可以在独立解释器中导入。
## 对应文件
```text
src/anyimage/
├── runtime.py # Environment 与 JobRuntime 配置
└── server/
├── app.py # Job 注册和共享资源
├── model_catalog.py # 模型声明
├── model_download.py # 下载、校验与镜像回退
├── model_manager.py # 模型目录与 Session 缓存
├── media/input.py # 图片、视频和序列输入
├── jobs/ # app.py 可提交的公开任务
├── selection/ # 选区栅格化与 Cutout 图片准备
├── models/ # 模型适配与 ONNX 推理
├── geometry/ # 深度反投影与法线纹理编码
└── outputs/ # Debug Prediction 序列化
```
## 任务生命周期
```mermaid
flowchart TD
Request["Operator request()"] --> Runtime["JobRuntime"]
Runtime --> Queue["单 Worker 队列"]
Queue --> Job["app.py 注册的 Job"]
Job --> Resource["ModelManager / Media"]
Resource --> Directory["jobs/<job-id>/"]
Directory --> Response["Operator response()"]
```
Runtime 在首次任务或显式启动时拉起 Server。任务在队列中串行执行,进度、取消和日志由 BlendJob 提供;AnyImage Job 在阶段边界调用 `context.check_cancelled()`,并把业务产物写入当前 Job 目录。Operator 完成响应后再清理 Blender 侧为 Packed Image 准备的临时输入。
`server/app.py` 是任务清单的唯一入口:
| Job | 主要用途 |
| --- | --- |
| `download-model`、`download-required-models` | 准备模型文件 |
| `remove-background` | BEN2 透明前景 |
| `upscale-image` | 4× 图片或帧序列 |
| `refine-image-mask` | 为 Image Box / Lasso 细化选区 |
| `prepare-geometry-data` | 为 Depth Plane 或 Cutout 准备颜色、选区、深度和法线数据 |
| `debug-*` | 保存模型适配层的直接输出 |
## 媒体输入
`server/media/input.py` 把单图、视频和图片目录统一为有序帧路径,并用上下文管理器负责临时帧目录的生命周期。它还支持将颜色输入与分析输入分开:例如 Upscale 结果可以作为颜色来源,同时使用它保存的原图引用进行 Depth Plane 分析。帧展开是媒体输入的内部能力,不再暴露没有业务消费者的独立 Job。 `server/media/resolution.py` 提供默认 `2048` 的共用输入长边限制;Upscale 直接限制每帧,`prepare-geometry-data` 则在 Cutout 裁切和分析输入选择完成后限制 Depth / Normal 模型输入。该临时缩放图不参与颜色产物写入。
当前 `prepare-geometry-data` 只接受一帧,因此 Depth Plane 与 Cutout 都是静态图工作流。Remove Background 与 Upscale 通过同一媒体入口处理单图、视频和序列帧。
公开 Job 不与 Operator 一一对应:Depth Plane 和 Cutout 共享 `prepare-geometry-data`,四个 Debug Job 共享 `jobs/debug_predictions.py`。Job 只负责协议和编排;可复用的输入、选区、模型与几何数据能力分别位于 `media/`、`selection/`、`models/` 和 `geometry/`,Job 之间不互相导入实现。
## 模型层
Job 只编排流程,模型适配层把不同推理 API 统一为内存对象:
- DA3 适配为深度、置信度、相机内外参与相机点。
- MoGe-2 适配为深度、法线、有效区域、相机内参与可选相机点。
- BEN2 返回带 Alpha 的前景图。
- Upscale 返回保留源 Alpha 的 4× RGBA 图。
`ModelManager` 管理下载目录和 ONNX Session。缓存按 BEN2、几何模型和 Upscale 三个族分开;每个族只保留最近一次使用的模型与 Device,切换同族模型时释放旧 Session。这样连续任务可以复用模型,同时避免同一类模型重复占用资源。
## Environment 与 Storage Root
`runtime.py` 声明 Python 3.12 环境和固定版本的 NumPy、Pillow、PyAV、trimesh、Hugging Face Hub 与 ONNX Runtime。Windows 安装 DirectML 版本,其他平台安装标准 ONNX Runtime。首次安装后依次下载默认 MoGe-2、BEN2 和 Real-ESRGAN WDN 模型。
Storage Root 默认是 `~/.anyimage`:
```text
~/.anyimage/
├── .venv/ # 独立 Environment
├── models/ # 模型目录
├── jobs/ # Job 产物与 job.log
├── environment.json
├── manifest.json
├── install.log
└── server.log
```
Storage Root 由扩展偏好设置提供给 Runtime。切换路径等同于切换整套 Environment、模型和任务数据,不会自动迁移旧目录。
各 Job 的稳定产物见 [Job 产物](outputs.md)。

View File

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

View File

@@ -1,34 +0,0 @@
# 输出格式
本页记录 Job 产物的稳定用途与主要字段。日常使用只需了解 [Job 文件夹](models-and-storage.md)。
## 文件概览
| Job | 主要文件 | 内容 |
| --- | --- | --- |
| `generate-depth` | `results.npz`、深度与颜色序列 | Depth Plane 所需深度、相机和贴图 |
| `generate-surface` | `mesh.npz`、`normal.png`、颜色层 | Surface 网格、相机与材质输入 |
| `remove-background` | `image.0001.png` 起 | 带 Alpha 的图片层 |
| `upscale-image` | `upscale.0001.png` 起 | 所选高清模型在输出上限内生成的 4× 高清层,并保留源图 Alpha |
| `refine-image-mask` | `selection-input.png`、`image.png`、`bounds.json` | BEN2 细化后的局部内容 mask 与原图坐标 bounds |
| `generate-mesh` | `image.0001.png`、`selection.png`、`bounds.json`,按形状输出 `normal.png`、`depth.npz` 或 `field.npz` | 当前图片所选区域的矩形 Color、几何 mask、原图坐标 bounds,以及同尺寸的局部深度或法线结果 |
| `debug-da3` | `prediction.npz` | DA3 API 返回 |
| `debug-moge2` | `prediction*.npz` | MoGe-2 API 返回 |
| `debug-ben2` | `foreground*.png` | BEN2 API 返回 |
| `debug-upscale` | `prediction*.png` | 请求模型的 4× RGB API 返回 |
## NPZ 字段
DA3 Depth 结果包含 `depth`、`conf`、`extrinsics` 与 `intrinsics`。MoGe-2 Depth 结果包含 `depth`、`normal`、`mask` 与 `intrinsics`。
Balloon Depth 的 `depth.npz` 包含相对 `depth_signal`、归一化拟合平面 `baseline_vector`、`valid`、`reference_depth`、取样原点与输入尺寸。Cutout Job 先按套索或 Refine 最终 Alpha 紧裁出局部矩形:`image.0001.png` 保留当前图片在矩形内的原始 RGB 与原有 Alpha,不乘选择 mask;`selection.png` 保存几何 Alpha;供 MoGe 使用的内部分析图仅在选区内保留像素,范围外 RGBA 为零。深度、normal 与 Color 使用相同的非正方形局部尺寸,Blender 根据 `bounds.json` 恢复位置和 UV。若输入是 Upscale 图片,推理直接使用高清图的所选区域。几何与 Surface 前景均以 Alpha `0.5` 为边界,小于 `0.5` 的顶点或区域会被剔除。Blender 创建并评估成品网格后,再用其 UV、平滑角法线与切线基底烘成实际使用的 tangent-space Image。
Surface 的 `mesh.npz` 包含 `vertices`、`faces`、`uv`、`intrinsics`、`image_size` 与 `reference_depth`,以及 Geometry Nodes 使用的网格属性。
Debug 文件保持模型 API 的数据形态,用于比较推理结果;正式 Job 则提供 Blender 导入所需的派生数据。三档高清模型的浮点 RGB 返回只做 `[0, 1]` 限制和 8-bit PNG 序列化,不做色调保护或额外画质处理;三个模型都使用 256×256、16 像素上下文的重叠分块,外边界以镜像像素提供模型上下文。输入含 Alpha 时,Alpha 不进入 RGB 模型,而是在相同输入限制后独立进行 4× 高质量插值并合并回 RGBA 结果。正式 `upscale-image` 还会在推理前应用输出长边与总像素上限,再把结果命名为 `upscale.0001.png` 起的序列并交给 Blender 写回 Image Empty;结果 Image 数据块始终基于原图名称使用 `_upscale.png` 后缀,重复执行时替换当前高清版本。
## 图片序列
正式图片层使用 `image.0001.*` 起始的文件名,Upscale 层单独使用 `upscale.0001.png`。导入 Blender 后,颜色、深度与法线 Image datablock 分别使用 `_color.png`、`_depth.exr` 与 `_normal.png` 后缀;重名时由 Blender 自动添加 `.001`。深度动画使用 EXR 序列以保存浮点值。源 Empty 为 Movie 或 Sequence 时,Blender 按序列载入这些文件,即使任务只输出一帧。
MoGe-2 Surface 的原生相机法线单独保存为 `normal.png`,Blender 以 object-space 解码。Depth Surface 同样直接保留该像素编码。Cutout、Balloon 与 Depth Balloon 不会把同一张图直接当作 tangent-space 解码,而是在 Blender 端依据各自最终评估网格的 TBN 重新生成 tangent-space 像素;材质的空间选择只负责匹配已经生成好的贴图。

View File

@@ -1,48 +0,0 @@
# 五分钟上手
这个流程会把一张图片转换为可继续编辑的 Depth Plane。
## 1. 导入图片
在 3D View 中选择 **Add > Image > Reference**,导入一张图片。Blender 会创建一个显示方式为 Image 的 Empty。
建议第一次尝试使用主体清晰、前后景关系明确的图片。人物、产品、建筑立面和插画通常都比较适合。
## 2. 打开 AnyImage 菜单
保持 Image Empty 处于选中状态,在 3D View 中点击右键,选择 **AnyImage > Convert to Depth Plane**。
如果菜单不存在,请确认当前对象是 Image Empty;如果转换入口是灰色,请先完成[安装与首次配置](installation.md)。
## 3. 使用默认设置转换
第一次使用建议保留默认设置:
- **Depth From**:MoGe-2 ViT-S Normal
- **Resolution Level**:5
- **Remove Background**:关闭
- **Shadeless**:关闭
- **Keep Original**:关闭
点击确认后,状态栏会显示模型加载和处理进度。首次推理需要加载模型,之后连续处理通常会更快。
## 4. 查看和调整结果
转换完成后,原 Image Empty 会被新的网格对象替代。打开 Geometry Nodes 修改器,可以调整:
- **Displacement**:选择直接改变几何,或改用材质置换。
- **Subdivision**:控制面片细分程度。
- **Background Depth**:控制透明区域或背景区域的深度。
- **Depth Scale**:放大、减小或反转深度变化。
如果希望保留原 Image Empty,转换前开启 **Keep Original**。
## 5. 让画面不受灯光影响
希望颜色尽量贴近原图时,在转换对话框中开启 **Shadeless**。需要让结果参与场景光照、阴影和材质表现时,则保持关闭。
## 接下来可以尝试
- 主体需要透明背景:[转换时去除背景](remove-background.md)
- 想要更强的透视结构:[转换为 Surface](guide-surface.md)
- 输入是视频或序列帧:[制作动画 Depth Plane](animation.md)

View File

@@ -0,0 +1,56 @@
# 功能参考
本页集中列出 AnyImage 当前实现的主要入口。操作建议见[工作流选择](../guides/workflows.md),内部设计见[内部实现总览](../internals/index.md)。
## Blender 入口
| 入口 | 位置 | 输入 |
| --- | --- | --- |
| Paste Clipboard Image | 3D View / Node Editor 的 `Ctrl+V` 或 `Cmd+V` | 剪贴板静态图 |
| Convert to Plane | Image Empty 右键菜单 | 单图、Movie、Sequence |
| Convert to Depth Plane | Image Empty 右键菜单 | 静态图 |
| Remove Background | Image Empty 右键菜单 | 单图、Movie、Sequence |
| Upscale | Image Empty 右键菜单 | 单图、Movie、Sequence |
| Image Box / Lasso / Line / Polyline | 3D Viewport 工具栏 | 静态 Image Empty |
| Image Perspective | 3D Viewport 工具栏 | 静态 Image Empty |
| Cutout | 3D Viewport 工具栏 | 静态 Image Empty |
右键菜单只在当前对象是 Image Empty 时出现。AI 功能会在 Environment 或默认模型未就绪时转入安装流程;普通 Plane、非 Refine 的 Image Tools 与基础 Cutout Surface / Balloon 不依赖 Environment。
## 结果类型
### Plane
普通 Plane 使用内置 `O Mesh Plane` Geometry Nodes,提供 Subdivide、Thickness 和 Material。Depth Plane 使用运行时生成的 `O Image Depth Plane`,增加 Depth Amount 与内部 Depth Image。
### Cutout Shape
Cutout 提供四种结果:
| Shape | `O Image Cutout` 内部分支 | 深度 |
| --- | --- | --- |
| Surface | `O Image Surface` | 无 |
| Balloon | `O Image Balloon` | Balloon Profile |
| Depth Balloon | `O Image Depth Balloon` | MoGe-2 高度 |
| Depth Surface | `O Image Depth Surface` | MoGe-2 相机点 |
四种 Shape 共用 `O Image Cutout` 修改器、规则三角基础面、UV 和 `o_balloon` 属性。Shape 菜单选择内部几何分支,Cleanup 在分支前执行,Smooth 在分支后执行;Point Spacing 控制网格密度,Normal 与 Refine Selection 按需启用 MoGe-2 和 BEN2。
### 图片结果
Image Mask 和 Perspective 直接更新当前 Empty 的图片并 Pack。Remove Background 输出透明颜色图或序列。Upscale 通过三档 ONNX 模型输出 4× RGBA,并保存原图引用,重复执行时从原图重新生成。
## 模型
- MoGe-2 ViT-S / ViT-B / ViT-L Normal
- DA3 Small / Base
- BEN2 Base
- Real-ESRGAN General WDN x4v3
- SAFMN Real x4
- Real-ESRGAN x4plus
默认安装准备 MoGe-2 ViT-S Normal、BEN2 Base 和 Real-ESRGAN General WDN x4v3。其余模型在扩展 Preferences 中按需下载。
## Debug
Server 面板提供 DA3、MoGe-2、BEN2 与 Upscale Debug Job。它们保存模型适配层的直接 Prediction,不创建正式功能对象。文件说明见 [Job 产物](../internals/outputs.md)。

View File

@@ -1,38 +0,0 @@
# 去除背景
Remove Background 使用 BEN2 为 Image Empty 生成透明前景,不创建网格。它适合在转换前检查抠图效果,或继续把结果当作普通 Image Empty 使用。
## 使用方法
1. 选中 Image Empty。
2. 在 3D View 中右键,选择 **AnyImage > Remove Background**。
3. 等待状态栏提示任务完成。
AnyImage 会生成透明颜色图。原图只被当前 Empty 引用时,结果直接替换原图并沿用名称;原图被多个对象引用时,只为当前 Empty 创建新图,其他对象继续使用原图。对象的 Transform、显示尺寸、Offset 和其他显示设置保持不变。
## 在转换时去除背景
Depth Plane 和 Surface 的转换对话框也提供 **Remove Background**:
- Depth Plane 会使用透明 Alpha 区分前景和背景深度。
- Surface 会在写出网格前删除背景三角面。
- 深度估算仍使用未经抠图的原始 RGB,避免透明区域提前影响模型判断。
## 图片、视频和序列帧
普通图片会得到一张透明 PNG。视频或序列帧会得到透明图片序列,并从源 Empty 的 Start 位置开始播放。处理范围跟随 **Image Frames** 和 **Offset**。
!!! note
动画结果引用 Storage Root 中任务目录里的序列帧。不要在仍使用这些素材时删除对应的 Job 文件夹。
## 常见问题
### Remove Background 是灰色的
确认 Environment 已安装,且 **MoGe-2 ViT-S Normal**、**BEN2 Base** 与 **Real-ESRGAN General WDN x4v3** 都显示 Ready。正式功能会等默认必需模型全部就绪后启用。
### 透明边缘需要调整
当前正式入口不提供 BEN2 refinement 参数。可以在结果材质中继续处理 Alpha,或在外部图像软件中修整生成的颜色图。
所有 BEN2 任务都会先用输入 Alpha 遮住推理输入中的透明 RGB。正式输出只采用 BEN2 的 Alpha,颜色继续使用源 RGB;已有 Alpha 时,结果 Alpha 为“输入 Alpha × BEN2 Alpha”。因此 Image Box / Lasso 或外部工具创建的透明范围不会被去背景重新扩张,软边缘也会继续保留,同时避免透明黑参与 Viewport 纹理过滤。

View File

@@ -1,98 +0,0 @@
# Job Server
AnyImage 把 AI 与媒体处理放在独立 Job Server 中运行,使 Blender Python 保持轻量。安装和使用问题见[安装与首次配置](installation.md)与[常见问题](faq.md)。
## 架构
```mermaid
flowchart LR
Blender["Blender Operator"] --> Runtime["JobRuntime"]
Runtime --> Queue["FIFO Queue"]
Queue --> Job["AnyImage Job"]
Job --> Media["Media"]
Job --> Models["ModelManager"]
Job --> Geometry["Geometry"]
Job --> Outputs["Outputs"]
Outputs --> Directory["Job Directory"]
Directory --> Blender
```
每个 Blender 实例拥有自己的 Server 进程,连接绑定到本机随机端口和 Instance ID。多个实例可以共享 Storage Root,但各自维护进程、队列和内存中的模型 Session。
BlendJob 提供 Runtime、HTTP transport、队列、状态和取消协议。AnyImage 的 Operator 负责业务参数,Server Job 负责工作流编排,结果由 Operator 在 Blender 主线程中导入。
## Storage Root
默认位置为 `~/.anyimage`:
```text
~/.anyimage/
├── .venv/ Python Environment
├── models/ ONNX 模型
├── jobs/ 任务产物与 job.log
├── environment.json 环境声明
├── manifest.json 已安装环境指纹
├── install.log 安装日志
└── server.log 带 Job ID 的任务主线与 Server 日志
```
更改 Storage Root 相当于切换整套 Environment、模型和任务数据。目录说明见[模型、设备与存储](models-and-storage.md)。
## 模块关系
| 模块 | 职责 |
| --- | --- |
| `runtime.py` | Environment、Server 生命周期、任务状态与 Storage Root |
| `server/jobs/` | 按功能编排完整任务 |
| `server/media/` | 图片、视频和序列输入 |
| `server/models/` | ONNX Session 与模型 Prediction |
| `server/geometry/` | 投影、网格和 Surface 数据转换 |
| `server/outputs/` | NPZ、EXR、PNG 与序列文件 |
| `server/model_catalog.py` | Blender UI 与 Server 共用的模型声明 |
调用方向从 Job 指向媒体、模型、几何和输出模块。模型层返回内存数据,输出层管理文件,Blender 数据始终由 Operator 的 Response 创建。
## Environment 与模型
Environment 使用 Python 3.12、ONNX Runtime、NumPy、Pillow、PyAV、trimesh、FastAPI 和 Uvicorn。Windows 使用 DirectML 或 CPU;其他平台根据支持情况使用 CoreML、OpenVINO 或 CPU。
Cutout 的 `generate-mesh` Job 会把套索或 BEN2 Alpha 收紧为裁切范围,同时分别写出未乘选区 Alpha 的 Color、几何 selection 与局部分析图。Cutout 和 Balloon 只在启用 Normal 时运行 MoGe-2;Depth Balloon 返回浮点深度分析,Depth Surface 返回深度场。Blender 主线程统一创建单面基础网格、计算 `o_balloon`、绑定内部深度图片并组装四个独立 Geometry Nodes;Cutout、Balloon 与 Depth Balloon 的 camera-axis normal 会在最终网格求值后烘成 tangent-space Image。
Depth Balloon 的基面拟合使用四轮稳健加权回归,并对细长选区中方差不足的短轴设置相对主轴的方差下限。该约束只处理病态方向,不会移除长轴上由多数有效深度点支持的趋势。Blender 会把回归平面的符号和半深度倍率转换到 Cutout 的局部 YZ 纸面坐标;Geometry Nodes 计算 `(Base Plane - 深度基值) × Depth Scale`,因此 `Depth Scale = 0` 会同时消除深度图和 Base Plane 对深度分支的影响。
Image Box 与 Lasso 的 `Refine Selection` 使用 `refine-image-mask` Job。它与标准 `remove-background` Job 共用单图 BEN2 函数;Server 在推理前清空 polygon 外的 RGBA,推理后再次应用几何 mask,并返回局部 Alpha 与源图 bounds。Line、Polyline 与 Perspective 不提交 Job。
所有 BEN2 调用都在公共模型入口用输入 Alpha 遮住透明 RGB。Remove Background、Depth Plane、Surface 与 Image Refine 的正式结果保留源 RGB,只采用 BEN2 Alpha;Cutout 的 Refine Selection 同样只消费 BEN2 Alpha。该边界既防止模型识别透明区隐藏内容,也避免把透明黑写回 Blender 后产生过滤黑边。
正式功能支持:
- MoGe-2 ViT-S、ViT-B、ViT-L Normal
- DA3 Small、DA3 Base
- BEN2 Base
- Real-ESRGAN General WDN x4v3、SAFMN Real x4、Real-ESRGAN x4plus
Upscale 使用上述三档 4× RGB 模型。正式 `upscale-image` Job 把结果写成 `upscale.0001.png` 起的序列并回写为 `_upscale.png` Image 数据块;对应 Debug Job 保留模型直通输出,便于横向比较。
首次安装会下载 MoGe-2 ViT-S Normal、BEN2 Base 与默认的 Real-ESRGAN General WDN x4v3。SAFMN Real x4 和 Real-ESRGAN x4plus 可在扩展偏好设置按需下载。模型保存在 `models/`,可由多个 Blender 版本共享。Server 会复用连续任务所需的同一 Session;切换模型族时释放旧 Session,推理资源异常时立即卸载当前高清 Session。
模型按目录声明的 Hugging Face 仓库下载,并以 OmooLab 镜像作为回退;未声明 Hugging Face 仓库的模型直接使用镜像。AnyImage 用同一个准备 Operator 封装 BlendJob 安装任务,并按状态显示 `Set Up AI Environment…` 或 `Download Required Models…`。
`upscale-image` 与 `debug-upscale` 共用模型推理。三个模型在模型层分块处理任意宽高输入;正式 Job 还应用输出上限、保留 Alpha 并导入 Blender。
## 任务生命周期
Server 在第一次提交 Job 或手动 Start 时启动。任务进入单 Worker FIFO Queue,同一时间执行一个推理、下载或媒体任务。
Job 被接受时,Runtime 创建 opaque ID 和对应目录。进度保存在 Server 内存中,Blender 定期轮询状态。取消会在下载分块或一次 ONNX 调用结束后的检查点生效。
`server.log` 记录带时间、Job ID 与 Job Type 的排队、开始、成功、取消和失败主线,以及 Server 自身异常。Handler 细节与完整 traceback 写入对应 `jobs/<job-id>/job.log`,因此可先从主线定位 Job ID,再查看单个任务。
Blender 正常退出或禁用扩展时会停止所属 Server;Server 也监视父进程,在异常退出后自行结束。
Environment 安装发生在 Server 启动前,使用 `install.log` 记录过程。安装完成后的模型下载是普通 Job,与推理共享队列、进度和取消机制。
## DA3 多帧限制
DA3 ONNX 的联合注意力显存随帧数和分辨率快速增长。Depth Plane 动画采用逐帧推理并复用 Session,使峰值显存接近单帧任务;代价是各帧不共享相机位姿与深度尺度。
DA3 Debug 保留联合推理,用于检查模型原始多帧行为。Server 会在预计注意力张量过大时提前报错,此时应减少帧数或 Resolution Scale。

View File

@@ -1,66 +0,0 @@
# Surface
Surface 从单张图片生成带透视关系的相机空间网格。它比 Depth Plane 更接近真实空间结构,适合需要匹配相机或继续编辑拓扑的场景。操作方法见[Surface 使用指南](guide-surface.md)。
## 数据流
```mermaid
flowchart LR
Image["单张 Image Empty"] --> Model["DA3 / MoGe-2"]
Model --> Builder["Surface Builder"]
Builder --> Mesh["mesh.npz"]
Mesh --> Blender["Blender 导入"]
Blender --> Nodes["Geometry Nodes 后处理"]
Nodes --> Result["Surface + Material"]
Blender --> Camera["可选匹配相机"]
```
模型提供深度、相机和有效区域,Surface Builder 将其转换为统一的相机空间网格。Blender 导入网格后,用 Geometry Nodes 完成清理、重拓扑、平滑和 UV 重投影。
## 模型数据
| 来源 | 用于建网格的数据 | 材质附加数据 |
| --- | --- | --- |
| DA3 | Depth、confidence、intrinsics | 原图颜色 |
| MoGe-2 | Metric depth、mask、intrinsics | 原图颜色与 normal |
两种模型共享同一 Surface Builder 和网格文件格式。启用 Remove Background 时,输入 Alpha 与 BEN2 Alpha 相乘后的结果会在建网格阶段移除背景三角面;这是几何裁剪,转换后关闭材质 Mask 不会恢复已删除的面。
## 源网格
`mesh.npz` 保存:
- camera-space `vertices` 与 `faces`
- 原图坐标对应的 `uv`
- `intrinsics` 与 `image_size`
- 用于整体尺度的 `reference_depth`
- Geometry Nodes 使用的深度、Mask、置信度或连续性属性
源网格保持模型空间信息,视觉清理集中在 Geometry Nodes 中完成。这样可以调整参数、比较结果,也可以 Apply 修改器后继续普通建模。
## Geometry Nodes
DA3 使用 `O DA3 Surface`,MoGe-2 使用 `O MoGe2 Surface`。两者采用相同的处理顺序:
1. 根据有效区域、深度连续性和可用置信度剔除不可靠面
2. 合并和修复拓扑
3. 清理小岛并平滑表面
4. 从源网格重新投影 UV
常用控制包括 Iterations、Resolution、Smooth、Depth Continuity、Confidence 和 Mask。节点资产由扩展复用,多个 Surface 不会各自复制一套节点图。
## 空间与相机
网格在 camera space 中生成,再通过源 Image Empty 的 Transform 放入场景。导入过程会匹配图片可见尺寸、宽高比、Offset 与主点位置。
启用 Create Matching Camera 时,AnyImage 根据模型 intrinsics 创建相机并使用相同坐标变换,使网格与相机视图保持一致。模型估计的焦距和主点决定透视关系;`reference_depth` 决定整体尺度。
## 材质与文件
颜色统一写为 `image.0001.*`,导入时沿用源 Image 名称,由 Blender 自动解决同名冲突。MoGe-2 还会输出 `normal.png`,作为 object-space normal 接入统一的 `O Image Layer`;材质会自动打开 Object Space,未提供 normal 的图片材质使用 `[0.5, 0.5, 1.0, 1.0]` 中性默认值。材质名称以源 Image datablock 为基名。
单图结果可以 Pack 到 `.blend`。模型原始结果与 `mesh.npz` 保留在 Job 目录中,便于调试和重新检查。
## 适用范围
Surface 面向单张图片。它生成可编辑的透视网格,但遮挡区域仍来自单视图估计,无法恢复图片中不可见的真实几何。视频和序列帧应使用 [Depth Plane](depth-plane.md)。

View File

@@ -1,25 +0,0 @@
# 高清放大
Upscale 用 ONNX 模型把 Image Empty 的图片放大 4 倍,不创建网格。它支持单图、Movie 和 Image Sequence,并保留源 Alpha。
## 使用
1. 在 AnyImage 扩展偏好设置中选择 **Default Upscale Model**。
2. 按需要设置 **Maximum Upscale Size**;默认 4096 px,最高 8192 px。
3. 选中 Image Empty,在右键 **AnyImage** 菜单中点击 **Upscale**。
较大的输入会先按比例缩小,使输出不超过尺寸和总像素上限。完成后,当前 Empty 会改用名称以 `_upscale.png` 结尾的结果图片。
## 模型选择
- **Real-ESRGAN General WDN x4v3**:默认模型,速度快,细节处理较保守。
- **SAFMN Real x4**:速度与细节较均衡。
- **Real-ESRGAN x4plus**:生成的细节更明显。
后两项可在扩展偏好设置中按需下载。
对高清结果再次执行 Upscale 时,AnyImage 会从最初的原图重新生成并替换当前结果,不会连续放大已有高清图。
把高清结果继续转换为 Depth Plane、Surface 或 Cutout 时,颜色材质仍使用当前高清图;深度与法线推理会读取保存的放大前原图,以减少计算量并避免凭空增加不必要的几何细节。
如果显存或内存不足,请降低 **Maximum Upscale Size**,或改用 CPU。

View File

@@ -1,46 +0,0 @@
# 选择转换方式
AnyImage 提供多种日常工作流。选择结果时,先考虑你需要的是普通图片平面、可调节的“深度面片”、带透视结构的“空间网格”、透明前景,还是 4× 高清图片。
| 工作流 | 适合场景 | 输入 | 结果 | 主要优点 |
| --- | --- | --- | --- | --- |
| Mesh Plane | 广告牌、图片卡片、背景板、不需要 AI 深度的素材 | 图片、视频、序列帧 | 带材质和 Geometry Nodes 的普通平面 | 无需 AI,Thickness 与 Subdivide 可随时调整 |
| Depth Plane | 人物、产品、插画、动态背景、快速场景搭建 | 图片、视频、序列帧 | 带深度位移的平面 | 快速、可动画、Geometry Nodes 参数直观 |
| Surface | 建筑、室内、街景、需要匹配视角的单图 | 单张图片 | 透视纹理网格 | 空间感更强,可创建匹配相机 |
| Remove Background | 抠出人物或物体,继续作为 Image Empty 使用 | 图片、视频、序列帧 | 带透明 Alpha 的颜色图 | 不创建网格,保留 Empty 的位置和显示设置 |
| Upscale | 摄影、插画、二次元等素材的 4× 高清放大 | 图片、视频、序列帧 | 4× RGB 颜色图 | 三档 ONNX 模型可选,直接替换 Empty 图片 |
| Image Box / Lasso / Line / Polyline | 用几何选区直接清理图片 | 静态图片 | 原位更新的透明裁切图 | `F` 反选,可用 BEN2 细化选区 |
| Image Perspective | 海报、屏幕、门窗等四边形内容的透视矫正裁切 | 静态图片 | 原位更新的矩形图片 | 四点任意顺序,交互调整比例 |
## 什么时候选择 Mesh Plane
只需要图片网格、不希望估算或使用深度时,选择 **Convert to Mesh Plane**。它保留图片比例与对象 Transform,并把 Thickness 和 Subdivide 留在 Geometry Nodes 中;Thickness 为 0 时是单面。
[阅读 Mesh Plane 指南](mesh-plane.md)
## 什么时候选择 Depth Plane
Depth Plane 把估算深度作为面片位移,特别适合需要快速获得层次感、又希望继续用少量参数控制结果的场景。它也支持视频和序列帧,是制作动态立体素材的首选。
[阅读 Depth Plane 指南](guide-depth-plane.md)
## 什么时候选择 Surface
Surface 会根据模型估算的相机和深度构建透视网格。相较 Depth Plane,它能更自然地表达近大远小和画面内部的空间关系,但目前只支持单张图片。
[阅读 Surface 指南](guide-surface.md)
## 什么时候只去除背景
如果只想把 Image Empty 换成透明前景图,不需要任何几何结构,直接选择 **Remove Background**。对象的 Transform、显示尺寸和 Offset 会保留。
[阅读去除背景指南](remove-background.md)
## 什么时候选择 Upscale
需要增加像素尺寸而不创建网格时,选择 **Upscale**。它支持单图、Movie 和 Image Sequence;模型选择、输出限制与重复执行行为见[高清放大指南](upscale.md)。
## 什么时候使用 Image Perspective
当图片中的矩形物体因为透视变成四边形,并且希望得到一张正视矩形裁切图时,使用 3D Viewport 工具栏中的 **Image Perspective**。它只把四点当作图片裁切与 Homography 输入,不假设当前 View 的焦距与原图拍摄相机一致。四点顺序不影响输出方向;结果 Empty 保持源图 Transform,并按源图中的选区范围近似更新显示位置与尺寸。

View File

@@ -10,29 +10,42 @@ nav:
- AnyImage:
- index.md
- 开始使用:
- 安装与首次配置: installation.md
- 五分钟上手: quick-start.md
- 粘贴剪贴板图片: clipboard-image.md
- 安装与首次配置: getting-started/installation.md
- 五分钟上手: getting-started/quick-start.md
- 粘贴剪贴板图片: guides/clipboard-image.md
- 使用指南:
- 选择转换方式: workflows.md
- Image 编辑工具: image-tools.md
- Cutout: cutout.md
- Mesh Plane: mesh-plane.md
- Depth Plane: guide-depth-plane.md
- Surface: guide-surface.md
- 去除背景: remove-background.md
- 高清放大: upscale.md
- 视频与序列帧: animation.md
- 模型、设备与存储: models-and-storage.md
- 常见问题: faq.md
- 参考与开发:
- 功能参考: features.md
- Image 工具技术实现: image-tools-technical.md
- Depth Plane 实现: depth-plane.md
- Surface 实现: surface.md
- 输出格式: outputs.md
- Job Server: server.md
- 程序设计与开发: development.md
- 选择工作流: guides/workflows.md
- Image 编辑工具: guides/image-tools.md
- Cutout: guides/cutout.md
- Plane: guides/mesh-plane.md
- Depth Plane: guides/depth-plane.md
- 去除背景: guides/remove-background.md
- 高清放大: guides/upscale.md
- 视频与序列帧: guides/animation.md
- 模型、设备与存储: guides/models-and-storage.md
- 常见问题: guides/faq.md
- 参考:
- 功能参考: reference/features.md
- 内部实现:
- 总览: internals/index.md
- 程序架构: internals/architecture.md
- 基础设施:
- Job Runtime 与 Server: internals/runtime.md
- Job 产物: internals/outputs.md
- Operators:
- 总览: internals/operators/index.md
- AI 环境管理: internals/operators/ai-setup.md
- Clipboard Image: internals/operators/clipboard-image.md
- Convert to Plane: internals/operators/convert-to-plane.md
- Cutout Tool: internals/operators/cutout-tool.md
- Debug Operator: internals/operators/debug.md
- Image Tool: internals/operators/image-tool.md
- Perspective Tool: internals/operators/perspective-tool.md
- Remove Background: internals/operators/remove-background.md
- Upscale: internals/operators/upscale.md
- 开发维护:
- 开发与发布: internals/development.md
- 节点资产: internals/node-assets.md
theme:
name: material

View File

@@ -105,6 +105,11 @@ def _build_extension(platform, wheels):
continue
if "__pycache__" in source_path.parts:
continue
if (
source_path.suffix.startswith(".blend")
and source_path.suffix != ".blend"
):
continue
relative_path = source_path.relative_to(SOURCE_DIR)
if relative_path.parts[0] == "wheels":
continue

View File

@@ -3,7 +3,7 @@ name = "anyimage"
version = "0.12.15"
description = "Fast image-guided structure building tools for Blender"
readme = "README.md"
requires-python = ">=3.11"
requires-python = ">=3.11,<3.12"
license = "GPL-3.0-or-later"
dependencies = ["blendjob==0.1.14"]

View File

@@ -1,17 +1,17 @@
import bpy
from . import clipboard_image
from .clipboard_image import CLASSES as CLIPBOARD_CLASSES
from .operators import clipboard_image
from .operators.clipboard_image import CLASSES as CLIPBOARD_CLASSES
from .menu import AnyImageImageMenu, draw_image_context_menu
from .operators import CLASSES as OPERATOR_CLASSES
from .operators.cutout import CutoutTool
from .operators.image_edit import (
from .operators.cutout_tool import CutoutTool
from .operators.image_tool import (
ImageBoxTool,
ImageLassoTool,
ImageLineTool,
ImagePerspectiveTool,
ImagePolylineTool,
)
from .operators.perspective_tool import ImagePerspectiveTool
from .panel import (
Ben2RemovalDebugPanel,
Da3OutputDebugPanel,

Binary file not shown.

View File

@@ -20,5 +20,6 @@ files = "Read source and clipboard images, and store environment data"
paths_exclude_pattern = [
"__pycache__/",
".*",
"*.blend[0-9]*",
"*.zip",
]

View File

@@ -1,6 +1,6 @@
import bpy
from .operators.image import is_image_empty
from .operators.common.image import is_image_empty
class AnyImageImageMenu(bpy.types.Menu):

View File

@@ -1,14 +1,19 @@
from .background import ProcessImageBackground, RemoveImageBackground
from .cutout import ActivateCutoutTool, SelectCutoutRegion
from .debug import DebugBen2, DebugDa3, DebugMoge2, DebugUpscale
from .image_edit import CLASSES as IMAGE_EDIT_CLASSES
from .mesh import (
from .remove_background import RemoveImageBackground, RunBackgroundRemoval
from .cutout_tool import (
ActivateCutoutTool,
ConvertCutoutShape,
GenerateCutoutShape,
SelectCutoutRegion,
)
from .debug import DebugBen2, DebugDa3, DebugMoge2, DebugUpscale
from .image_tool import CLASSES as IMAGE_TOOL_CLASSES
from .convert_to_plane import (
ConvertToDepthPlane,
ConvertToPlane,
GenerateMesh,
GenerateDepthPlane,
)
from .models import (
from .perspective_tool import CLASSES as PERSPECTIVE_CLASSES
from .ai_setup import (
ClearModels,
DownloadModel,
DownloadRequiredModels,
@@ -27,13 +32,15 @@ CLASSES = (
DebugMoge2,
DebugBen2,
DebugUpscale,
GenerateMesh,
GenerateDepthPlane,
GenerateCutoutShape,
SelectCutoutRegion,
ConvertCutoutShape,
ActivateCutoutTool,
ProcessImageBackground,
RunBackgroundRemoval,
ClearModels,
*IMAGE_EDIT_CLASSES,
*IMAGE_TOOL_CLASSES,
*PERSPECTIVE_CLASSES,
RemoveImageBackground,
UpscaleImage,
ConvertToPlane,

View File

@@ -1,5 +1,6 @@
import bpy
from ..preferences import ADDON_PACKAGE
from ..runtime import JobOperatorBase, runtime
from ..properties import (
REQUIRED_MODEL_KEYS,
@@ -7,7 +8,7 @@ from ..properties import (
model_download_size,
model_record,
)
from .common import ADDON_PACKAGE, HIDDEN, require_environment
from .common.operator import HIDDEN, require_environment
def format_download_size(size):

View File

@@ -145,7 +145,7 @@ def add_image_plane(
if width <= 0 or height <= 0:
raise PasteTargetError("The clipboard image has invalid dimensions")
from ..operators.mesh_plane import create_mesh_plane_object
from ..convert_to_plane.mesh_plane import create_mesh_plane_object
half_width, half_height = _plane_half_dimensions(width, height)
material = _create_image_material(image, shadeless)

View File

@@ -0,0 +1 @@
"""Shared Blender-side image, material, normal, and operator helpers."""

View File

@@ -4,6 +4,7 @@ import tempfile
from pathlib import Path
import bpy
DA3_PATCH_SIZE = 14
DA3_MIN_PROCESS_RES = 252
DA3_MAX_PROCESS_RES = 1008

View File

@@ -9,8 +9,8 @@ from ...properties import (
ai_setup_label,
ai_status,
)
from ..common import production_device, require_environment, require_model
from ..image import image_base_name, is_image_empty, plane_bounds
from .operator import production_device, require_environment, require_model
from .image import image_base_name, is_image_empty, plane_bounds
class ImageEditWarning(ValueError):

View File

@@ -3,42 +3,26 @@ import math
import bpy
from ...properties import (
refine_selection_property,
)
from ...runtime import JobOperatorBase
from .interaction import (
deserialize_matrix,
drawing_in_region,
hidden_string_property,
screen_path_to_image_pixels,
serialize_matrix,
)
from ..common import HIDDEN, cleanup_temporary_input, push_undo
from ..image import (
is_image_empty,
plane_bounds,
prepare_image_input,
remove_temporary_input,
video_total_frames,
)
from .common import (
ImageEditWarning,
from .image import is_image_empty, plane_bounds, video_total_frames
from .image_edit import (
active_view3d_tool_id,
apply_image_mask_settings,
crop_pixels_to_alpha,
dashed_line_segments,
draw_dashed_line,
draw_image_mask_operator_settings,
draw_polygon_fill,
image_edit_poll,
image_mask_refine_request,
polygon_bounds,
preview_fill_triangles,
replace_empty_image_pixels,
report_image_edit_exception,
resolve_image_edit_click,
)
from .image_interaction import (
deserialize_matrix,
drawing_in_region,
screen_path_to_image_pixels,
serialize_matrix,
)
GESTURE_TOOL_IDS = {
"BOX": "anyimage.image_box",
@@ -88,145 +72,6 @@ def append_lasso_point(path, point):
path.append(point)
return True
def points_inside_polygon(points, polygon):
import numpy as np
points = np.asarray(points, dtype=np.float64)
polygon = np.asarray(polygon, dtype=np.float64)
inside = np.zeros(len(points), dtype=bool)
previous = polygon[-1]
for current in polygon:
crosses = (current[1] > points[:, 1]) != (previous[1] > points[:, 1])
edge_x = (previous[0] - current[0]) * (points[:, 1] - current[1]) / (
previous[1] - current[1] + 1e-300
) + current[0]
inside ^= crosses & (points[:, 0] < edge_x)
previous = current
return inside
def polygon_mask(image_size, polygon, fill_rule="EVEN_ODD"):
import numpy as np
width, height = (int(value) for value in image_size)
if width <= 0 or height <= 0:
raise ValueError("The source image has no readable dimensions")
if fill_rule not in {"EVEN_ODD", "NONZERO"}:
raise ValueError(f"Unknown polygon fill rule: {fill_rule}")
left, top, right, bottom = polygon_bounds(polygon)
left = max(left, 0)
top = max(top, 0)
right = min(right, width)
bottom = min(bottom, height)
mask = np.zeros((height, width), dtype=bool)
if right <= left or bottom <= top:
return mask
points = np.asarray(polygon, dtype=np.float64)
x = np.arange(left, right, dtype=np.float64) + 0.5
for row_start in range(top, bottom, 128):
row_end = min(row_start + 128, bottom)
y = np.arange(row_start, row_end, dtype=np.float64) + 0.5
state = np.zeros(
(len(y), len(x)),
dtype=bool if fill_rule == "EVEN_ODD" else np.int16,
)
previous = points[-1]
for current in points:
crosses = (current[1] > y) != (previous[1] > y)
edge_x = (previous[0] - current[0]) * (y - current[1]) / (
previous[1] - current[1] + 1e-300
) + current[0]
selected = crosses[:, None] & (x[None, :] < edge_x[:, None])
if fill_rule == "EVEN_ODD":
state ^= selected
else:
winding = 1 if current[1] > previous[1] else -1
state += selected * winding
previous = current
mask[row_start:row_end, left:right] = state != 0
return mask
def refined_mask_from_pixels(mask_pixels, mask_size, bounds, source_size):
import numpy as np
mask_width, mask_height = (int(value) for value in mask_size)
source_width, source_height = (int(value) for value in source_size)
local = np.flipud(
np.asarray(mask_pixels, dtype=np.float32).reshape((mask_height, mask_width, 4))
)[:, :, 3]
result = np.zeros((source_height, source_width), dtype=np.float32)
left, top, right, bottom = (int(value) for value in bounds)
source_left = max(left, 0)
source_top = max(top, 0)
source_right = min(right, source_width)
source_bottom = min(bottom, source_height)
if source_right <= source_left or source_bottom <= source_top:
return result
result[source_top:source_bottom, source_left:source_right] = local[
source_top - top : source_bottom - top,
source_left - left : source_right - left,
]
return result
def edit_image_mask_pixels(source_pixels, source_size, selection_mask, invert=False):
"""Apply a mask while preserving source RGB, then tightly crop the canvas."""
import numpy as np
width, height = (int(value) for value in source_size)
rgba = np.flipud(
np.asarray(source_pixels, dtype=np.float32).reshape((height, width, 4))
)
mask = np.asarray(selection_mask, dtype=np.float32)
if mask.shape != (height, width):
raise ValueError("The refined selection does not match the source image")
mask = np.clip(mask, 0.0, 1.0)
if invert:
mask = 1.0 - mask
rgba[:, :, 3] *= mask
return crop_pixels_to_alpha(np.flipud(rgba).ravel(), (0, 0, width, height))
def edit_image_object(source_object, selection_mask, invert=False):
import numpy as np
image = source_object.data
source_size = tuple(image.size)
source_pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(source_pixels)
pixels, crop_bounds = edit_image_mask_pixels(
source_pixels, source_size, selection_mask, invert
)
left, top, right, bottom = crop_bounds
return replace_empty_image_pixels(
source_object,
pixels,
(right - left, bottom - top),
crop_bounds,
)
def selected_image_rgba(
source_object, polygon, invert=False, fill_rule="EVEN_ODD"
):
import numpy as np
image = source_object.data
width, height = (int(value) for value in image.size)
pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(pixels)
rgba = np.flipud(pixels.reshape((height, width, 4)))
selection = polygon_mask((width, height), polygon, fill_rule)
if invert:
selection = ~selection
rgba[:, :, 3] *= selection
if not np.any(rgba[:, :, 3] > 0.0):
raise ImageEditWarning("The selection contains no visible image pixels")
return rgba
def gesture_property():
return bpy.props.EnumProperty(
items=(
@@ -573,122 +418,3 @@ class ImageMaskGesture:
report_image_edit_exception(self, error)
self._finish(context)
return {"CANCELLED"}
def execute(self, context):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
self.report({"ERROR"}, "The source Image Empty is no longer available")
return {"CANCELLED"}
try:
polygon = json.loads(self.selection_data)
polygon_bounds(polygon)
if video_total_frames(source_object.data) > 1:
raise RuntimeError("Image tools only support a still image")
if self.refine_selection:
source_path, delete_input = prepare_image_input(
source_object.data, context.scene
)
result = bpy.ops.anyimage.refine_image_mask(
input_path=str(source_path),
delete_input=delete_input,
source_object_name=self.source_object_name,
selection_data=self.selection_data,
invert=self.invert,
)
if "CANCELLED" in result:
if delete_input:
remove_temporary_input(source_path)
return {"CANCELLED"}
return {"FINISHED"}
edit_image_object(
source_object,
polygon_mask(tuple(source_object.data.size), polygon),
self.invert,
)
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
return {"FINISHED"}
class EditImageMask(ImageMaskGesture, bpy.types.Operator):
bl_idname = "anyimage.edit_image_mask"
bl_label = "Edit Image Mask"
bl_description = "Edit and tightly crop an Image Empty with a gesture"
bl_options = {"REGISTER", "UNDO"}
source_object_name: hidden_string_property()
source_matrix_data: hidden_string_property()
selection_data: hidden_string_property()
refine_selection: refine_selection_property()
invert: bpy.props.BoolProperty(
name="Invert",
description="Keep the opposite side of the refined gesture mask",
default=False,
options={"SKIP_SAVE"},
)
gesture: gesture_property()
class RefineImageMask(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.refine_image_mask"
bl_label = "Refine Image Mask"
job_type = "refine-image-mask"
starting_message = "Refining image selection"
input_path: bpy.props.StringProperty(options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
source_object_name: hidden_string_property()
selection_data: hidden_string_property()
invert: bpy.props.BoolProperty(default=False, options=HIDDEN)
def request(self, _context):
return image_mask_refine_request(self.input_path, self.selection_data)
def response(self, _context, result):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty is no longer available")
import numpy as np
bounds = tuple(json.loads(result.file("bounds").read_text(encoding="utf-8")))
refined_image = bpy.data.images.load(
str(result.file("image")), check_existing=False
)
try:
refined_pixels = np.empty(len(refined_image.pixels), dtype=np.float32)
refined_image.pixels.foreach_get(refined_pixels)
mask = refined_mask_from_pixels(
refined_pixels,
tuple(refined_image.size),
bounds,
tuple(source_object.data.size),
)
finally:
bpy.data.images.remove(refined_image, do_unlink=True)
try:
edit_image_object(source_object, mask, self.invert)
except ImageEditWarning as error:
self.report({"WARNING"}, str(error))
return str(error)
push_undo("Edit Image Mask")
return "Image selection refined"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
class ActivateImageEditTool(bpy.types.Operator):
bl_idname = "anyimage.activate_image_edit_tool"
bl_label = "Image Edit Tool"
bl_description = "Activate the Image Box tool in the 3D Viewport"
bl_options = {"INTERNAL"}
@classmethod
def poll(cls, context):
return image_edit_poll(context)
def execute(self, _context):
return bpy.ops.wm.tool_set_by_id(
name="anyimage.image_box", space_type="VIEW_3D"
)

View File

@@ -0,0 +1,141 @@
from .image_edit import (
ImageEditWarning,
crop_pixels_to_alpha,
polygon_bounds,
replace_empty_image_pixels,
)
def polygon_mask(image_size, polygon, fill_rule="EVEN_ODD"):
import numpy as np
width, height = (int(value) for value in image_size)
if width <= 0 or height <= 0:
raise ValueError("The source image has no readable dimensions")
if fill_rule not in {"EVEN_ODD", "NONZERO"}:
raise ValueError(f"Unknown polygon fill rule: {fill_rule}")
left, top, right, bottom = polygon_bounds(polygon)
left = max(left, 0)
top = max(top, 0)
right = min(right, width)
bottom = min(bottom, height)
mask = np.zeros((height, width), dtype=bool)
if right <= left or bottom <= top:
return mask
points = np.asarray(polygon, dtype=np.float64)
x = np.arange(left, right, dtype=np.float64) + 0.5
for row_start in range(top, bottom, 128):
row_end = min(row_start + 128, bottom)
y = np.arange(row_start, row_end, dtype=np.float64) + 0.5
state = np.zeros(
(len(y), len(x)),
dtype=bool if fill_rule == "EVEN_ODD" else np.int16,
)
previous = points[-1]
for current in points:
crosses = (current[1] > y) != (previous[1] > y)
edge_x = (previous[0] - current[0]) * (y - current[1]) / (
previous[1] - current[1] + 1e-300
) + current[0]
selected = crosses[:, None] & (x[None, :] < edge_x[:, None])
if fill_rule == "EVEN_ODD":
state ^= selected
else:
winding = 1 if current[1] > previous[1] else -1
state += selected * winding
previous = current
mask[row_start:row_end, left:right] = state != 0
return mask
def refined_mask_from_pixels(mask_pixels, mask_size, bounds, source_size):
import numpy as np
mask_width, mask_height = (int(value) for value in mask_size)
source_width, source_height = (int(value) for value in source_size)
local = np.flipud(
np.asarray(mask_pixels, dtype=np.float32).reshape((mask_height, mask_width, 4))
)[:, :, 3]
result = np.zeros((source_height, source_width), dtype=np.float32)
left, top, right, bottom = (int(value) for value in bounds)
source_left = max(left, 0)
source_top = max(top, 0)
source_right = min(right, source_width)
source_bottom = min(bottom, source_height)
if source_right <= source_left or source_bottom <= source_top:
return result
result[source_top:source_bottom, source_left:source_right] = local[
source_top - top : source_bottom - top,
source_left - left : source_right - left,
]
return result
def visible_selection_mask(
source_alpha, selection_mask, invert=False, alpha_threshold=0.0
):
"""Restrict a selection to source pixels above an Alpha threshold."""
import numpy as np
alpha = np.asarray(source_alpha)
mask = np.asarray(selection_mask, dtype=np.float32)
if mask.shape != alpha.shape:
raise ValueError("The selection does not match the source image")
mask = np.clip(mask, 0.0, 1.0)
if invert:
mask = 1.0 - mask
return np.where(alpha > float(alpha_threshold), mask, 0.0)
def edit_image_mask_pixels(source_pixels, source_size, selection_mask, invert=False):
"""Apply a mask while preserving source RGB, then tightly crop the canvas."""
import numpy as np
width, height = (int(value) for value in source_size)
rgba = np.flipud(
np.asarray(source_pixels, dtype=np.float32).reshape((height, width, 4))
)
mask = visible_selection_mask(rgba[:, :, 3], selection_mask, invert)
rgba[:, :, 3] *= mask
return crop_pixels_to_alpha(np.flipud(rgba).ravel(), (0, 0, width, height))
def edit_image_object(source_object, selection_mask, invert=False):
import numpy as np
image = source_object.data
source_size = tuple(image.size)
source_pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(source_pixels)
pixels, crop_bounds = edit_image_mask_pixels(
source_pixels, source_size, selection_mask, invert
)
left, top, right, bottom = crop_bounds
return replace_empty_image_pixels(
source_object,
pixels,
(right - left, bottom - top),
crop_bounds,
)
def selected_image_rgba(
source_object, polygon, invert=False, fill_rule="EVEN_ODD"
):
import numpy as np
image = source_object.data
width, height = (int(value) for value in image.size)
pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(pixels)
rgba = np.flipud(pixels.reshape((height, width, 4)))
selection = visible_selection_mask(
rgba[:, :, 3],
polygon_mask((width, height), polygon, fill_rule),
invert,
)
rgba[:, :, 3] *= selection
if not np.any(rgba[:, :, 3] > 0.0):
raise ImageEditWarning("The selection contains no visible image pixels")
return rgba

View File

@@ -1,30 +1,16 @@
from pathlib import Path
import bpy
from .image import configure_image_user, is_animated_image
from .node_asset import load_shadeless_node_group
DISPLACEMENT_STRENGTH = 1.0
DISPLACEMENT_MIDLEVEL = 0.0
ORIGINAL_DISPLACEMENT_STRENGTH = 1.0
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"
DEPTH_SCALE_ATTRIBUTE = "o_depth_scale"
IMAGE_MATERIAL_NODE_GROUP_NAME = "O Image Layer"
IMAGE_MATERIAL_NODE_GROUP_VERSION = 6
SURFACE_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME
TEXTURE_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME
MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME
BALLOON_MATERIAL_NODE_GROUP_NAME = IMAGE_MATERIAL_NODE_GROUP_NAME
DEPTH_PLANE_MATERIAL_NODE_GROUP_NAME = "O DA3 Depth Plane Layer"
MOGE_MATERIAL_NODE_GROUP_NAME = "O MoGe2 Depth Plane Layer"
SHADELESS_NODE_GROUP_NAME = "O Shadeless"
DEPTH_MATERIAL_NODE_GROUP_NAME = "O Image Depth Layer"
VIEW_TRANSFORM_COLOR_SPACES = (
("ACES 1.3", "ACES 1.3 sRGB"),
("ACES 2.0", "ACES 2.0 sRGB"),
@@ -77,196 +63,14 @@ def set_material_color_space(image, scene=None):
return settings.name
def moge_material_node_group():
existing = bpy.data.node_groups.get(MOGE_MATERIAL_NODE_GROUP_NAME)
if existing is not None:
return existing
group = bpy.data.node_groups.new(
MOGE_MATERIAL_NODE_GROUP_NAME,
"ShaderNodeTree",
)
for name, socket_type in (
("Color", "NodeSocketColor"),
("Alpha", "NodeSocketFloat"),
("Depth", "NodeSocketFloat"),
("Bump Scale", "NodeSocketFloat"),
):
socket = group.interface.new_socket(
name=name,
in_out="INPUT",
socket_type=socket_type,
)
if name == "Alpha":
socket.default_value = 1.0
if name == "Bump Scale":
socket.default_value = 1.0
for name, socket_type in (
("Color", "NodeSocketColor"),
("Roughness", "NodeSocketFloat"),
("Alpha", "NodeSocketFloat"),
("Normal", "NodeSocketVector"),
("Displacement", "NodeSocketVector"),
):
group.interface.new_socket(
name=name,
in_out="OUTPUT",
socket_type=socket_type,
)
nodes = group.nodes
links = group.links
group_input = nodes.new("NodeGroupInput")
group_input.location = (-900.0, 100.0)
bump = nodes.new("ShaderNodeBump")
bump.location = (250.0, -100.0)
bump_distance = nodes.new("ShaderNodeMath")
bump_distance.operation = "MULTIPLY"
bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE
bump_distance.location = (0.0, -100.0)
background = nodes.new("ShaderNodeAttribute")
background.attribute_type = "GEOMETRY"
background.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE
background.location = (-650.0, -400.0)
material_mode = nodes.new("ShaderNodeAttribute")
material_mode.attribute_type = "GEOMETRY"
material_mode.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE
material_mode.location = (-650.0, -600.0)
strength = nodes.new("ShaderNodeAttribute")
strength.attribute_type = "GEOMETRY"
strength.attribute_name = DEPTH_SCALE_ATTRIBUTE
strength.location = (-400.0, -600.0)
clamp_depth = nodes.new("ShaderNodeMath")
clamp_depth.operation = "MAXIMUM"
clamp_depth.location = (-350.0, -350.0)
enable_depth = nodes.new("ShaderNodeMath")
enable_depth.operation = "MULTIPLY"
enable_depth.location = (-100.0, -350.0)
scale_depth = nodes.new("ShaderNodeMath")
scale_depth.operation = "MULTIPLY"
scale_depth.location = (150.0, -350.0)
displacement = nodes.new("ShaderNodeDisplacement")
displacement.inputs["Midlevel"].default_value = 0.0
displacement.inputs["Scale"].default_value = 1.0
displacement.location = (400.0, -350.0)
group_output = nodes.new("NodeGroupOutput")
group_output.location = (300.0, 100.0)
links.new(group_input.outputs["Color"], group_output.inputs["Color"])
links.new(group_input.outputs["Alpha"], group_output.inputs["Alpha"])
links.new(group_input.outputs["Color"], bump.inputs["Height"])
links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0])
links.new(bump_distance.outputs[0], bump.inputs["Distance"])
links.new(bump.outputs["Normal"], group_output.inputs["Normal"])
links.new(group_input.outputs["Depth"], clamp_depth.inputs[0])
links.new(background.outputs["Fac"], clamp_depth.inputs[1])
links.new(clamp_depth.outputs[0], enable_depth.inputs[0])
links.new(material_mode.outputs["Fac"], enable_depth.inputs[1])
links.new(enable_depth.outputs[0], scale_depth.inputs[0])
links.new(strength.outputs["Fac"], scale_depth.inputs[1])
links.new(scale_depth.outputs[0], displacement.inputs["Height"])
links.new(displacement.outputs["Displacement"], group_output.inputs["Displacement"])
group_output.inputs["Roughness"].default_value = 0.5
group.color_tag = "SHADER"
group.description = "Apply MoGe-2 color and derive bump detail from it."
group.use_fake_user = True
return group
def create_moge_material(
name,
color_image,
depth_image,
depth_scale,
shadeless=False,
frame_count=1,
frame_start=1,
use_cyclic=False,
scene=None,
):
material = bpy.data.materials.new(name)
material.use_nodes = True
if hasattr(material, "surface_render_method"):
material.surface_render_method = "DITHERED"
nodes = material.node_tree.nodes
nodes.clear()
links = material.node_tree.links
uv = nodes.new("ShaderNodeUVMap")
uv.uv_map = "UVMap"
uv.location = (-900.0, 200.0)
color = nodes.new("ShaderNodeTexImage")
set_material_color_space(color_image, scene)
color.image = color_image
color.interpolation = "Linear"
color.extension = "EXTEND"
color.location = (-650.0, 300.0)
depth = nodes.new("ShaderNodeTexImage")
depth.image = depth_image
depth.interpolation = "Linear"
depth.extension = "EXTEND"
depth.location = (-650.0, -100.0)
if frame_count > 1:
configure_image_user(
color.image_user,
frame_count,
frame_start=frame_start,
use_cyclic=use_cyclic,
)
configure_image_user(
depth.image_user,
frame_count,
frame_start=frame_start,
use_cyclic=use_cyclic,
)
layer = nodes.new("ShaderNodeGroup")
layer.node_tree = moge_material_node_group()
layer.name = MOGE_MATERIAL_NODE_GROUP_NAME
layer.location = (-300.0, 200.0)
output = nodes.new("ShaderNodeOutputMaterial")
output.location = (500.0, 200.0)
links.new(uv.outputs["UV"], color.inputs["Vector"])
links.new(uv.outputs["UV"], depth.inputs["Vector"])
links.new(color.outputs["Color"], layer.inputs["Color"])
links.new(color.outputs["Alpha"], layer.inputs["Alpha"])
links.new(depth.outputs["Color"], layer.inputs["Depth"])
layer.inputs["Bump Scale"].default_value = depth_scale
if shadeless:
shader = nodes.new("ShaderNodeGroup")
shader.node_tree = load_shadeless_node_group()
shader.name = SHADELESS_NODE_GROUP_NAME
transparent = nodes.new("ShaderNodeBsdfTransparent")
mix = nodes.new("ShaderNodeMixShader")
links.new(layer.outputs["Color"], shader.inputs["Color"])
links.new(layer.outputs["Alpha"], mix.inputs["Fac"])
links.new(transparent.outputs["BSDF"], mix.inputs[1])
links.new(shader.outputs["Shader"], mix.inputs[2])
links.new(mix.outputs["Shader"], output.inputs["Surface"])
return material
shader = nodes.new("ShaderNodeBsdfPrincipled")
links.new(layer.outputs["Color"], shader.inputs["Base Color"])
links.new(layer.outputs["Color"], shader.inputs["Subsurface Radius"])
links.new(layer.outputs["Color"], shader.inputs["Emission Color"])
links.new(layer.outputs["Alpha"], shader.inputs["Alpha"])
links.new(layer.outputs["Roughness"], shader.inputs["Roughness"])
links.new(layer.outputs["Normal"], shader.inputs["Normal"])
links.new(layer.outputs["Normal"], shader.inputs["Coat Normal"])
links.new(shader.outputs["BSDF"], output.inputs["Surface"])
links.new(layer.outputs["Displacement"], output.inputs["Displacement"])
set_displacement_only(material)
return material
def material_node_group(depth_plane=False, surface_group_name=None):
def material_node_group(depth_plane=False):
group_name = (
DEPTH_PLANE_MATERIAL_NODE_GROUP_NAME
DEPTH_MATERIAL_NODE_GROUP_NAME
if depth_plane
else surface_group_name or SURFACE_MATERIAL_NODE_GROUP_NAME
else IMAGE_MATERIAL_NODE_GROUP_NAME
)
existing = bpy.data.node_groups.get(group_name)
rebuild_image_layer = (
existing is not None
and group_name == IMAGE_MATERIAL_NODE_GROUP_NAME
and existing.get("anyimage_image_layer_version")
!= IMAGE_MATERIAL_NODE_GROUP_VERSION
)
if existing is not None and not rebuild_image_layer:
if existing is not None:
for item in existing.interface.items_tree:
if (
item.item_type == "SOCKET"
@@ -275,12 +79,7 @@ def material_node_group(depth_plane=False, surface_group_name=None):
):
item.default_value = (0.5, 0.5, 1.0, 1.0)
return existing
if existing is None:
group = bpy.data.node_groups.new(group_name, "ShaderNodeTree")
else:
group = existing
group.nodes.clear()
group.interface.clear()
interface = group.interface
input_sockets = [
("Color", "NodeSocketColor"),
@@ -444,13 +243,11 @@ def material_node_group(depth_plane=False, surface_group_name=None):
)
group.color_tag = "SHADER"
group.description = (
"Prepare DA3 depth-plane material outputs."
"Prepare image depth material outputs."
if depth_plane
else f"Prepare {group_name} material outputs."
)
group.use_fake_user = True
if group_name == IMAGE_MATERIAL_NODE_GROUP_NAME:
group["anyimage_image_layer_version"] = IMAGE_MATERIAL_NODE_GROUP_VERSION
return group
@@ -469,7 +266,6 @@ def create_material(
depth_frame_offset=0,
depth_frame_duration=None,
use_cyclic=None,
surface_group_name=None,
normal_image=None,
normal_space="TANGENT",
scene=None,
@@ -517,10 +313,7 @@ def create_material(
alpha = color_texture.outputs["Alpha"]
material_inputs = nodes.new("ShaderNodeGroup")
is_depth_plane = displacement_image is not None
material_inputs.node_tree = material_node_group(
is_depth_plane,
surface_group_name=surface_group_name,
)
material_inputs.node_tree = material_node_group(is_depth_plane)
material_inputs.location = (-300.0, 350.0)
material_inputs.inputs["Bump Scale"].default_value = depth_scale
if "Object Space" in material_inputs.inputs:
@@ -641,116 +434,3 @@ def set_displacement_only(material):
if owner is not None and hasattr(owner, property_name):
setattr(owner, property_name, "DISPLACEMENT")
return
def node_asset_path():
return Path(__file__).resolve().parents[1] / "assets" / NODE_ASSET_NAME
def load_asset_node_group(group_name):
existing = bpy.data.node_groups.get(group_name)
if existing is not None:
for item in existing.interface.items_tree:
if (
item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Normal"
):
item.default_value = (0.5, 0.5, 1.0, 1.0)
for node in existing.nodes:
if node.bl_idname == "ShaderNodeNormalMap":
node.space = "TANGENT"
return existing
asset_path = node_asset_path()
if not asset_path.is_file():
raise RuntimeError(f"Missing node asset: {asset_path}")
use_pack = bpy.app.version >= (5, 0, 0)
if not use_pack:
directory = str(asset_path) + "/NodeTree/"
result = bpy.ops.wm.append(
filepath=directory + group_name,
directory=directory,
filename=group_name,
link=False,
do_reuse_local_id=True,
)
if "FINISHED" not in result:
raise RuntimeError("Unable to append the node asset")
node_group = bpy.data.node_groups.get(group_name)
if node_group is None:
raise RuntimeError("Appended node asset is unavailable")
node_group.use_fake_user = True
return node_group
with bpy.data.libraries.load(str(asset_path), link=True) as (source, target):
if group_name not in source.node_groups:
raise RuntimeError(f"Node asset is missing {group_name}")
target.node_groups = [group_name]
linked_group = target.node_groups[0]
node_group = bpy.data.pack_linked_ids_hierarchy(root_id=linked_group)
if linked_group != node_group and linked_group.users == 0:
bpy.data.node_groups.remove(linked_group)
node_group.use_fake_user = True
return node_group
def load_shadeless_node_group():
return load_asset_node_group(SHADELESS_NODE_GROUP_NAME)
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:
if item.item_type != "SOCKET" or item.in_out != "INPUT":
continue
if item.name == name:
return item.identifier
raise RuntimeError(f"Geometry Nodes input is missing: {name}")
def modifier_input_slots(modifier):
"""Return the runtime input collection, or None for the legacy API."""
properties = getattr(modifier, "properties", None)
if properties is None:
return None
return getattr(properties, "inputs", None)
def set_modifier_input(modifier, identifier, value):
"""Assign a value to a Geometry Nodes modifier input socket."""
inputs = modifier_input_slots(modifier)
if inputs is not None:
# Blender 5.2+ exposes inputs as runtime RNA keyed by socket identifier.
getattr(inputs, identifier).value = value
else:
# Blender 4.x exposes inputs as modifier ID properties.
modifier[identifier] = value
def get_modifier_input(modifier, identifier):
"""Read a Geometry Nodes modifier input socket value."""
inputs = modifier_input_slots(modifier)
if inputs is not None:
return getattr(inputs, identifier).value
return modifier[identifier]
def modifier_input_driver_path(modifier, identifier):
"""Return the driver data path for a modifier input socket."""
if modifier_input_slots(modifier) is not None:
return f'modifiers["{modifier.name}"].properties.inputs.{identifier}.value'
return f'modifiers["{modifier.name}"]["{identifier}"]'
def drive_modifier_input(owner_object, modifier, identifier, expression="frame"):
"""Add a scripted driver to a modifier input so it follows the scene frame."""
driver = owner_object.driver_add(modifier_input_driver_path(modifier, identifier))
driver.driver.type = "SCRIPTED"
driver.driver.expression = expression
return driver

View File

@@ -0,0 +1,119 @@
from pathlib import Path
import bpy
NODE_ASSET_NAME = "O_AnyImage.blend"
MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane"
SHADELESS_NODE_GROUP_NAME = "O Shadeless"
def node_asset_path():
return Path(__file__).resolve().parents[2] / "assets" / NODE_ASSET_NAME
def load_asset_node_group(group_name):
existing = bpy.data.node_groups.get(group_name)
if existing is not None:
for item in existing.interface.items_tree:
if (
item.item_type == "SOCKET"
and item.in_out == "INPUT"
and item.name == "Normal"
):
item.default_value = (0.5, 0.5, 1.0, 1.0)
for node in existing.nodes:
if node.bl_idname == "ShaderNodeNormalMap":
node.space = "TANGENT"
return existing
asset_path = node_asset_path()
if not asset_path.is_file():
raise RuntimeError(f"Missing node asset: {asset_path}")
if bpy.app.version < (5, 0, 0):
directory = str(asset_path) + "/NodeTree/"
result = bpy.ops.wm.append(
filepath=directory + group_name,
directory=directory,
filename=group_name,
link=False,
do_reuse_local_id=True,
)
if "FINISHED" not in result:
raise RuntimeError("Unable to append the node asset")
node_group = bpy.data.node_groups.get(group_name)
if node_group is None:
raise RuntimeError("Appended node asset is unavailable")
node_group.use_fake_user = True
return node_group
with bpy.data.libraries.load(str(asset_path), link=True) as (source, target):
if group_name not in source.node_groups:
raise RuntimeError(f"Node asset is missing {group_name}")
target.node_groups = [group_name]
linked_group = target.node_groups[0]
node_group = bpy.data.pack_linked_ids_hierarchy(root_id=linked_group)
if linked_group != node_group and linked_group.users == 0:
bpy.data.node_groups.remove(linked_group)
node_group.use_fake_user = True
return node_group
def load_shadeless_node_group():
return load_asset_node_group(SHADELESS_NODE_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:
if item.item_type != "SOCKET" or item.in_out != "INPUT":
continue
if item.name == name:
return item.identifier
raise RuntimeError(f"Geometry Nodes input is missing: {name}")
def modifier_input_slots(modifier):
"""Return the runtime input collection, or None for the legacy API."""
properties = getattr(modifier, "properties", None)
if properties is None:
return None
return getattr(properties, "inputs", None)
def set_modifier_input(modifier, identifier, value):
"""Assign a value to a Geometry Nodes modifier input socket."""
inputs = modifier_input_slots(modifier)
if inputs is not None:
# Blender 5.2+ exposes inputs as runtime RNA keyed by socket identifier.
getattr(inputs, identifier).value = value
else:
# Blender 4.x exposes inputs as modifier ID properties.
modifier[identifier] = value
def get_modifier_input(modifier, identifier):
"""Read a Geometry Nodes modifier input socket value."""
inputs = modifier_input_slots(modifier)
if inputs is not None:
return getattr(inputs, identifier).value
return modifier[identifier]
def modifier_input_driver_path(modifier, identifier):
"""Return the driver data path for a modifier input socket."""
if modifier_input_slots(modifier) is not None:
return f'modifiers["{modifier.name}"].properties.inputs.{identifier}.value'
return f'modifiers["{modifier.name}"]["{identifier}"]'
def drive_modifier_input(owner_object, modifier, identifier, expression="frame"):
"""Add a scripted driver to a modifier input so it follows the scene frame."""
path = modifier_input_driver_path(modifier, identifier)
driver = owner_object.driver_add(path)
driver.driver.type = "SCRIPTED"
driver.driver.expression = expression
return driver

View File

@@ -0,0 +1,192 @@
"""Shared Geometry Nodes construction helpers."""
def interface_socket(group, name, in_out, socket_type):
socket = group.interface.new_socket(
name=name,
in_out=in_out,
socket_type=socket_type,
)
if in_out == "INPUT" and hasattr(socket, "structure_type"):
socket.structure_type = "SINGLE"
return socket
def group_input(nodes, location, visible_names):
node = nodes.new("NodeGroupInput")
node.location = location
visible = set(visible_names)
for output in node.outputs:
output.hide = output.name not in visible
return node
def edge_boundary_field(nodes, links, location):
x, y = location
vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors")
vertex_neighbors.location = (x, y + 150.0)
loose_vertex = nodes.new("FunctionNodeCompare")
loose_vertex.data_type = "INT"
loose_vertex.operation = "EQUAL"
loose_vertex_a = next(
socket for socket in loose_vertex.inputs if socket.identifier == "A_INT"
)
loose_vertex_b = next(
socket for socket in loose_vertex.inputs if socket.identifier == "B_INT"
)
loose_vertex_b.default_value = 1
loose_vertex.location = (x + 200.0, y + 150.0)
links.new(vertex_neighbors.outputs["Vertex Count"], loose_vertex_a)
edge_neighbors = nodes.new("GeometryNodeInputMeshEdgeNeighbors")
edge_neighbors.location = (x, y - 150.0)
boundary_edge = nodes.new("FunctionNodeCompare")
boundary_edge.data_type = "INT"
boundary_edge.operation = "EQUAL"
boundary_edge_a = next(
socket for socket in boundary_edge.inputs if socket.identifier == "A_INT"
)
boundary_edge_b = next(
socket for socket in boundary_edge.inputs if socket.identifier == "B_INT"
)
boundary_edge_b.default_value = 1
boundary_edge.location = (x + 200.0, y - 150.0)
links.new(edge_neighbors.outputs["Face Count"], boundary_edge_a)
edge_domain = nodes.new("GeometryNodeFieldOnDomain")
edge_domain.data_type = "BOOLEAN"
edge_domain.domain = "EDGE"
edge_domain.location = (x + 400.0, y - 150.0)
links.new(boundary_edge.outputs["Result"], edge_domain.inputs["Value"])
is_boundary = nodes.new("FunctionNodeBooleanMath")
is_boundary.operation = "OR"
is_boundary.location = (x + 600.0, y)
links.new(edge_domain.outputs["Value"], is_boundary.inputs[0])
links.new(loose_vertex.outputs["Result"], is_boundary.inputs[1])
return is_boundary.outputs["Boolean"]
def expand_smooth(
group, geometry, x_offset=0.0, iterations_name="Iterations"
):
nodes = group.nodes
links = group.links
existing_nodes = set(nodes)
iteration_controls = group_input(nodes, (550.0, 1150.0), {iterations_name})
weight_controls = group_input(nodes, (2300.0, 1450.0), {"Smooth"})
repeat_output = nodes.new("GeometryNodeRepeatOutput")
repeat_output.location = (3050.0, 550.0)
repeat_input = nodes.new("GeometryNodeRepeatInput")
repeat_input.pair_with_output(repeat_output)
repeat_output.repeat_items.new("GEOMETRY", "Geometry")
repeat_input.location = (550.0, 550.0)
links.new(
iteration_controls.outputs[iterations_name],
repeat_input.inputs["Iterations"],
)
links.new(geometry, repeat_input.inputs["Geometry"])
boundary_field = edge_boundary_field(nodes, links, (750.0, -350.0))
boundary_points = nodes.new("GeometryNodeFieldOnDomain")
boundary_points.data_type = "BOOLEAN"
boundary_points.domain = "POINT"
boundary_points.location = (1550.0, -350.0)
links.new(boundary_field, boundary_points.inputs["Value"])
boundary_geometry = nodes.new("GeometryNodeSeparateGeometry")
boundary_geometry.domain = "POINT"
boundary_geometry.location = (1750.0, -150.0)
links.new(repeat_input.outputs["Geometry"], boundary_geometry.inputs["Geometry"])
links.new(boundary_points.outputs["Value"], boundary_geometry.inputs["Selection"])
position = nodes.new("GeometryNodeInputPosition")
position.location = (750.0, 850.0)
smooth_all = nodes.new("GeometryNodeBlurAttribute")
smooth_all.data_type = "FLOAT_VECTOR"
smooth_all.inputs["Iterations"].default_value = 1
smooth_all.inputs["Weight"].default_value = 1.0
smooth_all.location = (950.0, 850.0)
links.new(position.outputs["Position"], smooth_all.inputs["Value"])
boundary_position = nodes.new("GeometryNodeInputPosition")
boundary_position.location = (1350.0, 250.0)
smooth_boundary = nodes.new("GeometryNodeBlurAttribute")
smooth_boundary.data_type = "FLOAT_VECTOR"
smooth_boundary.inputs["Iterations"].default_value = 1
smooth_boundary.inputs["Weight"].default_value = 0.5
smooth_boundary.location = (1550.0, 250.0)
links.new(boundary_position.outputs["Position"], smooth_boundary.inputs["Value"])
nearest_boundary = nodes.new("GeometryNodeSampleNearest")
nearest_boundary.domain = "POINT"
nearest_boundary.location = (1800.0, 150.0)
links.new(boundary_geometry.outputs["Selection"], nearest_boundary.inputs["Geometry"])
sample_boundary = nodes.new("GeometryNodeSampleIndex")
sample_boundary.data_type = "FLOAT_VECTOR"
sample_boundary.domain = "POINT"
sample_boundary.clamp = False
sample_boundary.location = (2050.0, 250.0)
links.new(boundary_geometry.outputs["Selection"], sample_boundary.inputs["Geometry"])
links.new(smooth_boundary.outputs["Value"], sample_boundary.inputs["Value"])
links.new(nearest_boundary.outputs["Index"], sample_boundary.inputs["Index"])
choose_boundary = nodes.new("GeometryNodeSwitch")
choose_boundary.input_type = "VECTOR"
choose_boundary.location = (2300.0, 350.0)
direct_boundary = edge_boundary_field(nodes, links, (750.0, 450.0))
links.new(direct_boundary, choose_boundary.inputs["Switch"])
links.new(smooth_all.outputs["Value"], choose_boundary.inputs["False"])
links.new(sample_boundary.outputs["Value"], choose_boundary.inputs["True"])
boundary_mix = nodes.new("ShaderNodeMix")
boundary_mix.data_type = "VECTOR"
boundary_mix.inputs[0].default_value = 1.0
boundary_mix.location = (2550.0, 400.0)
links.new(smooth_all.outputs["Value"], boundary_mix.inputs[4])
links.new(choose_boundary.outputs["Output"], boundary_mix.inputs[5])
normal = nodes.new("GeometryNodeInputNormal")
normal.location = (1350.0, 1050.0)
smooth_normal = nodes.new("GeometryNodeBlurAttribute")
smooth_normal.data_type = "FLOAT_VECTOR"
smooth_normal.inputs["Iterations"].default_value = 10
smooth_normal.inputs["Weight"].default_value = 1.0
smooth_normal.location = (1550.0, 1050.0)
links.new(normal.outputs["Normal"], smooth_normal.inputs["Value"])
normal_coherence = nodes.new("ShaderNodeVectorMath")
normal_coherence.operation = "LENGTH"
normal_coherence.location = (1800.0, 1050.0)
links.new(smooth_normal.outputs["Value"], normal_coherence.inputs[0])
sharp_switch = nodes.new("GeometryNodeSwitch")
sharp_switch.input_type = "FLOAT"
sharp_switch.inputs["Switch"].default_value = True
sharp_switch.inputs["False"].default_value = 1.0
sharp_switch.location = (2050.0, 1050.0)
links.new(normal_coherence.outputs["Value"], sharp_switch.inputs["True"])
smooth_weight = nodes.new("ShaderNodeMath")
smooth_weight.operation = "MULTIPLY"
smooth_weight.location = (2300.0, 1050.0)
links.new(weight_controls.outputs["Smooth"], smooth_weight.inputs[0])
links.new(sharp_switch.outputs["Output"], smooth_weight.inputs[1])
target_position = nodes.new("GeometryNodeSwitch")
target_position.input_type = "VECTOR"
target_position.inputs["Switch"].default_value = True
target_position.location = (2550.0, 700.0)
links.new(smooth_all.outputs["Value"], target_position.inputs["False"])
links.new(boundary_mix.outputs["Result"], target_position.inputs["True"])
current_position = nodes.new("GeometryNodeInputPosition")
current_position.location = (2300.0, 850.0)
mix_position = nodes.new("ShaderNodeMix")
mix_position.data_type = "VECTOR"
mix_position.location = (2800.0, 850.0)
links.new(smooth_weight.outputs[0], mix_position.inputs[0])
links.new(current_position.outputs["Position"], mix_position.inputs[4])
links.new(target_position.outputs["Output"], mix_position.inputs[5])
set_position = nodes.new("GeometryNodeSetPosition")
set_position.location = (2800.0, 550.0)
links.new(repeat_input.outputs["Geometry"], set_position.inputs["Geometry"])
links.new(mix_position.outputs["Result"], set_position.inputs["Position"])
links.new(set_position.outputs["Geometry"], repeat_output.inputs["Geometry"])
for node in set(nodes) - existing_nodes:
node.location.x += x_offset
return repeat_output.outputs["Geometry"]

View File

@@ -3,11 +3,10 @@ from pathlib import Path
import bpy
from ...preferences import addon_preferences
from ...properties import ai_ready, model_ready, model_record
from ...runtime import runtime
from .image import extract_frame_range
from ..preferences import ADDON_PACKAGE, addon_preferences
from ..runtime import runtime
from ..properties import ai_ready, model_ready, model_record
BEN2_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
BEN2_VIDEO_SUFFIXES = {

View File

@@ -0,0 +1,13 @@
"""Mesh Plane and Depth Plane conversion operators."""
from .conversion import (
ConvertToDepthPlane,
ConvertToPlane,
GenerateDepthPlane,
)
__all__ = (
"ConvertToDepthPlane",
"ConvertToPlane",
"GenerateDepthPlane",
)

View File

@@ -0,0 +1,209 @@
import bpy
from ..common.image import (
color_data_name,
create_color_image,
depth_data_name,
is_image_empty,
material_data_name,
normal_data_name,
plane_bounds,
)
from ..common.material import create_material
from ..common.node_asset import modifier_input_identifier, set_modifier_input
from ..common.normal_map import bake_tangent_normal_image
from ..common.operator import push_undo
from .mesh_plane import create_mesh_plane_mesh, create_mesh_plane_object
from .nodes import depth_plane_node_group
def copy_color_image(source_object):
source = source_object.data
image = source.copy()
if getattr(source, "source", None) == "GENERATED" or getattr(
source, "is_dirty", False
):
import numpy as np
pixels = np.empty(len(source.pixels), dtype=np.float32)
source.pixels.foreach_get(pixels)
image.pixels.foreach_set(pixels)
image.update()
image.name = color_data_name(source_object)
return image
def build_mesh_plane(context, source_object, subdivisions, material):
source_name = source_object.name
plane = create_mesh_plane_object(
context,
source_name,
plane_bounds(source_object),
material,
matrix_world=source_object.matrix_world,
subdivisions=subdivisions,
)
for polygon in plane.data.polygons:
polygon.use_smooth = True
plane["anyimage_mesh_shape"] = "PLANE"
for selected in context.selected_objects:
selected.select_set(False)
plane.select_set(True)
context.view_layer.objects.active = plane
bpy.data.objects.remove(source_object, do_unlink=True)
plane.name = source_name
return plane
def load_depth_field(path):
import numpy as np
with np.load(path, allow_pickle=False) as archive:
points = np.asarray(archive["points"], dtype=np.float32)
intrinsics = np.asarray(archive["intrinsics"], dtype=np.float32)
valid = np.asarray(archive["valid"], dtype=bool)
calibration_valid = np.asarray(archive["calibration_valid"], dtype=bool)
image_size = tuple(int(value) for value in archive["image_size"])
reference_depth = float(archive["reference_depth"])
if (
points.ndim != 3
or points.shape[2] != 3
or valid.shape != points.shape[:2]
or calibration_valid.shape != points.shape[:2]
):
raise ValueError("The mesh point field is invalid")
if intrinsics.shape != (3, 3) or not reference_depth > 0.0:
raise ValueError("The mesh camera field is invalid")
return points, intrinsics, valid, calibration_valid, image_size, reference_depth
def anchored_point_field(source_object, field):
import numpy as np
points, intrinsics, valid, _calibration, image_size, reference_depth = field
width, height = image_size
x_min, x_max, y_min, y_max = plane_bounds(source_object)
plane_width = x_max - x_min
plane_height = y_max - y_min
fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1])
cx, cy = float(intrinsics[0, 2]), float(intrinsics[1, 2])
if fx <= 0.0 or fy <= 0.0:
raise ValueError("Camera intrinsics contain an invalid focal length")
scale_x = plane_width * fx / (max(width - 1, 1) * reference_depth)
scale_y = plane_height * fy / (max(height - 1, 1) * reference_depth)
scale_z = float(np.sqrt(scale_x * scale_y))
camera_x = x_min + plane_width * cx / max(width - 1, 1)
camera_y = y_max - plane_height * cy / max(height - 1, 1)
anchored = np.empty_like(points, dtype=np.float32)
anchored[..., 0] = camera_x + points[..., 0] * scale_x
anchored[..., 1] = camera_y - points[..., 1] * scale_y
anchored[..., 2] = (reference_depth - points[..., 2]) * scale_z
return anchored, valid, points[..., 2]
def create_depth_image(source_object, anchored_points):
import numpy as np
values = np.asarray(anchored_points, dtype=np.float32)[..., 2]
height, width = values.shape
pixels = np.empty((height, width, 4), dtype=np.float32)
pixels[:, :, :3] = values[:, :, None]
pixels[:, :, 3] = 1.0
image = bpy.data.images.new(
depth_data_name(source_object),
width=width,
height=height,
alpha=True,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
image.pixels.foreach_set(np.flipud(pixels).ravel())
image.pack()
return image
def load_normal_image(path, source_object):
image = bpy.data.images.load(str(path), check_existing=False)
image.name = normal_data_name(source_object)
image.colorspace_settings.name = "Non-Color"
image.pack()
return image
def build_depth_plane(context, source_object, subdivisions, material, field):
source_name = source_object.name
mesh = create_mesh_plane_mesh(source_name, plane_bounds(source_object))
for polygon in mesh.polygons:
polygon.use_smooth = True
mesh.materials.append(material)
plane = bpy.data.objects.new(source_name, mesh)
context.collection.objects.link(plane)
plane.matrix_world = source_object.matrix_world.copy()
plane["anyimage_mesh_shape"] = "DEPTH_PLANE"
node_group = depth_plane_node_group()
modifier = plane.modifiers.new(name=node_group.name, type="NODES")
modifier.node_group = node_group
modifier.show_group_selector = False
depth_image = create_depth_image(
source_object, anchored_point_field(source_object, field)[0]
)
for name, value in (
("Subdivide", max(0, min(10, int(subdivisions)))),
("Material", material),
("Thickness", 0.1),
("Depth Amount", 1.0),
("Depth Image", depth_image),
):
set_modifier_input(
modifier,
modifier_input_identifier(node_group, name),
value,
)
for selected in context.selected_objects:
selected.select_set(False)
plane.select_set(True)
context.view_layer.objects.active = plane
bpy.data.objects.remove(source_object, do_unlink=True)
plane.name = source_name
return plane
def build_depth_plane_response(context, result, operator):
source_object = bpy.data.objects.get(operator.source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty no longer exists")
color_image = create_color_image(
source_object,
result.directory,
assign_to_source=False,
)
color_image.name = color_data_name(source_object)
normal_path = result.directory / "normal.png"
normal_image = (
load_normal_image(normal_path, source_object)
if normal_path.is_file()
else None
)
material = create_material(
material_data_name(source_object),
color_image,
0.0,
normal_image=normal_image,
scene=context.scene,
)
plane = build_depth_plane(
context,
source_object,
operator.mesh_detail,
material,
load_depth_field(result.file("field")),
)
if normal_image is not None:
bake_tangent_normal_image(
context,
source_object,
plane,
normal_image,
)
push_undo("Convert to Depth Plane")

View File

@@ -0,0 +1,213 @@
import bpy
from ...preferences import addon_preferences, configured_max_ai_input_size
from ...properties import DEFAULT_MOGE2_MODEL_KEY
from ...runtime import JobOperatorBase
from ...server.media.resolution import (
DEFAULT_MAX_AI_INPUT_SIZE,
MAX_MAX_AI_INPUT_SIZE,
MIN_MAX_AI_INPUT_SIZE,
)
from ..common.image import (
analysis_source_image,
image_user_settings,
is_image_empty,
material_data_name,
prepare_image_analysis_inputs,
remove_temporary_input,
scaled_process_resolution,
video_total_frames,
)
from ..common.material import create_material
from ..common.operator import (
HIDDEN,
cleanup_temporary_input,
da3_parameters,
input_path,
invoke_ai_setup_if_needed,
moge2_parameters,
production_device,
require_environment,
require_model,
source_start_frame,
)
from .build import build_depth_plane_response, build_mesh_plane, copy_color_image
def depth_plane_settings(context):
preferences = addon_preferences(context)
return (
getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY),
float(getattr(preferences, "mesh_process_res_factor", 100.0)),
int(getattr(preferences, "mesh_resolution_level", 5)),
)
class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.generate_depth_plane"
bl_label = "Generate Depth Plane"
job_type = "prepare-geometry-data"
starting_message = "Preparing Depth Plane"
input_path: bpy.props.StringProperty(options=HIDDEN)
analysis_input_path: bpy.props.StringProperty(options=HIDDEN)
source_object_name: bpy.props.StringProperty(options=HIDDEN)
mesh_detail: bpy.props.IntProperty(default=0, min=0, max=10, options=HIDDEN)
model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN)
process_res: bpy.props.IntProperty(default=1008, min=1, options=HIDDEN)
resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=HIDDEN)
max_input_size: bpy.props.IntProperty(
default=DEFAULT_MAX_AI_INPUT_SIZE,
min=MIN_MAX_AI_INPUT_SIZE,
max=MAX_MAX_AI_INPUT_SIZE,
options=HIDDEN,
)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
def request(self, context):
require_environment()
require_model(self.model)
parameters = {
"input": str(input_path(self.input_path)),
"analysis_input": str(input_path(self.analysis_input_path)),
"shape": "DEPTH_PLANE",
"polygon": None,
"mesh_detail": self.mesh_detail,
"device": production_device().lower(),
"video_frames": 1,
"start_frame": source_start_frame(self.source_object_name),
"max_input_size": int(self.max_input_size),
"refine_selection": False,
"normal_analysis": False,
"invert": False,
}
settings = context.scene.anyimage_settings
if self.model.startswith("MOGE2_"):
parameters.update(
moge2_parameters(
settings,
input_path(self.input_path),
self.model,
self.resolution_level,
source_start_frame(self.source_object_name),
)
)
else:
parameters.update(
da3_parameters(
settings,
input_path(self.input_path),
self.model,
self.process_res,
source_start_frame(self.source_object_name),
)
)
parameters.update(shape="DEPTH_PLANE", polygon=None)
return parameters
def response(self, context, result):
build_depth_plane_response(context, result, self)
return "Depth Plane is ready"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
cleanup_temporary_input(
self.analysis_input_path,
self.delete_analysis_input,
)
def start_depth_plane_job(operator, context):
source_object = context.object
if video_total_frames(source_object.data) != 1:
operator.report({"ERROR"}, "Depth Plane currently supports still images")
return {"CANCELLED"}
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
try:
source_path, delete_input, analysis_path, delete_analysis_input = (
prepare_image_analysis_inputs(source_object.data, context.scene)
)
analysis_image = analysis_source_image(source_object.data)
model, process_factor, resolution_level = depth_plane_settings(context)
process_res = (
scaled_process_resolution(analysis_image, process_factor)
if not model.startswith("MOGE2_")
else 1
)
except (RuntimeError, ValueError) as error:
operator.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.anyimage.generate_depth_plane(
input_path=str(source_path),
analysis_input_path=str(analysis_path),
source_object_name=source_object.name,
mesh_detail=operator.mesh_detail,
model=model,
process_res=process_res,
resolution_level=resolution_level,
max_input_size=configured_max_ai_input_size(context),
delete_input=delete_input,
delete_analysis_input=delete_analysis_input,
)
if "CANCELLED" in result:
if delete_input:
remove_temporary_input(source_path)
if delete_analysis_input:
remove_temporary_input(analysis_path)
return {"CANCELLED"} if "CANCELLED" in result else {"FINISHED"}
class ConvertToPlane(bpy.types.Operator):
bl_idname = "anyimage.convert_to_plane"
bl_label = "Convert to Plane"
bl_description = "Convert the complete Image Empty into a rectangular plane"
bl_options = {"REGISTER", "UNDO"}
mesh_detail: bpy.props.IntProperty(name="Subdivide", default=0, min=0, max=10)
@classmethod
def poll(cls, context):
return is_image_empty(context.object)
def execute(self, context):
source_object = context.object
color_image = copy_color_image(source_object)
frame_start, frame_offset, frame_duration, use_cyclic = image_user_settings(
source_object
)
material = create_material(
material_data_name(source_object),
color_image,
1.0,
frame_start=frame_start,
color_frame_offset=frame_offset,
color_frame_duration=frame_duration,
use_cyclic=use_cyclic,
scene=context.scene,
)
build_mesh_plane(context, source_object, self.mesh_detail, material)
return {"FINISHED"}
class ConvertToDepthPlane(bpy.types.Operator):
bl_idname = "anyimage.convert_to_depth_plane"
bl_label = "Convert to Depth Plane"
bl_description = "Convert the complete Image Empty into a front-projected relief"
bl_options = {"REGISTER", "UNDO"}
mesh_detail: bpy.props.IntProperty(name="Subdivide", default=0, min=0, max=10)
@classmethod
def poll(cls, context):
from ...runtime import runtime
return is_image_empty(context.object) and not runtime.server_busy()
def invoke(self, context, _event):
return self.execute(context)
def execute(self, context):
return start_depth_plane_job(self, context)

View File

@@ -0,0 +1,86 @@
"""Create Mesh Plane objects used by conversion and clipboard paste."""
import bpy
from ..common.node_asset import (
load_mesh_plane_node_group,
modifier_input_identifier,
set_modifier_input,
)
MESH_PLANE_NODE_GROUP_NAME = "O Mesh Plane"
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

View File

@@ -0,0 +1,156 @@
"""Runtime Geometry Nodes for Depth Plane."""
import bpy
from ..common.node_asset import load_mesh_plane_node_group
from ..common.node_graph import group_input, interface_socket
def depth_plane_node_group():
group_name = "O Image Depth Plane"
group = bpy.data.node_groups.get(group_name)
if group is not None:
return group
if group is None:
group = bpy.data.node_groups.new(group_name, "GeometryNodeTree")
else:
group.nodes.clear()
group.interface.clear()
group.is_modifier = True
interface_socket(group, "Geometry", "INPUT", "NodeSocketGeometry")
subdivide = interface_socket(group, "Subdivide", "INPUT", "NodeSocketInt")
subdivide.default_value = 7
subdivide.min_value = 0
subdivide.max_value = 10
thickness = interface_socket(group, "Thickness", "INPUT", "NodeSocketFloat")
thickness.default_value = 0.1
thickness.min_value = 0.0
thickness.subtype = "DISTANCE"
depth_amount = interface_socket(group, "Depth Amount", "INPUT", "NodeSocketFloat")
depth_amount.default_value = 1.0
depth_amount.min_value = 0.0
depth_image = interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage")
depth_image.hide_in_modifier = True
material = interface_socket(group, "Material", "INPUT", "NodeSocketMaterial")
material.hide_in_modifier = True
interface_socket(group, "Geometry", "OUTPUT", "NodeSocketGeometry")
nodes = group.nodes
links = group.links
geometry_input = group_input(
nodes,
(-1850.0, 450.0),
{"Geometry", "Subdivide", "Material"},
)
shape_input = group_input(
nodes,
(-1250.0, 850.0),
{"Thickness", "Depth Amount"},
)
image_input = group_input(nodes, (-1050.0, 50.0), {"Depth Image"})
cleanup_input = group_input(nodes, (650.0, -200.0), {"Thickness"})
group_output = nodes.new("NodeGroupOutput")
group_output.location = (1600.0, 450.0)
mesh_plane = nodes.new("GeometryNodeGroup")
mesh_plane.node_tree = load_mesh_plane_node_group()
mesh_plane.inputs["Thickness"].default_value = 0.0
mesh_plane.location = (-1550.0, 450.0)
links.new(geometry_input.outputs["Geometry"], mesh_plane.inputs["Geometry"])
links.new(geometry_input.outputs["Subdivide"], mesh_plane.inputs["Subdivide"])
links.new(geometry_input.outputs["Material"], mesh_plane.inputs["Material"])
extrude = nodes.new("GeometryNodeExtrudeMesh")
extrude.mode = "FACES"
extrude.inputs["Offset"].default_value = (0.0, 0.0, -1.0)
extrude.inputs["Individual"].default_value = False
extrude.location = (-1200.0, 450.0)
links.new(mesh_plane.outputs["Geometry"], extrude.inputs["Mesh"])
links.new(shape_input.outputs["Thickness"], extrude.inputs["Offset Scale"])
geometry = extrude.outputs["Mesh"]
uv = nodes.new("GeometryNodeInputNamedAttribute")
uv.data_type = "FLOAT_VECTOR"
uv.inputs["Name"].default_value = "UVMap"
uv.location = (-1000.0, 50.0)
texture = nodes.new("GeometryNodeImageTexture")
texture.location = (-800.0, 50.0)
links.new(image_input.outputs["Depth Image"], texture.inputs["Image"])
links.new(uv.outputs["Attribute"], texture.inputs["Vector"])
separate = nodes.new("ShaderNodeSeparateXYZ")
separate.location = (-600.0, 50.0)
links.new(texture.outputs["Color"], separate.inputs["Vector"])
raw_depth = nodes.new("ShaderNodeMath")
raw_depth.operation = "MULTIPLY"
raw_depth.location = (-350.0, 150.0)
links.new(separate.outputs["X"], raw_depth.inputs[0])
links.new(shape_input.outputs["Depth Amount"], raw_depth.inputs[1])
inward_limit = nodes.new("ShaderNodeMath")
inward_limit.operation = "MULTIPLY"
inward_limit.inputs[1].default_value = -0.98
inward_limit.location = (-350.0, -50.0)
links.new(shape_input.outputs["Thickness"], inward_limit.inputs[0])
clamp = nodes.new("ShaderNodeMath")
clamp.operation = "MAXIMUM"
clamp.location = (-100.0, 50.0)
links.new(raw_depth.outputs[0], clamp.inputs[0])
links.new(inward_limit.outputs[0], clamp.inputs[1])
has_thickness = nodes.new("ShaderNodeMath")
has_thickness.operation = "GREATER_THAN"
has_thickness.inputs[1].default_value = 1e-6
has_thickness.location = (-350.0, -250.0)
links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0])
choose_depth = nodes.new("GeometryNodeSwitch")
choose_depth.input_type = "FLOAT"
choose_depth.location = (150.0, 50.0)
links.new(has_thickness.outputs[0], choose_depth.inputs["Switch"])
links.new(raw_depth.outputs[0], choose_depth.inputs["False"])
links.new(clamp.outputs[0], choose_depth.inputs["True"])
combine = nodes.new("ShaderNodeCombineXYZ")
combine.location = (350.0, 50.0)
links.new(choose_depth.outputs["Output"], combine.inputs["Z"])
front = nodes.new("FunctionNodeBooleanMath")
front.operation = "NOT"
front.location = (150.0, 300.0)
links.new(extrude.outputs["Top"], front.inputs[0])
set_position = nodes.new("GeometryNodeSetPosition")
set_position.location = (550.0, 450.0)
links.new(geometry, set_position.inputs["Geometry"])
links.new(front.outputs[0], set_position.inputs["Selection"])
links.new(combine.outputs["Vector"], set_position.inputs["Offset"])
geometry = set_position.outputs["Geometry"]
zero_thickness = nodes.new("ShaderNodeMath")
zero_thickness.operation = "LESS_THAN"
zero_thickness.inputs[1].default_value = 1e-6
zero_thickness.location = (750.0, -150.0)
links.new(cleanup_input.outputs["Thickness"], zero_thickness.inputs[0])
back_or_side = nodes.new("FunctionNodeBooleanMath")
back_or_side.operation = "OR"
back_or_side.location = (750.0, 150.0)
links.new(extrude.outputs["Top"], back_or_side.inputs[0])
links.new(extrude.outputs["Side"], back_or_side.inputs[1])
delete_selection = nodes.new("FunctionNodeBooleanMath")
delete_selection.operation = "AND"
delete_selection.location = (950.0, 50.0)
links.new(zero_thickness.outputs[0], delete_selection.inputs[0])
links.new(back_or_side.outputs[0], delete_selection.inputs[1])
delete_geometry = nodes.new("GeometryNodeDeleteGeometry")
delete_geometry.domain = "FACE"
delete_geometry.location = (1100.0, 450.0)
links.new(geometry, delete_geometry.inputs["Geometry"])
links.new(delete_selection.outputs[0], delete_geometry.inputs["Selection"])
shade_smooth = nodes.new("GeometryNodeSetShadeSmooth")
shade_smooth.domain = "FACE"
shade_smooth.inputs["Shade Smooth"].default_value = True
shade_smooth.location = (1350.0, 450.0)
links.new(delete_geometry.outputs["Geometry"], shade_smooth.inputs["Geometry"])
links.new(shade_smooth.outputs["Geometry"], group_output.inputs["Geometry"])
group.use_fake_user = True
return group
def plane_node_group(shape):
if shape == "PLANE":
return load_mesh_plane_node_group()
if shape == "DEPTH_PLANE":
return depth_plane_node_group()
raise ValueError(f"Unsupported Plane shape: {shape}")

View File

@@ -1,226 +0,0 @@
"""MoGe-2 depth sampling and Depth Balloon base-plane controls."""
from pathlib import Path
import bpy
PLANE_VARIANCE_FLOOR_RATIO = 0.025
def load_depth_balloon_data(path):
import numpy as np
try:
with np.load(Path(path), allow_pickle=False) as archive:
required = {
"depth_signal",
"baseline_vector",
"valid",
"origin",
"reference_depth",
"crop_size",
"source_size",
}
missing = required.difference(archive.files)
if missing:
raise ValueError(
f"Depth Balloon data is missing: {', '.join(sorted(missing))}"
)
data = {
"depth_signal": np.asarray(
archive["depth_signal"],
dtype=np.float32,
),
"baseline_vector": np.asarray(
archive["baseline_vector"],
dtype=np.float32,
),
"valid": np.asarray(archive["valid"], dtype=bool),
"origin": tuple(int(value) for value in archive["origin"]),
"reference_depth": float(archive["reference_depth"]),
"crop_size": tuple(int(value) for value in archive["crop_size"]),
"source_size": tuple(int(value) for value in archive["source_size"]),
}
except OSError as error:
raise ValueError(f"Unable to read Depth Balloon data: {path}") from error
if data["depth_signal"].ndim != 2:
raise ValueError("Depth Balloon signal must be two-dimensional")
if data["valid"].shape != data["depth_signal"].shape:
raise ValueError("Depth Balloon mask dimensions do not match")
if data["baseline_vector"].shape != (3,):
raise ValueError("Depth Balloon base plane vector must have three components")
if any(value <= 0 for value in (*data["crop_size"], *data["source_size"])):
raise ValueError("Depth Balloon image dimensions must be positive")
return data
def _sample_valid_bilinear(values, valid, x, y):
import numpy as np
height, width = values.shape
x = np.clip(x, 0.0, max(width - 1, 0))
y = np.clip(y, 0.0, max(height - 1, 0))
x0 = np.floor(x).astype(np.int64)
y0 = np.floor(y).astype(np.int64)
x1 = np.minimum(x0 + 1, width - 1)
y1 = np.minimum(y0 + 1, height - 1)
fx = x - x0
fy = y - y0
samples = np.stack(
(
values[y0, x0],
values[y0, x1],
values[y1, x0],
values[y1, x1],
),
axis=1,
)
weights = np.stack(
(
(1.0 - fx) * (1.0 - fy),
fx * (1.0 - fy),
(1.0 - fx) * fy,
fx * fy,
),
axis=1,
)
sample_valid = np.stack(
(
valid[y0, x0],
valid[y0, x1],
valid[y1, x0],
valid[y1, x1],
),
axis=1,
)
weights *= sample_valid
totals = weights.sum(axis=1)
result = np.zeros(len(x), dtype=np.float32)
usable = totals > 1e-8
result[usable] = (samples[usable] * weights[usable]).sum(axis=1) / totals[usable]
return result, usable
def _stable_plane(coordinates, values):
import numpy as np
coordinates = np.asarray(coordinates, dtype=np.float64)
values = np.asarray(values, dtype=np.float64)
if len(coordinates) < 2:
return np.zeros(3, dtype=np.float32)
center = coordinates.mean(axis=0)
value_center = float(values.mean())
centered = coordinates - center
covariance = centered.T @ centered / len(centered)
cross = centered.T @ (values - value_center) / len(centered)
eigenvalues, eigenvectors = np.linalg.eigh(covariance)
largest = max(float(eigenvalues[-1]), 0.0)
if largest <= 1e-12:
slopes = np.zeros(2, dtype=np.float64)
else:
supported = np.maximum(eigenvalues, largest * PLANE_VARIANCE_FLOOR_RATIO)
slopes = eigenvectors @ ((eigenvectors.T @ cross) / supported)
intercept = value_center - float(center @ slopes)
return np.asarray((*slopes, intercept), dtype=np.float32)
def depth_balloon_fields(vertices, vertex_uv, depth_data, depth_scale):
"""Sample camera depth and fit its base plane at a fixed local scale."""
import numpy as np
vertices = np.asarray(vertices, dtype=np.float32)
uv = np.asarray(vertex_uv, dtype=np.float32)
if vertices.ndim != 2 or vertices.shape[1] != 3:
raise ValueError("Depth Balloon vertices must have three components")
if uv.shape != (len(vertices), 2):
raise ValueError("Depth Balloon UV coordinates do not match its vertices")
depth_signal = np.asarray(depth_data["depth_signal"], dtype=np.float32)
valid = np.asarray(depth_data["valid"], dtype=bool)
crop_width, crop_height = depth_data["crop_size"]
depth_height, depth_width = depth_signal.shape
crop_x = uv[:, 0] * max(crop_width - 1, 0)
crop_y = (1.0 - uv[:, 1]) * max(crop_height - 1, 0)
sample_x = crop_x * max(depth_width - 1, 0) / max(crop_width - 1, 1)
sample_y = crop_y * max(depth_height - 1, 0) / max(crop_height - 1, 1)
sampled, usable = _sample_valid_bilinear(
depth_signal,
valid,
sample_x,
sample_y,
)
inside_crop = (
(crop_x >= 0.0)
& (crop_x <= max(crop_width - 1, 0))
& (crop_y >= 0.0)
& (crop_y <= max(crop_height - 1, 0))
)
usable &= inside_crop
sampled[~usable] = 0.0
coordinates = np.stack(
(
sample_x / max(depth_width - 1, 1) * 2.0 - 1.0,
sample_y / max(depth_height - 1, 1) * 2.0 - 1.0,
np.ones(len(vertices), dtype=np.float32),
),
axis=1,
).astype(np.float32)
depth_scale = float(depth_scale)
if not depth_scale > 0.0:
raise ValueError("Depth Balloon reference scale must be positive")
normalized_plane = np.asarray(
depth_data["baseline_vector"],
dtype=np.float32,
)
baseline_values = (
coordinates @ normalized_plane * depth_scale * 0.5
)
local_coordinates = vertices[:, :2]
if np.count_nonzero(usable) >= 3:
local_plane = _stable_plane(
local_coordinates[usable],
baseline_values[usable],
)
else:
local_plane = np.zeros(3, dtype=np.float32)
depth_values = sampled * depth_scale
if usable.any():
plane_without_offset = local_coordinates @ local_plane[:2]
residual = depth_values - plane_without_offset
depth_values[usable] -= float(np.median(residual[usable]))
return depth_values, local_plane
def orient_depth_balloon_plane(local_plane):
slope_x, slope_y, offset = (float(value) for value in local_plane)
return -slope_x, slope_y, offset
def create_depth_balloon_base_plane(context, mesh_object, vertices, local_plane):
import numpy as np
from mathutils import Vector
vertices = np.asarray(vertices, dtype=np.float32)
slope_y, slope_z, offset = (float(value) for value in local_plane)
extent = vertices[:, 1:].max(axis=0) - vertices[:, 1:].min(axis=0)
center_y = 0.0
center_z = 0.0
center_x = offset
normal = Vector((-1.0, slope_y, slope_z)).normalized()
controller = bpy.data.objects.new(f"{mesh_object.name} Base Plane", None)
context.collection.objects.link(controller)
controller.parent = mesh_object
controller.location = (float(center_x), float(center_y), float(center_z))
controller.rotation_euler = normal.to_track_quat("Z", "Y").to_euler()
controller.empty_display_type = "SINGLE_ARROW"
controller.empty_display_size = max(float(extent.max()) * 0.15, 0.01)
controller.show_in_front = True
controller.hide_render = True
controller["anyimage_source_type"] = "DEPTH_BALLOON_BASE_PLANE"
controller["anyimage_base_plane"] = (slope_y, slope_z, offset)
return controller

View File

@@ -1,161 +0,0 @@
import math
from .cutout_teddy import teddy_balloon_heights
DEFAULT_INFLATION = 0.45
DEFAULT_ALGORITHM = "POISSON"
def cotangent_poisson_field(
planar_points,
planar_faces,
boundary_mask,
tolerance=1e-8,
):
import numpy as np
points = np.asarray(planar_points, dtype=np.float64)
boundary_mask = np.asarray(boundary_mask, dtype=bool)
loads = np.zeros(len(points), dtype=np.float64)
edge_weights = {}
def add_edge(start, end, weight):
edge = tuple(sorted((int(start), int(end))))
edge_weights[edge] = edge_weights.get(edge, 0.0) + weight
for face in planar_faces:
first, second, third = (int(index) for index in face)
a, b, c = points[[first, second, third]]
cross = abs(
(b[0] - a[0]) * (c[1] - a[1])
- (b[1] - a[1]) * (c[0] - a[0])
)
if cross <= 1e-14:
continue
area = cross * 0.5
loads[[first, second, third]] += area / 3.0
cotangent_a = float((b - a).dot(c - a) / cross)
cotangent_b = float((a - b).dot(c - b) / cross)
cotangent_c = float((a - c).dot(b - c) / cross)
add_edge(second, third, cotangent_a * 0.5)
add_edge(first, third, cotangent_b * 0.5)
add_edge(first, second, cotangent_c * 0.5)
interior = np.flatnonzero(~boundary_mask)
field = np.zeros(len(points), dtype=np.float64)
if not len(interior):
return field
compact = np.full(len(points), -1, dtype=np.int64)
compact[interior] = np.arange(len(interior), dtype=np.int64)
diagonal = np.zeros(len(interior), dtype=np.float64)
edge_starts = []
edge_ends = []
weights = []
# Constrained triangulation can create negative cotangent weights.
# Clamping them keeps the Dirichlet system positive definite.
for (start, end), weight in edge_weights.items():
weight = max(float(weight), 0.0)
if weight <= 1e-14:
continue
start_compact = compact[start]
end_compact = compact[end]
if start_compact >= 0:
diagonal[start_compact] += weight
if end_compact >= 0:
diagonal[end_compact] += weight
if start_compact >= 0 and end_compact >= 0:
edge_starts.append(start_compact)
edge_ends.append(end_compact)
weights.append(weight)
if np.any(diagonal <= 1e-14):
raise ValueError("Unable to build a stable Balloon profile")
edge_starts = np.asarray(edge_starts, dtype=np.int64)
edge_ends = np.asarray(edge_ends, dtype=np.int64)
weights = np.asarray(weights, dtype=np.float64)
def multiply(values):
result = diagonal * values
if len(weights):
np.add.at(result, edge_starts, -weights * values[edge_ends])
np.add.at(result, edge_ends, -weights * values[edge_starts])
return result
right_hand_side = loads[interior]
solution = np.zeros(len(interior), dtype=np.float64)
residual = right_hand_side.copy()
preconditioned = residual / diagonal
direction = preconditioned.copy()
residual_product = float(residual.dot(preconditioned))
target = max(float(np.linalg.norm(right_hand_side)) * tolerance, 1e-12)
max_iterations = max(64, min(len(interior) * 4, 4000))
for _iteration in range(max_iterations):
if float(np.linalg.norm(residual)) <= target:
break
multiplied = multiply(direction)
denominator = float(direction.dot(multiplied))
if denominator <= 0.0 or not math.isfinite(denominator):
raise ValueError("Unable to solve the Balloon profile")
step = residual_product / denominator
solution += step * direction
residual -= step * multiplied
preconditioned = residual / diagonal
next_product = float(residual.dot(preconditioned))
if not math.isfinite(next_product):
raise ValueError("Unable to solve the Balloon profile")
if abs(residual_product) <= 1e-30:
break
direction = preconditioned + next_product / residual_product * direction
residual_product = next_product
field[interior] = np.maximum(solution, 0.0)
return field
def poisson_balloon_profile(
planar_points,
planar_faces,
boundary_mask,
inflation=DEFAULT_INFLATION,
):
import numpy as np
field = cotangent_poisson_field(
planar_points,
planar_faces,
boundary_mask,
)
return 2.0 * float(inflation) * np.sqrt(field)
def balloon_profile(
algorithm,
planar_points,
planar_faces,
boundary_mask,
components,
inflation=DEFAULT_INFLATION,
):
import numpy as np
algorithm = str(algorithm).upper()
if algorithm == "POISSON":
return poisson_balloon_profile(
planar_points,
planar_faces,
boundary_mask,
inflation,
)
if algorithm != "TEDDY":
raise ValueError(f"Unknown Balloon profile algorithm: {algorithm}")
result = np.zeros(len(planar_points), dtype=np.float64)
for start, end, component_faces, outer_contour in components:
result[start:end] = teddy_balloon_heights(
np.asarray(planar_points)[start:end],
component_faces,
outer_contour,
np.asarray(boundary_mask)[start:end],
inflation,
)
return result

View File

@@ -0,0 +1,12 @@
"""Cutout selection, shape conversion, and geometry algorithms."""
from .selection_tool import ActivateCutoutTool, CutoutTool, SelectCutoutRegion
from .shape_conversion import ConvertCutoutShape, GenerateCutoutShape
__all__ = (
"ActivateCutoutTool",
"ConvertCutoutShape",
"CutoutTool",
"GenerateCutoutShape",
"SelectCutoutRegion",
)

View File

@@ -1,3 +1,7 @@
import math
DEFAULT_INFLATION = 0.45
def distances_to_contour(points, contour):
import numpy as np
@@ -215,3 +219,158 @@ def teddy_balloon_heights(
planar_faces,
boundary_mask,
)
DEFAULT_ALGORITHM = "POISSON"
def cotangent_poisson_field(
planar_points,
planar_faces,
boundary_mask,
tolerance=1e-8,
):
import numpy as np
points = np.asarray(planar_points, dtype=np.float64)
boundary_mask = np.asarray(boundary_mask, dtype=bool)
loads = np.zeros(len(points), dtype=np.float64)
edge_weights = {}
def add_edge(start, end, weight):
edge = tuple(sorted((int(start), int(end))))
edge_weights[edge] = edge_weights.get(edge, 0.0) + weight
for face in planar_faces:
first, second, third = (int(index) for index in face)
a, b, c = points[[first, second, third]]
cross = abs(
(b[0] - a[0]) * (c[1] - a[1])
- (b[1] - a[1]) * (c[0] - a[0])
)
if cross <= 1e-14:
continue
area = cross * 0.5
loads[[first, second, third]] += area / 3.0
cotangent_a = float((b - a).dot(c - a) / cross)
cotangent_b = float((a - b).dot(c - b) / cross)
cotangent_c = float((a - c).dot(b - c) / cross)
add_edge(second, third, cotangent_a * 0.5)
add_edge(first, third, cotangent_b * 0.5)
add_edge(first, second, cotangent_c * 0.5)
interior = np.flatnonzero(~boundary_mask)
field = np.zeros(len(points), dtype=np.float64)
if not len(interior):
return field
compact = np.full(len(points), -1, dtype=np.int64)
compact[interior] = np.arange(len(interior), dtype=np.int64)
diagonal = np.zeros(len(interior), dtype=np.float64)
edge_starts = []
edge_ends = []
weights = []
# Constrained triangulation can create negative cotangent weights.
# Clamping them keeps the Dirichlet system positive definite.
for (start, end), weight in edge_weights.items():
weight = max(float(weight), 0.0)
if weight <= 1e-14:
continue
start_compact = compact[start]
end_compact = compact[end]
if start_compact >= 0:
diagonal[start_compact] += weight
if end_compact >= 0:
diagonal[end_compact] += weight
if start_compact >= 0 and end_compact >= 0:
edge_starts.append(start_compact)
edge_ends.append(end_compact)
weights.append(weight)
if np.any(diagonal <= 1e-14):
raise ValueError("Unable to build a stable Balloon profile")
edge_starts = np.asarray(edge_starts, dtype=np.int64)
edge_ends = np.asarray(edge_ends, dtype=np.int64)
weights = np.asarray(weights, dtype=np.float64)
def multiply(values):
result = diagonal * values
if len(weights):
np.add.at(result, edge_starts, -weights * values[edge_ends])
np.add.at(result, edge_ends, -weights * values[edge_starts])
return result
right_hand_side = loads[interior]
solution = np.zeros(len(interior), dtype=np.float64)
residual = right_hand_side.copy()
preconditioned = residual / diagonal
direction = preconditioned.copy()
residual_product = float(residual.dot(preconditioned))
target = max(float(np.linalg.norm(right_hand_side)) * tolerance, 1e-12)
max_iterations = max(64, min(len(interior) * 4, 4000))
for _iteration in range(max_iterations):
if float(np.linalg.norm(residual)) <= target:
break
multiplied = multiply(direction)
denominator = float(direction.dot(multiplied))
if denominator <= 0.0 or not math.isfinite(denominator):
raise ValueError("Unable to solve the Balloon profile")
step = residual_product / denominator
solution += step * direction
residual -= step * multiplied
preconditioned = residual / diagonal
next_product = float(residual.dot(preconditioned))
if not math.isfinite(next_product):
raise ValueError("Unable to solve the Balloon profile")
if abs(residual_product) <= 1e-30:
break
direction = preconditioned + next_product / residual_product * direction
residual_product = next_product
field[interior] = np.maximum(solution, 0.0)
return field
def poisson_balloon_profile(
planar_points,
planar_faces,
boundary_mask,
inflation=DEFAULT_INFLATION,
):
import numpy as np
field = cotangent_poisson_field(
planar_points,
planar_faces,
boundary_mask,
)
return 2.0 * float(inflation) * np.sqrt(field)
def balloon_profile(
algorithm,
planar_points,
planar_faces,
boundary_mask,
components,
inflation=DEFAULT_INFLATION,
):
import numpy as np
algorithm = str(algorithm).upper()
if algorithm == "POISSON":
return poisson_balloon_profile(
planar_points,
planar_faces,
boundary_mask,
inflation,
)
if algorithm != "TEDDY":
raise ValueError(f"Unknown Balloon profile algorithm: {algorithm}")
result = np.zeros(len(planar_points), dtype=np.float64)
for start, end, component_faces, outer_contour in components:
result[start:end] = teddy_balloon_heights(
np.asarray(planar_points)[start:end],
component_faces,
outer_contour,
np.asarray(boundary_mask)[start:end],
inflation,
)
return result

View File

@@ -0,0 +1,814 @@
import json
import bpy
from ...preferences import addon_preferences, configured_max_ai_input_size
from ...server.media.resolution import limited_image_size
from ..common.image import (
color_data_name,
create_color_image,
depth_data_name,
is_image_empty,
material_data_name,
normal_data_name,
plane_bounds,
video_total_frames,
)
from ..common.material import create_material
from ..common.node_asset import modifier_input_identifier, set_modifier_input
from ..common.normal_map import bake_tangent_normal_image
from ..common.operator import (
push_undo,
)
from .depth_calibration import (
DEFAULT_DEPTH_DIRECTION,
UNREFINED_DEPTH_REFERENCE,
depth_balloon_projection_parameters,
metric_depth_reference,
)
from .planar_mesh import (
metric_transform,
prepare_planar_regions,
triangulate_planar_regions,
)
from .balloon_profile import balloon_profile as calculate_balloon_profile
from .mask_regions import alpha_regions
from .node_assets import cutout_node_group
from ..common.image_edit import (
ImageEditWarning,
polygon_bounds,
)
from ..common.image_mask import polygon_mask, visible_selection_mask
from .shape_types import (
BALLOON_ATTRIBUTE_NAME,
CUTOUT_SHAPE_INDICES,
)
CUTOUT_ALPHA_THRESHOLD = 0.1
CUTOUT_AXIS_TO_SOURCE = (
(0.0, -1.0, 0.0, 0.0),
(0.0, 0.0, 1.0, 0.0),
(-1.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
def _surface_depth_image(source_object, points, valid, camera_depth):
import numpy as np
points = np.asarray(points, dtype=np.float32)
valid = np.asarray(valid, dtype=bool)
height, width = valid.shape
if points.shape != (height, width, 3):
raise ValueError("Depth Surface point dimensions do not match")
discontinuity = _depth_discontinuity_map(camera_depth, valid)
discontinuity[~valid] = -1.0
pixels = np.empty((height, width, 4), dtype=np.float32)
pixels[:, :, :3] = points
pixels[:, :, 3] = discontinuity
image = bpy.data.images.new(
depth_data_name(source_object),
width=width,
height=height,
alpha=True,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
image.pixels.foreach_set(np.flipud(pixels).ravel())
image.pack()
return image
def _cutout_world_matrix(source_matrix, crop_center):
from mathutils import Matrix
center_x, center_y = (float(value) for value in crop_center)
return (
source_matrix
@ Matrix.Translation((center_x, center_y, 0.0))
@ Matrix(CUTOUT_AXIS_TO_SOURCE)
)
def _orient_cutout_vertices(vertices):
import numpy as np
vertices = np.asarray(vertices, dtype=np.float64)
return np.column_stack(
(
-vertices[:, 2],
-vertices[:, 0],
vertices[:, 1],
)
)
def _cutout_domain(
regions_pixels,
image_size,
bounds,
spacing,
world_metric,
balloon_algorithm="POISSON",
):
import numpy as np
x_min, x_max, y_min, y_max = bounds
regions, spacing = prepare_planar_regions(
regions_pixels,
image_size,
bounds,
spacing=spacing,
world_metric=world_metric,
)
planar, planar_faces, boundary, components = triangulate_planar_regions(
regions,
spacing,
return_components=True,
)
profile = calculate_balloon_profile(
balloon_algorithm, planar, planar_faces, boundary, components
)
faces = np.asarray(planar_faces, dtype=np.int32)
local = planar @ np.linalg.inv(metric_transform(world_metric))
uv = np.column_stack(
(
(local[:, 0] - x_min) / (x_max - x_min),
(local[:, 1] - y_min) / (y_max - y_min),
)
).astype(np.float32)
crop_center = np.asarray(
((x_min + x_max) * 0.5, (y_min + y_max) * 0.5),
dtype=np.float64,
)
local -= crop_center
vertices = np.column_stack((local, np.zeros(len(local), dtype=np.float64)))
return vertices, faces, uv, boundary, planar, profile
def _world_plane_metric(matrix_world):
from mathutils import Vector
linear = matrix_world.to_3x3()
x = linear @ Vector((1.0, 0.0, 0.0))
y = linear @ Vector((0.0, 1.0, 0.0))
z = linear @ Vector((0.0, 0.0, 1.0))
return ((x.dot(x), x.dot(y)), (x.dot(y), y.dot(y))), z.length
def _cutout_cleanup_area(spacing, world_metric):
import numpy as np
if spacing <= 0.0:
raise ValueError("Cutout point spacing must be greater than zero")
area_scale = abs(float(np.linalg.det(metric_transform(world_metric))))
if area_scale <= 1e-12:
raise ValueError("The Image Empty has a degenerate world transform")
return float(spacing) ** 2 / area_scale
def _load_field(path):
import numpy as np
with np.load(path, allow_pickle=False) as archive:
points = np.asarray(archive["points"], dtype=np.float32)
intrinsics = np.asarray(archive["intrinsics"], dtype=np.float32)
valid = np.asarray(archive["valid"], dtype=bool)
calibration_valid = np.asarray(archive["calibration_valid"], dtype=bool)
image_size = tuple(int(value) for value in archive["image_size"])
reference_depth = float(archive["reference_depth"])
if (
points.ndim != 3
or points.shape[2] != 3
or valid.shape != points.shape[:2]
or calibration_valid.shape != points.shape[:2]
):
raise ValueError("The mesh point field is invalid")
if intrinsics.shape != (3, 3) or not reference_depth > 0.0:
raise ValueError("The mesh camera field is invalid")
return points, intrinsics, valid, calibration_valid, image_size, reference_depth
def _surface_uniform_scale(
source_object,
field,
bounds=None,
):
import numpy as np
(
_points,
intrinsics,
_valid,
_calibration_valid,
image_size,
reference_depth,
) = field
width, height = image_size
x_min, x_max, y_min, y_max = bounds or plane_bounds(source_object)
plane_width = x_max - x_min
plane_height = y_max - y_min
fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1])
if fx <= 0.0 or fy <= 0.0:
raise ValueError("Camera intrinsics contain an invalid focal length")
scale_x = plane_width * fx / (max(width - 1, 1) * reference_depth)
scale_y = plane_height * fy / (max(height - 1, 1) * reference_depth)
return float(np.sqrt(scale_x * scale_y))
def _depth_discontinuity_map(depth, valid):
import numpy as np
depth = np.asarray(depth, dtype=np.float32)
valid = np.asarray(valid, dtype=bool) & np.isfinite(depth) & (depth > 0.0)
padded_depth = np.pad(depth, 1, mode="edge")
padded_valid = np.pad(valid, 1, mode="edge")
windows = np.lib.stride_tricks.sliding_window_view(padded_depth, (3, 3))
usable = np.lib.stride_tricks.sliding_window_view(padded_valid, (3, 3))
minimum = np.min(np.where(usable, windows, np.inf), axis=(-2, -1))
maximum = np.max(np.where(usable, windows, -np.inf), axis=(-2, -1))
result = (maximum - minimum) / np.maximum(np.abs(depth), 1e-6)
result[~usable.all(axis=(-2, -1))] = 1.0
return result.astype(np.float32)
def _counter_clockwise_faces(points, faces):
import numpy as np
points = np.asarray(points, dtype=np.float64)
result = np.asarray(faces, dtype=np.int32).copy()
polygons = points[result]
following = np.roll(polygons, -1, axis=1)
signed_area = np.sum(
polygons[..., 0] * following[..., 1]
- polygons[..., 1] * following[..., 0],
axis=1,
)
result[signed_area < 0.0] = result[signed_area < 0.0, ::-1]
return result
def _assign_uv(mesh, uv):
import numpy as np
for layer in list(mesh.uv_layers):
mesh.uv_layers.remove(layer)
uv_layer = mesh.uv_layers.new(name="UVMap")
loop_vertices = np.empty(len(mesh.loops), dtype=np.int32)
mesh.loops.foreach_get("vertex_index", loop_vertices)
loop_uv = np.asarray(uv, dtype=np.float32)[loop_vertices]
uv_layer.data.foreach_set("uv", loop_uv.ravel())
def _assign_float_point_attribute(mesh, name, values):
import numpy as np
attribute = mesh.attributes.new(name=name, type="FLOAT", domain="POINT")
attribute.data.foreach_set("value", np.asarray(values, dtype=np.float32))
def _image_rgba(image):
import numpy as np
width, height = (int(value) for value in image.size)
pixels = np.empty(len(image.pixels), dtype=np.float32)
image.pixels.foreach_get(pixels)
return np.flipud(pixels.reshape((height, width, 4)))
def _mask_crop_bounds(alpha):
import numpy as np
values = np.asarray(alpha)
visible_y, visible_x = np.nonzero(values > CUTOUT_ALPHA_THRESHOLD)
if not len(visible_x):
raise ValueError("The Cutout selection contains no visible pixels")
return (
int(visible_x.min()),
int(visible_y.min()),
int(visible_x.max()) + 1,
int(visible_y.max()) + 1,
)
def _crop_plane_bounds(source_object, crop_bounds, source_size):
source_width, source_height = (float(value) for value in source_size)
left, top, right, bottom = (float(value) for value in crop_bounds)
x_min, x_max, y_min, y_max = plane_bounds(source_object)
width = x_max - x_min
height = y_max - y_min
return (
x_min + left * width / source_width,
x_min + right * width / source_width,
y_max - bottom * height / source_height,
y_max - top * height / source_height,
)
def _cropped_color_image(source_object, crop_bounds):
import numpy as np
source = source_object.data
rgba = _image_rgba(source)
left, top, right, bottom = crop_bounds
width = right - left
height = bottom - top
if width <= 0 or height <= 0:
raise ValueError("The Cutout crop dimensions must be positive")
cropped = np.zeros((height, width, 4), dtype=np.float32)
source_height, source_width = rgba.shape[:2]
source_left = max(0, left)
source_top = max(0, top)
source_right = min(source_width, right)
source_bottom = min(source_height, bottom)
if source_right > source_left and source_bottom > source_top:
cropped[
source_top - top : source_bottom - top,
source_left - left : source_right - left,
] = rgba[source_top:source_bottom, source_left:source_right]
image = bpy.data.images.new(
color_data_name(source_object),
width=width,
height=height,
alpha=True,
)
try:
image.colorspace_settings.name = source.colorspace_settings.name
image.alpha_mode = source.alpha_mode
image.pixels.foreach_set(np.flipud(cropped).ravel())
image.pack()
image.update()
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def _cutout_regions(
alpha, mesh_detail, threshold=CUTOUT_ALPHA_THRESHOLD
):
contour_resolution = max(512, min(int(mesh_detail) * 8, 1024))
return alpha_regions(
alpha,
max_resolution=contour_resolution,
threshold=threshold,
)
def _antialias_selection(selection):
import numpy as np
values = np.asarray(selection, dtype=np.float32)
kernel = np.asarray((0.029, 0.235, 0.472, 0.235, 0.029), dtype=np.float32)
radius = len(kernel) // 2
horizontal = np.zeros_like(values)
padded = np.pad(values, ((0, 0), (radius, radius)), mode="edge")
for offset, weight in enumerate(kernel):
horizontal += padded[:, offset : offset + values.shape[1]] * weight
result = np.zeros_like(values)
padded = np.pad(horizontal, ((radius, radius), (0, 0)), mode="edge")
for offset, weight in enumerate(kernel):
result += padded[offset : offset + values.shape[0]] * weight
return result
def _geometry_cutout_regions(
alpha,
polygon,
mesh_detail,
invert=False,
refine_selection=False,
selection=None,
):
import numpy as np
values = np.asarray(alpha, dtype=np.float32)
if values.ndim != 2:
raise ValueError("Cutout Alpha must be two-dimensional")
source_height, source_width = values.shape
source_size = (source_width, source_height)
if refine_selection:
geometry_alpha = np.where(values > CUTOUT_ALPHA_THRESHOLD, values, 0.0)
regions, _image_size = _cutout_regions(geometry_alpha, mesh_detail)
return regions, source_size
geometry_alpha = selection
if geometry_alpha is None:
geometry_alpha = _antialias_selection(
polygon_mask(source_size, polygon, "NONZERO")
)
geometry_alpha = visible_selection_mask(
values,
geometry_alpha,
invert,
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
)
regions, _image_size = _cutout_regions(geometry_alpha, mesh_detail)
return regions, source_size
def _local_cutout_rgba(source_object, polygon, invert=False):
import numpy as np
rgba = _image_rgba(source_object.data).copy()
height, width = rgba.shape[:2]
selection = _antialias_selection(
polygon_mask((width, height), polygon, "NONZERO")
)
selection = visible_selection_mask(
rgba[:, :, 3],
selection,
invert,
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
)
rgba[:, :, 3] *= selection
rgba[rgba[:, :, 3] <= 0.0, :3] = 0.0
if not np.any(rgba[:, :, 3] > 0.0):
raise ImageEditWarning("The selection contains no visible image pixels")
return rgba, selection
def _new_material(
context,
source_object,
color_image,
normal_image=None,
normal_space="TANGENT",
):
return create_material(
material_data_name(source_object),
color_image,
0.0,
normal_image=normal_image,
normal_space=normal_space,
scene=context.scene,
)
def build_cutout_object(
context,
source_object,
shape,
detail,
material,
field=None,
regions=None,
image_size=None,
bounds=None,
keep_original=False,
refine_selection=False,
):
import numpy as np
bounds = bounds or plane_bounds(source_object)
crop_center = (
(bounds[0] + bounds[1]) * 0.5,
(bounds[2] + bounds[3]) * 0.5,
)
world_metric, world_z_scale = _world_plane_metric(source_object.matrix_world)
depth_image = None
depth_reference = None
depth_uniform_scale = None
depth_direction = None
preferences = addon_preferences(context)
balloon_algorithm = getattr(
preferences, "balloon_profile_algorithm", "POISSON"
)
point_spacing = _cutout_point_spacing(source_object, detail)
cleanup_area = _cutout_cleanup_area(point_spacing, world_metric)
domain = _cutout_domain(
regions,
image_size,
bounds,
point_spacing,
world_metric,
balloon_algorithm,
)
source_vertices, faces, uv, boundary, planar, balloon_profile = domain
faces = _counter_clockwise_faces(planar, faces)
vertices = _orient_cutout_vertices(source_vertices)
if world_z_scale <= 1e-10:
raise ValueError("The Image Empty has a degenerate world transform")
balloon_profile /= world_z_scale
if shape in {"DEPTH_BALLOON", "DEPTH_SURFACE"}:
(
points,
_intrinsics,
valid,
calibration_valid,
_image_size,
_reference,
) = field
depth_image = _surface_depth_image(
source_object, points, valid, points[..., 2]
)
depth_uniform_scale = _surface_uniform_scale(
source_object,
field,
bounds,
)
if not refine_selection:
depth_reference = UNREFINED_DEPTH_REFERENCE
if shape == "DEPTH_BALLOON":
depth_direction = DEFAULT_DEPTH_DIRECTION
elif shape == "DEPTH_SURFACE":
depth_reference = metric_depth_reference(
points[..., 2],
calibration_valid,
depth_uniform_scale,
)
else:
depth_reference, depth_direction = depth_balloon_projection_parameters(
vertices,
uv,
points[..., 2],
calibration_valid,
depth_uniform_scale,
)
source_name = source_object.name
mesh = bpy.data.meshes.new(source_name)
mesh.from_pydata(np.asarray(vertices).tolist(), [], faces)
for polygon in mesh.polygons:
polygon.use_smooth = True
_assign_uv(mesh, uv)
_assign_float_point_attribute(mesh, BALLOON_ATTRIBUTE_NAME, balloon_profile)
mesh.materials.append(material)
mesh.update()
mesh_object = bpy.data.objects.new(source_name, mesh)
context.collection.objects.link(mesh_object)
mesh_object.matrix_world = _cutout_world_matrix(
source_object.matrix_world, crop_center
)
mesh_object["anyimage_mesh_shape"] = shape
node_group = cutout_node_group(shape)
modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES")
modifier.node_group = node_group
modifier.show_group_selector = False
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Shape"),
CUTOUT_SHAPE_INDICES[shape],
)
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Cleanup"),
cleanup_area,
)
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Smooth Iterations"),
0,
)
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Smooth"),
1.0,
)
thickness = 1.0 if shape in {"BALLOON", "DEPTH_BALLOON"} else 0.0
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Thickness"),
thickness,
)
if shape in {"DEPTH_BALLOON", "DEPTH_SURFACE"}:
set_modifier_input(
modifier,
modifier_input_identifier(
node_group,
"Depth Scale",
),
1.0,
)
if shape == "DEPTH_SURFACE":
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Uniform Scale"),
depth_uniform_scale,
)
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Reference Depth"),
depth_reference,
)
if shape == "DEPTH_BALLOON":
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Reference Depth"),
depth_reference,
)
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Depth Direction"),
depth_direction,
)
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Uniform Scale"),
depth_uniform_scale,
)
if depth_image is not None:
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Depth Image"),
depth_image,
)
for selected in context.selected_objects:
selected.select_set(False)
mesh_object.select_set(True)
context.view_layer.objects.active = mesh_object
if not keep_original:
bpy.data.objects.remove(source_object, do_unlink=True)
mesh_object.name = source_name
return mesh_object
def _load_normal_image(path, source_object):
image = bpy.data.images.load(str(path), check_existing=False)
image.name = normal_data_name(source_object)
image.colorspace_settings.name = "Non-Color"
image.pack()
return image
def _orient_cutout_object_normal(image):
import numpy as np
width, height = (int(value) for value in image.size)
pixels = np.empty(width * height * 4, dtype=np.float32)
image.pixels.foreach_get(pixels)
rgba = pixels.reshape((height, width, 4))
source = rgba[:, :, :3] * 2.0 - 1.0
oriented = np.empty_like(source)
oriented[:, :, 0] = -source[:, :, 2]
oriented[:, :, 1] = -source[:, :, 0]
oriented[:, :, 2] = source[:, :, 1]
rgba[:, :, :3] = np.clip(oriented * 0.5 + 0.5, 0.0, 1.0)
image.pixels.foreach_set(rgba.ravel())
image.update()
def limited_cutout_mesh_detail(mesh_detail, crop_size, max_input_size):
crop_width, crop_height = (int(value) for value in crop_size)
limited_width, limited_height = limited_image_size(
(crop_width, crop_height),
max_input_size,
)
scale = max(limited_width, limited_height) / max(crop_width, crop_height)
return max(4, min(1024, round(int(mesh_detail) * scale)))
def _cutout_point_spacing(source_object, detail):
import math
x_min, x_max, y_min, y_max = plane_bounds(source_object)
metric, _world_z_scale = _world_plane_metric(source_object.matrix_world)
width = (x_max - x_min) * math.sqrt(max(metric[0][0], 0.0))
height = (y_max - y_min) * math.sqrt(max(metric[1][1], 0.0))
return max(width, height) / max(int(detail), 8)
def build_local_cutout(
operator,
context,
source_object,
shape,
selection_data,
invert=False,
):
if video_total_frames(source_object.data) != 1:
raise ValueError("Mesh conversion currently supports still images")
polygon = json.loads(selection_data) if selection_data else None
if not isinstance(polygon, list) or len(polygon) < 3:
raise ValueError("The Cutout selection is invalid")
_rgba, selection = _local_cutout_rgba(source_object, polygon, invert)
source_size = tuple(source_object.data.size)
crop_bounds = _mask_crop_bounds(selection)
left, top, right, bottom = crop_bounds
cropped_selection = selection[top:bottom, left:right]
mesh_detail = limited_cutout_mesh_detail(
operator.mesh_detail,
(right - left, bottom - top),
configured_max_ai_input_size(context),
)
regions, image_size = _cutout_regions(
cropped_selection,
mesh_detail,
threshold=CUTOUT_ALPHA_THRESHOLD,
)
local_bounds = _crop_plane_bounds(
source_object,
crop_bounds,
source_size,
)
color_image = None
material = None
try:
color_image = _cropped_color_image(source_object, crop_bounds)
material = _new_material(context, source_object, color_image)
build_cutout_object(
context,
source_object,
shape,
mesh_detail,
material,
regions=regions,
image_size=image_size,
bounds=local_bounds,
keep_original=True,
)
except Exception:
if material is not None and material.users == 0:
bpy.data.materials.remove(material)
if color_image is not None and color_image.users == 0:
bpy.data.images.remove(color_image)
raise
push_undo(f"Convert to {shape.replace('_', ' ').title()}")
def build_cutout_response(context, result, operator):
source_object = bpy.data.objects.get(operator.source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty no longer exists")
source_size = tuple(source_object.data.size)
crop_bounds = tuple(
int(value)
for value in json.loads(
result.file("bounds").read_text(encoding="utf-8")
)
)
local_bounds = _crop_plane_bounds(
source_object,
crop_bounds,
source_size,
)
selection_image = bpy.data.images.load(
str(result.file("selection")),
check_existing=False,
)
try:
selection_alpha = _image_rgba(selection_image)[:, :, 3]
mesh_detail = limited_cutout_mesh_detail(
operator.mesh_detail,
selection_image.size,
operator.max_input_size,
)
regions, image_size = _cutout_regions(
selection_alpha,
mesh_detail,
threshold=CUTOUT_ALPHA_THRESHOLD,
)
finally:
bpy.data.images.remove(selection_image)
color_image = create_color_image(
source_object,
result.directory,
assign_to_source=False,
)
normal_path = result.directory / "normal.png"
normal_image = (
_load_normal_image(normal_path, source_object)
if normal_path.is_file()
else None
)
if normal_image is not None and operator.shape == "DEPTH_SURFACE":
_orient_cutout_object_normal(normal_image)
material = _new_material(
context,
source_object,
color_image,
normal_image,
normal_space=(
"OBJECT"
if operator.shape == "DEPTH_SURFACE" and normal_image is not None
else "TANGENT"
),
)
field = (
_load_field(result.file("field"))
if operator.shape in {"DEPTH_BALLOON", "DEPTH_SURFACE"}
else None
)
mesh_object = build_cutout_object(
context,
source_object,
operator.shape,
mesh_detail,
material,
field=field,
regions=regions,
image_size=image_size,
bounds=local_bounds,
keep_original=True,
refine_selection=operator.refine_selection,
)
if normal_image is not None and operator.shape != "DEPTH_SURFACE":
bake_tangent_normal_image(
context,
source_object,
mesh_object,
normal_image,
)
push_undo(f"Convert to {operator.shape.replace('_', ' ').title()}")

View File

@@ -0,0 +1,166 @@
"""Calibrate metric depth for Cutout Depth Balloon and Depth Surface."""
PLANE_VARIANCE_FLOOR_RATIO = 0.025
DEPTH_REFERENCE_PERCENTILE = 95.0
UNREFINED_DEPTH_REFERENCE = 8.0
DEFAULT_DEPTH_DIRECTION = (0.0, 0.0, 1.0)
def metric_depth_reference(camera_depth, valid, uniform_scale):
"""Return the shared upper-percentile scene depth for both Depth shapes."""
import numpy as np
camera_depth = np.asarray(camera_depth, dtype=np.float32)
valid = np.asarray(valid, dtype=bool)
if valid.shape != camera_depth.shape:
raise ValueError("Depth values and validity mask dimensions do not match")
usable = valid & np.isfinite(camera_depth) & (camera_depth > 0.0)
if not usable.any():
raise ValueError("The depth field contains no valid metric depth")
if not uniform_scale > 0.0:
raise ValueError("Depth Uniform Scale must be positive")
reference = np.percentile(
camera_depth[usable],
DEPTH_REFERENCE_PERCENTILE,
)
return float(reference) * float(uniform_scale)
def _sample_valid_bilinear(values, valid, x, y):
import numpy as np
height, width = values.shape
x = np.clip(x, 0.0, max(width - 1, 0))
y = np.clip(y, 0.0, max(height - 1, 0))
x0 = np.floor(x).astype(np.int64)
y0 = np.floor(y).astype(np.int64)
x1 = np.minimum(x0 + 1, width - 1)
y1 = np.minimum(y0 + 1, height - 1)
fx = x - x0
fy = y - y0
samples = np.stack(
(
values[y0, x0],
values[y0, x1],
values[y1, x0],
values[y1, x1],
),
axis=1,
)
weights = np.stack(
(
(1.0 - fx) * (1.0 - fy),
fx * (1.0 - fy),
(1.0 - fx) * fy,
fx * fy,
),
axis=1,
)
sample_valid = np.stack(
(
valid[y0, x0],
valid[y0, x1],
valid[y1, x0],
valid[y1, x1],
),
axis=1,
)
weights *= sample_valid
totals = weights.sum(axis=1)
result = np.zeros(len(x), dtype=np.float32)
usable = totals > 1e-8
result[usable] = (samples[usable] * weights[usable]).sum(axis=1) / totals[usable]
return result, usable
def _weighted_plane(coordinates, values, weights):
import numpy as np
total = float(np.sum(weights))
if total <= 1e-12:
return np.zeros(3, dtype=np.float32)
center = np.sum(coordinates * weights[:, None], axis=0) / total
value_center = float(np.sum(values * weights) / total)
centered = coordinates - center
covariance = (centered * weights[:, None]).T @ centered / total
cross = centered.T @ (weights * (values - value_center)) / total
eigenvalues, eigenvectors = np.linalg.eigh(covariance)
largest = max(float(eigenvalues[-1]), 0.0)
if largest <= 1e-12:
slopes = np.zeros(2, dtype=np.float64)
else:
supported = np.maximum(eigenvalues, largest * PLANE_VARIANCE_FLOOR_RATIO)
slopes = eigenvectors @ ((eigenvectors.T @ cross) / supported)
intercept = value_center - float(center @ slopes)
return np.asarray((*slopes, intercept), dtype=np.float32)
def _stable_plane(coordinates, values):
import numpy as np
coordinates = np.asarray(coordinates, dtype=np.float64)
values = np.asarray(values, dtype=np.float64)
if len(coordinates) < 2:
return np.asarray((0.0, 0.0, np.median(values)), dtype=np.float32)
weights = np.ones(len(values), dtype=np.float64)
plane = np.zeros(3, dtype=np.float32)
for _iteration in range(4):
plane = _weighted_plane(coordinates, values, weights)
residual = values - coordinates @ plane[:2] - plane[2]
centered = residual - np.median(residual)
scale = max(float(np.median(np.abs(centered))) * 1.4826, 1e-6)
weights = np.minimum(
1.0,
(2.5 * scale) / np.maximum(np.abs(centered), 1e-12),
)
return plane
def depth_balloon_projection_parameters(
vertices,
vertex_uv,
camera_depth,
valid,
uniform_scale,
):
"""Fit a metric base plane and return its center depth and direction."""
import numpy as np
vertices = np.asarray(vertices, dtype=np.float32)
uv = np.asarray(vertex_uv, dtype=np.float32)
if vertices.ndim != 2 or vertices.shape[1] != 3:
raise ValueError("Depth Balloon vertices must have three components")
if uv.shape != (len(vertices), 2):
raise ValueError("Depth Balloon UV coordinates do not match its vertices")
camera_depth = np.asarray(camera_depth, dtype=np.float32)
valid = np.asarray(valid, dtype=bool)
if valid.shape != camera_depth.shape:
raise ValueError("Depth Balloon depth and mask dimensions do not match")
if not uniform_scale > 0.0:
raise ValueError("Depth Balloon Uniform Scale must be positive")
height, width = camera_depth.shape
sample_x = uv[:, 0] * max(width - 1, 0)
sample_y = (1.0 - uv[:, 1]) * max(height - 1, 0)
calibration_valid = valid & np.isfinite(camera_depth) & (camera_depth > 0.0)
sampled, usable = _sample_valid_bilinear(
camera_depth,
calibration_valid,
sample_x,
sample_y,
)
if not usable.any():
raise ValueError("MoGe-2 found no valid Depth Balloon samples")
local_coordinates = vertices[:, 1:]
metric_depth = sampled[usable] * float(uniform_scale)
plane = _stable_plane(local_coordinates[usable], metric_depth)
reference = metric_depth_reference(
camera_depth,
calibration_valid,
uniform_scale,
)
direction = np.asarray((-plane[0], plane[1], 1.0), dtype=np.float64)
direction /= np.linalg.norm(direction)
return reference, tuple(float(value) for value in direction)

View File

@@ -1,3 +1,6 @@
"""Convert a Cutout Alpha mask into outer contours and holes."""
def _choose_boundary_edge(start, direction, candidates):
right = (-direction[1], direction[0])
left = (direction[1], -direction[0])
@@ -193,7 +196,13 @@ def alpha_regions(alpha, max_resolution=512, threshold=0.5):
return (
[
[
[[float(x) * scale_x, float(y) * scale_y] for x, y in contour]
[
[
min(max(float(x) * scale_x, 0.0), source_size[0]),
min(max(float(y) * scale_y, 0.0), source_size[1]),
]
for x, y in contour
]
for contour in region
]
for region in regions

View File

@@ -0,0 +1,10 @@
"""Load Geometry Nodes assets used by Cutout shapes."""
from ..common.node_asset import load_asset_node_group
from .shape_types import CUTOUT_NODE_GROUP_NAME, CUTOUT_SHAPES
def cutout_node_group(shape):
if shape not in CUTOUT_SHAPES:
raise ValueError(f"Unsupported Cutout shape: {shape}")
return load_asset_node_group(CUTOUT_NODE_GROUP_NAME)

View File

@@ -289,7 +289,11 @@ def prepare_planar_contours(
if perimeter <= 1e-12:
continue
contour_spacing = min(spacing, perimeter / 8.0)
contours.append(smooth_lasso_contour(planar, contour_spacing))
smoothed = smooth_lasso_contour(planar, contour_spacing)
smoothed_local = smoothed @ np.linalg.inv(transform)
smoothed_local[:, 0] = np.clip(smoothed_local[:, 0], x_min, x_max)
smoothed_local[:, 1] = np.clip(smoothed_local[:, 1], y_min, y_max)
contours.append(smoothed_local @ transform)
if not contours:
raise ValueError("The Cutout has no region large enough for this resolution")
interior = uniform_interior_points_for_contours(contours, spacing)
@@ -446,4 +450,3 @@ def triangulate_planar_regions(regions, spacing, return_components=False):
)
return (*result, components) if return_components else result

View File

@@ -2,12 +2,11 @@ import json
import bpy
from ..properties import (
from ...properties import (
refine_selection_property,
)
from .image_edit.interaction import hidden_string_property, serialize_matrix
from .image import is_image_empty, video_total_frames
from .image_edit.common import (
from ..common.image import is_image_empty, video_total_frames
from ..common.image_edit import (
ImageEditWarning,
apply_image_mask_settings,
draw_image_mask_settings,
@@ -15,12 +14,9 @@ from .image_edit.common import (
report_image_edit_exception,
resolve_image_edit_click,
)
from .image_edit.mask import (
ImageMaskGesture,
gesture_property,
selected_image_rgba,
)
from .mesh import BALLOON, CUTOUT, DEPTH_BALLOON, DEPTH_SURFACE
from ..common.image_interaction import hidden_string_property, serialize_matrix
from ..common.image_gesture import ImageMaskGesture, gesture_property
from ..common.image_mask import selected_image_rgba
CUTOUT_DETAIL_MIN = 4
CUTOUT_DETAIL_MAX = 1024
@@ -77,7 +73,7 @@ def _open_shape_pie(
pie = menu.layout.menu_pie()
_pie_button(
pie,
CUTOUT,
"CUTOUT",
"Surface",
"MESH_GRID",
source_name,
@@ -89,7 +85,7 @@ def _open_shape_pie(
)
_pie_button(
pie,
BALLOON,
"BALLOON",
"Balloon",
"MESH_UVSPHERE",
source_name,
@@ -101,7 +97,7 @@ def _open_shape_pie(
)
_pie_button(
pie,
DEPTH_BALLOON,
"DEPTH_BALLOON",
"Depth Balloon",
"META_BALL",
source_name,
@@ -113,7 +109,7 @@ def _open_shape_pie(
)
_pie_button(
pie,
DEPTH_SURFACE,
"DEPTH_SURFACE",
"Depth Surface",
"SURFACE_NCURVE",
source_name,

View File

@@ -0,0 +1,214 @@
import json
import bpy
from ...preferences import addon_preferences, configured_max_ai_input_size
from ...properties import BEN2_MODEL_KEY, DEFAULT_MOGE2_MODEL_KEY
from ...runtime import JobOperatorBase
from ...server.media.resolution import (
DEFAULT_MAX_AI_INPUT_SIZE,
MAX_MAX_AI_INPUT_SIZE,
MIN_MAX_AI_INPUT_SIZE,
)
from ..common.image import (
is_image_empty,
prepare_image_input,
remove_temporary_input,
video_total_frames,
)
from ..common.image_edit import report_image_edit_exception
from ..common.operator import (
HIDDEN,
cleanup_temporary_input,
input_path,
invoke_ai_setup_if_needed,
moge2_parameters,
production_device,
require_environment,
require_model,
source_start_frame,
)
from .build_shapes import build_cutout_response, build_local_cutout
from .shape_types import DEPTH_CUTOUT_SHAPES, LOCAL_CUTOUT_SHAPES
def cutout_moge_settings(context):
preferences = addon_preferences(context)
model = getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY)
if not model.startswith("MOGE2_"):
model = DEFAULT_MOGE2_MODEL_KEY
return (
model,
int(getattr(preferences, "mesh_resolution_level", 5)),
)
class GenerateCutoutShape(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.generate_cutout_shape"
bl_label = "Generate Cutout Shape"
job_type = "prepare-geometry-data"
starting_message = "Preparing Cutout shape"
input_path: bpy.props.StringProperty(options=HIDDEN)
source_object_name: bpy.props.StringProperty(options=HIDDEN)
shape: bpy.props.StringProperty(options=HIDDEN)
selection_data: bpy.props.StringProperty(options=HIDDEN)
mesh_detail: bpy.props.IntProperty(default=128, min=4, max=1024, options=HIDDEN)
model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN)
resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=HIDDEN)
max_input_size: bpy.props.IntProperty(
default=DEFAULT_MAX_AI_INPUT_SIZE,
min=MIN_MAX_AI_INPUT_SIZE,
max=MAX_MAX_AI_INPUT_SIZE,
options=HIDDEN,
)
normal_analysis: bpy.props.BoolProperty(default=False, options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
refine_selection: bpy.props.BoolProperty(default=False, options=HIDDEN)
invert: bpy.props.BoolProperty(default=False, options=HIDDEN)
def request(self, context):
require_environment()
if self.shape in DEPTH_CUTOUT_SHAPES or self.normal_analysis:
require_model(self.model)
if self.refine_selection:
require_model(BEN2_MODEL_KEY)
polygon = json.loads(self.selection_data) if self.selection_data else None
if polygon is None or len(polygon) < 3:
raise ValueError("The Cutout selection is invalid")
parameters = {
"input": str(input_path(self.input_path)),
"analysis_input": str(input_path(self.input_path)),
"shape": self.shape,
"polygon": polygon,
"mesh_detail": self.mesh_detail,
"device": production_device().lower(),
"video_frames": 1,
"start_frame": source_start_frame(self.source_object_name),
"max_input_size": int(self.max_input_size),
"refine_selection": self.refine_selection,
"normal_analysis": self.normal_analysis,
"invert": self.invert,
}
if self.shape in DEPTH_CUTOUT_SHAPES or self.normal_analysis:
parameters.update(
model=self.model,
resolution_level=self.resolution_level,
)
if self.refine_selection:
parameters["refine_model"] = BEN2_MODEL_KEY
if self.shape not in DEPTH_CUTOUT_SHAPES:
return parameters
settings = context.scene.anyimage_settings
parameters.update(
moge2_parameters(
settings,
input_path(self.input_path),
self.model,
self.resolution_level,
source_start_frame(self.source_object_name),
)
)
parameters.update(shape=self.shape, polygon=polygon)
return parameters
def response(self, context, result):
build_cutout_response(context, result, self)
return f"{self.shape.replace('_', ' ').title()} is ready"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
def start_cutout_job(
operator,
context,
shape,
selection_data,
refine_selection=False,
normal_analysis=False,
invert=False,
):
source_object = context.object
if video_total_frames(source_object.data) != 1:
operator.report({"ERROR"}, "Cutout currently supports still images")
return {"CANCELLED"}
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
try:
source_path, delete_input = prepare_image_input(
source_object.data,
context.scene,
)
model, resolution_level = cutout_moge_settings(context)
except (RuntimeError, ValueError) as error:
operator.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.anyimage.generate_cutout_shape(
input_path=str(source_path),
source_object_name=source_object.name,
shape=shape,
selection_data=selection_data,
mesh_detail=operator.mesh_detail,
model=model,
resolution_level=resolution_level,
max_input_size=configured_max_ai_input_size(context),
normal_analysis=normal_analysis,
delete_input=delete_input,
refine_selection=refine_selection,
invert=invert,
)
if "CANCELLED" in result and delete_input:
remove_temporary_input(source_path)
return {"CANCELLED"} if "CANCELLED" in result else {"FINISHED"}
class ConvertCutoutShape(bpy.types.Operator):
bl_idname = "anyimage.convert_cutout_shape"
bl_label = "Convert Cutout Shape"
bl_description = "Convert the saved Cutout selection into the chosen shape"
bl_options = {"UNDO"}
source_object_name: bpy.props.StringProperty(options=HIDDEN)
selection_data: bpy.props.StringProperty(options=HIDDEN)
shape: bpy.props.StringProperty(options=HIDDEN)
mesh_detail: bpy.props.IntProperty(default=128, min=4, max=1024, options=HIDDEN)
refine_selection: bpy.props.BoolProperty(default=False, options=HIDDEN)
normal_analysis: bpy.props.BoolProperty(default=False, options=HIDDEN)
invert: bpy.props.BoolProperty(default=False, options=HIDDEN)
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"}
context.view_layer.objects.active = source_object
source_object.select_set(True)
if (
self.shape in LOCAL_CUTOUT_SHAPES
and not self.refine_selection
and not self.normal_analysis
):
try:
build_local_cutout(
self,
context,
source_object,
self.shape,
self.selection_data,
self.invert,
)
except Exception as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
return {"FINISHED"}
return start_cutout_job(
self,
context,
self.shape,
self.selection_data,
self.refine_selection,
self.normal_analysis,
self.invert,
)

View File

@@ -0,0 +1,23 @@
"""Cutout shape identifiers and Geometry Nodes asset names."""
CUTOUT_NODE_GROUP_NAME = "O Image Cutout"
CUTOUT_NODE_GROUP_NAMES = {
"CUTOUT": "O Image Surface",
"BALLOON": "O Image Balloon",
"DEPTH_BALLOON": "O Image Depth Balloon",
"DEPTH_SURFACE": "O Image Depth Surface",
}
CUTOUT_SHAPE_LABELS = {
"CUTOUT": "Surface",
"BALLOON": "Balloon",
"DEPTH_BALLOON": "Depth Balloon",
"DEPTH_SURFACE": "Depth Surface",
}
CUTOUT_SHAPE_INDICES = {
shape: index for index, shape in enumerate(CUTOUT_NODE_GROUP_NAMES)
}
CUTOUT_SHAPES = frozenset(CUTOUT_NODE_GROUP_NAMES)
LOCAL_CUTOUT_SHAPES = frozenset({"CUTOUT", "BALLOON"})
DEPTH_CUTOUT_SHAPES = frozenset({"DEPTH_BALLOON", "DEPTH_SURFACE"})
BALLOON_ATTRIBUTE_NAME = "o_balloon"

View File

@@ -2,7 +2,7 @@ import bpy
from ..runtime import JobOperatorBase
from ..properties import BEN2_MODEL_KEY, REALESRGAN_GENERAL_WDN_MODEL_KEY
from .common import (
from .common.operator import (
background_input_path,
da3_parameters,
input_path,

View File

@@ -1 +0,0 @@
"""Depth-plane asset builders retained for asset validation."""

View File

@@ -1,547 +0,0 @@
import bpy
from ..image import (
copy_depth_image,
create_sequence_image,
depth_data_name,
image_user_settings,
is_animated_image,
material_data_name,
plane_bounds,
sorted_frame_paths,
)
from ..material import (
DEPTH_PLANE_NODE_GROUP_NAME,
DISPLACEMENT_MIDLEVEL,
DISPLACEMENT_STRENGTH,
MOGE_DEPTH_PLANE_NODE_GROUP_NAME,
ORIGINAL_DISPLACEMENT_MIDLEVEL,
ORIGINAL_DISPLACEMENT_STRENGTH,
create_material,
create_moge_material,
drive_modifier_input,
load_depth_plane_node_group,
modifier_input_identifier,
set_modifier_input,
)
from .data import (
camera_plane_bounds,
create_depth_image,
create_depth_sequence_image,
depth_displacement_values,
extract_depth_arrays,
extract_intrinsics_array,
prepare_depth_values,
scale_depth_values,
)
def plane_fit_transform(source_object, camera_bounds):
target_bounds = plane_bounds(source_object)
camera_width = camera_bounds[1] - camera_bounds[0]
camera_height = camera_bounds[3] - camera_bounds[2]
if camera_width <= 0.0 or camera_height <= 0.0:
raise ValueError("The camera plane has invalid dimensions")
target_width = target_bounds[1] - target_bounds[0]
target_height = target_bounds[3] - target_bounds[2]
scale_x = target_width / camera_width
scale_y = target_height / camera_height
scale_z = (scale_x * scale_y) ** 0.5
camera_center_x = (camera_bounds[0] + camera_bounds[1]) * 0.5
camera_center_y = (camera_bounds[2] + camera_bounds[3]) * 0.5
target_center_x = (target_bounds[0] + target_bounds[1]) * 0.5
target_center_y = (target_bounds[2] + target_bounds[3]) * 0.5
return (
scale_x,
scale_y,
scale_z,
target_center_x - camera_center_x * scale_x,
target_center_y - camera_center_y * scale_y,
)
def apply_plane_transform(plane_object, source_object, camera_bounds):
from mathutils import Matrix
scale_x, scale_y, scale_z, offset_x, offset_y = plane_fit_transform(
source_object,
camera_bounds,
)
fit_matrix = Matrix(
(
(scale_x, 0.0, 0.0, offset_x),
(0.0, scale_y, 0.0, offset_y),
(0.0, 0.0, 1.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
)
plane_object.matrix_world = source_object.matrix_world @ fit_matrix
return abs(float(plane_object.scale.z)) * abs(scale_z)
def apply_object_scale_to_mesh(plane_object):
"""Bake object scale into the plane while keeping location and rotation."""
from mathutils import Matrix
scale = plane_object.scale.copy()
plane_object.data.transform(
Matrix.Diagonal((scale.x, scale.y, scale.z, 1.0))
)
plane_object.data.update()
plane_object.scale = (1.0, 1.0, 1.0)
def grid_segments(bounds, subdivision_level):
if subdivision_level < 0:
raise ValueError("Subdivision must not be negative")
width = bounds[1] - bounds[0]
height = bounds[3] - bounds[2]
if width <= 0.0 or height <= 0.0:
raise ValueError("The plane has invalid dimensions")
short_segments = 2**subdivision_level
if width >= height:
return (
max(short_segments, int(short_segments * width / height + 0.5)),
short_segments,
)
return (
short_segments,
max(short_segments, int(short_segments * height / width + 0.5)),
)
def create_plane_mesh(source_object, bounds=None, subdivision_level=5):
x_min, x_max, y_min, y_max = (
bounds if bounds is not None else plane_bounds(source_object)
)
if subdivision_level is None:
x_segments, y_segments = 1, 1
else:
x_segments, y_segments = grid_segments(
(x_min, x_max, y_min, y_max),
subdivision_level,
)
vertices = []
uv_coordinates = []
for y_index in range(y_segments + 1):
v = y_index / y_segments
y = y_min + (y_max - y_min) * v
for x_index in range(x_segments + 1):
u = x_index / x_segments
x = x_min + (x_max - x_min) * u
vertices.append((x, y, 0.0))
uv_coordinates.append((u, v))
faces = []
row_width = x_segments + 1
for y_index in range(y_segments):
for x_index in range(x_segments):
bottom_left = y_index * row_width + x_index
faces.append(
(
bottom_left,
bottom_left + 1,
bottom_left + row_width + 1,
bottom_left + row_width,
)
)
mesh = bpy.data.meshes.new(source_object.name)
mesh.from_pydata(vertices, (), faces)
mesh.update()
for polygon in mesh.polygons:
polygon.use_smooth = True
uv_layer = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
uv_layer.data[loop.index].uv = uv_coordinates[
loop.vertex_index
]
return mesh
def add_depth_plane_processing(
plane_object,
depth_image,
color_image,
material,
frame_start=1,
frame_duration=1,
use_cyclic=False,
animate_frame=False,
):
node_group = load_depth_plane_node_group()
modifier = plane_object.modifiers.new(
name=DEPTH_PLANE_NODE_GROUP_NAME,
type="NODES",
)
modifier.node_group = node_group
modifier.show_group_selector = False
for name, value in (
("Depth Image", depth_image),
("Color Image", color_image),
("Material", material),
("Frame", 1),
("Frame Start", frame_start),
("Frames", max(int(frame_duration or 1), 1)),
("Cyclic", use_cyclic),
):
identifier = modifier_input_identifier(node_group, name)
set_modifier_input(modifier, identifier, value)
if animate_frame:
frame_identifier = modifier_input_identifier(node_group, "Frame")
drive_modifier_input(plane_object, modifier, frame_identifier)
plane_object.update_tag(refresh={"DATA"})
return modifier
def add_moge_depth_plane_processing(
plane_object,
depth_image,
color_image,
material,
frame_start=1,
frame_duration=1,
use_cyclic=False,
animate_frame=False,
):
node_group = load_depth_plane_node_group(
MOGE_DEPTH_PLANE_NODE_GROUP_NAME
)
modifier = plane_object.modifiers.new(
name=MOGE_DEPTH_PLANE_NODE_GROUP_NAME,
type="NODES",
)
modifier.node_group = node_group
modifier.show_group_selector = False
for name, value in (
("Depth Image", depth_image),
("Color Image", color_image),
("Material", material),
("Frame", 1),
("Frame Start", frame_start),
("Frames", max(int(frame_duration or 1), 1)),
("Cyclic", use_cyclic),
):
identifier = modifier_input_identifier(node_group, name)
set_modifier_input(modifier, identifier, value)
if animate_frame:
frame_identifier = modifier_input_identifier(node_group, "Frame")
drive_modifier_input(plane_object, modifier, frame_identifier)
plane_object.update_tag(refresh={"DATA"})
return modifier
def finish_depth_plane(
context,
source_object,
plane_object,
delete_source,
):
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 delete_source:
bpy.data.objects.remove(source_object, do_unlink=True)
plane_object.name = source_name
return plane_object
def build_image_depth_plane(
context,
source_object,
delete_source,
shadeless=False,
color_image=None,
depth_frames_directory=None,
depth_image=None,
):
source_name = source_object.name
frame_count = 1
frames_image = None
if depth_frames_directory is not None:
frames_image = create_sequence_image(
depth_data_name(source_object),
depth_frames_directory,
frame_prefix="image.",
)
frame_count = len(
sorted_frame_paths(depth_frames_directory, "image.")
)
(
source_frame_start,
source_frame_offset,
source_frame_duration,
source_use_cyclic,
) = image_user_settings(source_object)
uses_extracted = frames_image is not None
texture_frame_count = (
frame_count if uses_extracted else source_frame_duration
)
texture_frame_offset = 0 if uses_extracted else source_frame_offset
depth_source_image = depth_image or source_object.data
if depth_image is None:
if frames_image is not None:
depth_source_image = frames_image
elif not is_animated_image(source_object.data):
depth_source_image = copy_depth_image(source_object)
material_image = color_image
if material_image is None:
raise RuntimeError("Depth Plane material image is required")
mesh = create_plane_mesh(source_object, subdivision_level=None)
plane_object = bpy.data.objects.new(source_name, mesh)
context.collection.objects.link(plane_object)
plane_object.matrix_world = source_object.matrix_world.copy()
depth_scale = abs(float(plane_object.scale.z))
material = create_material(
material_data_name(source_object),
material_image,
depth_scale,
displacement_image=depth_source_image,
displacement_midlevel=ORIGINAL_DISPLACEMENT_MIDLEVEL,
displacement_scale=ORIGINAL_DISPLACEMENT_STRENGTH,
shadeless=shadeless,
frame_count=frame_count,
frame_start=source_frame_start,
color_frame_offset=texture_frame_offset,
color_frame_duration=texture_frame_count,
depth_frame_offset=texture_frame_offset,
depth_frame_duration=texture_frame_count,
use_cyclic=source_use_cyclic,
scene=context.scene,
)
mesh.materials.append(material)
add_depth_plane_processing(
plane_object,
depth_source_image,
material_image,
material,
frame_start=source_frame_start,
frame_duration=frame_count,
use_cyclic=source_use_cyclic,
animate_frame=is_animated_image(source_object.data),
)
plane_object["anyimage_frame_count"] = frame_count
return finish_depth_plane(
context,
source_object,
plane_object,
delete_source,
)
def build_depth_plane(
context,
source_object,
npz_path,
delete_source,
shadeless=False,
depth_name="",
color_image=None,
depth_frames_directory=None,
):
source_name = source_object.name
raw_depth_arrays = extract_depth_arrays(npz_path)
raw_depth = raw_depth_arrays[0]
intrinsics = extract_intrinsics_array(npz_path)
raw_depth_values = prepare_depth_values(raw_depth)
bounds, reference_depth = camera_plane_bounds(
source_object,
raw_depth_values,
intrinsics,
DISPLACEMENT_STRENGTH,
)
depth_arrays = [
depth_displacement_values(frame) for frame in raw_depth_arrays
]
depth_name = depth_name or depth_data_name(source_object)
frame_count = len(depth_arrays)
material_image = color_image
if material_image is None:
raise RuntimeError("Depth Plane material image is required")
(
source_frame_start,
source_frame_offset,
source_frame_duration,
source_use_cyclic,
) = image_user_settings(source_object)
mesh = create_plane_mesh(source_object, bounds, None)
plane_object = bpy.data.objects.new(
source_name,
mesh,
)
context.collection.objects.link(plane_object)
depth_scale = apply_plane_transform(
plane_object,
source_object,
bounds,
)
apply_object_scale_to_mesh(plane_object)
scaled_depth_arrays = [
scale_depth_values(frame, depth_scale) for frame in depth_arrays
]
depth_image = create_depth_image(
depth_name,
scaled_depth_arrays[0],
)
if (
is_animated_image(source_object.data)
and depth_frames_directory is not None
):
bpy.data.images.remove(depth_image, do_unlink=True)
depth_image = create_depth_sequence_image(
depth_name,
scaled_depth_arrays,
context.scene,
depth_frames_directory,
)
material = create_material(
material_data_name(source_object),
material_image,
depth_scale,
displacement_image=depth_image,
displacement_midlevel=DISPLACEMENT_MIDLEVEL,
displacement_scale=DISPLACEMENT_STRENGTH,
shadeless=shadeless,
frame_count=frame_count,
frame_start=source_frame_start,
color_frame_offset=0 if color_image is not None else source_frame_offset,
color_frame_duration=(
frame_count if color_image is not None else source_frame_duration
),
depth_frame_offset=0,
depth_frame_duration=frame_count,
use_cyclic=source_use_cyclic,
scene=context.scene,
)
mesh.materials.append(material)
add_depth_plane_processing(
plane_object,
depth_image,
material_image,
material,
frame_start=source_frame_start,
frame_duration=frame_count,
use_cyclic=source_use_cyclic,
animate_frame=is_animated_image(source_object.data),
)
plane_object["anyimage_reference_depth"] = reference_depth
plane_object["anyimage_camera_plane_width"] = bounds[1] - bounds[0]
plane_object["anyimage_camera_plane_height"] = bounds[3] - bounds[2]
plane_object["anyimage_depth_scale"] = depth_scale
plane_object["anyimage_frame_count"] = frame_count
return finish_depth_plane(
context,
source_object,
plane_object,
delete_source,
)
def build_moge2_depth_plane(
context,
source_object,
npz_path,
delete_source,
shadeless=False,
color_image=None,
data_frames_directory=None,
):
import numpy as np
try:
with np.load(npz_path, allow_pickle=False) as archive:
depth = np.asarray(archive["depth"], dtype=np.float32)
mask = np.asarray(archive["mask"], dtype=bool)
intrinsics = np.asarray(archive["intrinsics"], dtype=np.float32)
except (KeyError, OSError) as error:
raise ValueError(f"Invalid MoGe-2 NPZ: {npz_path}") from error
if depth.ndim == 2:
depth = depth[None, ...]
mask = mask[None, ...]
if intrinsics.ndim == 2:
intrinsics = intrinsics[None, ...]
if not (len(depth) == len(mask) == len(intrinsics)):
raise ValueError("MoGe-2 arrays have different frame counts")
first_valid = mask[0] & np.isfinite(depth[0]) & (depth[0] > 0.0)
if not first_valid.any():
raise ValueError("MoGe-2 mask contains no valid depth")
reference_depth = float(np.median(depth[0][first_valid]))
height, width = depth[0].shape
pixel_intrinsics = intrinsics[0].copy()
pixel_intrinsics[0] *= width
pixel_intrinsics[1] *= height
bounds, _ = camera_plane_bounds(
source_object,
depth[0],
pixel_intrinsics,
1.0,
reference_depth=reference_depth,
)
displacement = [reference_depth - frame for frame in depth]
source_frame_start, _, _, source_use_cyclic = image_user_settings(
source_object
)
mesh = create_plane_mesh(source_object, bounds, None)
plane_object = bpy.data.objects.new(source_object.name, mesh)
context.collection.objects.link(plane_object)
depth_scale = apply_plane_transform(plane_object, source_object, bounds)
apply_object_scale_to_mesh(plane_object)
depth_image = create_depth_image(
depth_data_name(source_object),
displacement[0] * depth_scale,
)
if (
is_animated_image(source_object.data)
and data_frames_directory is not None
):
bpy.data.images.remove(depth_image, do_unlink=True)
depth_image = create_depth_sequence_image(
depth_data_name(source_object),
[frame * depth_scale for frame in displacement],
context.scene,
data_frames_directory,
"moge_depth",
)
material_image = color_image
if material_image is None:
raise RuntimeError("Depth Plane material image is required")
material = create_moge_material(
source_object.name,
material_image,
depth_image,
depth_scale,
shadeless=shadeless,
frame_count=len(depth),
frame_start=source_frame_start,
use_cyclic=source_use_cyclic,
scene=context.scene,
)
mesh.materials.append(material)
modifier = add_moge_depth_plane_processing(
plane_object,
depth_image,
material_image,
material,
frame_start=source_frame_start,
frame_duration=len(depth),
use_cyclic=source_use_cyclic,
animate_frame=is_animated_image(source_object.data),
)
plane_object["anyimage_reference_depth"] = reference_depth
plane_object["anyimage_depth_scale"] = depth_scale
plane_object["anyimage_frame_count"] = len(depth)
return finish_depth_plane(
context,
source_object,
plane_object,
delete_source,
)

View File

@@ -1,261 +0,0 @@
from pathlib import Path
import bpy
from ..image import force_sequence_source, pack_sequence_if_possible
DEPTH_KEY_CANDIDATES = ("depth", "depths")
INTRINSICS_KEY_CANDIDATES = ("intrinsics", "ixts")
def create_depth_sequence_image(
name,
depth_frames,
_scene,
frame_directory,
frame_prefix="depth",
):
"""Write one EXR per depth frame and return a SEQUENCE image datablock."""
import numpy as np
frame_directory = Path(frame_directory)
frame_directory.mkdir(parents=True, exist_ok=True)
height, width = depth_frames[0].shape
image = bpy.data.images.new(
name,
width=width,
height=height,
alpha=False,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
original_file_format = image.file_format
frame_paths = []
try:
image.file_format = "OPEN_EXR"
for frame_index, depth_values in enumerate(depth_frames):
# NPZ arrays use a top-left origin, while Blender image pixels start below.
depth_pixels = np.flipud(depth_values)
rgba = np.empty((height, width, 4), dtype=np.float32)
rgba[..., 0] = depth_pixels
rgba[..., 1] = depth_pixels
rgba[..., 2] = depth_pixels
rgba[..., 3] = 1.0
image.pixels.foreach_set(rgba.ravel())
frame_path = (
frame_directory / f"{frame_prefix}.{frame_index + 1:04d}.exr"
)
image.save(filepath=str(frame_path), save_copy=True)
if not frame_path.is_file():
raise RuntimeError(f"Blender did not write: {frame_path}")
frame_paths.append(frame_path)
finally:
image.file_format = original_file_format
bpy.data.images.remove(image, do_unlink=True)
if not frame_paths:
raise RuntimeError("Unable to write the depth frame sequence")
try:
sequence_image = bpy.data.images.load(
str(frame_paths[0]),
check_existing=False,
)
force_sequence_source(sequence_image)
except (OSError, RuntimeError) as error:
if "sequence_image" in locals():
bpy.data.images.remove(sequence_image, do_unlink=True)
raise RuntimeError(
"Unable to load the depth frame sequence"
) from error
sequence_image.name = name
sequence_image.colorspace_settings.name = "Non-Color"
pack_sequence_if_possible(sequence_image, len(frame_paths))
return sequence_image
def scale_depth_values(depth, depth_scale):
return prepare_depth_values(depth) * depth_scale
def extract_depth_arrays(npz_path):
try:
import numpy as np
except ImportError as error:
raise RuntimeError("Blender Python does not provide NumPy") from error
try:
with np.load(npz_path, allow_pickle=False) as archive:
depth_key = next(
(
key
for key in DEPTH_KEY_CANDIDATES
if key in archive.files
),
None,
)
if depth_key is None:
keys = ", ".join(archive.files) or "none"
raise ValueError(
f"Mini NPZ has no depth array; available keys: {keys}"
)
depth = np.asarray(archive[depth_key], dtype=np.float32)
except OSError as error:
raise ValueError(f"Unable to read Mini NPZ: {npz_path}") from error
if depth.ndim >= 3 and depth.shape[-1] == 1:
depth = depth[..., 0]
if depth.ndim == 2:
depth = depth[None, ...]
if depth.ndim != 3:
raise ValueError(
f"Unsupported depth array shape: {tuple(depth.shape)}"
)
return [depth[index] for index in range(depth.shape[0])]
def extract_depth_array(npz_path):
return extract_depth_arrays(npz_path)[0]
def extract_intrinsics_array(npz_path):
try:
import numpy as np
except ImportError as error:
raise RuntimeError("Blender Python does not provide NumPy") from error
try:
with np.load(npz_path, allow_pickle=False) as archive:
intrinsics_key = next(
(
key
for key in INTRINSICS_KEY_CANDIDATES
if key in archive.files
),
None,
)
if intrinsics_key is None:
keys = ", ".join(archive.files) or "none"
raise ValueError(
"Mini NPZ has no camera intrinsics; "
f"available keys: {keys}"
)
intrinsics = np.asarray(
archive[intrinsics_key],
dtype=np.float32,
)
except OSError as error:
raise ValueError(f"Unable to read Mini NPZ: {npz_path}") from error
if intrinsics.ndim == 3:
intrinsics = intrinsics[0]
if intrinsics.shape != (3, 3):
raise ValueError(
f"Unsupported intrinsics shape: {tuple(intrinsics.shape)}"
)
return intrinsics
def prepare_depth_values(depth):
import numpy as np
valid = np.isfinite(depth)
if not valid.any():
raise ValueError("Depth array contains no finite values")
values = depth.astype(np.float32, copy=True)
values[~valid] = 0.0
return values
def depth_displacement_values(depth):
"""Convert raw DA3 camera distance to positive outward displacement."""
import numpy as np
return 1.0 - np.asarray(depth, dtype=np.float32)
def camera_plane_bounds(
source_object,
depth,
intrinsics,
displacement_strength,
reference_depth=None,
):
import numpy as np
scale = abs(float(displacement_strength))
if scale <= 1e-8:
raise ValueError("Displacement Strength must not be zero")
if reference_depth is None:
valid = np.isfinite(depth) & (depth > 0.0)
if not valid.any():
raise ValueError("Depth array contains no positive finite values")
reference_depth = float(np.median(depth[valid]))
reference_distance = reference_depth * scale
height, width = depth.shape
fx = float(intrinsics[0, 0])
fy = float(intrinsics[1, 1])
cx = float(intrinsics[0, 2])
cy = float(intrinsics[1, 2])
if fx <= 0.0 or fy <= 0.0:
raise ValueError("Camera intrinsics contain an invalid focal length")
source_width, source_height = source_object.data.size
if source_width <= 0 or source_height <= 0:
raise ValueError("The source image has no readable dimensions")
source_aspect = source_width / source_height
# Predicted fx/fy may differ even for square pixels. Preserve their projected
# area while enforcing the source image aspect ratio to avoid image stretch.
raw_width = width * reference_distance / fx
raw_height = height * reference_distance / fy
plane_area = raw_width * raw_height
plane_width = float(np.sqrt(plane_area * source_aspect))
plane_height = float(np.sqrt(plane_area / source_aspect))
x_min = -(cx / width) * plane_width
x_max = (1.0 - cx / width) * plane_width
y_min = -(1.0 - cy / height) * plane_height
y_max = (cy / height) * plane_height
# Preserve an intentional Image Empty origin offset around the camera frame.
offset = source_object.empty_image_offset
x_offset = (float(offset[0]) + 0.5) * plane_width
y_offset = (float(offset[1]) + 0.5) * plane_height
return (
(
x_min + x_offset,
x_max + x_offset,
y_min + y_offset,
y_max + y_offset,
),
reference_depth,
)
def create_depth_image(name, depth_values):
import numpy as np
height, width = depth_values.shape
image = bpy.data.images.new(
name,
width=width,
height=height,
alpha=False,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
image.file_format = "OPEN_EXR"
# NPZ arrays use a top-left origin, while Blender image pixels start below.
depth_pixels = np.flipud(depth_values)
rgba = np.empty((height, width, 4), dtype=np.float32)
rgba[..., 0] = depth_pixels
rgba[..., 1] = depth_pixels
rgba[..., 2] = depth_pixels
rgba[..., 3] = 1.0
image.pixels.foreach_set(rgba.ravel())
# Pack as EXR so values outside 0..1 survive saving and reopening the blend.
image.pack()
return image

View File

@@ -1,521 +0,0 @@
import bpy
from ...properties import BEN2_MODEL_KEY, DEFAULT_MOGE2_MODEL_KEY
from ...runtime import JobOperatorBase
from ..common import (
HIDDEN,
background_input_path,
cleanup_temporary_input,
da3_parameters,
input_path,
invoke_ai_setup_if_needed,
moge2_parameters,
push_undo,
require_environment,
require_model,
source_start_frame,
)
from .build import build_depth_plane, build_image_depth_plane, build_moge2_depth_plane
from ..image import (
analysis_source_image,
default_extract_frames,
depth_data_name,
draw_process_resolution,
is_image_empty,
prepare_image_analysis_inputs,
prepare_image_input,
process_resolution_scale_property,
remove_temporary_input,
scaled_process_resolution,
video_total_frames,
)
def image_depth_model_items(owner, context):
from ...properties import depth_plane_model_enum_items
return depth_plane_model_enum_items(owner, context)
class ConvertToDepthPlane(bpy.types.Operator):
bl_idname = "anyimage.convert_to_depth_plane"
bl_label = "Convert to Depth Plane"
bl_description = "Convert an image to a displaced depth plane"
bl_options = {"REGISTER"}
shadeless: bpy.props.BoolProperty(
name="Shadeless",
description="Display the source color without scene lighting",
default=False,
)
remove_background: bpy.props.BoolProperty(
name="Remove Background",
description="Remove the background during conversion with BEN2",
default=False,
)
model: bpy.props.EnumProperty(
name="Depth From",
description="Model or original image used to build depth",
items=image_depth_model_items,
default=0,
)
process_res_factor: process_resolution_scale_property()
resolution_level: bpy.props.IntProperty(
name="Resolution Level",
description="MoGe-2 inference resolution level; higher is finer and slower",
default=5,
min=0,
max=9,
)
keep_original: bpy.props.BoolProperty(
name="Keep Original",
description="Keep the source Image Empty after conversion",
default=False,
)
@classmethod
def poll(cls, context):
from ...runtime import runtime
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):
layout = self.layout
layout.prop(self, "model")
if self.model in {"SMALL", "BASE"}:
draw_process_resolution(
layout,
self,
analysis_source_image(context.object.data),
)
elif self.model.startswith("MOGE2_"):
layout.prop(self, "resolution_level")
layout.prop(self, "remove_background")
layout.prop(self, "shadeless")
layout.separator(type="LINE")
layout.prop(self, "keep_original")
def execute(self, context):
source_object = context.object
if video_total_frames(source_object.data) > 1:
context.scene.anyimage_settings.video_frames = (
default_extract_frames(source_object)
)
if self.model == "ORIGINAL":
try:
source_path, delete_input = prepare_image_input(
source_object.data,
context.scene,
)
except RuntimeError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
run_original = (
bpy.ops.anyimage.generate_original_background
if self.remove_background
else bpy.ops.anyimage.extract_original_frames
)
result = run_original(
input_path=str(source_path),
source_object_name=source_object.name,
delete_source=not self.keep_original,
delete_input=delete_input,
shadeless=self.shadeless,
)
if "CANCELLED" not in result:
return {"FINISHED"}
if delete_input:
remove_temporary_input(source_path)
return {"CANCELLED"}
if self.model.startswith("MOGE2_"):
try:
(
source_path,
delete_input,
analysis_path,
delete_analysis_input,
) = prepare_image_analysis_inputs(
source_object.data,
context.scene,
)
except RuntimeError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.anyimage.generate_moge2_depth(
input_path=str(source_path),
analysis_input_path=str(analysis_path),
source_object_name=source_object.name,
resolution_level=self.resolution_level,
model=self.model,
delete_source=not self.keep_original,
delete_input=delete_input,
delete_analysis_input=delete_analysis_input,
shadeless=self.shadeless,
remove_background=self.remove_background,
)
if "CANCELLED" not in result:
return {"FINISHED"}
if delete_input:
remove_temporary_input(source_path)
if delete_analysis_input:
remove_temporary_input(analysis_path)
return {"CANCELLED"}
image = analysis_source_image(source_object.data)
try:
process_res = scaled_process_resolution(
image,
self.process_res_factor,
)
except ValueError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
try:
(
source_path,
delete_input,
analysis_path,
delete_analysis_input,
) = prepare_image_analysis_inputs(
source_object.data,
context.scene,
)
except RuntimeError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
result = bpy.ops.anyimage.generate_da3_depth(
input_path=str(source_path),
analysis_input_path=str(analysis_path),
source_object_name=source_object.name,
depth_data_name=depth_data_name(source_object),
model=self.model,
process_res=process_res,
delete_source=not self.keep_original,
delete_input=delete_input,
delete_analysis_input=delete_analysis_input,
shadeless=self.shadeless,
remove_background=self.remove_background,
)
if "CANCELLED" in result:
if delete_input:
remove_temporary_input(source_path)
if delete_analysis_input:
remove_temporary_input(analysis_path)
return {"CANCELLED"}
return {"FINISHED"}
def build_da3_depth_response(
context,
result,
*,
source_object_name,
depth_data_name,
remove_background,
delete_source,
shadeless,
):
from .build import build_depth_plane
from ..image import (
create_color_image,
is_animated_image,
is_image_empty,
)
directory = result.directory
source_object = bpy.data.objects.get(source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source image empty is no longer available")
animated = is_animated_image(source_object.data)
color_image = create_color_image(
source_object,
directory,
assign_to_source=False,
)
build_depth_plane(
context,
source_object,
result.file("depth"),
delete_source,
shadeless=shadeless,
depth_name=depth_data_name,
color_image=color_image,
depth_frames_directory=directory if animated else None,
)
push_undo("Convert to Depth Plane")
def build_moge2_depth_response(
context,
result,
*,
source_object_name,
remove_background,
delete_source,
shadeless,
):
from .build import build_moge2_depth_plane
from ..image import create_color_image, is_image_empty
directory = result.directory
source_object = bpy.data.objects.get(source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source image empty is no longer available")
color_image = create_color_image(
source_object,
directory,
assign_to_source=False,
)
build_moge2_depth_plane(
context,
source_object,
result.file("depth"),
delete_source,
shadeless=shadeless,
color_image=color_image,
data_frames_directory=directory,
)
push_undo("Convert to Depth Plane")
def build_original_depth_response(
context,
result,
*,
source_object_name,
background,
delete_source,
shadeless,
):
from .build import build_image_depth_plane
from ..image import (
create_color_image,
is_animated_image,
is_image_empty,
)
directory = result.directory
source_object = bpy.data.objects.get(source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source image empty is no longer available")
animated = is_animated_image(source_object.data)
color_image = create_color_image(
source_object,
directory,
assign_to_source=False,
)
build_image_depth_plane(
context,
source_object,
delete_source,
shadeless=shadeless,
color_image=color_image,
depth_frames_directory=directory if animated else None,
)
push_undo("Convert to Depth Plane")
class GenerateDa3Depth(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.generate_da3_depth"
bl_label = "Generate DA3 Depth"
job_type = "generate-depth"
starting_message = "Starting depth estimation"
input_path: bpy.props.StringProperty(options=HIDDEN)
analysis_input_path: bpy.props.StringProperty(options=HIDDEN)
source_object_name: bpy.props.StringProperty(options=HIDDEN)
depth_data_name: bpy.props.StringProperty(options=HIDDEN)
model: bpy.props.StringProperty(default="BASE", options=HIDDEN)
process_res: bpy.props.IntProperty(default=1024, min=1, options=HIDDEN)
delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN)
remove_background: bpy.props.BoolProperty(default=False, options=HIDDEN)
def request(self, context):
require_environment()
require_model(self.model)
if self.remove_background:
require_model(BEN2_MODEL_KEY)
settings = context.scene.anyimage_settings
parameters = {
**da3_parameters(
settings,
input_path(self.input_path),
self.model,
self.process_res,
source_start_frame(self.source_object_name),
),
"inference_mode": "per_frame",
"remove_background_model": (
BEN2_MODEL_KEY if self.remove_background else ""
),
}
analysis_value = getattr(self, "analysis_input_path", "") or self.input_path
parameters["analysis_input"] = str(input_path(analysis_value))
return parameters
def response(self, context, result):
build_da3_depth_response(
context,
result,
source_object_name=self.source_object_name,
depth_data_name=self.depth_data_name,
remove_background=self.remove_background,
delete_source=self.delete_source,
shadeless=self.shadeless,
)
return "Depth plane is ready"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
cleanup_temporary_input(
getattr(self, "analysis_input_path", ""),
getattr(self, "delete_analysis_input", False),
)
class GenerateMoge2Depth(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.generate_moge2_depth"
bl_label = "Generate MoGe-2 Depth"
job_type = "generate-depth"
starting_message = "Starting MoGe-2 inference"
input_path: bpy.props.StringProperty(options=HIDDEN)
analysis_input_path: bpy.props.StringProperty(options=HIDDEN)
source_object_name: bpy.props.StringProperty(options=HIDDEN)
model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=HIDDEN)
resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=HIDDEN)
delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
delete_analysis_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN)
remove_background: bpy.props.BoolProperty(default=False, options=HIDDEN)
def request(self, context):
require_environment()
require_model(self.model)
if self.remove_background:
require_model(BEN2_MODEL_KEY)
settings = context.scene.anyimage_settings
parameters = {
**moge2_parameters(
settings,
input_path(self.input_path),
self.model,
self.resolution_level,
source_start_frame(self.source_object_name),
),
"remove_background_model": (
BEN2_MODEL_KEY if self.remove_background else ""
),
}
analysis_value = getattr(self, "analysis_input_path", "") or self.input_path
parameters["analysis_input"] = str(input_path(analysis_value))
return parameters
def response(self, context, result):
build_moge2_depth_response(
context,
result,
source_object_name=self.source_object_name,
remove_background=self.remove_background,
delete_source=self.delete_source,
shadeless=self.shadeless,
)
return "MoGe-2 depth plane is ready"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
cleanup_temporary_input(
getattr(self, "analysis_input_path", ""),
getattr(self, "delete_analysis_input", False),
)
class GenerateOriginalBackground(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.generate_original_background"
bl_label = "Generate Original Background"
job_type = "remove-background"
starting_message = "Starting background removal"
input_path: bpy.props.StringProperty(options=HIDDEN)
source_object_name: bpy.props.StringProperty(options=HIDDEN)
delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN)
def request(self, context):
require_environment()
require_model(BEN2_MODEL_KEY)
settings = context.scene.anyimage_settings
return {
"input": str(background_input_path(self.input_path)),
"model": BEN2_MODEL_KEY,
"device": production_device().lower(),
"video_frames": settings.video_frames,
"start_frame": source_start_frame(self.source_object_name),
}
def response(self, context, result):
build_original_depth_response(
context,
result,
source_object_name=self.source_object_name,
background=True,
delete_source=self.delete_source,
shadeless=self.shadeless,
)
return "Depth plane is ready"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
class ExtractOriginalFrames(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.extract_original_frames"
bl_label = "Extract Original Frames"
job_type = "extract-frames"
starting_message = "Extracting source frames"
input_path: bpy.props.StringProperty(options=HIDDEN)
source_object_name: bpy.props.StringProperty(options=HIDDEN)
delete_source: bpy.props.BoolProperty(default=True, options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
shadeless: bpy.props.BoolProperty(default=False, options=HIDDEN)
def request(self, context):
require_environment()
settings = context.scene.anyimage_settings
return {
"input": str(input_path(self.input_path)),
"video_frames": settings.video_frames,
"start_frame": source_start_frame(self.source_object_name),
}
def response(self, context, result):
build_original_depth_response(
context,
result,
source_object_name=self.source_object_name,
background=False,
delete_source=self.delete_source,
shadeless=self.shadeless,
)
return "Depth plane is ready"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)

View File

@@ -1,16 +1,14 @@
from .box import ImageBoxTool
from .lasso import ImageLassoTool
from .line import ImageLineTool
from .mask import ActivateImageEditTool, EditImageMask, RefineImageMask
from .perspective import EditImagePerspective, ImagePerspectiveTool
from .edit_mask import ActivateImageTool, EditImageMask, RefineImageMask
from .polyline import ImagePolylineTool
CLASSES = (
EditImageMask,
RefineImageMask,
ActivateImageEditTool,
EditImagePerspective,
ActivateImageTool,
)
__all__ = (
@@ -19,5 +17,4 @@ __all__ = (
"ImageLassoTool",
"ImageLineTool",
"ImagePolylineTool",
"ImagePerspectiveTool",
)

View File

@@ -1,7 +1,7 @@
import bpy
from .common import draw_image_mask_settings
from .mask import EditImageMask
from ..common.image_edit import draw_image_mask_settings
from .edit_mask import EditImageMask
class ImageBoxTool(bpy.types.WorkSpaceTool):

View File

@@ -0,0 +1,145 @@
import json
import bpy
from ...properties import refine_selection_property
from ...runtime import JobOperatorBase
from ..common.image import (
is_image_empty,
prepare_image_input,
remove_temporary_input,
video_total_frames,
)
from ..common.image_edit import (
ImageEditWarning,
image_mask_refine_request,
polygon_bounds,
report_image_edit_exception,
)
from ..common.image_gesture import ImageMaskGesture, gesture_property
from ..common.image_interaction import hidden_string_property
from ..common.image_mask import (
edit_image_object,
polygon_mask,
refined_mask_from_pixels,
)
from ..common.operator import HIDDEN, cleanup_temporary_input, push_undo
class EditImageMask(ImageMaskGesture, bpy.types.Operator):
bl_idname = "anyimage.edit_image_mask"
bl_label = "Edit Image Mask"
bl_description = "Edit and tightly crop an Image Empty with a gesture"
bl_options = {"REGISTER", "UNDO"}
source_object_name: hidden_string_property()
source_matrix_data: hidden_string_property()
selection_data: hidden_string_property()
refine_selection: refine_selection_property()
invert: bpy.props.BoolProperty(
name="Invert",
description="Keep the opposite side of the refined gesture mask",
default=False,
options={"SKIP_SAVE"},
)
gesture: gesture_property()
def execute(self, context):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
self.report({"ERROR"}, "The source Image Empty is no longer available")
return {"CANCELLED"}
try:
polygon = json.loads(self.selection_data)
polygon_bounds(polygon)
if video_total_frames(source_object.data) > 1:
raise RuntimeError("Image tools only support a still image")
if self.refine_selection:
source_path, delete_input = prepare_image_input(
source_object.data, context.scene
)
result = bpy.ops.anyimage.refine_image_mask(
input_path=str(source_path),
delete_input=delete_input,
source_object_name=self.source_object_name,
selection_data=self.selection_data,
invert=self.invert,
)
if "CANCELLED" in result:
if delete_input:
remove_temporary_input(source_path)
return {"CANCELLED"}
return {"FINISHED"}
edit_image_object(
source_object,
polygon_mask(tuple(source_object.data.size), polygon),
self.invert,
)
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
return {"FINISHED"}
class RefineImageMask(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.refine_image_mask"
bl_label = "Refine Image Mask"
job_type = "refine-image-mask"
starting_message = "Refining image selection"
input_path: bpy.props.StringProperty(options=HIDDEN)
delete_input: bpy.props.BoolProperty(default=False, options=HIDDEN)
source_object_name: hidden_string_property()
selection_data: hidden_string_property()
invert: bpy.props.BoolProperty(default=False, options=HIDDEN)
def request(self, _context):
return image_mask_refine_request(self.input_path, self.selection_data)
def response(self, _context, result):
source_object = bpy.data.objects.get(self.source_object_name)
if not is_image_empty(source_object):
raise RuntimeError("The source Image Empty is no longer available")
import numpy as np
bounds = tuple(json.loads(result.file("bounds").read_text(encoding="utf-8")))
refined_image = bpy.data.images.load(
str(result.file("image")), check_existing=False
)
try:
refined_pixels = np.empty(len(refined_image.pixels), dtype=np.float32)
refined_image.pixels.foreach_get(refined_pixels)
mask = refined_mask_from_pixels(
refined_pixels,
tuple(refined_image.size),
bounds,
tuple(source_object.data.size),
)
finally:
bpy.data.images.remove(refined_image, do_unlink=True)
try:
edit_image_object(source_object, mask, self.invert)
except ImageEditWarning as error:
self.report({"WARNING"}, str(error))
return str(error)
push_undo("Edit Image Mask")
return "Image selection refined"
def cleanup(self):
cleanup_temporary_input(self.input_path, self.delete_input)
class ActivateImageTool(bpy.types.Operator):
bl_idname = "anyimage.activate_image_tool"
bl_label = "Image Tool"
bl_description = "Activate the Image Box tool in the 3D Viewport"
bl_options = {"INTERNAL"}
@classmethod
def poll(cls, context):
return image_edit_poll(context)
def execute(self, _context):
return bpy.ops.wm.tool_set_by_id(
name="anyimage.image_box", space_type="VIEW_3D"
)

View File

@@ -1,7 +1,7 @@
import bpy
from .common import draw_image_mask_settings
from .mask import EditImageMask
from ..common.image_edit import draw_image_mask_settings
from .edit_mask import EditImageMask
class ImageLassoTool(bpy.types.WorkSpaceTool):

View File

@@ -1,6 +1,6 @@
import bpy
from .mask import EditImageMask
from .edit_mask import EditImageMask
class ImageLineTool(bpy.types.WorkSpaceTool):

Some files were not shown because too many files have changed in this diff Show More