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:
@@ -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
104
README.md
@@ -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
|
||||
|
||||
8
pack.py
8
pack.py
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
50
src/snapform/__init__.py
Normal 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)
|
||||
@@ -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"
|
||||
|
||||
@@ -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
640
src/snapform/image_depth.py
Normal 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",
|
||||
)
|
||||
@@ -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):
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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"] = {
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
83
tests/test_download_progress.py
Normal file
83
tests/test_download_progress.py
Normal 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
330
tests/test_image_depth.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user