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

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