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:
2026-08-20 11:33:19 +08:00
parent 13ef705226
commit 4e1231790b
18 changed files with 864 additions and 851 deletions

View File

@@ -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` 的文档相关文件变化时部署。

View File

@@ -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 一样随场景帧刷新。

View File

@@ -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 模型仓库决定

View File

@@ -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)。

View File

@@ -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` 文件名。

View File

@@ -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` 贡献算子,或注入内存高效的注意力算子;
- 若未来需要统一坐标系的视频重建,可按小窗口分批推理,再对齐相机位姿与深度尺度。

View File

@@ -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)。

View File

@@ -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"):

View File

@@ -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()

View File

@@ -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,

View File

@@ -1 +1,5 @@
from .operator import BalloonLasso, ExtractBalloon from .operator import (
ActivateBalloonTool,
BalloonTool,
ExtractBalloon,
)

View File

@@ -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")

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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",

View File

@@ -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):

View File

@@ -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: