Implement Omoo DA3 functionality with property group, runtime, worker, and status bar
- Added properties for Omoo DA3 settings in properties.py, including input path, video frames, model selection, and export format. - Created runtime management in runtime.py, handling model downloads, job paths, and command execution for reconstruction. - Developed status bar functionality in status_bar.py to display job progress and status. - Implemented worker.py for image and video processing, including frame extraction and model inference. - Added comprehensive unit tests covering addon registration, model handling, export processes, and UI interactions. - Included a lock file for dependency management with uv.
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
.uv-cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.zip
|
||||||
|
.pytest_cache/
|
||||||
25
AGENTS.md
Normal file
25
AGENTS.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
## 总规范
|
||||||
|
|
||||||
|
- 说明性内容(例如提交说明、文档、注释、mermaid),以中文为主,专有名词、约定俗称可用英文
|
||||||
|
|
||||||
|
## Markdown 规范
|
||||||
|
|
||||||
|
- 不使用`---`分割器
|
||||||
|
- `# Heading` 一级标题仅用于开头
|
||||||
|
- mermaid 节点名用英文字符
|
||||||
|
|
||||||
|
## 代码规范
|
||||||
|
|
||||||
|
- 避免嵌套结构
|
||||||
|
- 特殊的、非常规的需要写注释
|
||||||
|
- 变量、函数名、Docstring 用全英文
|
||||||
|
- 同一事物用统一表达
|
||||||
|
- 模块、函数、变量名,前缀针对不同的命名规范,使用`O *`、`o_*`、`O_*`、`o.*`、`o-*`
|
||||||
|
- 使用的依赖越少越好
|
||||||
|
- 选用简单直接的方式实现
|
||||||
|
- 代码、执行方式改变,调整已有的文档、计划,而非新建
|
||||||
|
|
||||||
|
## Shell 规范
|
||||||
|
|
||||||
|
- 优先使用、提供 Git Bash 执行所有 Shell 命令
|
||||||
|
- Windows 路径在 Bash 命令中优先使用正斜杠形式
|
||||||
8
LICENSE
Normal file
8
LICENSE
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
This project is licensed under the GNU General Public License,
|
||||||
|
version 3 or any later version.
|
||||||
|
|
||||||
|
The complete license text is available at:
|
||||||
|
https://www.gnu.org/licenses/gpl-3.0.txt
|
||||||
154
README.md
Normal file
154
README.md
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
# OmooLab DA3
|
||||||
|
|
||||||
|
`OmooLab DA3` 是基于
|
||||||
|
[Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3)
|
||||||
|
的 Blender Extension。Extension 在独立 Runtime 中运行 DA3,将官方导出结果保存在
|
||||||
|
独立 job 目录,并在任务完成后使用系统文件管理器打开该目录。
|
||||||
|
|
||||||
|
## 当前功能
|
||||||
|
|
||||||
|
- 使用 Blender Extension manifest,可通过 `Install from Disk` 安装
|
||||||
|
- 在独立 Python Runtime 中运行 Torch 与 DA3,避免污染 Blender Python 进程
|
||||||
|
- Runtime、模型和任务结果默认保存在 `~/.omoo_da3`,可由多个 Blender 版本共享
|
||||||
|
- 支持 DA3 Small、Base、Large 1.1、Giant 1.1 和 Nested Giant Large 1.1
|
||||||
|
- 支持单张 JPG、JPEG、PNG、WEBP 图片或常见视频文件输入
|
||||||
|
- 视频均匀抽帧,可设置参与重建的最大帧数
|
||||||
|
- `Setup` 集中管理 Runtime,并逐项列出和下载所有可用模型
|
||||||
|
- Runtime 已就绪时不额外占用面板空间;模型列表每个模型只占一行
|
||||||
|
- `Debug` 是独立且默认折叠的 DA3 测试面板
|
||||||
|
- 使用一个导出类型下拉菜单和一个 `Export` 按钮
|
||||||
|
- 只显示当前格式的专属参数;GLB 与 PLY 设置不会同时出现
|
||||||
|
- Debug 的模型选项按格式过滤;PLY 只提供两个带 Gaussian head 的 Giant 模型
|
||||||
|
- Debug 在模型选项同一行用勾选或警告图标表示下载状态,不显示状态文字、License 和
|
||||||
|
已完成任务的文件计数
|
||||||
|
- 分别调用 DA3 的 `glb`、`gs_ply`、`depth_vis` 和 `mini_npz` exporter
|
||||||
|
- 导出完成后只打开对应 job 文件夹,不向当前 Blender 文件添加任何数据
|
||||||
|
- 完整保留每次任务生成的所有官方文件
|
||||||
|
- 安装、下载和推理均在后台子进程运行,状态栏显示阶段、百分比进度条与取消按钮
|
||||||
|
|
||||||
|
Blender 端不读取或转换导出文件,也不会重新反投影深度、构造网格、转换坐标、创建材质或
|
||||||
|
Geometry Nodes。
|
||||||
|
|
||||||
|
## 使用
|
||||||
|
|
||||||
|
1. 使用 `uv run python pack.py` 构建 Extension ZIP。
|
||||||
|
2. 在 Blender 的 `Preferences > Extensions` 中选择 `Install from Disk`。
|
||||||
|
3. 在 Extension Preferences 中确认 `uv` 路径和 Torch Backend。
|
||||||
|
4. 点击 `Install Runtime`。首次安装会下载 Python、PyTorch 和 DA3 依赖。
|
||||||
|
5. 在 3D View 按 `N`,打开 `Omoo DA3` 页签。
|
||||||
|
6. 在 `Setup` 中找到需要的模型并点击该行的 `Download`。
|
||||||
|
7. 展开默认收起的 `Debug`,选择 `Export Format` 和本次执行使用的模型。
|
||||||
|
8. 选择一张图片或一个视频文件。视频可设置均匀抽取的帧数,然后点击 `Export`。
|
||||||
|
|
||||||
|
选择 PLY 后,模型菜单只会提供 `DA3 Giant 1.1` 和
|
||||||
|
`DA3 Nested Giant Large 1.1`。Small、Base 和 Large 没有 Gaussian head,无法由
|
||||||
|
DA3 生成 `gs_ply`。
|
||||||
|
|
||||||
|
每次任务会在 `~/.omoo_da3/jobs/<任务目录>/` 中保留 DA3 官方产物:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<GLB 任务目录>/
|
||||||
|
└── scene.glb
|
||||||
|
|
||||||
|
<PLY 任务目录>/
|
||||||
|
└── gs_ply/*.ply
|
||||||
|
|
||||||
|
<Depth 任务目录>/
|
||||||
|
└── depth_vis/*.jpg
|
||||||
|
|
||||||
|
<Mini NPZ 任务目录>/
|
||||||
|
└── exports/mini_npz/results.npz
|
||||||
|
```
|
||||||
|
|
||||||
|
建议从 `DA3 Base` 开始。它使用 Apache 2.0 模型权重;`DA3 Large 1.1` 权重采用
|
||||||
|
CC BY-NC 4.0,仅限非商业用途。
|
||||||
|
|
||||||
|
## 输出格式说明
|
||||||
|
|
||||||
|
`Export Format` 下拉菜单分别把以下原始 `export_format` 传给 DA3:
|
||||||
|
|
||||||
|
- GLB:`glb`
|
||||||
|
- PLY:`gs_ply`,同时传入 `infer_gs=True`
|
||||||
|
- Depth Images:`depth_vis`
|
||||||
|
- Mini NPZ:`mini_npz`
|
||||||
|
|
||||||
|
界面使用 DA3 API 的参数名、选项和默认值。GLB 专属设置仅在选择 `GLB` 时显示,
|
||||||
|
`gs_views_interval` 仅在选择 `PLY (3DGS)` 时显示:
|
||||||
|
|
||||||
|
- `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` 保存深度、置信度和可用的相机参数。
|
||||||
|
GLB 任务会关闭其默认附带的深度预览,使四种 job 产物保持分离。
|
||||||
|
|
||||||
|
官方格式与限制可参阅
|
||||||
|
[DA3 Python API 文档](https://github.com/ByteDance-Seed/Depth-Anything-3/blob/main/docs/API.md)。
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
Extension 本身不包含 Torch。`Install Runtime` 使用 `uv` 创建独立 Python 3.12
|
||||||
|
环境,并安装:
|
||||||
|
|
||||||
|
- PyTorch 2.9.1
|
||||||
|
- TorchVision 0.24.1
|
||||||
|
- xFormers 0.0.33.post2(与 PyTorch 2.9.1 对应)
|
||||||
|
- 固定到已验证 commit 的 Depth Anything 3
|
||||||
|
- 固定到 DA3 官方推荐 commit 的 `gsplat`;仅安装 PLY 导出所需的纯 Python 部分,
|
||||||
|
不编译本机 CUDA rasterizer
|
||||||
|
- `addict` 2.4.0(补充 DA3 上游包元数据暂未声明的运行依赖)
|
||||||
|
|
||||||
|
默认 CUDA Backend 为 CUDA 12.8,也可以选择 CPU。CPU 可以用于验证,但实际推理
|
||||||
|
通常很慢。
|
||||||
|
|
||||||
|
Runtime、模型和任务结果默认保存在用户主目录的 `~/.omoo_da3`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
~/.omoo_da3/
|
||||||
|
├── runtime/
|
||||||
|
├── models/
|
||||||
|
└── jobs/
|
||||||
|
```
|
||||||
|
|
||||||
|
不同 Blender 版本安装相同 Extension 后会共用这个目录。也可以在 Extension
|
||||||
|
Preferences 的 `Storage Root` 中修改共享位置。不要把模型写入 Extension 安装目录,
|
||||||
|
因为该目录可能只读,并且升级时会被替换。不要同时从多个 Blender 实例执行
|
||||||
|
`Install Runtime`;推理任务和模型读取可以共享。
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run python -m unittest discover -s tests -v
|
||||||
|
uv run python pack.py
|
||||||
|
```
|
||||||
|
|
||||||
|
项目结构:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.
|
||||||
|
├── pack.py
|
||||||
|
├── src/
|
||||||
|
│ └── omoo_da3/
|
||||||
|
│ ├── blender_manifest.toml
|
||||||
|
│ ├── bootstrap_runtime.py
|
||||||
|
│ ├── models.py
|
||||||
|
│ ├── operators.py
|
||||||
|
│ ├── panel.py
|
||||||
|
│ ├── preferences.py
|
||||||
|
│ ├── properties.py
|
||||||
|
│ ├── runtime.py
|
||||||
|
│ └── worker.py
|
||||||
|
└── tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 上游与许可证
|
||||||
|
|
||||||
|
- Depth Anything 3 代码采用 Apache 2.0。
|
||||||
|
- 本 Extension 代码采用 GPL-3.0-or-later。
|
||||||
|
- 模型权重许可证由所选 Hugging Face 模型仓库决定,Extension 下载前会在界面显示。
|
||||||
32
pack.py
Normal file
32
pack.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZIP_DEFLATED, ZipFile
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent
|
||||||
|
SOURCE_DIR = PROJECT_ROOT / "src" / "omoo_da3"
|
||||||
|
DIST_DIR = PROJECT_ROOT / "dist"
|
||||||
|
|
||||||
|
|
||||||
|
def build_extension():
|
||||||
|
manifest = tomllib.loads(
|
||||||
|
(SOURCE_DIR / "blender_manifest.toml").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
output_path = DIST_DIR / f"{manifest['id']}-{manifest['version']}.zip"
|
||||||
|
DIST_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
with ZipFile(output_path, "w", ZIP_DEFLATED) as archive:
|
||||||
|
for source_path in sorted(SOURCE_DIR.rglob("*")):
|
||||||
|
if not source_path.is_file():
|
||||||
|
continue
|
||||||
|
if "__pycache__" in source_path.parts:
|
||||||
|
continue
|
||||||
|
archive.write(source_path, source_path.relative_to(SOURCE_DIR))
|
||||||
|
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
extension_path = build_extension()
|
||||||
|
size_mib = extension_path.stat().st_size / (1024 * 1024)
|
||||||
|
print(f"Built {extension_path} ({size_mib:.2f} MiB)")
|
||||||
11
pyproject.toml
Normal file
11
pyproject.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[project]
|
||||||
|
name = "omoo-da3"
|
||||||
|
version = "0.8.1"
|
||||||
|
description = "Direct Depth Anything 3 exports from Blender"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
license = "GPL-3.0-or-later"
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
package = false
|
||||||
41
src/omoo_da3/__init__.py
Normal file
41
src/omoo_da3/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import bpy
|
||||||
|
|
||||||
|
from .operators import (
|
||||||
|
OMOO_DA3_OT_cancel_job,
|
||||||
|
OMOO_DA3_OT_run_job,
|
||||||
|
cancel_active_job,
|
||||||
|
close_active_job,
|
||||||
|
)
|
||||||
|
from .panel import OMOO_DA3_PT_debug, OMOO_DA3_PT_runtime_models
|
||||||
|
from .preferences import OMOO_DA3_preferences
|
||||||
|
from .properties import OMOO_DA3_PG_settings
|
||||||
|
from .status_bar import draw_status_bar
|
||||||
|
|
||||||
|
|
||||||
|
CLASSES = (
|
||||||
|
OMOO_DA3_preferences,
|
||||||
|
OMOO_DA3_PG_settings,
|
||||||
|
OMOO_DA3_OT_run_job,
|
||||||
|
OMOO_DA3_OT_cancel_job,
|
||||||
|
OMOO_DA3_PT_runtime_models,
|
||||||
|
OMOO_DA3_PT_debug,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register():
|
||||||
|
for class_type in CLASSES:
|
||||||
|
bpy.utils.register_class(class_type)
|
||||||
|
bpy.types.Scene.omoo_da3_settings = bpy.props.PointerProperty(
|
||||||
|
type=OMOO_DA3_PG_settings,
|
||||||
|
)
|
||||||
|
bpy.types.STATUSBAR_HT_header.prepend(draw_status_bar)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister():
|
||||||
|
cancel_active_job()
|
||||||
|
close_active_job()
|
||||||
|
bpy.types.STATUSBAR_HT_header.remove(draw_status_bar)
|
||||||
|
if hasattr(bpy.types.Scene, "omoo_da3_settings"):
|
||||||
|
del bpy.types.Scene.omoo_da3_settings
|
||||||
|
for class_type in reversed(CLASSES):
|
||||||
|
bpy.utils.unregister_class(class_type)
|
||||||
23
src/omoo_da3/blender_manifest.toml
Normal file
23
src/omoo_da3/blender_manifest.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
schema_version = "1.0.0"
|
||||||
|
|
||||||
|
id = "omoo_da3"
|
||||||
|
version = "0.8.1"
|
||||||
|
name = "OmooLab DA3"
|
||||||
|
tagline = "Export official DA3 GLB, PLY, depth, and mini NPZ"
|
||||||
|
maintainer = "OmooLab"
|
||||||
|
type = "add-on"
|
||||||
|
|
||||||
|
blender_version_min = "4.5.0"
|
||||||
|
platforms = ["windows-x64"]
|
||||||
|
license = ["SPDX:GPL-3.0-or-later"]
|
||||||
|
|
||||||
|
[permissions]
|
||||||
|
network = "Download DA3 runtime dependencies and model weights"
|
||||||
|
files = "Read source images and store runtime data"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
paths_exclude_pattern = [
|
||||||
|
"__pycache__/",
|
||||||
|
".*",
|
||||||
|
"*.zip",
|
||||||
|
]
|
||||||
203
src/omoo_da3/bootstrap_runtime.py
Normal file
203
src/omoo_da3/bootstrap_runtime.py
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
TORCH_VERSION = "2.9.1"
|
||||||
|
TORCHVISION_VERSION = "0.24.1"
|
||||||
|
XFORMERS_VERSION = "0.0.33.post2"
|
||||||
|
ADDICT_VERSION = "2.4.0"
|
||||||
|
GSPLAT_COMMIT = "0b4dddf04cb687367602c01196913cde6a743d70"
|
||||||
|
RUNTIME_SCHEMA = 2
|
||||||
|
TORCH_INDEXES = {
|
||||||
|
"cu128": "https://download.pytorch.org/whl/cu128",
|
||||||
|
"cpu": "https://download.pytorch.org/whl/cpu",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def write_status(path, progress, message, error=None):
|
||||||
|
payload = {"progress": progress, "message": message}
|
||||||
|
if error:
|
||||||
|
payload["error"] = error
|
||||||
|
Path(path).write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run(command, environment=None):
|
||||||
|
print("Running:", subprocess.list2cmdline([str(value) for value in command]))
|
||||||
|
process_environment = None
|
||||||
|
if environment:
|
||||||
|
process_environment = os.environ.copy()
|
||||||
|
process_environment.update(environment)
|
||||||
|
subprocess.check_call(
|
||||||
|
[str(value) for value in command],
|
||||||
|
env=process_environment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def environment_python(runtime_dir):
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return Path(runtime_dir) / "Scripts" / "python.exe"
|
||||||
|
return Path(runtime_dir) / "bin" / "python"
|
||||||
|
|
||||||
|
|
||||||
|
def install(args):
|
||||||
|
runtime_dir = Path(args.runtime_dir)
|
||||||
|
runtime_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
write_status(args.status, 0.03, "Creating isolated Python Runtime")
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
args.uv,
|
||||||
|
"venv",
|
||||||
|
"--clear",
|
||||||
|
"--python",
|
||||||
|
"3.12",
|
||||||
|
runtime_dir,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
python = environment_python(runtime_dir)
|
||||||
|
|
||||||
|
write_status(args.status, 0.12, "Installing PyTorch")
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
args.uv,
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--python",
|
||||||
|
python,
|
||||||
|
f"torch=={TORCH_VERSION}",
|
||||||
|
f"torchvision=={TORCHVISION_VERSION}",
|
||||||
|
"--index-url",
|
||||||
|
TORCH_INDEXES[args.backend],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
write_status(args.status, 0.38, "Installing matching xFormers")
|
||||||
|
xformers_command = [
|
||||||
|
args.uv,
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--python",
|
||||||
|
python,
|
||||||
|
f"xformers=={XFORMERS_VERSION}",
|
||||||
|
]
|
||||||
|
if args.backend == "cu128":
|
||||||
|
xformers_command.extend(
|
||||||
|
["--index-url", TORCH_INDEXES[args.backend]]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
xformers_command.append("--no-deps")
|
||||||
|
run(xformers_command)
|
||||||
|
|
||||||
|
write_status(args.status, 0.50, "Installing Depth Anything 3")
|
||||||
|
da3_requirement = (
|
||||||
|
"depth-anything-3 @ "
|
||||||
|
"git+https://github.com/ByteDance-Seed/Depth-Anything-3.git"
|
||||||
|
f"@{args.da3_commit}"
|
||||||
|
)
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
args.uv,
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--python",
|
||||||
|
python,
|
||||||
|
da3_requirement,
|
||||||
|
f"addict=={ADDICT_VERSION}",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
write_status(args.status, 0.78, "Installing gsplat build tools")
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
args.uv,
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--python",
|
||||||
|
python,
|
||||||
|
"setuptools",
|
||||||
|
"wheel",
|
||||||
|
"ninja",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
write_status(args.status, 0.84, "Installing gsplat")
|
||||||
|
# PLY 导出不调用 rasterizer,禁用本地扩展可避免 CUDA 与 MSVC 工具链耦合。
|
||||||
|
gsplat_environment = {"BUILD_NO_CUDA": "1"}
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
args.uv,
|
||||||
|
"pip",
|
||||||
|
"install",
|
||||||
|
"--python",
|
||||||
|
python,
|
||||||
|
"--no-build-isolation",
|
||||||
|
(
|
||||||
|
"gsplat @ git+https://github.com/nerfstudio-project/"
|
||||||
|
f"gsplat.git@{GSPLAT_COMMIT}"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
gsplat_environment,
|
||||||
|
)
|
||||||
|
|
||||||
|
write_status(args.status, 0.92, "Checking Runtime")
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
python,
|
||||||
|
"-c",
|
||||||
|
(
|
||||||
|
"import torch; "
|
||||||
|
"from depth_anything_3.api import DepthAnything3; "
|
||||||
|
"import gsplat; "
|
||||||
|
"print(torch.__version__, torch.cuda.is_available())"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
ready_path = runtime_dir / "omoo_da3_ready.json"
|
||||||
|
ready_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"runtime_schema": RUNTIME_SCHEMA,
|
||||||
|
"python": "3.12",
|
||||||
|
"torch": TORCH_VERSION,
|
||||||
|
"xformers": XFORMERS_VERSION,
|
||||||
|
"backend": args.backend,
|
||||||
|
"da3_commit": args.da3_commit,
|
||||||
|
"gsplat_commit": GSPLAT_COMMIT,
|
||||||
|
"gsplat_cuda": False,
|
||||||
|
},
|
||||||
|
indent=2,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
write_status(args.status, 1.0, "Runtime is ready")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--uv", required=True)
|
||||||
|
parser.add_argument("--runtime-dir", required=True)
|
||||||
|
parser.add_argument("--backend", choices=tuple(TORCH_INDEXES), required=True)
|
||||||
|
parser.add_argument("--da3-commit", required=True)
|
||||||
|
parser.add_argument("--status", required=True)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parse_args()
|
||||||
|
try:
|
||||||
|
install(args)
|
||||||
|
except Exception as error:
|
||||||
|
write_status(args.status, 1.0, "Runtime installation failed", str(error))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
81
src/omoo_da3/models.py
Normal file
81
src/omoo_da3/models.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ModelSpec:
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
repository: str
|
||||||
|
license_name: str
|
||||||
|
description: str
|
||||||
|
supports_gs: bool
|
||||||
|
|
||||||
|
|
||||||
|
MODELS = {
|
||||||
|
"SMALL": ModelSpec(
|
||||||
|
key="SMALL",
|
||||||
|
label="DA3 Small",
|
||||||
|
repository="depth-anything/DA3-SMALL",
|
||||||
|
license_name="Apache 2.0",
|
||||||
|
description="Fastest any-view model with Apache 2.0 weights",
|
||||||
|
supports_gs=False,
|
||||||
|
),
|
||||||
|
"BASE": ModelSpec(
|
||||||
|
key="BASE",
|
||||||
|
label="DA3 Base",
|
||||||
|
repository="depth-anything/DA3-BASE",
|
||||||
|
license_name="Apache 2.0",
|
||||||
|
description="Balanced any-view model with Apache 2.0 weights",
|
||||||
|
supports_gs=False,
|
||||||
|
),
|
||||||
|
"LARGE_1_1": ModelSpec(
|
||||||
|
key="LARGE_1_1",
|
||||||
|
label="DA3 Large 1.1",
|
||||||
|
repository="depth-anything/DA3-LARGE-1.1",
|
||||||
|
license_name="CC BY-NC 4.0",
|
||||||
|
description="Higher quality non-commercial model with larger VRAM use",
|
||||||
|
supports_gs=False,
|
||||||
|
),
|
||||||
|
"GIANT_1_1": ModelSpec(
|
||||||
|
key="GIANT_1_1",
|
||||||
|
label="DA3 Giant 1.1",
|
||||||
|
repository="depth-anything/DA3-GIANT-1.1",
|
||||||
|
license_name="CC BY-NC 4.0",
|
||||||
|
description="Non-commercial Giant model with Gaussian head",
|
||||||
|
supports_gs=True,
|
||||||
|
),
|
||||||
|
"NESTED_GIANT_LARGE_1_1": ModelSpec(
|
||||||
|
key="NESTED_GIANT_LARGE_1_1",
|
||||||
|
label="DA3 Nested Giant Large 1.1",
|
||||||
|
repository="depth-anything/DA3NESTED-GIANT-LARGE-1.1",
|
||||||
|
license_name="CC BY-NC 4.0",
|
||||||
|
description="Non-commercial nested model with metric depth and Gaussian head",
|
||||||
|
supports_gs=True,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def enum_items(supports_gs=None):
|
||||||
|
return [
|
||||||
|
(
|
||||||
|
model.key,
|
||||||
|
model.label,
|
||||||
|
f"{model.description}; license: {model.license_name}",
|
||||||
|
)
|
||||||
|
for model in MODELS.values()
|
||||||
|
if supports_gs is None or model.supports_gs == supports_gs
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def selected_model_key(settings, export_format=None):
|
||||||
|
selected_format = export_format or settings.export_format
|
||||||
|
if selected_format == "gs_ply":
|
||||||
|
return settings.gs_model
|
||||||
|
return settings.model
|
||||||
|
|
||||||
|
|
||||||
|
def get_model(model_key):
|
||||||
|
try:
|
||||||
|
return MODELS[model_key]
|
||||||
|
except KeyError as error:
|
||||||
|
raise ValueError(f"Unknown DA3 model: {model_key}") from error
|
||||||
283
src/omoo_da3/operators.py
Normal file
283
src/omoo_da3/operators.py
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
from . import runtime
|
||||||
|
from .models import get_model, selected_model_key
|
||||||
|
|
||||||
|
|
||||||
|
RECONSTRUCTION_JOB = "RECONSTRUCT"
|
||||||
|
|
||||||
|
|
||||||
|
def open_job_directory(result_directory):
|
||||||
|
result_directory = Path(result_directory)
|
||||||
|
if not result_directory.is_dir():
|
||||||
|
raise RuntimeError(f"Job directory does not exist: {result_directory}")
|
||||||
|
result = bpy.ops.wm.path_open(filepath=str(result_directory))
|
||||||
|
if "FINISHED" not in result:
|
||||||
|
raise RuntimeError(f"Unable to open job directory: {result_directory}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ActiveJob:
|
||||||
|
kind: str
|
||||||
|
process: subprocess.Popen
|
||||||
|
log_file: object
|
||||||
|
log_path: Path
|
||||||
|
status_path: Path
|
||||||
|
result_directory: Path
|
||||||
|
export_format: str = ""
|
||||||
|
cancelled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
_active_job = None
|
||||||
|
|
||||||
|
|
||||||
|
def active_job():
|
||||||
|
return _active_job
|
||||||
|
|
||||||
|
|
||||||
|
def cancel_active_job():
|
||||||
|
global _active_job
|
||||||
|
|
||||||
|
if _active_job is None:
|
||||||
|
return
|
||||||
|
_active_job.cancelled = True
|
||||||
|
if _active_job.process.poll() is None:
|
||||||
|
_active_job.process.terminate()
|
||||||
|
|
||||||
|
|
||||||
|
def close_active_job():
|
||||||
|
global _active_job
|
||||||
|
|
||||||
|
if _active_job is None:
|
||||||
|
return
|
||||||
|
_active_job.log_file.close()
|
||||||
|
_active_job = None
|
||||||
|
|
||||||
|
|
||||||
|
class OMOO_DA3_OT_run_job(bpy.types.Operator):
|
||||||
|
bl_idname = "omoo_da3.run_job"
|
||||||
|
bl_label = "Run Omoo DA3 Job"
|
||||||
|
bl_options = {"INTERNAL"}
|
||||||
|
|
||||||
|
job_type: bpy.props.EnumProperty(
|
||||||
|
items=(
|
||||||
|
("INSTALL_RUNTIME", "Install Runtime", ""),
|
||||||
|
("DOWNLOAD_MODEL", "Download Model", ""),
|
||||||
|
(RECONSTRUCTION_JOB, "Export DA3 Result", ""),
|
||||||
|
),
|
||||||
|
options={"HIDDEN", "SKIP_SAVE"},
|
||||||
|
)
|
||||||
|
model_key: bpy.props.StringProperty(
|
||||||
|
options={"HIDDEN", "SKIP_SAVE"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
global _active_job
|
||||||
|
|
||||||
|
if active_job() is not None:
|
||||||
|
self.report({"WARNING"}, "Another Omoo DA3 task is running")
|
||||||
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
settings = context.scene.omoo_da3_settings
|
||||||
|
try:
|
||||||
|
command, paths = self._prepare_command(context, settings)
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
self.report({"ERROR"}, str(error))
|
||||||
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
log_file = paths["log"].open("w", encoding="utf-8")
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(
|
||||||
|
command,
|
||||||
|
stdout=log_file,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
cwd=runtime.storage_root(),
|
||||||
|
creationflags=runtime.creation_flags(),
|
||||||
|
)
|
||||||
|
except OSError as error:
|
||||||
|
log_file.close()
|
||||||
|
self.report({"ERROR"}, f"Unable to start DA3 task: {error}")
|
||||||
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
_active_job = ActiveJob(
|
||||||
|
kind=self.job_type,
|
||||||
|
process=process,
|
||||||
|
log_file=log_file,
|
||||||
|
log_path=paths["log"],
|
||||||
|
status_path=paths["status"],
|
||||||
|
result_directory=paths["result"],
|
||||||
|
export_format=(
|
||||||
|
settings.export_format
|
||||||
|
if self.job_type == RECONSTRUCTION_JOB
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
)
|
||||||
|
settings.status = self._starting_message(settings)
|
||||||
|
settings.progress = 0.0
|
||||||
|
self._timer = context.window_manager.event_timer_add(
|
||||||
|
0.25,
|
||||||
|
window=context.window,
|
||||||
|
)
|
||||||
|
context.window_manager.modal_handler_add(self)
|
||||||
|
self._redraw_ui(context, force=True)
|
||||||
|
return {"RUNNING_MODAL"}
|
||||||
|
|
||||||
|
def _prepare_command(self, _context, settings):
|
||||||
|
paths = runtime.new_job_paths(self.job_type)
|
||||||
|
if self.job_type == "INSTALL_RUNTIME":
|
||||||
|
self._require_online_access()
|
||||||
|
return runtime.install_runtime_command(paths["status"]), paths
|
||||||
|
|
||||||
|
if not runtime.runtime_ready():
|
||||||
|
raise RuntimeError("Install the Omoo DA3 Runtime first")
|
||||||
|
|
||||||
|
if self.job_type == "DOWNLOAD_MODEL":
|
||||||
|
self._require_online_access()
|
||||||
|
model = get_model(self.model_key)
|
||||||
|
return (
|
||||||
|
runtime.download_model_command(model.key, paths["status"]),
|
||||||
|
paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
input_path = Path(bpy.path.abspath(settings.input_path))
|
||||||
|
if not input_path.is_file():
|
||||||
|
raise ValueError("Select a valid image or video file")
|
||||||
|
model_key = selected_model_key(settings)
|
||||||
|
model = get_model(model_key)
|
||||||
|
if not runtime.model_ready(model_key):
|
||||||
|
raise RuntimeError(f"Download {model.label} first")
|
||||||
|
if self.job_type != RECONSTRUCTION_JOB:
|
||||||
|
raise RuntimeError(f"Unsupported task type: {self.job_type}")
|
||||||
|
export_format = settings.export_format
|
||||||
|
if export_format == "gs_ply" and not model.supports_gs:
|
||||||
|
raise RuntimeError(
|
||||||
|
"PLY export requires a DA3 Giant or Nested Giant model"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
runtime.reconstruct_command(
|
||||||
|
settings,
|
||||||
|
export_format,
|
||||||
|
paths["status"],
|
||||||
|
paths["result"],
|
||||||
|
),
|
||||||
|
paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _require_online_access(self):
|
||||||
|
if not bpy.app.online_access:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Online Access is disabled in Blender Preferences"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _starting_message(self, settings):
|
||||||
|
labels = {
|
||||||
|
"INSTALL_RUNTIME": "Starting Runtime installation",
|
||||||
|
"DOWNLOAD_MODEL": "Starting model download",
|
||||||
|
}
|
||||||
|
if self.job_type == RECONSTRUCTION_JOB:
|
||||||
|
return f"Starting DA3 {settings.export_format} export"
|
||||||
|
if self.job_type == "DOWNLOAD_MODEL":
|
||||||
|
return f"Starting {get_model(self.model_key).label} download"
|
||||||
|
return labels[self.job_type]
|
||||||
|
|
||||||
|
def modal(self, context, event):
|
||||||
|
if event.type == "ESC":
|
||||||
|
cancel_active_job()
|
||||||
|
|
||||||
|
if event.type != "TIMER":
|
||||||
|
return {"PASS_THROUGH"}
|
||||||
|
|
||||||
|
job = active_job()
|
||||||
|
if job is None:
|
||||||
|
self._remove_timer(context)
|
||||||
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
self._update_status(context, job)
|
||||||
|
return_code = job.process.poll()
|
||||||
|
if return_code is None:
|
||||||
|
return {"RUNNING_MODAL"}
|
||||||
|
return self._finish(context, job, return_code)
|
||||||
|
|
||||||
|
def _update_status(self, context, job):
|
||||||
|
status = runtime.read_status(job.status_path)
|
||||||
|
if status is None:
|
||||||
|
return
|
||||||
|
settings = context.scene.omoo_da3_settings
|
||||||
|
settings.progress = float(status.get("progress", settings.progress))
|
||||||
|
settings.status = status.get("message", settings.status)
|
||||||
|
self._redraw_ui(context)
|
||||||
|
|
||||||
|
def _finish(self, context, job, return_code):
|
||||||
|
settings = context.scene.omoo_da3_settings
|
||||||
|
self._remove_timer(context)
|
||||||
|
job.log_file.close()
|
||||||
|
|
||||||
|
if job.cancelled:
|
||||||
|
settings.status = "Task cancelled"
|
||||||
|
settings.progress = 0.0
|
||||||
|
close_active_job()
|
||||||
|
self._redraw_ui(context, force=True)
|
||||||
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
status = runtime.read_status(job.status_path) or {}
|
||||||
|
error = status.get("error")
|
||||||
|
if return_code != 0 or error:
|
||||||
|
settings.status = "Task failed"
|
||||||
|
settings.progress = 0.0
|
||||||
|
self.report(
|
||||||
|
{"ERROR"},
|
||||||
|
f"{error or 'DA3 subprocess failed'}; log: {job.log_path}",
|
||||||
|
)
|
||||||
|
close_active_job()
|
||||||
|
self._redraw_ui(context, force=True)
|
||||||
|
return {"CANCELLED"}
|
||||||
|
|
||||||
|
if job.kind == RECONSTRUCTION_JOB:
|
||||||
|
try:
|
||||||
|
open_job_directory(job.result_directory)
|
||||||
|
except Exception as open_error:
|
||||||
|
self.report({"WARNING"}, str(open_error))
|
||||||
|
self.report(
|
||||||
|
{"INFO"},
|
||||||
|
f"Exported {job.export_format} to {job.result_directory}",
|
||||||
|
)
|
||||||
|
|
||||||
|
settings.status = status.get("message", "Task complete")
|
||||||
|
settings.progress = 1.0
|
||||||
|
close_active_job()
|
||||||
|
self._redraw_ui(context, force=True)
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
def _remove_timer(self, context):
|
||||||
|
timer = getattr(self, "_timer", None)
|
||||||
|
if timer is None:
|
||||||
|
return
|
||||||
|
context.window_manager.event_timer_remove(timer)
|
||||||
|
self._timer = None
|
||||||
|
|
||||||
|
def _redraw_ui(self, context, force=False):
|
||||||
|
from .status_bar import redraw_ui
|
||||||
|
|
||||||
|
redraw_ui(context, force=force)
|
||||||
|
|
||||||
|
def cancel(self, context):
|
||||||
|
cancel_active_job()
|
||||||
|
self._remove_timer(context)
|
||||||
|
self._redraw_ui(context, force=True)
|
||||||
|
|
||||||
|
|
||||||
|
class OMOO_DA3_OT_cancel_job(bpy.types.Operator):
|
||||||
|
bl_idname = "omoo_da3.cancel_job"
|
||||||
|
bl_label = "Cancel Omoo DA3 Task"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def poll(cls, _context):
|
||||||
|
return active_job() is not None
|
||||||
|
|
||||||
|
def execute(self, _context):
|
||||||
|
cancel_active_job()
|
||||||
|
return {"FINISHED"}
|
||||||
112
src/omoo_da3/panel.py
Normal file
112
src/omoo_da3/panel.py
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import bpy
|
||||||
|
|
||||||
|
from .models import MODELS, selected_model_key
|
||||||
|
from .operators import active_job
|
||||||
|
from .runtime import model_ready, runtime_ready
|
||||||
|
|
||||||
|
|
||||||
|
class OMOO_DA3_PT_runtime_models(bpy.types.Panel):
|
||||||
|
bl_label = "Setup"
|
||||||
|
bl_idname = "OMOO_DA3_PT_runtime_models"
|
||||||
|
bl_space_type = "VIEW_3D"
|
||||||
|
bl_region_type = "UI"
|
||||||
|
bl_category = "Omoo DA3"
|
||||||
|
|
||||||
|
def draw(self, _context):
|
||||||
|
layout = self.layout
|
||||||
|
running = active_job() is not None
|
||||||
|
|
||||||
|
if not runtime_ready():
|
||||||
|
layout.label(text="Runtime is not installed", icon="ERROR")
|
||||||
|
if not running:
|
||||||
|
operator = layout.operator(
|
||||||
|
"omoo_da3.run_job",
|
||||||
|
text="Install Runtime",
|
||||||
|
icon="IMPORT",
|
||||||
|
)
|
||||||
|
operator.job_type = "INSTALL_RUNTIME"
|
||||||
|
|
||||||
|
for model in MODELS.values():
|
||||||
|
self._draw_model(layout, model, running)
|
||||||
|
|
||||||
|
def _draw_model(self, layout, model, running):
|
||||||
|
row = layout.row(align=True)
|
||||||
|
row.label(text=model.label)
|
||||||
|
if model_ready(model.key):
|
||||||
|
row.label(text="Ready", icon="CHECKMARK")
|
||||||
|
elif runtime_ready() and not running:
|
||||||
|
operator = row.operator(
|
||||||
|
"omoo_da3.run_job",
|
||||||
|
text="Download",
|
||||||
|
icon="IMPORT",
|
||||||
|
)
|
||||||
|
operator.job_type = "DOWNLOAD_MODEL"
|
||||||
|
operator.model_key = model.key
|
||||||
|
else:
|
||||||
|
row.label(text="Not downloaded", icon="ERROR")
|
||||||
|
|
||||||
|
|
||||||
|
class OMOO_DA3_PT_debug(bpy.types.Panel):
|
||||||
|
bl_label = "Debug"
|
||||||
|
bl_idname = "OMOO_DA3_PT_debug"
|
||||||
|
bl_space_type = "VIEW_3D"
|
||||||
|
bl_region_type = "UI"
|
||||||
|
bl_category = "Omoo DA3"
|
||||||
|
bl_options = {"DEFAULT_CLOSED"}
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
|
layout = self.layout
|
||||||
|
settings = context.scene.omoo_da3_settings
|
||||||
|
|
||||||
|
layout.prop(settings, "export_format")
|
||||||
|
model_key = selected_model_key(settings)
|
||||||
|
model_property = (
|
||||||
|
"gs_model" if settings.export_format == "gs_ply" else "model"
|
||||||
|
)
|
||||||
|
model_row = layout.row(align=True)
|
||||||
|
model_row.prop(settings, model_property)
|
||||||
|
if model_ready(model_key):
|
||||||
|
model_row.label(text="", icon="CHECKMARK")
|
||||||
|
else:
|
||||||
|
model_row.label(text="", icon="ERROR")
|
||||||
|
|
||||||
|
layout.prop(settings, "input_path")
|
||||||
|
input_path = bpy.path.abspath(settings.input_path)
|
||||||
|
if input_path.lower().endswith(
|
||||||
|
(
|
||||||
|
".mp4",
|
||||||
|
".mov",
|
||||||
|
".avi",
|
||||||
|
".mkv",
|
||||||
|
".webm",
|
||||||
|
".m4v",
|
||||||
|
".mts",
|
||||||
|
".mpg",
|
||||||
|
".mpeg",
|
||||||
|
)
|
||||||
|
):
|
||||||
|
layout.prop(settings, "video_frames")
|
||||||
|
layout.prop(settings, "device")
|
||||||
|
layout.prop(settings, "process_res")
|
||||||
|
layout.prop(settings, "process_res_method")
|
||||||
|
layout.prop(settings, "use_ray_pose")
|
||||||
|
layout.prop(settings, "ref_view_strategy")
|
||||||
|
|
||||||
|
if settings.export_format == "glb":
|
||||||
|
glb_box = layout.box()
|
||||||
|
glb_box.label(text="GLB Settings")
|
||||||
|
glb_box.prop(settings, "conf_thresh_percentile")
|
||||||
|
glb_box.prop(settings, "num_max_points")
|
||||||
|
glb_box.prop(settings, "show_cameras")
|
||||||
|
elif settings.export_format == "gs_ply":
|
||||||
|
ply_box = layout.box()
|
||||||
|
ply_box.label(text="PLY (3DGS) Settings")
|
||||||
|
ply_box.prop(settings, "gs_views_interval")
|
||||||
|
|
||||||
|
if active_job() is None:
|
||||||
|
operator = layout.operator(
|
||||||
|
"omoo_da3.run_job",
|
||||||
|
text="Export",
|
||||||
|
icon="EXPORT",
|
||||||
|
)
|
||||||
|
operator.job_type = "RECONSTRUCT"
|
||||||
52
src/omoo_da3/preferences.py
Normal file
52
src/omoo_da3/preferences.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
|
||||||
|
class OMOO_DA3_preferences(bpy.types.AddonPreferences):
|
||||||
|
bl_idname = __package__
|
||||||
|
|
||||||
|
storage_root: bpy.props.StringProperty(
|
||||||
|
name="Storage Root",
|
||||||
|
description=(
|
||||||
|
"Shared user-writable storage for Runtime, models and jobs; "
|
||||||
|
"all Blender versions can use the same directory"
|
||||||
|
),
|
||||||
|
subtype="DIR_PATH",
|
||||||
|
default=str(Path.home() / ".omoo_da3"),
|
||||||
|
)
|
||||||
|
uv_executable: bpy.props.StringProperty(
|
||||||
|
name="uv Executable",
|
||||||
|
description="Path to uv; a command name is accepted when uv is on PATH",
|
||||||
|
subtype="FILE_PATH",
|
||||||
|
default=shutil.which("uv") or "uv",
|
||||||
|
)
|
||||||
|
torch_backend: bpy.props.EnumProperty(
|
||||||
|
name="Torch Backend",
|
||||||
|
description="PyTorch build installed into the isolated Runtime",
|
||||||
|
items=(
|
||||||
|
("CU128", "CUDA 12.8", "NVIDIA CUDA 12.8 PyTorch wheels"),
|
||||||
|
("CPU", "CPU", "CPU-only PyTorch wheels"),
|
||||||
|
),
|
||||||
|
default="CU128",
|
||||||
|
)
|
||||||
|
|
||||||
|
def draw(self, _context):
|
||||||
|
from .runtime import runtime_directory, runtime_ready
|
||||||
|
|
||||||
|
layout = self.layout
|
||||||
|
layout.prop(self, "storage_root")
|
||||||
|
layout.prop(self, "uv_executable")
|
||||||
|
layout.prop(self, "torch_backend")
|
||||||
|
layout.separator()
|
||||||
|
layout.label(text=f"Runtime: {runtime_directory()}")
|
||||||
|
if runtime_ready():
|
||||||
|
layout.label(text="Runtime is ready", icon="CHECKMARK")
|
||||||
|
else:
|
||||||
|
operator = layout.operator(
|
||||||
|
"omoo_da3.run_job",
|
||||||
|
text="Install Runtime",
|
||||||
|
icon="IMPORT",
|
||||||
|
)
|
||||||
|
operator.job_type = "INSTALL_RUNTIME"
|
||||||
146
src/omoo_da3/properties.py
Normal file
146
src/omoo_da3/properties.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
import bpy
|
||||||
|
|
||||||
|
from .models import enum_items
|
||||||
|
|
||||||
|
|
||||||
|
class OMOO_DA3_PG_settings(bpy.types.PropertyGroup):
|
||||||
|
input_path: bpy.props.StringProperty(
|
||||||
|
name="Image or Video",
|
||||||
|
description="A single image or video file used for reconstruction",
|
||||||
|
subtype="FILE_PATH",
|
||||||
|
)
|
||||||
|
video_frames: bpy.props.IntProperty(
|
||||||
|
name="Video Frames",
|
||||||
|
description="Maximum number of frames sampled evenly from a video",
|
||||||
|
default=16,
|
||||||
|
min=2,
|
||||||
|
soft_max=64,
|
||||||
|
)
|
||||||
|
model: bpy.props.EnumProperty(
|
||||||
|
name="Model",
|
||||||
|
description="Depth Anything 3 model",
|
||||||
|
items=enum_items(),
|
||||||
|
default="BASE",
|
||||||
|
)
|
||||||
|
gs_model: bpy.props.EnumProperty(
|
||||||
|
name="Model",
|
||||||
|
description="Depth Anything 3 model with a Gaussian head",
|
||||||
|
items=enum_items(supports_gs=True),
|
||||||
|
default="GIANT_1_1",
|
||||||
|
)
|
||||||
|
device: bpy.props.EnumProperty(
|
||||||
|
name="Device",
|
||||||
|
description="Inference device used by the external Runtime",
|
||||||
|
items=(
|
||||||
|
("AUTO", "Auto", "Use CUDA when available, otherwise CPU"),
|
||||||
|
("CUDA", "CUDA", "Require an NVIDIA CUDA device"),
|
||||||
|
("CPU", "CPU", "Run on CPU"),
|
||||||
|
),
|
||||||
|
default="AUTO",
|
||||||
|
)
|
||||||
|
process_res: bpy.props.IntProperty(
|
||||||
|
name="Process Res",
|
||||||
|
description="DA3 base processing resolution",
|
||||||
|
default=504,
|
||||||
|
min=1,
|
||||||
|
soft_max=1008,
|
||||||
|
)
|
||||||
|
process_res_method: bpy.props.EnumProperty(
|
||||||
|
name="Process Res Method",
|
||||||
|
description="DA3 image resize method",
|
||||||
|
items=(
|
||||||
|
(
|
||||||
|
"upper_bound_resize",
|
||||||
|
"Upper Bound Resize",
|
||||||
|
"Make Process Res the longer side",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"lower_bound_resize",
|
||||||
|
"Lower Bound Resize",
|
||||||
|
"Make Process Res the shorter side",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
default="upper_bound_resize",
|
||||||
|
)
|
||||||
|
conf_thresh_percentile: bpy.props.FloatProperty(
|
||||||
|
name="Conf Thresh Percentile",
|
||||||
|
description="DA3 GLB confidence threshold percentile",
|
||||||
|
default=40.0,
|
||||||
|
min=0.0,
|
||||||
|
max=100.0,
|
||||||
|
subtype="PERCENTAGE",
|
||||||
|
)
|
||||||
|
num_max_points: bpy.props.IntProperty(
|
||||||
|
name="Num Max Points",
|
||||||
|
description="DA3 GLB maximum point count",
|
||||||
|
default=1_000_000,
|
||||||
|
min=1,
|
||||||
|
soft_max=5_000_000,
|
||||||
|
)
|
||||||
|
show_cameras: bpy.props.BoolProperty(
|
||||||
|
name="Show Cameras",
|
||||||
|
description="Include DA3 camera wireframes in the GLB export",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
use_ray_pose: bpy.props.BoolProperty(
|
||||||
|
name="Use Ray Pose",
|
||||||
|
description="Use the slower and more accurate ray-based pose estimation",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
ref_view_strategy: bpy.props.EnumProperty(
|
||||||
|
name="Ref View Strategy",
|
||||||
|
description="DA3 reference view selection strategy",
|
||||||
|
items=(
|
||||||
|
("first", "First", "Always use the first view"),
|
||||||
|
("middle", "Middle", "Use the middle view"),
|
||||||
|
(
|
||||||
|
"saddle_balanced",
|
||||||
|
"Saddle Balanced",
|
||||||
|
"Select a view with balanced feature metrics",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"saddle_sim_range",
|
||||||
|
"Saddle Sim Range",
|
||||||
|
"Select the view with the largest similarity range",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
default="saddle_balanced",
|
||||||
|
)
|
||||||
|
export_format: bpy.props.EnumProperty(
|
||||||
|
name="Export Format",
|
||||||
|
description="Official DA3 export format",
|
||||||
|
items=(
|
||||||
|
("glb", "GLB", "Export a colored point cloud as GLB"),
|
||||||
|
("gs_ply", "PLY (3DGS)", "Export Gaussian splats as PLY"),
|
||||||
|
(
|
||||||
|
"depth_vis",
|
||||||
|
"Depth Images",
|
||||||
|
"Export image and depth visualization pairs",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"mini_npz",
|
||||||
|
"Mini NPZ",
|
||||||
|
"Export compact depth and camera data",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
default="glb",
|
||||||
|
)
|
||||||
|
gs_views_interval: bpy.props.IntProperty(
|
||||||
|
name="GS Views Interval",
|
||||||
|
description="Export 3D Gaussians every N input views",
|
||||||
|
default=1,
|
||||||
|
min=1,
|
||||||
|
)
|
||||||
|
status: bpy.props.StringProperty(
|
||||||
|
name="Status",
|
||||||
|
default="Ready",
|
||||||
|
options={"SKIP_SAVE"},
|
||||||
|
)
|
||||||
|
progress: bpy.props.FloatProperty(
|
||||||
|
name="Progress",
|
||||||
|
default=0.0,
|
||||||
|
min=0.0,
|
||||||
|
max=1.0,
|
||||||
|
subtype="FACTOR",
|
||||||
|
options={"SKIP_SAVE"},
|
||||||
|
)
|
||||||
198
src/omoo_da3/runtime.py
Normal file
198
src/omoo_da3/runtime.py
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
|
||||||
|
from .models import get_model, selected_model_key
|
||||||
|
|
||||||
|
|
||||||
|
DA3_COMMIT = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4"
|
||||||
|
RUNTIME_SCHEMA = 2
|
||||||
|
DEFAULT_STORAGE_ROOT = Path.home() / ".omoo_da3"
|
||||||
|
|
||||||
|
|
||||||
|
def preferences():
|
||||||
|
addon = bpy.context.preferences.addons.get(__package__)
|
||||||
|
return addon.preferences if addon is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def storage_root():
|
||||||
|
addon_preferences = preferences()
|
||||||
|
configured = getattr(addon_preferences, "storage_root", "")
|
||||||
|
root = (
|
||||||
|
Path(bpy.path.abspath(configured)).expanduser()
|
||||||
|
if configured
|
||||||
|
else DEFAULT_STORAGE_ROOT
|
||||||
|
)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_directory():
|
||||||
|
return storage_root() / "runtime"
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_python():
|
||||||
|
directory = runtime_directory()
|
||||||
|
if os.name == "nt":
|
||||||
|
return directory / "Scripts" / "python.exe"
|
||||||
|
return directory / "bin" / "python"
|
||||||
|
|
||||||
|
|
||||||
|
def runtime_ready():
|
||||||
|
ready_path = runtime_directory() / "omoo_da3_ready.json"
|
||||||
|
if not runtime_python().is_file() or not ready_path.is_file():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
ready = json.loads(ready_path.read_text(encoding="utf-8"))
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return False
|
||||||
|
return ready.get("runtime_schema") == RUNTIME_SCHEMA
|
||||||
|
|
||||||
|
|
||||||
|
def models_directory():
|
||||||
|
path = storage_root() / "models"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def model_directory(model_key):
|
||||||
|
return models_directory() / model_key.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def model_ready(model_key):
|
||||||
|
path = model_directory(model_key)
|
||||||
|
return (path / "config.json").is_file() and any(
|
||||||
|
path.glob("*.safetensors")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def jobs_directory():
|
||||||
|
path = storage_root() / "jobs"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def new_job_paths(job_type):
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
token = uuid.uuid4().hex[:8]
|
||||||
|
stem = f"{timestamp}-{job_type.lower()}-{token}"
|
||||||
|
directory = jobs_directory()
|
||||||
|
return {
|
||||||
|
"log": directory / f"{stem}.log",
|
||||||
|
"status": directory / f"{stem}.json",
|
||||||
|
"result": directory / stem,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def uv_executable():
|
||||||
|
addon_preferences = preferences()
|
||||||
|
configured = getattr(addon_preferences, "uv_executable", "uv")
|
||||||
|
resolved = shutil.which(configured)
|
||||||
|
if resolved:
|
||||||
|
return resolved
|
||||||
|
if Path(configured).is_file():
|
||||||
|
return str(Path(configured))
|
||||||
|
raise RuntimeError("uv executable was not found")
|
||||||
|
|
||||||
|
|
||||||
|
def install_runtime_command(status_path):
|
||||||
|
executable = uv_executable()
|
||||||
|
addon_preferences = preferences()
|
||||||
|
backend = getattr(addon_preferences, "torch_backend", "CU128")
|
||||||
|
script = Path(__file__).with_name("bootstrap_runtime.py")
|
||||||
|
return [
|
||||||
|
executable,
|
||||||
|
"run",
|
||||||
|
"--no-project",
|
||||||
|
"--python",
|
||||||
|
"3.12",
|
||||||
|
str(script),
|
||||||
|
"--uv",
|
||||||
|
executable,
|
||||||
|
"--runtime-dir",
|
||||||
|
str(runtime_directory()),
|
||||||
|
"--backend",
|
||||||
|
backend.lower(),
|
||||||
|
"--da3-commit",
|
||||||
|
DA3_COMMIT,
|
||||||
|
"--status",
|
||||||
|
str(status_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def download_model_command(model_key, status_path):
|
||||||
|
model = get_model(model_key)
|
||||||
|
return [
|
||||||
|
str(runtime_python()),
|
||||||
|
str(Path(__file__).with_name("worker.py")),
|
||||||
|
"download",
|
||||||
|
"--repository",
|
||||||
|
model.repository,
|
||||||
|
"--model-dir",
|
||||||
|
str(model_directory(model_key)),
|
||||||
|
"--status",
|
||||||
|
str(status_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def reconstruct_command(
|
||||||
|
settings,
|
||||||
|
export_format,
|
||||||
|
status_path,
|
||||||
|
result_directory,
|
||||||
|
):
|
||||||
|
model_key = selected_model_key(settings, export_format)
|
||||||
|
return [
|
||||||
|
str(runtime_python()),
|
||||||
|
str(Path(__file__).with_name("worker.py")),
|
||||||
|
"reconstruct",
|
||||||
|
"--input",
|
||||||
|
str(Path(bpy.path.abspath(settings.input_path))),
|
||||||
|
"--model-dir",
|
||||||
|
str(model_directory(model_key)),
|
||||||
|
"--output-dir",
|
||||||
|
str(result_directory),
|
||||||
|
"--export-format",
|
||||||
|
export_format,
|
||||||
|
"--status",
|
||||||
|
str(status_path),
|
||||||
|
"--process-res",
|
||||||
|
str(settings.process_res),
|
||||||
|
"--process-res-method",
|
||||||
|
settings.process_res_method,
|
||||||
|
"--conf-thresh-percentile",
|
||||||
|
str(settings.conf_thresh_percentile),
|
||||||
|
"--num-max-points",
|
||||||
|
str(settings.num_max_points),
|
||||||
|
"--show-cameras",
|
||||||
|
"1" if settings.show_cameras else "0",
|
||||||
|
"--gs-views-interval",
|
||||||
|
str(settings.gs_views_interval),
|
||||||
|
"--video-frames",
|
||||||
|
str(settings.video_frames),
|
||||||
|
"--device",
|
||||||
|
settings.device.lower(),
|
||||||
|
"--use-ray-pose",
|
||||||
|
"1" if settings.use_ray_pose else "0",
|
||||||
|
"--ref-view-strategy",
|
||||||
|
settings.ref_view_strategy,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def creation_flags():
|
||||||
|
if os.name != "nt":
|
||||||
|
return 0
|
||||||
|
return subprocess.CREATE_NO_WINDOW
|
||||||
|
|
||||||
|
|
||||||
|
def read_status(path):
|
||||||
|
try:
|
||||||
|
return json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return None
|
||||||
51
src/omoo_da3/status_bar.py
Normal file
51
src/omoo_da3/status_bar.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import bpy
|
||||||
|
|
||||||
|
from .operators import active_job
|
||||||
|
|
||||||
|
|
||||||
|
def draw_status_bar(self, context):
|
||||||
|
scene = getattr(context, "scene", None)
|
||||||
|
if active_job() is None or scene is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
settings = getattr(scene, "omoo_da3_settings", None)
|
||||||
|
if settings is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
progress = min(max(settings.progress, 0.0), 1.0)
|
||||||
|
row = self.layout.row(align=True)
|
||||||
|
row.label(text=f"Omoo DA3: {settings.status}", icon="TIME")
|
||||||
|
|
||||||
|
progress_row = row.row(align=True)
|
||||||
|
progress_row.ui_units_x = 9.0
|
||||||
|
progress_row.progress(
|
||||||
|
factor=progress,
|
||||||
|
type="BAR",
|
||||||
|
text=f"{progress:.0%}",
|
||||||
|
)
|
||||||
|
row.operator(
|
||||||
|
"omoo_da3.cancel_job",
|
||||||
|
text="",
|
||||||
|
icon="X",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def redraw_ui(context, force=False):
|
||||||
|
window_manager = getattr(context, "window_manager", None)
|
||||||
|
if window_manager is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
for window in window_manager.windows:
|
||||||
|
screen = getattr(window, "screen", None)
|
||||||
|
if screen is None:
|
||||||
|
continue
|
||||||
|
for area in screen.areas:
|
||||||
|
if area.type in {"STATUSBAR", "VIEW_3D"}:
|
||||||
|
area.tag_redraw()
|
||||||
|
|
||||||
|
if not force or getattr(context, "window", None) is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
bpy.ops.wm.redraw_timer(type="DRAW_WIN_SWAP", iterations=1)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
304
src/omoo_da3/worker.py
Normal file
304
src/omoo_da3/worker.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".webp"}
|
||||||
|
VIDEO_SUFFIXES = {
|
||||||
|
".mp4",
|
||||||
|
".mov",
|
||||||
|
".avi",
|
||||||
|
".mkv",
|
||||||
|
".webm",
|
||||||
|
".m4v",
|
||||||
|
".mts",
|
||||||
|
".mpg",
|
||||||
|
".mpeg",
|
||||||
|
}
|
||||||
|
SCENE_FILENAME = "scene.glb"
|
||||||
|
EXPORT_FORMATS = ("glb", "gs_ply", "depth_vis", "mini_npz")
|
||||||
|
|
||||||
|
|
||||||
|
def write_status(path, progress, message, error=None):
|
||||||
|
payload = {"progress": progress, "message": message}
|
||||||
|
if error:
|
||||||
|
payload["error"] = error
|
||||||
|
Path(path).write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def collect_images(directory):
|
||||||
|
directory = Path(directory)
|
||||||
|
return sorted(
|
||||||
|
path
|
||||||
|
for path in directory.iterdir()
|
||||||
|
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_video_frames(video_path, output_directory, maximum_frames, cv2):
|
||||||
|
capture = cv2.VideoCapture(str(video_path))
|
||||||
|
if not capture.isOpened():
|
||||||
|
raise RuntimeError(f"Unable to open video: {video_path}")
|
||||||
|
|
||||||
|
output_directory = Path(output_directory)
|
||||||
|
output_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
total_frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||||
|
if total_frames <= 0:
|
||||||
|
capture.release()
|
||||||
|
raise RuntimeError("The video does not report a readable frame count")
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
sample_count = min(maximum_frames, total_frames)
|
||||||
|
frame_indices = np.linspace(
|
||||||
|
0,
|
||||||
|
total_frames - 1,
|
||||||
|
sample_count,
|
||||||
|
dtype=np.int64,
|
||||||
|
)
|
||||||
|
image_paths = []
|
||||||
|
try:
|
||||||
|
for output_index, frame_index in enumerate(frame_indices):
|
||||||
|
capture.set(cv2.CAP_PROP_POS_FRAMES, int(frame_index))
|
||||||
|
success, frame = capture.read()
|
||||||
|
if not success:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Unable to read video frame {int(frame_index)}"
|
||||||
|
)
|
||||||
|
image_path = output_directory / f"frame-{output_index:04d}.jpg"
|
||||||
|
if not cv2.imwrite(str(image_path), frame):
|
||||||
|
raise RuntimeError(f"Unable to write video frame: {image_path}")
|
||||||
|
image_paths.append(image_path)
|
||||||
|
finally:
|
||||||
|
capture.release()
|
||||||
|
|
||||||
|
return image_paths
|
||||||
|
|
||||||
|
|
||||||
|
def prepare_input(input_path, video_frames, temporary_root):
|
||||||
|
input_path = Path(input_path)
|
||||||
|
suffix = input_path.suffix.lower()
|
||||||
|
if suffix in IMAGE_SUFFIXES:
|
||||||
|
return [input_path], None
|
||||||
|
if suffix not in VIDEO_SUFFIXES:
|
||||||
|
supported = ", ".join(sorted(IMAGE_SUFFIXES | VIDEO_SUFFIXES))
|
||||||
|
raise RuntimeError(f"Unsupported input type; expected one of: {supported}")
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
temporary_directory = Path(
|
||||||
|
tempfile.mkdtemp(prefix="omoo-da3-video-", dir=temporary_root)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
images = extract_video_frames(
|
||||||
|
input_path,
|
||||||
|
temporary_directory,
|
||||||
|
video_frames,
|
||||||
|
cv2,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(temporary_directory, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
return images, temporary_directory
|
||||||
|
|
||||||
|
|
||||||
|
def download_model(args):
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
|
||||||
|
model_dir = Path(args.model_dir)
|
||||||
|
model_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
write_status(args.status, 0.05, f"Downloading {args.repository}")
|
||||||
|
snapshot_download(
|
||||||
|
repo_id=args.repository,
|
||||||
|
local_dir=model_dir,
|
||||||
|
)
|
||||||
|
write_status(args.status, 1.0, "Model is ready")
|
||||||
|
|
||||||
|
|
||||||
|
def select_device(requested, torch):
|
||||||
|
if requested == "cpu":
|
||||||
|
return torch.device("cpu")
|
||||||
|
if requested == "cuda":
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise RuntimeError("CUDA was requested but is not available")
|
||||||
|
return torch.device("cuda")
|
||||||
|
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
|
||||||
|
|
||||||
|
def exported_paths(output_directory, export_format):
|
||||||
|
output_directory = Path(output_directory)
|
||||||
|
if export_format == "glb":
|
||||||
|
scene_path = output_directory / SCENE_FILENAME
|
||||||
|
return [scene_path] if scene_path.is_file() else []
|
||||||
|
if export_format == "gs_ply":
|
||||||
|
return sorted((output_directory / "gs_ply").glob("*.ply"))
|
||||||
|
if export_format == "depth_vis":
|
||||||
|
return sorted((output_directory / "depth_vis").glob("*.jpg"))
|
||||||
|
if export_format == "mini_npz":
|
||||||
|
result_path = output_directory / "exports" / "mini_npz" / "results.npz"
|
||||||
|
return [result_path] if result_path.is_file() else []
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def wait_for_exported_paths(output_directory, export_format, timeout=30.0):
|
||||||
|
# 上游 NPZ exporter 使用后台线程,因此需要等待文件实际落盘。
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
result_paths = exported_paths(output_directory, export_format)
|
||||||
|
if result_paths:
|
||||||
|
return result_paths
|
||||||
|
time.sleep(0.05)
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def reconstruct(args):
|
||||||
|
import torch
|
||||||
|
from depth_anything_3.api import DepthAnything3
|
||||||
|
from depth_anything_3.utils.export import export
|
||||||
|
|
||||||
|
output_directory = Path(args.output_dir)
|
||||||
|
output_directory.mkdir(parents=True, exist_ok=False)
|
||||||
|
write_status(args.status, 0.03, "Preparing input")
|
||||||
|
images, temporary_directory = prepare_input(
|
||||||
|
args.input,
|
||||||
|
args.video_frames,
|
||||||
|
output_directory.parent,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
device = select_device(args.device, torch)
|
||||||
|
write_status(args.status, 0.10, f"Loading model on {device.type}")
|
||||||
|
model = DepthAnything3.from_pretrained(args.model_dir)
|
||||||
|
model = model.to(device=device)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
write_status(args.status, 0.18, f"Processing {len(images)} frame(s)")
|
||||||
|
export_kwargs = {}
|
||||||
|
if args.export_format == "glb":
|
||||||
|
export_kwargs["glb"] = {
|
||||||
|
"conf_thresh_percentile": args.conf_thresh_percentile,
|
||||||
|
"num_max_points": args.num_max_points,
|
||||||
|
"show_cameras": bool(args.show_cameras),
|
||||||
|
"export_depth_vis": False,
|
||||||
|
}
|
||||||
|
if args.export_format == "gs_ply":
|
||||||
|
export_kwargs["gs_ply"] = {
|
||||||
|
"gs_views_interval": args.gs_views_interval,
|
||||||
|
}
|
||||||
|
with torch.inference_mode():
|
||||||
|
prediction = model.inference(
|
||||||
|
[str(path) for path in images],
|
||||||
|
infer_gs=args.export_format == "gs_ply",
|
||||||
|
process_res=args.process_res,
|
||||||
|
process_res_method=args.process_res_method,
|
||||||
|
use_ray_pose=bool(args.use_ray_pose),
|
||||||
|
ref_view_strategy=args.ref_view_strategy,
|
||||||
|
)
|
||||||
|
write_status(args.status, 0.88, f"Exporting {args.export_format}")
|
||||||
|
export(
|
||||||
|
prediction,
|
||||||
|
args.export_format,
|
||||||
|
str(output_directory),
|
||||||
|
**export_kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
result_paths = wait_for_exported_paths(
|
||||||
|
output_directory,
|
||||||
|
args.export_format,
|
||||||
|
)
|
||||||
|
if not result_paths:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"DA3 did not export any {args.export_format} files"
|
||||||
|
)
|
||||||
|
write_status(
|
||||||
|
args.status,
|
||||||
|
1.0,
|
||||||
|
f"Exported {len(result_paths)} {args.export_format} file(s)",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if temporary_directory is not None:
|
||||||
|
shutil.rmtree(temporary_directory, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def parser():
|
||||||
|
root = argparse.ArgumentParser()
|
||||||
|
commands = root.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
download = commands.add_parser("download")
|
||||||
|
download.add_argument("--repository", required=True)
|
||||||
|
download.add_argument("--model-dir", required=True)
|
||||||
|
download.add_argument("--status", required=True)
|
||||||
|
|
||||||
|
reconstruction = commands.add_parser("reconstruct")
|
||||||
|
reconstruction.add_argument("--input", required=True)
|
||||||
|
reconstruction.add_argument("--model-dir", required=True)
|
||||||
|
reconstruction.add_argument("--output-dir", required=True)
|
||||||
|
reconstruction.add_argument(
|
||||||
|
"--export-format",
|
||||||
|
choices=EXPORT_FORMATS,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
reconstruction.add_argument("--status", required=True)
|
||||||
|
reconstruction.add_argument("--process-res", type=int, required=True)
|
||||||
|
reconstruction.add_argument(
|
||||||
|
"--process-res-method",
|
||||||
|
choices=("upper_bound_resize", "lower_bound_resize"),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
reconstruction.add_argument(
|
||||||
|
"--conf-thresh-percentile",
|
||||||
|
type=float,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
reconstruction.add_argument("--num-max-points", type=int, required=True)
|
||||||
|
reconstruction.add_argument(
|
||||||
|
"--show-cameras",
|
||||||
|
type=int,
|
||||||
|
choices=(0, 1),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
reconstruction.add_argument(
|
||||||
|
"--gs-views-interval",
|
||||||
|
type=int,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
reconstruction.add_argument("--video-frames", type=int, required=True)
|
||||||
|
reconstruction.add_argument(
|
||||||
|
"--device",
|
||||||
|
choices=("auto", "cuda", "cpu"),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
reconstruction.add_argument(
|
||||||
|
"--use-ray-pose",
|
||||||
|
type=int,
|
||||||
|
choices=(0, 1),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
reconstruction.add_argument(
|
||||||
|
"--ref-view-strategy",
|
||||||
|
choices=("first", "middle", "saddle_balanced", "saddle_sim_range"),
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
return root
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = parser().parse_args()
|
||||||
|
try:
|
||||||
|
if args.command == "download":
|
||||||
|
download_model(args)
|
||||||
|
else:
|
||||||
|
reconstruct(args)
|
||||||
|
except Exception as error:
|
||||||
|
write_status(args.status, 1.0, "DA3 task failed", str(error))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
322
tests/test_addon.py
Normal file
322
tests/test_addon.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from types import ModuleType, SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
SOURCE_ROOT = PROJECT_ROOT / "src"
|
||||||
|
|
||||||
|
|
||||||
|
class AddonTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.remove_modules()
|
||||||
|
fake_bpy = ModuleType("bpy")
|
||||||
|
fake_bpy.props = SimpleNamespace(
|
||||||
|
BoolProperty=lambda **options: options,
|
||||||
|
EnumProperty=lambda **options: options,
|
||||||
|
FloatProperty=lambda **options: options,
|
||||||
|
IntProperty=lambda **options: options,
|
||||||
|
PointerProperty=lambda **options: options,
|
||||||
|
StringProperty=lambda **options: options,
|
||||||
|
)
|
||||||
|
fake_bpy.types = SimpleNamespace(
|
||||||
|
AddonPreferences=object,
|
||||||
|
Operator=object,
|
||||||
|
Panel=object,
|
||||||
|
PropertyGroup=object,
|
||||||
|
Scene=type("Scene", (), {}),
|
||||||
|
STATUSBAR_HT_header=SimpleNamespace(
|
||||||
|
prepend=lambda draw: cls.status_bar_draws.append(draw),
|
||||||
|
remove=lambda draw: cls.status_bar_draws.remove(draw),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
cls.registered = []
|
||||||
|
cls.status_bar_draws = []
|
||||||
|
fake_bpy.utils = SimpleNamespace(
|
||||||
|
register_class=cls.registered.append,
|
||||||
|
unregister_class=cls.registered.remove,
|
||||||
|
)
|
||||||
|
fake_bpy.context = SimpleNamespace(
|
||||||
|
preferences=SimpleNamespace(addons={}),
|
||||||
|
)
|
||||||
|
fake_bpy.app = SimpleNamespace(online_access=True)
|
||||||
|
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
|
||||||
|
sys.modules["bpy"] = fake_bpy
|
||||||
|
sys.path.insert(0, str(SOURCE_ROOT))
|
||||||
|
cls.addon = importlib.import_module("omoo_da3")
|
||||||
|
cls.fake_bpy = fake_bpy
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def tearDownClass(cls):
|
||||||
|
sys.path.remove(str(SOURCE_ROOT))
|
||||||
|
sys.modules.pop("bpy", None)
|
||||||
|
cls.remove_modules()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def remove_modules():
|
||||||
|
for module_name in tuple(sys.modules):
|
||||||
|
if module_name == "omoo_da3" or module_name.startswith("omoo_da3."):
|
||||||
|
sys.modules.pop(module_name)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.registered.clear()
|
||||||
|
self.status_bar_draws.clear()
|
||||||
|
self.addon.operators._active_job = None
|
||||||
|
if hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings"):
|
||||||
|
del self.fake_bpy.types.Scene.omoo_da3_settings
|
||||||
|
|
||||||
|
def test_uses_extension_manifest_instead_of_legacy_bl_info(self):
|
||||||
|
self.assertFalse(hasattr(self.addon, "bl_info"))
|
||||||
|
|
||||||
|
def test_registers_and_unregisters_extension_types(self):
|
||||||
|
self.addon.register()
|
||||||
|
|
||||||
|
self.assertEqual(self.registered, list(self.addon.CLASSES))
|
||||||
|
self.assertTrue(
|
||||||
|
hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings")
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.status_bar_draws,
|
||||||
|
[self.addon.draw_status_bar],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addon.unregister()
|
||||||
|
|
||||||
|
self.assertEqual(self.registered, [])
|
||||||
|
self.assertFalse(
|
||||||
|
hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings")
|
||||||
|
)
|
||||||
|
self.assertEqual(self.status_bar_draws, [])
|
||||||
|
|
||||||
|
def test_preferences_use_package_identifier(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.addon.OMOO_DA3_preferences.bl_idname,
|
||||||
|
"omoo_da3",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_default_storage_is_shared_in_user_home(self):
|
||||||
|
runtime = importlib.import_module("omoo_da3.runtime")
|
||||||
|
|
||||||
|
self.assertEqual(runtime.DEFAULT_STORAGE_ROOT.name, ".omoo_da3")
|
||||||
|
self.assertEqual(
|
||||||
|
self.addon.OMOO_DA3_preferences.__annotations__["storage_root"][
|
||||||
|
"default"
|
||||||
|
],
|
||||||
|
str(runtime.DEFAULT_STORAGE_ROOT),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_reconstruct_command_preserves_da3_parameter_names(self):
|
||||||
|
runtime = importlib.import_module("omoo_da3.runtime")
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
input_path="input.png",
|
||||||
|
model="GIANT_1_1",
|
||||||
|
gs_model="NESTED_GIANT_LARGE_1_1",
|
||||||
|
process_res=504,
|
||||||
|
process_res_method="upper_bound_resize",
|
||||||
|
conf_thresh_percentile=40.0,
|
||||||
|
num_max_points=1_000_000,
|
||||||
|
show_cameras=True,
|
||||||
|
gs_views_interval=1,
|
||||||
|
video_frames=16,
|
||||||
|
device="CUDA",
|
||||||
|
use_ray_pose=False,
|
||||||
|
ref_view_strategy="saddle_balanced",
|
||||||
|
)
|
||||||
|
with patch.object(
|
||||||
|
runtime,
|
||||||
|
"runtime_python",
|
||||||
|
return_value=Path("python"),
|
||||||
|
), patch.object(
|
||||||
|
runtime,
|
||||||
|
"model_directory",
|
||||||
|
return_value=Path("model"),
|
||||||
|
):
|
||||||
|
command = runtime.reconstruct_command(
|
||||||
|
settings,
|
||||||
|
"gs_ply",
|
||||||
|
Path("status.json"),
|
||||||
|
Path("result"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("--export-format", command)
|
||||||
|
self.assertIn("gs_ply", command)
|
||||||
|
for parameter in (
|
||||||
|
"--process-res",
|
||||||
|
"--process-res-method",
|
||||||
|
"--conf-thresh-percentile",
|
||||||
|
"--num-max-points",
|
||||||
|
"--show-cameras",
|
||||||
|
"--gs-views-interval",
|
||||||
|
"--use-ray-pose",
|
||||||
|
"--ref-view-strategy",
|
||||||
|
):
|
||||||
|
with self.subTest(parameter=parameter):
|
||||||
|
self.assertIn(parameter, command)
|
||||||
|
|
||||||
|
def test_export_format_property_includes_all_da3_formats(self):
|
||||||
|
export_format = self.addon.OMOO_DA3_PG_settings.__annotations__[
|
||||||
|
"export_format"
|
||||||
|
]
|
||||||
|
identifiers = [item[0] for item in export_format["items"]]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
identifiers,
|
||||||
|
["glb", "gs_ply", "depth_vis", "mini_npz"],
|
||||||
|
)
|
||||||
|
self.assertEqual(export_format["default"], "glb")
|
||||||
|
|
||||||
|
def test_gs_model_property_only_includes_gaussian_models(self):
|
||||||
|
gs_model = self.addon.OMOO_DA3_PG_settings.__annotations__[
|
||||||
|
"gs_model"
|
||||||
|
]
|
||||||
|
identifiers = [item[0] for item in gs_model["items"]]
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
identifiers,
|
||||||
|
["GIANT_1_1", "NESTED_GIANT_LARGE_1_1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_debug_panel_is_closed_by_default(self):
|
||||||
|
self.assertIn(
|
||||||
|
"DEFAULT_CLOSED",
|
||||||
|
self.addon.OMOO_DA3_PT_debug.bl_options,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_debug_panel_uses_one_export_button_and_dynamic_settings(self):
|
||||||
|
draw_source = inspect.getsource(
|
||||||
|
self.addon.OMOO_DA3_PT_debug.draw
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(draw_source.count("layout.operator("), 1)
|
||||||
|
self.assertIn('settings.export_format == "glb"', draw_source)
|
||||||
|
self.assertIn('settings.export_format == "gs_ply"', draw_source)
|
||||||
|
self.assertIn('"gs_model" if settings.export_format', draw_source)
|
||||||
|
self.assertIn("model_row.prop(settings, model_property)", draw_source)
|
||||||
|
self.assertIn('model_row.label(text="", icon="CHECKMARK")', draw_source)
|
||||||
|
self.assertIn('model_row.label(text="", icon="ERROR")', draw_source)
|
||||||
|
self.assertNotIn('text="Ready"', draw_source)
|
||||||
|
self.assertNotIn('text="Missing"', draw_source)
|
||||||
|
self.assertNotIn("License:", draw_source)
|
||||||
|
self.assertIn('operator.job_type = "RECONSTRUCT"', draw_source)
|
||||||
|
|
||||||
|
def test_runtime_models_panel_assigns_each_download_target(self):
|
||||||
|
draw_source = inspect.getsource(
|
||||||
|
self.addon.OMOO_DA3_PT_runtime_models.draw
|
||||||
|
)
|
||||||
|
model_source = inspect.getsource(
|
||||||
|
self.addon.OMOO_DA3_PT_runtime_models._draw_model
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("for model in MODELS.values()", draw_source)
|
||||||
|
self.assertIn("operator.model_key = model.key", model_source)
|
||||||
|
self.assertNotIn("Isolated Runtime ready", draw_source)
|
||||||
|
self.assertNotIn("License:", model_source)
|
||||||
|
self.assertEqual(
|
||||||
|
self.addon.OMOO_DA3_PT_runtime_models.bl_label,
|
||||||
|
"Setup",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_completed_export_opens_job_directory(self):
|
||||||
|
calls = []
|
||||||
|
self.fake_bpy.ops = SimpleNamespace(
|
||||||
|
wm=SimpleNamespace(
|
||||||
|
path_open=lambda **options: calls.append(options) or {"FINISHED"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
self.addon.operators.open_job_directory(directory)
|
||||||
|
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
self.assertIn("filepath", calls[0])
|
||||||
|
|
||||||
|
def test_status_bar_draws_progress_and_cancel_button(self):
|
||||||
|
progress_calls = []
|
||||||
|
operator_calls = []
|
||||||
|
|
||||||
|
class Layout:
|
||||||
|
ui_units_x = 0.0
|
||||||
|
|
||||||
|
def row(self, align=False):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def label(self, **_options):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def progress(self, **options):
|
||||||
|
progress_calls.append(options)
|
||||||
|
|
||||||
|
def operator(self, operator, **options):
|
||||||
|
operator_calls.append((operator, options))
|
||||||
|
|
||||||
|
self.addon.operators._active_job = SimpleNamespace()
|
||||||
|
context = SimpleNamespace(
|
||||||
|
scene=SimpleNamespace(
|
||||||
|
omoo_da3_settings=SimpleNamespace(
|
||||||
|
status="Processing",
|
||||||
|
progress=0.25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addon.draw_status_bar(
|
||||||
|
SimpleNamespace(layout=Layout()),
|
||||||
|
context,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
progress_calls,
|
||||||
|
[{"factor": 0.25, "type": "BAR", "text": "25%"}],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
operator_calls,
|
||||||
|
[("omoo_da3.cancel_job", {"text": "", "icon": "X"})],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_ui_redraw_targets_status_bar_and_view_3d_areas(self):
|
||||||
|
redrawn = []
|
||||||
|
areas = (
|
||||||
|
SimpleNamespace(
|
||||||
|
type="STATUSBAR",
|
||||||
|
tag_redraw=lambda: redrawn.append("status"),
|
||||||
|
),
|
||||||
|
SimpleNamespace(
|
||||||
|
type="VIEW_3D",
|
||||||
|
tag_redraw=lambda: redrawn.append("view"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
window_manager=SimpleNamespace(
|
||||||
|
windows=(
|
||||||
|
SimpleNamespace(screen=SimpleNamespace(areas=areas)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addon.status_bar.redraw_ui(context)
|
||||||
|
|
||||||
|
self.assertEqual(redrawn, ["status", "view"])
|
||||||
|
|
||||||
|
def test_forced_ui_redraw_swaps_window_immediately(self):
|
||||||
|
calls = []
|
||||||
|
self.fake_bpy.ops = SimpleNamespace(
|
||||||
|
wm=SimpleNamespace(
|
||||||
|
redraw_timer=lambda **options: calls.append(options),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
window=SimpleNamespace(),
|
||||||
|
window_manager=SimpleNamespace(windows=()),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addon.status_bar.redraw_ui(context, force=True)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
calls,
|
||||||
|
[{"type": "DRAW_WIN_SWAP", "iterations": 1}],
|
||||||
|
)
|
||||||
63
tests/test_bootstrap_runtime.py
Normal file
63
tests/test_bootstrap_runtime.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import importlib.util
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def load_bootstrap_runtime():
|
||||||
|
specification = importlib.util.spec_from_file_location(
|
||||||
|
"omoo_da3_bootstrap_runtime",
|
||||||
|
PROJECT_ROOT / "src" / "omoo_da3" / "bootstrap_runtime.py",
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class BootstrapRuntimeTest(unittest.TestCase):
|
||||||
|
def test_installs_da3_and_non_compiled_gsplat(self):
|
||||||
|
bootstrap = load_bootstrap_runtime()
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
runtime_dir = root / "runtime"
|
||||||
|
runtime_dir.mkdir()
|
||||||
|
args = SimpleNamespace(
|
||||||
|
uv="uv",
|
||||||
|
runtime_dir=str(runtime_dir),
|
||||||
|
backend="cu128",
|
||||||
|
da3_commit="test-commit",
|
||||||
|
status=str(root / "status.json"),
|
||||||
|
)
|
||||||
|
with patch.object(bootstrap, "run") as mocked_run:
|
||||||
|
bootstrap.install(args)
|
||||||
|
|
||||||
|
commands = [call.args[0] for call in mocked_run.call_args_list]
|
||||||
|
da3_command = next(
|
||||||
|
command
|
||||||
|
for command in commands
|
||||||
|
if any("depth-anything-3 @" in str(value) for value in command)
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
f"addict=={bootstrap.ADDICT_VERSION}",
|
||||||
|
da3_command,
|
||||||
|
)
|
||||||
|
gsplat_call = next(
|
||||||
|
call
|
||||||
|
for call in mocked_run.call_args_list
|
||||||
|
if any("gsplat @" in str(value) for value in call.args[0])
|
||||||
|
)
|
||||||
|
gsplat_command = gsplat_call.args[0]
|
||||||
|
self.assertIn("--no-build-isolation", gsplat_command)
|
||||||
|
self.assertTrue(
|
||||||
|
any(bootstrap.GSPLAT_COMMIT in str(value) for value in gsplat_command)
|
||||||
|
)
|
||||||
|
self.assertEqual(gsplat_call.args[1], {"BUILD_NO_CUDA": "1"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
25
tests/test_manifest.py
Normal file
25
tests/test_manifest.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import tomllib
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
MANIFEST_PATH = PROJECT_ROOT / "src" / "omoo_da3" / "blender_manifest.toml"
|
||||||
|
|
||||||
|
|
||||||
|
class ManifestTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.manifest = tomllib.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
def test_declares_modern_extension_metadata(self):
|
||||||
|
self.assertEqual(self.manifest["schema_version"], "1.0.0")
|
||||||
|
self.assertEqual(self.manifest["id"], "omoo_da3")
|
||||||
|
self.assertEqual(self.manifest["type"], "add-on")
|
||||||
|
self.assertNotIn("bl_info", self.manifest)
|
||||||
|
|
||||||
|
def test_declares_required_runtime_permissions(self):
|
||||||
|
self.assertEqual(
|
||||||
|
set(self.manifest["permissions"]),
|
||||||
|
{"network", "files"},
|
||||||
|
)
|
||||||
45
tests/test_models.py
Normal file
45
tests/test_models.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import importlib.util
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def load_module(name, relative_path):
|
||||||
|
specification = importlib.util.spec_from_file_location(
|
||||||
|
name,
|
||||||
|
PROJECT_ROOT / relative_path,
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class ModelTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.models = load_module(
|
||||||
|
"omoo_da3_models",
|
||||||
|
"src/omoo_da3/models.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_base_model_is_default_friendly_apache_model(self):
|
||||||
|
model = self.models.get_model("BASE")
|
||||||
|
|
||||||
|
self.assertEqual(model.repository, "depth-anything/DA3-BASE")
|
||||||
|
self.assertEqual(model.license_name, "Apache 2.0")
|
||||||
|
|
||||||
|
def test_large_model_exposes_noncommercial_license(self):
|
||||||
|
model = self.models.get_model("LARGE_1_1")
|
||||||
|
|
||||||
|
self.assertEqual(model.license_name, "CC BY-NC 4.0")
|
||||||
|
|
||||||
|
def test_giant_models_expose_gaussian_head(self):
|
||||||
|
for model_key in ("GIANT_1_1", "NESTED_GIANT_LARGE_1_1"):
|
||||||
|
with self.subTest(model_key=model_key):
|
||||||
|
self.assertTrue(self.models.get_model(model_key).supports_gs)
|
||||||
|
|
||||||
|
def test_unknown_model_is_rejected(self):
|
||||||
|
with self.assertRaisesRegex(ValueError, "Unknown DA3 model"):
|
||||||
|
self.models.get_model("UNKNOWN")
|
||||||
39
tests/test_pack.py
Normal file
39
tests/test_pack.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import importlib.util
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def load_pack():
|
||||||
|
specification = importlib.util.spec_from_file_location(
|
||||||
|
"omoo_da3_pack",
|
||||||
|
PROJECT_ROOT / "pack.py",
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class PackTest(unittest.TestCase):
|
||||||
|
def test_builds_extension_root_layout(self):
|
||||||
|
pack = load_pack()
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
original_dist = pack.DIST_DIR
|
||||||
|
pack.DIST_DIR = Path(directory)
|
||||||
|
try:
|
||||||
|
output_path = pack.build_extension()
|
||||||
|
finally:
|
||||||
|
pack.DIST_DIR = original_dist
|
||||||
|
|
||||||
|
with ZipFile(output_path) as archive:
|
||||||
|
names = set(archive.namelist())
|
||||||
|
|
||||||
|
self.assertIn("__init__.py", names)
|
||||||
|
self.assertIn("blender_manifest.toml", names)
|
||||||
|
self.assertIn("worker.py", names)
|
||||||
|
self.assertNotIn("importer.py", names)
|
||||||
|
self.assertNotIn("omoo_da3/__init__.py", names)
|
||||||
194
tests/test_worker.py
Normal file
194
tests/test_worker.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from contextlib import nullcontext
|
||||||
|
from pathlib import Path
|
||||||
|
from types import ModuleType, SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def load_worker():
|
||||||
|
specification = importlib.util.spec_from_file_location(
|
||||||
|
"omoo_da3_worker",
|
||||||
|
PROJECT_ROOT / "src" / "omoo_da3" / "worker.py",
|
||||||
|
)
|
||||||
|
module = importlib.util.module_from_spec(specification)
|
||||||
|
specification.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
class WorkerTest(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.worker = load_worker()
|
||||||
|
|
||||||
|
def test_collect_images_is_sorted_and_filters_suffixes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
(root / "b.PNG").touch()
|
||||||
|
(root / "a.jpg").touch()
|
||||||
|
(root / "notes.txt").touch()
|
||||||
|
|
||||||
|
images = self.worker.collect_images(root)
|
||||||
|
|
||||||
|
self.assertEqual([path.name for path in images], ["a.jpg", "b.PNG"])
|
||||||
|
|
||||||
|
def test_prepares_a_single_image_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
image_path = Path(directory) / "input.png"
|
||||||
|
image_path.touch()
|
||||||
|
|
||||||
|
images, temporary_directory = self.worker.prepare_input(
|
||||||
|
image_path,
|
||||||
|
16,
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(images, [image_path])
|
||||||
|
self.assertIsNone(temporary_directory)
|
||||||
|
|
||||||
|
def test_rejects_unsupported_input_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
input_path = Path(directory) / "input.txt"
|
||||||
|
input_path.touch()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Unsupported input type"):
|
||||||
|
self.worker.prepare_input(input_path, 16, directory)
|
||||||
|
|
||||||
|
def test_uses_official_da3_export_parameters_for_all_four_formats(self):
|
||||||
|
inference_options = []
|
||||||
|
export_calls = []
|
||||||
|
|
||||||
|
class FakeModel:
|
||||||
|
@classmethod
|
||||||
|
def from_pretrained(cls, model_dir):
|
||||||
|
if model_dir != "model":
|
||||||
|
raise AssertionError(f"Unexpected model directory: {model_dir}")
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
def to(self, device):
|
||||||
|
if device.type != "cpu":
|
||||||
|
raise AssertionError(f"Unexpected device: {device.type}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
def eval(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def inference(self, images, **options):
|
||||||
|
inference_options.append((images, options))
|
||||||
|
return SimpleNamespace()
|
||||||
|
|
||||||
|
def fake_export(prediction, export_format, export_dir, **options):
|
||||||
|
export_calls.append((prediction, export_format, export_dir, options))
|
||||||
|
output_directory = Path(export_dir)
|
||||||
|
if export_format == "glb":
|
||||||
|
(output_directory / "scene.glb").touch()
|
||||||
|
elif export_format == "gs_ply":
|
||||||
|
ply_directory = output_directory / "gs_ply"
|
||||||
|
ply_directory.mkdir()
|
||||||
|
(ply_directory / "0000.ply").touch()
|
||||||
|
elif export_format == "depth_vis":
|
||||||
|
depth_directory = output_directory / "depth_vis"
|
||||||
|
depth_directory.mkdir()
|
||||||
|
(depth_directory / "0000.jpg").touch()
|
||||||
|
else:
|
||||||
|
mini_directory = output_directory / "exports" / "mini_npz"
|
||||||
|
mini_directory.mkdir(parents=True)
|
||||||
|
(mini_directory / "results.npz").touch()
|
||||||
|
|
||||||
|
fake_torch = ModuleType("torch")
|
||||||
|
fake_torch.device = lambda device_type: SimpleNamespace(
|
||||||
|
type=device_type,
|
||||||
|
)
|
||||||
|
fake_torch.inference_mode = nullcontext
|
||||||
|
fake_api = ModuleType("depth_anything_3.api")
|
||||||
|
fake_api.DepthAnything3 = FakeModel
|
||||||
|
fake_export_module = ModuleType("depth_anything_3.utils.export")
|
||||||
|
fake_export_module.export = fake_export
|
||||||
|
fake_utils = ModuleType("depth_anything_3.utils")
|
||||||
|
fake_utils.__path__ = []
|
||||||
|
fake_package = ModuleType("depth_anything_3")
|
||||||
|
fake_package.__path__ = []
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
input_path = root / "input.png"
|
||||||
|
input_path.touch()
|
||||||
|
with patch.dict(
|
||||||
|
sys.modules,
|
||||||
|
{
|
||||||
|
"torch": fake_torch,
|
||||||
|
"depth_anything_3": fake_package,
|
||||||
|
"depth_anything_3.api": fake_api,
|
||||||
|
"depth_anything_3.utils": fake_utils,
|
||||||
|
"depth_anything_3.utils.export": fake_export_module,
|
||||||
|
},
|
||||||
|
):
|
||||||
|
for export_format in self.worker.EXPORT_FORMATS:
|
||||||
|
args = SimpleNamespace(
|
||||||
|
input=str(input_path),
|
||||||
|
video_frames=16,
|
||||||
|
output_dir=str(root / export_format),
|
||||||
|
export_format=export_format,
|
||||||
|
status=str(root / f"{export_format}.json"),
|
||||||
|
device="cpu",
|
||||||
|
model_dir="model",
|
||||||
|
process_res=518,
|
||||||
|
process_res_method="lower_bound_resize",
|
||||||
|
use_ray_pose=1,
|
||||||
|
ref_view_strategy="middle",
|
||||||
|
conf_thresh_percentile=35.0,
|
||||||
|
num_max_points=12345,
|
||||||
|
show_cameras=0,
|
||||||
|
gs_views_interval=2,
|
||||||
|
)
|
||||||
|
self.worker.reconstruct(args)
|
||||||
|
status = json.loads(
|
||||||
|
Path(args.status).read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
self.assertEqual(status["progress"], 1.0)
|
||||||
|
self.assertIn(export_format, status["message"])
|
||||||
|
|
||||||
|
self.assertEqual(len(inference_options), 4)
|
||||||
|
self.assertEqual(len(export_calls), 4)
|
||||||
|
for images, options in inference_options:
|
||||||
|
self.assertEqual(images, [str(input_path)])
|
||||||
|
self.assertEqual(options["process_res"], 518)
|
||||||
|
self.assertEqual(
|
||||||
|
options["process_res_method"],
|
||||||
|
"lower_bound_resize",
|
||||||
|
)
|
||||||
|
self.assertTrue(options["use_ray_pose"])
|
||||||
|
self.assertEqual(options["ref_view_strategy"], "middle")
|
||||||
|
for _prediction, export_format, _export_dir, options in export_calls:
|
||||||
|
if export_format == "glb":
|
||||||
|
self.assertEqual(
|
||||||
|
options,
|
||||||
|
{
|
||||||
|
"glb": {
|
||||||
|
"conf_thresh_percentile": 35.0,
|
||||||
|
"num_max_points": 12345,
|
||||||
|
"show_cameras": False,
|
||||||
|
"export_depth_vis": False,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif export_format == "gs_ply":
|
||||||
|
self.assertEqual(
|
||||||
|
options,
|
||||||
|
{"gs_ply": {"gs_views_interval": 2}},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertEqual(options, {})
|
||||||
|
|
||||||
|
infer_gs_values = [options["infer_gs"] for _, options in inference_options]
|
||||||
|
self.assertEqual(infer_gs_values, [False, True, False, False])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user