From b02372c45788fe8c90966542d488bd623720d803 Mon Sep 17 00:00:00 2001 From: nan Date: Mon, 3 Aug 2026 10:13:33 +0800 Subject: [PATCH] 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. --- AGENTS.md | 9 +- README.md | 104 ++- pack.py | 8 +- pyproject.toml | 6 +- src/omoo_da3/__init__.py | 41 -- src/snapform/__init__.py | 50 ++ .../blender_manifest.toml | 8 +- .../bootstrap_runtime.py | 2 +- src/snapform/image_depth.py | 640 ++++++++++++++++++ src/{omoo_da3 => snapform}/models.py | 0 src/{omoo_da3 => snapform}/operators.py | 137 +++- src/{omoo_da3 => snapform}/panel.py | 20 +- src/{omoo_da3 => snapform}/preferences.py | 6 +- src/{omoo_da3 => snapform}/properties.py | 8 +- src/{omoo_da3 => snapform}/runtime.py | 36 +- src/{omoo_da3 => snapform}/status_bar.py | 28 +- src/{omoo_da3 => snapform}/worker.py | 85 ++- tests/test_addon.py | 393 ++++++++++- tests/test_bootstrap_runtime.py | 4 +- tests/test_download_progress.py | 83 +++ tests/test_image_depth.py | 330 +++++++++ tests/test_manifest.py | 4 +- tests/test_models.py | 4 +- tests/test_pack.py | 7 +- tests/test_worker.py | 13 +- uv.lock | 4 +- 26 files changed, 1849 insertions(+), 181 deletions(-) delete mode 100644 src/omoo_da3/__init__.py create mode 100644 src/snapform/__init__.py rename src/{omoo_da3 => snapform}/blender_manifest.toml (75%) rename src/{omoo_da3 => snapform}/bootstrap_runtime.py (98%) create mode 100644 src/snapform/image_depth.py rename src/{omoo_da3 => snapform}/models.py (100%) rename src/{omoo_da3 => snapform}/operators.py (62%) rename src/{omoo_da3 => snapform}/panel.py (89%) rename src/{omoo_da3 => snapform}/preferences.py (91%) rename src/{omoo_da3 => snapform}/properties.py (96%) rename src/{omoo_da3 => snapform}/runtime.py (81%) rename src/{omoo_da3 => snapform}/status_bar.py (64%) rename src/{omoo_da3 => snapform}/worker.py (78%) create mode 100644 tests/test_download_progress.py create mode 100644 tests/test_image_depth.py diff --git a/AGENTS.md b/AGENTS.md index 507ed6b..80c2ef1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` \ No newline at end of file diff --git a/README.md b/README.md index 55b43ff..70c421d 100644 --- a/README.md +++ b/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 / @@ -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 diff --git a/pack.py b/pack.py index a0f76b8..4725cd6 100644 --- a/pack.py +++ b/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: diff --git a/pyproject.toml b/pyproject.toml index 32e4728..59ee571 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/omoo_da3/__init__.py b/src/omoo_da3/__init__.py deleted file mode 100644 index 4d555c4..0000000 --- a/src/omoo_da3/__init__.py +++ /dev/null @@ -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) diff --git a/src/snapform/__init__.py b/src/snapform/__init__.py new file mode 100644 index 0000000..6493c21 --- /dev/null +++ b/src/snapform/__init__.py @@ -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) diff --git a/src/omoo_da3/blender_manifest.toml b/src/snapform/blender_manifest.toml similarity index 75% rename from src/omoo_da3/blender_manifest.toml rename to src/snapform/blender_manifest.toml index 2b8b519..00535dd 100644 --- a/src/omoo_da3/blender_manifest.toml +++ b/src/snapform/blender_manifest.toml @@ -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" diff --git a/src/omoo_da3/bootstrap_runtime.py b/src/snapform/bootstrap_runtime.py similarity index 98% rename from src/omoo_da3/bootstrap_runtime.py rename to src/snapform/bootstrap_runtime.py index 6187e76..fe4c0ed 100644 --- a/src/omoo_da3/bootstrap_runtime.py +++ b/src/snapform/bootstrap_runtime.py @@ -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( { diff --git a/src/snapform/image_depth.py b/src/snapform/image_depth.py new file mode 100644 index 0000000..290d9fb --- /dev/null +++ b/src/snapform/image_depth.py @@ -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", + ) diff --git a/src/omoo_da3/models.py b/src/snapform/models.py similarity index 100% rename from src/omoo_da3/models.py rename to src/snapform/models.py diff --git a/src/omoo_da3/operators.py b/src/snapform/operators.py similarity index 62% rename from src/omoo_da3/operators.py rename to src/snapform/operators.py index 3f5fe6d..052d709 100644 --- a/src/omoo_da3/operators.py +++ b/src/snapform/operators.py @@ -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): diff --git a/src/omoo_da3/panel.py b/src/snapform/panel.py similarity index 89% rename from src/omoo_da3/panel.py rename to src/snapform/panel.py index 03b80cb..24b8d17 100644 --- a/src/omoo_da3/panel.py +++ b/src/snapform/panel.py @@ -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", ) diff --git a/src/omoo_da3/preferences.py b/src/snapform/preferences.py similarity index 91% rename from src/omoo_da3/preferences.py rename to src/snapform/preferences.py index 4c70f4f..3c2fa23 100644 --- a/src/omoo_da3/preferences.py +++ b/src/snapform/preferences.py @@ -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", ) diff --git a/src/omoo_da3/properties.py b/src/snapform/properties.py similarity index 96% rename from src/omoo_da3/properties.py rename to src/snapform/properties.py index f0028bb..a3ec254 100644 --- a/src/omoo_da3/properties.py +++ b/src/snapform/properties.py @@ -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", diff --git a/src/omoo_da3/runtime.py b/src/snapform/runtime.py similarity index 81% rename from src/omoo_da3/runtime.py rename to src/snapform/runtime.py index 3716ba9..58130ef 100644 --- a/src/omoo_da3/runtime.py +++ b/src/snapform/runtime.py @@ -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, ] diff --git a/src/omoo_da3/status_bar.py b/src/snapform/status_bar.py similarity index 64% rename from src/omoo_da3/status_bar.py rename to src/snapform/status_bar.py index 1989b2a..6082921 100644 --- a/src/omoo_da3/status_bar.py +++ b/src/snapform/status_bar.py @@ -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) diff --git a/src/omoo_da3/worker.py b/src/snapform/worker.py similarity index 78% rename from src/omoo_da3/worker.py rename to src/snapform/worker.py index aa08759..5398ab7 100644 --- a/src/omoo_da3/worker.py +++ b/src/snapform/worker.py @@ -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"] = { diff --git a/tests/test_addon.py b/tests/test_addon.py index fc97c70..4417877 100644 --- a/tests/test_addon.py +++ b/tests/test_addon.py @@ -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) diff --git a/tests/test_bootstrap_runtime.py b/tests/test_bootstrap_runtime.py index 8991b7f..2c94504 100644 --- a/tests/test_bootstrap_runtime.py +++ b/tests/test_bootstrap_runtime.py @@ -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) diff --git a/tests/test_download_progress.py b/tests/test_download_progress.py new file mode 100644 index 0000000..459b551 --- /dev/null +++ b/tests/test_download_progress.py @@ -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() diff --git a/tests/test_image_depth.py b/tests/test_image_depth.py new file mode 100644 index 0000000..06ce5e9 --- /dev/null +++ b/tests/test_image_depth.py @@ -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() diff --git a/tests/test_manifest.py b/tests/test_manifest.py index 7fcf356..be06009 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -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) diff --git a/tests/test_models.py b/tests/test_models.py index de693b1..fc02145 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -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): diff --git a/tests/test_pack.py b/tests/test_pack.py index 36cbd6e..b84f113 100644 --- a/tests/test_pack.py +++ b/tests/test_pack.py @@ -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) diff --git a/tests/test_worker.py b/tests/test_worker.py index 95267d3..601a7f7 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -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" diff --git a/uv.lock b/uv.lock index 9825743..8f5ad45 100644 --- a/uv.lock +++ b/uv.lock @@ -3,6 +3,6 @@ revision = 3 requires-python = ">=3.11" [[package]] -name = "omoo-da3" -version = "0.8.1" +name = "snapform" +version = "0.9.27" source = { virtual = "." }