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:
2026-08-02 00:04:31 +08:00
commit 9586b35440
24 changed files with 2428 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
__pycache__/
*.py[cod]
.venv/
.uv-cache/
dist/
build/
*.zip
.pytest_cache/

25
AGENTS.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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)

View 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",
]

View 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
View 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
View 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
View 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"

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

View 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
View 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
View 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}],
)

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

8
uv.lock generated Normal file
View File

@@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.11"
[[package]]
name = "omoo-da3"
version = "0.8.1"
source = { virtual = "." }