diff --git a/README.md b/README.md index d48c5bd..da309bc 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ ViT-S、ViT-B、ViT-L 或 DA3 Small、Base,其中 MoGe-2 ViT-B 为默认模型 `utils3d.build_mesh_from_map` 与深度断层 mask 生成干净拓扑;DA3 保留 confidence 与边拉伸剔除。两者再由独立的 Geometry Nodes 修改器完成剔面、快速重拓扑、Relax 与 UV 重投。开启 Remove Background 时,背景三角面会在转换期间 直接删除,不再交给 Geometry Nodes。匹配相机默认不创建;开启后会作为 Surface 的子级。 +MoGe-2 的像素级 normal 会作为 Object Space normal 贴图接入 `O MoGe2 Surface Layer`,并与原图 +Bump 合并;网格本身不写入 custom normal,因此重拓扑降低顶点数后仍能保留细腻的法线细节。 当前此功能仅支持单张图片。 转换对话框提供模型与 `Create Matching Camera`、`Remove Background`、`Unshaded`;DA3 显示 @@ -82,4 +84,4 @@ SnapForm 支持视频(mp4、mkv、mov 等)和序列帧,会逐帧处理并 ## 技术文档 -面向开发者的技术内容见 [docs/technical.md](docs/technical.md)。 +详细功能与技术内容见 [docs/index.md](docs/index.md)。 diff --git a/docs/depth-plane.md b/docs/depth-plane.md new file mode 100644 index 0000000..636179c --- /dev/null +++ b/docs/depth-plane.md @@ -0,0 +1,66 @@ +# Depth Plane + +## Image Empty 转深度面片 + +1. 在 3D View 选中 `Object Type = Empty`、`Display As = Image` 的对象。 +2. 右键打开 `SnapForm` 子菜单并选择 `Convert to Depth Plane`,通过 `Depth from` 选择深度来源: + - `DA3`:显示 Model、Use Ray Pose 和 Resolution Scale。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 进程内用 points 和官方 `utils3d.build_mesh_from_map` 直接生成 `mesh.npz`。Debug 始终保留原始 `results.npz`,并按 Output Format 单独生成 Maps、纹理 GLB 或彩色 PLY。 + - `Original`:直接把原图作为深度贴图,不显示估算参数。仅在开启 `Remove Background` 时运行 BEN2 + 取得前景 Alpha;视频或序列帧按需同时抽取原始帧与前景序列,把原始帧作为 `.depth` 序列。 + `Original` 是默认深度来源。选择 `DA3` 时,其 Model、Use Ray Pose 与 Resolution Scale + 参数会集中显示在独立边框中。`Remove Background` 与 `Unshaded` 直接显示,不再使用 Options 折叠; + `Keep Original` 默认关闭,并在分割线下方保持为最后一项。仅当开启 `Remove Background` 时才需要 + `BEN2 Base`,并把 `.foreground` 的 Alpha 作为独立 + Mask,不在 Service 中改写深度值。保存 `.foreground` 时保留 BEN2 Alpha,但 RGB 使用未经修改的原始输入; + 因此材质直接用 `.foreground` 同时提供颜色和 Mask,不再额外加载原图 Image Texture。 + Image Empty 引用视频或序列帧时,DA3 与 MoGe-2 会按 Empty 的 Image Frames 数值逐帧估计 + 深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致; + Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty + 一致;Original 模式的视频或序列帧使用 `frame.0001.jpg` 等原图序列作为颜色与深度, + `foreground.0001.png` 只提供前景 Alpha。 + DA3 的深度仍以 EXR 序列帧(`depth.0001.exr` 起)保存在 job 目录根下;MoGe-2 分别使用 `moge_depth`、`moge_mask` 与 `moge_normal` EXR 序列。 + `Unshaded`、`Remove Background` 与 `Keep Original` 均默认关闭,因此默认删除原对象。 +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 位移、mask 删点和材质设置;材质使用 `O MoGe2 Depth Plane Layer`,直接接入 + 模型 normal,不携带 DA3 的材质置换与背景深度分支。DA3 修改器公开以下参数: + - `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 与 Foreground Image 是修改器隐藏数据输入, + 由导入流程自动设置。 + 视频与序列帧还会为 Depth 与 Foreground 共同写入隐藏的 `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 一样随场景帧刷新。 + diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..3938931 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,42 @@ +# 开发与许可证 + +## 开发 + +```bash +uv run python prepare_vendor.py +uv run pytest tests -v +uv run python pack.py +``` + +项目结构: + +```text +. +├── vendor.lock.toml 第三方构建输入的版本、URL 与 SHA-256 +├── prepare_vendor.py 下载并校验第三方构建输入 +├── pack.py +├── patches/ 进入 Git 的 DA3 源码覆盖文件 +├── vendor/ 不进入 Git 的下载缓存;本地调试也直接读取这里 +├── src/ +│ └── snapform/ +│ ├── blender_manifest.toml +│ ├── service.py +│ ├── install_env.py +│ ├── models.py +│ ├── image_depth.py +│ ├── operators.py +│ ├── panel.py +│ ├── preferences.py +│ ├── properties.py +│ ├── client.py +│ └── worker.py +└── tests/ +``` + +## 上游与许可证 + +- Depth Anything 3 代码采用 Apache 2.0。 +- BEN2 代码与模型权重采用 MIT License。 +- 本 Extension 代码采用 GPL-3.0-or-later。 +- 模型权重许可证由所选 Hugging Face 模型仓库决定,Extension 下载前会在界面显示。 + diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..ebeea6d --- /dev/null +++ b/docs/features.md @@ -0,0 +1,102 @@ +# 功能与使用 + +本文档面向开发者和需要了解内部实现细节的用户,从原 README 迁移而来。普通用户请阅读项目根目录的 +`README.md`。 + +`SnapForm` 是一套从图片快速构建空间结构的 Blender Extension。目前以 +[Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3) +作为首个 AI 能力,在独立 Environment 中运行 DA3,可将官方导出结果保存在独立 job 目录, +也可把 Image Empty 转换成带几何与材质置换的立体面片。MoGe-2 作为独立深度来源,保留其 metric depth、normal、mask 与 intrinsics 输出,不复用 DA3 Prediction 后处理。Debug 面板还可运行 +[BEN2](https://github.com/PramaLLC/BEN2) 背景剔除功能测试。 + +## 当前功能 + +- 使用 Blender Extension manifest,可通过 `Install from Disk` 安装 +- 在独立 Environment 中通过常驻 Service 运行 Torch、DA3、MoGe-2 与 BEN2,避免污染 Blender Python 进程 +- Service 的深度模型缓存只保留 DA3 或 MoGe-2 其中一个,切换时主动释放前一个;BEN2 使用独立缓存, + 可与当前深度模型共存 +- Blender 使用内置 `urllib.request` 访问仅监听 `127.0.0.1` 的 Service;请求带随机 Token +- 常驻 Service 复用 Torch、CUDA、BEN2 与最近使用的 DA3 模型,连续执行时不再重复加载 +- 多个 Blender 实例自动连接同一个共享 Service;`AI Service` 面板显示状态、端口、客户端与模型缓存,并提供安装、启动、清理、重启和停止控制 +- Environment、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享 +- 当前启用 DA3 Small 和 Base;Large 1.1、Giant 1.1 与 Nested Giant Large 1.1 暂停使用 +- 支持 BEN2 Base 单图背景剔除测试,输出透明 PNG 与推理耗时 +- 支持单张 JPG、JPEG、PNG、WEBP 图片或常见视频文件输入 +- 视频按顺序截取前若干帧,可设置参与重建的最大帧数 +- `Models` panel 默认折叠,展开后可逐项查看和下载所有可用模型 +- Environment 已安装时不额外占用面板空间;模型列表每个模型只占一行 +- `Debug` 默认折叠,内部的 `DA3`、`MoGe-2` 与 `BEN2` 也分别默认折叠 +- 使用一个输出类型下拉菜单和一个 `Generate Output` 按钮 +- DA3 与 MoGe-2 Debug 都先选择单一 Output Format,只显示当前格式的专属参数;GLB 与 PLY 不会在一次测试中同时生成 +- Debug 的模型选项按格式过滤;PLY 只提供两个带 Gaussian head 的 Giant 模型 +- Debug 与 Convert to Depth Plane 的模型选项直接显示下载状态;未下载模型带 + `(Missing)`,已下载模型保持原名 +- 分别调用 DA3 的 `glb`、`gs_ply`、`depth_vis` 和 `mini_npz` exporter +- `Textured Surface (GLB)` 直接使用 DA3 Prediction 中的深度、置信度和相机参数,把相邻像素 + 组成三角面并写入 `mesh.glb`;每个输入帧生成一张独立纹理网格,不做多视图融合 +- Image Empty 右键菜单可直接运行 DA3,根据 `mini_npz` 深度生成面片 +- Image Empty 右键菜单可直接运行 BEN2,保留原图并换用新建的透明背景图片 +- Image Empty 支持视频与序列帧输入:`Convert to Depth Plane` 逐帧估计深度, + `Remove Background` 逐帧抠图,入口保持不变 +- 视频生成的序列帧以外部文件引用保留在 job 目录(Blender 无法整体 Pack 序列帧); + Geometry Nodes 与材质 Image Texture 均可按场景帧播放序列帧 +- 使用浮点深度贴图、`O DA3 Depth Plane` Geometry Nodes 和原图 Bump 构建立体面片 +- 转换对话框选择推理模式、处理分辨率与背景移除;细分和置换方式在 Geometry Nodes 修改器中调整 +- Debug 的 DA3 输出或 BEN2 背景剔除完成后只打开对应 job 文件夹,不修改当前场景 +- 完整保留每次任务生成的所有官方文件 +- 安装和模型下载使用后台子进程;推理提交给常驻 Service,状态栏实时显示阶段、百分比进度条与取消按钮 +- 后台任务运行时,Install、Download 和 Export 按钮保持原位置并变灰,任务完成后恢复 + +## 使用 + +1. 先运行 `uv run python prepare_vendor.py`,按 `vendor.lock.toml` 下载并校验固定版本的 + 第三方构建输入;再运行 `uv run python pack.py`,分别构建 `windows-x64`、`macos-arm64`、 + `linux-x64` 三个平台的 Extension ZIP(如 `SnapForm.v0.10.12.macos-arm64.zip`)。 + 每个包只含对应平台的 uv 二进制,并内置 DA3、BEN2 源码包。 +2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。 +3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能 + (Image Empty 右键菜单)使用的推理设备,默认取本机最快的 CUDA/MPS,否则 CPU,且只列出 + 本机支持的设备。 +4. 在 `AI Service` 点击 `Install Environment`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn; + DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 安装任务的临时目录中使用,安装结束后自动清理, + 并在 Environment 安装后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Environment 安装 + 固定显示 `Environment`、`PyTorch`、`AI components` 三个用户阶段;xFormers(macOS 跳过)、 + FastAPI 等内部安装步骤合并在 `AI components` 中。耗时最长的 PyTorch 与 AI 组件占主要进度 + 区间。安装完成后直接写入 Environment Manifest,不再额外导入全部组件进行验证。推荐模型属于安装 + 后的自动步骤,不计入阶段编号;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。 + 状态文字使用 `阶段/总数 阶段名 | 当前操作 | 预计剩余时间`,依赖安装会结合预设耗时与实际 + 进度修正预期;模型下载根据字节数和速度计算剩余时间,例如 + `Downloading DA3 Small | 14.2 MB/s | About 2 min left`。重试或切换镜像时进度不会倒退。 + Environment 安装后即使模型下载中断也不影响 Service 使用,可稍后在 `Models` 面板单独补下。 + 安装不做启动网络预检:pip 安装失败会自动依次重试清华、阿里镜像,模型下载由 worker + 自行探测回退到 HF 镜像;需要手动指定镜像时可通过 `PIP_INDEX_URL` 环境变量覆盖。 +5. 在 3D View 按 `N`,打开 `SnapForm` 页签。 +6. 如需其他模型,展开 `Models` panel,找到模型并点击该行的 `Download`。 +7. 展开默认收起的 `Debug`,选择 `Output Type` 和本次执行使用的模型。 +8. 选择一张图片或一个视频文件。视频可设置从头截取的帧数,然后点击 `Generate Output`。 +9. 测试背景剔除时,确认 `Models` panel 中的 `BEN2 Base` 已就绪,然后在 `Debug` 的 + `BEN2` 区域选择单张图片并点击 `Remove Background`。 + 完成后会打开 job 文件夹,其中的 `foreground.png` 带透明 Alpha;状态栏显示 + BEN2 推理、模型加载与任务总耗时。当前测试固定关闭较慢的 foreground refinement。 + +## Image Empty 去背景 + +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 使用原图去掉格式后再追加 `.foreground` 的名称。例如 `img.png` 生成 + `img.foreground`,`img.png.001` 生成 `img.001.foreground`。已有同名 datablock 时由 + Blender 自动追加编号。 + +原图已 Pack 时会先写出临时 PNG 交给 BEN2,任务结束后删除临时输入;原 Image 仍保持 Pack +状态和原始内容。BEN2 的 `foreground.png` 也会保留在对应 job 目录中。 + +Image Empty 引用视频或序列帧时,Remove Background 直接执行,默认取 Empty 的 Image Frames +数值(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset;若 Offset 加 Frames 超出视频长度, +会把窗口整体收进视频内(保证实际处理 Frames 帧数),Offset 过小时从第 1 帧开始。输出 +`foreground.0001.png` 等透明序列帧(帧号从 0001 开始,与 Blender 场景帧一一对应),并把 +源帧以 `frame.0001.jpg` 等命名复制到 job 目录根下供材质颜色导入。替换 Empty 的 Image 为 +加载这些帧的 SEQUENCE Image,从原 Empty 的 Start 位置开始播放(Start 原样保留), +Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因此保持外部引用 job 目录; +请保留对应 job 目录,否则序列会失效。 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..83d164a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,10 @@ +# SnapForm 文档 + +技术内容已按主题拆分: + +- [功能与使用](features.md):能力概览、安装使用和 Image Empty 去背景 +- [Depth Plane](depth-plane.md):深度面片的数据、节点与材质流程 +- [Surface](surface.md):透视网格、Geometry Nodes 后处理与材质法线 +- [输出格式](outputs.md):Debug 输出类型、参数和任务产物 +- [AI Service](service.md):Environment、模型缓存、任务目录与运行机制 +- [开发与许可证](development.md):开发验证、打包、上游版本与许可证 diff --git a/docs/outputs.md b/docs/outputs.md new file mode 100644 index 0000000..656e2ed --- /dev/null +++ b/docs/outputs.md @@ -0,0 +1,31 @@ +# 输出格式 + +## 输出格式说明 + +`Output Type` 提供以下结果: + +- Point Cloud (GLB):DA3 `glb` +- Gaussian Splats (PLY):DA3 `gs_ply`,同时传入 `infer_gs=True` +- Depth Preview (Images):DA3 `depth_vis` +- Depth Data (NPZ):DA3 `mini_npz` +- Textured Surface (GLB):SnapForm 根据 DA3 Prediction 构建并输出 `mesh.glb` + +Point Cloud 专属设置仅在对应类型下显示,`gs_views_interval` 仅用于 Gaussian Splats。 +Textured Surface 使用 0–100% 的 Confidence Threshold 删除置信度最低的一定比例: + +- `process_res=504` +- `process_res_method="upper_bound_resize"` +- `use_ray_pose=False` +- `ref_view_strategy="saddle_balanced"` +- `conf_thresh_percentile=40.0` +- `num_max_points=1_000_000` +- `show_cameras=True` +- `export_kwargs["gs_ply"]["gs_views_interval"]=1` + +DA3 官方 `glb` 是彩色点云与可选相机线框;`gs_ply` 是带 Gaussian 属性的 PLY; +`depth_vis` 是原图与着色深度图的并排预览;`mini_npz` 保存深度、置信度和可用的相机参数。 +Point Cloud GLB 任务会关闭其默认附带的深度预览,使五种输出产物保持分离。 + +官方格式与限制可参阅 +[DA3 Python API 文档](https://github.com/ByteDance-Seed/Depth-Anything-3/blob/main/docs/API.md)。 + diff --git a/docs/service.md b/docs/service.md new file mode 100644 index 0000000..6f43c28 --- /dev/null +++ b/docs/service.md @@ -0,0 +1,98 @@ +# AI Service + +## AI Service + +Environment 指磁盘上已安装的 Python、依赖及模型执行组件;Service 是由 Environment 启动、接收 Blender 任务的 +常驻 HTTP 进程。`manifest.json` 记录当前 Environment 的安装信息;Service 的 `/health` 返回进程状态和 +实例标识。项目发布前不维护安装清单或任务协议的兼容版本号。 + +Extension 本身不包含 Torch。`Install Environment` 使用 `uv` 创建独立 Python 3.12 +环境,并安装。机器上即使没有安装 `uv` 或 Python 也没关系:安装程序由 Blender +自带的 Python 启动,缺 `uv` 时会解包扩展内置的对应平台二进制,再用它下载 +Python 3.12 与全部依赖。`uv` 会缓存在扩展存储目录下,后续安装直接复用。 +安装不做网络预检,直接开始;环境变量仍可切换镜像与代理:`PIP_INDEX_URL`(PyPI 镜像)、 +`SNAPFORM_TORCH_INDEX`(PyTorch 索引)、`HF_ENDPOINT`(Hugging Face 镜像)、 +`HTTP(S)_PROXY`/`ALL_PROXY`(代理)。未指定 PyPI 镜像时, +pip 安装失败会自动依次重试镜像(清华、阿里云);模型下载不预探测,官方 Hugging Face +失败后自动回退 OmooLab R2(`models.omoolab.xyz`),并校验文件大小和 SHA-256。 +GitHub 不再参与安装:DA3、BEN2、uv 已内置,缺少内置文件会直接报错提示重新安装,不再下载兜底。 +单独下载模型时遵循同样的顺序:Hugging Face(可用 `HF_ENDPOINT` 指定其他入口)失败后自动切换到 +OmooLab R2,不再使用 ModelScope。 +Windows 上代理若只在系统设置里而未写入环境变量,安装程序会尝试自动从系统代理注入子进程。 + +- PyTorch 2.9.1 +- TorchVision 0.24.1 +- xFormers 0.0.33.post2(与 PyTorch 2.9.1 对应,仅 Windows/Linux;macOS 无 wheel 自动跳过) +- 固定到已验证 commit 的 Depth Anything 3 +- 固定到已验证 commit 的 BEN2 +- FastAPI 0.139.2 与 Uvicorn 0.51.0 +- DA3 与 BEN2 源码包随扩展打包,安装时复制到临时目录后由 uv 本地安装,完成后立即清理 + (无需访问 GitHub);SnapForm 直接调用 DA3 + 底层 export 接口,`gs_ply` 导出不会触发 `gs_video` 渲染,因此不需要 gsplat + 与 C++ 编译工具链 +- `vendor/` 是不进入 Git 的本地缓存;`vendor.lock.toml` 固定下载 URL、commit/version 与 + SHA-256,`patches/<包名>/` 进入 Git 并存放 SnapForm 对源码的覆盖文件(移除 xformers + 硬依赖、export 入口改懒加载、 + 射线位姿的 3×3 QR 分解在 MPS 上改走 CPU), + `pack.py` 打包时自动套入官方包,扩展 zip 内才是修补后的版本;更新上游只需替换官方包 + 并核对覆盖文件是否仍适用 +- `addict` 2.4.0(补充 DA3 上游包元数据暂未声明的运行依赖) + +安装 Environment 时会自动选择 PyTorch 后端:检测到 NVIDIA 驱动则安装 CUDA 12.8 版本,否则安装 +CPU 版;macOS 始终安装 CPU 版 PyTorch(MPS 包含其中)。CPU 可以用于验证,但实际推理通常很慢。 +Extension Preferences 的 `Device` 决定正式功能的推理设备,默认取本机最快的 CUDA/MPS,否则 +CPU;Debug 面板的 `Debug Device` 只影响 Debug 任务。两处设备选项都只列出本机支持的设备: +Windows 不显示 MPS,机器上没有可用 NVIDIA 驱动时不显示 CUDA。Environment 安装完成时会检测真实 +可用的设备并写入 `manifest.json`,设备列表以该检测结果为准。 +CUDA 12.8 的 PyTorch wheel 自带 CUDA 运行库,无需安装 CUDA Toolkit;只需要 +NVIDIA 驱动 ≥ 570(数据中心显卡可用更老驱动)。驱动不满足或没有 NVIDIA 显卡时, +设备会自动回退到 CPU(macOS 为 MPS),只有显式选择 CUDA 才会报错。 + +macOS(Apple Silicon)会自动从 PyPI 安装 CPU 版 PyTorch,推理设备默认使用 MPS +(Apple Metal)加速,也可以在设备选项里切换 CPU。PyTorch 自 2.3 起不再提供 Intel +Mac(x86_64)轮子,Intel Mac 暂不支持。DA3 射线位姿估计中的 3×3 QR 分解在 MPS +上尚未实现,SnapForm 的覆盖文件会单独把该分解放到 CPU 执行,其余推理仍走 MPS。 + +Extension 启用后只会查找已有的共享 Service,不会主动启动新进程。第一次执行 AI 操作或在侧边栏 +手动点击 `Start` 时才会启动 Service。Service 只绑定回环地址,并让操作系统分配动态端口;第一个实例把 +端口、进程和随机 Token 写入 `service.json`,其余 Blender 通过标准库 +`urllib.request` 连接同一个 Service。`service.lock` 保证同一 Storage Root 同时只有一个 Service,Token +则阻止同机其他进程直接调用接口。Service 全局同时只执行一个 AI 任务。 + +每个 Blender 会注册独立客户端并每 5 秒发送心跳。正常关闭 Blender 时先注销自身;如果当前 Service +就是由这个 Blender 启动的,会直接终止它,无需等待。异常退出的客户端会在 10 秒后过期;Service +在最后一个客户端离开后仍会保留模型缓存等待 20 秒再退出,作为 Service 由其他进程启动等场景的兜底。 +取消任务不会重启 Service,并会保留已经加载的模型; +如果取消发生在一次不可中断的 Torch 调用中,任务会在该调用返回后的最近检查点结束。 + +侧边栏 `AI Service` 面板以 `Running (Clients: n)` 表示空闲服务,以 `Running (Busy)` 表示正在执行任务, +并显示缓存模型和活动任务。Blender 完成结果导入时会立即释放当前任务对应的本地 Busy 快照,无需等待 +下一次 Service 轮询。空闲且只有当前 Blender 连接时,可以清理模型缓存、重启或停止 Service; +多客户端或任务执行期间不会允许破坏共享服务。 + +BEN2 在首次使用后保持加载;DA3 保留最近使用的一个模型。切换 DA3 模型时会释放旧模型;加载时如果 +显存不足,会先释放另一类缓存模型并重试。 + +Environment Manifest Revision 变化时,旧 Environment 需要重新执行 `Install Environment`。 +Service 的启动和崩溃日志写入 `~/.snapform/service.log`。 + +Environment、模型和任务结果默认保存在用户主目录的 `~/.snapform`: + +```text +~/.snapform/ +├── .venv/ +├── models/ +├── jobs/ +├── service.log +├── service.json # Service 运行时存在 +└── service.lock # Service 运行时持有 +``` + +`models/` 下每个模型一个文件夹,命名统一带前缀:DA3 为 `da3_*`(如 `da3_small`、`da3_base`), +BEN2 为 `ben2_*`(如 `ben2_base`)。旧版无前缀的 DA3 文件夹会在首次使用时自动改名为新名称。 + +不同 Blender 版本安装相同 Extension 后会共用这个目录。也可以在 Extension +Preferences 的 `Storage Root` 中修改共享位置。不要把模型写入 Extension 安装目录, +因为该目录可能只读,并且升级时会被替换。不要同时从多个 Blender 实例执行 +`Install Environment`;Service、任务队列和模型读取可以共享。 + diff --git a/docs/surface.md b/docs/surface.md new file mode 100644 index 0000000..3c40eb7 --- /dev/null +++ b/docs/surface.md @@ -0,0 +1,223 @@ +# Surface + +## Image Empty 转透视网格 + +1. 在 3D View 选中仅包含一帧的 Image Empty,右键打开 `SnapForm` 子菜单并选择 + `Convert to Surface`。该入口独立于 + `Convert to Depth Plane`,当前不处理视频或序列帧。 + 模型列表包含三个 MoGe-2 Normal 模型和两个 DA3 模型,默认使用 MoGe-2 ViT-B Normal。DA3 的 + Resolution Scale 与 Depth Plane 使用相同设计;MoGe-2 改为显示共享的 Resolution Level。 +2. Service 请求 DA3 的 `depth`、`conf` 与 `intrinsics`。仅在开启 `Remove Background` 时运行 BEN2 取得 + 前景 Alpha,并在输出前删除包含背景顶点的三角面;关闭时不加载 BEN2。Depth 不做 smoothing,Service + 反投影深度网格,建立源三角面,计算 confidence percentile 与 3×3 相对深度不连续度,并写入 + 基于原图像素坐标的源 `UVMap`。这些步骤只准备修改器的输入网格,不执行 Relax、拓扑修复、平滑、 + 小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Service 端源数据准备。 +3. Blender 把勾选 `Usage > Modifier` 的 `O DA3 Surface` 节点组直接作为 Geometry Nodes 修改器 + 添加到 Surface,不另设修改器名称。节点组内没有子节点组,依次完成: + - 修改器顶层依次显示 `Resolution`、`Relax Iterations`、`Smooth`;默认展开的 `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`。Service 由 `utils3d.np.build_mesh_from_map(..., tri=True)` + 生成有限深度范围内的紧凑三角网格,并保留模型 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` 的 `Resolution` 默认值均为 `0.1`,所有输入 socket 的 + Structure Type 均固定为 `Single`。 +5. Service 准备过程固定显示 6 个编号阶段:验证 DA3 数据、反投影、准备 DA3 与可选 BEN2 数据、构建原始 + 源三角面、源 UV/attributes、写出网格。状态栏显示 `Preparing surface data n/6: ...`。 +6. Service 在 job 根目录写出 `mesh.npz` 与 `texture.png`。NPZ 保存 camera-space `vertices`、 + `faces`、`uv`、`intrinsics`、`image_size` 与有效深度中位数 `reference_depth`。Blender 读取这些 + 数据后直接创建 Surface 与原图材质,不需要 Open3D 或 OpenCV 进入 Blender Python 环境。 + DA3 Surface 只保留 Geometry Nodes 后处理实际使用的 attributes:点域 `o_depth`、面域 + `o_face_confidence` 与面域 `o_depth_discontinuity`。Remove Background 已在 Service + 建面前直接删除背景三角面,不写入任何 background attribute。MoGe-2 的导入网格保留点域 `o_depth`, + 以及面域 `o_mask`、`o_depth_discontinuity`,不伪造 confidence、stretch 或 background 属性, + 也不写入 custom normal。完整分辨率的 `normal_map` 保存在 NPZ 中并转换为材质贴图。 + 所有 MoGe-2 Image datablock 都以源图片名而非对象名为基名:depth、normal 与 mask 分别使用 + `图片名.depth`、`图片名.normal`、`图片名.mask`,不附加 `.moge2`。 +7. 网格以 `reference_depth` 截面匹配 Image Empty 的显示矩形:该截面的投影四角与 Empty 的可见 + 边界重合,主点同时匹配 Empty Offset。网格对象直接沿用源 Empty 的 `matrix_world`,因此位置、旋转、 + 缩放及父级关系产生的世界变换保持一致。更近的顶点沿 Empty 局部 `+Z` 方向凸出,三角面正面朝向 + 观察相机,启用 Backface Culling 时可从正面看到。 +8. 转换对话框不再提供后处理参数,只保留模型与 Resolution Scale 设置、`Create Matching Camera`、 + `Remove Background`、`Unshaded`,以及分割线下方的 `Keep Original`。背景移除默认关闭;开启后 Service + 使用 BEN2,并在写出 Surface 前直接删除背景三角面,不再由 Geometry Nodes 的 Background 开关处理。 + 该预删除同时适用于 DA3 与 MoGe-2;因此 MoGe-2 转换后即使关闭 `Mask`,BEN2 已删除的背景也不会恢复。 + `Unshaded` 使用与 Depth Plane 相同的 Emission/Transparent 材质路径;普通材质的 + 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)。 + +Debug 的 Textured Surface GLB 会把 depth、confidence 与 confidence percentile 写入自定义顶点 +attributes。其他 DA3 Debug 三维输出会同时写出未压缩的 `prediction.attributes.npz`,保存完整 +depth、confidence、intrinsics、extrinsics 与 confidence percentile。当前 trimesh 的 glTF 点图元 +导出器只写 Position/Color,无法可靠把任意 attribute 嵌入点云 GLB,因此点云先以 companion NPZ +保留完整结构;后续若改为 SnapForm 自有 glTF writer,可再把这些字段直接嵌入点图元。 + +所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的进度条内部,取消按钮 +位于进度条右侧。状态字段支持阶段、当前操作与预计剩余秒数,界面统一组合为紧凑文案并保证 +进度单调递增。任务进程与 Service 请求在后台启动,按钮点击后立即进入进度状态。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` 仍用于 +确定原始相机面片,随后对象 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 仍使用这些参数处理后的深度。 +无论选择哪种置换方式,原始彩色贴图都会连接 +Principled BSDF 的 `Base Color`,并通过 +`Bump.Height → Bump.Normal → Principled.Normal` 提供表面细节。Bump 的基础 Distance 为 +`0.001`,会乘以面片 Transform 的统一缩放倍率。原图 Color 还会经过 `Float Curve` 连接到 +Principled BSDF 的 `Roughness`,曲线两端输出分别为 `0.5` 和 `0.1`;材质 IOR 保持 +Principled BSDF 默认值 `1.5`。 +开启 `Unshaded` 后,色彩贴图 Color 连接 Emission,二值 Alpha 作为 Mix Shader 系数,在 +Transparent BSDF 与 Emission 之间混合后连接 Material Output;不创建 Principled BSDF、Bump 或 +Float Curve。开启 `Remove Background` 时色彩贴图使用 BEN2 输出;关闭时使用原图。 + +这里的“原始距离”是 DA3 模型输出的尺度。普通 Small、Base、Large 和 Giant 模型的深度不应 +被解释为现实世界的米;需要现实尺度时应选择带 metric depth 的 +`DA3 Nested Giant Large 1.1`。此外,当前结果仍是固定 XY 平面上的深度位移面,不是使用 +相机内参逐像素反投影得到的完整 camera-space 网格;中位深度截面为 Geometry Nodes +深度面片提供正确的整体高宽尺度。 + +生成完成后会直接创建面片,不提供缓存重建入口或单独的导入参数。重新选择 +`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`、`frame.0001.jpg`(`.frame`)或 `foreground.0001.png`,并在每帧保留原始 +浮点深度;保存 `.blend` 后再次打开仍需对应 job 目录存在。 + +选择 PLY 后,模型菜单只会提供 `DA3 Giant 1.1` 和 +`DA3 Nested Giant Large 1.1`。Small、Base 和 Large 没有 Gaussian head,无法由 +DA3 生成 `gs_ply`。 + +每个任务在 `~/.snapform/jobs/` 下用独立文件夹包裹。文件夹统一采用 +`动作-对象_日期-时间-微秒`,每种任务都有唯一名称,例如 +`generate-depth-plane_20260807-153000-123456`。微秒用于避免同一秒启动的任务共用目录。 + +Blender 为 Packed Image 或序列帧生成的输入副本也归档到对应 job 根目录:单图命名为 +`input.png`,序列帧命名为 `frame.0001.*`。它们以前暂存在 `jobs/inputs/`,现在不再创建这个 +公共中转文件夹。普通磁盘文件和视频仍直接读取原文件,不额外复制。 + +日志、运行中的状态文件、归档输入与输出产物均直接放在 job 根目录。各类任务名称为: + +- `install-environment` +- `download-model` +- `export-reconstruction` +- `generate-depth-plane` +- `generate-image-mesh` +- `remove-background` +- `remove-image-background` +- `generate-original-depth-plane` + +DA3 官方 exporter 产生的 `gs_ply/`、`depth_vis/`、`exports/mini_npz/` 会在完成后自动平铺, +不会保留只含一层产物的子目录: + +```text +export-reconstruction_20260807-153000-123456/ +├── job.log +├── status.json +└── scene.glb + +export-reconstruction_20260807-153000-123456/ +├── job.log +├── status.json +└── *.ply + +export-reconstruction_20260807-153000-123456/ +├── job.log +├── status.json +└── *.jpg + +export-reconstruction_20260807-153000-123456/ +├── job.log +├── status.json +└── results.npz + +remove-background_20260807-153000-123456/ +├── job.log +├── status.json +├── foreground.png 单图结果 +├── foreground.0001.png… 视频/序列帧结果 +├── frame.0001.jpg… 视频/序列帧的源帧副本 +└── depth.0001.exr… AI 深度序列帧 +``` + +建议从 `DA3 Base` 开始。它使用 Apache 2.0 模型权重;`DA3 Large 1.1` 权重采用 +CC BY-NC 4.0,仅限非商业用途。 + diff --git a/docs/technical.md b/docs/technical.md deleted file mode 100644 index e0ffa9b..0000000 --- a/docs/technical.md +++ /dev/null @@ -1,541 +0,0 @@ -# SnapForm 技术文档 - -本文档面向开发者和需要了解内部实现细节的用户,从原 README 迁移而来。普通用户请阅读项目根目录的 -`README.md`。 - -`SnapForm` 是一套从图片快速构建空间结构的 Blender Extension。目前以 -[Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3) -作为首个 AI 能力,在独立 Environment 中运行 DA3,可将官方导出结果保存在独立 job 目录, -也可把 Image Empty 转换成带几何与材质置换的立体面片。MoGe-2 作为独立深度来源,保留其 metric depth、normal、mask 与 intrinsics 输出,不复用 DA3 Prediction 后处理。Debug 面板还可运行 -[BEN2](https://github.com/PramaLLC/BEN2) 背景剔除功能测试。 - -## 当前功能 - -- 使用 Blender Extension manifest,可通过 `Install from Disk` 安装 -- 在独立 Environment 中通过常驻 Service 运行 Torch、DA3、MoGe-2 与 BEN2,避免污染 Blender Python 进程 -- Blender 使用内置 `urllib.request` 访问仅监听 `127.0.0.1` 的 Service;请求带随机 Token -- 常驻 Service 复用 Torch、CUDA、BEN2 与最近使用的 DA3 模型,连续执行时不再重复加载 -- 多个 Blender 实例自动连接同一个共享 Service;`AI Service` 面板显示状态、端口、客户端与模型缓存,并提供安装、启动、清理、重启和停止控制 -- Environment、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享 -- 当前启用 DA3 Small 和 Base;Large 1.1、Giant 1.1 与 Nested Giant Large 1.1 暂停使用 -- 支持 BEN2 Base 单图背景剔除测试,输出透明 PNG 与推理耗时 -- 支持单张 JPG、JPEG、PNG、WEBP 图片或常见视频文件输入 -- 视频按顺序截取前若干帧,可设置参与重建的最大帧数 -- `Models` panel 默认折叠,展开后可逐项查看和下载所有可用模型 -- Environment 已安装时不额外占用面板空间;模型列表每个模型只占一行 -- `Debug` 默认折叠,内部的 `DA3 Output Test`、`MoGe-2 API Test` 与 `BEN2 Removal Test` 也分别默认折叠 -- 使用一个输出类型下拉菜单和一个 `Generate Output` 按钮 -- DA3 与 MoGe-2 Debug 都先选择单一 Output Format,只显示当前格式的专属参数;GLB 与 PLY 不会在一次测试中同时生成 -- Debug 的模型选项按格式过滤;PLY 只提供两个带 Gaussian head 的 Giant 模型 -- Debug 与 Convert to Depth Plane 的模型选项直接显示下载状态;未下载模型带 - `(Missing)`,已下载模型保持原名 -- 分别调用 DA3 的 `glb`、`gs_ply`、`depth_vis` 和 `mini_npz` exporter -- `Textured Surface (GLB)` 直接使用 DA3 Prediction 中的深度、置信度和相机参数,把相邻像素 - 组成三角面并写入 `mesh.glb`;每个输入帧生成一张独立纹理网格,不做多视图融合 -- Image Empty 右键菜单可直接运行 DA3,根据 `mini_npz` 深度生成面片 -- Image Empty 右键菜单可直接运行 BEN2,保留原图并换用新建的透明背景图片 -- Image Empty 支持视频与序列帧输入:`Convert to Depth Plane` 逐帧估计深度, - `Remove Background` 逐帧抠图,入口保持不变 -- 视频生成的序列帧以外部文件引用保留在 job 目录(Blender 无法整体 Pack 序列帧); - Geometry Nodes 与材质 Image Texture 均可按场景帧播放序列帧 -- 使用浮点深度贴图、`O DA3 Depth Plane` Geometry Nodes 和原图 Bump 构建立体面片 -- 转换对话框选择推理模式、处理分辨率与背景移除;细分和置换方式在 Geometry Nodes 修改器中调整 -- Debug 的 DA3 输出或 BEN2 背景剔除完成后只打开对应 job 文件夹,不修改当前场景 -- 完整保留每次任务生成的所有官方文件 -- 安装和模型下载使用后台子进程;推理提交给常驻 Service,状态栏实时显示阶段、百分比进度条与取消按钮 -- 后台任务运行时,Install、Download 和 Export 按钮保持原位置并变灰,任务完成后恢复 - -## 使用 - -1. 先运行 `uv run python prepare_vendor.py`,按 `vendor.lock.toml` 下载并校验固定版本的 - 第三方构建输入;再运行 `uv run python pack.py`,分别构建 `windows-x64`、`macos-arm64`、 - `linux-x64` 三个平台的 Extension ZIP(如 `SnapForm.v0.10.11.macos-arm64.zip`)。 - 每个包只含对应平台的 uv 二进制,并内置 DA3、BEN2 源码包。 -2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。 -3. 在 Extension Preferences 中确认 Storage Root 与 Device。Device 是正式功能 - (Image Empty 右键菜单)使用的推理设备,默认取本机最快的 CUDA/MPS,否则 CPU,且只列出 - 本机支持的设备。 -4. 在 `AI Service` 点击 `Install Environment`。首次安装会下载 Python、PyTorch、FastAPI、Uvicorn; - DA3、BEN2 源码包与 uv 已内置在扩展中,安装时从 安装任务的临时目录中使用,安装结束后自动清理, - 并在 Environment 安装后自动下载 `DA3 Small` 和 `BEN2 Base` 推荐模型。Environment 安装 - 固定显示 `Environment`、`PyTorch`、`AI components` 三个用户阶段;xFormers(macOS 跳过)、 - FastAPI 等内部安装步骤合并在 `AI components` 中。耗时最长的 PyTorch 与 AI 组件占主要进度 - 区间。安装完成后直接写入 Environment Manifest,不再额外导入全部组件进行验证。推荐模型属于安装 - 后的自动步骤,不计入阶段编号;`DA3 Base` 等其他模型可稍后在 `Models` 面板按需下载。 - 状态文字使用 `阶段/总数 阶段名 | 当前操作 | 预计剩余时间`,依赖安装会结合预设耗时与实际 - 进度修正预期;模型下载根据字节数和速度计算剩余时间,例如 - `Downloading DA3 Small | 14.2 MB/s | About 2 min left`。重试或切换镜像时进度不会倒退。 - Environment 安装后即使模型下载中断也不影响 Service 使用,可稍后在 `Models` 面板单独补下。 - 安装不做启动网络预检:pip 安装失败会自动依次重试清华、阿里镜像,模型下载由 worker - 自行探测回退到 HF 镜像;需要手动指定镜像时可通过 `PIP_INDEX_URL` 环境变量覆盖。 -5. 在 3D View 按 `N`,打开 `SnapForm` 页签。 -6. 如需其他模型,展开 `Models` panel,找到模型并点击该行的 `Download`。 -7. 展开默认收起的 `Debug`,选择 `Output Type` 和本次执行使用的模型。 -8. 选择一张图片或一个视频文件。视频可设置从头截取的帧数,然后点击 `Generate Output`。 -9. 测试背景剔除时,确认 `Models` panel 中的 `BEN2 Base` 已就绪,然后在 `Debug` 的 - `BEN2 Removal Test` 区域选择单张图片并点击 `Remove Background`。 - 完成后会打开 job 文件夹,其中的 `foreground.png` 带透明 Alpha;状态栏显示 - BEN2 推理、模型加载与任务总耗时。当前测试固定关闭较慢的 foreground refinement。 - -## Image Empty 去背景 - -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 使用原图去掉格式后再追加 `.foreground` 的名称。例如 `img.png` 生成 - `img.foreground`,`img.png.001` 生成 `img.001.foreground`。已有同名 datablock 时由 - Blender 自动追加编号。 - -原图已 Pack 时会先写出临时 PNG 交给 BEN2,任务结束后删除临时输入;原 Image 仍保持 Pack -状态和原始内容。BEN2 的 `foreground.png` 也会保留在对应 job 目录中。 - -Image Empty 引用视频或序列帧时,Remove Background 直接执行,默认取 Empty 的 Image Frames -数值(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset;若 Offset 加 Frames 超出视频长度, -会把窗口整体收进视频内(保证实际处理 Frames 帧数),Offset 过小时从第 1 帧开始。输出 -`foreground.0001.png` 等透明序列帧(帧号从 0001 开始,与 Blender 场景帧一一对应),并把 -源帧以 `frame.0001.jpg` 等命名复制到 job 目录根下供材质颜色导入。替换 Empty 的 Image 为 -加载这些帧的 SEQUENCE Image,从原 Empty 的 Start 位置开始播放(Start 原样保留), -Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因此保持外部引用 job 目录; -请保留对应 job 目录,否则序列会失效。 - -## Image Empty 转深度面片 - -1. 在 3D View 选中 `Object Type = Empty`、`Display As = Image` 的对象。 -2. 右键打开 `SnapForm` 子菜单并选择 `Convert to Depth Plane`,通过 `Depth from` 选择深度来源: - - `DA3`:显示 Model、Use Ray Pose 和 Resolution Scale。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 进程内用 points 和官方 `utils3d.build_mesh_from_map` 直接生成 `mesh.npz`。Debug 始终保留原始 `results.npz`,并按 Output Format 单独生成 Maps、纹理 GLB 或彩色 PLY。 - - `Original`:直接把原图作为深度贴图,不显示估算参数。仅在开启 `Remove Background` 时运行 BEN2 - 取得前景 Alpha;视频或序列帧按需同时抽取原始帧与前景序列,把原始帧作为 `.depth` 序列。 - `Original` 是默认深度来源。选择 `DA3` 时,其 Model、Use Ray Pose 与 Resolution Scale - 参数会集中显示在独立边框中。`Remove Background` 与 `Unshaded` 直接显示,不再使用 Options 折叠; - `Keep Original` 默认关闭,并在分割线下方保持为最后一项。仅当开启 `Remove Background` 时才需要 - `BEN2 Base`,并把 `.foreground` 的 Alpha 作为独立 - Mask,不在 Service 中改写深度值。保存 `.foreground` 时保留 BEN2 Alpha,但 RGB 使用未经修改的原始输入; - 因此材质直接用 `.foreground` 同时提供颜色和 Mask,不再额外加载原图 Image Texture。 - Image Empty 引用视频或序列帧时,DA3 与 MoGe-2 会按 Empty 的 Image Frames 数值逐帧估计 - 深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致; - Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty - 一致;Original 模式的视频或序列帧使用 `frame.0001.jpg` 等原图序列作为颜色与深度, - `foreground.0001.png` 只提供前景 Alpha。 - DA3 的深度仍以 EXR 序列帧(`depth.0001.exr` 起)保存在 job 目录根下;MoGe-2 分别使用 `moge_depth`、`moge_mask` 与 `moge_normal` EXR 序列。 - `Unshaded`、`Remove Background` 与 `Keep Original` 均默认关闭,因此默认删除原对象。 -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 位移、mask 删点和材质设置;材质使用 `O MoGe2 Depth Plane Layer`,直接接入 - 模型 normal,不携带 DA3 的材质置换与背景深度分支。DA3 修改器公开以下参数: - - `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 与 Foreground Image 是修改器隐藏数据输入, - 由导入流程自动设置。 - 视频与序列帧还会为 Depth 与 Foreground 共同写入隐藏的 `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 一样随场景帧刷新。 - -## Image Empty 转透视网格 - -1. 在 3D View 选中仅包含一帧的 Image Empty,右键打开 `SnapForm` 子菜单并选择 - `Convert to Surface`。该入口独立于 - `Convert to Depth Plane`,当前不处理视频或序列帧。 - 模型列表包含三个 MoGe-2 Normal 模型和两个 DA3 模型,默认使用 MoGe-2 ViT-B Normal。DA3 的 - Resolution Scale 与 Depth Plane 使用相同设计;MoGe-2 改为显示共享的 Resolution Level。 -2. Service 请求 DA3 的 `depth`、`conf` 与 `intrinsics`。仅在开启 `Remove Background` 时运行 BEN2 取得 - 前景 Alpha,并在输出前删除包含背景顶点的三角面;关闭时不加载 BEN2。Depth 不做 smoothing,Service - 反投影深度网格,建立源三角面,计算 confidence percentile 与 camera-space edge stretch,并写入 - 基于原图像素坐标的源 `UVMap`。这些步骤只准备修改器的输入网格,不执行 Relax、拓扑修复、平滑、 - 小岛清理或最终 UV 重投影。运行时显示的六个 `Preparing surface data` 步骤均指这段 Service 端源数据准备。 -3. Blender 把勾选 `Usage > Modifier` 的 `O DA3 Surface` 节点组直接作为 Geometry Nodes 修改器 - 添加到 Surface,不另设修改器名称。节点组内没有子节点组,依次完成: - - 修改器顶层依次显示 `Resolution`、`Relax Iterations`、`Smooth`;默认展开的 `Culling` Panel - 只包含 `Confidence` 与 `Stretch`。两个百分比输入都使用 `0–100` 的节点值,并在组内 - 转换为 `0–1` 阈值;`Stretch = 100%` 明确选择全部面,剔面后同时删除 - Face Count 为零的孤立顶点; - - 每轮 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`。Service 与官方 `infer.py` 一样,先把模型 mask 与 - `utils3d.np.depth_map_edge(depth, rtol=0.04)` 合并,再由 `utils3d.np.build_mesh_from_map(..., tri=True)` - 直接生成紧凑三角网格;Blender 不再从规则像素网格重新生面。节点组保留顶层 `Mask`、Resolution、 - Relax、Smooth、拓扑修复与 UV 重投影,不提供 Confidence、Stretch 或 Remesh 开关;Mask 的 Structure - Type 为 `Single`。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,并隐藏没有连线的接口,避免跨越整个节点图的输入连线。 -5. Service 准备过程固定显示 6 个编号阶段:验证 DA3 数据、反投影、准备 DA3 与可选 BEN2 数据、构建原始 - 源三角面、源 UV/attributes、写出网格。状态栏显示 `Preparing surface data n/6: ...`。 -6. Service 在 job 根目录写出 `mesh.npz` 与 `texture.png`。NPZ 保存 camera-space `vertices`、 - `faces`、`uv`、`intrinsics`、`image_size` 与有效深度中位数 `reference_depth`。Blender 读取这些 - 数据后直接创建 Surface 与原图材质,不需要 Open3D 或 OpenCV 进入 Blender Python 环境。 - Surface 保留下列结构化 attributes: - - 点域:`o_depth`、`o_confidence`、`o_confidence_percentile`、`o_background`; - - 面域:`o_face_confidence`、`o_background_face`;`o_edge_stretch` 位于边域。 - MoGe-2 的导入网格只保留点域 `o_depth` 与面域 `o_mask`,不伪造 confidence 或 background 属性。 -7. 网格以 `reference_depth` 截面匹配 Image Empty 的显示矩形:该截面的投影四角与 Empty 的可见 - 边界重合,主点同时匹配 Empty Offset。网格对象直接沿用源 Empty 的 `matrix_world`,因此位置、旋转、 - 缩放及父级关系产生的世界变换保持一致。更近的顶点沿 Empty 局部 `+Z` 方向凸出,三角面正面朝向 - 观察相机,启用 Backface Culling 时可从正面看到。 -8. 转换对话框不再提供后处理参数,只保留模型与 Resolution Scale 设置、`Create Matching Camera`、 - `Remove Background`、`Unshaded`,以及分割线下方的 `Keep Original`。背景移除默认关闭;开启后 Service - 使用 BEN2,并在写出 Surface 前直接删除背景三角面,不再由 Geometry Nodes 的 Background 开关处理。 - `Unshaded` 使用与 Depth Plane 相同的 Emission/Transparent 材质路径;普通材质的 - 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)。 - -Debug 的 Textured Surface GLB 会把 depth、confidence 与 confidence percentile 写入自定义顶点 -attributes。其他 DA3 Debug 三维输出会同时写出未压缩的 `prediction.attributes.npz`,保存完整 -depth、confidence、intrinsics、extrinsics 与 confidence percentile。当前 trimesh 的 glTF 点图元 -导出器只写 Position/Color,无法可靠把任意 attribute 嵌入点云 GLB,因此点云先以 companion NPZ -保留完整结构;后续若改为 SnapForm 自有 glTF writer,可再把这些字段直接嵌入点图元。 - -所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的进度条内部,取消按钮 -位于进度条右侧。状态字段支持阶段、当前操作与预计剩余秒数,界面统一组合为紧凑文案并保证 -进度单调递增。任务进程与 Service 请求在后台启动,按钮点击后立即进入进度状态。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` 仍用于 -确定原始相机面片,随后对象 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`; -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 仍使用这些参数处理后的深度。 -无论选择哪种置换方式,原始彩色贴图都会连接 -Principled BSDF 的 `Base Color`,并通过 -`Bump.Height → Bump.Normal → Principled.Normal` 提供表面细节。Bump 的基础 Distance 为 -`0.001`,会乘以面片 Transform 的统一缩放倍率。原图 Color 还会经过 `Float Curve` 连接到 -Principled BSDF 的 `Roughness`,曲线两端输出分别为 `0.5` 和 `0.1`;材质 IOR 保持 -Principled BSDF 默认值 `1.5`。 -开启 `Unshaded` 后,色彩贴图 Color 连接 Emission,二值 Alpha 作为 Mix Shader 系数,在 -Transparent BSDF 与 Emission 之间混合后连接 Material Output;不创建 Principled BSDF、Bump 或 -Float Curve。开启 `Remove Background` 时色彩贴图使用 BEN2 输出;关闭时使用原图。 - -这里的“原始距离”是 DA3 模型输出的尺度。普通 Small、Base、Large 和 Giant 模型的深度不应 -被解释为现实世界的米;需要现实尺度时应选择带 metric depth 的 -`DA3 Nested Giant Large 1.1`。此外,当前结果仍是固定 XY 平面上的深度位移面,不是使用 -相机内参逐像素反投影得到的完整 camera-space 网格;中位深度截面为 Geometry Nodes -深度面片提供正确的整体高宽尺度。 - -生成完成后会直接创建面片,不提供缓存重建入口或单独的导入参数。重新选择 -`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`、`frame.0001.jpg`(`.frame`)或 `foreground.0001.png`,并在每帧保留原始 -浮点深度;保存 `.blend` 后再次打开仍需对应 job 目录存在。 - -选择 PLY 后,模型菜单只会提供 `DA3 Giant 1.1` 和 -`DA3 Nested Giant Large 1.1`。Small、Base 和 Large 没有 Gaussian head,无法由 -DA3 生成 `gs_ply`。 - -每个任务在 `~/.snapform/jobs/` 下用独立文件夹包裹。文件夹统一采用 -`动作-对象_日期-时间-微秒`,每种任务都有唯一名称,例如 -`generate-depth-plane_20260807-153000-123456`。微秒用于避免同一秒启动的任务共用目录。 - -Blender 为 Packed Image 或序列帧生成的输入副本也归档到对应 job 根目录:单图命名为 -`input.png`,序列帧命名为 `frame.0001.*`。它们以前暂存在 `jobs/inputs/`,现在不再创建这个 -公共中转文件夹。普通磁盘文件和视频仍直接读取原文件,不额外复制。 - -日志、运行中的状态文件、归档输入与输出产物均直接放在 job 根目录。各类任务名称为: - -- `install-environment` -- `download-model` -- `export-reconstruction` -- `generate-depth-plane` -- `generate-image-mesh` -- `remove-background` -- `remove-image-background` -- `generate-original-depth-plane` - -DA3 官方 exporter 产生的 `gs_ply/`、`depth_vis/`、`exports/mini_npz/` 会在完成后自动平铺, -不会保留只含一层产物的子目录: - -```text -export-reconstruction_20260807-153000-123456/ -├── job.log -├── status.json -└── scene.glb - -export-reconstruction_20260807-153000-123456/ -├── job.log -├── status.json -└── *.ply - -export-reconstruction_20260807-153000-123456/ -├── job.log -├── status.json -└── *.jpg - -export-reconstruction_20260807-153000-123456/ -├── job.log -├── status.json -└── results.npz - -remove-background_20260807-153000-123456/ -├── job.log -├── status.json -├── foreground.png 单图结果 -├── foreground.0001.png… 视频/序列帧结果 -├── frame.0001.jpg… 视频/序列帧的源帧副本 -└── depth.0001.exr… AI 深度序列帧 -``` - -建议从 `DA3 Base` 开始。它使用 Apache 2.0 模型权重;`DA3 Large 1.1` 权重采用 -CC BY-NC 4.0,仅限非商业用途。 - -## 输出格式说明 - -`Output Type` 提供以下结果: - -- Point Cloud (GLB):DA3 `glb` -- Gaussian Splats (PLY):DA3 `gs_ply`,同时传入 `infer_gs=True` -- Depth Preview (Images):DA3 `depth_vis` -- Depth Data (NPZ):DA3 `mini_npz` -- Textured Surface (GLB):SnapForm 根据 DA3 Prediction 构建并输出 `mesh.glb` - -Point Cloud 专属设置仅在对应类型下显示,`gs_views_interval` 仅用于 Gaussian Splats。 -Textured Surface 使用 0–100% 的 Confidence Threshold 删除置信度最低的一定比例: - -- `process_res=504` -- `process_res_method="upper_bound_resize"` -- `use_ray_pose=False` -- `ref_view_strategy="saddle_balanced"` -- `conf_thresh_percentile=40.0` -- `num_max_points=1_000_000` -- `show_cameras=True` -- `export_kwargs["gs_ply"]["gs_views_interval"]=1` - -DA3 官方 `glb` 是彩色点云与可选相机线框;`gs_ply` 是带 Gaussian 属性的 PLY; -`depth_vis` 是原图与着色深度图的并排预览;`mini_npz` 保存深度、置信度和可用的相机参数。 -Point Cloud GLB 任务会关闭其默认附带的深度预览,使五种输出产物保持分离。 - -官方格式与限制可参阅 -[DA3 Python API 文档](https://github.com/ByteDance-Seed/Depth-Anything-3/blob/main/docs/API.md)。 - -## AI Service - -Environment 指磁盘上已安装的 Python、依赖及模型执行组件;Service 是由 Environment 启动、接收 Blender 任务的 -常驻 HTTP 进程。`manifest.json` 记录当前 Environment 的安装信息;Service 的 `/health` 返回进程状态和 -实例标识。项目发布前不维护安装清单或任务协议的兼容版本号。 - -Extension 本身不包含 Torch。`Install Environment` 使用 `uv` 创建独立 Python 3.12 -环境,并安装。机器上即使没有安装 `uv` 或 Python 也没关系:安装程序由 Blender -自带的 Python 启动,缺 `uv` 时会解包扩展内置的对应平台二进制,再用它下载 -Python 3.12 与全部依赖。`uv` 会缓存在扩展存储目录下,后续安装直接复用。 -安装不做网络预检,直接开始;环境变量仍可切换镜像与代理:`PIP_INDEX_URL`(PyPI 镜像)、 -`SNAPFORM_TORCH_INDEX`(PyTorch 索引)、`HF_ENDPOINT`(Hugging Face 镜像)、 -`HTTP(S)_PROXY`/`ALL_PROXY`(代理)。未指定 PyPI 镜像时, -pip 安装失败会自动依次重试镜像(清华、阿里云);模型下载不预探测,官方 Hugging Face -失败后自动回退 OmooLab R2(`models.omoolab.xyz`),并校验文件大小和 SHA-256。 -GitHub 不再参与安装:DA3、BEN2、uv 已内置,缺少内置文件会直接报错提示重新安装,不再下载兜底。 -单独下载模型时遵循同样的顺序:Hugging Face(可用 `HF_ENDPOINT` 指定其他入口)失败后自动切换到 -OmooLab R2,不再使用 ModelScope。 -Windows 上代理若只在系统设置里而未写入环境变量,安装程序会尝试自动从系统代理注入子进程。 - -- PyTorch 2.9.1 -- TorchVision 0.24.1 -- xFormers 0.0.33.post2(与 PyTorch 2.9.1 对应,仅 Windows/Linux;macOS 无 wheel 自动跳过) -- 固定到已验证 commit 的 Depth Anything 3 -- 固定到已验证 commit 的 BEN2 -- FastAPI 0.139.2 与 Uvicorn 0.51.0 -- DA3 与 BEN2 源码包随扩展打包,安装时复制到临时目录后由 uv 本地安装,完成后立即清理 - (无需访问 GitHub);SnapForm 直接调用 DA3 - 底层 export 接口,`gs_ply` 导出不会触发 `gs_video` 渲染,因此不需要 gsplat - 与 C++ 编译工具链 -- `vendor/` 是不进入 Git 的本地缓存;`vendor.lock.toml` 固定下载 URL、commit/version 与 - SHA-256,`patches/<包名>/` 进入 Git 并存放 SnapForm 对源码的覆盖文件(移除 xformers - 硬依赖、export 入口改懒加载、 - 射线位姿的 3×3 QR 分解在 MPS 上改走 CPU), - `pack.py` 打包时自动套入官方包,扩展 zip 内才是修补后的版本;更新上游只需替换官方包 - 并核对覆盖文件是否仍适用 -- `addict` 2.4.0(补充 DA3 上游包元数据暂未声明的运行依赖) - -安装 Environment 时会自动选择 PyTorch 后端:检测到 NVIDIA 驱动则安装 CUDA 12.8 版本,否则安装 -CPU 版;macOS 始终安装 CPU 版 PyTorch(MPS 包含其中)。CPU 可以用于验证,但实际推理通常很慢。 -Extension Preferences 的 `Device` 决定正式功能的推理设备,默认取本机最快的 CUDA/MPS,否则 -CPU;Debug 面板的 `Debug Device` 只影响 Debug 任务。两处设备选项都只列出本机支持的设备: -Windows 不显示 MPS,机器上没有可用 NVIDIA 驱动时不显示 CUDA。Environment 安装完成时会检测真实 -可用的设备并写入 `manifest.json`,设备列表以该检测结果为准。 -CUDA 12.8 的 PyTorch wheel 自带 CUDA 运行库,无需安装 CUDA Toolkit;只需要 -NVIDIA 驱动 ≥ 570(数据中心显卡可用更老驱动)。驱动不满足或没有 NVIDIA 显卡时, -设备会自动回退到 CPU(macOS 为 MPS),只有显式选择 CUDA 才会报错。 - -macOS(Apple Silicon)会自动从 PyPI 安装 CPU 版 PyTorch,推理设备默认使用 MPS -(Apple Metal)加速,也可以在设备选项里切换 CPU。PyTorch 自 2.3 起不再提供 Intel -Mac(x86_64)轮子,Intel Mac 暂不支持。DA3 射线位姿估计中的 3×3 QR 分解在 MPS -上尚未实现,SnapForm 的覆盖文件会单独把该分解放到 CPU 执行,其余推理仍走 MPS。 - -Extension 启用后只会查找已有的共享 Service,不会主动启动新进程。第一次执行 AI 操作或在侧边栏 -手动点击 `Start` 时才会启动 Service。Service 只绑定回环地址,并让操作系统分配动态端口;第一个实例把 -端口、进程和随机 Token 写入 `service.json`,其余 Blender 通过标准库 -`urllib.request` 连接同一个 Service。`service.lock` 保证同一 Storage Root 同时只有一个 Service,Token -则阻止同机其他进程直接调用接口。Service 全局同时只执行一个 AI 任务。 - -每个 Blender 会注册独立客户端并每 5 秒发送心跳。正常关闭 Blender 时先注销自身;如果当前 Service -就是由这个 Blender 启动的,会直接终止它,无需等待。异常退出的客户端会在 10 秒后过期;Service -在最后一个客户端离开后仍会保留模型缓存等待 20 秒再退出,作为 Service 由其他进程启动等场景的兜底。 -取消任务不会重启 Service,并会保留已经加载的模型; -如果取消发生在一次不可中断的 Torch 调用中,任务会在该调用返回后的最近检查点结束。 - -侧边栏 `AI Service` 面板以 `Running (Clients: n)` 表示空闲服务,以 `Running (Busy)` 表示正在执行任务, -并显示缓存模型和活动任务。Blender 完成结果导入时会立即释放当前任务对应的本地 Busy 快照,无需等待 -下一次 Service 轮询。空闲且只有当前 Blender 连接时,可以清理模型缓存、重启或停止 Service; -多客户端或任务执行期间不会允许破坏共享服务。 - -BEN2 在首次使用后保持加载;DA3 保留最近使用的一个模型。切换 DA3 模型时会释放旧模型;加载时如果 -显存不足,会先释放另一类缓存模型并重试。 - -Environment Manifest Revision 变化时,旧 Environment 需要重新执行 `Install Environment`。 -Service 的启动和崩溃日志写入 `~/.snapform/service.log`。 - -Environment、模型和任务结果默认保存在用户主目录的 `~/.snapform`: - -```text -~/.snapform/ -├── .venv/ -├── models/ -├── jobs/ -├── service.log -├── service.json # Service 运行时存在 -└── service.lock # Service 运行时持有 -``` - -`models/` 下每个模型一个文件夹,命名统一带前缀:DA3 为 `da3_*`(如 `da3_small`、`da3_base`), -BEN2 为 `ben2_*`(如 `ben2_base`)。旧版无前缀的 DA3 文件夹会在首次使用时自动改名为新名称。 - -不同 Blender 版本安装相同 Extension 后会共用这个目录。也可以在 Extension -Preferences 的 `Storage Root` 中修改共享位置。不要把模型写入 Extension 安装目录, -因为该目录可能只读,并且升级时会被替换。不要同时从多个 Blender 实例执行 -`Install Environment`;Service、任务队列和模型读取可以共享。 - -## 开发 - -```bash -uv run python prepare_vendor.py -uv run pytest tests -v -uv run python pack.py -``` - -项目结构: - -```text -. -├── vendor.lock.toml 第三方构建输入的版本、URL 与 SHA-256 -├── prepare_vendor.py 下载并校验第三方构建输入 -├── pack.py -├── patches/ 进入 Git 的 DA3 源码覆盖文件 -├── vendor/ 不进入 Git 的下载缓存;本地调试也直接读取这里 -├── src/ -│ └── snapform/ -│ ├── blender_manifest.toml -│ ├── service.py -│ ├── install_env.py -│ ├── models.py -│ ├── image_depth.py -│ ├── operators.py -│ ├── panel.py -│ ├── preferences.py -│ ├── properties.py -│ ├── client.py -│ └── worker.py -└── tests/ -``` - -## 上游与许可证 - -- Depth Anything 3 代码采用 Apache 2.0。 -- BEN2 代码与模型权重采用 MIT License。 -- 本 Extension 代码采用 GPL-3.0-or-later。 -- 模型权重许可证由所选 Hugging Face 模型仓库决定,Extension 下载前会在界面显示。 diff --git a/pack.py b/pack.py index 588f608..bf996d9 100644 --- a/pack.py +++ b/pack.py @@ -90,7 +90,8 @@ def _apply_tar_overlay(archive_path, overlay_dir): info = tarfile.TarInfo(member.name) info.size = overlay_file.stat().st_size info.mode = member.mode - output.addfile(info, overlay_file.open("rb")) + with overlay_file.open("rb") as overlay_stream: + output.addfile(info, overlay_stream) else: with source.extractfile(member) as extracted: output.addfile(member, extracted) diff --git a/pyproject.toml b/pyproject.toml index cdbcc6a..82eb642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "snapform" -version = "0.10.11" +version = "0.10.12" description = "Fast image-guided structure building tools for Blender" readme = "README.md" requires-python = ">=3.11" diff --git a/src/snapform/assets/O_SnapForm.blend b/src/snapform/assets/O_SnapForm.blend index 2003356..d5c45b9 100644 --- a/src/snapform/assets/O_SnapForm.blend +++ b/src/snapform/assets/O_SnapForm.blend @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7ce30daf46cec0d4d6286cb2913b05a9a9f00a3fd9fe83de6f9e494e511ad8da -size 1314790 +oid sha256:2ed29453e0bed988cccf001eddc8d566eea521c3fb0131f050fdcb7cf608c8e4 +size 1337030 diff --git a/src/snapform/blender_manifest.toml b/src/snapform/blender_manifest.toml index 7cf6f8a..081f2d4 100644 --- a/src/snapform/blender_manifest.toml +++ b/src/snapform/blender_manifest.toml @@ -1,7 +1,7 @@ schema_version = "1.0.0" id = "snapform" -version = "0.10.11" +version = "0.10.12" name = "SnapForm" tagline = "Build spatial structures from images, fast" maintainer = "OmooLab" diff --git a/src/snapform/image_depth.py b/src/snapform/image_depth.py index fd45373..d12ce6d 100644 --- a/src/snapform/image_depth.py +++ b/src/snapform/image_depth.py @@ -167,6 +167,14 @@ def depth_data_name(source_object): return f"{image_base_name(source_object.data)}.depth" +def normal_data_name(source_object): + return f"{image_base_name(source_object.data)}.normal" + + +def mask_data_name(source_object): + return f"{image_base_name(source_object.data)}.mask" + + def copy_depth_image(source_object): """Return a renamed copy of the source image for use as the depth map.""" depth_image = source_object.data.copy() @@ -818,11 +826,12 @@ def create_moge_mask_image(name, mask_values): return image -def create_moge_normal_image(name, normal_values): +def create_moge_normal_image(name, normal_values, convert_coordinates=True): import numpy as np converted = normal_values.astype(np.float32, copy=True) - converted *= np.array([1.0, -1.0, -1.0], dtype=np.float32) + if convert_coordinates: + converted *= np.array([1.0, -1.0, -1.0], dtype=np.float32) converted = np.clip(converted * 0.5 + 0.5, 0.0, 1.0) height, width = converted.shape[:2] image = bpy.data.images.new( @@ -1144,6 +1153,9 @@ def material_node_group(depth_plane=False, surface_group_name=None): ("Alpha", "NodeSocketFloat"), ("Bump Scale", "NodeSocketFloat"), ] + if group_name == MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME: + input_sockets.insert(2, ("Normal", "NodeSocketColor")) + input_sockets.insert(3, ("Normal Strength", "NodeSocketFloat")) if depth_plane: input_sockets.insert(2, ("Depth", "NodeSocketFloat")) for name, socket_type in input_sockets: @@ -1161,6 +1173,10 @@ def material_node_group(depth_plane=False, surface_group_name=None): socket.default_value = 1.0 socket.min_value = -10000.0 socket.max_value = 10000.0 + elif name == "Normal Strength": + socket.default_value = 1.0 + socket.min_value = 0.0 + socket.max_value = 10.0 output_sockets = [ ("Color", "NodeSocketColor"), ("Roughness", "NodeSocketFloat"), @@ -1191,6 +1207,16 @@ def material_node_group(depth_plane=False, surface_group_name=None): roughness_curve.location = (-250.0, 350.0) bump = nodes.new("ShaderNodeBump") bump.location = (-250.0, 0.0) + if group_name == MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME: + normal_map = nodes.new("ShaderNodeNormalMap") + normal_map.space = "OBJECT" + normal_map.location = (-500.0, -150.0) + links.new(group_input.outputs["Normal"], normal_map.inputs["Color"]) + links.new( + group_input.outputs["Normal Strength"], + normal_map.inputs["Strength"], + ) + links.new(normal_map.outputs["Normal"], bump.inputs["Normal"]) displacement = None processed_alpha = group_input.outputs["Alpha"] @@ -1295,6 +1321,7 @@ def create_material( depth_frame_duration=None, use_cyclic=None, surface_group_name=None, + normal_image=None, ): material = bpy.data.materials.new(name) material.use_nodes = True @@ -1359,6 +1386,14 @@ def create_material( material_inputs.inputs["Bump Scale"].default_value = depth_scale links.new(color_texture.outputs["Color"], material_inputs.inputs["Color"]) links.new(alpha, material_inputs.inputs["Alpha"]) + if normal_image is not None and "Normal" in material_inputs.inputs: + normal_texture = nodes.new("ShaderNodeTexImage") + normal_texture.image = normal_image + normal_texture.interpolation = "Linear" + normal_texture.extension = "EXTEND" + normal_texture.location = (-1000.0, -50.0) + links.new(uv_map.outputs["UV"], normal_texture.inputs["Vector"]) + links.new(normal_texture.outputs["Color"], material_inputs.inputs["Normal"]) if unshaded: emission = nodes.new("ShaderNodeEmission") transparent = nodes.new("ShaderNodeBsdfTransparent") @@ -1923,13 +1958,13 @@ def build_moge2_depth_plane( depth_scale = apply_plane_transform(plane_object, source_object, bounds) apply_object_scale_to_mesh(plane_object) depth_image = create_depth_image( - f"{depth_data_name(source_object)}.moge2", + depth_data_name(source_object), displacement[0] * depth_scale, ) if len(depth) > 1 and data_frames_directory is not None: bpy.data.images.remove(depth_image, do_unlink=True) depth_image = create_moge_sequence_image( - f"{depth_data_name(source_object)}.moge2", + depth_data_name(source_object), [frame * depth_scale for frame in displacement], context.scene, data_frames_directory, @@ -2201,7 +2236,7 @@ class ConvertImageToDepthPlane(bpy.types.Operator): default=False, ) snapform_model: bpy.props.EnumProperty( - name="Model", + name="Depth From", description="Model or original image used to build depth", items=image_depth_model_items, default=1, diff --git a/src/snapform/image_mesh.py b/src/snapform/image_mesh.py index 8eec143..ae53e6b 100644 --- a/src/snapform/image_mesh.py +++ b/src/snapform/image_mesh.py @@ -7,6 +7,7 @@ from .image_depth import ( create_material, draw_process_resolution, is_image_empty, + normal_data_name, plane_bounds, prepare_image_input, process_resolution_scale_property, @@ -18,22 +19,16 @@ from .image_depth import ( POINT_ATTRIBUTES = { "o_depth": ("FLOAT", "point_depth"), - "o_confidence": ("FLOAT", "point_confidence"), - "o_confidence_percentile": ( - "FLOAT", - "point_confidence_percentile", - ), - "o_background": ("BOOLEAN", "point_background"), } FACE_ATTRIBUTES = { "o_face_confidence": ( "FLOAT", "face_confidence_percentile", ), - "o_background_face": ("BOOLEAN", "face_background"), } OPTIONAL_FACE_ATTRIBUTES = { "o_mask": ("BOOLEAN", "face_mask"), + "o_depth_discontinuity": ("FLOAT", "face_depth_discontinuity"), } NODE_GROUP_NAME = "O DA3 Surface" MOGE_NODE_GROUP_NAME = "O MoGe2 Surface" @@ -61,10 +56,10 @@ def load_image_mesh_data(npz_path, moge2=False): "point_depth", ] if moge2: - required.append("face_mask") + required.extend(("face_mask", "face_depth_discontinuity", "normal_map")) else: required.extend( - ("point_confidence", "point_confidence_percentile", "point_background", "face_confidence_percentile", "face_background") + ("face_confidence_percentile", "face_depth_discontinuity") ) missing = [key for key in required if key not in archive.files] if missing: @@ -93,9 +88,9 @@ def load_image_mesh_data(npz_path, moge2=False): for _name, (_data_type, key) in OPTIONAL_FACE_ATTRIBUTES.items(): if key in archive.files: attributes[key] = np.asarray(archive[key]) - if "vertex_normal" in archive.files: - attributes["vertex_normal"] = np.asarray( - archive["vertex_normal"], + if "normal_map" in archive.files: + attributes["normal_map"] = np.asarray( + archive["normal_map"], dtype=np.float32, ) except OSError as error: @@ -112,6 +107,14 @@ def load_image_mesh_data(npz_path, moge2=False): raise ValueError(f"Unsupported image size: {image_size}") if not reference_depth > 0.0: raise ValueError("Surface reference depth must be positive") + if moge2 and attributes["normal_map"].shape != ( + image_size[1], + image_size[0], + 3, + ): + raise ValueError("Surface normal map and image sizes do not match") + if moge2 and not np.isfinite(attributes["normal_map"]).all(): + raise ValueError("Surface normal map contains invalid values") for _name, (_data_type, key) in POINT_ATTRIBUTES.items(): if key in attributes and len(attributes[key]) != len(vertices): raise ValueError(f"Point attribute has the wrong length: {key}") @@ -208,6 +211,13 @@ def assign_snapform_attributes(mesh, attributes, moge2=False): if moge2: assign_mesh_attribute(mesh, "o_depth", "FLOAT", "POINT", attributes["point_depth"]) assign_mesh_attribute(mesh, "o_mask", "BOOLEAN", "FACE", attributes["face_mask"]) + assign_mesh_attribute( + mesh, + "o_depth_discontinuity", + "FLOAT", + "FACE", + attributes["face_depth_discontinuity"], + ) return for name, (data_type, key) in POINT_ATTRIBUTES.items(): assign_mesh_attribute( @@ -234,50 +244,12 @@ def assign_snapform_attributes(mesh, attributes, moge2=False): "FACE", attributes[key], ) - assign_edge_depth_difference(mesh, attributes["point_depth"]) - - -def assign_edge_depth_difference(mesh, point_depth): - import numpy as np - - edge_vertices = np.empty(len(mesh.edges) * 2, dtype=np.int32) - mesh.edges.foreach_get("vertices", edge_vertices) - edge_vertices = edge_vertices.reshape(-1, 2) - depth = np.asarray(point_depth, dtype=np.float32) - first = depth[edge_vertices[:, 0]] - second = depth[edge_vertices[:, 1]] - denominator = np.maximum(np.minimum(np.abs(first), np.abs(second)), 1e-6) - difference = np.abs(first - second) / denominator assign_mesh_attribute( mesh, - "o_edge_stretch", + "o_depth_discontinuity", "FLOAT", - "EDGE", - difference, - ) - - -def remove_surface_background(vertices, faces, uv, attributes): - """Remove background faces and compact matching point/face data.""" - import numpy as np - - background_faces = np.asarray(attributes["face_background"], dtype=bool) - kept_faces = faces[~background_faces] - if not len(kept_faces): - raise ValueError("Background removal deleted every surface face") - used_vertices = np.unique(kept_faces) - index_map = np.full(len(vertices), -1, dtype=np.int32) - index_map[used_vertices] = np.arange(len(used_vertices), dtype=np.int32) - filtered_attributes = {} - for _name, (_data_type, key) in POINT_ATTRIBUTES.items(): - filtered_attributes[key] = attributes[key][used_vertices] - for _name, (_data_type, key) in FACE_ATTRIBUTES.items(): - filtered_attributes[key] = attributes[key][~background_faces] - return ( - vertices[used_vertices], - index_map[kept_faces], - uv[used_vertices], - filtered_attributes, + "FACE", + attributes["face_depth_discontinuity"], ) @@ -285,7 +257,7 @@ def prepare_moge_surface_archive(results_path, output_path): import numpy as np with np.load(results_path, allow_pickle=False) as archive: - required = {"depth", "mask", "points", "intrinsics"} + required = {"depth", "mask", "normal", "points", "intrinsics"} missing = required.difference(archive.files) if missing: raise ValueError(f"MoGe-2 surface data is missing: {', '.join(sorted(missing))}") @@ -293,11 +265,7 @@ def prepare_moge_surface_archive(results_path, output_path): mask = np.asarray(archive["mask"], dtype=bool) points = np.asarray(archive["points"], dtype=np.float32) intrinsics = np.asarray(archive["intrinsics"], dtype=np.float32) - normal = ( - np.asarray(archive["normal"], dtype=np.float32) - if "normal" in archive.files - else None - ) + normal = np.asarray(archive["normal"], dtype=np.float32) if depth.shape[0] != 1 or points.shape[:3] != (*depth.shape,): raise ValueError("Convert to Surface requires one matching MoGe-2 point grid") raw_depth = depth[0] @@ -314,8 +282,9 @@ def prepare_moge_surface_archive(results_path, output_path): if not len(faces): raise ValueError("MoGe-2 output does not contain a valid surface") flat_points = points[0].reshape(-1, 3) - clean_mask = mask[0] & ~depth_discontinuity_mask(raw_depth, 0.04) - face_mask = clean_mask.reshape(-1)[faces].all(axis=1) + face_mask = mask[0].reshape(-1)[faces].all(axis=1) + depth_edge_ratio = depth_discontinuity_ratio(raw_depth) + face_depth_discontinuity = depth_edge_ratio.reshape(-1)[faces].max(axis=1) used = np.unique(faces) index_map = np.full(height * width, -1, dtype=np.int32) index_map[used] = np.arange(len(used), dtype=np.int32) @@ -341,32 +310,21 @@ def prepare_moge_surface_archive(results_path, output_path): image_size=np.array((width, height), dtype=np.int32), reference_depth=np.float32(reference_depth), point_depth=point_depth, - vertex_normal=( - normal[0].reshape(-1, 3)[used] - if normal is not None - else np.empty((0, 3), dtype=np.float32) - ), + normal_map=normal[0], face_mask=face_mask, + face_depth_discontinuity=face_depth_discontinuity, ) return output_path -def depth_discontinuity_mask(depth, threshold): +def depth_discontinuity_ratio(depth): import numpy as np - edges = np.zeros(depth.shape, dtype=bool) - for axis in (0, 1): - first = np.take(depth, range(depth.shape[axis] - 1), axis=axis) - second = np.take(depth, range(1, depth.shape[axis]), axis=axis) - scale = np.maximum(np.minimum(np.abs(first), np.abs(second)), 1e-6) - discontinuity = np.abs(first - second) / scale > threshold - lower = [slice(None), slice(None)] - upper = [slice(None), slice(None)] - lower[axis] = slice(0, -1) - upper[axis] = slice(1, None) - edges[tuple(lower)] |= discontinuity - edges[tuple(upper)] |= discontinuity - return edges + padded = np.pad(depth, 1, mode="edge") + windows = np.lib.stride_tricks.sliding_window_view(padded, (3, 3)) + difference = np.nanmax(windows, axis=(-2, -1)) + difference -= np.nanmin(windows, axis=(-2, -1)) + return difference / np.maximum(depth, 1e-6) def node_asset_path(): @@ -505,7 +463,6 @@ def build_image_mesh( delete_source, create_camera, unshaded, - remove_background=False, moge2=False, ): import numpy as np @@ -519,13 +476,6 @@ def build_image_mesh( reference_depth, attributes, ) = load_image_mesh_data(npz_path, moge2=moge2) - if remove_background: - camera_vertices, faces, uv, attributes = remove_surface_background( - camera_vertices, - faces, - uv, - attributes, - ) vertices, camera_parameters = empty_anchored_vertices( source_object, camera_vertices, @@ -541,24 +491,25 @@ def build_image_mesh( mesh.update() for polygon in mesh.polygons: polygon.use_smooth = True - if moge2 and len(attributes.get("vertex_normal", ())) == len(vertices): - normals = attributes["vertex_normal"].copy() - normals[:, 0] /= camera_parameters["scale_x"] - normals[:, 1] /= -camera_parameters["scale_y"] - normals[:, 2] /= -camera_parameters["scale_z"] - lengths = np.linalg.norm(normals, axis=1, keepdims=True) - normals /= np.maximum(lengths, 1e-6) - set_custom_normals = getattr( - mesh, - "normals_split_custom_set_from_vertices", - None, - ) - if callable(set_custom_normals): - set_custom_normals(normals.tolist()) mesh_object = bpy.data.objects.new(source_name, mesh) context.collection.objects.link(mesh_object) mesh_object.matrix_world = source_object.matrix_world.copy() depth_scale = material_depth_scale(mesh_object, camera_parameters) + normal_image = None + if moge2 and attributes.get("normal_map", np.empty(0)).size: + from .image_depth import create_moge_normal_image + + normal_map = attributes["normal_map"].copy() + normal_map[..., 0] /= camera_parameters["scale_x"] + normal_map[..., 1] /= -camera_parameters["scale_y"] + normal_map[..., 2] /= -camera_parameters["scale_z"] + lengths = np.linalg.norm(normal_map, axis=-1, keepdims=True) + normal_map /= np.maximum(lengths, 1e-6) + normal_image = create_moge_normal_image( + normal_data_name(source_object), + normal_map, + convert_coordinates=False, + ) material = create_material( source_name, source_object.data, @@ -567,6 +518,7 @@ def build_image_mesh( surface_group_name=( MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME if moge2 else None ), + normal_image=normal_image, ) mesh.materials.append(material) mesh_object["snapform_reference_depth"] = reference_depth @@ -620,10 +572,6 @@ class BuildImageMesh(bpy.types.Operator): default=False, options={"HIDDEN", "SKIP_SAVE"}, ) - snapform_remove_background: bpy.props.BoolProperty( - default=False, - options={"HIDDEN", "SKIP_SAVE"}, - ) snapform_moge2: bpy.props.BoolProperty( default=False, options={"HIDDEN", "SKIP_SAVE"}, @@ -642,7 +590,6 @@ class BuildImageMesh(bpy.types.Operator): self.snapform_delete_source, self.snapform_create_camera, self.snapform_unshaded, - self.snapform_remove_background, self.snapform_moge2, ) except (OSError, RuntimeError, ValueError) as error: @@ -658,7 +605,7 @@ class ConvertImageToMesh(bpy.types.Operator): bl_options = {"REGISTER"} snapform_model: bpy.props.EnumProperty( - name="Model", + name="Depth From", description="Model used to estimate image depth", items=image_surface_model_items, default=1, diff --git a/src/snapform/operators.py b/src/snapform/operators.py index effd52d..6cebf66 100644 --- a/src/snapform/operators.py +++ b/src/snapform/operators.py @@ -822,7 +822,6 @@ class RunJob(bpy.types.Operator): snapform_delete_source=job.delete_source, snapform_create_camera=job.mesh_create_camera, snapform_unshaded=job.unshaded, - snapform_remove_background=job.remove_background, ) if "FINISHED" not in result: raise RuntimeError("Unable to build the perspective surface") @@ -841,7 +840,6 @@ class RunJob(bpy.types.Operator): snapform_delete_source=job.delete_source, snapform_create_camera=job.mesh_create_camera, snapform_unshaded=job.unshaded, - snapform_remove_background=False, snapform_moge2=True, ) if "FINISHED" not in result: diff --git a/src/snapform/panel.py b/src/snapform/panel.py index b066817..fb16dc5 100644 --- a/src/snapform/panel.py +++ b/src/snapform/panel.py @@ -125,11 +125,12 @@ class ModelsPanel(bpy.types.Panel): layout = self.layout running = active_job() is not None or client.service_busy() + for model in MOGE2_MODELS.values(): + self._draw_model(layout, model, running) for model in MODELS.values(): if model.enabled: self._draw_model(layout, model, running) - for model in MOGE2_MODELS.values(): - self._draw_model(layout, model, running) + layout.separator(type="LINE") self._draw_model(layout, BEN2_MODEL, running) def _draw_model(self, layout, model, running): @@ -190,7 +191,7 @@ def draw_da3_input_settings(layout, settings, model_property): class Da3OutputDebugPanel(bpy.types.Panel): - bl_label = "DA3 Output Test" + bl_label = "DA3" bl_idname = "SNAPFORM_PT_debug_da3_output" bl_parent_id = DebugPanel.bl_idname bl_space_type = "VIEW_3D" @@ -240,7 +241,7 @@ class Da3OutputDebugPanel(bpy.types.Panel): class Moge2ApiDebugPanel(bpy.types.Panel): - bl_label = "MoGe-2 API Test" + bl_label = "MoGe-2" bl_idname = "SNAPFORM_PT_debug_moge2_api" bl_parent_id = DebugPanel.bl_idname bl_space_type = "VIEW_3D" @@ -271,7 +272,7 @@ class Moge2ApiDebugPanel(bpy.types.Panel): class Ben2RemovalDebugPanel(bpy.types.Panel): - bl_label = "BEN2 Removal Test" + bl_label = "BEN2" bl_idname = "SNAPFORM_PT_debug_ben2_removal" bl_parent_id = DebugPanel.bl_idname bl_space_type = "VIEW_3D" diff --git a/src/snapform/service.py b/src/snapform/service.py index 8388a1b..dda942a 100644 --- a/src/snapform/service.py +++ b/src/snapform/service.py @@ -110,6 +110,8 @@ class ModelCache: def get_da3(self, model_dir, device): key = (str(Path(model_dir).resolve()), device.type) + if self.moge_model is not None: + self.release_moge() if self.da3_model is not None and self.da3_key == key: return self.da3_model self.release_da3() @@ -132,6 +134,8 @@ class ModelCache: def get_moge(self, model_dir, device): key = (str(Path(model_dir).resolve()), device.type) + if self.da3_model is not None: + self.release_da3() if self.moge_model is not None and self.moge_key == key: return self.moge_model self.release_moge() diff --git a/src/snapform/worker.py b/src/snapform/worker.py index db70a8e..2e24e92 100644 --- a/src/snapform/worker.py +++ b/src/snapform/worker.py @@ -347,6 +347,16 @@ def confidence_percentile_map(depth, confidence): return result +def depth_discontinuity_ratio(depth): + import numpy as np + + padded = np.pad(depth, 1, mode="edge") + windows = np.lib.stride_tricks.sliding_window_view(padded, (3, 3)) + difference = np.nanmax(windows, axis=(-2, -1)) + difference -= np.nanmin(windows, axis=(-2, -1)) + return difference / np.maximum(depth, 1e-6) + + def triangle_edge_stretch_values(points, triangles): import numpy as np @@ -381,39 +391,23 @@ def image_mesh_attributes( vertices, faces, intrinsics, - depth, - confidence, confidence_percentiles, - foreground_mask, + face_depth_discontinuity, ): import numpy as np - pixels = project_camera_pixels(vertices, intrinsics) - point_confidence = sample_image_nearest(confidence, pixels).astype( - np.float32 - ) - point_confidence = np.nan_to_num(point_confidence, nan=0.0) point_percentile = sample_image_nearest( confidence_percentiles, - pixels, + project_camera_pixels(vertices, intrinsics), ).astype(np.float32) - point_background = ~sample_image_nearest(foreground_mask, pixels).astype( - bool - ) face_confidence = point_percentile[faces].min(axis=1) - face_edge_stretch = triangle_edge_stretch_values( - vertices, - faces, - ).max(axis=1) - face_background = point_background[faces].any(axis=1) return { "point_depth": vertices[:, 2].astype(np.float32), - "point_confidence": point_confidence, - "point_confidence_percentile": point_percentile, - "point_background": point_background, "face_confidence_percentile": face_confidence.astype(np.float32), - "face_edge_stretch": face_edge_stretch.astype(np.float32), - "face_background": face_background, + "face_depth_discontinuity": np.asarray( + face_depth_discontinuity, + dtype=np.float32, + ), } @@ -507,6 +501,8 @@ def export_image_mesh_data( "DA3 output does not contain any valid surface faces" ) progress(5, "Preparing source UV and attributes") + depth_discontinuity = depth_discontinuity_ratio(raw_depth) + face_depth_discontinuity = depth_discontinuity.reshape(-1)[faces].max(axis=1) vertices, faces = compact_mesh( points.reshape(-1, 3), faces, @@ -517,13 +513,10 @@ def export_image_mesh_data( vertices, faces, intrinsics, - raw_depth, - confidence, confidence_percentiles, - foreground_mask, + face_depth_discontinuity, ) - valid_depth = raw_depth[np.isfinite(raw_depth) & (raw_depth > 0.0)] - reference_depth = float(np.median(valid_depth)) + reference_depth = float(np.median(vertices[:, 2])) output_directory = Path(output_directory) output_directory.mkdir(parents=True, exist_ok=True) progress(6, "Writing source surface data") @@ -1568,26 +1561,46 @@ def export_moge_surface_archive( mask, normal, intrinsics, + foreground_mask=None, ): import numpy as np import utils3d height, width = depth.shape - clean_mask = mask & ~utils3d.np.depth_map_edge(depth, rtol=0.04) + finite_mask = np.isfinite(depth) & (depth > 0.0) + finite_mask &= np.isfinite(points).all(axis=-1) + if foreground_mask is not None: + finite_mask &= foreground_mask + depth_discontinuity = depth_discontinuity_ratio(depth) result = utils3d.np.build_mesh_from_map( points, utils3d.np.uv_map(height, width), normal, - mask=clean_mask, + mask.astype(np.float32), + depth_discontinuity.astype(np.float32), + depth, + mask=finite_mask, tri=True, ) - faces, vertices, vertex_uvs, vertex_normals = result + ( + faces, + vertices, + vertex_uvs, + _vertex_normals, + vertex_mask, + vertex_depth_discontinuity, + point_depth, + ) = result vertex_uvs = vertex_uvs * np.array([1.0, -1.0]) + np.array([0.0, 1.0]) pixel_intrinsics = intrinsics.copy() pixel_intrinsics[0] *= width pixel_intrinsics[1] *= height pixel_intrinsics[2] = (0.0, 0.0, 1.0) - point_depth = vertices[:, 2].astype(np.float32) + point_depth = point_depth.astype(np.float32) + face_mask = vertex_mask[faces].all(axis=1) + face_depth_discontinuity = vertex_depth_discontinuity[faces].max(axis=1) + masked_depth = point_depth[vertex_mask.astype(bool)] + reference_depth = np.median(masked_depth if len(masked_depth) else point_depth) np.savez( output_path, vertices=vertices.astype(np.float32), @@ -1595,10 +1608,11 @@ def export_moge_surface_archive( uv=vertex_uvs.astype(np.float32), intrinsics=pixel_intrinsics.astype(np.float32), image_size=np.array((width, height), dtype=np.int32), - reference_depth=np.float32(np.median(point_depth)), + reference_depth=np.float32(reference_depth), point_depth=point_depth, - vertex_normal=vertex_normals.astype(np.float32), - face_mask=np.ones(len(faces), dtype=bool), + normal_map=normal.astype(np.float32), + face_mask=face_mask, + face_depth_discontinuity=face_depth_discontinuity, ) @@ -1703,6 +1717,7 @@ def run_moge(args, model_cache=None, cancel_check=None): debug_format, ) + foreground_mask = None remove_background_model_dir = getattr( args, "remove_background_model_dir", "" ) @@ -1724,7 +1739,6 @@ def run_moge(args, model_cache=None, cancel_check=None): foreground_path, result["depth"][0].shape, ) - result["mask"][0] &= foreground_mask else: infer_foreground_frames( images, @@ -1749,6 +1763,11 @@ def run_moge(args, model_cache=None, cancel_check=None): result["mask"][0], result["normal"][0], result["intrinsics"][0], + foreground_mask=( + foreground_mask + if remove_background_model_dir + else None + ), ) np.savez(output_directory / "results.npz", **result) copy_frames(images, output_directory) diff --git a/tests/test_addon.py b/tests/test_addon.py index ea54b63..58da071 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -333,33 +333,6 @@ class AddonTest(unittest.TestCase): float(np.median(edge_lengths) * np.sqrt(10.0) * 2.0), ) - def test_surface_background_filter_compacts_geometry_and_attributes(self): - vertices = np.arange(18, dtype=np.float32).reshape(6, 3) - faces = np.asarray(((0, 1, 2), (2, 3, 4)), dtype=np.int32) - uv = np.arange(12, dtype=np.float32).reshape(6, 2) - attributes = { - key: np.arange(6 if name in self.addon.image_mesh.POINT_ATTRIBUTES else 2) - for name, (_data_type, key) in { - **self.addon.image_mesh.POINT_ATTRIBUTES, - **self.addon.image_mesh.FACE_ATTRIBUTES, - }.items() - } - attributes["face_background"] = np.asarray((True, False)) - - result = self.addon.image_mesh.remove_surface_background( - vertices, - faces, - uv, - attributes, - ) - - filtered_vertices, filtered_faces, filtered_uv, filtered_attributes = result - np.testing.assert_array_equal(filtered_vertices, vertices[[2, 3, 4]]) - np.testing.assert_array_equal(filtered_faces, ((0, 1, 2),)) - np.testing.assert_array_equal(filtered_uv, uv[[2, 3, 4]]) - self.assertEqual(len(filtered_attributes["point_depth"]), 3) - self.assertEqual(len(filtered_attributes["face_background"]), 1) - def test_registers_image_background_operators(self): self.assertIn(self.addon.RemoveImageBackground, self.addon.CLASSES) self.assertIn(self.addon.ApplyRemovedBackground, self.addon.CLASSES) @@ -706,7 +679,10 @@ class AddonTest(unittest.TestCase): "snapform_keep_original", }, ) - self.assertEqual(operator.__annotations__["snapform_model"]["name"], "Model") + self.assertEqual( + operator.__annotations__["snapform_model"]["name"], + "Depth From", + ) self.assertEqual( operator.__annotations__["snapform_unshaded"]["name"], "Unshaded", @@ -1980,7 +1956,7 @@ class AddonTest(unittest.TestCase): self.assertIn("DEFAULT_CLOSED", panel.bl_options) self.assertEqual( [panel.bl_label for panel in panels], - ["DA3 Output Test", "MoGe-2 API Test", "BEN2 Removal Test"], + ["DA3", "MoGe-2", "BEN2"], ) def test_service_panel_exposes_shared_service_state_and_controls(self): diff --git a/tests/test_image_depth.py b/tests/test_image_depth.py index 75f3932..9f14f4e 100644 --- a/tests/test_image_depth.py +++ b/tests/test_image_depth.py @@ -601,6 +601,21 @@ class ImageDepthTest(unittest.TestCase): "img.001.depth", ) + def test_moge_map_names_use_source_image_name(self): + source_object = SimpleNamespace( + name="obj_img", + data=SimpleNamespace(name="img.png"), + ) + + self.assertEqual( + self.image_depth.normal_data_name(source_object), + "img.normal", + ) + self.assertEqual( + self.image_depth.mask_data_name(source_object), + "img.mask", + ) + def test_copy_depth_image_renames_still_source(self): class FakeImage: def __init__(self, name): diff --git a/tests/test_service.py b/tests/test_service.py index b630617..343fbac 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -45,6 +45,8 @@ class ServiceTest(unittest.TestCase): cache.ben2_key = None cache.da3_model = None cache.da3_key = None + cache.moge_model = None + cache.moge_key = None return cache def test_ben2_model_is_reused_for_matching_runtime_key(self): @@ -111,6 +113,39 @@ class ServiceTest(unittest.TestCase): self.assertEqual(loads, ["model"]) self.assertEqual(cache.status()["da3"], "model") + def test_depth_model_cache_keeps_only_da3_or_moge(self): + cache = self.create_cache() + cache.ben2_model = object() + cache.ben2_key = ("ben2", "cuda") + cache.moge_model = object() + cache.moge_key = ("moge", "cuda") + device = SimpleNamespace(type="cuda") + da3 = object() + + def load_da3(_model_dir, _device, key): + cache.da3_model = da3 + cache.da3_key = key + return da3 + + with patch.object(cache, "_load_da3", side_effect=load_da3): + self.assertIs(cache.get_da3("da3", device), da3) + + self.assertIsNone(cache.moge_model) + self.assertIsNotNone(cache.ben2_model) + + moge = object() + + def load_moge(_model_dir, _device, key): + cache.moge_model = moge + cache.moge_key = key + return moge + + with patch.object(cache, "_load_moge", side_effect=load_moge): + self.assertIs(cache.get_moge("moge", device), moge) + + self.assertIsNone(cache.da3_model) + self.assertIsNotNone(cache.ben2_model) + def test_service_tracks_clients_and_rejects_shared_shutdown(self): fake_models = SimpleNamespace( status=lambda: {"ben2": "", "da3": ""}, diff --git a/tests/test_worker.py b/tests/test_worker.py index e07f196..c2844c4 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -1365,14 +1365,23 @@ class WorkerTest(unittest.TestCase): self.assertNotIn("postprocess_method", result.files) self.assertEqual(result["point_depth"].shape, (9,)) self.assertEqual( - result["point_confidence_percentile"].shape, - (9,), - ) - self.assertEqual( - result["face_edge_stretch"].shape, + result["face_confidence_percentile"].shape, (8,), ) - self.assertEqual(result["face_background"].shape, (8,)) + self.assertEqual( + set(result.files), + { + "vertices", + "faces", + "uv", + "intrinsics", + "image_size", + "reference_depth", + "point_depth", + "face_confidence_percentile", + "face_depth_discontinuity", + }, + ) np.testing.assert_allclose(result["uv"][0], [0.0, 1.0]) np.testing.assert_allclose(result["uv"][-1], [1.0, 0.0]) self.assertTrue(texture_path.is_file()) diff --git a/tools/build_geometry_nodes_asset.py b/tools/build_geometry_nodes_asset.py index b217f89..cb5d1cc 100644 --- a/tools/build_geometry_nodes_asset.py +++ b/tools/build_geometry_nodes_asset.py @@ -25,12 +25,15 @@ def input_socket(node, name, fallback): def new_socket(interface, name, in_out, socket_type, parent=None): - return interface.new_socket( + socket = interface.new_socket( name=name, in_out=in_out, socket_type=socket_type, parent=parent, ) + if in_out == "INPUT" and hasattr(socket, "structure_type"): + socket.structure_type = "SINGLE" + return socket def new_compare(nodes, data_type, operation, location, value): @@ -361,8 +364,11 @@ def add_uv_reprojection(group, source, geometry, location): def build_group( group_name=GROUP_NAME, include_confidence=True, - include_stretch=True, + include_discontinuity=True, include_mask=False, + discontinuity_name="Depth Continuity", + discontinuity_attribute="o_depth_discontinuity", + discontinuity_default=50.0, ): group = bpy.data.node_groups.new(group_name, "GeometryNodeTree") group.is_modifier = True @@ -381,7 +387,7 @@ def build_group( "INPUT", "NodeSocketFloat", ) - resolution.default_value = 0.01 + resolution.default_value = 0.1 resolution.min_value = 0.000001 resolution.subtype = "DISTANCE" remesh_iterations = new_socket( @@ -403,22 +409,24 @@ def build_group( smooth_amount.min_value = 0.0 smooth_amount.max_value = 1.0 smooth_amount.subtype = "FACTOR" - if include_mask: - mask_socket = new_socket( - interface, - "Mask", - "INPUT", - "NodeSocketBool", - ) - mask_socket.default_value = True - mask_socket.structure_type = "SINGLE" - culling_panel = None - if include_confidence or include_stretch: + if include_confidence or include_discontinuity or include_mask: culling_panel = interface.new_panel( name="Culling", default_closed=False, ) + if include_discontinuity: + discontinuity = new_socket( + interface, + discontinuity_name, + "INPUT", + "NodeSocketFloat", + parent=culling_panel, + ) + discontinuity.default_value = discontinuity_default + discontinuity.min_value = 0.0 + discontinuity.max_value = 100.0 + discontinuity.subtype = "PERCENTAGE" if include_confidence: confidence = new_socket( interface, @@ -431,18 +439,15 @@ def build_group( confidence.min_value = 0.0 confidence.max_value = 100.0 confidence.subtype = "PERCENTAGE" - if include_stretch: - edge = new_socket( + if include_mask: + mask_socket = new_socket( interface, - "Stretch", + "Mask", "INPUT", - "NodeSocketFloat", + "NodeSocketBool", parent=culling_panel, ) - edge.default_value = 10.0 - edge.min_value = 0.0 - edge.max_value = 100.0 - edge.subtype = "PERCENTAGE" + mask_socket.default_value = False options_panel = interface.new_panel( name="Options", @@ -470,9 +475,13 @@ def build_group( (-1800.0, 800.0), {"Confidence"}, ) - stretch_input = None - if include_stretch: - stretch_input = new_group_input(nodes, (-1800.0, 50.0), {"Stretch"}) + discontinuity_input = None + if include_discontinuity: + discontinuity_input = new_group_input( + nodes, + (-1800.0, 50.0), + {discontinuity_name}, + ) remesh_input = new_group_input( nodes, (-100.0, -500.0), @@ -524,54 +533,67 @@ def build_group( geometry = delete_confidence.outputs["Geometry"] culled_geometry = geometry - if include_stretch: - edge_attribute = nodes.new("GeometryNodeInputNamedAttribute") - edge_attribute.data_type = "FLOAT" - edge_attribute.inputs["Name"].default_value = "o_edge_stretch" - edge_attribute.location = (-1800.0, 350.0) - edge_scale = nodes.new("ShaderNodeMath") - edge_scale.operation = "MULTIPLY" - edge_scale.inputs[1].default_value = 0.01 - edge_scale.location = (-1600.0, -50.0) - edge_compare = new_compare( + if include_discontinuity: + discontinuity_data = nodes.new("GeometryNodeInputNamedAttribute") + discontinuity_data.data_type = "FLOAT" + discontinuity_data.inputs["Name"].default_value = discontinuity_attribute + discontinuity_data.location = (-1800.0, 350.0) + culling_scale = nodes.new("ShaderNodeMath") + culling_scale.operation = "MULTIPLY" + culling_scale.inputs[1].default_value = 0.01 + culling_scale.location = (-1650.0, -50.0) + inverse_culling = nodes.new("ShaderNodeMath") + inverse_culling.operation = "SUBTRACT" + inverse_culling.inputs[0].default_value = 0.5 + inverse_culling.location = (-1450.0, -50.0) + exponent_scale = nodes.new("ShaderNodeMath") + exponent_scale.operation = "MULTIPLY" + exponent_scale.inputs[1].default_value = 10.0 + exponent_scale.location = (-1250.0, -50.0) + threshold_power = nodes.new("ShaderNodeMath") + threshold_power.operation = "POWER" + threshold_power.inputs[0].default_value = 2.0 + threshold_power.location = (-1050.0, -50.0) + threshold_scale = nodes.new("ShaderNodeMath") + threshold_scale.operation = "MULTIPLY" + threshold_scale.inputs[1].default_value = 0.04 + threshold_scale.location = (-850.0, -50.0) + discontinuity_compare = new_compare( nodes, "FLOAT", "GREATER_THAN", - (-1200.0, 350.0), + (-650.0, 350.0), 0.0, ) - links.new(stretch_input.outputs["Stretch"], edge_scale.inputs[0]) - links.new(edge_attribute.outputs["Attribute"], input_socket(edge_compare, "A", 0)) - links.new(edge_scale.outputs[0], input_socket(edge_compare, "B", 1)) + links.new( + discontinuity_input.outputs[discontinuity_name], + culling_scale.inputs[0], + ) + links.new(culling_scale.outputs[0], inverse_culling.inputs[1]) + links.new(inverse_culling.outputs[0], exponent_scale.inputs[0]) + links.new(exponent_scale.outputs[0], threshold_power.inputs[1]) + links.new(threshold_power.outputs[0], threshold_scale.inputs[0]) + links.new( + discontinuity_data.outputs["Attribute"], + input_socket(discontinuity_compare, "A", 0), + ) + links.new( + threshold_scale.outputs[0], + input_socket(discontinuity_compare, "B", 1), + ) delete_geometry = nodes.new("GeometryNodeDeleteGeometry") - delete_geometry.domain = "EDGE" + delete_geometry.domain = "FACE" delete_geometry.location = (-350.0, 100.0) links.new(geometry, delete_geometry.inputs["Geometry"]) - links.new(edge_compare.outputs["Result"], delete_geometry.inputs["Selection"]) + links.new( + discontinuity_compare.outputs["Result"], + delete_geometry.inputs["Selection"], + ) culled_geometry = delete_geometry.outputs["Geometry"] - vertex_neighbors = nodes.new("GeometryNodeInputMeshVertexNeighbors") - vertex_neighbors.location = (-550.0, -320.0) - orphan_vertex = new_compare( - nodes, - "INT", - "EQUAL", - (-350.0, -320.0), - 0, - ) - links.new( - vertex_neighbors.outputs["Face Count"], - input_socket(orphan_vertex, "A", 0), - ) - delete_orphans = nodes.new("GeometryNodeDeleteGeometry") - delete_orphans.domain = "POINT" - delete_orphans.location = (-300.0, 100.0) - links.new(culled_geometry, delete_orphans.inputs["Geometry"]) - links.new(orphan_vertex.outputs["Result"], delete_orphans.inputs["Selection"]) - remeshed = add_remesh_zone( group, - delete_orphans.outputs["Geometry"], + culled_geometry, remesh_input.outputs["Resolution"], remesh_input.outputs["Relax Iterations"], topology_input.outputs["Fix Topology"], @@ -634,7 +656,11 @@ def build_group( def configure_moge_surface_group(group): nodes = group.nodes links = group.links - mask_input = new_group_input(nodes, (-1800.0, 1050.0), {"Mask"}) + controls = new_group_input( + nodes, + (-1800.0, 1050.0), + {"Mask"}, + ) mask_attribute = nodes.new("GeometryNodeInputNamedAttribute") mask_attribute.data_type = "BOOLEAN" mask_attribute.inputs["Name"].default_value = "o_mask" @@ -646,7 +672,7 @@ def configure_moge_surface_group(group): use_mask.operation = "AND" use_mask.location = (-1150.0, 1050.0) links.new(mask_attribute.outputs["Attribute"], invert_mask.inputs[0]) - links.new(mask_input.outputs["Mask"], use_mask.inputs[0]) + links.new(controls.outputs["Mask"], use_mask.inputs[0]) links.new(invert_mask.outputs[0], use_mask.inputs[1]) first_processing = min( ( @@ -667,7 +693,7 @@ def configure_moge_surface_group(group): links.new(use_mask.outputs[0], delete_mask.inputs["Selection"]) links.new(delete_mask.outputs["Geometry"], geometry_input) group.description = ( - "Cull MoGe-2 stretched faces, repair topology, preserve boundaries, " + "Cull MoGe-2 mask and depth-edge faces, repair topology, preserve boundaries, " "and reproject UVs." ) group.use_fake_user = True @@ -1301,8 +1327,11 @@ def main(): moge_surface_group = build_group( MOGE_SURFACE_GROUP_NAME, include_confidence=False, - include_stretch=False, + include_discontinuity=True, include_mask=True, + discontinuity_name="Depth Continuity", + discontinuity_attribute="o_depth_discontinuity", + discontinuity_default=50.0, ) configure_moge_surface_group(moge_surface_group) build_depth_plane_group() diff --git a/tools/validate_geometry_nodes_asset.py b/tools/validate_geometry_nodes_asset.py index 6d69bed..4439ba8 100644 --- a/tools/validate_geometry_nodes_asset.py +++ b/tools/validate_geometry_nodes_asset.py @@ -21,14 +21,9 @@ def set_input(modifier, group, name, value): modifier[item.identifier] = value -def add_face_attributes(mesh, count, background_values=None): +def add_face_attributes(mesh, count): confidence = mesh.attributes.new("o_face_confidence", "FLOAT", "FACE") confidence.data.foreach_set("value", [1.0] * count) - background = mesh.attributes.new("o_background_face", "BOOLEAN", "FACE") - background.data.foreach_set( - "value", - background_values or [False] * count, - ) def main(): @@ -44,6 +39,13 @@ def main(): and item.name == "Geometry" ) assert geometry_input.structure_type == "SINGLE" + input_sockets = [ + item for item in group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT" + ] + assert all(item.structure_type == "SINGLE" for item in input_sockets) + resolution = next(item for item in input_sockets if item.name == "Resolution") + assert abs(resolution.default_value - 0.1) < 1e-6 assert group.asset_data is None assert not any(node.bl_idname == "GeometryNodeGroup" for node in group.nodes) assert not any(node.bl_idname == "GeometryNodeTriangulate" for node in group.nodes) @@ -72,8 +74,8 @@ def main(): assert not panels["Culling"].default_closed assert panels["Options"].default_closed assert [item.name for item in panels["Culling"].interface_items] == [ + "Depth Continuity", "Confidence", - "Stretch", ] assert [item.name for item in panels["Options"].interface_items] == [ "Fix Topology", @@ -86,7 +88,10 @@ def main(): and item.name == "Smooth" ) assert smooth.default_value == 0.5 - for name, default_value in (("Confidence", 0.0), ("Stretch", 10.0)): + for name, default_value in ( + ("Confidence", 0.0), + ("Depth Continuity", 50.0), + ): item = next( item for item in panels["Culling"].interface_items @@ -105,11 +110,21 @@ def main(): [], [(0, 2, 1), (1, 2, 3)], ) - edge_stretch = mesh.attributes.new("o_edge_stretch", "FLOAT", "EDGE") - edge_stretch.data.foreach_set("value", [0.05] * len(mesh.edges)) + discontinuity = mesh.attributes.new( + "o_depth_discontinuity", + "FLOAT", + "FACE", + ) + discontinuity.data.foreach_set("value", [0.03] * len(mesh.polygons)) uv = mesh.uv_layers.new(name="UVMap") uv.data.foreach_set("uv", [0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1]) - add_face_attributes(mesh, 2, [False, True]) + add_face_attributes(mesh, 2) + assert not { + "o_background", + "o_background_face", + "o_confidence", + "o_confidence_percentile", + }.intersection(mesh.attributes.keys()) obj = bpy.data.objects.new("Validation Object", mesh) bpy.context.scene.collection.objects.link(obj) @@ -130,13 +145,13 @@ def main(): finally: evaluated.to_mesh_clear() - set_input(modifier, group, "Stretch", 0.0) - stretch_item = next( + set_input(modifier, group, "Depth Continuity", 100.0) + discontinuity_item = next( item for item in group.interface.items_tree - if item.item_type == "SOCKET" and item.name == "Stretch" + if item.item_type == "SOCKET" and item.name == "Depth Continuity" ) - assert modifier[stretch_item.identifier] == 0.0 + assert modifier[discontinuity_item.identifier] == 100.0 obj.update_tag(refresh={"DATA"}) bpy.context.view_layer.update() culled = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) diff --git a/tools/validate_moge_depth_plane.py b/tools/validate_moge_depth_plane.py index 8f7fb74..487f8db 100644 --- a/tools/validate_moge_depth_plane.py +++ b/tools/validate_moge_depth_plane.py @@ -80,6 +80,7 @@ def main(): ] assert image_textures assert all(node.extension == "EXTEND" for node in image_textures) + assert any(node.image.name == "MoGe Test.depth" for node in image_textures) geometry_textures = [ node for node in geometry_group.nodes diff --git a/tools/validate_moge_surface.py b/tools/validate_moge_surface.py index fe76906..a0fce15 100644 --- a/tools/validate_moge_surface.py +++ b/tools/validate_moge_surface.py @@ -51,6 +51,10 @@ def main(): mask=mask, points=points, intrinsics=intrinsics, + normal=np.broadcast_to( + np.array([0.0, 0.0, -1.0], dtype=np.float32), + (1, height, width, 3), + ), ) prepare_moge_surface_archive(results_path, mesh_path) surface = build_image_mesh( @@ -60,7 +64,6 @@ def main(): False, True, False, - False, True, ) assert len(surface.data.vertices) > 0 @@ -71,12 +74,16 @@ def main(): if getattr(item, "in_out", None) == "INPUT" } assert "Stretch" not in inputs + assert "Depth Continuity" in inputs assert "Mask" in inputs assert "Confidence" not in inputs assert "o_mask" in surface.data.attributes assert surface.data.attributes["o_mask"].domain == "FACE" + assert not all(item.value for item in surface.data.attributes["o_mask"].data) attribute_names = set(surface.data.attributes.keys()) assert attribute_names >= {"o_depth", "o_mask"} + assert "o_depth_discontinuity" in attribute_names + assert surface.data.attributes["o_depth_discontinuity"].data_type == "FLOAT" assert "o_edge_stretch" not in attribute_names assert not { "o_confidence", @@ -90,13 +97,35 @@ def main(): for item in surface.modifiers[0].node_group.interface.items_tree if getattr(item, "in_out", None) == "INPUT" and item.name == "Mask" ) - assert mask_socket.default_value is True + assert mask_socket.default_value is False assert mask_socket.structure_type == "SINGLE" - assert not mask_socket.parent.name - assert not any( - item.item_type == "PANEL" and item.name == "Culling" + assert mask_socket.parent.name == "Culling" + depth_edge_socket = next( + item for item in surface.modifiers[0].node_group.interface.items_tree + if getattr(item, "in_out", None) == "INPUT" + and item.name == "Depth Continuity" ) + assert depth_edge_socket.default_value == 50.0 + assert depth_edge_socket.subtype == "PERCENTAGE" + assert depth_edge_socket.structure_type == "SINGLE" + culling_panel = next( + item for item in surface.modifiers[0].node_group.interface.items_tree + if item.item_type == "PANEL" and item.name == "Culling" + ) + assert [item.name for item in culling_panel.interface_items] == [ + "Depth Continuity", + "Mask", + ] + input_sockets = [ + item for item in surface.modifiers[0].node_group.interface.items_tree + if item.item_type == "SOCKET" and item.in_out == "INPUT" + ] + assert all(item.structure_type == "SINGLE" for item in input_sockets) + resolution_socket = next( + item for item in input_sockets if item.name == "Resolution" + ) + assert abs(resolution_socket.default_value - 0.1) < 1e-6 smooth_socket = next( item for item in surface.modifiers[0].node_group.interface.items_tree @@ -111,6 +140,26 @@ def main(): assert MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME in bpy.data.node_groups material_group = bpy.data.node_groups[MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME] assert any(node.bl_idname == "ShaderNodeBump" for node in material_group.nodes) + normal_map = next( + node for node in material_group.nodes + if node.bl_idname == "ShaderNodeNormalMap" + ) + assert normal_map.space == "OBJECT" + normal_strength = next( + item for item in material_group.interface.items_tree + if getattr(item, "in_out", None) == "INPUT" + and item.name == "Normal Strength" + ) + assert normal_strength.default_value == 1.0 + assert normal_strength.min_value == 0.0 + assert normal_map.inputs["Strength"].is_linked + assert not surface.data.has_custom_normals + normal_texture = next( + node for node in surface.data.materials[0].node_tree.nodes + if node.bl_idname == "ShaderNodeTexImage" + and node.image.colorspace_settings.name == "Non-Color" + ) + assert normal_texture.image.name == "MoGe Surface Test.normal" assert any(child.type == "CAMERA" for child in surface.children) print("MoGe-2 surface validation passed") @@ -131,23 +180,24 @@ def validate_real_result(results_path, image_path): False, True, False, - False, True, ) assert len(surface.data.vertices) > 100 assert len(surface.data.polygons) > 100 assert surface.modifiers[0].node_group.name == MOGE_NODE_GROUP_NAME assert MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME in bpy.data.node_groups - assert surface.data.has_custom_normals - normals = np.empty(len(surface.data.corner_normals) * 3, dtype=np.float32) - surface.data.corner_normals.foreach_get("vector", normals) - normals = normals.reshape(-1, 3) - assert np.isfinite(normals).all() - assert np.min(np.linalg.norm(normals, axis=1)) > 0.99 + assert not surface.data.has_custom_normals material_group = bpy.data.node_groups[MOGE_SURFACE_MATERIAL_NODE_GROUP_NAME] assert any(node.bl_idname == "ShaderNodeBump" for node in material_group.nodes) + normal_map = next( + node for node in material_group.nodes + if node.bl_idname == "ShaderNodeNormalMap" + ) + assert normal_map.space == "OBJECT" + assert normal_map.inputs["Strength"].is_linked attribute_names = set(surface.data.attributes.keys()) assert {"o_depth", "o_mask"}.issubset(attribute_names) + assert "o_depth_discontinuity" in attribute_names assert "o_edge_stretch" not in attribute_names assert not { "o_confidence", diff --git a/uv.lock b/uv.lock index 5304761..a3d6970 100644 --- a/uv.lock +++ b/uv.lock @@ -205,7 +205,7 @@ wheels = [ [[package]] name = "snapform" -version = "0.10.11" +version = "0.10.12" source = { virtual = "." } [package.dev-dependencies]