Add SnapForm panels, preferences, properties, and runtime management

- Implemented RuntimeModelsPanel and DebugPanel for UI integration in Blender.
- Created SnapFormPreferences to manage user settings for storage and runtime configuration.
- Defined SnapFormSettings to hold various properties related to input, model selection, and export options.
- Developed runtime management functions for installation, model downloading, and reconstruction commands.
- Added status bar functionality to display job progress and status in the Blender UI.
- Implemented worker script for handling model downloads and depth reconstruction tasks.
- Introduced unit tests for download progress reporting and image depth extraction functionalities.
This commit is contained in:
2026-08-03 10:13:33 +08:00
parent 9586b35440
commit b02372c457
26 changed files with 1849 additions and 181 deletions

View File

@@ -14,7 +14,6 @@
- 特殊的、非常规的需要写注释
- 变量、函数名、Docstring 用全英文
- 同一事物用统一表达
- 模块、函数、变量名,前缀针对不同的命名规范,使用`O *`、`o_*`、`O_*`、`o.*`、`o-*`
- 使用的依赖越少越好
- 选用简单直接的方式实现
- 代码、执行方式改变,调整已有的文档、计划,而非新建
@@ -23,3 +22,11 @@
- 优先使用、提供 Git Bash 执行所有 Shell 命令
- Windows 路径在 Bash 命令中优先使用正斜杠形式
## Blender 开发规范
- 插件、资产库压缩包名用大驼峰,如果需要,用`b45`来表示适用blender版本。比如`OmooLab.v0.1.0.b45.zip`
- Operator、Menu、Panel、AddonPreferences... Class 用大驼峰命名,不加任何前缀。Operator 动宾结构,比如`RenderImage`。Menu、Panel 以它们本身为后缀,比如`RenderPanel`
- 根据项目名提炼项目缩写,作为 bl_idname 和 自定义 Property 的前缀以区分其他功能和属性,以`Omoo`为例
- bl_idname 用下划线小写命名,Operator 的 bl_idname 以`omoo.*`为前缀,比如`omoo.render_panel`;Menu 的以`OMOO_MT_*`为前缀;Panel 的以`OMOO_PT_*`为前缀。比如`OMOO_PT_render_panel`
- 自定义 Property 用下划线小写命名,以`omoo_*`为前缀。比如`omoo_progress_factor`

104
README.md
View File

@@ -1,15 +1,15 @@
# OmooLab DA3
# SnapForm
`OmooLab DA3` 是基于
`SnapForm` 是一套从图片快速构建空间结构的 Blender Extension。目前以
[Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3)
的 Blender Extension。Extension 在独立 Runtime 中运行 DA3,将官方导出结果保存在
独立 job 目录,并在任务完成后使用系统文件管理器打开该目录。
作为首个 AI 能力,在独立 Runtime 中运行 DA3,可将官方导出结果保存在独立 job 目录,
也可把 Image Empty 转换成带几何与材质置换的立体面片。
## 当前功能
- 使用 Blender Extension manifest,可通过 `Install from Disk` 安装
- 在独立 Python Runtime 中运行 Torch 与 DA3,避免污染 Blender Python 进程
- Runtime、模型和任务结果默认保存在 `~/.omoo_da3`,可由多个 Blender 版本共享
- Runtime、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享
- 支持 DA3 Small、Base、Large 1.1、Giant 1.1 和 Nested Giant Large 1.1
- 支持单张 JPG、JPEG、PNG、WEBP 图片或常见视频文件输入
- 视频均匀抽帧,可设置参与重建的最大帧数
@@ -22,12 +22,13 @@
- Debug 在模型选项同一行用勾选或警告图标表示下载状态,不显示状态文字、License 和
已完成任务的文件计数
- 分别调用 DA3 的 `glb`、`gs_ply`、`depth_vis` 和 `mini_npz` exporter
- 导出完成后只打开对应 job 文件夹,不向当前 Blender 文件添加任何数据
- Image Empty 右键菜单可直接运行 DA3,根据 `mini_npz` 深度生成面片
- 使用浮点深度贴图、Simple Subdivision、Displace Modifier 和原图 Bump
构建立体面片
- 转换对话框可选择推理模式、处理分辨率和面片网格细分
- Debug 导出完成后只打开对应 job 文件夹,不向当前 Blender 文件添加任何数据
- 完整保留每次任务生成的所有官方文件
- 安装、下载和推理均在后台子进程运行,状态栏显示阶段、百分比进度条与取消按钮
Blender 端不读取或转换导出文件,也不会重新反投影深度、构造网格、转换坐标、创建材质或
Geometry Nodes。
- 安装、模型下载和推理均在后台子进程运行,状态栏实时显示阶段、百分比进度条与取消按钮
## 使用
@@ -35,16 +36,84 @@ Geometry Nodes。
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` 页签。
5. 在 3D View 按 `N`,打开 `SnapForm` 页签。
6. 在 `Setup` 中找到需要的模型并点击该行的 `Download`。
7. 展开默认收起的 `Debug`,选择 `Export Format` 和本次执行使用的模型。
8. 选择一张图片或一个视频文件。视频可设置均匀抽取的帧数,然后点击 `Export`。
## Image Empty 转深度面片
1. 在 3D View 选中 `Object Type = Empty`、`Display As = Image` 的对象。
2. 右键选择 `Convert to Depth Plane`,在对话框中选择 Model、Use Ray Pose、Process Resolution、Subdivision,
以及是否删除原 Image Empty。默认分辨率为 `1024`、Subdivision 为 `5`,并默认删除原对象。
Model 可选择 `DA3 Small`、`DA3 Base`、`DA3 Large 1.1`、`DA3 Giant 1.1` 或
`DA3 Nested Giant Large 1.1`,默认 `DA3 Base`;Use Ray Pose
默认开启。转换导出 `mini_npz`,使用前需要在 `Setup` 下载所选模型,并固定使用
`process_res_method="lower_bound_resize"`;设备和参考视图策略继续使用场景设置。
3. 推理完成后会自动创建面片。面片几何先由 `mini_npz` 的相机内参与深度确定,再通过对象
Transform 匹配原 Image Empty 的可见尺寸、位置、旋转和 Offset。XY 拟合得到的统一倍率
同时应用于 Z 轴,使面片厚度与宽高按同一比例变化。开启 `Delete Original` 时,成功生成后
删除源 Empty。生成面片 Object 使用源 Empty 的原名;关闭 `Delete Original` 时不处理
同名冲突,由 Blender 自动为生成面片追加编号。
4. 短边分段数为 `2^Subdivision`,长边分段数根据图像宽高比取最接近的整数。例如高:宽为
`2:3`、Subdivision 为 `1` 时,直接生成 `2×3` 个面;Subdivision 为 `3` 时生成
`8×12` 个面。随后的 Simple Subdivision 修改器使用原生名称,Viewport Levels 固定为
`1`,Render Levels 固定为 `2`,不再随 Process Resolution 或 Subdivision 动态变化。
所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的紧凑进度条内部,取消按钮
位于进度条右侧。Image Depth 的阶段包括 Runtime 启动、输入准备、模型加载、深度估算、缓存
写出和面片构建。状态更新仅标记相关区域重绘,不强制执行窗口 Draw/Swap。
导入过程精确保留 NPZ 内深度值之间的差值。例如深度 `2.0` 和 `5.5` 在
`Displacement Strength = -1` 时,沿局部 Z 轴相差 `3.5 Blender Unit`。它不会把数值压缩到
`0..1`,也不再提供视觉归一化模式。
面片尺寸以有效深度的中位数 `Zref` 作为参考截面。先用 `mini_npz` 中的相机内参计算原始
投影面积,再恢复源图纵横比:
```text
RawWidth = depth_width × Zref × abs(Displacement Strength) / fx
RawHeight = depth_height × Zref × abs(Displacement Strength) / fy
Area = RawWidth × RawHeight
Aspect = source_image_width / source_image_height
Width = sqrt(Area × Aspect)
Height = sqrt(Area / Aspect)
```
这样即使 DA3 估计出的 `fx/fy` 不一致,也不会把正方形原图拉成长方形。主点 `cx/cy` 仍用于
确定原始相机面片,随后对象 XY Transform 将它匹配到源 Image Empty。生成对象会记录
`snapform_reference_depth`、`snapform_camera_plane_width` 和
`snapform_camera_plane_height`,便于检查实际采用的
参考深度和尺寸。
Displace Modifier 的深度贴图使用 `Extension = Extend`。材质不再使用生成的深度图执行
Displacement,其 `Strength` 固定为 `-1`、`Midlevel` 固定为 `1`;原始彩色贴图同时连接
Principled BSDF 的 `Base Color`,并通过
`Bump.Height → Bump.Normal → Principled.Normal` 提供表面细节。Bump 的基础 Distance 为
`0.001`,会乘以面片 Transform 的统一缩放倍率。原图 Color 还会经过 `Float Curve` 连接到
Principled BSDF 的 `Roughness`,曲线两端输出分别为 `0.5` 和 `0.1`;材质 IOR 默认为 `1.2`。
这里的“原始距离”是 DA3 模型输出的尺度。普通 Small、Base、Large 和 Giant 模型的深度不应
被解释为现实世界的米;需要现实尺度时应选择带 metric depth 的
`DA3 Nested Giant Large 1.1`。此外,当前结果仍是固定 XY 平面上的深度位移面,不是使用
相机内参逐像素反投影得到的完整 camera-space 网格;中位深度截面为现有 Displace Modifier
工作流提供正确的整体高宽尺度。
生成完成后会直接创建面片,不提供缓存重建入口或单独的导入参数。
重新选择 `Convert to Depth Plane` 会再次运行 DA3。
面片、深度 Image、Texture 与 Material 会在同一个 Blender Undo 步骤中创建;转换完成后执行
`Ctrl+Z` 会完整撤销本次转换,不会越过转换去回退更早的 Image Empty 操作。
当前转换入口要求 Image Empty 引用仍然存在的本地图像文件。DA3 的原始
`results.npz` 保留在对应 job 的 `exports/mini_npz/` 中,生成的浮点深度 Image 会 Pack 到
当前 `.blend`。源 Empty 名为 `img` 时,深度 Image 与 Displace Texture 都命名为
`img.depth`。Pack 使用 OpenEXR,因而不会把大于 `1.0` 的原始深度截断。
选择 PLY 后,模型菜单只会提供 `DA3 Giant 1.1` 和
`DA3 Nested Giant Large 1.1`。Small、Base 和 Large 没有 Gaussian head,无法由
DA3 生成 `gs_ply`。
每次任务会在 `~/.omoo_da3/jobs/<任务目录>/` 中保留 DA3 官方产物:
每次任务会在 `~/.snapform/jobs/<任务目录>/` 中保留 DA3 官方产物:
```text
<GLB 任务目录>/
@@ -75,8 +144,8 @@ CC BY-NC 4.0,仅限非商业用途。
界面使用 DA3 API 的参数名、选项和默认值。GLB 专属设置仅在选择 `GLB` 时显示,
`gs_views_interval` 仅在选择 `PLY (3DGS)` 时显示:
- `process_res=504`
- `process_res_method="upper_bound_resize"`
- `process_res=1024`
- `process_res_method="lower_bound_resize"`
- `use_ray_pose=False`
- `ref_view_strategy="saddle_balanced"`
- `conf_thresh_percentile=40.0`
@@ -107,10 +176,10 @@ Extension 本身不包含 Torch。`Install Runtime` 使用 `uv` 创建独立 Pyt
默认 CUDA Backend 为 CUDA 12.8,也可以选择 CPU。CPU 可以用于验证,但实际推理
通常很慢。
Runtime、模型和任务结果默认保存在用户主目录的 `~/.omoo_da3`:
Runtime、模型和任务结果默认保存在用户主目录的 `~/.snapform`:
```text
~/.omoo_da3/
~/.snapform/
├── runtime/
├── models/
└── jobs/
@@ -134,10 +203,11 @@ uv run python pack.py
.
├── pack.py
├── src/
│ └── omoo_da3/
│ └── snapform/
│ ├── blender_manifest.toml
│ ├── bootstrap_runtime.py
│ ├── models.py
│ ├── image_depth.py
│ ├── operators.py
│ ├── panel.py
│ ├── preferences.py

View File

@@ -4,7 +4,7 @@ from zipfile import ZIP_DEFLATED, ZipFile
PROJECT_ROOT = Path(__file__).resolve().parent
SOURCE_DIR = PROJECT_ROOT / "src" / "omoo_da3"
SOURCE_DIR = PROJECT_ROOT / "src" / "snapform"
DIST_DIR = PROJECT_ROOT / "dist"
@@ -12,7 +12,11 @@ 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"
blender_version = manifest["blender_version_min"].split(".")
blender_tag = f"b{blender_version[0]}{blender_version[1]}"
output_path = DIST_DIR / (
f"{manifest['name']}.v{manifest['version']}.{blender_tag}.zip"
)
DIST_DIR.mkdir(exist_ok=True)
with ZipFile(output_path, "w", ZIP_DEFLATED) as archive:

View File

@@ -1,7 +1,7 @@
[project]
name = "omoo-da3"
version = "0.8.1"
description = "Direct Depth Anything 3 exports from Blender"
name = "snapform"
version = "0.9.27"
description = "Fast image-guided structure building tools for Blender"
readme = "README.md"
requires-python = ">=3.11"
license = "GPL-3.0-or-later"

View File

@@ -1,41 +0,0 @@
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)

50
src/snapform/__init__.py Normal file
View File

@@ -0,0 +1,50 @@
import bpy
from .image_depth import (
BuildDepthPlane,
ConvertImageToDepthPlane,
draw_image_context_menu,
)
from .operators import (
CancelJob,
RunJob,
cancel_active_job,
close_active_job,
)
from .panel import DebugPanel, RuntimeModelsPanel
from .preferences import SnapFormPreferences
from .properties import SnapFormSettings
from .status_bar import draw_status_bar
CLASSES = (
SnapFormPreferences,
SnapFormSettings,
RunJob,
CancelJob,
BuildDepthPlane,
ConvertImageToDepthPlane,
RuntimeModelsPanel,
DebugPanel,
)
def register():
for class_type in CLASSES:
bpy.utils.register_class(class_type)
bpy.types.Scene.snapform_settings = bpy.props.PointerProperty(
type=SnapFormSettings,
)
bpy.types.STATUSBAR_HT_header.append(draw_status_bar)
bpy.types.VIEW3D_MT_object_context_menu.append(draw_image_context_menu)
def unregister():
cancel_active_job()
close_active_job()
bpy.types.VIEW3D_MT_object_context_menu.remove(draw_image_context_menu)
bpy.types.STATUSBAR_HT_header.remove(draw_status_bar)
if hasattr(bpy.types.Scene, "snapform_settings"):
del bpy.types.Scene.snapform_settings
for class_type in reversed(CLASSES):
bpy.utils.unregister_class(class_type)

View File

@@ -1,9 +1,9 @@
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"
id = "snapform"
version = "0.9.27"
name = "SnapForm"
tagline = "Build spatial structures from images, fast"
maintainer = "OmooLab"
type = "add-on"

View File

@@ -160,7 +160,7 @@ def install(args):
]
)
ready_path = runtime_dir / "omoo_da3_ready.json"
ready_path = runtime_dir / "snapform_ready.json"
ready_path.write_text(
json.dumps(
{

640
src/snapform/image_depth.py Normal file
View File

@@ -0,0 +1,640 @@
from pathlib import Path
import bpy
DEPTH_KEY_CANDIDATES = ("depth", "depths")
INTRINSICS_KEY_CANDIDATES = ("intrinsics", "ixts")
IMAGE_DEPTH_MODEL_ITEMS = (
(
"SMALL",
"DA3 Small",
"Fastest model with Apache 2.0 weights",
),
(
"BASE",
"DA3 Base",
"Balanced model with Apache 2.0 weights",
),
(
"LARGE_1_1",
"DA3 Large 1.1",
"Higher quality model with CC BY-NC 4.0 weights",
),
(
"GIANT_1_1",
"DA3 Giant 1.1",
"Giant model with Gaussian head and CC BY-NC 4.0 weights",
),
(
"NESTED_GIANT_LARGE_1_1",
"DA3 Nested Giant Large 1.1",
"Nested Giant model with metric depth and CC BY-NC 4.0 weights",
),
)
DISPLACEMENT_STRENGTH = -1.0
DISPLACEMENT_MIDLEVEL = 1.0
BUMP_BASE_DISTANCE = 0.001
def is_image_empty(candidate):
return bool(
candidate
and getattr(candidate, "type", None) == "EMPTY"
and getattr(candidate, "empty_display_type", None) == "IMAGE"
and getattr(candidate, "data", None) is not None
)
def image_path(image):
filepath_from_user = getattr(image, "filepath_from_user", None)
filepath = (
filepath_from_user()
if callable(filepath_from_user)
else getattr(image, "filepath", "")
)
if not filepath:
return Path()
return Path(bpy.path.abspath(filepath)).expanduser()
def plane_bounds(source_object):
image_width, image_height = source_object.data.size
if image_width <= 0 or image_height <= 0:
raise ValueError("The source image has no readable dimensions")
aspect = image_width / image_height
display_size = float(source_object.empty_display_size)
if aspect >= 1.0:
width = display_size
height = display_size / aspect
else:
width = display_size * aspect
height = display_size
offset = source_object.empty_image_offset
x_min = float(offset[0]) * width
y_min = float(offset[1]) * height
return (
x_min,
x_min + width,
y_min,
y_min + height,
)
def plane_fit_transform(source_object, camera_bounds):
target_bounds = plane_bounds(source_object)
camera_width = camera_bounds[1] - camera_bounds[0]
camera_height = camera_bounds[3] - camera_bounds[2]
if camera_width <= 0.0 or camera_height <= 0.0:
raise ValueError("The camera plane has invalid dimensions")
target_width = target_bounds[1] - target_bounds[0]
target_height = target_bounds[3] - target_bounds[2]
scale_x = target_width / camera_width
scale_y = target_height / camera_height
scale_z = (scale_x * scale_y) ** 0.5
camera_center_x = (camera_bounds[0] + camera_bounds[1]) * 0.5
camera_center_y = (camera_bounds[2] + camera_bounds[3]) * 0.5
target_center_x = (target_bounds[0] + target_bounds[1]) * 0.5
target_center_y = (target_bounds[2] + target_bounds[3]) * 0.5
return (
scale_x,
scale_y,
scale_z,
target_center_x - camera_center_x * scale_x,
target_center_y - camera_center_y * scale_y,
)
def apply_plane_transform(plane_object, source_object, camera_bounds):
from mathutils import Matrix
scale_x, scale_y, scale_z, offset_x, offset_y = plane_fit_transform(
source_object,
camera_bounds,
)
fit_matrix = Matrix(
(
(scale_x, 0.0, 0.0, offset_x),
(0.0, scale_y, 0.0, offset_y),
(0.0, 0.0, scale_z, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
)
plane_object.matrix_world = source_object.matrix_world @ fit_matrix
return abs(float(plane_object.scale.z))
def extract_depth_array(npz_path):
try:
import numpy as np
except ImportError as error:
raise RuntimeError("Blender Python does not provide NumPy") from error
try:
with np.load(npz_path, allow_pickle=False) as archive:
depth_key = next(
(
key
for key in DEPTH_KEY_CANDIDATES
if key in archive.files
),
None,
)
if depth_key is None:
keys = ", ".join(archive.files) or "none"
raise ValueError(
f"Mini NPZ has no depth array; available keys: {keys}"
)
depth = np.asarray(archive[depth_key], dtype=np.float32)
except OSError as error:
raise ValueError(f"Unable to read Mini NPZ: {npz_path}") from error
if depth.ndim >= 3 and depth.shape[-1] == 1:
depth = depth[..., 0]
if depth.ndim == 3:
depth = depth[0]
if depth.ndim != 2:
raise ValueError(
f"Unsupported depth array shape: {tuple(depth.shape)}"
)
return depth
def extract_intrinsics_array(npz_path):
try:
import numpy as np
except ImportError as error:
raise RuntimeError("Blender Python does not provide NumPy") from error
try:
with np.load(npz_path, allow_pickle=False) as archive:
intrinsics_key = next(
(
key
for key in INTRINSICS_KEY_CANDIDATES
if key in archive.files
),
None,
)
if intrinsics_key is None:
keys = ", ".join(archive.files) or "none"
raise ValueError(
"Mini NPZ has no camera intrinsics; "
f"available keys: {keys}"
)
intrinsics = np.asarray(
archive[intrinsics_key],
dtype=np.float32,
)
except OSError as error:
raise ValueError(f"Unable to read Mini NPZ: {npz_path}") from error
if intrinsics.ndim == 3:
intrinsics = intrinsics[0]
if intrinsics.shape != (3, 3):
raise ValueError(
f"Unsupported intrinsics shape: {tuple(intrinsics.shape)}"
)
return intrinsics
def prepare_depth_values(depth):
import numpy as np
valid = np.isfinite(depth) & (depth > 0.0)
if not valid.any():
raise ValueError("Depth array contains no positive finite values")
values = depth.astype(np.float32, copy=True)
values[~valid] = 0.0
return values
def camera_plane_bounds(
source_object,
depth,
intrinsics,
displacement_strength,
):
import numpy as np
scale = abs(float(displacement_strength))
if scale <= 1e-8:
raise ValueError("Displacement Strength must not be zero")
valid = np.isfinite(depth) & (depth > 0.0)
if not valid.any():
raise ValueError("Depth array contains no positive finite values")
reference_depth = float(np.median(depth[valid]))
reference_distance = reference_depth * scale
height, width = depth.shape
fx = float(intrinsics[0, 0])
fy = float(intrinsics[1, 1])
cx = float(intrinsics[0, 2])
cy = float(intrinsics[1, 2])
if fx <= 0.0 or fy <= 0.0:
raise ValueError("Camera intrinsics contain an invalid focal length")
source_width, source_height = source_object.data.size
if source_width <= 0 or source_height <= 0:
raise ValueError("The source image has no readable dimensions")
source_aspect = source_width / source_height
# Predicted fx/fy may differ even for square pixels. Preserve their projected
# area while enforcing the source image aspect ratio to avoid image stretch.
raw_width = width * reference_distance / fx
raw_height = height * reference_distance / fy
plane_area = raw_width * raw_height
plane_width = float(np.sqrt(plane_area * source_aspect))
plane_height = float(np.sqrt(plane_area / source_aspect))
x_min = -(cx / width) * plane_width
x_max = (1.0 - cx / width) * plane_width
y_min = -(1.0 - cy / height) * plane_height
y_max = (cy / height) * plane_height
# Preserve an intentional Image Empty origin offset around the camera frame.
offset = source_object.empty_image_offset
x_offset = (float(offset[0]) + 0.5) * plane_width
y_offset = (float(offset[1]) + 0.5) * plane_height
return (
(
x_min + x_offset,
x_max + x_offset,
y_min + y_offset,
y_max + y_offset,
),
reference_depth,
)
def create_depth_image(name, depth_values):
import numpy as np
height, width = depth_values.shape
image = bpy.data.images.new(
name,
width=width,
height=height,
alpha=False,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
image.file_format = "OPEN_EXR"
# NPZ arrays use a top-left origin, while Blender image pixels start below.
depth_pixels = np.flipud(depth_values)
rgba = np.empty((height, width, 4), dtype=np.float32)
rgba[..., 0] = depth_pixels
rgba[..., 1] = depth_pixels
rgba[..., 2] = depth_pixels
rgba[..., 3] = 1.0
image.pixels.foreach_set(rgba.ravel())
# Pack as EXR so values outside 0..1 survive saving and reopening the blend.
image.pack()
return image
def grid_segments(bounds, subdivision_level):
if subdivision_level < 0:
raise ValueError("Subdivision must not be negative")
width = bounds[1] - bounds[0]
height = bounds[3] - bounds[2]
if width <= 0.0 or height <= 0.0:
raise ValueError("The plane has invalid dimensions")
short_segments = 2**subdivision_level
if width >= height:
return (
max(short_segments, int(short_segments * width / height + 0.5)),
short_segments,
)
return (
short_segments,
max(short_segments, int(short_segments * height / width + 0.5)),
)
def create_plane_mesh(source_object, bounds=None, subdivision_level=5):
x_min, x_max, y_min, y_max = (
bounds if bounds is not None else plane_bounds(source_object)
)
x_segments, y_segments = grid_segments(
(x_min, x_max, y_min, y_max),
subdivision_level,
)
vertices = []
uv_coordinates = []
for y_index in range(y_segments + 1):
v = y_index / y_segments
y = y_min + (y_max - y_min) * v
for x_index in range(x_segments + 1):
u = x_index / x_segments
x = x_min + (x_max - x_min) * u
vertices.append((x, y, 0.0))
uv_coordinates.append((u, v))
faces = []
row_width = x_segments + 1
for y_index in range(y_segments):
for x_index in range(x_segments):
bottom_left = y_index * row_width + x_index
faces.append(
(
bottom_left,
bottom_left + 1,
bottom_left + row_width + 1,
bottom_left + row_width,
)
)
mesh = bpy.data.meshes.new(f"{source_object.name}.DA3Depth")
mesh.from_pydata(vertices, (), faces)
mesh.update()
for polygon in mesh.polygons:
polygon.use_smooth = True
uv_layer = mesh.uv_layers.new(name="UVMap")
for loop in mesh.loops:
uv_layer.data[loop.index].uv = uv_coordinates[
loop.vertex_index
]
return mesh
def create_material(
name,
color_image,
depth_scale,
):
material = bpy.data.materials.new(name)
material.use_nodes = True
if hasattr(material, "surface_render_method"):
material.surface_render_method = "DITHERED"
nodes = material.node_tree.nodes
nodes.clear()
output = nodes.new("ShaderNodeOutputMaterial")
shader = nodes.new("ShaderNodeBsdfPrincipled")
color_texture = nodes.new("ShaderNodeTexImage")
bump = nodes.new("ShaderNodeBump")
roughness_curve = nodes.new("ShaderNodeFloatCurve")
color_texture.image = color_image
color_texture.interpolation = "Linear"
color_texture.extension = "EXTEND"
bump.inputs["Distance"].default_value = BUMP_BASE_DISTANCE * depth_scale
shader.inputs["IOR"].default_value = 1.2
roughness_curve.mapping.initialize()
curve_points = roughness_curve.mapping.curves[0].points
curve_points[0].location = (0.0, 0.5)
curve_points[-1].location = (1.0, 0.1)
roughness_curve.mapping.update()
color_texture.location = (-640.0, 180.0)
roughness_curve.location = (-300.0, 320.0)
bump.location = (-300.0, -80.0)
shader.location = (80.0, 180.0)
output.location = (460.0, 180.0)
links = material.node_tree.links
links.new(color_texture.outputs["Color"], shader.inputs["Base Color"])
links.new(color_texture.outputs["Alpha"], shader.inputs["Alpha"])
links.new(color_texture.outputs["Color"], bump.inputs["Height"])
links.new(bump.outputs["Normal"], shader.inputs["Normal"])
links.new(
color_texture.outputs["Color"],
roughness_curve.inputs["Value"],
)
links.new(
roughness_curve.outputs["Value"],
shader.inputs["Roughness"],
)
links.new(shader.outputs["BSDF"], output.inputs["Surface"])
return material
def build_depth_plane(
context,
source_object,
npz_path,
subdivision_level,
delete_source,
):
source_name = source_object.name
depth = extract_depth_array(npz_path)
intrinsics = extract_intrinsics_array(npz_path)
depth_values = prepare_depth_values(depth)
bounds, reference_depth = camera_plane_bounds(
source_object,
depth_values,
intrinsics,
DISPLACEMENT_STRENGTH,
)
depth_name = f"{source_name}.depth"
depth_image = create_depth_image(
depth_name,
depth_values,
)
mesh = create_plane_mesh(source_object, bounds, subdivision_level)
plane_object = bpy.data.objects.new(
source_name,
mesh,
)
context.collection.objects.link(plane_object)
depth_scale = apply_plane_transform(
plane_object,
source_object,
bounds,
)
subdivision = plane_object.modifiers.new("Subdivision", "SUBSURF")
subdivision.subdivision_type = "SIMPLE"
subdivision.levels = 1
subdivision.render_levels = 2
texture = bpy.data.textures.new(
depth_name,
type="IMAGE",
)
texture.image = depth_image
texture.extension = "EXTEND"
displace = plane_object.modifiers.new("Displace", "DISPLACE")
displace.texture = texture
displace.texture_coords = "UV"
displace.direction = "NORMAL"
displace.strength = DISPLACEMENT_STRENGTH
displace.mid_level = DISPLACEMENT_MIDLEVEL
material = create_material(
f"{source_name}.DA3DepthMaterial",
source_object.data,
depth_scale,
)
mesh.materials.append(material)
plane_object["snapform_reference_depth"] = reference_depth
plane_object["snapform_camera_plane_width"] = bounds[1] - bounds[0]
plane_object["snapform_camera_plane_height"] = bounds[3] - bounds[2]
plane_object["snapform_depth_scale"] = depth_scale
for selected in context.selected_objects:
selected.select_set(False)
plane_object.select_set(True)
context.view_layer.objects.active = plane_object
if delete_source:
bpy.data.objects.remove(source_object, do_unlink=True)
plane_object.name = source_name
return plane_object
class BuildDepthPlane(bpy.types.Operator):
bl_idname = "snapform.build_depth_plane"
bl_label = "Build Depth Plane"
bl_options = {"INTERNAL"}
snapform_source_object_name: bpy.props.StringProperty(
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_npz_path: bpy.props.StringProperty(
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_subdivision_level: bpy.props.IntProperty(
default=5,
min=0,
max=10,
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_delete_source: bpy.props.BoolProperty(
default=True,
options={"HIDDEN", "SKIP_SAVE"},
)
def execute(self, context):
source_object = bpy.data.objects.get(self.snapform_source_object_name)
if not is_image_empty(source_object):
self.report(
{"ERROR"},
"The source image empty is no longer available",
)
return {"CANCELLED"}
npz_path = Path(self.snapform_npz_path)
if not npz_path.is_file():
self.report(
{"ERROR"},
f"DA3 Mini NPZ does not exist: {npz_path}",
)
return {"CANCELLED"}
try:
build_depth_plane(
context,
source_object,
npz_path,
self.snapform_subdivision_level,
self.snapform_delete_source,
)
except (OSError, RuntimeError, ValueError) as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
return {"FINISHED"}
class ConvertImageToDepthPlane(bpy.types.Operator):
bl_idname = "snapform.convert_image_to_depth_plane"
bl_label = "Convert to Depth Plane"
bl_description = "Run DA3 and convert the image to a displaced depth plane"
bl_options = {"REGISTER"}
snapform_model: bpy.props.EnumProperty(
name="Model",
description="DA3 model used to estimate image depth",
items=IMAGE_DEPTH_MODEL_ITEMS,
default="BASE",
)
snapform_use_ray_pose: bpy.props.BoolProperty(
name="Use Ray Pose",
description="Use ray-based camera pose estimation",
default=True,
)
snapform_process_res: bpy.props.IntProperty(
name="Process Resolution",
description="DA3 processing resolution",
default=1024,
min=14,
max=2016,
step=14,
)
snapform_delete_original: bpy.props.BoolProperty(
name="Delete Original",
description="Delete the source Image Empty after conversion",
default=True,
)
snapform_subdivision_level: bpy.props.IntProperty(
name="Subdivision",
description="Power of two for the short-edge segment count; the long edge follows the image aspect ratio",
default=5,
min=0,
max=10,
)
@classmethod
def poll(cls, context):
from .operators import active_job
return is_image_empty(context.object) and active_job() is None
def invoke(self, context, _event):
return context.window_manager.invoke_props_dialog(self, width=340)
def draw(self, _context):
layout = self.layout
layout.prop(self, "snapform_model")
layout.prop(self, "snapform_use_ray_pose")
layout.prop(self, "snapform_process_res")
layout.prop(self, "snapform_subdivision_level")
layout.prop(self, "snapform_delete_original")
def execute(self, context):
from .operators import IMAGE_DEPTH_JOB
source_object = context.object
source_path = image_path(source_object.data)
if not source_path.is_file():
self.report(
{"ERROR"},
"The image empty must reference an existing image file",
)
return {"CANCELLED"}
result = bpy.ops.snapform.run_job(
job_type=IMAGE_DEPTH_JOB,
snapform_input_path=str(source_path),
snapform_source_object_name=source_object.name,
snapform_model_key=self.snapform_model,
snapform_use_ray_pose=self.snapform_use_ray_pose,
snapform_process_res=self.snapform_process_res,
snapform_subdivision_level=self.snapform_subdivision_level,
snapform_delete_source=self.snapform_delete_original,
)
if "CANCELLED" in result:
return {"CANCELLED"}
return {"FINISHED"}
def draw_image_context_menu(self, context):
active_object = context.object
if not is_image_empty(active_object):
return
self.layout.separator()
row = self.layout.row()
row.operator_context = "INVOKE_DEFAULT"
row.operator(
"snapform.convert_image_to_depth_plane",
text="Convert to Depth Plane",
icon="MOD_DISPLACE",
)

View File

@@ -9,6 +9,9 @@ from .models import get_model, selected_model_key
RECONSTRUCTION_JOB = "RECONSTRUCT"
IMAGE_DEPTH_JOB = "IMAGE_DEPTH"
IMAGE_DEPTH_DEFAULT_PROCESS_RES = 1024
IMAGE_DEPTH_PROCESS_RES_METHOD = "lower_bound_resize"
def open_job_directory(result_directory):
@@ -29,6 +32,10 @@ class ActiveJob:
status_path: Path
result_directory: Path
export_format: str = ""
source_object_name: str = ""
process_res: int = IMAGE_DEPTH_DEFAULT_PROCESS_RES
subdivision_level: int = 5
delete_source: bool = True
cancelled: bool = False
@@ -58,9 +65,9 @@ def close_active_job():
_active_job = None
class OMOO_DA3_OT_run_job(bpy.types.Operator):
bl_idname = "omoo_da3.run_job"
bl_label = "Run Omoo DA3 Job"
class RunJob(bpy.types.Operator):
bl_idname = "snapform.run_job"
bl_label = "Run SnapForm Job"
bl_options = {"INTERNAL"}
job_type: bpy.props.EnumProperty(
@@ -68,21 +75,52 @@ class OMOO_DA3_OT_run_job(bpy.types.Operator):
("INSTALL_RUNTIME", "Install Runtime", ""),
("DOWNLOAD_MODEL", "Download Model", ""),
(RECONSTRUCTION_JOB, "Export DA3 Result", ""),
(IMAGE_DEPTH_JOB, "Convert Image to Depth Plane", ""),
),
options={"HIDDEN", "SKIP_SAVE"},
)
model_key: bpy.props.StringProperty(
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_input_path: bpy.props.StringProperty(
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_source_object_name: bpy.props.StringProperty(
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_model_key: bpy.props.StringProperty(
default="BASE",
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_use_ray_pose: bpy.props.BoolProperty(
default=True,
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_process_res: bpy.props.IntProperty(
default=IMAGE_DEPTH_DEFAULT_PROCESS_RES,
min=14,
max=2016,
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_delete_source: bpy.props.BoolProperty(
default=True,
options={"HIDDEN", "SKIP_SAVE"},
)
snapform_subdivision_level: bpy.props.IntProperty(
default=5,
min=0,
max=10,
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")
self.report({"WARNING"}, "Another SnapForm task is running")
return {"CANCELLED"}
settings = context.scene.omoo_da3_settings
settings = context.scene.snapform_settings
try:
command, paths = self._prepare_command(context, settings)
except (OSError, RuntimeError, ValueError) as error:
@@ -111,10 +149,14 @@ class OMOO_DA3_OT_run_job(bpy.types.Operator):
status_path=paths["status"],
result_directory=paths["result"],
export_format=(
settings.export_format
if self.job_type == RECONSTRUCTION_JOB
settings.export_format if self.job_type == RECONSTRUCTION_JOB
else "mini_npz" if self.job_type == IMAGE_DEPTH_JOB
else ""
),
source_object_name=self.snapform_source_object_name,
process_res=self.snapform_process_res,
subdivision_level=self.snapform_subdivision_level,
delete_source=self.snapform_delete_source,
)
settings.status = self._starting_message(settings)
settings.progress = 0.0
@@ -133,7 +175,7 @@ class OMOO_DA3_OT_run_job(bpy.types.Operator):
return runtime.install_runtime_command(paths["status"]), paths
if not runtime.runtime_ready():
raise RuntimeError("Install the Omoo DA3 Runtime first")
raise RuntimeError("Install the SnapForm Runtime first")
if self.job_type == "DOWNLOAD_MODEL":
self._require_online_access()
@@ -143,16 +185,31 @@ class OMOO_DA3_OT_run_job(bpy.types.Operator):
paths,
)
input_path = Path(bpy.path.abspath(settings.input_path))
configured_input = (
self.snapform_input_path
if self.job_type == IMAGE_DEPTH_JOB
else settings.input_path
)
input_path = Path(bpy.path.abspath(configured_input))
if not input_path.is_file():
raise ValueError("Select a valid image or video file")
model_key = selected_model_key(settings)
if self.job_type not in {RECONSTRUCTION_JOB, IMAGE_DEPTH_JOB}:
raise RuntimeError(f"Unsupported task type: {self.job_type}")
export_format = (
"mini_npz"
if self.job_type == IMAGE_DEPTH_JOB
else settings.export_format
)
use_ray_pose = None
process_res = None
model_key = selected_model_key(settings, export_format)
if self.job_type == IMAGE_DEPTH_JOB:
model_key = self.snapform_model_key
use_ray_pose = self.snapform_use_ray_pose
process_res = self.snapform_process_res
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"
@@ -163,6 +220,15 @@ class OMOO_DA3_OT_run_job(bpy.types.Operator):
export_format,
paths["status"],
paths["result"],
input_path=input_path,
model_key=model_key,
process_res=process_res,
process_res_method=(
IMAGE_DEPTH_PROCESS_RES_METHOD
if self.job_type == IMAGE_DEPTH_JOB
else None
),
use_ray_pose=use_ray_pose,
),
paths,
)
@@ -180,6 +246,8 @@ class OMOO_DA3_OT_run_job(bpy.types.Operator):
}
if self.job_type == RECONSTRUCTION_JOB:
return f"Starting DA3 {settings.export_format} export"
if self.job_type == IMAGE_DEPTH_JOB:
return "Starting DA3 image depth conversion"
if self.job_type == "DOWNLOAD_MODEL":
return f"Starting {get_model(self.model_key).label} download"
return labels[self.job_type]
@@ -206,13 +274,13 @@ class OMOO_DA3_OT_run_job(bpy.types.Operator):
status = runtime.read_status(job.status_path)
if status is None:
return
settings = context.scene.omoo_da3_settings
settings = context.scene.snapform_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
settings = context.scene.snapform_settings
self._remove_timer(context)
job.log_file.close()
@@ -245,6 +313,20 @@ class OMOO_DA3_OT_run_job(bpy.types.Operator):
{"INFO"},
f"Exported {job.export_format} to {job.result_directory}",
)
if job.kind == IMAGE_DEPTH_JOB:
try:
settings.status = "Building depth plane"
settings.progress = 0.96
self._redraw_ui(context, force=True)
self._build_depth_plane(context, job)
except (OSError, RuntimeError, ValueError) as import_error:
settings.status = "Depth import failed"
settings.progress = 0.0
self.report({"ERROR"}, str(import_error))
close_active_job()
self._redraw_ui(context, force=True)
return {"CANCELLED"}
status["message"] = "DA3 depth plane is ready"
settings.status = status.get("message", "Task complete")
settings.progress = 1.0
@@ -252,6 +334,25 @@ class OMOO_DA3_OT_run_job(bpy.types.Operator):
self._redraw_ui(context, force=True)
return {"FINISHED"}
def _build_depth_plane(self, context, job):
npz_path = (
job.result_directory
/ "exports"
/ "mini_npz"
/ "results.npz"
)
result = bpy.ops.snapform.build_depth_plane(
snapform_source_object_name=job.source_object_name,
snapform_npz_path=str(npz_path),
snapform_subdivision_level=job.subdivision_level,
snapform_delete_source=job.delete_source,
)
if "FINISHED" not in result:
raise RuntimeError("Unable to build the depth plane")
undo_result = bpy.ops.ed.undo_push(message="Convert to Depth Plane")
if "FINISHED" not in undo_result:
raise RuntimeError("Unable to create the depth plane Undo step")
def _remove_timer(self, context):
timer = getattr(self, "_timer", None)
if timer is None:
@@ -270,9 +371,9 @@ class OMOO_DA3_OT_run_job(bpy.types.Operator):
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"
class CancelJob(bpy.types.Operator):
bl_idname = "snapform.cancel_job"
bl_label = "Cancel SnapForm Task"
@classmethod
def poll(cls, _context):

View File

@@ -5,12 +5,12 @@ from .operators import active_job
from .runtime import model_ready, runtime_ready
class OMOO_DA3_PT_runtime_models(bpy.types.Panel):
class RuntimeModelsPanel(bpy.types.Panel):
bl_label = "Setup"
bl_idname = "OMOO_DA3_PT_runtime_models"
bl_idname = "SNAPFORM_PT_runtime_models"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Omoo DA3"
bl_category = "SnapForm"
def draw(self, _context):
layout = self.layout
@@ -20,7 +20,7 @@ class OMOO_DA3_PT_runtime_models(bpy.types.Panel):
layout.label(text="Runtime is not installed", icon="ERROR")
if not running:
operator = layout.operator(
"omoo_da3.run_job",
"snapform.run_job",
text="Install Runtime",
icon="IMPORT",
)
@@ -36,7 +36,7 @@ class OMOO_DA3_PT_runtime_models(bpy.types.Panel):
row.label(text="Ready", icon="CHECKMARK")
elif runtime_ready() and not running:
operator = row.operator(
"omoo_da3.run_job",
"snapform.run_job",
text="Download",
icon="IMPORT",
)
@@ -46,17 +46,17 @@ class OMOO_DA3_PT_runtime_models(bpy.types.Panel):
row.label(text="Not downloaded", icon="ERROR")
class OMOO_DA3_PT_debug(bpy.types.Panel):
class DebugPanel(bpy.types.Panel):
bl_label = "Debug"
bl_idname = "OMOO_DA3_PT_debug"
bl_idname = "SNAPFORM_PT_debug"
bl_space_type = "VIEW_3D"
bl_region_type = "UI"
bl_category = "Omoo DA3"
bl_category = "SnapForm"
bl_options = {"DEFAULT_CLOSED"}
def draw(self, context):
layout = self.layout
settings = context.scene.omoo_da3_settings
settings = context.scene.snapform_settings
layout.prop(settings, "export_format")
model_key = selected_model_key(settings)
@@ -105,7 +105,7 @@ class OMOO_DA3_PT_debug(bpy.types.Panel):
if active_job() is None:
operator = layout.operator(
"omoo_da3.run_job",
"snapform.run_job",
text="Export",
icon="EXPORT",
)

View File

@@ -4,7 +4,7 @@ from pathlib import Path
import bpy
class OMOO_DA3_preferences(bpy.types.AddonPreferences):
class SnapFormPreferences(bpy.types.AddonPreferences):
bl_idname = __package__
storage_root: bpy.props.StringProperty(
@@ -14,7 +14,7 @@ class OMOO_DA3_preferences(bpy.types.AddonPreferences):
"all Blender versions can use the same directory"
),
subtype="DIR_PATH",
default=str(Path.home() / ".omoo_da3"),
default=str(Path.home() / ".snapform"),
)
uv_executable: bpy.props.StringProperty(
name="uv Executable",
@@ -45,7 +45,7 @@ class OMOO_DA3_preferences(bpy.types.AddonPreferences):
layout.label(text="Runtime is ready", icon="CHECKMARK")
else:
operator = layout.operator(
"omoo_da3.run_job",
"snapform.run_job",
text="Install Runtime",
icon="IMPORT",
)

View File

@@ -3,7 +3,7 @@ import bpy
from .models import enum_items
class OMOO_DA3_PG_settings(bpy.types.PropertyGroup):
class SnapFormSettings(bpy.types.PropertyGroup):
input_path: bpy.props.StringProperty(
name="Image or Video",
description="A single image or video file used for reconstruction",
@@ -41,9 +41,9 @@ class OMOO_DA3_PG_settings(bpy.types.PropertyGroup):
process_res: bpy.props.IntProperty(
name="Process Res",
description="DA3 base processing resolution",
default=504,
default=1024,
min=1,
soft_max=1008,
soft_max=2048,
)
process_res_method: bpy.props.EnumProperty(
name="Process Res Method",
@@ -60,7 +60,7 @@ class OMOO_DA3_PG_settings(bpy.types.PropertyGroup):
"Make Process Res the shorter side",
),
),
default="upper_bound_resize",
default="lower_bound_resize",
)
conf_thresh_percentile: bpy.props.FloatProperty(
name="Conf Thresh Percentile",

View File

@@ -13,7 +13,7 @@ from .models import get_model, selected_model_key
DA3_COMMIT = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4"
RUNTIME_SCHEMA = 2
DEFAULT_STORAGE_ROOT = Path.home() / ".omoo_da3"
DEFAULT_STORAGE_ROOT = Path.home() / ".snapform"
def preferences():
@@ -45,7 +45,7 @@ def runtime_python():
def runtime_ready():
ready_path = runtime_directory() / "omoo_da3_ready.json"
ready_path = runtime_directory() / "snapform_ready.json"
if not runtime_python().is_file() or not ready_path.is_file():
return False
try:
@@ -146,16 +146,36 @@ def reconstruct_command(
export_format,
status_path,
result_directory,
input_path=None,
model_key=None,
process_res=None,
process_res_method=None,
use_ray_pose=None,
):
model_key = selected_model_key(settings, export_format)
snapform_selected_key = model_key or selected_model_key(settings, export_format)
snapform_selected_process_res = (
settings.process_res if process_res is None else process_res
)
snapform_selected_process_res_method = (
settings.process_res_method
if process_res_method is None
else process_res_method
)
snapform_selected_ray_pose = (
settings.use_ray_pose if use_ray_pose is None else use_ray_pose
)
return [
str(runtime_python()),
str(Path(__file__).with_name("worker.py")),
"reconstruct",
"--input",
str(Path(bpy.path.abspath(settings.input_path))),
str(
Path(input_path)
if input_path is not None
else Path(bpy.path.abspath(settings.input_path))
),
"--model-dir",
str(model_directory(model_key)),
str(model_directory(snapform_selected_key)),
"--output-dir",
str(result_directory),
"--export-format",
@@ -163,9 +183,9 @@ def reconstruct_command(
"--status",
str(status_path),
"--process-res",
str(settings.process_res),
str(snapform_selected_process_res),
"--process-res-method",
settings.process_res_method,
snapform_selected_process_res_method,
"--conf-thresh-percentile",
str(settings.conf_thresh_percentile),
"--num-max-points",
@@ -179,7 +199,7 @@ def reconstruct_command(
"--device",
settings.device.lower(),
"--use-ray-pose",
"1" if settings.use_ray_pose else "0",
"1" if snapform_selected_ray_pose else "0",
"--ref-view-strategy",
settings.ref_view_strategy,
]

View File

@@ -1,36 +1,33 @@
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:
job = active_job()
if job is None or scene is None:
return
settings = getattr(scene, "omoo_da3_settings", None)
settings = getattr(scene, "snapform_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.ui_units_x = 12.0
progress_row.progress(
factor=progress,
type="BAR",
text=f"{progress:.0%}",
text=settings.status,
)
row.operator(
"omoo_da3.cancel_job",
"snapform.cancel_job",
text="",
icon="X",
)
def redraw_ui(context, force=False):
def tag_ui_regions(context):
window_manager = getattr(context, "window_manager", None)
if window_manager is None:
return
@@ -42,10 +39,9 @@ def redraw_ui(context, force=False):
for area in screen.areas:
if area.type in {"STATUSBAR", "VIEW_3D"}:
area.tag_redraw()
for region in getattr(area, "regions", ()):
region.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
def redraw_ui(context, force=False):
tag_ui_regions(context)

View File

@@ -2,6 +2,7 @@ import argparse
import json
import shutil
import tempfile
import threading
import time
from pathlib import Path
@@ -93,7 +94,7 @@ def prepare_input(input_path, video_frames, temporary_root):
import cv2
temporary_directory = Path(
tempfile.mkdtemp(prefix="omoo-da3-video-", dir=temporary_root)
tempfile.mkdtemp(prefix="snapform-video-", dir=temporary_root)
)
try:
images = extract_video_frames(
@@ -108,15 +109,85 @@ def prepare_input(input_path, video_frames, temporary_root):
return images, temporary_directory
class DownloadProgressReporter:
def __init__(self, status_path, repository, clock=time.monotonic):
self.status_path = status_path
self.repository = repository
self.clock = clock
self.lock = threading.Lock()
self.progress = 0.05
self.last_write_time = 0.0
def report(self, current, total):
if not total or current <= 0:
return
factor = min(max(current / total, 0.0), 1.0)
progress = 0.05 + factor * 0.90
now = self.clock()
with self.lock:
if progress <= self.progress:
return
if now - self.last_write_time < 0.20 and progress < 0.95:
return
self.progress = progress
self.last_write_time = now
write_status(
self.status_path,
progress,
f"Downloading {self.repository}",
)
def create_download_progress_class(
status_path,
repository,
tqdm_type=None,
clock=time.monotonic,
):
if tqdm_type is None:
from tqdm.auto import tqdm as tqdm_type
reporter = DownloadProgressReporter(status_path, repository, clock)
class DownloadProgress(tqdm_type):
def __init__(self, *args, **kwargs):
description = str(kwargs.get("desc", ""))
self.snapform_reports_progress = (
description == "Downloading bytes"
or description.startswith("Fetching ")
)
super().__init__(*args, **kwargs)
def update(self, amount=1):
result = super().update(amount)
if self.snapform_reports_progress:
reporter.report(self.n, self.total)
return result
def refresh(self, *args, **kwargs):
result = super().refresh(*args, **kwargs)
if self.snapform_reports_progress:
reporter.report(self.n, self.total)
return result
return DownloadProgress
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}")
progress_class = create_download_progress_class(
args.status,
args.repository,
)
snapshot_download(
repo_id=args.repository,
local_dir=model_dir,
tqdm_class=progress_class,
)
write_status(args.status, 1.0, "Model is ready")
@@ -158,13 +229,15 @@ def wait_for_exported_paths(output_directory, export_format, timeout=30.0):
def reconstruct(args):
write_status(args.status, 0.02, "Starting DA3 runtime")
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")
write_status(args.status, 0.08, "Preparing input")
images, temporary_directory = prepare_input(
args.input,
args.video_frames,
@@ -173,12 +246,16 @@ def reconstruct(args):
try:
device = select_device(args.device, torch)
write_status(args.status, 0.10, f"Loading model on {device.type}")
write_status(args.status, 0.15, 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)")
write_status(
args.status,
0.25,
f"Estimating depth for {len(images)} frame(s)",
)
export_kwargs = {}
if args.export_format == "glb":
export_kwargs["glb"] = {

View File

@@ -32,12 +32,17 @@ class AddonTest(unittest.TestCase):
PropertyGroup=object,
Scene=type("Scene", (), {}),
STATUSBAR_HT_header=SimpleNamespace(
prepend=lambda draw: cls.status_bar_draws.append(draw),
append=lambda draw: cls.status_bar_draws.append(draw),
remove=lambda draw: cls.status_bar_draws.remove(draw),
),
VIEW3D_MT_object_context_menu=SimpleNamespace(
append=lambda draw: cls.context_menu_draws.append(draw),
remove=lambda draw: cls.context_menu_draws.remove(draw),
),
)
cls.registered = []
cls.status_bar_draws = []
cls.context_menu_draws = []
fake_bpy.utils = SimpleNamespace(
register_class=cls.registered.append,
unregister_class=cls.registered.remove,
@@ -49,7 +54,7 @@ class AddonTest(unittest.TestCase):
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.addon = importlib.import_module("snapform")
cls.fake_bpy = fake_bpy
@classmethod
@@ -61,15 +66,16 @@ class AddonTest(unittest.TestCase):
@staticmethod
def remove_modules():
for module_name in tuple(sys.modules):
if module_name == "omoo_da3" or module_name.startswith("omoo_da3."):
if module_name == "snapform" or module_name.startswith("snapform."):
sys.modules.pop(module_name)
def tearDown(self):
self.registered.clear()
self.status_bar_draws.clear()
self.context_menu_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
if hasattr(self.fake_bpy.types.Scene, "snapform_settings"):
del self.fake_bpy.types.Scene.snapform_settings
def test_uses_extension_manifest_instead_of_legacy_bl_info(self):
self.assertFalse(hasattr(self.addon, "bl_info"))
@@ -79,40 +85,215 @@ class AddonTest(unittest.TestCase):
self.assertEqual(self.registered, list(self.addon.CLASSES))
self.assertTrue(
hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings")
hasattr(self.fake_bpy.types.Scene, "snapform_settings")
)
self.assertEqual(
self.status_bar_draws,
[self.addon.draw_status_bar],
)
self.assertEqual(
self.context_menu_draws,
[self.addon.draw_image_context_menu],
)
self.addon.unregister()
self.assertEqual(self.registered, [])
self.assertFalse(
hasattr(self.fake_bpy.types.Scene, "omoo_da3_settings")
hasattr(self.fake_bpy.types.Scene, "snapform_settings")
)
self.assertEqual(self.status_bar_draws, [])
self.assertEqual(self.context_menu_draws, [])
def test_registers_depth_plane_operator_without_cache_rebuild(self):
self.assertIn(
self.addon.ConvertImageToDepthPlane,
self.addon.CLASSES,
)
self.assertIn(self.addon.BuildDepthPlane, self.addon.CLASSES)
self.assertEqual(
self.addon.BuildDepthPlane.bl_options,
{"INTERNAL"},
)
self.assertFalse(hasattr(self.addon, "ImportDepthPlane"))
def test_completed_depth_job_uses_undoable_build_operator(self):
source = inspect.getsource(
self.addon.RunJob._build_depth_plane
)
self.assertIn("bpy.ops.snapform.build_depth_plane(", source)
self.assertIn('if "FINISHED" not in result:', source)
self.assertIn(
'bpy.ops.ed.undo_push(message="Convert to Depth Plane")',
source,
)
self.assertIn('if "FINISHED" not in undo_result:', source)
def test_image_context_menu_only_exposes_conversion(self):
draw_source = inspect.getsource(
self.addon.draw_image_context_menu
)
self.assertIn(
'"snapform.convert_image_to_depth_plane"',
draw_source,
)
self.assertIn('operator_context = "INVOKE_DEFAULT"', draw_source)
self.assertNotIn("import_depth_plane", draw_source)
self.assertNotIn("Rebuild", draw_source)
def test_conversion_dialog_exposes_requested_settings(self):
operator = self.addon.ConvertImageToDepthPlane
self.assertEqual(
operator.__annotations__["snapform_model"]["default"],
"BASE",
)
self.assertTrue(operator.__annotations__["snapform_use_ray_pose"]["default"])
self.assertEqual(
operator.__annotations__["snapform_process_res"]["default"],
1024,
)
self.assertTrue(
operator.__annotations__["snapform_delete_original"]["default"]
)
self.assertEqual(
operator.__annotations__["snapform_subdivision_level"]["default"],
5,
)
self.assertEqual(
set(operator.__annotations__),
{
"snapform_model",
"snapform_use_ray_pose",
"snapform_process_res",
"snapform_delete_original",
"snapform_subdivision_level",
},
)
model_items = operator.__annotations__["snapform_model"]["items"]
self.assertEqual(
[item[0] for item in model_items],
[
"SMALL",
"BASE",
"LARGE_1_1",
"GIANT_1_1",
"NESTED_GIANT_LARGE_1_1",
],
)
self.assertIn("invoke_props_dialog", inspect.getsource(operator.invoke))
def test_image_depth_model_and_ray_pose_are_independent(self):
settings = SimpleNamespace(
input_path="debug-input.png",
export_format="gs_ply",
model="BASE",
gs_model="GIANT_1_1",
)
operator = self.addon.RunJob()
operator.job_type = self.addon.operators.IMAGE_DEPTH_JOB
operator.snapform_source_object_name = "Reference"
operator.snapform_process_res = 336
with TemporaryDirectory() as directory:
input_path = Path(directory) / "input.png"
input_path.touch()
operator.snapform_input_path = str(input_path)
paths = {
"log": Path(directory) / "job.log",
"status": Path(directory) / "job.json",
"result": Path(directory) / "result",
}
for snapform_model, snapform_ray_pose in (
("SMALL", True),
("BASE", False),
("LARGE_1_1", True),
("GIANT_1_1", False),
("NESTED_GIANT_LARGE_1_1", True),
):
with self.subTest(model=snapform_model), patch.object(
self.addon.runtime,
"new_job_paths",
return_value=paths,
), patch.object(
self.addon.runtime,
"runtime_ready",
return_value=True,
), patch.object(
self.addon.runtime,
"model_ready",
return_value=True,
) as model_ready, patch.object(
self.addon.runtime,
"reconstruct_command",
return_value=["worker"],
) as reconstruct:
operator.snapform_model_key = snapform_model
operator.snapform_use_ray_pose = snapform_ray_pose
command, prepared_paths = operator._prepare_command(
None,
settings,
)
self.assertEqual(command, ["worker"])
self.assertEqual(prepared_paths, paths)
model_ready.assert_called_once_with(snapform_model)
self.assertEqual(reconstruct.call_args.args[1], "mini_npz")
self.assertEqual(
reconstruct.call_args.kwargs["input_path"],
input_path,
)
self.assertEqual(
reconstruct.call_args.kwargs["model_key"],
snapform_model,
)
self.assertEqual(
reconstruct.call_args.kwargs["process_res"],
336,
)
self.assertEqual(
reconstruct.call_args.kwargs["process_res_method"],
"lower_bound_resize",
)
self.assertEqual(
reconstruct.call_args.kwargs["use_ray_pose"],
snapform_ray_pose,
)
def test_preferences_use_package_identifier(self):
self.assertEqual(
self.addon.OMOO_DA3_preferences.bl_idname,
"omoo_da3",
self.addon.SnapFormPreferences.bl_idname,
"snapform",
)
def test_default_storage_is_shared_in_user_home(self):
runtime = importlib.import_module("omoo_da3.runtime")
runtime = importlib.import_module("snapform.runtime")
self.assertEqual(runtime.DEFAULT_STORAGE_ROOT.name, ".omoo_da3")
self.assertEqual(runtime.DEFAULT_STORAGE_ROOT.name, ".snapform")
self.assertEqual(
self.addon.OMOO_DA3_preferences.__annotations__["storage_root"][
self.addon.SnapFormPreferences.__annotations__["storage_root"][
"default"
],
str(runtime.DEFAULT_STORAGE_ROOT),
)
def test_process_res_method_defaults_to_lower_bound_resize(self):
process_res = self.addon.SnapFormSettings.__annotations__[
"process_res"
]
process_res_method = (
self.addon.SnapFormSettings.__annotations__[
"process_res_method"
]
)
self.assertEqual(process_res["default"], 1024)
self.assertEqual(process_res_method["default"], "lower_bound_resize")
def test_reconstruct_command_preserves_da3_parameter_names(self):
runtime = importlib.import_module("omoo_da3.runtime")
runtime = importlib.import_module("snapform.runtime")
settings = SimpleNamespace(
input_path="input.png",
model="GIANT_1_1",
@@ -159,8 +340,56 @@ class AddonTest(unittest.TestCase):
with self.subTest(parameter=parameter):
self.assertIn(parameter, command)
def test_reconstruct_command_accepts_image_depth_overrides(self):
runtime = importlib.import_module("snapform.runtime")
settings = SimpleNamespace(
input_path="input.png",
model="BASE",
gs_model="GIANT_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="AUTO",
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("large-model"),
) as snapform_model_directory:
snapform_command = runtime.reconstruct_command(
settings,
"mini_npz",
Path("status.json"),
Path("result"),
model_key="BASE",
process_res=252,
process_res_method="lower_bound_resize",
use_ray_pose=False,
)
snapform_model_directory.assert_called_once_with("BASE")
snapform_process_res_index = snapform_command.index("--process-res")
self.assertEqual(snapform_command[snapform_process_res_index + 1], "252")
snapform_process_res_method_index = snapform_command.index("--process-res-method")
self.assertEqual(
snapform_command[snapform_process_res_method_index + 1],
"lower_bound_resize",
)
snapform_ray_pose_index = snapform_command.index("--use-ray-pose")
self.assertEqual(snapform_command[snapform_ray_pose_index + 1], "0")
def test_export_format_property_includes_all_da3_formats(self):
export_format = self.addon.OMOO_DA3_PG_settings.__annotations__[
export_format = self.addon.SnapFormSettings.__annotations__[
"export_format"
]
identifiers = [item[0] for item in export_format["items"]]
@@ -172,7 +401,7 @@ class AddonTest(unittest.TestCase):
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 = self.addon.SnapFormSettings.__annotations__[
"gs_model"
]
identifiers = [item[0] for item in gs_model["items"]]
@@ -185,12 +414,12 @@ class AddonTest(unittest.TestCase):
def test_debug_panel_is_closed_by_default(self):
self.assertIn(
"DEFAULT_CLOSED",
self.addon.OMOO_DA3_PT_debug.bl_options,
self.addon.DebugPanel.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.addon.DebugPanel.draw
)
self.assertEqual(draw_source.count("layout.operator("), 1)
@@ -207,10 +436,10 @@ class AddonTest(unittest.TestCase):
def test_runtime_models_panel_assigns_each_download_target(self):
draw_source = inspect.getsource(
self.addon.OMOO_DA3_PT_runtime_models.draw
self.addon.RuntimeModelsPanel.draw
)
model_source = inspect.getsource(
self.addon.OMOO_DA3_PT_runtime_models._draw_model
self.addon.RuntimeModelsPanel._draw_model
)
self.assertIn("for model in MODELS.values()", draw_source)
@@ -218,7 +447,7 @@ class AddonTest(unittest.TestCase):
self.assertNotIn("Isolated Runtime ready", draw_source)
self.assertNotIn("License:", model_source)
self.assertEqual(
self.addon.OMOO_DA3_PT_runtime_models.bl_label,
self.addon.RuntimeModelsPanel.bl_label,
"Setup",
)
@@ -257,7 +486,7 @@ class AddonTest(unittest.TestCase):
self.addon.operators._active_job = SimpleNamespace()
context = SimpleNamespace(
scene=SimpleNamespace(
omoo_da3_settings=SimpleNamespace(
snapform_settings=SimpleNamespace(
status="Processing",
progress=0.25,
),
@@ -271,23 +500,92 @@ class AddonTest(unittest.TestCase):
self.assertEqual(
progress_calls,
[{"factor": 0.25, "type": "BAR", "text": "25%"}],
[{"factor": 0.25, "type": "BAR", "text": "Processing"}],
)
self.assertEqual(
operator_calls,
[("omoo_da3.cancel_job", {"text": "", "icon": "X"})],
[("snapform.cancel_job", {"text": "", "icon": "X"})],
)
def test_image_depth_status_uses_same_progress_layout(self):
progress_calls = []
operator_calls = []
class Layout:
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(
kind=self.addon.operators.IMAGE_DEPTH_JOB,
)
context = SimpleNamespace(
scene=SimpleNamespace(
snapform_settings=SimpleNamespace(
status="Estimating depth",
progress=0.25,
),
),
)
self.addon.draw_status_bar(
SimpleNamespace(layout=Layout()),
context,
)
self.assertEqual(
progress_calls,
[
{
"factor": 0.25,
"type": "BAR",
"text": "Estimating depth",
}
],
)
self.assertEqual(
operator_calls,
[("snapform.cancel_job", {"text": "", "icon": "X"})],
)
def test_progress_is_appended_before_cancel_button(self):
register_source = inspect.getsource(self.addon.register)
draw_source = inspect.getsource(self.addon.draw_status_bar)
self.assertIn("STATUSBAR_HT_header.append", register_source)
self.assertNotIn("STATUSBAR_HT_header.prepend", register_source)
self.assertLess(
draw_source.index("progress_row.progress("),
draw_source.index('"snapform.cancel_job"'),
)
self.assertIn("progress_row.ui_units_x = 12.0", draw_source)
self.assertNotIn('label(text="SnapForm"', draw_source)
def test_ui_redraw_targets_status_bar_and_view_3d_areas(self):
redrawn = []
status_regions = (
SimpleNamespace(
tag_redraw=lambda: redrawn.append("status-region"),
),
)
areas = (
SimpleNamespace(
type="STATUSBAR",
tag_redraw=lambda: redrawn.append("status"),
regions=status_regions,
),
SimpleNamespace(
type="VIEW_3D",
tag_redraw=lambda: redrawn.append("view"),
regions=(),
),
)
context = SimpleNamespace(
@@ -298,25 +596,46 @@ class AddonTest(unittest.TestCase):
),
)
self.addon.status_bar.redraw_ui(context)
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),
),
self.assertEqual(
redrawn,
[
"status",
"status-region",
"view",
"status",
"status-region",
"view",
],
)
def test_force_flag_does_not_trigger_window_draw_and_swap(self):
redrawn = []
context = SimpleNamespace(
window=SimpleNamespace(),
window_manager=SimpleNamespace(windows=()),
window_manager=SimpleNamespace(
windows=(
SimpleNamespace(
screen=SimpleNamespace(
areas=(
SimpleNamespace(
type="STATUSBAR",
tag_redraw=lambda: redrawn.append(
"status"
),
regions=(),
),
),
),
),
),
),
)
self.addon.status_bar.redraw_ui(context, force=True)
self.assertEqual(
calls,
[{"type": "DRAW_WIN_SWAP", "iterations": 1}],
)
self.assertEqual(redrawn, ["status"])
source = inspect.getsource(self.addon.status_bar)
self.assertNotIn("redraw_timer", source)
self.assertNotIn("DRAW_WIN_SWAP", source)

View File

@@ -11,8 +11,8 @@ 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",
"snapform_bootstrap_runtime",
PROJECT_ROOT / "src" / "snapform" / "bootstrap_runtime.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)

View File

@@ -0,0 +1,83 @@
import importlib.util
import json
import tempfile
import unittest
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_worker():
specification = importlib.util.spec_from_file_location(
"snapform_download_worker",
PROJECT_ROOT / "src" / "snapform" / "worker.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
class FakeTqdm:
def __init__(self, *args, **kwargs):
self.n = kwargs.get("initial", 0)
self.total = kwargs.get("total", 0)
def update(self, amount=1):
self.n += amount
return True
def refresh(self, *args, **kwargs):
return True
class DownloadProgressTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.worker = load_worker()
def test_reports_aggregated_download_bytes(self):
with tempfile.TemporaryDirectory() as directory:
status_path = Path(directory) / "status.json"
progress_class = self.worker.create_download_progress_class(
status_path,
"owner/model",
tqdm_type=FakeTqdm,
clock=lambda: 1.0,
)
progress = progress_class(
desc="Downloading bytes",
total=100,
initial=0,
unit="B",
)
progress.update(25)
status = json.loads(status_path.read_text(encoding="utf-8"))
self.assertAlmostEqual(status["progress"], 0.275)
self.assertEqual(status["message"], "Downloading owner/model")
def test_ignores_unrelated_progress_bars(self):
with tempfile.TemporaryDirectory() as directory:
status_path = Path(directory) / "status.json"
progress_class = self.worker.create_download_progress_class(
status_path,
"owner/model",
tqdm_type=FakeTqdm,
clock=lambda: 1.0,
)
progress = progress_class(
desc="Reconstructing",
total=100,
initial=0,
unit="B",
)
progress.update(25)
self.assertFalse(status_path.exists())
if __name__ == "__main__":
unittest.main()

330
tests/test_image_depth.py Normal file
View File

@@ -0,0 +1,330 @@
import importlib.util
import inspect
import sys
import tempfile
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
import numpy as np
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_image_depth():
fake_bpy = ModuleType("bpy")
fake_bpy.props = SimpleNamespace(
BoolProperty=lambda **options: options,
EnumProperty=lambda **options: options,
FloatProperty=lambda **options: options,
IntProperty=lambda **options: options,
StringProperty=lambda **options: options,
)
fake_bpy.types = SimpleNamespace(Operator=object)
fake_bpy.path = SimpleNamespace(abspath=lambda path: path)
with patch.dict(sys.modules, {"bpy": fake_bpy}):
specification = importlib.util.spec_from_file_location(
"snapform_image_depth_test",
PROJECT_ROOT / "src" / "snapform" / "image_depth.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
return module
class ImageDepthTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.image_depth = load_image_depth()
def test_extracts_depth_and_uses_first_view(self):
with tempfile.TemporaryDirectory() as directory:
npz_path = Path(directory) / "results.npz"
np.savez_compressed(
npz_path,
depth=np.array(
(
((1.0, 2.0), (3.0, 4.0)),
((5.0, 6.0), (7.0, 8.0)),
),
dtype=np.float32,
),
)
depth = self.image_depth.extract_depth_array(npz_path)
np.testing.assert_array_equal(
depth,
np.array(((1.0, 2.0), (3.0, 4.0)), dtype=np.float32),
)
def test_accepts_depths_key_with_single_channel(self):
with tempfile.TemporaryDirectory() as directory:
npz_path = Path(directory) / "results.npz"
np.savez_compressed(
npz_path,
depths=np.ones((1, 2, 3, 1), dtype=np.float32),
)
depth = self.image_depth.extract_depth_array(npz_path)
self.assertEqual(depth.shape, (2, 3))
def test_accepts_single_depth_map_with_channel_axis(self):
with tempfile.TemporaryDirectory() as directory:
npz_path = Path(directory) / "results.npz"
np.savez_compressed(
npz_path,
depth=np.ones((2, 3, 1), dtype=np.float32),
)
depth = self.image_depth.extract_depth_array(npz_path)
self.assertEqual(depth.shape, (2, 3))
def test_extracts_camera_intrinsics(self):
expected = np.array(
((100.0, 0.0, 50.0), (0.0, 120.0, 40.0), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
with tempfile.TemporaryDirectory() as directory:
npz_path = Path(directory) / "results.npz"
np.savez_compressed(
npz_path,
intrinsics=expected[None, ...],
)
intrinsics = self.image_depth.extract_intrinsics_array(
npz_path
)
np.testing.assert_array_equal(intrinsics, expected)
def test_camera_plane_bounds_use_intrinsics_and_median_depth(self):
source = SimpleNamespace(
data=SimpleNamespace(size=(200, 100)),
empty_image_offset=(-0.5, -0.5),
)
depth = np.full((100, 200), 2.0, dtype=np.float32)
intrinsics = np.array(
((100.0, 0.0, 100.0), (0.0, 100.0, 50.0), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
bounds, reference_depth = self.image_depth.camera_plane_bounds(
source,
depth,
intrinsics,
-1.0,
)
self.assertEqual(bounds, (-2.0, 2.0, -1.0, 1.0))
self.assertEqual(reference_depth, 2.0)
def test_camera_plane_bounds_follow_displacement_unit_scale(self):
source = SimpleNamespace(
data=SimpleNamespace(size=(200, 100)),
empty_image_offset=(-0.5, -0.5),
)
depth = np.full((100, 200), 2.0, dtype=np.float32)
intrinsics = np.array(
((100.0, 0.0, 100.0), (0.0, 100.0, 50.0), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
bounds, _reference_depth = (
self.image_depth.camera_plane_bounds(
source,
depth,
intrinsics,
-0.5,
)
)
self.assertEqual(bounds, (-1.0, 1.0, -0.5, 0.5))
def test_camera_plane_bounds_preserve_square_source_aspect(self):
source = SimpleNamespace(
data=SimpleNamespace(size=(447, 447)),
empty_image_offset=(-0.5, -0.5),
)
depth = np.full((140, 140), 0.7366, dtype=np.float32)
intrinsics = np.array(
(
(460.758, 0.0, 70.0),
(0.0, 393.372, 70.0),
(0.0, 0.0, 1.0),
),
dtype=np.float32,
)
bounds, _reference_depth = (
self.image_depth.camera_plane_bounds(
source,
depth,
intrinsics,
-1.0,
)
)
width = bounds[1] - bounds[0]
height = bounds[3] - bounds[2]
self.assertAlmostEqual(width / height, 1.0)
def test_raw_distance_preserves_original_values(self):
depth = np.array(
((1.25, 2.5), (5.0, np.nan)),
dtype=np.float32,
)
mapped = self.image_depth.prepare_depth_values(depth)
self.assertEqual(float(mapped[0, 0]), 1.25)
self.assertEqual(float(mapped[0, 1]), 2.5)
self.assertEqual(float(mapped[1, 0]), 5.0)
self.assertEqual(float(mapped[1, 1]), 0.0)
def test_depth_plane_uses_fixed_import_settings(self):
self.assertEqual(self.image_depth.DISPLACEMENT_STRENGTH, -1.0)
self.assertEqual(self.image_depth.DISPLACEMENT_MIDLEVEL, 1.0)
self.assertEqual(self.image_depth.BUMP_BASE_DISTANCE, 0.001)
def test_grid_segments_follow_aspect_ratio_from_subdivision_power(self):
self.assertEqual(
self.image_depth.grid_segments((0.0, 3.0, 0.0, 2.0), 1),
(3, 2),
)
self.assertEqual(
self.image_depth.grid_segments((0.0, 2.0, 0.0, 3.0), 1),
(2, 3),
)
self.assertEqual(
self.image_depth.grid_segments((0.0, 3.0, 0.0, 2.0), 3),
(12, 8),
)
def test_modifiers_use_native_names_and_fixed_levels(self):
source = inspect.getsource(self.image_depth.build_depth_plane)
self.assertIn('modifiers.new("Subdivision", "SUBSURF")', source)
self.assertIn('modifiers.new("Displace", "DISPLACE")', source)
self.assertNotIn("DA3 Subdivision", source)
self.assertNotIn("DA3 Displace", source)
self.assertIn("subdivision.levels = 1", source)
self.assertIn("subdivision.render_levels = 2", source)
def test_plane_fit_transform_matches_source_size_and_offset(self):
source = SimpleNamespace(
data=SimpleNamespace(size=(800, 400)),
empty_display_size=2.0,
empty_image_offset=(-0.25, -0.75),
)
transform = self.image_depth.plane_fit_transform(
source,
(-0.1, 0.3, -0.1, 0.1),
)
for value, expected in zip(
transform,
(5.0, 5.0, 5.0, 0.0, -0.25),
):
self.assertAlmostEqual(value, expected)
def test_source_empty_is_only_removed_when_requested(self):
source = inspect.getsource(self.image_depth.build_depth_plane)
self.assertIn("source_name = source_object.name", source)
self.assertIn("if delete_source:", source)
self.assertIn(
"bpy.data.objects.remove(source_object, do_unlink=True)",
source,
)
self.assertNotIn("source_object.name =", source)
self.assertIn(
"bpy.data.objects.new(\n source_name,",
source,
)
self.assertIn(
"bpy.data.objects.remove(source_object, do_unlink=True)\n"
" plane_object.name = source_name",
source,
)
def test_displace_texture_uses_extend(self):
source = inspect.getsource(
self.image_depth.build_depth_plane
)
self.assertIn('depth_name = f"{source_name}.depth"', source)
self.assertIn("create_depth_image(\n depth_name,", source)
self.assertIn("bpy.data.textures.new(\n depth_name,", source)
self.assertIn('texture.extension = "EXTEND"', source)
def test_original_image_drives_bump_instead_of_displacement(self):
source = inspect.getsource(self.image_depth.create_material)
self.assertIn('nodes.new("ShaderNodeBump")', source)
self.assertIn(
'color_texture.outputs["Color"], bump.inputs["Height"]',
source,
)
self.assertIn(
'bump.outputs["Normal"], shader.inputs["Normal"]',
source,
)
self.assertIn(
'bump.inputs["Distance"].default_value = '
"BUMP_BASE_DISTANCE * depth_scale",
source,
)
self.assertIn('nodes.new("ShaderNodeFloatCurve")', source)
self.assertIn(
'roughness_curve.inputs["Value"]',
source,
)
self.assertIn(
'shader.inputs["Roughness"]',
source,
)
self.assertIn(
'curve_points[0].location = (0.0, 0.5)',
source,
)
self.assertIn(
'curve_points[-1].location = (1.0, 0.1)',
source,
)
self.assertIn(
'shader.inputs["IOR"].default_value = 1.2',
source,
)
self.assertIn(
'roughness_curve.location = (-300.0, 320.0)',
source,
)
self.assertIn('bump.location = (-300.0, -80.0)', source)
self.assertNotIn("ShaderNodeDisplacement", source)
def test_plane_transform_scales_depth_axis(self):
source = inspect.getsource(self.image_depth.apply_plane_transform)
self.assertIn("(0.0, 0.0, scale_z, 0.0)", source)
self.assertIn("plane_object.scale.z", source)
def test_plane_bounds_keep_image_aspect_and_empty_offset(self):
source = SimpleNamespace(
data=SimpleNamespace(size=(800, 400)),
empty_display_size=2.0,
empty_image_offset=(-0.5, -0.5),
)
bounds = self.image_depth.plane_bounds(source)
self.assertEqual(bounds, (-1.0, 1.0, -0.5, 0.5))
if __name__ == "__main__":
unittest.main()

View File

@@ -4,7 +4,7 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[1]
MANIFEST_PATH = PROJECT_ROOT / "src" / "omoo_da3" / "blender_manifest.toml"
MANIFEST_PATH = PROJECT_ROOT / "src" / "snapform" / "blender_manifest.toml"
class ManifestTest(unittest.TestCase):
@@ -14,7 +14,7 @@ class ManifestTest(unittest.TestCase):
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["id"], "snapform")
self.assertEqual(self.manifest["type"], "add-on")
self.assertNotIn("bl_info", self.manifest)

View File

@@ -20,8 +20,8 @@ class ModelTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.models = load_module(
"omoo_da3_models",
"src/omoo_da3/models.py",
"snapform_models",
"src/snapform/models.py",
)
def test_base_model_is_default_friendly_apache_model(self):

View File

@@ -10,7 +10,7 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_pack():
specification = importlib.util.spec_from_file_location(
"omoo_da3_pack",
"snapform_pack",
PROJECT_ROOT / "pack.py",
)
module = importlib.util.module_from_spec(specification)
@@ -29,11 +29,14 @@ class PackTest(unittest.TestCase):
finally:
pack.DIST_DIR = original_dist
self.assertEqual(output_path.name, "SnapForm.v0.9.27.b45.zip")
with ZipFile(output_path) as archive:
names = set(archive.namelist())
self.assertIn("__init__.py", names)
self.assertIn("blender_manifest.toml", names)
self.assertIn("image_depth.py", names)
self.assertNotIn("snapform_depth_plane.py", names)
self.assertIn("worker.py", names)
self.assertNotIn("importer.py", names)
self.assertNotIn("omoo_da3/__init__.py", names)
self.assertNotIn("snapform/__init__.py", names)

View File

@@ -1,4 +1,5 @@
import importlib.util
import inspect
import json
import sys
import tempfile
@@ -14,8 +15,8 @@ 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",
"snapform_worker",
PROJECT_ROOT / "src" / "snapform" / "worker.py",
)
module = importlib.util.module_from_spec(specification)
specification.loader.exec_module(module)
@@ -38,6 +39,14 @@ class WorkerTest(unittest.TestCase):
self.assertEqual([path.name for path in images], ["a.jpg", "b.PNG"])
def test_reconstruct_reports_status_before_loading_torch(self):
source = inspect.getsource(self.worker.reconstruct)
self.assertLess(
source.index('"Starting DA3 runtime"'),
source.index("import torch"),
)
def test_prepares_a_single_image_file(self):
with tempfile.TemporaryDirectory() as directory:
image_path = Path(directory) / "input.png"

4
uv.lock generated
View File

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