feat: Introduce Balloon Lasso Tool for mesh extraction
- Added BalloonTool workspace tool to facilitate the extraction of balloon meshes in the 3D viewport. - Implemented ActivateBalloonTool operator to activate the Balloon Lasso Tool. - Refactored BalloonLasso operator into ExtractBalloon for better clarity and functionality. - Enhanced properties for balloon extraction, including mode, thickness, resolution, and material reuse options. - Updated SnapFormSettings to include new balloon-related properties. - Improved material and image handling for balloon extraction, ensuring compatibility and reusability. - Added tests to validate the registration and functionality of the new balloon tools and properties.
This commit is contained in:
14
README.md
14
README.md
@@ -52,15 +52,15 @@ Environment 未安装时,同一位置显示两个安装按钮。Environment
|
|||||||
|
|
||||||
### 套索生成 Balloon
|
### 套索生成 Balloon
|
||||||
|
|
||||||
选中单张图片的 Image Empty,右键打开 `SnapForm` 子菜单并选择 `Balloon Mesh (Lasso)`。按住左键沿目标轮廓绘制套索,松开左键即由独立的 `Extract Balloon` Operator 生成带原图 UV 的封闭网格,同时继续停留在 Lasso 状态等待下一次圈选。每次生成后源 Image Empty 都保持选中和 Active;中键、滚轮、触控板、NDOF 与 Alt+左键事件会交还 3D View,可在持续圈选期间旋转、缩放和平移视角。`Esc` 结束会话,右键只取消当前轨迹。
|
选中单张图片的 Image Empty,右键打开 `SnapForm` 子菜单并选择 `Balloon Lasso Tool`。Blender 会激活 3D Viewport 左侧工具栏底部、紧邻 Add Cube、且不增加分隔空隙的独立 Balloon Lasso,Poly Build 的绿色图标、Annotate 同款铅笔光标、Tool Header 与状态栏共同提示当前工具状态,不再用黄色外框或强制修改 Shading、Overlay。Blender 的公开 Workspace Tool API 不支持按 Active Object 动态隐藏工具,因此图标在 Object Mode 中保持可见,但只有 Active Object 是 Image Empty 时才能启动 Stroke。
|
||||||
|
|
||||||
生成后可在左下角 `Adjust Last Operation` 调整:
|
按住左键沿目标轮廓绘制,松开后生成一个带原图 UV 的封闭网格。每次拖动都是一个独立完成的顶层 `Extract Balloon` Operator,工具本身继续保持激活,下一次拖动会创建另一个 Balloon;因此 `Ctrl+Z` 只撤销最近一次生成,`Ctrl+Shift+Z` 重做。每次生成后源 Image Empty 都保持选中和 Active。未绘制时没有持续 Modal 拦截事件,视图导航和其他原生快捷键由 Blender 正常处理;绘制中可用右键或 `Esc` 取消当前轨迹。选择左侧工具栏中的其他工具即可离开 Balloon Lasso。
|
||||||
|
|
||||||
- `Mode`:在全局 Poisson 场和 Teddy / Chordal Axis 局部椭圆截面之间切换
|
- `Mode`、`Thickness`、`Resolution (m)` 和 `Reuse Material` 显示在 Tool Header / Tool Settings
|
||||||
- `Thickness`:控制鼓起厚度,软上限为 `5.0`,不设硬上限
|
- `S`:调用 Blender 原生 Radial Control 调节 Thickness;通过 `zoom_path` 把默认 `1.0` 映射为约 100 px 的交互圆,软上限为 `5.0`,不设硬上限
|
||||||
- `Resolution`:相邻采样点的目标世界空间距离,类型为 Distance,默认 `0.1 Blender unit`;数值越小网格越密
|
- `D`:调用 Blender 原生 Radial Control 调节 Resolution;通过独立 `zoom_path` 把默认 `0.1 m` 映射为约 100 px 的交互圆,数值越小网格越密
|
||||||
|
|
||||||
每次释放产生的 `Extract Balloon` 都会独立完成并成为 Last Operation,因此持续 Lasso 尚未退出时即可在左下角调整当前 Balloon;每个结果也拥有独立的 Undo 步骤。调整参数会基于该次套索和保存的源对象 Matrix World 重建,不会跳回原点。两种模式共用世界距离采样、平滑边界、`.color` 图片副本和 `O Texture Layer` 材质。该流程不运行 BEN2 或任何其他 AI,也不需要安装 Environment。当前只支持静态图片,不处理 Movie 或 Image Sequence。
|
`Resolution (m)` 会根据 Scene Unit Scale 换算为网格使用的世界距离,因此不同单位设置下仍代表相同的物理采样间距。两种模式共用轮廓平滑、`.color` 图片副本和 `O Texture Layer` 材质。开启 `Reuse Material` 后,连续创建会按图片基名复用同一张 `.color` 与兼容的 Balloon 材质;关闭后每次新建。Balloon、Surface 与 Depth Plane 的材质名均取自图片数据名,而不是 Image Empty 对象名。该流程不运行 BEN2 或任何其他 AI,也不需要安装 Environment。当前只支持静态图片,不处理 Movie 或 Image Sequence。
|
||||||
### 去除背景
|
### 去除背景
|
||||||
|
|
||||||
选中 Image Empty 后,右键打开 `SnapForm` 子菜单并选择 `Remove Background`,完成后 Empty 会自动
|
选中 Image Empty 后,右键打开 `SnapForm` 子菜单并选择 `Remove Background`,完成后 Empty 会自动
|
||||||
@@ -120,5 +120,5 @@ Session 逐帧推理,显存峰值不随视频帧数平方增长。Debug 只保
|
|||||||
## 技术文档
|
## 技术文档
|
||||||
|
|
||||||
Blender 侧只负责界面、任务提交与结果导入,AI 与文件输出由独立 Job Server 执行。源码将插件级 `runtime.py`、`menu.py`、`panel.py` 保持在顶层,具体功能与共享图片、材质能力统一放在 `operators/`;每种任务的结果回写与对应 Operator 放在一起。详细功能见
|
Blender 侧只负责界面、任务提交与结果导入,AI 与文件输出由独立 Job Server 执行。源码将插件级 `runtime.py`、`menu.py`、`panel.py` 保持在顶层,具体功能与共享图片、材质能力统一放在 `operators/`;每种任务的结果回写与对应 Operator 放在一起。详细功能见
|
||||||
[文档索引](docs/index.md),模块边界、调用流程、项目结构与开发命令见
|
[文档索引](docs/index.md),程序设计、调用流程、项目结构与开发命令见
|
||||||
[程序设计、开发与许可证](docs/development.md)。CI 在 `main` push 与 Pull Request 上运行测试,仅由 `v*` tag 构建并发布扩展包;文档在 `main` 的文档相关文件变化时部署。
|
[程序设计、开发与许可证](docs/development.md)。CI 在 `main` push 与 Pull Request 上运行测试,仅由 `v*` tag 构建并发布扩展包;文档在 `main` 的文档相关文件变化时部署。
|
||||||
|
|||||||
@@ -1,73 +1,66 @@
|
|||||||
# Depth Plane
|
# Depth Plane
|
||||||
|
|
||||||
本页记录 Depth Plane 的数据、节点和材质实现。日常操作与参数说明请阅读
|
Depth Plane 把图片映射为带深度位移的面片,适合快速获得可调节的立体效果。操作方法见[Depth Plane 使用指南](guide-depth-plane.md)。
|
||||||
[Depth Plane 使用指南](guide-depth-plane.md)。
|
|
||||||
|
|
||||||
## Image Empty 转深度面片
|
## 数据流
|
||||||
|
|
||||||
1. 在 3D View 选中 `Object Type = Empty`、`Display As = Image` 的对象。
|
```mermaid
|
||||||
2. 右键打开 `SnapForm` 子菜单并选择 `Convert to Depth Plane`,通过 `Depth from` 选择深度来源:
|
flowchart LR
|
||||||
- `DA3`:显示 Model 和 Resolution Scale。ONNX 模型使用 camera decoder 恢复相机。Process Res 先以原图长边乘以
|
Input["Image Empty"] --> Depth{"深度来源"}
|
||||||
Resolution Scale,再钳制到 `252–1008`,最后对齐到最接近的 `14` 倍数。Resolution Scale 以百分比
|
Depth --> DA3["DA3"]
|
||||||
显示,范围为 `1–100%`,默认 `100%`;界面只在标签中实时显示最终 Process Res。
|
Depth --> MoGe["MoGe-2"]
|
||||||
转换导出 `mini_npz`,使用前需要在
|
Depth --> Original["Original"]
|
||||||
`Models` panel 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略
|
DA3 --> Layers["颜色层 + 深度层"]
|
||||||
继续使用场景设置。Model 下拉菜单只列出已经下载的 DA3 模型;若没有可用模型,对话框会引导到
|
MoGe --> Layers
|
||||||
`Models` panel 下载。
|
Original --> Layers
|
||||||
- MoGe-2:使用所选模型输出 metric depth、normal、validity mask 与 normalized intrinsics。Depth Plane 不保存体积较大的 points;Surface 会在 AI 进程内用内置 NumPy 网格构建生成 mesh.npz。Debug 则逐帧保存 API 直接返回的 points、depth、normal、mask 与 intrinsics,不生成网格或预览。
|
Layers --> Import["Blender 导入"]
|
||||||
- `Original`:直接把原图作为深度贴图,不显示估算参数。仅在开启 `Remove Background` 时运行 BEN2
|
Import --> Mesh["基础面片"]
|
||||||
取得前景 Alpha;视频或序列帧按需同时抽取原始帧与前景序列,把原始帧作为 `.depth` 序列。
|
Import --> Nodes["Geometry Nodes"]
|
||||||
`MoGe-2 ViT-S` 是默认深度来源。选择 `DA3` 时,其 Model 与 Resolution Scale
|
Import --> Material["材质"]
|
||||||
参数会集中显示在独立边框中。`Remove Background` 与 `Shadeless` 直接显示,不再使用 Options 折叠;
|
```
|
||||||
`Keep Original` 默认关闭,并在分割线下方保持为最后一项。仅当开启 `Remove Background` 时才需要
|
|
||||||
`BEN2 Base`,并把 `.color` 的 Alpha 作为独立 Mask,不在 Server 中改写深度值。无论颜色层是直接
|
Server 为每帧准备颜色、深度和可选前景 Alpha。Blender 导入这些数据,创建基础面片,并用 Geometry Nodes 与材质分别完成几何位移和表面细节。
|
||||||
复制原图,还是由 BEN2 为单图或序列修改 Alpha,Server 都统一写为 `color.0001.*`;Blender 只导入
|
|
||||||
这一份 `.color`,不复用源 Empty 的 Image。颜色贴图会按当前场景的 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`。
|
## 深度来源
|
||||||
Image Empty 引用视频或序列帧时,DA3 与 MoGe-2 会按 Empty 的 Image Frames 数值逐帧估计
|
|
||||||
深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致;
|
| 来源 | 深度含义 | 额外数据 | 适合 |
|
||||||
DA3 会复用同一个 ONNX Session 对每帧独立推理,再合并为原有序列输出,避免多帧 Attention 的平方级显存占用。
|
| --- | --- | --- | --- |
|
||||||
DA3 与 MoGe-2 均先用未经抠图的原始 RGB 生成深度,再运行可选 BEN2;BEN2 Alpha 只参与最终输出过滤和材质 Mask。
|
| DA3 | 相对深度 | 相机内参与置信度 | 通用单图与动画 |
|
||||||
该行为只用于 Depth Plane 转换;Surface 仅支持单张图片。Debug 中 DA3 多帧输入联合推理,
|
| MoGe-2 | Metric depth | 法线、Mask、相机内参 | 更稳定的尺度与表面法线 |
|
||||||
MoGe-2 与 BEN2 按单图 API 逐帧运行。
|
| Original | 原图亮度 | 可选 BEN2 Alpha | 已准备好高度图的素材 |
|
||||||
Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty
|
|
||||||
一致;Original 模式的视频或序列帧使用 `color.0001.*` 颜色层同时作为颜色与深度输入;开启
|
DA3 和 MoGe-2 都以未经抠图的 RGB 估计深度。启用 Remove Background 后,BEN2 Alpha 用于颜色透明度和前景 Mask,深度本身保持模型输出。
|
||||||
Remove Background 时同名颜色层包含 BEN2 修改后的 Alpha。
|
|
||||||
DA3 动画深度以 EXR 序列帧(`depth.0001.exr` 起)保存在 Job 目录根下;MoGe-2 动画只为置换数据创建 `moge_depth.0001.exr` 起的 EXR 序列。MoGe-2 的 `results.npz` 保留完整 depth、normal 与 mask 数组,Server 另外输出对应 PNG 预览。
|
视频与序列帧逐帧推理并复用同一个模型 Session,因此显存主要由单帧分辨率决定。DA3 Debug 的多帧联合推理属于另一条工作流。
|
||||||
DA3 与 MoGe-2 的单帧推理进度只显示模型与阶段名称,不追加 `1/1`;多帧时继续显示当前帧与总数。`Shadeless`、`Remove Background` 与 `Keep Original` 均默认关闭,因此默认删除原对象。`Shadeless` 会以 `O Shadeless` 节点组替代普通 Principled BSDF 路径,并继续通过 Alpha 与 Transparent BSDF 混合。
|
|
||||||
3. DA3 或 MoGe-2 完成后会自动创建面片。DA3 面片几何先由 `mini_npz` 的相机内参与深度确定,再通过对象
|
## Blender 结果
|
||||||
Transform 匹配原 Image Empty 的可见尺寸、位置、旋转和 Offset。Original 模式直接使用源 Empty
|
|
||||||
的可见尺寸、Offset 与 Transform。DA3 会把用于匹配画面尺寸的缩放预乘到 `.depth` 数值,
|
转换会创建以下数据:
|
||||||
XY 缩放烘焙到 Mesh,Z 不参与平面匹配;最终对象 Scale 为 `(1, 1, 1)`,视觉深度与旧逻辑一致。
|
|
||||||
关闭 `Keep Original` 时,成功生成后删除源 Empty。生成面片 Object 使用源 Empty 的原名;开启
|
- 与源 Image Empty 对齐的面片
|
||||||
`Keep Original` 时不处理
|
- `.depth` 与 `.color` Image datablock
|
||||||
同名冲突,由 Blender 自动为生成面片追加编号。
|
- 使用 `O DA3 Depth Plane` 或 `O MoGe2 Depth Plane` 的 Geometry Nodes 修改器
|
||||||
4. Blender 只创建保留图像纵横比的最低密度基础网格,再把 `O DA3 Depth Plane` 作为 Geometry Nodes
|
- 连接颜色、Alpha、Bump 与材质位移的材质
|
||||||
修改器添加到对象。节点组与 `O DA3 Surface` 一同保存在 `O_SnapForm.blend`,关闭
|
|
||||||
`show_group_selector`,不为每个对象复制节点组。MoGe-2 使用单独的 `O MoGe2 Depth Plane`,处理
|
Geometry Nodes 提供深度范围、细分、重拓扑、平滑、背景深度和位移强度等控制。深度 Mask 在几何和材质路径中使用相同阈值,以保持轮廓一致。
|
||||||
网格细分、metric depth 位移和材质设置;材质使用 `O MoGe2 Depth Plane Layer`,直接接入模型 normal。
|
|
||||||
`O MoGe2 Depth Plane` 与 DA3 一样通过隐藏的 Color Image 读取 `.color` Alpha,以 `Alpha > 0.5`
|
材质与图片名称以源 Image datablock 为基名。对象 Transform、显示尺寸和 Offset 会转换为面片的空间位置与比例。
|
||||||
划分前景范围,并在范围外改用 `Background Depth`。两个修改器公开以下参数:
|
|
||||||
- `Displacement`:位于 Geometry 之后的第一项,菜单包含 `Geometry`(默认)与 `Material`。`Geometry`
|
## 单图与动画文件
|
||||||
在节点组内沿法线修改网格,`Material` 保持网格平面并启用材质置换;
|
|
||||||
- `Subdivision`:位于第二项,默认 `5`。节点读取基础平面的 Bounding Box,短边分段数为 `2^Subdivision`,
|
单图深度使用 OpenEXR Pack 到 `.blend`,以保留浮点范围。Packed Image 会先导出临时输入,任务结束后清理,源 Image 保持不变。
|
||||||
长边按宽高比计算并四舍五入;例如 16:9 图片在等级 `5` 时生成 `57×32` 个面;
|
|
||||||
- `Background Depth`:默认 `-1`,允许正负值,Structure Type 为 `Single`,直接显示且不再放在 Panel 中。前景 Mask 外的结构或
|
动画使用 Job 目录中的外部序列:
|
||||||
材质置换使用该值;是否存在背景只取决于转换所得色彩贴图的 `Alpha > 0.5` 二值结果;
|
|
||||||
- `Depth Scale`:默认 `1`,允许正负值,Structure Type 为 `Single`,同时控制 Geometry 与 Material
|
```text
|
||||||
两条置换路径。
|
<job-id>/
|
||||||
Grid 重建后通过隐藏的 Material 输入重新赋予转换材质,并对所有面执行 Shade Smooth。
|
├── color.0001.jpg/png
|
||||||
材质使用显式指向 `UVMap` 的 UV Map 节点连接颜色、前景 Mask 与深度 Image Texture,不依赖活动 UV。
|
├── depth.0001.exr
|
||||||
前景深度先与 `Background Depth` 取 Maximum,再按色彩贴图 Alpha 与背景深度混合,
|
└── results.npz
|
||||||
深度分支在混合前使用 `Alpha > 0.5` 转为二值 Mask,避免半透明前景被背景深度拉扯;
|
```
|
||||||
材质表面 Alpha 同样使用二值 Mask。材质置换使用相同的二值和钳制顺序。
|
|
||||||
节点组还会把 `o_material_displacement` 与 `o_background_depth` 写入点域,材质通过
|
MoGe-2 动画的深度序列使用 `moge_depth.0001.exr`。这些文件是 `.blend` 的运行时依赖,详见[动画与序列帧](animation.md)。
|
||||||
`Geometry` 类型的 Attribute 节点读取。点域会为顶点保存重复值,但能在应用修改器后继续保留参数;
|
|
||||||
相比自适应网格本身,其占用很小。Depth Image 与 Color Image 是修改器隐藏数据输入,
|
## 适用范围
|
||||||
由导入流程自动设置。
|
|
||||||
`.color` 与 `.depth` 的 Blender Image 类型只依据源 Empty Image:普通单图使用 `IMAGE`,Movie 或 Sequence 使用 `SEQUENCE`;即使动画源这次只处理一帧也仍为 `SEQUENCE`,不按输出文件数量推断。视频与序列帧还会为 Depth 与 Color 共同写入隐藏的 `Frame`、`Frame Start`、`Frames` 与 `Cyclic`。
|
Depth Plane 是固定平面上的深度位移,不是完整的逐像素 camera-space 重建。它强调可编辑性和动画支持;需要透视网格时使用 [Surface](surface.md)。
|
||||||
节点组内部不再使用 Scene Time。只有原 Image Empty 是视频或序列时,导入流程才直接给修改器的 `Frame`
|
|
||||||
socket 添加 Scripted Expression Driver `frame`;静态图保持默认值 `1`,不创建 Driver。导入内容已经
|
|
||||||
是截取后的帧,因此 Offset 在节点组内恒定为 `0`。
|
|
||||||
节点组根据 `Frame` 计算从 `1` 开始的 Image Texture Frame:场景帧与 Start 都为 `1` 时读取图片第 `1` 帧;非循环输入
|
|
||||||
钳制到有效范围;循环输入使用 Floored Modulo,让 `Frame Start` 前后的负、正相对帧都映射到
|
|
||||||
`1…Frames`,因此开始帧之前也会循环播放。Geometry 置换会像材质 Image Texture 一样随场景帧刷新。
|
|
||||||
|
|||||||
@@ -2,94 +2,64 @@
|
|||||||
|
|
||||||
## 程序设计
|
## 程序设计
|
||||||
|
|
||||||
SnapForm 分成 Blender 主进程与独立 Job Server 两侧。Blender 侧只负责界面、任务参数和 `.blend` 数据修改;模型下载、媒体准备、ONNX 推理和文件输出都在 Python 3.12 Environment 中运行。两侧由随扩展分发的 BlendJob wheel 连接,避免把 FastAPI、ONNX Runtime 等计算依赖载入 Blender Python。
|
SnapForm 分为 Blender 交互层、BlendJob Runtime 和 Job Server 计算层。
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
User["Image Empty / Debug Panel"] --> Operator["Blender Operator"]
|
UI["菜单 / Panel"] --> Operator["Feature Operator"]
|
||||||
Operator --> Runtime["JobRuntime"]
|
Operator --> Runtime["JobRuntime"]
|
||||||
Runtime --> Server["JobServer FIFO Queue"]
|
Runtime --> Job["Server Job"]
|
||||||
Server --> Handler["SnapForm Job Handler"]
|
Job --> Files["Job Directory"]
|
||||||
Handler --> Model["ModelManager / ONNX"]
|
Files --> Response["Operator Response"]
|
||||||
Handler --> JobFiles["Job Directory"]
|
|
||||||
JobFiles --> Response["Operator response"]
|
|
||||||
Response --> Scene["Blender Scene"]
|
Response --> Scene["Blender Scene"]
|
||||||
```
|
```
|
||||||
|
|
||||||
核心边界如下:
|
Blender 交互层管理 UI、RNA 状态、任务参数和结果导入。Runtime 管理 Environment、Server、队列、进度与取消。Server Job 组合媒体、模型、几何和输出模块。完整运行设计见 [Job Server](server.md)。
|
||||||
|
|
||||||
- `src/snapform/__init__.py` 是注册入口,集中注册 Blender 类型、`Scene.snapform_settings`、右键菜单与 Runtime
|
### 代码组织
|
||||||
- `menu.py` 与 `panel.py` 是同级 Blender UI 入口;`preferences.py`、`properties.py` 分别保存插件配置和 RNA 状态,它们都不直接导入 Server 模型实现
|
|
||||||
- `runtime.py` 创建唯一 `JobRuntime`,配置 Environment、Storage Root、Server 入口与首次安装后的推荐模型下载
|
|
||||||
- `operators/` 按功能组织 Blender 操作。Depth Plane、Surface、Background 与 Balloon 各自包含入口 Operator、Job 请求和结果回写,不设置跨功能的 `results.py`
|
|
||||||
- `operators/common.py` 只保留跨 Job Operator 共用的参数校验、模型就绪检查、临时输入清理和请求参数构造;`operators/image.py` 与 `operators/material.py` 提供各功能 Operator 共用的 Image Empty、序列和材质操作,三者同层但不互相混合职责
|
|
||||||
- `operators/models.py` 与 `operators/debug.py` 分别承载模型管理和 Debug 操作
|
|
||||||
- `operators/balloon/` 以持续 `BalloonLasso` 控制器分发单次、可独立 Redo/Undo 的 `ExtractBalloon`;`geometry.py` 负责共用拓扑,`poisson.py` 与 `teddy.py` 分别计算两种厚度场
|
|
||||||
- `operators/depth_plane/` 将转换入口、Job 请求、响应回写、深度数据和网格构建收拢在同一功能包;`operators/surface.py` 与 `operators/background.py` 采用相同的功能内聚原则
|
|
||||||
- `server/__init__.py` 创建 `JobServer`、注册公开 Job,并把 `ModelManager` 注册为 Resource
|
|
||||||
- `server/jobs/` 按任务组织完整流程;DA3、MoGe-2、BEN2 Debug 各自拥有独立模块和 Job,不用模型分支总控函数
|
|
||||||
- `server/models/` 包含三个模型适配器及其 ONNX 实现,只负责模型载入、推理和 Prediction;不接收 Job Context、不写文件
|
|
||||||
- `server/geometry/` 包含投影、像素网格和唯一 Surface Builder,均为纯数据转换;不负责 Debug 或文件输出
|
|
||||||
- `server/outputs/` 只负责 Depth、Surface 与各模型 Debug 的序列化;Debug writer 原样保存 API 字段,不构建派生几何
|
|
||||||
- `server/media/input.py` 只处理图片、序列和视频输入;共享的可选前景流程保留在 `jobs/foreground.py`
|
|
||||||
- 根目录 `model_catalog.py` 供 Blender UI 与本地就绪检查使用;`server/model_catalog.py` 独立声明下载与推理所需模型,二者的模型键、目录名和 `ready_patterns` 由测试约束一致
|
|
||||||
- `server/__init__.py` 只注册 Job 与 Resource;`jobs/background.py`、`jobs/frames.py` 分别实现去背景和帧提取
|
|
||||||
- 所有任务产物写入 Runtime 分配的 opaque-ID Job 目录
|
|
||||||
|
|
||||||
功能细节继续按主题维护:[Depth Plane](depth-plane.md)、[Surface](surface.md)、[输出格式](outputs.md) 和 [Job Server](server.md)。
|
| 位置 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `__init__.py` | Blender 类型、菜单与 Runtime 注册 |
|
||||||
|
| `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_SnapForm.blend` | Geometry Nodes 与材质资产 |
|
||||||
|
|
||||||
|
Depth Plane、Surface、Background 和 Balloon 按功能组织。一个功能的入口、请求和 Blender 回写放在一起;跨功能的图片、材质和参数处理放在 `operators/common.py`、`operators/image.py` 与 `operators/material.py`。
|
||||||
|
|
||||||
|
Server 的模型模块返回 Prediction,几何模块处理数组与网格,输出模块写文件。Blender 数据只在 Operator Response 的主线程阶段创建。
|
||||||
|
|
||||||
## 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```text
|
```text
|
||||||
.
|
.
|
||||||
├── AGENTS.md
|
|
||||||
├── README.md
|
├── README.md
|
||||||
|
├── docs/
|
||||||
├── pack.py
|
├── pack.py
|
||||||
├── sync_models.py
|
├── sync_models.py
|
||||||
├── docs/
|
├── src/snapform/
|
||||||
├── src/
|
│ ├── blender_manifest.toml
|
||||||
│ └── snapform/
|
│ ├── runtime.py
|
||||||
│ ├── __init__.py
|
│ ├── menu.py
|
||||||
│ ├── blender_manifest.toml
|
│ ├── panel.py
|
||||||
│ ├── runtime.py
|
│ ├── preferences.py
|
||||||
│ ├── menu.py
|
│ ├── properties.py
|
||||||
│ ├── panel.py
|
│ ├── operators/
|
||||||
│ ├── preferences.py
|
│ ├── server/
|
||||||
│ ├── properties.py
|
│ ├── assets/
|
||||||
│ ├── model_catalog.py
|
│ └── wheels/
|
||||||
│ ├── assets/
|
|
||||||
│ │ └── O_SnapForm.blend
|
|
||||||
│ ├── operators/
|
|
||||||
│ │ ├── common.py
|
|
||||||
│ │ ├── image.py
|
|
||||||
│ │ ├── material.py
|
|
||||||
│ │ ├── models.py
|
|
||||||
│ │ ├── debug.py
|
|
||||||
│ │ ├── background.py
|
|
||||||
│ │ ├── surface.py
|
|
||||||
│ │ ├── depth_plane/
|
|
||||||
│ │ │ ├── operator.py
|
|
||||||
│ │ │ ├── data.py
|
|
||||||
│ │ │ └── build.py
|
|
||||||
│ │ └── balloon/
|
|
||||||
│ │ ├── operator.py
|
|
||||||
│ │ ├── geometry.py
|
|
||||||
│ │ ├── poisson.py
|
|
||||||
│ │ └── teddy.py
|
|
||||||
│ ├── server/
|
|
||||||
│ │ ├── __init__.py
|
|
||||||
│ │ ├── jobs/
|
|
||||||
│ │ ├── geometry/
|
|
||||||
│ │ ├── media/
|
|
||||||
│ │ ├── models/
|
|
||||||
│ │ └── outputs/
|
|
||||||
│ └── wheels/
|
|
||||||
│ └── blendjob-*.whl
|
|
||||||
├── tests/
|
├── tests/
|
||||||
└── tools/
|
└── tools/
|
||||||
```
|
```
|
||||||
|
|
||||||
## 常见命令
|
## 开发命令
|
||||||
|
|
||||||
在项目根目录使用 Git Bash:
|
在项目根目录使用 Git Bash:
|
||||||
|
|
||||||
@@ -100,51 +70,35 @@ uv run pytest tests/test_addon.py tests/test_manifest.py tests/test_pack.py -v
|
|||||||
uv run python pack.py
|
uv run python pack.py
|
||||||
```
|
```
|
||||||
|
|
||||||
`.github/workflows/test.yml` 在 `main` push、Pull Request 与手动触发时使用 Python 3.11 和锁定依赖运行完整测试。`.github/workflows/build.yml` 只响应 `v*` tag,在再次通过完整测试后运行 `pack.py`,并把生成的 ZIP 上传到该 tag 对应的 Gitea Release。`pack.py` 从 `pyproject.toml` 的默认 `dependencies` 读取 Extension wheel 需求,按 Manifest 平台从 PyPI 下载 wheel,并把各平台对应的 wheel 列入包内 Manifest;`src/snapform/wheels/` 只保留本地资产,不作为 uv source。`.github/workflows/docs.yml` 在 `main` 的文档内容、MkDocs 配置或自身 Workflow 变化时部署,也可手动触发;它只使用 `docs` 依赖组,因此不安装默认依赖。Server Environment 与测试依赖声明在 `dev` 组,文档工具声明在 `docs` 组;新增依赖后应更新锁文件,不要在 Workflow 中另行维护下载清单。
|
`pack.py` 为 Windows x64、macOS Apple Silicon 与 Linux x64 构建扩展 ZIP 到 `dist/`。依赖、wheel 或 Manifest 改变后,应同步检查 `pyproject.toml`、`uv.lock`、`blender_manifest.toml` 与打包测试。
|
||||||
|
|
||||||
本地预览用户文档:
|
CI 分为三条流程:
|
||||||
|
|
||||||
|
- `test.yml`:在 push、Pull Request 与手动触发时运行测试
|
||||||
|
- `build.yml`:响应 `v*` tag,测试后构建并上传 Release
|
||||||
|
- `docs.yml`:文档变化后构建并部署站点
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
本地预览和严格构建:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run mkdocs serve
|
uv run --only-group docs mkdocs serve
|
||||||
|
uv run --only-group docs mkdocs build
|
||||||
```
|
```
|
||||||
|
|
||||||
构建静态文档或使用 Mike 发布版本化文档:
|
版本化发布可使用 Mike:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run mkdocs build
|
uv run --only-group docs mike deploy <version> -p
|
||||||
uv run mike deploy <version> -p
|
uv run --only-group docs mike set-default <version> -p
|
||||||
uv run mike set-default <version> -p
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 文档部署
|
自动部署将 MkDocs 站点发布到专用 Cloudflare Worker。仓库需要配置 `CLOUDFLARE_ACCOUNT_ID`、`CLOUDFLARE_API_TOKEN` 和 `CLOUDFLARE_WORKER_NAME`。Worker 路由对应 `docs.omoolab.xyz/snapform/*`。
|
||||||
|
|
||||||
`.github/workflows/docs.yml` 在 `main` 分支的文档相关文件变化后运行,也可从 Gitea Actions 手动触发。工作流使用锁定的 Python 依赖严格构建 `site/`,将其放入与站点 URL 对应的 `public/snapform/`,再通过 Wrangler 部署为 Cloudflare Workers Static Assets。
|
|
||||||
|
|
||||||
在 Gitea 仓库的 **Settings > Actions** 中配置:
|
|
||||||
|
|
||||||
- Secret `CLOUDFLARE_ACCOUNT_ID`:Cloudflare Account ID
|
|
||||||
- Secret `CLOUDFLARE_API_TOKEN`:限制到目标账号的 Cloudflare API Token,使用 **Edit Cloudflare Workers** 模板
|
|
||||||
- Variable `CLOUDFLARE_WORKER_NAME`:承载文档的 Worker 名称
|
|
||||||
|
|
||||||
API Token 不写入仓库。Workflow 只在 `main` 上部署生产文档,不在 Pull Request 中读取 Cloudflare Secret。
|
|
||||||
|
|
||||||
目标 Worker 必须专门用于 SnapForm,并把 `docs.omoolab.xyz/snapform/*` 路由到该 Worker。每次 Wrangler 部署都会更新目标 Worker 的整套静态资源;如果一个 Worker 同时承载多个项目,应改由中央文档仓库聚合全部站点后统一部署,不能从各项目仓库分别覆盖它。
|
|
||||||
|
|
||||||
|
|
||||||
Mike 不依赖 GitHub API;`mike deploy --branch <branch> --push` 也可以向 Gitea 保存版本化静态站点。当前自动部署先发布 `mkdocs build` 生成的单版本站点,等确定版本 URL 规则后再启用 Mike 分支,避免在 Cloudflare 上提前固化不合适的路径结构。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
`pack.py` 根据 `blender_manifest.toml` 分别生成 Windows、macOS 和 Linux 扩展包,例如 `dist/SnapForm.v0.12.14.windows-x64.zip`。更换 `src/snapform/wheels/` 中随扩展分发的 wheel 后,先刷新锁文件再测试:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv lock --refresh
|
|
||||||
uv run pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
## 资产构建与验证
|
## 资产构建与验证
|
||||||
|
|
||||||
`tools/` 脚本依赖 `bpy`,必须由 Blender 执行。设置本机 Blender 可执行文件后,可以重建或验证 `O_SnapForm.blend`:
|
`tools/` 中的资产脚本依赖 `bpy`,应由 Blender 执行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe"
|
BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe"
|
||||||
@@ -156,14 +110,11 @@ BLENDER_BIN="/c/Program Files/Blender Foundation/Blender 4.5/blender.exe"
|
|||||||
"$BLENDER_BIN" --background --python tools/validate_moge_surface.py
|
"$BLENDER_BIN" --background --python tools/validate_moge_surface.py
|
||||||
```
|
```
|
||||||
|
|
||||||
验证脚本会自动把 `src/snapform/wheels/` 中的 BlendJob wheel 加入 Blender Python 路径,因此可在
|
修改节点组、材质组或资产接口后,运行对应验证脚本和完整测试。Python、文档或打包逻辑变化无需重建 `.blend` 资产。
|
||||||
`--factory-startup` 的干净环境中直接运行。
|
|
||||||
|
|
||||||
只改 Python、文档或打包逻辑时不需要重建 `.blend` 资产;修改节点组、材质组或其接口时,应运行相应资产验证脚本,并再次运行完整测试。
|
|
||||||
|
|
||||||
## 上游与许可证
|
## 上游与许可证
|
||||||
|
|
||||||
- Depth Anything 3 代码采用 Apache 2.0
|
- Depth Anything 3 代码采用 Apache 2.0
|
||||||
- BEN2 ONNX 模型采用其仓库声明的 Apache 2.0 License
|
- BEN2 ONNX 模型采用其仓库声明的 Apache 2.0 License
|
||||||
- SnapForm Extension 代码采用 GPL-3.0-or-later
|
- SnapForm Extension 代码采用 GPL-3.0-or-later
|
||||||
- 模型权重许可证由所选 Hugging Face 模型仓库决定,Extension 下载前会在界面显示
|
- 模型权重许可证由所选 Hugging Face 模型仓库决定
|
||||||
|
|||||||
135
docs/features.md
135
docs/features.md
@@ -1,120 +1,49 @@
|
|||||||
# 功能参考
|
# 功能参考
|
||||||
|
|
||||||
本页记录完整功能行为和实现边界,主要供开发、测试与故障排查使用。第一次使用 SnapForm 时,请从
|
本页概览 SnapForm 的能力。安装步骤见[安装与首次配置](installation.md),实际操作见[工作流选择](workflows.md)与各使用指南。
|
||||||
[安装与首次配置](installation.md)或[五分钟上手](quick-start.md)开始。
|
|
||||||
|
|
||||||
`SnapForm` 是一套从图片快速构建空间结构的 Blender Extension。目前以
|
## 工作流
|
||||||
[Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3)
|
|
||||||
作为首个 AI 能力,在独立 Environment 中运行 DA3,可将模型 API Prediction 保存在独立 job 目录,
|
|
||||||
也可把 Image Empty 转换成带几何与材质置换的立体面片。MoGe-2 作为独立深度来源,保留其 metric depth、normal、mask 与 intrinsics 输出,不复用 DA3 Prediction 后处理。Debug 面板还可运行
|
|
||||||
[BEN2](https://github.com/PramaLLC/BEN2) 背景剔除功能测试。
|
|
||||||
|
|
||||||
## 当前功能
|
| 工作流 | 适合 | 输入 | 结果 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Depth Plane | 快速制作可调节的立体图片 | 单图、视频、序列帧 | 带颜色、深度与 Geometry Nodes 的面片 |
|
||||||
|
| Surface | 从单张图片恢复透视空间 | 单图 | 相机空间网格、材质与可选匹配相机 |
|
||||||
|
| Remove Background | 提取透明前景 | 单图、视频、序列帧 | 透明图片或透明序列 |
|
||||||
|
| Balloon | 沿轮廓绘制鼓起造型 | 静态 Image Empty | 带原图 UV 的封闭网格 |
|
||||||
|
| Debug | 检查模型原始输出 | 模型支持的图片或帧序列 | 保存在 Job 目录中的原始 Prediction |
|
||||||
|
|
||||||
- 使用 Blender Extension manifest,可通过 `Install from Disk` 安装
|
Depth Plane 与 Surface 可使用 DA3 或 MoGe-2。DA3 提供相对深度和相机信息;MoGe-2 还提供 metric depth、法线与有效区域。BEN2 为去背景和可选前景处理提供 Alpha。
|
||||||
- 在独立 Environment 中通过常驻 Server 运行 ONNX Runtime;MoGe-2、DA3 与 BEN2 的模型执行及后处理完全不使用 PyTorch,避免污染 Blender Python 进程
|
|
||||||
- Server 的深度模型缓存只保留 DA3 或 MoGe-2 其中一个,切换时主动释放前一个;BEN2 使用独立缓存,
|
|
||||||
可与当前深度模型共存
|
|
||||||
- Blender 使用内置 `urllib.request` 访问仅监听 `127.0.0.1` 的 Server;连接由随机端口与 Instance ID 归属当前 Runtime
|
|
||||||
- 常驻 Server 复用 ONNX Runtime session、BEN2 与最近使用的深度模型,连续执行时不再重复加载
|
|
||||||
- 每个 Blender 实例持有自己的 Job Server;`Job Server` 面板显示状态与模型缓存,并提供安装、启动、清理、重启和停止控制
|
|
||||||
- Environment、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享
|
|
||||||
- 当前仅启用 FP32 DA3 Small 和 Base;它们不随首次安装下载,Giant 1.1 与 Nested Giant Large 1.1 暂停使用
|
|
||||||
- 支持 BEN2 Base 单图背景剔除测试,输出透明 PNG 与推理耗时
|
|
||||||
- 支持单张 JPG、JPEG、PNG、WEBP 图片或常见视频文件输入
|
|
||||||
- 视频按顺序截取前若干帧,可设置参与重建的最大帧数
|
|
||||||
- `Models` panel 默认折叠,展开后可逐项查看和下载所有可用模型
|
|
||||||
- Environment 已安装时不额外占用面板空间;模型列表每个模型只占一行
|
|
||||||
- `Debug` 默认折叠,内部的 `DA3`、`MoGe-2` 与 `BEN2` 也分别默认折叠
|
|
||||||
- DA3、MoGe-2 与 BEN2 分别使用独立 Debug Job,直接保存各自 API 返回,不共享按模型分支的总控函数
|
|
||||||
- DA3 Debug 保存 Prediction NPZ;MoGe-2 Debug 保存逐帧原始预测 NPZ;BEN2 Debug 保存直接返回的透明前景
|
|
||||||
- Debug 与 Convert to Depth Plane 的模型选项直接显示下载状态;未下载模型带
|
|
||||||
`(Missing)`,已下载模型保持原名
|
|
||||||
- Debug 不运行点云、Surface、深度预览、置信度 percentile 或坐标变换等 SnapForm 后处理
|
|
||||||
- Image Empty 右键菜单提供单一 Balloon 套索提取,并可在 Last Operation 中切换 Poisson 与 Teddy / Chordal Axis Mode;无需 Environment 或模型,源 Empty 保留
|
|
||||||
- Image Empty 右键菜单可直接运行 DA3,根据 `mini_npz` 深度生成面片
|
|
||||||
- Image Empty 右键菜单可直接运行 BEN2,保留原图并换用新建的透明背景图片
|
|
||||||
- 缺少必需的 `MoGe-2 ViT-S Normal` 或 `BEN2 Base` 时,Image Empty 的 `SnapForm`
|
|
||||||
右键子菜单显示 `Download Required Models`;单次任务只下载缺失项,并在补齐前禁用
|
|
||||||
`Convert to Depth Plane`、`Convert to Surface` 和 `Remove Background`。Environment 未安装时,
|
|
||||||
同一位置改为显示 `Install Environment` 与 `Install Environment (Mirror)`,三个禁用的功能入口仍然可见
|
|
||||||
- 菜单、模型面板和转换对话框直接检查 Storage Root 的 `models/` 必需文件,显示模型状态时不会启动 Job Server
|
|
||||||
- Image Empty 支持视频与序列帧输入:`Convert to Depth Plane` 逐帧估计深度,
|
|
||||||
`Remove Background` 逐帧抠图,入口保持不变。DA3 转换时每帧独立执行 ONNX 推理并复用模型 Session;
|
|
||||||
DA3 Debug 多帧任务继续联合推理;MoGe-2 与 BEN2 Debug 按单图 API 逐帧运行
|
|
||||||
- 视频生成的序列帧以外部文件引用保留在 job 目录(Blender 无法整体 Pack 序列帧);
|
|
||||||
Geometry Nodes 与材质 Image Texture 均可按场景帧播放序列帧
|
|
||||||
- 使用浮点深度贴图、`O DA3 Depth Plane` Geometry Nodes 和原图 Bump 构建立体面片
|
|
||||||
- 转换对话框选择推理模式、处理分辨率与背景移除;细分和置换方式在 Geometry Nodes 修改器中调整
|
|
||||||
- Debug 的 DA3 输出或 BEN2 背景剔除完成后只打开对应 job 文件夹,不修改当前场景
|
|
||||||
- Debug 完整保留模型 API 返回的数组字段,不混入 SnapForm 派生几何
|
|
||||||
- 安装和模型下载使用后台子进程;推理提交给常驻 Server,状态栏实时显示阶段、百分比进度条与取消按钮
|
|
||||||
- 后台任务运行时,Install、Download 和 Export 按钮保持原位置并变灰,任务完成后恢复
|
|
||||||
|
|
||||||
## 使用
|
## Blender 交互
|
||||||
|
|
||||||
1. 运行 `uv run python pack.py`,分别构建 `windows-x64`、`macos-arm64`、
|
主要功能位于 Image Empty 的 `SnapForm` 右键菜单。菜单会显示 Environment 和必需模型的就绪状态,并在缺少内容时提供安装或下载入口。
|
||||||
`linux-x64` 三个平台的 Extension ZIP(如 `SnapForm.v0.12.17.macos-arm64.zip`)。
|
|
||||||
安装包不携带 uv 或 AI 项目源码;首次安装 Environment 时下载固定版本 uv,并校验固定 SHA-256。官方安装按钮使用 GitHub、uv 默认 Python 下载源和 PyPI;镜像按钮使用 CNB uv Release、CNB Python 解释器镜像和清华 PyPI。
|
|
||||||
2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。
|
|
||||||
3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能
|
|
||||||
(Image Empty 右键菜单)使用的推理设备。Windows 默认取 DirectML,否则 CPU;Apple M4 或更新芯片列出 MPS/CoreML 与 CPU,Intel、M1–M3 和无法识别的 Mac 只列出 CPU。
|
|
||||||
4. 在 `Job Server` 选择 `Install Environment` 或 `Install Environment (Mirror)`。首次安装会下载 Python、ONNX Runtime、FastAPI、Uvicorn;
|
|
||||||
uv 由 Job Runtime 的 Environment 安装器下载并校验,DA3、BEN2 与 MoGe 不安装源码包,
|
|
||||||
并在 Environment 安装后自动下载 `MoGe-2 ViT-S Normal` 和 FP16 `BEN2 Base` 推荐模型。首次安装
|
|
||||||
Environment Bootstrap 的 0–100% 只包含 uv、Python 与 packages:三阶段显示 `1/3`、`2/3`、`3/3`,范围分别为 1–20%、20–50%、50–98%,最终验证使用 98–100%。下载占各阶段的 84–87%;开始与验证不显示编号,单阶段状态不显示 `1/1`。
|
|
||||||
两个默认模型由普通的 `post_install(runtime)` 发起正式 Server Job。
|
|
||||||
安装完成后直接写入 Environment Manifest,不再额外导入全部组件进行验证;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。
|
|
||||||
Environment packages 使用固定的 Resolving、Downloading 与 Installing 状态,原始 uv 输出写入 `install.log`。模型下载显示速度,例如
|
|
||||||
`Downloading DA3 Small 14.2 MB/s`;镜像来源显示为
|
|
||||||
`Downloading DA3 Small (mirror) 14.2 MB/s`。下载按字节连续推进,重试或切换镜像时进度不会倒退。
|
|
||||||
Environment 安装阶段与 `Models` 面板单独下载使用相同的模型名称格式,不会改写为通用的 `Downloading packages`。
|
|
||||||
Environment 安装后即使模型下载中断也仍会保留,但 `Install Environment` 会以失败结束并显示模型下载错误。
|
|
||||||
可稍后在 Image Empty 的 `SnapForm` 右键菜单用 `Download Required Models` 一次补齐缺失的
|
|
||||||
MoGe-2 ViT-S 与 BEN2,也可在 `Models` 面板单独补下。
|
|
||||||
安装不做启动网络预检;每个模型都先尝试 Hugging Face,失败后尝试 OmooLab R2,两者都失败时当前 Job 失败。
|
|
||||||
官方安装固定使用 GitHub、uv 默认 Python 下载源和 PyPI;镜像安装固定使用 CNB uv Release、`python-build-standalone` 镜像和清华 PyPI。两种方式都不测速、不竞速、不自动切换,镜像配置只注入当前安装子进程。模型仍按自身的 Hugging Face 与 OmooLab R2 逻辑下载。
|
|
||||||
5. 在 3D View 按 `N`,打开 `SnapForm` 页签。
|
|
||||||
6. 如需其他模型,展开 `Models` panel,找到模型并点击该行的 `Download`。
|
|
||||||
7. 展开默认收起的 `Debug`,在 DA3、MoGe-2 或 BEN2 区域选择模型与输入,然后直接运行对应 API。
|
|
||||||
8. 选择一张图片或一个视频文件。视频可设置从头截取的帧数,然后点击 `Generate Output`。
|
|
||||||
9. 测试背景剔除时,确认 `Models` panel 中的 `BEN2 Base` 已就绪,然后在 `Debug` 的
|
|
||||||
`BEN2` 区域选择单张图片并点击 `Remove Background`。
|
|
||||||
完成后会打开 job 文件夹,其中的 `foreground.png` 带透明 Alpha;状态栏显示
|
|
||||||
BEN2 推理、模型加载与任务总耗时。当前测试固定关闭较慢的 foreground refinement。
|
|
||||||
|
|
||||||
## Extract Balloon
|
转换任务完成后,SnapForm 在一个 Undo 步骤中创建网格、图片、材质和修改器。源 Image Empty 是否保留由转换选项决定。
|
||||||
|
|
||||||
`Balloon Mesh (Lasso)` 是纯 Blender 侧入口,不提交 Job,也不调用 BEN2、深度模型或其他 AI,仅支持静态 Image Empty。它启动不进入 Undo/Redo 历史的持续 `BalloonLasso` 控制器;按住左键绘制套索,松开时控制器调用一次独立的 `ExtractBalloon` Operator 并立即完成,随后继续等待下一次圈选。`Esc` 结束控制器,右键只清除当前未完成轨迹。屏幕轨迹通过视图射线与 Empty 图片平面求交,再结合图片宽高比、Display Size、Image Offset 与 Object Transform 映射为原图像素坐标。
|
Balloon 是 Blender 内部工具,无需 Environment 或模型。它支持连续绘制,每次 Stroke 都生成一个可单独 Undo/Redo 的结果。Tool Settings 提供模式、厚度、采样距离与材质复用设置。
|
||||||
|
|
||||||
Operator 的 `Mode` 保留两种厚度算法:
|
## 动画
|
||||||
|
|
||||||
- `Poisson` 在共用二维网格上组装 cotangent Laplacian 与面积载荷,求解零边界 Poisson 场;高度取场值平方根并按 `Thickness` 缩放
|
Depth Plane 和 Remove Background 支持 Movie 与 Image Sequence。它们按 Image Empty 的帧范围处理素材,并生成与场景帧对应的颜色或深度序列。
|
||||||
- `Teddy / Chordal Axis` 依据 [Teddy: A Sketching Interface for 3D Freeform Design](https://www.cs.princeton.edu/courses/archive/fall00/cs597b/papers/teddy-siggraph99.pdf) 的思路,仅对平滑轮廓做约束 Delaunay,形成袖段、分叉和端点轴,并用轴半径生成局部圆/椭圆式截面。当前实现将厚度场映射到共用均匀网格,并非逐面复刻论文的专用缝合拓扑
|
|
||||||
|
|
||||||
每次释放都会把该次套索、源对象名称和启动时的完整 Matrix World 传给一个带 `REGISTER`、`UNDO` 的 `ExtractBalloon`。它完成后立即成为 Last Operation,即使持续 Lasso 仍在运行,也可在左下角修改当前结果的 `Mode`、`Thickness` 和 `Resolution`;每次提取分别拥有 Undo 步骤。重建后会重新把源 Image Empty 设为选中和 Active,便于继续圈选,结果也不会跳回原点。Modal 对中键、滚轮、触控板、NDOF、Alt+左键、HUD 和其他未处理事件返回 `PASS_THROUGH`,所以持续状态不阻断 3D View 导航或参数面板交互。`Thickness` 默认 `1.0`、软上限 `5.0` 且无硬上限。`Resolution` 是 subtype 为 `DISTANCE` 的长度属性,直接表示相邻采样点的目标世界空间距离,默认 `0.1 Blender unit`;数值越小网格越密,不再使用密度倍率换算。
|
Blender 无法整体 Pack 图片序列,因此动画结果会继续引用 Job 目录。保存或移动 `.blend` 时,应同时保留这些外部文件。详见[动画与序列帧](animation.md)。
|
||||||
|
|
||||||
两种模式共用轮廓平滑、按弧长等距重采样和二维约束 Delaunay。边界与内部点以 `Resolution` 的世界距离采样,不随图片分辨率、Display Size 或对象 Scale 改变目标间距。前后表面在同一条零厚度边缘共享顶点,不生成直立侧壁,结果为封闭网格。UV 保持原图坐标;材质使用独立的 `O Texture Layer`,并复制源 Image datablock、按 Convert 系列相同规则命名为 `.color`。创建 Object 与 Mesh 时直接使用源 Image Empty 名称,不主动追加 Balloon 后缀;同一 Blender datablock 集合已有重名时仍由 Blender 自动追加数字编号。源 Image Empty 保持不变,Undo 可移除提取结果。
|
## 运行环境
|
||||||
|
|
||||||
当前不提供添加/减去选区、边缘羽化和视频跟踪。
|
AI 任务在独立 Python Environment 中通过 ONNX Runtime 执行。Blender 负责界面、任务提交和结果导入,Job Server 负责模型与文件处理。
|
||||||
## Image Empty 去背景
|
|
||||||
|
|
||||||
1. 确认 `Models` panel 中的 `BEN2 Base` 已就绪;首次安装 Environment 时会默认下载。
|
默认 Storage Root 为 `~/.snapform`,其中保存 Environment、模型、日志和 Job 结果。连续任务会复用已加载的模型 Session;DA3 与 MoGe-2 共享一个深度模型缓存位置,BEN2 可同时保留。
|
||||||
2. 在 3D View 选中 Image Empty,右键打开 `SnapForm` 子菜单并选择 `Remove Background`。
|
|
||||||
3. 完成后以 Fake User 保留原 Image datablock,新建并 Pack 一张透明 PNG 到当前 `.blend`,然后只把
|
|
||||||
当前 Empty 的 `data` 换成新图;Empty 的 Transform、显示尺寸、Offset 及其他设置保持不变。
|
|
||||||
4. 新 Image 使用原图去掉格式后再追加 `.color` 的名称。例如 `img.png` 生成
|
|
||||||
`img.color`,`img.png.001` 生成 `img.001.color`。已有同名 datablock 时由
|
|
||||||
Blender 自动追加编号。
|
|
||||||
|
|
||||||
原图已 Pack 时会先写出临时 PNG 交给 BEN2,任务结束后删除临时输入;原 Image 仍保持 Pack
|
安装、下载和推理都在后台运行,并通过状态栏提供进度与取消操作。详细设计见[Job Server](server.md)。
|
||||||
状态和原始内容。正式任务的 `color.0001.png` 也会保留在对应 job 目录中。
|
|
||||||
|
|
||||||
Image Empty 引用视频或序列帧时,Remove Background 直接执行,默认取 Empty 的 Image Frames
|
## 模型与调试
|
||||||
数值(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset;若 Offset 加 Frames 超出视频长度,
|
|
||||||
会把窗口整体收进视频内(保证实际处理 Frames 帧数),Offset 过小时从第 1 帧开始。输出
|
正式功能使用以下 ONNX 模型:
|
||||||
`color.0001.png` 等透明序列帧(帧号从 0001 开始,与 Blender 场景帧一一对应),并把
|
|
||||||
关闭去背景时,原始颜色帧同样以 `color.0001.*` 起始的统一名称写入 job 目录。替换 Empty 的 Image 为
|
- MoGe-2 ViT-S、ViT-B、ViT-L Normal
|
||||||
加载这些帧的 SEQUENCE Image,从原 Empty 的 Start 位置开始播放(Start 原样保留)。图片类型只取决于源 Empty:普通单图的单个 `color.0001.*` 仍作为 `IMAGE`,Movie/Sequence 即使只导出一帧也作为 `SEQUENCE`;`.depth` 同样遵循该规则。
|
- DA3 Small、DA3 Base
|
||||||
Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因此保持外部引用 job 目录;
|
- BEN2 Base
|
||||||
请保留对应 job 目录,否则序列会失效。
|
|
||||||
|
首次安装会准备 MoGe-2 ViT-S Normal 与 BEN2 Base,其他模型可从 Models 面板按需下载。
|
||||||
|
|
||||||
|
Debug 工作流保存模型 API 的直接返回,适合验证模型、设备与输入。Depth Plane 和 Surface 则在模型结果之上继续生成 Blender 可用的贴图与几何。文件字段见[输出格式](outputs.md)。
|
||||||
|
|||||||
@@ -1,37 +1,28 @@
|
|||||||
# 输出格式
|
# 输出格式
|
||||||
|
|
||||||
本页说明任务文件的字段与序列化约定,主要用于开发与数据检查。普通使用者只需了解
|
本页记录 Job 产物的稳定用途与主要字段。日常使用只需了解 [Job 文件夹](models-and-storage.md)。
|
||||||
[模型、设备与存储](models-and-storage.md)中的 Job 文件夹说明。
|
|
||||||
|
|
||||||
## Debug
|
## 文件概览
|
||||||
|
|
||||||
Debug 用于直接比较 DA3、MoGe-2 与 BEN2 模型 API 的返回效果,不复用 Depth、Surface 或 Geometry
|
| Job | 主要文件 | 内容 |
|
||||||
后处理。三个入口对应三个独立 Job:
|
| --- | --- | --- |
|
||||||
|
| `generate-depth` | `results.npz`、深度与颜色序列 | Depth Plane 所需深度、相机和贴图 |
|
||||||
|
| `generate-surface` | `mesh.npz`、`normal.png`、颜色层 | Surface 网格、相机与材质输入 |
|
||||||
|
| `remove-background` | `color.0001.png` 起 | 带 Alpha 的颜色层 |
|
||||||
|
| `debug-da3` | `prediction.npz` | DA3 API 返回 |
|
||||||
|
| `debug-moge2` | `prediction*.npz` | MoGe-2 API 返回 |
|
||||||
|
| `debug-ben2` | `foreground*.png` | BEN2 API 返回 |
|
||||||
|
|
||||||
- debug-da3:保存 prediction.npz,字段直接来自 DA3 Prediction,包括 depth、conf、
|
## NPZ 字段
|
||||||
extrinsics、intrinsics、processed_images 及模型实际返回的可选标量或数组
|
|
||||||
- debug-moge2:每张输入保存一个 prediction.npz 或 prediction.0001.npz,完整保留 MoGe-2 API
|
|
||||||
字典中的 points、depth、normal、mask 与 intrinsics
|
|
||||||
- debug-ben2:直接保存 BEN2 API 返回的 foreground.png 或 foreground.0001.png
|
|
||||||
|
|
||||||
Debug 不生成点云、三角网格、深度预览、置信度 percentile 或坐标变换后的派生产物。输入为多帧时,
|
DA3 Depth 结果包含 `depth`、`conf`、`extrinsics` 与 `intrinsics`。MoGe-2 Depth 结果包含 `depth`、`normal`、`mask` 与 `intrinsics`。
|
||||||
DA3 保留联合推理;MoGe-2 与 BEN2 按其单图 API 逐帧保存结果。
|
|
||||||
|
|
||||||
## Depth
|
Surface 的 `mesh.npz` 包含 `vertices`、`faces`、`uv`、`intrinsics`、`image_size` 与 `reference_depth`,以及 Geometry Nodes 使用的网格属性。
|
||||||
|
|
||||||
generate-depth 输出 Blender Depth Plane 所需的 results.npz。DA3 保存 depth、conf、
|
Debug 文件保持模型 API 的数据形态,用于比较推理结果;正式 Job 则提供 Blender 导入所需的派生数据。
|
||||||
extrinsics 与 intrinsics;MoGe-2 保存 depth、normal、mask 与 intrinsics,并额外写出
|
|
||||||
Depth Plane 导入所需的贴图。这里的文件转换属于正式 Depth 工作流,不属于 Debug。
|
|
||||||
|
|
||||||
## Surface
|
## 图片序列
|
||||||
|
|
||||||
generate-surface 统一写出 `mesh.npz`,不再生成 `texture.png`。DA3 与 MoGe-2 都使用
|
正式颜色层统一使用 `color.0001.*` 起始的文件名。深度动画使用 EXR 序列以保存浮点值。源 Empty 为 Movie 或 Sequence 时,Blender 按序列载入这些文件,即使任务只输出一帧。
|
||||||
`geometry/surface.py` 的同一套 Surface 构建算法;序列化位于 `outputs/surface.py`。MoGe-2 normal
|
|
||||||
仍由 Server 单独导出为 `normal.png`,Blender 只负责加载并用于材质,不写入几何 NPZ。未去背景时
|
|
||||||
无论颜色来自原图复制还是 BEN2,Server 都统一写为 `color.0001.*` 起始的颜色层。Blender 不依据文件数量判断图片类型:源 Empty 为普通单图时载入为 `IMAGE`,源 Empty 为 Movie/Sequence 时即使只有一帧也载入为 `SEQUENCE`;`.depth` 使用相同规则。材质只在 Blender
|
|
||||||
端创建。MoGe-2 的 `normal.png` 仍由 Server 生成,Blender 只负责加载使用。
|
|
||||||
|
|
||||||
## BEN2
|
MoGe-2 Surface 的法线单独保存为 `normal.png`。材质在 Blender 端创建,不属于 Job 文件格式。
|
||||||
|
|
||||||
正式 remove-background 工作流输出 `color.0001.png` 起始的透明颜色层。BEN2 ONNX API 返回的 Alpha
|
|
||||||
恢复到输入分辨率,RGB 保留输入图像颜色。Debug BEN2 仍保留模型 API 语义,使用 `foreground` 文件名。
|
|
||||||
|
|||||||
151
docs/server.md
151
docs/server.md
@@ -1,129 +1,84 @@
|
|||||||
# Job Server
|
# Job Server
|
||||||
|
|
||||||
本页记录 Environment、模型缓存和 Job Runtime 的内部设计。安装或使用遇到问题时,请先查看
|
SnapForm 把 AI 与媒体处理放在独立 Job Server 中运行,使 Blender Python 保持轻量。安装和使用问题见[安装与首次配置](installation.md)与[常见问题](faq.md)。
|
||||||
[安装与首次配置](installation.md)和[常见问题](faq.md)。
|
|
||||||
|
|
||||||
## 运行架构
|
## 架构
|
||||||
|
|
||||||
SnapForm 在独立的 Python 3.12 Environment 中运行 HTTP Job Server,Blender 只通过标准库 HTTP Client 提交和轮询任务,不把计算依赖载入 Blender Python。Server 只监听 `127.0.0.1`,由外置 BlendJob wheel 提供的 `ServerController` 保存随机端口与 Instance ID;专属本地进程协议不使用额外 Token 鉴权。
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Blender["Blender Operator"] --> Runtime["JobRuntime"]
|
||||||
|
Runtime --> Queue["FIFO Queue"]
|
||||||
|
Queue --> Job["SnapForm Job"]
|
||||||
|
Job --> Media["Media"]
|
||||||
|
Job --> Models["ModelManager"]
|
||||||
|
Job --> Geometry["Geometry"]
|
||||||
|
Job --> Outputs["Outputs"]
|
||||||
|
Outputs --> Directory["Job Directory"]
|
||||||
|
Directory --> Blender
|
||||||
|
```
|
||||||
|
|
||||||
每个 Blender 实例启动并持有一个 SnapForm 专属 Server,不发现或复用其它 Blender、其它插件启动的本地 Server。多个 Blender 可以对应多个进程,但模型、Environment 与任务产物仍可共享同一个 Storage Root。
|
每个 Blender 实例拥有自己的 Server 进程,连接绑定到本机随机端口和 Instance ID。多个实例可以共享 Storage Root,但各自维护进程、队列和内存中的模型 Session。
|
||||||
|
|
||||||
Environment、模型和任务结果默认保存在 `~/.snapform`:
|
BlendJob 提供 Runtime、HTTP transport、队列、状态和取消协议。SnapForm 的 Operator 负责业务参数,Server Job 负责工作流编排,结果由 Operator 在 Blender 主线程中导入。
|
||||||
|
|
||||||
|
## Storage Root
|
||||||
|
|
||||||
|
默认位置为 `~/.snapform`:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
~/.snapform/
|
~/.snapform/
|
||||||
├── .venv/
|
├── .venv/ Python Environment
|
||||||
├── environment.json
|
├── models/ ONNX 模型
|
||||||
├── models/
|
├── jobs/ 任务产物
|
||||||
├── jobs/
|
├── environment.json 环境声明
|
||||||
├── manifest.json
|
├── manifest.json 已安装环境指纹
|
||||||
├── install.log
|
├── install.log 安装日志
|
||||||
├── install-status.json # 仅在安装期间存在
|
└── server.log Server 日志
|
||||||
└── server.log
|
|
||||||
```
|
```
|
||||||
|
|
||||||
不同 Blender 版本可共享此目录。也可以在 Extension Preferences 的 `Storage Root` 中修改位置。连接端口与 Instance ID 只保存在启动它们的 Blender 进程内存中。
|
更改 Storage Root 相当于切换整套 Environment、模型和任务数据。目录说明见[模型、设备与存储](models-and-storage.md)。
|
||||||
|
|
||||||
Blender Client 与独立 Server 的通用 HTTP Runtime 都复用 `src/snapform/wheels/` 中随扩展分发的同一份 BlendJob package;Server Environment 不再从 PyPI 安装第二份。`JobServer` 包装 FastAPI 并提供 Job、FIFO Queue 与 Resource API,`JobClient` 是无第三方依赖的 Blender 侧访问器。Blender 侧只把 `server/__init__.py:server` 入口字符串交给 Runtime,不导入 `server` package;独立进程启动后才取得其中的 `server` 实例、注册全部公开 Job,并创建 `model_manager` Resource。
|
## 模块关系
|
||||||
|
|
||||||
SnapForm 根 package 不再有 `client.py`、`environment.py`、`storage.py` 或 `models.py`。这些文件会制造并不存在的业务边界:
|
| 模块 | 职责 |
|
||||||
|
| --- | --- |
|
||||||
|
| `runtime.py` | Environment、Server 生命周期、任务状态与 Storage Root |
|
||||||
|
| `server/jobs/` | 按功能编排完整任务 |
|
||||||
|
| `server/media/` | 图片、视频和序列输入 |
|
||||||
|
| `server/models/` | ONNX Session 与模型 Prediction |
|
||||||
|
| `server/geometry/` | 投影、网格和 Surface 数据转换 |
|
||||||
|
| `server/outputs/` | NPZ、EXR、PNG 与序列文件 |
|
||||||
|
| `model_catalog.py` | Blender UI 的模型声明与本地就绪检查 |
|
||||||
|
| `server/model_catalog.py` | Server 的模型下载与推理声明 |
|
||||||
|
|
||||||
- Storage Root、`.venv`、Manifest、安装状态与日志路径由 `JobRuntime` 唯一管理;
|
调用方向从 Job 指向媒体、模型、几何和输出模块。模型层返回内存数据,输出层管理文件,Blender 数据始终由 Operator 的 Response 创建。
|
||||||
- `InstallEnvironment` 由 `runtime.operator_classes()` 提供,Python 与 Job 自身的额外 packages 直接声明在传给 Runtime 的 `environment` 字典中;BlendJob 不需要重复声明;
|
|
||||||
- Blender Client 使用根目录 `model_catalog.py` 构造 UI 并检查 Storage Root 下的模型必需文件;
|
|
||||||
- 独立 Server 使用 `server/model_catalog.py` 下载和验证模型,不依赖 Extension 根 package。两份声明的共享存储字段由测试保持一致,已加载 Session 状态仍读取 Server Snapshot。
|
|
||||||
|
|
||||||
BlendJob 的 `JobClient` 只是无依赖 HTTP transport,不是 SnapForm 业务抽象,也不向 Operator 暴露 `JobRequest`。
|
## Environment 与模型
|
||||||
|
|
||||||
SnapForm 的业务 Operator 直接提交参数字典,Job Type 一一对应:
|
Environment 使用 Python 3.12、ONNX Runtime、NumPy、Pillow、PyAV、trimesh、FastAPI 和 Uvicorn。Windows 使用 DirectML 或 CPU;其他平台根据支持情况使用 CoreML、OpenVINO 或 CPU。
|
||||||
|
|
||||||
- `download_model` → `download-model`
|
正式功能支持:
|
||||||
- `download_required_models` → `download-required-models`
|
|
||||||
- `remove_background` → `remove-background`
|
|
||||||
- `extract_frames` → `extract-frames`
|
|
||||||
- `generate_depth` → `generate-depth`
|
|
||||||
- `generate_surface` → `generate-surface`
|
|
||||||
- `debug_da3` → `debug-da3`
|
|
||||||
- `debug_moge2` → `debug-moge2`
|
|
||||||
- `debug_ben2` → `debug-ben2`
|
|
||||||
|
|
||||||
DA3 与 MoGe-2 的生产任务不再合并成带 Backend 参数的通用请求。每个 Operator 的 `request(context)` 直接构造自己的有效参数,Debug 任务也与生产任务完全分开。
|
|
||||||
|
|
||||||
Server 端没有 `execute_*`、`*_result` 或 CLI worker 中转。全部 `@server.job(...)` 都集中在
|
|
||||||
`server/__init__.py`,这里只负责注册 Job 与 Resource。完整工作流位于 `jobs/`;模型适配器和
|
|
||||||
ONNX 实现位于 `models/`;纯投影、网格与唯一 Surface Builder 位于 `geometry/`;文件序列化位于
|
|
||||||
`outputs/`;输入准备位于 `media/`。模型下载由 `ModelManager` 单向调用
|
|
||||||
`model_download.py`,不存在循环 import。
|
|
||||||
|
|
||||||
## ONNX-only Environment
|
|
||||||
|
|
||||||
扩展不内置 `uv` 可执行文件或 AI 运行库。`Install Environment` 首先下载固定版本的 uv,校验 SHA-256 后解压到共享 Storage Root,再用它创建环境并安装:
|
|
||||||
|
|
||||||
- ONNX Runtime 1.24.4;Windows 使用 `onnxruntime-directml`
|
|
||||||
- NumPy 2.4.2、Pillow 12.1.1、PyAV 17.0.0、trimesh 4.11.2
|
|
||||||
- FastAPI 0.139.2、Uvicorn 0.51.0、huggingface-hub 1.4.1
|
|
||||||
|
|
||||||
运行环境不安装 PyTorch、TorchVision、xFormers,也不安装 DA3、MoGe、BEN2 或 utils3d 的 Python 源码包。模型推理和 SnapForm 所需的相机、点云、网格后处理均由 ONNX Runtime、NumPy 与内部实现完成。
|
|
||||||
|
|
||||||
Windows 当前发行环境提供 DirectML 与 CPU;`Auto` 优先使用 `DmlExecutionProvider`。显式指定不可用设备时会安全回退到 CPU。其他平台安装标准 ONNX Runtime,并按可用 provider 选择 CoreML、OpenVINO 或 CPU。Blender 侧通过 `sysctl` 识别 Apple Silicon 代际,只有 M4 或更新芯片才提供 MPS 选项;其他 Mac 只提供 CPU。M4 或更新芯片的 CoreML Session 使用 `MLProgram` 模型格式、全部可用计算单元并允许动态输入 Shape。
|
|
||||||
|
|
||||||
`environment.json` 保存规范化后的声明,`manifest.json` 保存该声明与基础 Server packages 的 Hash。Python、packages、平台 packages、基础 Server packages 或 Environment Schema 改变后需要重新执行 `Install Environment`,不再维护手工 Manifest Revision。BlendJob 直接随 Extension 更新,不参与 Environment Hash。
|
|
||||||
|
|
||||||
## 模型
|
|
||||||
|
|
||||||
所有正式功能都使用 ONNX:
|
|
||||||
|
|
||||||
- MoGe-2 ViT-S、ViT-B、ViT-L Normal
|
- MoGe-2 ViT-S、ViT-B、ViT-L Normal
|
||||||
- DA3 Small、DA3 Base
|
- DA3 Small、DA3 Base
|
||||||
- BEN2 Base
|
- BEN2 Base
|
||||||
|
|
||||||
首次安装在 `post_install(runtime)` 中自动下载 `MoGe-2 ViT-S Normal` 与 FP16 `BEN2 Base`。它们和 `Models` panel 的后续下载一样,都是 `download-model` Job,与推理共用队列、HTTP 进度和取消协议。模型按声明的目录名保存在 `~/.snapform/models/`,并保留仓库内部结构。声明了文件清单与校验值的模型先走 Hugging Face `resolve` 直链流式下载,失败后尝试 `https://models.omoolab.xyz/<repository-name>/<relative-path>`;两者都失败时当前 Job 直接失败,不记录或猜测后续下载源。模型直链的 TCP、代理 CONNECT 与 TLS 建连最长等待 10 秒;连接后连续 60 秒没有收到响应数据则视为当前来源失败并切换下一来源。该空闲超时不限制模型总下载时长。R2 文件会校验大小与 SHA-256。未声明清单的仓库仍使用 `huggingface_hub` 快照下载。可用 `HF_ENDPOINT` 指定 Hugging Face 镜像。
|
首次安装会下载 MoGe-2 ViT-S Normal 与 BEN2 Base。模型保存在 `models/`,可由多个 Blender 版本共享。Server 缓存 BEN2 和最近使用的一个深度模型,以控制内存占用。
|
||||||
|
|
||||||
Image Empty 的右键菜单会读取 `model_manager` 状态;缺少 MoGe-2 ViT-S 或 BEN2 时显示
|
模型下载优先使用 Hugging Face,并以 OmooLab 镜像作为回退。Environment 安装则提供官方源和镜像源两个明确入口;它们只影响 Environment Bootstrap,不改变模型下载策略。
|
||||||
`Download Required Models`。对应的 `download-required-models` Job 在 Server 端再次检查就绪状态,
|
|
||||||
只下载缺失项,并把多个下载的进度合并为单次 0–100%。必需模型补齐前,右键菜单中的
|
|
||||||
`Convert to Depth Plane` 保持禁用。
|
|
||||||
|
|
||||||
`model_manager` Resource 在 Server 内持有磁盘就绪状态和内存 Session。Blender UI 使用 Client catalog 声明的目录名与 `ready_patterns` 直接检查 `<storage-root>/models/`,因此展开模型面板、打开转换对话框或 Image Empty 右键菜单都不会仅为状态检测启动 Server。真正下载或推理时,Server 仍按自身 catalog 通过 `ModelManager.ready()` 重新验证文件。`ModelSessionCache` 保留 BEN2,并在 DA3 与 MoGe-2 之间只保留最近使用的一种 Depth Session。`GET /resources/model_manager` 只返回当前加载状态;`POST /resources/model_manager/clear` 只允许在没有 Active 或 Queued Job 时执行。
|
## 任务生命周期
|
||||||
|
|
||||||
DA3 ONNX 使用模型的 camera decoder 恢复外参与内参,不提供 ray-pose 参数。MoGe-2 的 metric depth、normal、mask、normalized intrinsics,以及 BEN2 的 Alpha,均在 NumPy 后处理中恢复。
|
Server 在第一次提交 Job 或手动 Start 时启动。任务进入单 Worker FIFO Queue,同一时间执行一个推理、下载或媒体任务。
|
||||||
|
|
||||||
|
Job 被接受时,Runtime 创建 opaque ID 和对应目录。进度保存在 Server 内存中,Blender 定期轮询状态。取消会在下载分块或一次 ONNX 调用结束后的检查点生效。
|
||||||
|
|
||||||
## 安装网络与镜像
|
Blender 正常退出或禁用扩展时会停止所属 Server;Server 也监视父进程,在异常退出后自行结束。
|
||||||
|
|
||||||
安装器不做带宽测速、来源竞速或失败后的自动切换。`Install Environment` 固定使用 Astral GitHub Release、uv 默认 Python 下载源和 PyPI;`Install Environment (Mirror)` 固定使用 CNB uv Release、CNB `python-build-standalone` 与清华 PyPI。两个 uv 来源使用同一固定版本和 SHA-256;下载压缩包只作临时文件,安装完成后删除。
|
Environment 安装发生在 Server 启动前,使用 `install.log` 记录过程。安装完成后的模型下载是普通 Job,与推理共享队列、进度和取消机制。
|
||||||
|
|
||||||
镜像模式只在当前安装子进程中设置 `UV_PYTHON_INSTALL_MIRROR` 与 `UV_INDEX_URL`,不修改用户环境变量。Windows 系统代理会注入子进程;同时支持 `HTTP_PROXY`、`HTTPS_PROXY` 与 `ALL_PROXY`。post-install 模型下载保持 Hugging Face 与 OmooLab R2 的独立来源逻辑,不读取 Environment 安装模式。
|
## DA3 多帧限制
|
||||||
|
|
||||||
Environment Bootstrap 的单次 0–100% 只包含 uv、Python 与 packages,不包含后续模型下载。开始显示 `Starting installation ...`;uv 使用 1–20%,显示 `1/3 Starting uv download ...`、`1/3 Downloading uv [速度]` 或镜像形式、`1/3 Installing uv ...`;Python 使用 20–50%,显示 Checking、Downloading、Installing,镜像下载带 `(mirror)`;packages 使用 50–98%,显示 Resolving、`Downloading packages [已开始数]/[解析总数]`、Installing,镜像模式带 `(mirror)`;98–100% 显示 `Verifying installation ...`。三个下载区间分别占对应阶段的 84–87%,缓存命中时直接跳过。单阶段状态不显示 `1/1`。
|
DA3 ONNX 的联合注意力显存随帧数和分辨率快速增长。Depth Plane 动画采用逐帧推理并复用 Session,使峰值显存接近单帧任务;代价是各帧不共享相机位姿与深度尺度。
|
||||||
|
|
||||||
首次安装依次显示 Environment、MoGe-2 下载与 BEN2 下载,每项分别使用 0–100%。Environment 由 Job Runtime 依次完成 uv 下载、Python Environment 创建与 packages 安装,uv 不再占用独立一轮进度。两个模型下载由 SnapForm 的 `post_install(runtime)` 通过 `runtime.request()` 顺序发起;切换模型时进度归零,但不显示 `2/2` 等阶段编号。
|
DA3 Debug 保留联合推理,用于检查模型原始多帧行为。Server 会在预计注意力张量过大时提前报错,此时应减少帧数或 Resolution Scale。
|
||||||
任一 post-install 模型失败时,Environment 文件仍然保留,但安装 Operator 返回失败并显示原始模型错误。
|
|
||||||
|
|
||||||
## 生命周期
|
|
||||||
|
|
||||||
扩展启用后不启动 Server;第一次执行 Job 或点击 `Start` 时才为当前 Blender 启动专属实例。正常关闭或禁用扩展时直接停止自己启动的进程;Server 也监视 Blender 父进程 PID,父进程异常退出后会自行停止。
|
|
||||||
|
|
||||||
AI、帧提取和 Models panel 下载的 Job 进度保存在 Server 内存中。Handler 只使用 `context.progress(value, message)` 发布进度,终态由 JobServer 内部管理;SnapForm 不再维护独立的 `server/progress.py` 或 Job 状态 JSON。Blender 每 0.25 秒轮询 `GET /jobs/{job_id}`,任务目录不写 `status.json`。Operator 直接提交参数字典;Job 被接受时由 `JobServer` 自动生成 ID 并创建 `jobs/<opaque-job-id>/`,提交响应同时返回 `job_id` 与 `directory`。路径通过 `JobContext.directory` 传给 Handler,SnapForm Server 强制将输出写入该目录。
|
|
||||||
|
|
||||||
`runtime.py` 创建唯一的 `runtime = JobRuntime(...)`,以 `src/snapform/` 为 `entrypoint_root`,传入 `server:server` 相对入口字符串与显式 `snapform` namespace,并导出 `runtime.JobOperatorBase`。Runtime 独占 Storage Root、Environment 路径、安装进程、Server 控制和 Status Bar。Operator 未覆盖 `request(context)` 时会提交全部 Property;覆盖后返回值纯替换默认参数。可选的 `response(context, result)` 在 Blender 主线程直接完成结果导入。SnapForm 不再提供 Client 请求层、Job Mixin、Status Bar 或 Build 中转 Operator。
|
|
||||||
|
|
||||||
Environment 安装是 Server 启动前唯一的 Bootstrap 流程:`environment.json` 记录期望安装的环境声明,`install-status.json` 在安装期间回传进度并在任务收尾后删除,`install.log` 持久记录安装输出。Bootstrap 不创建 Job 目录。Environment 成功后执行普通的 `post_install(runtime)`;默认模型下载此时已经是正式 Server Job,会创建自己的 Job 目录,并使用与其它任务相同的进度和取消协议。
|
|
||||||
|
|
||||||
Server 使用标准库单 Worker FIFO Queue,全局同时只执行一个 Job,包括模型下载。Server 忙碌时新 Job 进入 `queued`,不再返回 409;排队任务可以在执行前取消。取消运行中任务不会重启 Server;流式模型下载会在分块检查点结束,ONNX Runtime 当前调用则在返回后的最近检查点结束。JobServer 最多保留 100 个已完成 Job 的内存状态,后续提交时清理更早记录。
|
|
||||||
|
|
||||||
Server 由 Environment 通过 `python -m blendjob.runner` 启动,避免 BlendJob 内部模块名遮蔽 Python 标准库。启动与崩溃信息写入 `~/.snapform/server.log`。
|
|
||||||
|
|
||||||
## 已知限制
|
|
||||||
|
|
||||||
DA3 ONNX 的注意力是朴素实现(`MatMul → Softmax → MatMul`),峰值显存与 `(帧数 × 每帧 patch 数)²` 成正比。迁移到 ONNX 前的 PyTorch 版使用 `scaled_dot_product_attention`(`XFORMERS_DISABLED=1`),按块计算注意力分数、不落地完整注意力矩阵,显存近似线性,因此视频能处理几十上百帧;ONNX 导出把 SDPA 回退成朴素注意力,多帧在中高分辨率下会显存爆炸(约 12 帧 @ 1008 即需 ~90 GB)。
|
|
||||||
|
|
||||||
Depth Plane 的视频、序列帧转换会复用同一个 ONNX Session 逐帧独立推理,再把结果合并到现有序列输出,因此峰值显存只取决于单帧分辨率。逐帧结果不保留 DA3 的跨视图尺度与相机位姿一致性,适用于当前逐帧动画结构。Surface 只支持单张图片。DA3 Debug 多帧输入仍联合推理;MoGe-2 与 BEN2 Debug 按单图 API 逐帧保存直接返回。
|
|
||||||
|
|
||||||
联合推理时,`onnx_da3.infer` 会在执行前按 `views × patches` 预估注意力张量,超过 4 GiB 直接报错并提示减少帧数或 Resolution Scale,避免 onnxruntime 报出难以定位的分配失败或 `UnicodeDecodeError`(DirectML 在中文 Windows 下报错信息为 GBK 编码,被 UTF-8 解码掩盖)。
|
|
||||||
|
|
||||||
待解决方向:
|
|
||||||
|
|
||||||
- 重新导出 DA3 ONNX,使用 ORT 的 `Attention` / `MultiHeadAttention` 贡献算子,或注入内存高效的注意力算子;
|
|
||||||
- 若未来需要统一坐标系的视频重建,可按小窗口分批推理,再对齐相机位姿与深度尺度。
|
|
||||||
|
|||||||
226
docs/surface.md
226
docs/surface.md
@@ -1,194 +1,66 @@
|
|||||||
# Surface
|
# Surface
|
||||||
|
|
||||||
本页记录 Surface 的网格、相机、Geometry Nodes 和材质实现。日常操作与参数说明请阅读
|
Surface 从单张图片生成带透视关系的相机空间网格。它比 Depth Plane 更接近真实空间结构,适合需要匹配相机或继续编辑拓扑的场景。操作方法见[Surface 使用指南](guide-surface.md)。
|
||||||
[Surface 使用指南](guide-surface.md)。
|
|
||||||
|
|
||||||
## Image Empty 转透视网格
|
## 数据流
|
||||||
|
|
||||||
1. 在 3D View 选中仅包含一帧的 Image Empty,右键打开 `SnapForm` 子菜单并选择
|
```mermaid
|
||||||
`Convert to Surface`。该入口独立于
|
flowchart LR
|
||||||
`Convert to Depth Plane`,当前不处理视频或序列帧。
|
Image["单张 Image Empty"] --> Model["DA3 / MoGe-2"]
|
||||||
模型列表包含三个 MoGe-2 Normal 模型和两个 DA3 模型,默认使用 MoGe-2 ViT-S Normal。DA3 的
|
Model --> Builder["Surface Builder"]
|
||||||
Resolution Scale 与 Depth Plane 使用相同设计;MoGe-2 改为显示共享的 Resolution Level。
|
Builder --> Mesh["mesh.npz"]
|
||||||
2. Server 先用未经抠图的原始 RGB 请求 DA3 的 `depth`、`conf` 与 `intrinsics`,随后仅在开启
|
Mesh --> Blender["Blender 导入"]
|
||||||
`Remove Background` 时运行 BEN2 取得前景 Alpha;Alpha 经目标网格分辨率缩放与轻微模糊后,只要
|
Blender --> Nodes["Geometry Nodes 后处理"]
|
||||||
大于 `0` 就把对应点视为前景;每个三角形的三个顶点必须全部为前景,任一顶点 Alpha 为 `0` 就在输出前删除该面;
|
Nodes --> Result["Surface + Material"]
|
||||||
关闭时不加载 BEN2。Depth 不做 smoothing,Server 反投影深度网格,建立源三角面,
|
Blender --> Camera["可选匹配相机"]
|
||||||
计算 confidence percentile 与 3×3 相对深度不连续度,并写入
|
|
||||||
基于原图像素坐标的源 `UVMap`。这些步骤只准备修改器的输入网格,不执行 Relax、拓扑修复、平滑、
|
|
||||||
小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Server 端源数据准备。
|
|
||||||
DA3 与 MoGe-2 最终都由 `server/geometry/surface.py` 构建同一份 Surface 数据;模型模块只提供各自的
|
|
||||||
深度、点、法线、Mask 与相机数据,不再自行拥有 Surface 网格输出实现。
|
|
||||||
3. Blender 把勾选 `Usage > Modifier` 的 `O DA3 Surface` 节点组直接作为 Geometry Nodes 修改器
|
|
||||||
添加到 Surface,不另设修改器名称。节点组内没有子节点组,依次完成:
|
|
||||||
- 修改器顶层依次显示 `Iterations`、`Resolution`、`Smooth`;`Iterations` 默认 `2`、最小 `0`,
|
|
||||||
默认展开的 `Culling` Panel 依次包含 `Depth Continuity` 与 `Confidence`。前者直接读取面域 3×3
|
|
||||||
相对深度差,并使用指数映射:
|
|
||||||
默认 `50%` 对应官方 `rtol=0.04`,数值越大,内部阈值越小,剔除的断层面越多;
|
|
||||||
- 每轮 Remesh Iteration 依次执行 Connected Merge、循环拓扑修复与一次 Smooth,使新拓扑在进入下一轮
|
|
||||||
Merge 前先稳定;拓扑修复删除非流形面、单面拓扑和小面积 mesh islands;
|
|
||||||
- 默认 `Resolution` 为原始有效三角边长中位数乘以 `sqrt(10) * 2`。`sqrt(10)` 来自原先 90% 面数缩减
|
|
||||||
的二维边距估计,再乘以 2 为迭代合并留出更平滑、稳定的默认尺度;节点内部 Connected Merge 的
|
|
||||||
Distance 为 `Resolution * 0.5`;
|
|
||||||
- Smooth 采用 `O Smooth` 的边界检测和模糊法线权重,固定开放边界与薄面,只保留一个
|
|
||||||
`Smooth` 程度系数;节点组不再额外执行 Flip Faces;
|
|
||||||
- 从修改前的原始表面以 nearest-surface 重新采样 `UVMap`,最终明确写为 Corner 域 `2D Vector`。
|
|
||||||
MoGe-2 使用独立的 `O MoGe2 Surface`。Server 由内置 NumPy 网格构建函数
|
|
||||||
生成有限深度范围内的紧凑三角网格,并保留模型 mask、逐点 depth 及相邻点相对深度差。Geometry Nodes
|
|
||||||
按官方 `mask & ~depth_map_edge(depth, rtol=0.04)` 的含义,先由 `Mask` 删除 mask 外的面,再由
|
|
||||||
`Depth Continuity` 按同一份 3×3 邻域相对深度差删除断层面。DA3 与 MoGe-2 使用相同的属性、
|
|
||||||
名称、指数滑块和 FACE 删除逻辑,不再额外删除孤立点。之后才执行拓扑修复、Relax、孤岛清理与 UV 重投影。
|
|
||||||
节点组不提供 Confidence 或 Remesh 开关;Remesh 仍是节点处理流程的一部分。
|
|
||||||
4. 节点组随插件放在 `O_SnapForm.blend`。Blender 4.5 首次 Append 后复用同一 datablock;
|
|
||||||
Blender 5.0 以 linked ID 载入后调用 Pack,后续 Surface 继续复用。插件不会为每个 Surface 新建节点组,
|
|
||||||
也不会在添加后重命名 Geometry Nodes 修改器。节点组只启用 `Usage > Modifier`,不 Mark as Asset;
|
|
||||||
修改器关闭 `show_group_selector`。`Fix Topology` 位于默认折叠的 `Options` Panel。
|
|
||||||
节点图按 Culling、Remesh、Topology Fix、Island Cleanup 与 UV 分区摆放;各区就近使用独立的
|
|
||||||
Group Input,并隐藏没有连线的接口,避免跨越整个节点图的输入连线。
|
|
||||||
`O DA3 Surface` 与 `O MoGe2 Surface` 的 `Iterations` 默认值均为 `2`、最小值为 `0`,
|
|
||||||
`Resolution` 默认值均为 `0.1`,所有输入 socket 的 Structure Type 均固定为 `Single`。
|
|
||||||
5. Surface Job 统一按输入准备、模型推理、可选 BEN2、构建 SurfaceMesh、写出产物五个阶段更新进度;单帧推理只显示模型与阶段名称,不追加 `1/1`,仅多帧时显示当前帧与总数;
|
|
||||||
Surface Builder 是不接收 Job Context 或 progress callback 的纯数据转换。
|
|
||||||
6. Server 在 job 根目录写出 `mesh.npz`。NPZ 保存 camera-space `vertices`、`faces`、`uv`、
|
|
||||||
`intrinsics`、`image_size` 与有效深度中位数 `reference_depth`。颜色层无论来自原图复制还是 BEN2,
|
|
||||||
Server 都统一写为 `color.0001.*`。Blender 读取网格数据后额外导入为 `.color`,材质不复用源
|
|
||||||
Empty 的 Image。颜色贴图会按当前场景的 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`。 Server 不生成 `texture.png`,材质只在
|
|
||||||
Blender 端创建。
|
|
||||||
DA3 Surface 只保留 Geometry Nodes 后处理实际使用的 attributes:点域 `o_depth`、面域
|
|
||||||
`o_confidence` 与面域 `o_depth_discontinuity`。Remove Background 已在 Server
|
|
||||||
建面前直接删除背景三角面,不写入任何 background attribute。MoGe-2 的导入网格保留点域 `o_depth`,
|
|
||||||
以及面域 `o_mask`、`o_depth_discontinuity`,不伪造 confidence、stretch 或 background 属性,
|
|
||||||
也不写入 custom normal。MoGe-2 推理返回的 normal 直接编码为独立的 `normal.png`,Blender 将它
|
|
||||||
作为 Non-Color 贴图导入现有材质;normal 不进入 `mesh.npz`。
|
|
||||||
MoGe-2 Surface 独立导入的 normal Image datablock 以源图片名而非对象名为基名,使用
|
|
||||||
`图片名.normal`,不附加 `.moge2`。
|
|
||||||
7. 网格以 `reference_depth` 截面匹配 Image Empty 的显示矩形:该截面的投影四角与 Empty 的可见
|
|
||||||
边界重合,主点同时匹配 Empty Offset。网格对象直接沿用源 Empty 的 `matrix_world`,因此位置、旋转、
|
|
||||||
缩放及父级关系产生的世界变换保持一致。更近的顶点沿 Empty 局部 `+Z` 方向凸出,三角面正面朝向
|
|
||||||
观察相机,启用 Backface Culling 时可从正面看到。
|
|
||||||
8. 转换对话框不再提供后处理参数,只保留模型与 Resolution Scale 设置、`Create Matching Camera`、
|
|
||||||
`Remove Background`、`Shadeless`,以及分割线下方的 `Keep Original`。背景移除默认关闭;开启后 Server
|
|
||||||
使用 BEN2,并在写出 Surface 前直接删除背景三角面,不再由 Geometry Nodes 的 Background 开关处理。
|
|
||||||
该预删除同时适用于 DA3 与 MoGe-2;因此 MoGe-2 转换后即使关闭 `Mask`,BEN2 已删除的背景也不会恢复。
|
|
||||||
Surface 材质的 Color Image Texture 始终使用 `.color`;Remove Background 只决定该层是否带 BEN2 Alpha,原 Empty 始终保持原图。颜色贴图会按当前场景的 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`。
|
|
||||||
`Shadeless` 使用与 Depth Plane 相同的 `O Shadeless` 节点组,并继续通过 Alpha 与 Transparent BSDF 混合;普通材质的
|
|
||||||
Bump Scale 按 `abs(object.scale.z) × camera scale_z` 设置,并在材质组内乘以固定基准 `0.001`,
|
|
||||||
与透视表面实际深度尺度一致;它允许负数以反转凹凸方向。
|
|
||||||
相机默认关闭;开启后会在 Empty 局部 `+Z` 侧创建一台朝向局部 `-Z` 的透视相机,并成为生成 Surface
|
|
||||||
的子级,使二者移动时保持投影关系。关闭 `Keep Original` 时,网格成功创建后删除源 Empty。
|
|
||||||
表面、材质与相机会归入同一个 `Convert to Surface` Undo 步骤。
|
|
||||||
|
|
||||||
Process Res 的设计依据 DA3 官方实现:API 与 CLI 默认使用 `process_res=504` 和
|
|
||||||
`process_res_method="upper_bound_resize"`;输入预处理的 `PATCH_SIZE=14`,会把宽高分别调整到最接近的
|
|
||||||
`14` 倍数。DA3 论文以 `504×504` 为 base resolution,公开的训练尺寸长边为 `504`、`672`、`756` 或
|
|
||||||
`896`。官方 CLI 虽给出 `1024` 示例,但没有声明更高分辨率会提升质量,也没有规定 `1000` 为硬上限。
|
|
||||||
因此转换入口使用相对原图的 Resolution Scale,同时通过上下限避免 Process Res 过低或过高,并把训练范围
|
|
||||||
之外的值明确视为实验设置。参考
|
|
||||||
[DA3 API](https://github.com/ByteDance-Seed/Depth-Anything-3/blob/main/docs/API.md)、
|
|
||||||
[输入预处理实现](https://github.com/ByteDance-Seed/Depth-Anything-3/blob/main/src/depth_anything_3/utils/io/input_processor.py)
|
|
||||||
与[技术报告](https://depth-anything-3.github.io/assets/da3_tech_report_2025.pdf)。
|
|
||||||
|
|
||||||
所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的进度条内部,取消按钮
|
|
||||||
位于进度条右侧。状态字段支持阶段、当前操作与预计剩余秒数,界面统一组合为紧凑文案并保证
|
|
||||||
进度单调递增。任务进程与 Server 请求在后台启动,按钮点击后立即进入进度状态。Image Depth 的阶段包括 Environment 启动、输入准备、模型加载、深度估算、缓存
|
|
||||||
写出和面片构建。进度更新会强制刷新状态栏与窗口,无需移动鼠标即可实时显示。安装中断后残留的
|
|
||||||
半成品 Environment 会在下次安装前自动清理,不会因为中断而无法再次安装。
|
|
||||||
|
|
||||||
导入过程精确保留 NPZ 内深度值之间的差值。例如深度 `2.0` 和 `5.5` 在
|
|
||||||
`Displacement Strength = -1` 时,沿局部 Z 轴相差 `3.5 Blender Unit`。它不会把数值压缩到
|
|
||||||
`0..1`,也不再提供视觉归一化模式。
|
|
||||||
|
|
||||||
面片尺寸以有效深度的中位数 `Zref` 作为参考截面。先用 `mini_npz` 中的相机内参计算原始
|
|
||||||
投影面积,再恢复源图纵横比:
|
|
||||||
|
|
||||||
```text
|
|
||||||
RawWidth = depth_width × Zref × abs(Displacement Strength) / fx
|
|
||||||
RawHeight = depth_height × Zref × abs(Displacement Strength) / fy
|
|
||||||
Area = RawWidth × RawHeight
|
|
||||||
Aspect = source_image_width / source_image_height
|
|
||||||
Width = sqrt(Area × Aspect)
|
|
||||||
Height = sqrt(Area / Aspect)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
这样即使 DA3 估计出的 `fx/fy` 不一致,也不会把正方形原图拉成长方形。主点 `cx/cy` 仍用于
|
模型提供深度、相机和有效区域,Surface Builder 将其转换为统一的相机空间网格。Blender 导入网格后,用 Geometry Nodes 完成清理、重拓扑、平滑和 UV 重投影。
|
||||||
确定原始相机面片,随后对象 XY Transform 将它匹配到源 Image Empty。生成对象会记录
|
|
||||||
`snapform_reference_depth`、`snapform_camera_plane_width` 和
|
|
||||||
`snapform_camera_plane_height`,便于检查实际采用的
|
|
||||||
参考深度和尺寸。
|
|
||||||
|
|
||||||
材质始终导入深度 Image Texture,并连接到 Displacement 节点和 Material Output;材质 Surface
|
## 模型数据
|
||||||
Displacement 设置为 `Displacement Only`,DA3 与 Original 都使用 `Midlevel = 0`、`Scale = 1`。MoGe-2 的位移只在专用 Geometry Nodes 中处理。
|
|
||||||
`o_material_displacement` 为 `False` 时,材质把 Height 乘以零,置换只在几何节点中生效;为 `True` 时,
|
|
||||||
几何节点关闭结构置换,材质 Height 恢复。色彩贴图的 Alpha 二值结果同时用于选择原始深度或有符号的
|
|
||||||
`Background Depth`,使两种置换路径保持一致;未移除背景的普通不透明图片因此全部视为前景。
|
|
||||||
视频与序列帧的颜色、深度与前景 Image Texture
|
|
||||||
通过 image_user 的 Frames / Offset / Start 把第 i 帧映射到场景 `Start+i` 帧,并启用 Auto Refresh。
|
|
||||||
颜色贴图到 Principled BSDF 之间使用独立的 Shader Node Group。DA3 Surface 使用 `O DA3 Surface Layer`,
|
|
||||||
MoGe-2 Surface 使用独立的 `O MoGe2 Surface Layer`;它额外接收模型输出的 `Normal` 贴图,使用
|
|
||||||
`Normal Map` 节点按 `Object Space` 解码后连接到 Bump 的 `Normal` 输入,使像素级模型法线与原图
|
|
||||||
Bump 合并,不依赖重拓扑后的顶点密度;`Normal Strength` 默认值为 `1.0`,只调节模型 normal map,
|
|
||||||
颜色贴图 Bump 仍由独立的 `Bump Scale` 控制;
|
|
||||||
Depth Plane 使用 `O DA3 Depth Plane Layer` 材质组,并把背景剔除、背景深度与材质置换 Attribute
|
|
||||||
读取全部收进组内。两者都接收 `Color`、`Alpha` 与 `Bump Scale`,只有 `O DA3 Depth Plane Layer`
|
|
||||||
额外接收 Float 类型的 `Depth`。
|
|
||||||
`O DA3 Surface Layer` 只输出 `Color`、`Roughness`、`Alpha` 与 `Normal`,以原图 Color 保留 Bump 效果,
|
|
||||||
不创建 Displacement 节点或输出;`O DA3 Depth Plane Layer` 另外输出 `Displacement`。外层材质只保留贴图、
|
|
||||||
材质组、Principled BSDF 与 Material Output,方便继续调整。
|
|
||||||
Depth Plane 的 Bump Height 直接使用材质组的 `Color` 输入,只受独立的 `Bump Scale` 影响,不使用深度贴图,
|
|
||||||
也不经过 `Displacement` 模式开关、`Depth Scale` 或 `Background Depth`;材质 Displacement 仍使用这些参数处理后的深度。
|
|
||||||
无论选择哪种置换方式,Layer 的 `Color` 都会连接 Principled BSDF 的 `Base Color`、
|
|
||||||
`Subsurface Radius` 与 `Emission Color`;Layer 的 `Normal` 同时连接 `Normal` 与 `Coat Normal`,
|
|
||||||
通过 `Bump.Height → Bump.Normal` 提供表面细节。Subsurface、Emission 与 Coat 的强度等其他输入
|
|
||||||
保持 Principled BSDF 默认值。Bump 的基础 Distance 为
|
|
||||||
`0.001`,会乘以面片 Transform 的统一缩放倍率。原图 Color 还会经过 `Float Curve` 连接到
|
|
||||||
Principled BSDF 的 `Roughness`,曲线两端输出分别为 `0.5` 和 `0.1`;材质 IOR 保持
|
|
||||||
Principled BSDF 默认值 `1.5`。
|
|
||||||
开启 `Shadeless` 后,色彩贴图 Color 接入 `O Shadeless`。该组沿用 Blender Images as Planes 的
|
|
||||||
Diffuse、Emission、Light Path 与射线判断连接;二值 Alpha 仍作为 Mix Shader 系数,在 Transparent BSDF
|
|
||||||
与 `O Shadeless` 之间混合后连接 Material Output。外层不再创建 Emission、Principled BSDF、Bump 或
|
|
||||||
Float Curve。开启 `Remove Background` 时色彩贴图使用 `.color`;关闭时使用 `.color`。
|
|
||||||
|
|
||||||
这里的“原始距离”是 DA3 模型输出的尺度。普通 Small、Base、Large 和 Giant 模型的深度不应
|
| 来源 | 用于建网格的数据 | 材质附加数据 |
|
||||||
被解释为现实世界的米;需要现实尺度时应选择带 metric depth 的
|
| --- | --- | --- |
|
||||||
`DA3 Nested Giant Large 1.1`。此外,当前结果仍是固定 XY 平面上的深度位移面,不是使用
|
| DA3 | Depth、confidence、intrinsics | 原图颜色 |
|
||||||
相机内参逐像素反投影得到的完整 camera-space 网格;中位深度截面为 Geometry Nodes
|
| MoGe-2 | Metric depth、mask、intrinsics | 原图颜色与 normal |
|
||||||
深度面片提供正确的整体高宽尺度。
|
|
||||||
|
|
||||||
生成完成后会直接创建面片,不提供缓存重建入口或单独的导入参数。重新选择
|
两种模型共享同一 Surface Builder 和网格文件格式。启用 Remove Background 时,BEN2 Alpha 会在建网格阶段移除背景三角面;这是几何裁剪,转换后关闭材质 Mask 不会恢复已删除的面。
|
||||||
`Convert to Depth Plane` 会按当前 Depth from 再次转换。
|
|
||||||
面片、深度 Image、颜色 Image、Material 与 Geometry Nodes 修改器会在同一个 Blender Undo 步骤中创建;转换完成后执行
|
|
||||||
`Ctrl+Z` 会完整撤销本次转换,不会越过转换去回退更早的 Image Empty 操作。
|
|
||||||
|
|
||||||
Image Empty 使用 Packed Image 时,SnapForm 会通过 Blender 写出临时 PNG 交给 DA3,
|
## 源网格
|
||||||
不会 Unpack 或修改源 Image;任务结束后自动删除临时文件。普通图片仍直接读取源文件。DA3 的原始
|
|
||||||
`results.npz` 保留在对应 job 文件夹根下,生成的浮点深度 Image 会 Pack 到
|
|
||||||
当前 `.blend`。Depth Image 与 Displace Texture 根据源 Image datablock 命名。例如 Empty Object
|
|
||||||
名为 `obj_img`、引用的 Image 名为 `img.png` 时,Depth Image 与 Texture 都命名为 `img.depth`;
|
|
||||||
生成的 Object、Mesh 与 Material 仍命名为 `obj_img`。Pack 使用 OpenEXR,因而不会把大于 `1.0`
|
|
||||||
的原始深度截断。已有同名 datablock 时由 Blender 自动追加 `.001` 等编号。
|
|
||||||
视频输入时 Blender 无法整体 Pack 序列帧,因此 `.depth` 与颜色序列改为引用 job 目录根下的
|
|
||||||
`depth.0001.exr`、`color.0001.jpg`(`.color`)或 `color.0001.png`,并在每帧保留原始
|
|
||||||
浮点深度;保存 `.blend` 后再次打开仍需对应 job 目录存在。
|
|
||||||
|
|
||||||
每个 Server Job 被接受时,Job Runtime 在 Runtime Storage Root 的 `jobs/` 下创建一个独立文件夹。文件夹名是 32 位 opaque Job ID,不包含任务类型或对象名称。执行逻辑从 `JobContext.directory` 获取路径,输出产物直接写入该目录。
|
`mesh.npz` 保存:
|
||||||
|
|
||||||
Blender 为 Packed Image 或序列帧生成的输入副本先保存在系统临时目录,Server 读取完成后由 Blender 清理,不归档到 Job 目录。普通磁盘文件和视频仍直接读取原文件。Server 日志统一写入 `server.log`,进度只保存在 Server 内存中。Environment Bootstrap 使用 Storage Root 下的 `install.log` 与临时的 `install-status.json`,不创建 Job 目录。
|
- camera-space `vertices` 与 `faces`
|
||||||
|
- 原图坐标对应的 `uv`
|
||||||
|
- `intrinsics` 与 `image_size`
|
||||||
|
- 用于整体尺度的 `reference_depth`
|
||||||
|
- Geometry Nodes 使用的深度、Mask、置信度或连续性属性
|
||||||
|
|
||||||
正式几何输出由 `generate-depth` 与 `generate-surface` 负责。Debug 使用彼此独立的
|
源网格保持模型空间信息,视觉清理集中在 Geometry Nodes 中完成。这样可以调整参数、比较结果,也可以 Apply 修改器后继续普通建模。
|
||||||
`debug-da3`、`debug-moge2` 与 `debug-ben2`,只保存模型 API 的直接返回,不进入 Geometry 或
|
|
||||||
Surface 输出流程。
|
|
||||||
|
|
||||||
Job 目录中的典型正式产物如下:
|
## Geometry Nodes
|
||||||
|
|
||||||
```text
|
DA3 使用 `O DA3 Surface`,MoGe-2 使用 `O MoGe2 Surface`。两者采用相同的处理顺序:
|
||||||
<opaque-job-id>/
|
|
||||||
├── results.npz、mesh.npz 或 normal.png
|
|
||||||
└── prediction.npz Debug 原始 API 返回
|
|
||||||
|
|
||||||
<opaque-job-id>/
|
1. 根据有效区域、深度连续性和可用置信度剔除不可靠面
|
||||||
├── color.0001.jpg/png… 正式工作流颜色层(原图复制或 BEN2 Alpha)
|
2. 合并和修复拓扑
|
||||||
├── depth.0001.exr… AI 深度序列帧
|
3. 清理小岛并平滑表面
|
||||||
└── normal.png MoGe-2 Surface 法线贴图
|
4. 从源网格重新投影 UV
|
||||||
```
|
|
||||||
|
|
||||||
建议从 `DA3 Base` 开始。它使用 Apache 2.0 模型权重。
|
常用控制包括 Iterations、Resolution、Smooth、Depth Continuity、Confidence 和 Mask。节点资产由扩展复用,多个 Surface 不会各自复制一套节点图。
|
||||||
|
|
||||||
|
## 空间与相机
|
||||||
|
|
||||||
|
网格在 camera space 中生成,再通过源 Image Empty 的 Transform 放入场景。导入过程会匹配图片可见尺寸、宽高比、Offset 与主点位置。
|
||||||
|
|
||||||
|
启用 Create Matching Camera 时,SnapForm 根据模型 intrinsics 创建相机并使用相同坐标变换,使网格与相机视图保持一致。模型估计的焦距和主点决定透视关系;`reference_depth` 决定整体尺度。
|
||||||
|
|
||||||
|
## 材质与文件
|
||||||
|
|
||||||
|
颜色统一写为 `color.0001.*` 并导入为 `.color` Image。MoGe-2 还会输出 `normal.png`,用于材质法线。材质名称以源 Image datablock 为基名。
|
||||||
|
|
||||||
|
单图结果可以 Pack 到 `.blend`。模型原始结果与 `mesh.npz` 保留在 Job 目录中,便于调试和重新检查。
|
||||||
|
|
||||||
|
## 适用范围
|
||||||
|
|
||||||
|
Surface 面向单张图片。它生成可编辑的透视网格,但遮挡区域仍来自单视图估计,无法恢复图片中不可见的真实几何。视频和序列帧应使用 [Depth Plane](depth-plane.md)。
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import bpy
|
|||||||
|
|
||||||
from .runtime import runtime
|
from .runtime import runtime
|
||||||
from .operators import CLASSES as OPERATOR_CLASSES
|
from .operators import CLASSES as OPERATOR_CLASSES
|
||||||
|
from .operators.balloon import BalloonTool
|
||||||
from .preferences import SnapFormPreferences
|
from .preferences import SnapFormPreferences
|
||||||
from .properties import SnapFormSettings
|
from .properties import SnapFormSettings
|
||||||
from .menu import SnapFormImageMenu, draw_image_context_menu
|
from .menu import SnapFormImageMenu, draw_image_context_menu
|
||||||
@@ -32,6 +33,10 @@ CLASSES = (
|
|||||||
def register():
|
def register():
|
||||||
for class_type in CLASSES:
|
for class_type in CLASSES:
|
||||||
bpy.utils.register_class(class_type)
|
bpy.utils.register_class(class_type)
|
||||||
|
bpy.utils.register_tool(
|
||||||
|
BalloonTool,
|
||||||
|
separator=False,
|
||||||
|
)
|
||||||
bpy.types.Scene.snapform_settings = bpy.props.PointerProperty(
|
bpy.types.Scene.snapform_settings = bpy.props.PointerProperty(
|
||||||
type=SnapFormSettings,
|
type=SnapFormSettings,
|
||||||
)
|
)
|
||||||
@@ -42,6 +47,7 @@ def register():
|
|||||||
|
|
||||||
def unregister():
|
def unregister():
|
||||||
runtime.unregister()
|
runtime.unregister()
|
||||||
|
bpy.utils.unregister_tool(BalloonTool)
|
||||||
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
|
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
|
||||||
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
|
bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu)
|
||||||
if hasattr(bpy.types.Scene, "snapform_settings"):
|
if hasattr(bpy.types.Scene, "snapform_settings"):
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ class SnapFormImageMenu(bpy.types.Menu):
|
|||||||
layout = self.layout
|
layout = self.layout
|
||||||
layout.operator_context = "INVOKE_DEFAULT"
|
layout.operator_context = "INVOKE_DEFAULT"
|
||||||
layout.operator(
|
layout.operator(
|
||||||
"snapform.balloon_lasso",
|
"snapform.activate_balloon_tool",
|
||||||
text="Balloon Mesh (Lasso)",
|
text="Balloon Lasso Tool",
|
||||||
icon="MOD_SKIN",
|
icon="MOD_SKIN",
|
||||||
)
|
)
|
||||||
layout.separator()
|
layout.separator()
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from .background import ProcessImageBackground, RemoveImageBackground
|
from .background import ProcessImageBackground, RemoveImageBackground
|
||||||
from .balloon import BalloonLasso, ExtractBalloon
|
from .balloon import (
|
||||||
|
ActivateBalloonTool,
|
||||||
|
ExtractBalloon,
|
||||||
|
)
|
||||||
from .debug import DebugBen2, DebugDa3, DebugMoge2
|
from .debug import DebugBen2, DebugDa3, DebugMoge2
|
||||||
from .depth_plane import (
|
from .depth_plane import (
|
||||||
ConvertToDepthPlane,
|
ConvertToDepthPlane,
|
||||||
@@ -27,7 +30,7 @@ CLASSES = (
|
|||||||
ExtractOriginalFrames,
|
ExtractOriginalFrames,
|
||||||
ClearModels,
|
ClearModels,
|
||||||
ExtractBalloon,
|
ExtractBalloon,
|
||||||
BalloonLasso,
|
ActivateBalloonTool,
|
||||||
RemoveImageBackground,
|
RemoveImageBackground,
|
||||||
ConvertToDepthPlane,
|
ConvertToDepthPlane,
|
||||||
ConvertToSurface,
|
ConvertToSurface,
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
from .operator import BalloonLasso, ExtractBalloon
|
from .operator import (
|
||||||
|
ActivateBalloonTool,
|
||||||
|
BalloonTool,
|
||||||
|
ExtractBalloon,
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,7 +4,18 @@ import math
|
|||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
from .geometry import INFLATION, POINT_SPACING, triangulate_balloon
|
from .geometry import INFLATION, POINT_SPACING, triangulate_balloon
|
||||||
from ..image import color_data_name, is_image_empty, plane_bounds
|
from ...properties import (
|
||||||
|
balloon_mode_property as mode_property,
|
||||||
|
balloon_resolution_property as resolution_property,
|
||||||
|
balloon_reuse_material_property as reuse_material_property,
|
||||||
|
balloon_thickness_property as thickness_property,
|
||||||
|
)
|
||||||
|
from ..image import (
|
||||||
|
color_data_name,
|
||||||
|
is_image_empty,
|
||||||
|
material_data_name,
|
||||||
|
plane_bounds,
|
||||||
|
)
|
||||||
from ..material import TEXTURE_MATERIAL_NODE_GROUP_NAME, create_material
|
from ..material import TEXTURE_MATERIAL_NODE_GROUP_NAME, create_material
|
||||||
from ..surface import assign_vertex_uv
|
from ..surface import assign_vertex_uv
|
||||||
|
|
||||||
@@ -99,6 +110,55 @@ def balloon_geometry_parameters(thickness, resolution):
|
|||||||
return resolution, INFLATION * thickness
|
return resolution, INFLATION * thickness
|
||||||
|
|
||||||
|
|
||||||
|
def data_name_matches(name, base_name):
|
||||||
|
if name == base_name:
|
||||||
|
return True
|
||||||
|
suffix = name.removeprefix(f"{base_name}.")
|
||||||
|
return suffix != name and suffix.isdigit()
|
||||||
|
|
||||||
|
|
||||||
|
def material_uses_balloon_texture(material, color_name):
|
||||||
|
node_tree = getattr(material, "node_tree", None)
|
||||||
|
if node_tree is None:
|
||||||
|
return False
|
||||||
|
has_texture_layer = any(
|
||||||
|
getattr(node, "type", None) == "GROUP"
|
||||||
|
and getattr(getattr(node, "node_tree", None), "name", None)
|
||||||
|
== TEXTURE_MATERIAL_NODE_GROUP_NAME
|
||||||
|
for node in node_tree.nodes
|
||||||
|
)
|
||||||
|
has_color_image = any(
|
||||||
|
getattr(node, "type", None) == "TEX_IMAGE"
|
||||||
|
and getattr(node, "image", None) is not None
|
||||||
|
and data_name_matches(node.image.name, color_name)
|
||||||
|
for node in node_tree.nodes
|
||||||
|
)
|
||||||
|
return has_texture_layer and has_color_image
|
||||||
|
|
||||||
|
|
||||||
|
def reusable_balloon_material(material_name, color_name):
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
material
|
||||||
|
for material in bpy.data.materials
|
||||||
|
if data_name_matches(material.name, material_name)
|
||||||
|
and material_uses_balloon_texture(material, color_name)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reusable_color_image(color_name):
|
||||||
|
return next(
|
||||||
|
(
|
||||||
|
image
|
||||||
|
for image in bpy.data.images
|
||||||
|
if data_name_matches(image.name, color_name)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def extract_balloon_object(
|
def extract_balloon_object(
|
||||||
context,
|
context,
|
||||||
source_object,
|
source_object,
|
||||||
@@ -108,6 +168,7 @@ def extract_balloon_object(
|
|||||||
thickness=1.0,
|
thickness=1.0,
|
||||||
resolution=POINT_SPACING,
|
resolution=POINT_SPACING,
|
||||||
select_result=True,
|
select_result=True,
|
||||||
|
reuse_material=True,
|
||||||
):
|
):
|
||||||
image = source_object.data
|
image = source_object.data
|
||||||
image_size = tuple(image.size)
|
image_size = tuple(image.size)
|
||||||
@@ -130,19 +191,37 @@ def extract_balloon_object(
|
|||||||
|
|
||||||
source_name = source_object.name
|
source_name = source_object.name
|
||||||
balloon_name = source_name
|
balloon_name = source_name
|
||||||
color_image = image.copy()
|
material_name = material_data_name(source_object)
|
||||||
color_image.name = color_data_name(source_object)
|
color_name = color_data_name(source_object)
|
||||||
material = None
|
color_image = None
|
||||||
|
material = (
|
||||||
|
reusable_balloon_material(material_name, color_name)
|
||||||
|
if reuse_material
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
owns_color_image = False
|
||||||
|
owns_material = False
|
||||||
mesh = None
|
mesh = None
|
||||||
balloon = None
|
balloon = None
|
||||||
try:
|
try:
|
||||||
material = create_material(
|
if material is None:
|
||||||
balloon_name,
|
color_image = (
|
||||||
color_image,
|
reusable_color_image(color_name)
|
||||||
0.0,
|
if reuse_material
|
||||||
surface_group_name=TEXTURE_MATERIAL_NODE_GROUP_NAME,
|
else None
|
||||||
scene=context.scene,
|
)
|
||||||
)
|
if color_image is None:
|
||||||
|
color_image = image.copy()
|
||||||
|
color_image.name = color_name
|
||||||
|
owns_color_image = True
|
||||||
|
material = create_material(
|
||||||
|
material_name,
|
||||||
|
color_image,
|
||||||
|
0.0,
|
||||||
|
surface_group_name=TEXTURE_MATERIAL_NODE_GROUP_NAME,
|
||||||
|
scene=context.scene,
|
||||||
|
)
|
||||||
|
owns_material = True
|
||||||
mesh = bpy.data.meshes.new(balloon_name)
|
mesh = bpy.data.meshes.new(balloon_name)
|
||||||
mesh.from_pydata(vertices, [], faces)
|
mesh.from_pydata(vertices, [], faces)
|
||||||
mesh.update()
|
mesh.update()
|
||||||
@@ -164,9 +243,13 @@ def extract_balloon_object(
|
|||||||
bpy.data.objects.remove(balloon, do_unlink=True)
|
bpy.data.objects.remove(balloon, do_unlink=True)
|
||||||
if mesh is not None and mesh.users == 0:
|
if mesh is not None and mesh.users == 0:
|
||||||
bpy.data.meshes.remove(mesh)
|
bpy.data.meshes.remove(mesh)
|
||||||
if material is not None and material.users == 0:
|
if owns_material and material is not None and material.users == 0:
|
||||||
bpy.data.materials.remove(material)
|
bpy.data.materials.remove(material)
|
||||||
if color_image.users == 0:
|
if (
|
||||||
|
owns_color_image
|
||||||
|
and color_image is not None
|
||||||
|
and color_image.users == 0
|
||||||
|
):
|
||||||
bpy.data.images.remove(color_image)
|
bpy.data.images.remove(color_image)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
@@ -177,97 +260,44 @@ def extract_balloon_object(
|
|||||||
return balloon
|
return balloon
|
||||||
|
|
||||||
|
|
||||||
def thickness_property():
|
|
||||||
return bpy.props.FloatProperty(
|
|
||||||
name="Thickness",
|
|
||||||
description="Scale the Balloon thickness while keeping the lasso region",
|
|
||||||
default=1.0,
|
|
||||||
min=0.05,
|
|
||||||
soft_max=5.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def mode_property():
|
|
||||||
return bpy.props.EnumProperty(
|
|
||||||
name="Mode",
|
|
||||||
description="Algorithm used to inflate the saved lasso region",
|
|
||||||
items=(
|
|
||||||
(
|
|
||||||
"POISSON",
|
|
||||||
"Poisson",
|
|
||||||
"Use a global Poisson field for smooth inflation",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"TEDDY",
|
|
||||||
"Teddy / Chordal Axis",
|
|
||||||
"Use a Teddy-style chordal axis and local oval sections",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
default="POISSON",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def resolution_property():
|
|
||||||
return bpy.props.FloatProperty(
|
|
||||||
name="Resolution",
|
|
||||||
description=(
|
|
||||||
"World-space distance between sampled mesh points; smaller "
|
|
||||||
"values create more geometry"
|
|
||||||
),
|
|
||||||
default=POINT_SPACING,
|
|
||||||
min=0.005,
|
|
||||||
soft_min=0.02,
|
|
||||||
soft_max=0.5,
|
|
||||||
max=10.0,
|
|
||||||
subtype="DISTANCE",
|
|
||||||
unit="LENGTH",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def hidden_string_property():
|
def hidden_string_property():
|
||||||
return bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
|
return bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
|
||||||
|
|
||||||
|
|
||||||
def hidden_bool_property(default=False):
|
def meters_to_world_distance(scene, meters):
|
||||||
return bpy.props.BoolProperty(
|
unit_settings = getattr(scene, "unit_settings", None)
|
||||||
default=default,
|
scale_length = getattr(unit_settings, "scale_length", 1.0)
|
||||||
options={"HIDDEN", "SKIP_SAVE"},
|
if scale_length <= 0.0:
|
||||||
|
scale_length = 1.0
|
||||||
|
return meters / scale_length
|
||||||
|
|
||||||
|
|
||||||
|
def draw_line_strip(vertices, color, width=2.0):
|
||||||
|
import gpu
|
||||||
|
from gpu_extras.batch import batch_for_shader
|
||||||
|
|
||||||
|
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
||||||
|
batch = batch_for_shader(shader, "LINE_STRIP", {"pos": vertices})
|
||||||
|
gpu.state.blend_set("ALPHA")
|
||||||
|
gpu.state.line_width_set(width)
|
||||||
|
shader.bind()
|
||||||
|
shader.uniform_float("color", color)
|
||||||
|
batch.draw(shader)
|
||||||
|
gpu.state.line_width_set(1.0)
|
||||||
|
gpu.state.blend_set("NONE")
|
||||||
|
|
||||||
|
|
||||||
|
def drawing_in_region(area_pointer, region_pointer):
|
||||||
|
area = getattr(bpy.context, "area", None)
|
||||||
|
region = getattr(bpy.context, "region", None)
|
||||||
|
return (
|
||||||
|
area is not None
|
||||||
|
and region is not None
|
||||||
|
and area.as_pointer() == area_pointer
|
||||||
|
and region.as_pointer() == region_pointer
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def event_over_interface_region(context, event):
|
|
||||||
area = getattr(context, "area", None)
|
|
||||||
if area is None:
|
|
||||||
return False
|
|
||||||
mouse_x = getattr(event, "mouse_x", -1)
|
|
||||||
mouse_y = getattr(event, "mouse_y", -1)
|
|
||||||
for region in area.regions:
|
|
||||||
if region.type == "WINDOW":
|
|
||||||
continue
|
|
||||||
if (
|
|
||||||
region.x <= mouse_x < region.x + region.width
|
|
||||||
and region.y <= mouse_y < region.y + region.height
|
|
||||||
):
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
VIEW_NAVIGATION_EVENTS = {
|
|
||||||
"MIDDLEMOUSE",
|
|
||||||
"WHEELUPMOUSE",
|
|
||||||
"WHEELDOWNMOUSE",
|
|
||||||
"WHEELINMOUSE",
|
|
||||||
"WHEELOUTMOUSE",
|
|
||||||
"WHEELLEFTMOUSE",
|
|
||||||
"WHEELRIGHTMOUSE",
|
|
||||||
"TRACKPADPAN",
|
|
||||||
"TRACKPADZOOM",
|
|
||||||
"MOUSEROTATE",
|
|
||||||
"MOUSESMARTZOOM",
|
|
||||||
"NDOF_MOTION",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ExtractBalloon(bpy.types.Operator):
|
class ExtractBalloon(bpy.types.Operator):
|
||||||
bl_idname = "snapform.extract_balloon"
|
bl_idname = "snapform.extract_balloon"
|
||||||
bl_label = "Extract Balloon"
|
bl_label = "Extract Balloon"
|
||||||
@@ -278,148 +308,94 @@ class ExtractBalloon(bpy.types.Operator):
|
|||||||
thickness: thickness_property()
|
thickness: thickness_property()
|
||||||
resolution: resolution_property()
|
resolution: resolution_property()
|
||||||
source_object_name: hidden_string_property()
|
source_object_name: hidden_string_property()
|
||||||
|
reuse_material: reuse_material_property()
|
||||||
source_matrix_data: hidden_string_property()
|
source_matrix_data: hidden_string_property()
|
||||||
selection_data: hidden_string_property()
|
selection_data: hidden_string_property()
|
||||||
keep_source_selected: hidden_bool_property()
|
|
||||||
|
_path = None
|
||||||
|
_handle = None
|
||||||
|
_area_pointer = 0
|
||||||
|
_region_pointer = 0
|
||||||
|
|
||||||
def draw(self, context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
layout.prop(self, "mode")
|
layout.prop(self, "mode")
|
||||||
layout.prop(self, "thickness")
|
layout.prop(self, "thickness")
|
||||||
layout.prop(self, "resolution")
|
layout.prop(self, "resolution", text="Resolution (m)")
|
||||||
|
layout.prop(self, "reuse_material")
|
||||||
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)
|
|
||||||
if len(polygon) < 3:
|
|
||||||
raise ValueError("The stored lasso region is invalid")
|
|
||||||
matrix_world = deserialize_matrix(self.source_matrix_data)
|
|
||||||
extract_balloon_object(
|
|
||||||
context,
|
|
||||||
source_object,
|
|
||||||
polygon,
|
|
||||||
matrix_world,
|
|
||||||
self.mode,
|
|
||||||
thickness=self.thickness,
|
|
||||||
resolution=self.resolution,
|
|
||||||
select_result=not self.keep_source_selected,
|
|
||||||
)
|
|
||||||
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
|
|
||||||
self.report({"ERROR"}, str(error))
|
|
||||||
return {"CANCELLED"}
|
|
||||||
return {"FINISHED"}
|
|
||||||
|
|
||||||
|
|
||||||
class BalloonLasso(bpy.types.Operator):
|
|
||||||
bl_idname = "snapform.balloon_lasso"
|
|
||||||
bl_label = "Balloon Mesh"
|
|
||||||
bl_description = "Continuously draw lasso regions and extract Balloon meshes"
|
|
||||||
bl_options = {"INTERNAL"}
|
|
||||||
|
|
||||||
mode: mode_property()
|
|
||||||
thickness: thickness_property()
|
|
||||||
resolution: resolution_property()
|
|
||||||
source_object_name: hidden_string_property()
|
|
||||||
source_matrix_data: hidden_string_property()
|
|
||||||
|
|
||||||
_path = None
|
|
||||||
_drawing = False
|
|
||||||
_handle = None
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def poll(cls, context):
|
def poll(cls, context):
|
||||||
return context.area is not None and context.area.type == "VIEW_3D"
|
return (
|
||||||
|
context.area is not None
|
||||||
|
and context.area.type == "VIEW_3D"
|
||||||
|
and is_image_empty(context.active_object)
|
||||||
|
)
|
||||||
|
|
||||||
def _draw_lasso(self, context):
|
def _draw_lasso(self):
|
||||||
if not self._path:
|
if (
|
||||||
|
not self._path
|
||||||
|
or not drawing_in_region(self._area_pointer, self._region_pointer)
|
||||||
|
):
|
||||||
return
|
return
|
||||||
import gpu
|
|
||||||
from gpu_extras.batch import batch_for_shader
|
|
||||||
|
|
||||||
vertices = list(self._path)
|
vertices = list(self._path)
|
||||||
if len(vertices) > 2:
|
if len(vertices) > 2:
|
||||||
vertices.append(vertices[0])
|
vertices.append(vertices[0])
|
||||||
shader = gpu.shader.from_builtin("UNIFORM_COLOR")
|
draw_line_strip(vertices, (0.15, 0.65, 1.0, 0.95), 2.0)
|
||||||
batch = batch_for_shader(shader, "LINE_STRIP", {"pos": vertices})
|
|
||||||
gpu.state.blend_set("ALPHA")
|
|
||||||
gpu.state.line_width_set(2.0)
|
|
||||||
shader.bind()
|
|
||||||
shader.uniform_float("color", (0.15, 0.65, 1.0, 0.95))
|
|
||||||
batch.draw(shader)
|
|
||||||
gpu.state.line_width_set(1.0)
|
|
||||||
gpu.state.blend_set("NONE")
|
|
||||||
|
|
||||||
def _finish(self, context):
|
def _finish(self, context):
|
||||||
if self._handle is not None:
|
if self._handle is not None:
|
||||||
bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW")
|
bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW")
|
||||||
self._handle = None
|
self._handle = None
|
||||||
|
context.workspace.status_text_set(None)
|
||||||
if context.area is not None:
|
if context.area is not None:
|
||||||
context.area.header_text_set(None)
|
|
||||||
context.area.tag_redraw()
|
context.area.tag_redraw()
|
||||||
|
|
||||||
|
def cancel(self, context):
|
||||||
|
self._finish(context)
|
||||||
|
|
||||||
def invoke(self, context, event):
|
def invoke(self, context, event):
|
||||||
source_object = context.active_object
|
source_object = context.active_object
|
||||||
if not is_image_empty(source_object):
|
settings = context.scene.snapform_settings
|
||||||
self.report({"ERROR"}, "Select an Image Empty before extracting")
|
self.mode = settings.balloon_mode
|
||||||
return {"CANCELLED"}
|
self.thickness = settings.balloon_thickness
|
||||||
|
self.resolution = settings.balloon_resolution
|
||||||
|
self.reuse_material = settings.balloon_reuse_material
|
||||||
self.source_object_name = source_object.name
|
self.source_object_name = source_object.name
|
||||||
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
|
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
|
||||||
self._path = []
|
self.selection_data = ""
|
||||||
self._drawing = False
|
self._path = [(event.mouse_region_x, event.mouse_region_y)]
|
||||||
|
self._area_pointer = context.area.as_pointer()
|
||||||
|
self._region_pointer = context.region.as_pointer()
|
||||||
self._handle = bpy.types.SpaceView3D.draw_handler_add(
|
self._handle = bpy.types.SpaceView3D.draw_handler_add(
|
||||||
self._draw_lasso,
|
self._draw_lasso,
|
||||||
(context,),
|
(),
|
||||||
"WINDOW",
|
"WINDOW",
|
||||||
"POST_PIXEL",
|
"POST_PIXEL",
|
||||||
)
|
)
|
||||||
context.window_manager.modal_handler_add(self)
|
context.workspace.status_text_set(
|
||||||
context.area.header_text_set(
|
"Balloon Lasso: release LMB to extract, RMB or Esc to cancel"
|
||||||
"Balloon Mesh: drag LMB repeatedly, navigate normally, Esc to finish"
|
|
||||||
)
|
)
|
||||||
|
context.window_manager.modal_handler_add(self)
|
||||||
context.area.tag_redraw()
|
context.area.tag_redraw()
|
||||||
return {"RUNNING_MODAL"}
|
return {"RUNNING_MODAL"}
|
||||||
|
|
||||||
def modal(self, context, event):
|
def modal(self, context, event):
|
||||||
if event.type == "ESC":
|
if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS":
|
||||||
self._finish(context)
|
self._finish(context)
|
||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
|
|
||||||
if (
|
if event.type == "MOUSEMOVE":
|
||||||
event.type in VIEW_NAVIGATION_EVENTS
|
|
||||||
or event_over_interface_region(context, event)
|
|
||||||
or (event.type == "LEFTMOUSE" and event.alt)
|
|
||||||
):
|
|
||||||
return {"PASS_THROUGH"}
|
|
||||||
|
|
||||||
if event.type == "RIGHTMOUSE" and event.value == "PRESS":
|
|
||||||
self._drawing = False
|
|
||||||
self._path = []
|
|
||||||
context.area.tag_redraw()
|
|
||||||
return {"RUNNING_MODAL"}
|
|
||||||
|
|
||||||
if event.type == "LEFTMOUSE" and event.value == "PRESS":
|
|
||||||
self._drawing = True
|
|
||||||
self._path = [(event.mouse_region_x, event.mouse_region_y)]
|
|
||||||
context.area.tag_redraw()
|
|
||||||
return {"RUNNING_MODAL"}
|
|
||||||
|
|
||||||
if event.type == "MOUSEMOVE" and self._drawing:
|
|
||||||
self._path.append((event.mouse_region_x, event.mouse_region_y))
|
self._path.append((event.mouse_region_x, event.mouse_region_y))
|
||||||
context.area.tag_redraw()
|
context.area.tag_redraw()
|
||||||
return {"RUNNING_MODAL"}
|
return {"RUNNING_MODAL"}
|
||||||
|
|
||||||
if event.type == "LEFTMOUSE" and event.value == "RELEASE" and self._drawing:
|
if event.type == "LEFTMOUSE" and event.value == "RELEASE":
|
||||||
self._drawing = False
|
|
||||||
path = simplify_screen_path(self._path)
|
path = simplify_screen_path(self._path)
|
||||||
self._path = []
|
|
||||||
if len(path) < 3:
|
if len(path) < 3:
|
||||||
self.report({"WARNING"}, "Draw a larger lasso")
|
self.report({"WARNING"}, "Draw a larger lasso")
|
||||||
context.area.tag_redraw()
|
self._finish(context)
|
||||||
return {"RUNNING_MODAL"}
|
return {"CANCELLED"}
|
||||||
|
|
||||||
source_object = bpy.data.objects.get(self.source_object_name)
|
source_object = bpy.data.objects.get(self.source_object_name)
|
||||||
if not is_image_empty(source_object):
|
if not is_image_empty(source_object):
|
||||||
@@ -436,21 +412,125 @@ class BalloonLasso(bpy.types.Operator):
|
|||||||
plane_bounds(source_object),
|
plane_bounds(source_object),
|
||||||
tuple(source_object.data.size),
|
tuple(source_object.data.size),
|
||||||
)
|
)
|
||||||
result = bpy.ops.snapform.extract_balloon(
|
self.selection_data = json.dumps(polygon, separators=(",", ":"))
|
||||||
"EXEC_DEFAULT",
|
except (RuntimeError, TypeError, ValueError) as error:
|
||||||
source_object_name=self.source_object_name,
|
|
||||||
source_matrix_data=self.source_matrix_data,
|
|
||||||
selection_data=json.dumps(polygon, separators=(",", ":")),
|
|
||||||
mode=self.mode,
|
|
||||||
thickness=self.thickness,
|
|
||||||
resolution=self.resolution,
|
|
||||||
keep_source_selected=True,
|
|
||||||
)
|
|
||||||
if "FINISHED" not in result:
|
|
||||||
raise RuntimeError("Unable to extract the Balloon")
|
|
||||||
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
|
|
||||||
self.report({"ERROR"}, str(error))
|
self.report({"ERROR"}, str(error))
|
||||||
context.area.tag_redraw()
|
self._finish(context)
|
||||||
return {"RUNNING_MODAL"}
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
self._finish(context)
|
||||||
|
return self.execute(context)
|
||||||
|
|
||||||
return {"PASS_THROUGH"}
|
return {"PASS_THROUGH"}
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
source_object = bpy.data.objects.get(self.source_object_name)
|
||||||
|
if not is_image_empty(source_object):
|
||||||
|
self.report({"ERROR"}, "The source Image Empty is no longer available")
|
||||||
|
return {"CANCELLED"}
|
||||||
|
try:
|
||||||
|
polygon = json.loads(self.selection_data)
|
||||||
|
if len(polygon) < 3:
|
||||||
|
raise ValueError("The stored lasso region is invalid")
|
||||||
|
matrix_world = deserialize_matrix(self.source_matrix_data)
|
||||||
|
resolution_world = meters_to_world_distance(
|
||||||
|
context.scene,
|
||||||
|
self.resolution,
|
||||||
|
)
|
||||||
|
balloon = extract_balloon_object(
|
||||||
|
context,
|
||||||
|
source_object,
|
||||||
|
polygon,
|
||||||
|
matrix_world,
|
||||||
|
self.mode,
|
||||||
|
thickness=self.thickness,
|
||||||
|
resolution=resolution_world,
|
||||||
|
select_result=False,
|
||||||
|
reuse_material=self.reuse_material,
|
||||||
|
)
|
||||||
|
balloon["snapform_resolution_m"] = self.resolution
|
||||||
|
except (json.JSONDecodeError, RuntimeError, TypeError, ValueError) as error:
|
||||||
|
self.report({"ERROR"}, str(error))
|
||||||
|
return {"CANCELLED"}
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ActivateBalloonTool(bpy.types.Operator):
|
||||||
|
bl_idname = "snapform.activate_balloon_tool"
|
||||||
|
bl_label = "Balloon Lasso Tool"
|
||||||
|
bl_description = "Activate the Balloon Lasso tool in the 3D Viewport"
|
||||||
|
bl_options = {"INTERNAL"}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, context):
|
||||||
|
return (
|
||||||
|
context.area is not None
|
||||||
|
and context.area.type == "VIEW_3D"
|
||||||
|
and is_image_empty(context.active_object)
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
return bpy.ops.wm.tool_set_by_id(
|
||||||
|
name=BalloonTool.bl_idname,
|
||||||
|
space_type="VIEW_3D",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BalloonTool(bpy.types.WorkSpaceTool):
|
||||||
|
bl_space_type = "VIEW_3D"
|
||||||
|
bl_context_mode = "OBJECT"
|
||||||
|
bl_idname = "snapform.balloon_lasso"
|
||||||
|
bl_label = "Balloon Lasso"
|
||||||
|
bl_description = (
|
||||||
|
"Draw an Image Empty region and extract one independently undoable "
|
||||||
|
"Balloon per stroke"
|
||||||
|
)
|
||||||
|
bl_icon = "ops.mesh.polybuild_hover"
|
||||||
|
bl_cursor = "PAINT_BRUSH"
|
||||||
|
bl_operator = ExtractBalloon.bl_idname
|
||||||
|
bl_keymap = (
|
||||||
|
(
|
||||||
|
ExtractBalloon.bl_idname,
|
||||||
|
{"type": "LEFTMOUSE", "value": "PRESS"},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wm.radial_control",
|
||||||
|
{"type": "S", "value": "PRESS"},
|
||||||
|
{
|
||||||
|
"properties": [
|
||||||
|
(
|
||||||
|
"data_path_primary",
|
||||||
|
"scene.snapform_settings.balloon_thickness",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"zoom_path",
|
||||||
|
"scene.snapform_settings.balloon_thickness_radial_zoom",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"wm.radial_control",
|
||||||
|
{"type": "D", "value": "PRESS"},
|
||||||
|
{
|
||||||
|
"properties": [
|
||||||
|
(
|
||||||
|
"data_path_primary",
|
||||||
|
"scene.snapform_settings.balloon_resolution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"zoom_path",
|
||||||
|
"scene.snapform_settings.balloon_resolution_radial_zoom",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
},
|
||||||
|
), )
|
||||||
|
|
||||||
|
def draw_settings(context, layout, tool):
|
||||||
|
settings = context.scene.snapform_settings
|
||||||
|
layout.prop(settings, "balloon_mode", text="")
|
||||||
|
layout.prop(settings, "balloon_thickness")
|
||||||
|
layout.prop(settings, "balloon_resolution", text="Resolution (m)")
|
||||||
|
layout.prop(settings, "balloon_reuse_material")
|
||||||
@@ -6,6 +6,7 @@ from ..image import (
|
|||||||
depth_data_name,
|
depth_data_name,
|
||||||
image_user_settings,
|
image_user_settings,
|
||||||
is_animated_image,
|
is_animated_image,
|
||||||
|
material_data_name,
|
||||||
plane_bounds,
|
plane_bounds,
|
||||||
sorted_frame_paths,
|
sorted_frame_paths,
|
||||||
)
|
)
|
||||||
@@ -298,7 +299,7 @@ def build_image_depth_plane(
|
|||||||
|
|
||||||
depth_scale = abs(float(plane_object.scale.z))
|
depth_scale = abs(float(plane_object.scale.z))
|
||||||
material = create_material(
|
material = create_material(
|
||||||
source_name,
|
material_data_name(source_object),
|
||||||
material_image,
|
material_image,
|
||||||
depth_scale,
|
depth_scale,
|
||||||
displacement_image=depth_source_image,
|
displacement_image=depth_source_image,
|
||||||
@@ -401,7 +402,7 @@ def build_depth_plane(
|
|||||||
)
|
)
|
||||||
|
|
||||||
material = create_material(
|
material = create_material(
|
||||||
source_name,
|
material_data_name(source_object),
|
||||||
material_image,
|
material_image,
|
||||||
depth_scale,
|
depth_scale,
|
||||||
displacement_image=depth_image,
|
displacement_image=depth_image,
|
||||||
|
|||||||
@@ -159,6 +159,10 @@ def color_data_name(source_object):
|
|||||||
return f"{image_base_name(source_object.data)}.color"
|
return f"{image_base_name(source_object.data)}.color"
|
||||||
|
|
||||||
|
|
||||||
|
def material_data_name(source_object):
|
||||||
|
return image_base_name(source_object.data)
|
||||||
|
|
||||||
|
|
||||||
def scaled_process_resolution(image, scale_percent):
|
def scaled_process_resolution(image, scale_percent):
|
||||||
"""Return the scaled long edge clamped and aligned for DA3 inference."""
|
"""Return the scaled long edge clamped and aligned for DA3 inference."""
|
||||||
width, height = image.size
|
width, height = image.size
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .common import (
|
|||||||
from .image import (
|
from .image import (
|
||||||
draw_process_resolution,
|
draw_process_resolution,
|
||||||
is_image_empty,
|
is_image_empty,
|
||||||
|
material_data_name,
|
||||||
normal_data_name,
|
normal_data_name,
|
||||||
plane_bounds,
|
plane_bounds,
|
||||||
prepare_image_input,
|
prepare_image_input,
|
||||||
@@ -464,7 +465,7 @@ def build_image_mesh(
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
material = create_material(
|
material = create_material(
|
||||||
source_name,
|
material_data_name(source_object),
|
||||||
color_image,
|
color_image,
|
||||||
depth_scale,
|
depth_scale,
|
||||||
shadeless=shadeless,
|
shadeless=shadeless,
|
||||||
|
|||||||
@@ -172,7 +172,77 @@ def surface_model_enum_items(_owner, _context):
|
|||||||
return _model_items(models)
|
return _model_items(models)
|
||||||
|
|
||||||
|
|
||||||
|
BALLOON_MODE_ITEMS = (
|
||||||
|
(
|
||||||
|
"POISSON",
|
||||||
|
"Poisson",
|
||||||
|
"Use a global Poisson field for smooth inflation",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"TEDDY",
|
||||||
|
"Teddy / Chordal Axis",
|
||||||
|
"Use a Teddy-style chordal axis and local oval sections",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def balloon_mode_property():
|
||||||
|
return bpy.props.EnumProperty(
|
||||||
|
name="Mode",
|
||||||
|
description="Algorithm used to inflate the saved lasso region",
|
||||||
|
items=BALLOON_MODE_ITEMS,
|
||||||
|
default="POISSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def balloon_thickness_property():
|
||||||
|
return bpy.props.FloatProperty(
|
||||||
|
name="Thickness",
|
||||||
|
description="Scale the Balloon thickness while keeping the lasso region",
|
||||||
|
default=1.0,
|
||||||
|
min=0.05,
|
||||||
|
soft_max=5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def balloon_resolution_property():
|
||||||
|
return bpy.props.FloatProperty(
|
||||||
|
name="Resolution (m)",
|
||||||
|
description=(
|
||||||
|
"Distance in meters between sampled mesh points; smaller values "
|
||||||
|
"create more geometry"
|
||||||
|
),
|
||||||
|
default=0.1,
|
||||||
|
min=0.005,
|
||||||
|
soft_min=0.02,
|
||||||
|
soft_max=0.5,
|
||||||
|
max=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def balloon_reuse_material_property():
|
||||||
|
return bpy.props.BoolProperty(
|
||||||
|
name="Reuse Material",
|
||||||
|
description="Reuse a compatible Balloon material made from the same image",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def balloon_radial_zoom_property(scale):
|
||||||
|
return bpy.props.FloatVectorProperty(
|
||||||
|
size=2,
|
||||||
|
default=(scale, scale),
|
||||||
|
min=0.01,
|
||||||
|
options={"HIDDEN", "SKIP_SAVE"},
|
||||||
|
)
|
||||||
|
|
||||||
class SnapFormSettings(bpy.types.PropertyGroup):
|
class SnapFormSettings(bpy.types.PropertyGroup):
|
||||||
|
balloon_mode: balloon_mode_property()
|
||||||
|
balloon_thickness: balloon_thickness_property()
|
||||||
|
balloon_resolution: balloon_resolution_property()
|
||||||
|
balloon_reuse_material: balloon_reuse_material_property()
|
||||||
|
balloon_thickness_radial_zoom: balloon_radial_zoom_property(100.0)
|
||||||
|
balloon_resolution_radial_zoom: balloon_radial_zoom_property(1000.0)
|
||||||
input_path: bpy.props.StringProperty(
|
input_path: bpy.props.StringProperty(
|
||||||
name="Image or Video",
|
name="Image or Video",
|
||||||
description="A single image or video file used for reconstruction",
|
description="A single image or video file used for reconstruction",
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class AddonTest(unittest.TestCase):
|
|||||||
BoolProperty=lambda **options: options,
|
BoolProperty=lambda **options: options,
|
||||||
EnumProperty=lambda **options: options,
|
EnumProperty=lambda **options: options,
|
||||||
FloatProperty=lambda **options: options,
|
FloatProperty=lambda **options: options,
|
||||||
|
FloatVectorProperty=lambda **options: options,
|
||||||
IntProperty=lambda **options: options,
|
IntProperty=lambda **options: options,
|
||||||
PointerProperty=lambda **options: options,
|
PointerProperty=lambda **options: options,
|
||||||
StringProperty=lambda **options: options,
|
StringProperty=lambda **options: options,
|
||||||
@@ -37,6 +38,7 @@ class AddonTest(unittest.TestCase):
|
|||||||
Operator=object,
|
Operator=object,
|
||||||
Panel=object,
|
Panel=object,
|
||||||
PropertyGroup=object,
|
PropertyGroup=object,
|
||||||
|
WorkSpaceTool=object,
|
||||||
Scene=type("Scene", (), {}),
|
Scene=type("Scene", (), {}),
|
||||||
STATUSBAR_HT_header=SimpleNamespace(
|
STATUSBAR_HT_header=SimpleNamespace(
|
||||||
append=append_status_bar,
|
append=append_status_bar,
|
||||||
@@ -52,12 +54,19 @@ class AddonTest(unittest.TestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
cls.registered = []
|
cls.registered = []
|
||||||
|
cls.registered_tools = []
|
||||||
cls.status_bar_draws = []
|
cls.status_bar_draws = []
|
||||||
cls.context_menu_draws = []
|
cls.context_menu_draws = []
|
||||||
cls.outliner_menu_draws = []
|
cls.outliner_menu_draws = []
|
||||||
fake_bpy.utils = SimpleNamespace(
|
fake_bpy.utils = SimpleNamespace(
|
||||||
register_class=cls.registered.append,
|
register_class=cls.registered.append,
|
||||||
unregister_class=cls.registered.remove,
|
unregister_class=cls.registered.remove,
|
||||||
|
register_tool=lambda tool, **options: cls.registered_tools.append(
|
||||||
|
(tool, options)
|
||||||
|
),
|
||||||
|
unregister_tool=lambda tool: cls.registered_tools.remove(
|
||||||
|
next(item for item in cls.registered_tools if item[0] is tool)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
fake_bpy.context = SimpleNamespace(
|
fake_bpy.context = SimpleNamespace(
|
||||||
preferences=SimpleNamespace(addons={}),
|
preferences=SimpleNamespace(addons={}),
|
||||||
@@ -82,6 +91,9 @@ class AddonTest(unittest.TestCase):
|
|||||||
cls.depth_operator = importlib.import_module(
|
cls.depth_operator = importlib.import_module(
|
||||||
"snapform.operators.depth_plane.operator"
|
"snapform.operators.depth_plane.operator"
|
||||||
)
|
)
|
||||||
|
cls.depth_build = importlib.import_module(
|
||||||
|
"snapform.operators.depth_plane.build"
|
||||||
|
)
|
||||||
cls.image_background = importlib.import_module(
|
cls.image_background = importlib.import_module(
|
||||||
"snapform.operators.background"
|
"snapform.operators.background"
|
||||||
)
|
)
|
||||||
@@ -125,6 +137,7 @@ class AddonTest(unittest.TestCase):
|
|||||||
def tearDown(self):
|
def tearDown(self):
|
||||||
self.addon.runtime.close_active()
|
self.addon.runtime.close_active()
|
||||||
self.registered.clear()
|
self.registered.clear()
|
||||||
|
self.registered_tools.clear()
|
||||||
self.status_bar_draws.clear()
|
self.status_bar_draws.clear()
|
||||||
self.context_menu_draws.clear()
|
self.context_menu_draws.clear()
|
||||||
self.outliner_menu_draws.clear()
|
self.outliner_menu_draws.clear()
|
||||||
@@ -520,7 +533,7 @@ class AddonTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
calls[0],
|
calls[0],
|
||||||
("snapform.balloon_lasso", True),
|
("snapform.activate_balloon_tool", True),
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
("snapform.download_required_models", True),
|
("snapform.download_required_models", True),
|
||||||
@@ -564,7 +577,7 @@ class AddonTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
calls[0],
|
calls[0],
|
||||||
("snapform.balloon_lasso", True),
|
("snapform.activate_balloon_tool", True),
|
||||||
)
|
)
|
||||||
self.assertIn(("snapform.install_environment", True), calls)
|
self.assertIn(("snapform.install_environment", True), calls)
|
||||||
self.assertIn(("snapform.install_environment_mirror", True), calls)
|
self.assertIn(("snapform.install_environment_mirror", True), calls)
|
||||||
@@ -779,79 +792,136 @@ class AddonTest(unittest.TestCase):
|
|||||||
self.assertTrue(edges)
|
self.assertTrue(edges)
|
||||||
self.assertTrue(all(users == 2 for users in edges.values()))
|
self.assertTrue(all(users == 2 for users in edges.values()))
|
||||||
|
|
||||||
def test_balloon_controller_and_extract_operator_are_registered(self):
|
def test_balloon_tool_operators_are_registered(self):
|
||||||
self.assertIn(self.balloon.ExtractBalloon, self.addon.CLASSES)
|
self.assertIn(self.balloon.ExtractBalloon, self.addon.CLASSES)
|
||||||
self.assertIn(self.balloon.BalloonLasso, self.addon.CLASSES)
|
self.assertIn(self.balloon.ActivateBalloonTool, self.addon.CLASSES)
|
||||||
|
self.assertNotIn(self.balloon.BalloonTool, self.addon.CLASSES)
|
||||||
self.assertLess(
|
self.assertLess(
|
||||||
self.addon.CLASSES.index(self.balloon.ExtractBalloon),
|
self.addon.CLASSES.index(self.balloon.ExtractBalloon),
|
||||||
self.addon.CLASSES.index(self.balloon.BalloonLasso),
|
self.addon.CLASSES.index(self.balloon.ActivateBalloonTool),
|
||||||
)
|
)
|
||||||
def test_balloon_redo_properties_use_mode_and_world_resolution(self):
|
|
||||||
|
def test_balloon_redo_properties_use_mode_and_meter_resolution(self):
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
spacing, inflation = self.balloon.balloon_geometry_parameters(2.0, 0.2)
|
spacing, inflation = self.balloon.balloon_geometry_parameters(2.0, 0.2)
|
||||||
|
scene = SimpleNamespace(
|
||||||
|
unit_settings=SimpleNamespace(scale_length=0.01),
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(spacing, 0.2)
|
self.assertEqual(spacing, 0.2)
|
||||||
self.assertEqual(inflation, 0.9)
|
self.assertEqual(inflation, 0.9)
|
||||||
|
self.assertEqual(
|
||||||
|
self.balloon.meters_to_world_distance(scene, 0.1),
|
||||||
|
10.0,
|
||||||
|
)
|
||||||
operator = self.balloon.ExtractBalloon
|
operator = self.balloon.ExtractBalloon
|
||||||
self.assertIn("REGISTER", operator.bl_options)
|
self.assertEqual(operator.bl_options, {"REGISTER", "UNDO"})
|
||||||
self.assertIn("mode", operator.__annotations__)
|
self.assertIn("mode", operator.__annotations__)
|
||||||
self.assertIn("thickness", operator.__annotations__)
|
self.assertIn("thickness", operator.__annotations__)
|
||||||
self.assertIn("resolution", operator.__annotations__)
|
self.assertIn("resolution", operator.__annotations__)
|
||||||
|
self.assertIn("reuse_material", operator.__annotations__)
|
||||||
self.assertIn("source_matrix_data", operator.__annotations__)
|
self.assertIn("source_matrix_data", operator.__annotations__)
|
||||||
resolution_source = inspect.getsource(self.balloon.resolution_property)
|
settings = self.addon.properties.SnapFormSettings.__annotations__
|
||||||
thickness_source = inspect.getsource(self.balloon.thickness_property)
|
self.assertIn("balloon_mode", settings)
|
||||||
self.assertIn('subtype="DISTANCE"', resolution_source)
|
self.assertIn("balloon_thickness", settings)
|
||||||
self.assertIn('unit="LENGTH"', resolution_source)
|
self.assertIn("balloon_resolution", settings)
|
||||||
|
self.assertIn("balloon_reuse_material", settings)
|
||||||
|
self.assertIn("balloon_thickness_radial_zoom", settings)
|
||||||
|
self.assertIn("balloon_resolution_radial_zoom", settings)
|
||||||
|
self.assertEqual(
|
||||||
|
settings["balloon_thickness_radial_zoom"]["default"],
|
||||||
|
(100.0, 100.0),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
settings["balloon_resolution_radial_zoom"]["default"],
|
||||||
|
(1000.0, 1000.0),
|
||||||
|
)
|
||||||
|
resolution_source = inspect.getsource(
|
||||||
|
self.addon.properties.balloon_resolution_property
|
||||||
|
)
|
||||||
|
thickness_source = inspect.getsource(
|
||||||
|
self.addon.properties.balloon_thickness_property
|
||||||
|
)
|
||||||
|
self.assertIn('name="Resolution (m)"', resolution_source)
|
||||||
|
self.assertNotIn("subtype=", resolution_source)
|
||||||
|
self.assertNotIn("unit=", resolution_source)
|
||||||
self.assertIn("soft_max=5.0", thickness_source)
|
self.assertIn("soft_max=5.0", thickness_source)
|
||||||
self.assertNotIn(" max=", thickness_source)
|
self.assertNotIn(" max=", thickness_source)
|
||||||
|
|
||||||
def test_balloon_lasso_passes_hud_and_navigation_events(self):
|
def test_balloon_workspace_tool_uses_one_top_level_operator_per_stroke(self):
|
||||||
regions = (
|
|
||||||
SimpleNamespace(type="WINDOW", x=0, y=0, width=800, height=600),
|
|
||||||
SimpleNamespace(type="HUD", x=20, y=20, width=240, height=180),
|
|
||||||
)
|
|
||||||
context = SimpleNamespace(area=SimpleNamespace(regions=regions))
|
|
||||||
|
|
||||||
self.assertTrue(
|
|
||||||
self.balloon.event_over_interface_region(
|
|
||||||
context,
|
|
||||||
SimpleNamespace(mouse_x=40, mouse_y=40),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.assertFalse(
|
|
||||||
self.balloon.event_over_interface_region(
|
|
||||||
context,
|
|
||||||
SimpleNamespace(mouse_x=500, mouse_y=400),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
for event_type in (
|
|
||||||
"MIDDLEMOUSE",
|
|
||||||
"WHEELUPMOUSE",
|
|
||||||
"TRACKPADPAN",
|
|
||||||
"NDOF_MOTION",
|
|
||||||
):
|
|
||||||
self.assertIn(event_type, self.balloon.VIEW_NAVIGATION_EVENTS)
|
|
||||||
self.assertEqual(self.balloon.BalloonLasso.bl_options, {"INTERNAL"})
|
|
||||||
def test_balloon_lasso_dispatches_individual_redo_operator(self):
|
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
execute_source = inspect.getsource(self.balloon.ExtractBalloon.execute)
|
execute_source = inspect.getsource(self.balloon.ExtractBalloon.execute)
|
||||||
invoke_source = inspect.getsource(self.balloon.BalloonLasso.invoke)
|
invoke_source = inspect.getsource(self.balloon.ExtractBalloon.invoke)
|
||||||
modal_source = inspect.getsource(self.balloon.BalloonLasso.modal)
|
modal_source = inspect.getsource(self.balloon.ExtractBalloon.modal)
|
||||||
|
|
||||||
self.assertIn("json.loads(self.selection_data)", execute_source)
|
self.assertIn("json.loads(self.selection_data)", execute_source)
|
||||||
self.assertIn("deserialize_matrix(self.source_matrix_data)", execute_source)
|
self.assertIn("deserialize_matrix(self.source_matrix_data)", execute_source)
|
||||||
self.assertIn("select_result=not self.keep_source_selected", execute_source)
|
self.assertIn("extract_balloon_object(", execute_source)
|
||||||
|
self.assertIn("select_result=False", execute_source)
|
||||||
|
self.assertIn("reuse_material=self.reuse_material", execute_source)
|
||||||
|
self.assertIn("meters_to_world_distance(", execute_source)
|
||||||
self.assertIn("serialize_matrix(source_object.matrix_world)", invoke_source)
|
self.assertIn("serialize_matrix(source_object.matrix_world)", invoke_source)
|
||||||
self.assertIn("bpy.ops.snapform.extract_balloon(", modal_source)
|
self.assertIn("settings.balloon_thickness", invoke_source)
|
||||||
self.assertIn('"EXEC_DEFAULT"', modal_source)
|
self.assertIn("settings.balloon_resolution", invoke_source)
|
||||||
self.assertIn("keep_source_selected=True", modal_source)
|
self.assertIn("settings.balloon_reuse_material", invoke_source)
|
||||||
self.assertIn('event.type == "ESC"', modal_source)
|
self.assertIn("return self.execute(context)", modal_source)
|
||||||
self.assertIn('return {"RUNNING_MODAL"}', modal_source)
|
self.assertIn('return {"RUNNING_MODAL"}', modal_source)
|
||||||
self.assertIn('return {"PASS_THROUGH"}', modal_source)
|
self.assertIn('return {"PASS_THROUGH"}', modal_source)
|
||||||
self.assertIn("event_over_interface_region(context, event)", modal_source)
|
self.assertNotIn("bpy.ops.snapform.extract_balloon", modal_source)
|
||||||
self.assertNotIn("extract_balloon_object(", modal_source)
|
|
||||||
|
tool = self.balloon.BalloonTool
|
||||||
|
self.assertEqual(tool.bl_space_type, "VIEW_3D")
|
||||||
|
self.assertEqual(tool.bl_context_mode, "OBJECT")
|
||||||
|
self.assertEqual(tool.bl_operator, self.balloon.ExtractBalloon.bl_idname)
|
||||||
|
self.assertEqual(tool.bl_icon, "ops.mesh.polybuild_hover")
|
||||||
|
self.assertEqual(tool.bl_cursor, "PAINT_BRUSH")
|
||||||
|
keymap_operators = tuple(item[0] for item in tool.bl_keymap)
|
||||||
|
self.assertEqual(keymap_operators[0], self.balloon.ExtractBalloon.bl_idname)
|
||||||
|
self.assertEqual(
|
||||||
|
keymap_operators[1:],
|
||||||
|
("wm.radial_control", "wm.radial_control"),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
tool.bl_keymap[1][2]["properties"],
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"data_path_primary",
|
||||||
|
"scene.snapform_settings.balloon_thickness",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"zoom_path",
|
||||||
|
"scene.snapform_settings.balloon_thickness_radial_zoom",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
tool.bl_keymap[2][2]["properties"],
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"data_path_primary",
|
||||||
|
"scene.snapform_settings.balloon_resolution",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"zoom_path",
|
||||||
|
"scene.snapform_settings.balloon_resolution_radial_zoom",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_balloon_draw_handlers_are_filtered_to_the_invoking_viewport(self):
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
source = inspect.getsource(self.balloon.drawing_in_region)
|
||||||
|
operator_source = inspect.getsource(self.balloon.ExtractBalloon)
|
||||||
|
|
||||||
|
self.assertIn("area.as_pointer() == area_pointer", source)
|
||||||
|
self.assertIn("region.as_pointer() == region_pointer", source)
|
||||||
|
self.assertIn("drawing_in_region(", operator_source)
|
||||||
|
self.assertNotIn("wireframe_opacity", operator_source)
|
||||||
|
self.assertNotIn("shading.type", operator_source)
|
||||||
|
|
||||||
def test_balloon_matrix_serialization_keeps_all_components(self):
|
def test_balloon_matrix_serialization_keeps_all_components(self):
|
||||||
import json
|
import json
|
||||||
|
|
||||||
@@ -872,18 +942,81 @@ class AddonTest(unittest.TestCase):
|
|||||||
self.assertIn("selected_object = balloon if select_result else source_object", source)
|
self.assertIn("selected_object = balloon if select_result else source_object", source)
|
||||||
self.assertIn("balloon.matrix_world = matrix_world.copy()", source)
|
self.assertIn("balloon.matrix_world = matrix_world.copy()", source)
|
||||||
self.assertIn("create_material(", source)
|
self.assertIn("create_material(", source)
|
||||||
|
self.assertIn("material_name = material_data_name(source_object)", source)
|
||||||
|
self.assertIn(
|
||||||
|
"reusable_balloon_material(material_name, color_name)",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn("if reuse_material", source)
|
||||||
self.assertIn("color_image = image.copy()", source)
|
self.assertIn("color_image = image.copy()", source)
|
||||||
self.assertIn("color_image.name = color_data_name(source_object)", source)
|
self.assertIn("color_image.name = color_name", source)
|
||||||
self.assertIn("balloon_name,\n color_image,\n 0.0,", source)
|
self.assertIn(
|
||||||
|
"material_name,\n color_image,\n 0.0,",
|
||||||
|
source,
|
||||||
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"surface_group_name=TEXTURE_MATERIAL_NODE_GROUP_NAME",
|
"surface_group_name=TEXTURE_MATERIAL_NODE_GROUP_NAME",
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
self.assertIn("owns_material", source)
|
||||||
|
self.assertIn("owns_color_image", source)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.image_depth.TEXTURE_MATERIAL_NODE_GROUP_NAME,
|
self.image_depth.TEXTURE_MATERIAL_NODE_GROUP_NAME,
|
||||||
"O Texture Layer",
|
"O Texture Layer",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_balloon_material_reuse_requires_matching_image_and_node_group(self):
|
||||||
|
color_image = SimpleNamespace(name="cow.color")
|
||||||
|
texture_node = SimpleNamespace(type="TEX_IMAGE", image=color_image)
|
||||||
|
texture_layer = SimpleNamespace(
|
||||||
|
type="GROUP",
|
||||||
|
node_tree=SimpleNamespace(name="O Texture Layer"),
|
||||||
|
)
|
||||||
|
compatible = SimpleNamespace(
|
||||||
|
name="cow.001",
|
||||||
|
node_tree=SimpleNamespace(nodes=[texture_node, texture_layer]),
|
||||||
|
)
|
||||||
|
wrong_group = SimpleNamespace(
|
||||||
|
name="cow",
|
||||||
|
node_tree=SimpleNamespace(
|
||||||
|
nodes=[
|
||||||
|
texture_node,
|
||||||
|
SimpleNamespace(
|
||||||
|
type="GROUP",
|
||||||
|
node_tree=SimpleNamespace(name="O DA3 Surface Layer"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.fake_bpy.data = SimpleNamespace(
|
||||||
|
objects=SimpleNamespace(get=lambda _name: None),
|
||||||
|
materials=[wrong_group, compatible],
|
||||||
|
images=[color_image],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(
|
||||||
|
self.balloon.reusable_balloon_material("cow", "cow.color"),
|
||||||
|
compatible,
|
||||||
|
)
|
||||||
|
self.assertIs(
|
||||||
|
self.balloon.reusable_color_image("cow.color"),
|
||||||
|
color_image,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_surface_and_depth_plane_material_names_use_image_data(self):
|
||||||
|
import inspect
|
||||||
|
|
||||||
|
surface_source = inspect.getsource(self.image_mesh.build_image_mesh)
|
||||||
|
original_depth_source = inspect.getsource(
|
||||||
|
self.depth_build.build_image_depth_plane
|
||||||
|
)
|
||||||
|
depth_source = inspect.getsource(self.depth_build.build_depth_plane)
|
||||||
|
expected = "material_data_name(source_object)"
|
||||||
|
|
||||||
|
self.assertIn(expected, surface_source)
|
||||||
|
self.assertIn(expected, original_depth_source)
|
||||||
|
self.assertIn(expected, depth_source)
|
||||||
|
|
||||||
def test_image_operators_require_environment_and_required_models(self):
|
def test_image_operators_require_environment_and_required_models(self):
|
||||||
context = SimpleNamespace(
|
context = SimpleNamespace(
|
||||||
object=SimpleNamespace(data=SimpleNamespace()),
|
object=SimpleNamespace(data=SimpleNamespace()),
|
||||||
@@ -1017,11 +1150,23 @@ class AddonTest(unittest.TestCase):
|
|||||||
system_classes = self.addon.runtime.operator_classes()
|
system_classes = self.addon.runtime.operator_classes()
|
||||||
|
|
||||||
self.assertTrue(set(system_classes) <= set(self.registered))
|
self.assertTrue(set(system_classes) <= set(self.registered))
|
||||||
|
self.assertEqual(
|
||||||
|
self.registered_tools,
|
||||||
|
[
|
||||||
|
(
|
||||||
|
self.balloon.BalloonTool,
|
||||||
|
{
|
||||||
|
"separator": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
self.assertEqual(len(self.status_bar_draws), 1)
|
self.assertEqual(len(self.status_bar_draws), 1)
|
||||||
self.assertEqual(self.status_bar_draws[0]._owner, "snapform")
|
self.assertEqual(self.status_bar_draws[0]._owner, "snapform")
|
||||||
|
|
||||||
self.addon.unregister()
|
self.addon.unregister()
|
||||||
self.assertEqual(self.registered, [])
|
self.assertEqual(self.registered, [])
|
||||||
|
self.assertEqual(self.registered_tools, [])
|
||||||
self.assertEqual(self.status_bar_draws, [])
|
self.assertEqual(self.status_bar_draws, [])
|
||||||
|
|
||||||
def test_status_bar_reads_runtime_state(self):
|
def test_status_bar_reads_runtime_state(self):
|
||||||
|
|||||||
@@ -717,7 +717,7 @@ class ImageDepthTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertEqual(depth_image.name, "img.depth")
|
self.assertEqual(depth_image.name, "img.depth")
|
||||||
|
|
||||||
def test_color_name_uses_source_image_base_name(self):
|
def test_color_and_material_names_use_source_image_base_name(self):
|
||||||
source_object = SimpleNamespace(
|
source_object = SimpleNamespace(
|
||||||
data=SimpleNamespace(name="img.png"),
|
data=SimpleNamespace(name="img.png"),
|
||||||
)
|
)
|
||||||
@@ -726,11 +726,19 @@ class ImageDepthTest(unittest.TestCase):
|
|||||||
self.image_depth.color_data_name(source_object),
|
self.image_depth.color_data_name(source_object),
|
||||||
"img.color",
|
"img.color",
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.image_depth.material_data_name(source_object),
|
||||||
|
"img",
|
||||||
|
)
|
||||||
source_object.data.name = "img.png.001"
|
source_object.data.name = "img.png.001"
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.image_depth.color_data_name(source_object),
|
self.image_depth.color_data_name(source_object),
|
||||||
"img.001.color",
|
"img.001.color",
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.image_depth.material_data_name(source_object),
|
||||||
|
"img.001",
|
||||||
|
)
|
||||||
|
|
||||||
def test_surface_displacement_is_set_to_displacement_only(self):
|
def test_surface_displacement_is_set_to_displacement_only(self):
|
||||||
class Material:
|
class Material:
|
||||||
|
|||||||
Reference in New Issue
Block a user