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:
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.11
|
||||
68
AGENTS.md
68
AGENTS.md
@@ -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
189
README.md
@@ -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。模型权重使用各自上游许可证。
|
||||
|
||||
@@ -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 的外部数据工具重新映射路径。
|
||||
@@ -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 原图。
|
||||
@@ -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)。
|
||||
@@ -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 模型仓库决定
|
||||
@@ -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)。
|
||||
@@ -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。
|
||||
|
||||
## 下一步
|
||||
|
||||
40
docs/getting-started/quick-start.md
Normal file
40
docs/getting-started/quick-start.md
Normal 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)
|
||||
@@ -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)。
|
||||
@@ -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
33
docs/guides/animation.md
Normal 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
59
docs/guides/cutout.md
Normal 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)。
|
||||
42
docs/guides/depth-plane.md
Normal file
42
docs/guides/depth-plane.md
Normal 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`。超过时只会等比缩放模型分析输入;结果材质仍使用原始颜色图片,不会改用限制后的临时图。
|
||||
@@ -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** 稳定表面。必要时降低模型分辨率。
|
||||
|
||||
## 动画面片不播放
|
||||
|
||||
@@ -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
27
docs/guides/mesh-plane.md
Normal 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)。
|
||||
@@ -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
|
||||
|
||||
23
docs/guides/remove-background.md
Normal file
23
docs/guides/remove-background.md
Normal 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
27
docs/guides/upscale.md
Normal 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
41
docs/guides/workflows.md
Normal 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)
|
||||
@@ -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 参数保持为可序列化字典。
|
||||
@@ -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)。
|
||||
|
||||
65
docs/internals/architecture.md
Normal file
65
docs/internals/architecture.md
Normal 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 参数辅助。共享模块只承载跨功能概念;完整工作流仍留在各自功能包中,以便从用户入口追踪到实现。
|
||||
81
docs/internals/development.md
Normal file
81
docs/internals/development.md
Normal 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
48
docs/internals/index.md
Normal 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 的构建、加载与验证。
|
||||
|
||||
内部实现文档描述模块之间的职责和主要数据流。函数级分支、参数校验与异常处理以源码和测试为准,避免文档重复实现细节。
|
||||
58
docs/internals/node-assets.md
Normal file
58
docs/internals/node-assets.md
Normal 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` 等备份不属于发布资产。
|
||||
36
docs/internals/operators/ai-setup.md
Normal file
36
docs/internals/operators/ai-setup.md
Normal 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 释放;它不负责删除磁盘上的模型文件。
|
||||
34
docs/internals/operators/clipboard-image.md
Normal file
34
docs/internals/operators/clipboard-image.md
Normal 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`,避免依赖临时剪贴板文件。
|
||||
58
docs/internals/operators/convert-to-plane.md
Normal file
58
docs/internals/operators/convert-to-plane.md
Normal 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。它强调一张图上的可编辑浮雕,而不是局部透视表面。
|
||||
80
docs/internals/operators/cutout-tool.md
Normal file
80
docs/internals/operators/cutout-tool.md
Normal 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,最终法线贴图的空间转换仍发生在网格生成之后。
|
||||
27
docs/internals/operators/debug.md
Normal file
27
docs/internals/operators/debug.md
Normal 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 或节点资产。因此它适合验证模型、设备和输出格式,不代表正式功能的用户流程。
|
||||
46
docs/internals/operators/image-tool.md
Normal file
46
docs/internals/operators/image-tool.md
Normal 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` 不变。
|
||||
18
docs/internals/operators/index.md
Normal file
18
docs/internals/operators/index.md
Normal 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)。
|
||||
32
docs/internals/operators/perspective-tool.md
Normal file
32
docs/internals/operators/perspective-tool.md
Normal 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 相机等于图片拍摄相机。
|
||||
31
docs/internals/operators/remove-background.md
Normal file
31
docs/internals/operators/remove-background.md
Normal 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 时,旧数据块会被清理并由结果继承名称。
|
||||
36
docs/internals/operators/upscale.md
Normal file
36
docs/internals/operators/upscale.md
Normal 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
72
docs/internals/outputs.md
Normal 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
86
docs/internals/runtime.md
Normal 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)。
|
||||
@@ -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` 修改器中调整。
|
||||
@@ -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 像素;材质的空间选择只负责匹配已经生成好的贴图。
|
||||
@@ -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)
|
||||
56
docs/reference/features.md
Normal file
56
docs/reference/features.md
Normal 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)。
|
||||
@@ -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 纹理过滤。
|
||||
@@ -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。
|
||||
@@ -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)。
|
||||
@@ -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。
|
||||
@@ -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,并按源图中的选区范围近似更新显示位置与尺寸。
|
||||
57
mkdocs.yml
57
mkdocs.yml
@@ -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
|
||||
|
||||
5
pack.py
5
pack.py
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
|
||||
@@ -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.
@@ -20,5 +20,6 @@ files = "Read source and clipboard images, and store environment data"
|
||||
paths_exclude_pattern = [
|
||||
"__pycache__/",
|
||||
".*",
|
||||
"*.blend[0-9]*",
|
||||
"*.zip",
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
@@ -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)
|
||||
1
src/anyimage/operators/common/__init__.py
Normal file
1
src/anyimage/operators/common/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared Blender-side image, material, normal, and operator helpers."""
|
||||
@@ -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
|
||||
@@ -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):
|
||||
@@ -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"
|
||||
)
|
||||
141
src/anyimage/operators/common/image_mask.py
Normal file
141
src/anyimage/operators/common/image_mask.py
Normal 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
|
||||
|
||||
|
||||
@@ -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
|
||||
119
src/anyimage/operators/common/node_asset.py
Normal file
119
src/anyimage/operators/common/node_asset.py
Normal 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
|
||||
192
src/anyimage/operators/common/node_graph.py
Normal file
192
src/anyimage/operators/common/node_graph.py
Normal 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"]
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
13
src/anyimage/operators/convert_to_plane/__init__.py
Normal file
13
src/anyimage/operators/convert_to_plane/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Mesh Plane and Depth Plane conversion operators."""
|
||||
|
||||
from .conversion import (
|
||||
ConvertToDepthPlane,
|
||||
ConvertToPlane,
|
||||
GenerateDepthPlane,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"ConvertToDepthPlane",
|
||||
"ConvertToPlane",
|
||||
"GenerateDepthPlane",
|
||||
)
|
||||
209
src/anyimage/operators/convert_to_plane/build.py
Normal file
209
src/anyimage/operators/convert_to_plane/build.py
Normal 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")
|
||||
213
src/anyimage/operators/convert_to_plane/conversion.py
Normal file
213
src/anyimage/operators/convert_to_plane/conversion.py
Normal 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)
|
||||
86
src/anyimage/operators/convert_to_plane/mesh_plane.py
Normal file
86
src/anyimage/operators/convert_to_plane/mesh_plane.py
Normal 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
|
||||
156
src/anyimage/operators/convert_to_plane/nodes.py
Normal file
156
src/anyimage/operators/convert_to_plane/nodes.py
Normal 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}")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
12
src/anyimage/operators/cutout_tool/__init__.py
Normal file
12
src/anyimage/operators/cutout_tool/__init__.py
Normal 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",
|
||||
)
|
||||
@@ -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
|
||||
814
src/anyimage/operators/cutout_tool/build_shapes.py
Normal file
814
src/anyimage/operators/cutout_tool/build_shapes.py
Normal 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()}")
|
||||
166
src/anyimage/operators/cutout_tool/depth_calibration.py
Normal file
166
src/anyimage/operators/cutout_tool/depth_calibration.py
Normal 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
10
src/anyimage/operators/cutout_tool/node_assets.py
Normal file
10
src/anyimage/operators/cutout_tool/node_assets.py
Normal 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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
214
src/anyimage/operators/cutout_tool/shape_conversion.py
Normal file
214
src/anyimage/operators/cutout_tool/shape_conversion.py
Normal 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,
|
||||
)
|
||||
23
src/anyimage/operators/cutout_tool/shape_types.py
Normal file
23
src/anyimage/operators/cutout_tool/shape_types.py
Normal 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"
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Depth-plane asset builders retained for asset validation."""
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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):
|
||||
145
src/anyimage/operators/image_tool/edit_mask.py
Normal file
145
src/anyimage/operators/image_tool/edit_mask.py
Normal 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"
|
||||
)
|
||||
@@ -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):
|
||||
@@ -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
Reference in New Issue
Block a user