From 4e1231790ba8acfb67c644535e6c4f8df9ba6edd Mon Sep 17 00:00:00 2001 From: nan Date: Thu, 20 Aug 2026 11:33:19 +0800 Subject: [PATCH] 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. --- README.md | 14 +- docs/depth-plane.md | 131 +++--- docs/development.md | 161 +++---- docs/features.md | 135 ++---- docs/outputs.md | 43 +- docs/server.md | 151 +++--- docs/surface.md | 226 ++------- src/snapform/__init__.py | 6 + src/snapform/menu.py | 4 +- src/snapform/operators/__init__.py | 7 +- src/snapform/operators/balloon/__init__.py | 6 +- src/snapform/operators/balloon/operator.py | 494 ++++++++++++-------- src/snapform/operators/depth_plane/build.py | 5 +- src/snapform/operators/image.py | 4 + src/snapform/operators/surface.py | 3 +- src/snapform/properties.py | 70 +++ tests/test_addon.py | 245 ++++++++-- tests/test_image_depth.py | 10 +- 18 files changed, 864 insertions(+), 851 deletions(-) diff --git a/README.md b/README.md index 92fa071..e18b5ed 100644 --- a/README.md +++ b/README.md @@ -52,15 +52,15 @@ Environment 未安装时,同一位置显示两个安装按钮。Environment ### 套索生成 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 局部椭圆截面之间切换 -- `Thickness`:控制鼓起厚度,软上限为 `5.0`,不设硬上限 -- `Resolution`:相邻采样点的目标世界空间距离,类型为 Distance,默认 `0.1 Blender unit`;数值越小网格越密 +- `Mode`、`Thickness`、`Resolution (m)` 和 `Reuse Material` 显示在 Tool Header / Tool Settings +- `S`:调用 Blender 原生 Radial Control 调节 Thickness;通过 `zoom_path` 把默认 `1.0` 映射为约 100 px 的交互圆,软上限为 `5.0`,不设硬上限 +- `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 会自动 @@ -120,5 +120,5 @@ Session 逐帧推理,显存峰值不随视频帧数平方增长。Debug 只保 ## 技术文档 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` 的文档相关文件变化时部署。 diff --git a/docs/depth-plane.md b/docs/depth-plane.md index 39eeb2f..c8ddb3a 100644 --- a/docs/depth-plane.md +++ b/docs/depth-plane.md @@ -1,73 +1,66 @@ # Depth Plane -本页记录 Depth Plane 的数据、节点和材质实现。日常操作与参数说明请阅读 -[Depth Plane 使用指南](guide-depth-plane.md)。 +Depth Plane 把图片映射为带深度位移的面片,适合快速获得可调节的立体效果。操作方法见[Depth Plane 使用指南](guide-depth-plane.md)。 -## Image Empty 转深度面片 +## 数据流 -1. 在 3D View 选中 `Object Type = Empty`、`Display As = Image` 的对象。 -2. 右键打开 `SnapForm` 子菜单并选择 `Convert to Depth Plane`,通过 `Depth from` 选择深度来源: - - `DA3`:显示 Model 和 Resolution Scale。ONNX 模型使用 camera decoder 恢复相机。Process Res 先以原图长边乘以 - Resolution Scale,再钳制到 `252–1008`,最后对齐到最接近的 `14` 倍数。Resolution Scale 以百分比 - 显示,范围为 `1–100%`,默认 `100%`;界面只在标签中实时显示最终 Process Res。 - 转换导出 `mini_npz`,使用前需要在 - `Models` panel 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略 - 继续使用场景设置。Model 下拉菜单只列出已经下载的 DA3 模型;若没有可用模型,对话框会引导到 - `Models` panel 下载。 - - MoGe-2:使用所选模型输出 metric depth、normal、validity mask 与 normalized intrinsics。Depth Plane 不保存体积较大的 points;Surface 会在 AI 进程内用内置 NumPy 网格构建生成 mesh.npz。Debug 则逐帧保存 API 直接返回的 points、depth、normal、mask 与 intrinsics,不生成网格或预览。 - - `Original`:直接把原图作为深度贴图,不显示估算参数。仅在开启 `Remove Background` 时运行 BEN2 - 取得前景 Alpha;视频或序列帧按需同时抽取原始帧与前景序列,把原始帧作为 `.depth` 序列。 - `MoGe-2 ViT-S` 是默认深度来源。选择 `DA3` 时,其 Model 与 Resolution Scale - 参数会集中显示在独立边框中。`Remove Background` 与 `Shadeless` 直接显示,不再使用 Options 折叠; - `Keep Original` 默认关闭,并在分割线下方保持为最后一项。仅当开启 `Remove Background` 时才需要 - `BEN2 Base`,并把 `.color` 的 Alpha 作为独立 Mask,不在 Server 中改写深度值。无论颜色层是直接 - 复制原图,还是由 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。 - 该行为只用于 Depth Plane 转换;Surface 仅支持单张图片。Debug 中 DA3 多帧输入联合推理, - MoGe-2 与 BEN2 按单图 API 逐帧运行。 - Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty - 一致;Original 模式的视频或序列帧使用 `color.0001.*` 颜色层同时作为颜色与深度输入;开启 - 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 预览。 - 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` 的相机内参与深度确定,再通过对象 - Transform 匹配原 Image Empty 的可见尺寸、位置、旋转和 Offset。Original 模式直接使用源 Empty - 的可见尺寸、Offset 与 Transform。DA3 会把用于匹配画面尺寸的缩放预乘到 `.depth` 数值, - XY 缩放烘焙到 Mesh,Z 不参与平面匹配;最终对象 Scale 为 `(1, 1, 1)`,视觉深度与旧逻辑一致。 - 关闭 `Keep Original` 时,成功生成后删除源 Empty。生成面片 Object 使用源 Empty 的原名;开启 - `Keep Original` 时不处理 - 同名冲突,由 Blender 自动为生成面片追加编号。 -4. Blender 只创建保留图像纵横比的最低密度基础网格,再把 `O DA3 Depth Plane` 作为 Geometry Nodes - 修改器添加到对象。节点组与 `O DA3 Surface` 一同保存在 `O_SnapForm.blend`,关闭 - `show_group_selector`,不为每个对象复制节点组。MoGe-2 使用单独的 `O MoGe2 Depth Plane`,处理 - 网格细分、metric depth 位移和材质设置;材质使用 `O MoGe2 Depth Plane Layer`,直接接入模型 normal。 - `O MoGe2 Depth Plane` 与 DA3 一样通过隐藏的 Color Image 读取 `.color` Alpha,以 `Alpha > 0.5` - 划分前景范围,并在范围外改用 `Background Depth`。两个修改器公开以下参数: - - `Displacement`:位于 Geometry 之后的第一项,菜单包含 `Geometry`(默认)与 `Material`。`Geometry` - 在节点组内沿法线修改网格,`Material` 保持网格平面并启用材质置换; - - `Subdivision`:位于第二项,默认 `5`。节点读取基础平面的 Bounding Box,短边分段数为 `2^Subdivision`, - 长边按宽高比计算并四舍五入;例如 16:9 图片在等级 `5` 时生成 `57×32` 个面; - - `Background Depth`:默认 `-1`,允许正负值,Structure Type 为 `Single`,直接显示且不再放在 Panel 中。前景 Mask 外的结构或 - 材质置换使用该值;是否存在背景只取决于转换所得色彩贴图的 `Alpha > 0.5` 二值结果; - - `Depth Scale`:默认 `1`,允许正负值,Structure Type 为 `Single`,同时控制 Geometry 与 Material - 两条置换路径。 - Grid 重建后通过隐藏的 Material 输入重新赋予转换材质,并对所有面执行 Shade Smooth。 - 材质使用显式指向 `UVMap` 的 UV Map 节点连接颜色、前景 Mask 与深度 Image Texture,不依赖活动 UV。 - 前景深度先与 `Background Depth` 取 Maximum,再按色彩贴图 Alpha 与背景深度混合, - 深度分支在混合前使用 `Alpha > 0.5` 转为二值 Mask,避免半透明前景被背景深度拉扯; - 材质表面 Alpha 同样使用二值 Mask。材质置换使用相同的二值和钳制顺序。 - 节点组还会把 `o_material_displacement` 与 `o_background_depth` 写入点域,材质通过 - `Geometry` 类型的 Attribute 节点读取。点域会为顶点保存重复值,但能在应用修改器后继续保留参数; - 相比自适应网格本身,其占用很小。Depth Image 与 Color Image 是修改器隐藏数据输入, - 由导入流程自动设置。 - `.color` 与 `.depth` 的 Blender Image 类型只依据源 Empty Image:普通单图使用 `IMAGE`,Movie 或 Sequence 使用 `SEQUENCE`;即使动画源这次只处理一帧也仍为 `SEQUENCE`,不按输出文件数量推断。视频与序列帧还会为 Depth 与 Color 共同写入隐藏的 `Frame`、`Frame Start`、`Frames` 与 `Cyclic`。 - 节点组内部不再使用 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 一样随场景帧刷新。 +```mermaid +flowchart LR + Input["Image Empty"] --> Depth{"深度来源"} + Depth --> DA3["DA3"] + Depth --> MoGe["MoGe-2"] + Depth --> Original["Original"] + DA3 --> Layers["颜色层 + 深度层"] + MoGe --> Layers + Original --> Layers + Layers --> Import["Blender 导入"] + Import --> Mesh["基础面片"] + Import --> Nodes["Geometry Nodes"] + Import --> Material["材质"] +``` + +Server 为每帧准备颜色、深度和可选前景 Alpha。Blender 导入这些数据,创建基础面片,并用 Geometry Nodes 与材质分别完成几何位移和表面细节。 + +## 深度来源 + +| 来源 | 深度含义 | 额外数据 | 适合 | +| --- | --- | --- | --- | +| DA3 | 相对深度 | 相机内参与置信度 | 通用单图与动画 | +| MoGe-2 | Metric depth | 法线、Mask、相机内参 | 更稳定的尺度与表面法线 | +| Original | 原图亮度 | 可选 BEN2 Alpha | 已准备好高度图的素材 | + +DA3 和 MoGe-2 都以未经抠图的 RGB 估计深度。启用 Remove Background 后,BEN2 Alpha 用于颜色透明度和前景 Mask,深度本身保持模型输出。 + +视频与序列帧逐帧推理并复用同一个模型 Session,因此显存主要由单帧分辨率决定。DA3 Debug 的多帧联合推理属于另一条工作流。 + +## Blender 结果 + +转换会创建以下数据: + +- 与源 Image Empty 对齐的面片 +- `.depth` 与 `.color` Image datablock +- 使用 `O DA3 Depth Plane` 或 `O MoGe2 Depth Plane` 的 Geometry Nodes 修改器 +- 连接颜色、Alpha、Bump 与材质位移的材质 + +Geometry Nodes 提供深度范围、细分、重拓扑、平滑、背景深度和位移强度等控制。深度 Mask 在几何和材质路径中使用相同阈值,以保持轮廓一致。 + +材质与图片名称以源 Image datablock 为基名。对象 Transform、显示尺寸和 Offset 会转换为面片的空间位置与比例。 + +## 单图与动画文件 + +单图深度使用 OpenEXR Pack 到 `.blend`,以保留浮点范围。Packed Image 会先导出临时输入,任务结束后清理,源 Image 保持不变。 + +动画使用 Job 目录中的外部序列: + +```text +/ +├── color.0001.jpg/png +├── depth.0001.exr +└── results.npz +``` + +MoGe-2 动画的深度序列使用 `moge_depth.0001.exr`。这些文件是 `.blend` 的运行时依赖,详见[动画与序列帧](animation.md)。 + +## 适用范围 + +Depth Plane 是固定平面上的深度位移,不是完整的逐像素 camera-space 重建。它强调可编辑性和动画支持;需要透视网格时使用 [Surface](surface.md)。 diff --git a/docs/development.md b/docs/development.md index 0aba0d2..24a016f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 flowchart LR - User["Image Empty / Debug Panel"] --> Operator["Blender Operator"] + UI["菜单 / Panel"] --> Operator["Feature Operator"] Operator --> Runtime["JobRuntime"] - Runtime --> Server["JobServer FIFO Queue"] - Server --> Handler["SnapForm Job Handler"] - Handler --> Model["ModelManager / ONNX"] - Handler --> JobFiles["Job Directory"] - JobFiles --> Response["Operator response"] + Runtime --> Job["Server Job"] + Job --> Files["Job Directory"] + Files --> Response["Operator Response"] Response --> Scene["Blender Scene"] ``` -核心边界如下: +Blender 交互层管理 UI、RNA 状态、任务参数和结果导入。Runtime 管理 Environment、Server、队列、进度与取消。Server Job 组合媒体、模型、几何和输出模块。完整运行设计见 [Job Server](server.md)。 -- `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 . -├── AGENTS.md ├── README.md +├── docs/ ├── pack.py ├── sync_models.py -├── docs/ -├── src/ -│ └── snapform/ -│ ├── __init__.py -│ ├── blender_manifest.toml -│ ├── runtime.py -│ ├── menu.py -│ ├── panel.py -│ ├── preferences.py -│ ├── properties.py -│ ├── model_catalog.py -│ ├── 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 +├── src/snapform/ +│ ├── blender_manifest.toml +│ ├── runtime.py +│ ├── menu.py +│ ├── panel.py +│ ├── preferences.py +│ ├── properties.py +│ ├── operators/ +│ ├── server/ +│ ├── assets/ +│ └── wheels/ ├── tests/ └── tools/ ``` -## 常见命令 +## 开发命令 在项目根目录使用 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 ``` -`.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 -uv run mkdocs serve +uv run --only-group docs mkdocs serve +uv run --only-group docs mkdocs build ``` -构建静态文档或使用 Mike 发布版本化文档: +版本化发布可使用 Mike: ```bash -uv run mkdocs build -uv run mike deploy -p -uv run mike set-default -p +uv run --only-group docs mike deploy -p +uv run --only-group docs mike set-default -p ``` -### 文档部署 - -`.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 --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 -``` +自动部署将 MkDocs 站点发布到专用 Cloudflare Worker。仓库需要配置 `CLOUDFLARE_ACCOUNT_ID`、`CLOUDFLARE_API_TOKEN` 和 `CLOUDFLARE_WORKER_NAME`。Worker 路由对应 `docs.omoolab.xyz/snapform/*`。 ## 资产构建与验证 -`tools/` 脚本依赖 `bpy`,必须由 Blender 执行。设置本机 Blender 可执行文件后,可以重建或验证 `O_SnapForm.blend`: +`tools/` 中的资产脚本依赖 `bpy`,应由 Blender 执行: ```bash 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 ``` -验证脚本会自动把 `src/snapform/wheels/` 中的 BlendJob wheel 加入 Blender Python 路径,因此可在 -`--factory-startup` 的干净环境中直接运行。 - -只改 Python、文档或打包逻辑时不需要重建 `.blend` 资产;修改节点组、材质组或其接口时,应运行相应资产验证脚本,并再次运行完整测试。 +修改节点组、材质组或资产接口后,运行对应验证脚本和完整测试。Python、文档或打包逻辑变化无需重建 `.blend` 资产。 ## 上游与许可证 - Depth Anything 3 代码采用 Apache 2.0 - BEN2 ONNX 模型采用其仓库声明的 Apache 2.0 License - SnapForm Extension 代码采用 GPL-3.0-or-later -- 模型权重许可证由所选 Hugging Face 模型仓库决定,Extension 下载前会在界面显示 +- 模型权重许可证由所选 Hugging Face 模型仓库决定 diff --git a/docs/features.md b/docs/features.md index a9de734..297f929 100644 --- a/docs/features.md +++ b/docs/features.md @@ -1,120 +1,49 @@ # 功能参考 -本页记录完整功能行为和实现边界,主要供开发、测试与故障排查使用。第一次使用 SnapForm 时,请从 -[安装与首次配置](installation.md)或[五分钟上手](quick-start.md)开始。 +本页概览 SnapForm 的能力。安装步骤见[安装与首次配置](installation.md),实际操作见[工作流选择](workflows.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` 安装 -- 在独立 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 按钮保持原位置并变灰,任务完成后恢复 +Depth Plane 与 Surface 可使用 DA3 或 MoGe-2。DA3 提供相对深度和相机信息;MoGe-2 还提供 metric depth、法线与有效区域。BEN2 为去背景和可选前景处理提供 Alpha。 -## 使用 +## Blender 交互 -1. 运行 `uv run python pack.py`,分别构建 `windows-x64`、`macos-arm64`、 - `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。 +主要功能位于 Image Empty 的 `SnapForm` 右键菜单。菜单会显示 Environment 和必需模型的就绪状态,并在缺少内容时提供安装或下载入口。 -## 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` 缩放 -- `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,形成袖段、分叉和端点轴,并用轴半径生成局部圆/椭圆式截面。当前实现将厚度场映射到共用均匀网格,并非逐面复刻论文的专用缝合拓扑 +Depth Plane 和 Remove Background 支持 Movie 与 Image Sequence。它们按 Image Empty 的帧范围处理素材,并生成与场景帧对应的颜色或深度序列。 -每次释放都会把该次套索、源对象名称和启动时的完整 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 可移除提取结果。 +## 运行环境 -当前不提供添加/减去选区、边缘羽化和视频跟踪。 -## Image Empty 去背景 +AI 任务在独立 Python Environment 中通过 ONNX Runtime 执行。Blender 负责界面、任务提交和结果导入,Job Server 负责模型与文件处理。 -1. 确认 `Models` panel 中的 `BEN2 Base` 已就绪;首次安装 Environment 时会默认下载。 -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 自动追加编号。 +默认 Storage Root 为 `~/.snapform`,其中保存 Environment、模型、日志和 Job 结果。连续任务会复用已加载的模型 Session;DA3 与 MoGe-2 共享一个深度模型缓存位置,BEN2 可同时保留。 -原图已 Pack 时会先写出临时 PNG 交给 BEN2,任务结束后删除临时输入;原 Image 仍保持 Pack -状态和原始内容。正式任务的 `color.0001.png` 也会保留在对应 job 目录中。 +安装、下载和推理都在后台运行,并通过状态栏提供进度与取消操作。详细设计见[Job Server](server.md)。 -Image Empty 引用视频或序列帧时,Remove Background 直接执行,默认取 Empty 的 Image Frames -数值(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset;若 Offset 加 Frames 超出视频长度, -会把窗口整体收进视频内(保证实际处理 Frames 帧数),Offset 过小时从第 1 帧开始。输出 -`color.0001.png` 等透明序列帧(帧号从 0001 开始,与 Blender 场景帧一一对应),并把 -关闭去背景时,原始颜色帧同样以 `color.0001.*` 起始的统一名称写入 job 目录。替换 Empty 的 Image 为 -加载这些帧的 SEQUENCE Image,从原 Empty 的 Start 位置开始播放(Start 原样保留)。图片类型只取决于源 Empty:普通单图的单个 `color.0001.*` 仍作为 `IMAGE`,Movie/Sequence 即使只导出一帧也作为 `SEQUENCE`;`.depth` 同样遵循该规则。 -Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因此保持外部引用 job 目录; -请保留对应 job 目录,否则序列会失效。 +## 模型与调试 + +正式功能使用以下 ONNX 模型: + +- MoGe-2 ViT-S、ViT-B、ViT-L Normal +- DA3 Small、DA3 Base +- BEN2 Base + +首次安装会准备 MoGe-2 ViT-S Normal 与 BEN2 Base,其他模型可从 Models 面板按需下载。 + +Debug 工作流保存模型 API 的直接返回,适合验证模型、设备与输入。Depth Plane 和 Surface 则在模型结果之上继续生成 Blender 可用的贴图与几何。文件字段见[输出格式](outputs.md)。 diff --git a/docs/outputs.md b/docs/outputs.md index 3bf7280..c6a3531 100644 --- a/docs/outputs.md +++ b/docs/outputs.md @@ -1,37 +1,28 @@ # 输出格式 -本页说明任务文件的字段与序列化约定,主要用于开发与数据检查。普通使用者只需了解 -[模型、设备与存储](models-and-storage.md)中的 Job 文件夹说明。 +本页记录 Job 产物的稳定用途与主要字段。日常使用只需了解 [Job 文件夹](models-and-storage.md)。 -## 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、 - 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 +## NPZ 字段 -Debug 不生成点云、三角网格、深度预览、置信度 percentile 或坐标变换后的派生产物。输入为多帧时, -DA3 保留联合推理;MoGe-2 与 BEN2 按其单图 API 逐帧保存结果。 +DA3 Depth 结果包含 `depth`、`conf`、`extrinsics` 与 `intrinsics`。MoGe-2 Depth 结果包含 `depth`、`normal`、`mask` 与 `intrinsics`。 -## Depth +Surface 的 `mesh.npz` 包含 `vertices`、`faces`、`uv`、`intrinsics`、`image_size` 与 `reference_depth`,以及 Geometry Nodes 使用的网格属性。 -generate-depth 输出 Blender Depth Plane 所需的 results.npz。DA3 保存 depth、conf、 -extrinsics 与 intrinsics;MoGe-2 保存 depth、normal、mask 与 intrinsics,并额外写出 -Depth Plane 导入所需的贴图。这里的文件转换属于正式 Depth 工作流,不属于 Debug。 +Debug 文件保持模型 API 的数据形态,用于比较推理结果;正式 Job 则提供 Blender 导入所需的派生数据。 -## Surface +## 图片序列 -generate-surface 统一写出 `mesh.npz`,不再生成 `texture.png`。DA3 与 MoGe-2 都使用 -`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 只负责加载使用。 +正式颜色层统一使用 `color.0001.*` 起始的文件名。深度动画使用 EXR 序列以保存浮点值。源 Empty 为 Movie 或 Sequence 时,Blender 按序列载入这些文件,即使任务只输出一帧。 -## BEN2 - -正式 remove-background 工作流输出 `color.0001.png` 起始的透明颜色层。BEN2 ONNX API 返回的 Alpha -恢复到输入分辨率,RGB 保留输入图像颜色。Debug BEN2 仍保留模型 API 语义,使用 `foreground` 文件名。 +MoGe-2 Surface 的法线单独保存为 `normal.png`。材质在 Blender 端创建,不属于 Job 文件格式。 diff --git a/docs/server.md b/docs/server.md index f6aeb28..6e74df0 100644 --- a/docs/server.md +++ b/docs/server.md @@ -1,129 +1,84 @@ # Job Server -本页记录 Environment、模型缓存和 Job Runtime 的内部设计。安装或使用遇到问题时,请先查看 -[安装与首次配置](installation.md)和[常见问题](faq.md)。 +SnapForm 把 AI 与媒体处理放在独立 Job Server 中运行,使 Blender Python 保持轻量。安装和使用问题见[安装与首次配置](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 ~/.snapform/ -├── .venv/ -├── environment.json -├── models/ -├── jobs/ -├── manifest.json -├── install.log -├── install-status.json # 仅在安装期间存在 -└── server.log +├── .venv/ Python Environment +├── models/ ONNX 模型 +├── jobs/ 任务产物 +├── environment.json 环境声明 +├── manifest.json 已安装环境指纹 +├── install.log 安装日志 +└── server.log Server 日志 ``` -不同 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` 唯一管理; -- `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。 +调用方向从 Job 指向媒体、模型、几何和输出模块。模型层返回内存数据,输出层管理文件,Blender 数据始终由 Operator 的 Response 创建。 -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 - DA3 Small、DA3 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//`;两者都失败时当前 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 时显示 -`Download Required Models`。对应的 `download-required-models` Job 在 Server 端再次检查就绪状态, -只下载缺失项,并把多个下载的进度合并为单次 0–100%。必需模型补齐前,右键菜单中的 -`Convert to Depth Plane` 保持禁用。 +模型下载优先使用 Hugging Face,并以 OmooLab 镜像作为回退。Environment 安装则提供官方源和镜像源两个明确入口;它们只影响 Environment Bootstrap,不改变模型下载策略。 -`model_manager` Resource 在 Server 内持有磁盘就绪状态和内存 Session。Blender UI 使用 Client catalog 声明的目录名与 `ready_patterns` 直接检查 `/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` 等阶段编号。 -任一 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//`,提交响应同时返回 `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` 贡献算子,或注入内存高效的注意力算子; -- 若未来需要统一坐标系的视频重建,可按小窗口分批推理,再对齐相机位姿与深度尺度。 +DA3 Debug 保留联合推理,用于检查模型原始多帧行为。Server 会在预计注意力张量过大时提前报错,此时应减少帧数或 Resolution Scale。 diff --git a/docs/surface.md b/docs/surface.md index b9bfb9b..d6d58a3 100644 --- a/docs/surface.md +++ b/docs/surface.md @@ -1,194 +1,66 @@ # Surface -本页记录 Surface 的网格、相机、Geometry Nodes 和材质实现。日常操作与参数说明请阅读 -[Surface 使用指南](guide-surface.md)。 +Surface 从单张图片生成带透视关系的相机空间网格。它比 Depth Plane 更接近真实空间结构,适合需要匹配相机或继续编辑拓扑的场景。操作方法见[Surface 使用指南](guide-surface.md)。 -## Image Empty 转透视网格 +## 数据流 -1. 在 3D View 选中仅包含一帧的 Image Empty,右键打开 `SnapForm` 子菜单并选择 - `Convert to Surface`。该入口独立于 - `Convert to Depth Plane`,当前不处理视频或序列帧。 - 模型列表包含三个 MoGe-2 Normal 模型和两个 DA3 模型,默认使用 MoGe-2 ViT-S Normal。DA3 的 - Resolution Scale 与 Depth Plane 使用相同设计;MoGe-2 改为显示共享的 Resolution Level。 -2. Server 先用未经抠图的原始 RGB 请求 DA3 的 `depth`、`conf` 与 `intrinsics`,随后仅在开启 - `Remove Background` 时运行 BEN2 取得前景 Alpha;Alpha 经目标网格分辨率缩放与轻微模糊后,只要 - 大于 `0` 就把对应点视为前景;每个三角形的三个顶点必须全部为前景,任一顶点 Alpha 为 `0` 就在输出前删除该面; - 关闭时不加载 BEN2。Depth 不做 smoothing,Server 反投影深度网格,建立源三角面, - 计算 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) +```mermaid +flowchart LR + Image["单张 Image Empty"] --> Model["DA3 / MoGe-2"] + Model --> Builder["Surface Builder"] + Builder --> Mesh["mesh.npz"] + Mesh --> Blender["Blender 导入"] + Blender --> Nodes["Geometry Nodes 后处理"] + Nodes --> Result["Surface + Material"] + Blender --> Camera["可选匹配相机"] ``` -这样即使 DA3 估计出的 `fx/fy` 不一致,也不会把正方形原图拉成长方形。主点 `cx/cy` 仍用于 -确定原始相机面片,随后对象 XY Transform 将它匹配到源 Image Empty。生成对象会记录 -`snapform_reference_depth`、`snapform_camera_plane_width` 和 -`snapform_camera_plane_height`,便于检查实际采用的 -参考深度和尺寸。 +模型提供深度、相机和有效区域,Surface Builder 将其转换为统一的相机空间网格。Blender 导入网格后,用 Geometry Nodes 完成清理、重拓扑、平滑和 UV 重投影。 -材质始终导入深度 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 平面上的深度位移面,不是使用 -相机内参逐像素反投影得到的完整 camera-space 网格;中位深度截面为 Geometry Nodes -深度面片提供正确的整体高宽尺度。 +| 来源 | 用于建网格的数据 | 材质附加数据 | +| --- | --- | --- | +| DA3 | Depth、confidence、intrinsics | 原图颜色 | +| MoGe-2 | Metric depth、mask、intrinsics | 原图颜色与 normal | -生成完成后会直接创建面片,不提供缓存重建入口或单独的导入参数。重新选择 -`Convert to Depth Plane` 会按当前 Depth from 再次转换。 -面片、深度 Image、颜色 Image、Material 与 Geometry Nodes 修改器会在同一个 Blender Undo 步骤中创建;转换完成后执行 -`Ctrl+Z` 会完整撤销本次转换,不会越过转换去回退更早的 Image Empty 操作。 +两种模型共享同一 Surface Builder 和网格文件格式。启用 Remove Background 时,BEN2 Alpha 会在建网格阶段移除背景三角面;这是几何裁剪,转换后关闭材质 Mask 不会恢复已删除的面。 -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 使用彼此独立的 -`debug-da3`、`debug-moge2` 与 `debug-ben2`,只保存模型 API 的直接返回,不进入 Geometry 或 -Surface 输出流程。 +源网格保持模型空间信息,视觉清理集中在 Geometry Nodes 中完成。这样可以调整参数、比较结果,也可以 Apply 修改器后继续普通建模。 -Job 目录中的典型正式产物如下: +## Geometry Nodes -```text -/ -├── results.npz、mesh.npz 或 normal.png -└── prediction.npz Debug 原始 API 返回 +DA3 使用 `O DA3 Surface`,MoGe-2 使用 `O MoGe2 Surface`。两者采用相同的处理顺序: -/ -├── color.0001.jpg/png… 正式工作流颜色层(原图复制或 BEN2 Alpha) -├── depth.0001.exr… AI 深度序列帧 -└── normal.png MoGe-2 Surface 法线贴图 -``` +1. 根据有效区域、深度连续性和可用置信度剔除不可靠面 +2. 合并和修复拓扑 +3. 清理小岛并平滑表面 +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)。 diff --git a/src/snapform/__init__.py b/src/snapform/__init__.py index 688fdaa..927f784 100644 --- a/src/snapform/__init__.py +++ b/src/snapform/__init__.py @@ -2,6 +2,7 @@ import bpy from .runtime import runtime from .operators import CLASSES as OPERATOR_CLASSES +from .operators.balloon import BalloonTool from .preferences import SnapFormPreferences from .properties import SnapFormSettings from .menu import SnapFormImageMenu, draw_image_context_menu @@ -32,6 +33,10 @@ CLASSES = ( def register(): for class_type in CLASSES: bpy.utils.register_class(class_type) + bpy.utils.register_tool( + BalloonTool, + separator=False, + ) bpy.types.Scene.snapform_settings = bpy.props.PointerProperty( type=SnapFormSettings, ) @@ -42,6 +47,7 @@ def register(): def unregister(): runtime.unregister() + bpy.utils.unregister_tool(BalloonTool) bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu) bpy.types.OUTLINER_MT_object.remove(draw_image_context_menu) if hasattr(bpy.types.Scene, "snapform_settings"): diff --git a/src/snapform/menu.py b/src/snapform/menu.py index 8063f0e..ef3f85b 100644 --- a/src/snapform/menu.py +++ b/src/snapform/menu.py @@ -14,8 +14,8 @@ class SnapFormImageMenu(bpy.types.Menu): layout = self.layout layout.operator_context = "INVOKE_DEFAULT" layout.operator( - "snapform.balloon_lasso", - text="Balloon Mesh (Lasso)", + "snapform.activate_balloon_tool", + text="Balloon Lasso Tool", icon="MOD_SKIN", ) layout.separator() diff --git a/src/snapform/operators/__init__.py b/src/snapform/operators/__init__.py index 7edf027..679963c 100644 --- a/src/snapform/operators/__init__.py +++ b/src/snapform/operators/__init__.py @@ -1,5 +1,8 @@ from .background import ProcessImageBackground, RemoveImageBackground -from .balloon import BalloonLasso, ExtractBalloon +from .balloon import ( + ActivateBalloonTool, + ExtractBalloon, +) from .debug import DebugBen2, DebugDa3, DebugMoge2 from .depth_plane import ( ConvertToDepthPlane, @@ -27,7 +30,7 @@ CLASSES = ( ExtractOriginalFrames, ClearModels, ExtractBalloon, - BalloonLasso, + ActivateBalloonTool, RemoveImageBackground, ConvertToDepthPlane, ConvertToSurface, diff --git a/src/snapform/operators/balloon/__init__.py b/src/snapform/operators/balloon/__init__.py index 63ce0d2..b33ec8d 100644 --- a/src/snapform/operators/balloon/__init__.py +++ b/src/snapform/operators/balloon/__init__.py @@ -1 +1,5 @@ -from .operator import BalloonLasso, ExtractBalloon +from .operator import ( + ActivateBalloonTool, + BalloonTool, + ExtractBalloon, +) diff --git a/src/snapform/operators/balloon/operator.py b/src/snapform/operators/balloon/operator.py index 7a240b0..7a5ac69 100644 --- a/src/snapform/operators/balloon/operator.py +++ b/src/snapform/operators/balloon/operator.py @@ -4,7 +4,18 @@ import math import bpy 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 ..surface import assign_vertex_uv @@ -99,6 +110,55 @@ def balloon_geometry_parameters(thickness, resolution): 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( context, source_object, @@ -108,6 +168,7 @@ def extract_balloon_object( thickness=1.0, resolution=POINT_SPACING, select_result=True, + reuse_material=True, ): image = source_object.data image_size = tuple(image.size) @@ -130,19 +191,37 @@ def extract_balloon_object( source_name = source_object.name balloon_name = source_name - color_image = image.copy() - color_image.name = color_data_name(source_object) - material = None + material_name = material_data_name(source_object) + color_name = color_data_name(source_object) + 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 balloon = None try: - material = create_material( - balloon_name, - color_image, - 0.0, - surface_group_name=TEXTURE_MATERIAL_NODE_GROUP_NAME, - scene=context.scene, - ) + if material is None: + color_image = ( + reusable_color_image(color_name) + if reuse_material + else None + ) + 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.from_pydata(vertices, [], faces) mesh.update() @@ -164,9 +243,13 @@ def extract_balloon_object( bpy.data.objects.remove(balloon, do_unlink=True) if mesh is not None and mesh.users == 0: 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) - 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) raise @@ -177,97 +260,44 @@ def extract_balloon_object( 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(): return bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"}) -def hidden_bool_property(default=False): - return bpy.props.BoolProperty( - default=default, - options={"HIDDEN", "SKIP_SAVE"}, +def meters_to_world_distance(scene, meters): + unit_settings = getattr(scene, "unit_settings", None) + scale_length = getattr(unit_settings, "scale_length", 1.0) + 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): bl_idname = "snapform.extract_balloon" bl_label = "Extract Balloon" @@ -278,148 +308,94 @@ class ExtractBalloon(bpy.types.Operator): thickness: thickness_property() resolution: resolution_property() source_object_name: hidden_string_property() + reuse_material: reuse_material_property() source_matrix_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): layout = self.layout layout.prop(self, "mode") layout.prop(self, "thickness") - layout.prop(self, "resolution") - - 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 + layout.prop(self, "resolution", text="Resolution (m)") + layout.prop(self, "reuse_material") @classmethod 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): - if not self._path: + def _draw_lasso(self): + if ( + not self._path + or not drawing_in_region(self._area_pointer, self._region_pointer) + ): return - import gpu - from gpu_extras.batch import batch_for_shader - vertices = list(self._path) if len(vertices) > 2: vertices.append(vertices[0]) - 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(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") + draw_line_strip(vertices, (0.15, 0.65, 1.0, 0.95), 2.0) def _finish(self, context): if self._handle is not None: bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW") self._handle = None + context.workspace.status_text_set(None) if context.area is not None: - context.area.header_text_set(None) context.area.tag_redraw() + def cancel(self, context): + self._finish(context) + def invoke(self, context, event): source_object = context.active_object - if not is_image_empty(source_object): - self.report({"ERROR"}, "Select an Image Empty before extracting") - return {"CANCELLED"} + settings = context.scene.snapform_settings + self.mode = settings.balloon_mode + 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_matrix_data = serialize_matrix(source_object.matrix_world) - self._path = [] - self._drawing = False + self.selection_data = "" + self._path = [(event.mouse_region_x, event.mouse_region_y)] + self._area_pointer = context.area.as_pointer() + self._region_pointer = context.region.as_pointer() self._handle = bpy.types.SpaceView3D.draw_handler_add( self._draw_lasso, - (context,), + (), "WINDOW", "POST_PIXEL", ) - context.window_manager.modal_handler_add(self) - context.area.header_text_set( - "Balloon Mesh: drag LMB repeatedly, navigate normally, Esc to finish" + context.workspace.status_text_set( + "Balloon Lasso: release LMB to extract, RMB or Esc to cancel" ) + context.window_manager.modal_handler_add(self) context.area.tag_redraw() return {"RUNNING_MODAL"} def modal(self, context, event): - if event.type == "ESC": + if event.type in {"ESC", "RIGHTMOUSE"} and event.value == "PRESS": self._finish(context) return {"CANCELLED"} - if ( - 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: + if event.type == "MOUSEMOVE": self._path.append((event.mouse_region_x, event.mouse_region_y)) context.area.tag_redraw() return {"RUNNING_MODAL"} - if event.type == "LEFTMOUSE" and event.value == "RELEASE" and self._drawing: - self._drawing = False + if event.type == "LEFTMOUSE" and event.value == "RELEASE": path = simplify_screen_path(self._path) - self._path = [] if len(path) < 3: self.report({"WARNING"}, "Draw a larger lasso") - context.area.tag_redraw() - return {"RUNNING_MODAL"} + self._finish(context) + return {"CANCELLED"} source_object = bpy.data.objects.get(self.source_object_name) if not is_image_empty(source_object): @@ -436,21 +412,125 @@ class BalloonLasso(bpy.types.Operator): plane_bounds(source_object), tuple(source_object.data.size), ) - result = bpy.ops.snapform.extract_balloon( - "EXEC_DEFAULT", - 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.selection_data = json.dumps(polygon, separators=(",", ":")) + except (RuntimeError, TypeError, ValueError) as error: self.report({"ERROR"}, str(error)) - context.area.tag_redraw() - return {"RUNNING_MODAL"} + self._finish(context) + return {"CANCELLED"} + + self._finish(context) + return self.execute(context) return {"PASS_THROUGH"} + + def execute(self, context): + source_object = bpy.data.objects.get(self.source_object_name) + if not is_image_empty(source_object): + self.report({"ERROR"}, "The source Image Empty is no longer available") + return {"CANCELLED"} + try: + polygon = json.loads(self.selection_data) + 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") \ No newline at end of file diff --git a/src/snapform/operators/depth_plane/build.py b/src/snapform/operators/depth_plane/build.py index 77a5de5..103fdfe 100644 --- a/src/snapform/operators/depth_plane/build.py +++ b/src/snapform/operators/depth_plane/build.py @@ -6,6 +6,7 @@ from ..image import ( depth_data_name, image_user_settings, is_animated_image, + material_data_name, plane_bounds, sorted_frame_paths, ) @@ -298,7 +299,7 @@ def build_image_depth_plane( depth_scale = abs(float(plane_object.scale.z)) material = create_material( - source_name, + material_data_name(source_object), material_image, depth_scale, displacement_image=depth_source_image, @@ -401,7 +402,7 @@ def build_depth_plane( ) material = create_material( - source_name, + material_data_name(source_object), material_image, depth_scale, displacement_image=depth_image, diff --git a/src/snapform/operators/image.py b/src/snapform/operators/image.py index 1a058a7..100921e 100644 --- a/src/snapform/operators/image.py +++ b/src/snapform/operators/image.py @@ -159,6 +159,10 @@ def color_data_name(source_object): 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): """Return the scaled long edge clamped and aligned for DA3 inference.""" width, height = image.size diff --git a/src/snapform/operators/surface.py b/src/snapform/operators/surface.py index 66f5636..af4ddff 100644 --- a/src/snapform/operators/surface.py +++ b/src/snapform/operators/surface.py @@ -19,6 +19,7 @@ from .common import ( from .image import ( draw_process_resolution, is_image_empty, + material_data_name, normal_data_name, plane_bounds, prepare_image_input, @@ -464,7 +465,7 @@ def build_image_mesh( else None ) material = create_material( - source_name, + material_data_name(source_object), color_image, depth_scale, shadeless=shadeless, diff --git a/src/snapform/properties.py b/src/snapform/properties.py index bb79583..13693c4 100644 --- a/src/snapform/properties.py +++ b/src/snapform/properties.py @@ -172,7 +172,77 @@ def surface_model_enum_items(_owner, _context): 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): + 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( name="Image or Video", description="A single image or video file used for reconstruction", diff --git a/tests/test_addon.py b/tests/test_addon.py index 6a9dc39..839bc3a 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -27,6 +27,7 @@ class AddonTest(unittest.TestCase): BoolProperty=lambda **options: options, EnumProperty=lambda **options: options, FloatProperty=lambda **options: options, + FloatVectorProperty=lambda **options: options, IntProperty=lambda **options: options, PointerProperty=lambda **options: options, StringProperty=lambda **options: options, @@ -37,6 +38,7 @@ class AddonTest(unittest.TestCase): Operator=object, Panel=object, PropertyGroup=object, + WorkSpaceTool=object, Scene=type("Scene", (), {}), STATUSBAR_HT_header=SimpleNamespace( append=append_status_bar, @@ -52,12 +54,19 @@ class AddonTest(unittest.TestCase): ), ) cls.registered = [] + cls.registered_tools = [] cls.status_bar_draws = [] cls.context_menu_draws = [] cls.outliner_menu_draws = [] fake_bpy.utils = SimpleNamespace( register_class=cls.registered.append, 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( preferences=SimpleNamespace(addons={}), @@ -82,6 +91,9 @@ class AddonTest(unittest.TestCase): cls.depth_operator = importlib.import_module( "snapform.operators.depth_plane.operator" ) + cls.depth_build = importlib.import_module( + "snapform.operators.depth_plane.build" + ) cls.image_background = importlib.import_module( "snapform.operators.background" ) @@ -125,6 +137,7 @@ class AddonTest(unittest.TestCase): def tearDown(self): self.addon.runtime.close_active() self.registered.clear() + self.registered_tools.clear() self.status_bar_draws.clear() self.context_menu_draws.clear() self.outliner_menu_draws.clear() @@ -520,7 +533,7 @@ class AddonTest(unittest.TestCase): self.assertEqual( calls[0], - ("snapform.balloon_lasso", True), + ("snapform.activate_balloon_tool", True), ) self.assertIn( ("snapform.download_required_models", True), @@ -564,7 +577,7 @@ class AddonTest(unittest.TestCase): self.assertEqual( calls[0], - ("snapform.balloon_lasso", True), + ("snapform.activate_balloon_tool", True), ) self.assertIn(("snapform.install_environment", True), calls) self.assertIn(("snapform.install_environment_mirror", True), calls) @@ -779,79 +792,136 @@ class AddonTest(unittest.TestCase): self.assertTrue(edges) 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.BalloonLasso, self.addon.CLASSES) + self.assertIn(self.balloon.ActivateBalloonTool, self.addon.CLASSES) + self.assertNotIn(self.balloon.BalloonTool, self.addon.CLASSES) self.assertLess( 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 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(inflation, 0.9) + self.assertEqual( + self.balloon.meters_to_world_distance(scene, 0.1), + 10.0, + ) operator = self.balloon.ExtractBalloon - self.assertIn("REGISTER", operator.bl_options) + self.assertEqual(operator.bl_options, {"REGISTER", "UNDO"}) self.assertIn("mode", operator.__annotations__) self.assertIn("thickness", operator.__annotations__) self.assertIn("resolution", operator.__annotations__) + self.assertIn("reuse_material", operator.__annotations__) self.assertIn("source_matrix_data", operator.__annotations__) - resolution_source = inspect.getsource(self.balloon.resolution_property) - thickness_source = inspect.getsource(self.balloon.thickness_property) - self.assertIn('subtype="DISTANCE"', resolution_source) - self.assertIn('unit="LENGTH"', resolution_source) + settings = self.addon.properties.SnapFormSettings.__annotations__ + self.assertIn("balloon_mode", settings) + self.assertIn("balloon_thickness", settings) + 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.assertNotIn(" max=", thickness_source) - def test_balloon_lasso_passes_hud_and_navigation_events(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): + def test_balloon_workspace_tool_uses_one_top_level_operator_per_stroke(self): import inspect execute_source = inspect.getsource(self.balloon.ExtractBalloon.execute) - invoke_source = inspect.getsource(self.balloon.BalloonLasso.invoke) - modal_source = inspect.getsource(self.balloon.BalloonLasso.modal) + invoke_source = inspect.getsource(self.balloon.ExtractBalloon.invoke) + modal_source = inspect.getsource(self.balloon.ExtractBalloon.modal) self.assertIn("json.loads(self.selection_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("bpy.ops.snapform.extract_balloon(", modal_source) - self.assertIn('"EXEC_DEFAULT"', modal_source) - self.assertIn("keep_source_selected=True", modal_source) - self.assertIn('event.type == "ESC"', modal_source) + self.assertIn("settings.balloon_thickness", invoke_source) + self.assertIn("settings.balloon_resolution", invoke_source) + self.assertIn("settings.balloon_reuse_material", invoke_source) + self.assertIn("return self.execute(context)", modal_source) self.assertIn('return {"RUNNING_MODAL"}', modal_source) self.assertIn('return {"PASS_THROUGH"}', modal_source) - self.assertIn("event_over_interface_region(context, event)", modal_source) - self.assertNotIn("extract_balloon_object(", modal_source) + self.assertNotIn("bpy.ops.snapform.extract_balloon", 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): import json @@ -872,18 +942,81 @@ class AddonTest(unittest.TestCase): self.assertIn("selected_object = balloon if select_result else source_object", source) self.assertIn("balloon.matrix_world = matrix_world.copy()", 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.name = color_data_name(source_object)", source) - self.assertIn("balloon_name,\n color_image,\n 0.0,", source) + self.assertIn("color_image.name = color_name", source) + self.assertIn( + "material_name,\n color_image,\n 0.0,", + source, + ) self.assertIn( "surface_group_name=TEXTURE_MATERIAL_NODE_GROUP_NAME", source, ) + self.assertIn("owns_material", source) + self.assertIn("owns_color_image", source) self.assertEqual( self.image_depth.TEXTURE_MATERIAL_NODE_GROUP_NAME, "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): context = SimpleNamespace( object=SimpleNamespace(data=SimpleNamespace()), @@ -1017,11 +1150,23 @@ class AddonTest(unittest.TestCase): system_classes = self.addon.runtime.operator_classes() 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(self.status_bar_draws[0]._owner, "snapform") self.addon.unregister() self.assertEqual(self.registered, []) + self.assertEqual(self.registered_tools, []) self.assertEqual(self.status_bar_draws, []) def test_status_bar_reads_runtime_state(self): diff --git a/tests/test_image_depth.py b/tests/test_image_depth.py index 0afed99..e924108 100644 --- a/tests/test_image_depth.py +++ b/tests/test_image_depth.py @@ -717,7 +717,7 @@ class ImageDepthTest(unittest.TestCase): 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( data=SimpleNamespace(name="img.png"), ) @@ -726,11 +726,19 @@ class ImageDepthTest(unittest.TestCase): self.image_depth.color_data_name(source_object), "img.color", ) + self.assertEqual( + self.image_depth.material_data_name(source_object), + "img", + ) source_object.data.name = "img.png.001" self.assertEqual( self.image_depth.color_data_name(source_object), "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): class Material: