feat: Add MoGe-2 support and enhance depth plane functionality
- Introduced new MoGe-2 depth plane group with geometry and material displacement options. - Updated existing depth plane group to improve background depth handling and subdivision settings. - Enhanced tests to validate MoGe-2 models and ensure proper integration with existing systems. - Modified installation environment to include new dependencies for MoGe-2 and utils3d. - Updated vendor lock file to include new artifacts for MoGe and utils3d. - Refactored download and model handling to accommodate new MoGe-2 models. - Improved validation scripts for depth plane assets to ensure compliance with new specifications.
This commit is contained in:
@@ -19,7 +19,7 @@ SnapForm 是一个 Blender 扩展,可以把一张照片、一段视频或一
|
||||
最快的 CUDA/MPS,否则 CPU。下载超时会自动重试国内镜像,无需手动配置
|
||||
5. 打开 3D 视图右侧的 `SnapForm` 面板,点击 `Install Environment`
|
||||
|
||||
DA3、BEN2 源码包和 uv 已内置在扩展里,安装时直接从本地解包,不需要访问 GitHub。首次使用仍会自动
|
||||
DA3、MoGe-2、utils3d、BEN2 源码包和 uv 已内置在扩展里,安装时直接从本地解包,不需要访问 GitHub。首次使用仍会自动
|
||||
下载运行环境依赖(PyTorch 等),随后自动下载 `DA3 Small` 和 `BEN2 Base`。其他 AI 模型可在
|
||||
`Models` panel 中按需下载,请保持网络畅通,首次安装需要一些时间。
|
||||
|
||||
@@ -30,7 +30,7 @@ DA3、BEN2 源码包和 uv 已内置在扩展里,安装时直接从本地解
|
||||
1. 在 3D 视图中选中一个 Image Empty(空物体,显示方式为 Image)
|
||||
2. 右键打开 `SnapForm` 子菜单,选择 `Convert to Depth Plane`
|
||||
3. 在对话框中选择:
|
||||
- `Depth from`:`Original` 直接用原图,`AI Generated` 让 AI 估算深度
|
||||
- `Depth from`:`Original` 直接用原图,`DA3` 使用现有 DA3 流程,`MoGe-2` 生成 metric depth、normal 与有效区域 mask
|
||||
- `Resolution Scale` 使用 `1–100%`,按原图长边计算 Process Res;结果会限制在 `252–1008` 并自动对齐到 14 的倍数
|
||||
- `Remove Background` 默认关闭;开启后在转换期间使用 BEN2 删除背景
|
||||
- `Unshaded` 保留为直接显示贴图颜色的选项
|
||||
|
||||
@@ -6,24 +6,24 @@
|
||||
`SnapForm` 是一套从图片快速构建空间结构的 Blender Extension。目前以
|
||||
[Depth Anything 3](https://github.com/ByteDance-Seed/Depth-Anything-3)
|
||||
作为首个 AI 能力,在独立 Environment 中运行 DA3,可将官方导出结果保存在独立 job 目录,
|
||||
也可把 Image Empty 转换成带几何与材质置换的立体面片。Debug 面板还可运行
|
||||
也可把 Image Empty 转换成带几何与材质置换的立体面片。MoGe-2 作为独立深度来源,保留其 metric depth、normal、mask 与 intrinsics 输出,不复用 DA3 Prediction 后处理。Debug 面板还可运行
|
||||
[BEN2](https://github.com/PramaLLC/BEN2) 背景剔除功能测试。
|
||||
|
||||
## 当前功能
|
||||
|
||||
- 使用 Blender Extension manifest,可通过 `Install from Disk` 安装
|
||||
- 在独立 Environment 中通过常驻 Service 运行 Torch、DA3 与 BEN2,避免污染 Blender Python 进程
|
||||
- 在独立 Environment 中通过常驻 Service 运行 Torch、DA3、MoGe-2 与 BEN2,避免污染 Blender Python 进程
|
||||
- Blender 使用内置 `urllib.request` 访问仅监听 `127.0.0.1` 的 Service;请求带随机 Token
|
||||
- 常驻 Service 复用 Torch、CUDA、BEN2 与最近使用的 DA3 模型,连续执行时不再重复加载
|
||||
- 多个 Blender 实例自动连接同一个共享 Service;`AI Service` 面板显示状态、端口、客户端与模型缓存,并提供安装、启动、清理、重启和停止控制
|
||||
- Environment、模型和任务结果默认保存在 `~/.snapform`,可由多个 Blender 版本共享
|
||||
- 支持 DA3 Small、Base、Large 1.1、Giant 1.1 和 Nested Giant Large 1.1
|
||||
- 当前启用 DA3 Small 和 Base;Large 1.1、Giant 1.1 与 Nested Giant Large 1.1 暂停使用
|
||||
- 支持 BEN2 Base 单图背景剔除测试,输出透明 PNG 与推理耗时
|
||||
- 支持单张 JPG、JPEG、PNG、WEBP 图片或常见视频文件输入
|
||||
- 视频按顺序截取前若干帧,可设置参与重建的最大帧数
|
||||
- `Models` panel 默认折叠,展开后可逐项查看和下载所有可用模型
|
||||
- Environment 已安装时不额外占用面板空间;模型列表每个模型只占一行
|
||||
- `Debug` 默认折叠,内部的 `DA3 Output Test` 与 `BEN2 Removal Test` 也分别默认折叠
|
||||
- `Debug` 默认折叠,内部的 `DA3 Output Test`、`MoGe-2 API Test` 与 `BEN2 Removal Test` 也分别默认折叠
|
||||
- 使用一个输出类型下拉菜单和一个 `Generate Output` 按钮
|
||||
- 只显示当前格式的专属参数;GLB 与 PLY 设置不会同时出现
|
||||
- Debug 的模型选项按格式过滤;PLY 只提供两个带 Gaussian head 的 Giant 模型
|
||||
@@ -103,38 +103,41 @@ Cyclic 沿用原 Empty 的设置。序列帧无法被 Blender 整体 Pack,因
|
||||
|
||||
1. 在 3D View 选中 `Object Type = Empty`、`Display As = Image` 的对象。
|
||||
2. 右键打开 `SnapForm` 子菜单并选择 `Convert to Depth Plane`,通过 `Depth from` 选择深度来源:
|
||||
- `AI Generated`:显示 Model、Use Ray Pose 和 Resolution Scale。Process Res 先以原图长边乘以
|
||||
- `DA3`:显示 Model、Use Ray Pose 和 Resolution Scale。Process Res 先以原图长边乘以
|
||||
Resolution Scale,再钳制到 `252–1008`,最后对齐到最接近的 `14` 倍数。Resolution Scale 以百分比
|
||||
显示,范围为 `1–100%`,默认 `100%`;界面只在标签中实时显示最终 Process Res。
|
||||
转换导出 `mini_npz`,使用前需要在
|
||||
`Models` panel 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略
|
||||
继续使用场景设置。Model 下拉菜单只列出已经下载的 DA3 模型;若没有可用模型,对话框会引导到
|
||||
`Models` panel 下载。
|
||||
- `MoGe-2`:使用 Base Normal 模型输出 metric depth、normal、validity mask 与 normalized intrinsics。正式转换的 `results.npz` 不保存体积较大的 points;Debug 测试会保留 points,并另外写出 depth、normal、mask 预览。
|
||||
- `Original`:直接把原图作为深度贴图,不显示估算参数。仅在开启 `Remove Background` 时运行 BEN2
|
||||
取得前景 Alpha;视频或序列帧按需同时抽取原始帧与前景序列,把原始帧作为 `.depth` 序列。
|
||||
`Original` 是默认深度来源。选择 `AI Generated` 时,其 Model、Use Ray Pose 与 Resolution Scale
|
||||
`Original` 是默认深度来源。选择 `DA3` 时,其 Model、Use Ray Pose 与 Resolution Scale
|
||||
参数会集中显示在独立边框中。`Remove Background` 与 `Unshaded` 直接显示,不再使用 Options 折叠;
|
||||
`Keep Original` 默认关闭,并在分割线下方保持为最后一项。仅当开启 `Remove Background` 时才需要
|
||||
`BEN2 Base`,并把 `.foreground` 的 Alpha 作为独立
|
||||
Mask,不在 Service 中改写深度值。保存 `.foreground` 时保留 BEN2 Alpha,但 RGB 使用未经修改的原始输入;
|
||||
因此材质直接用 `.foreground` 同时提供颜色和 Mask,不再额外加载原图 Image Texture。
|
||||
Image Empty 引用视频或序列帧时,AI Generated 会按 Empty 的 Image Frames 数值逐帧估计
|
||||
Image Empty 引用视频或序列帧时,DA3 与 MoGe-2 会按 Empty 的 Image Frames 数值逐帧估计
|
||||
深度(未设置时为完整帧数,显式设为 1 时只处理 1 帧),起始帧跟随 Empty 的 Offset,与背景蒙版范围一致;
|
||||
Offset 超出视频长度时同样把窗口收进视频内。材质节点与置换贴图的 Start 保持与原 Empty
|
||||
一致;Original 模式的视频或序列帧使用 `frame.0001.jpg` 等原图序列作为颜色与深度,
|
||||
`foreground.0001.png` 只提供前景 Alpha。
|
||||
AI Generated 的深度仍以 EXR 序列帧(`depth.0001.exr` 起)保存在 job 目录根下。
|
||||
DA3 的深度仍以 EXR 序列帧(`depth.0001.exr` 起)保存在 job 目录根下;MoGe-2 分别使用 `moge_depth`、`moge_mask` 与 `moge_normal` EXR 序列。
|
||||
`Unshaded`、`Remove Background` 与 `Keep Original` 均默认关闭,因此默认删除原对象。
|
||||
3. AI Generated 完成后会自动创建面片。面片几何先由 `mini_npz` 的相机内参与深度确定,再通过对象
|
||||
3. DA3 或 MoGe-2 完成后会自动创建面片。DA3 面片几何先由 `mini_npz` 的相机内参与深度确定,再通过对象
|
||||
Transform 匹配原 Image Empty 的可见尺寸、位置、旋转和 Offset。Original 模式直接使用源 Empty
|
||||
的可见尺寸、Offset 与 Transform。AI Generated 会把用于匹配画面尺寸的缩放预乘到 `.depth` 数值,
|
||||
的可见尺寸、Offset 与 Transform。DA3 会把用于匹配画面尺寸的缩放预乘到 `.depth` 数值,
|
||||
XY 缩放烘焙到 Mesh,Z 不参与平面匹配;最终对象 Scale 为 `(1, 1, 1)`,视觉深度与旧逻辑一致。
|
||||
关闭 `Keep Original` 时,成功生成后删除源 Empty。生成面片 Object 使用源 Empty 的原名;开启
|
||||
`Keep Original` 时不处理
|
||||
同名冲突,由 Blender 自动为生成面片追加编号。
|
||||
4. Blender 只创建保留图像纵横比的最低密度基础网格,再把 `O DA3 Depth Plane` 作为 Geometry Nodes
|
||||
修改器添加到对象。节点组与 `O DA3 Surface` 一同保存在 `O_SnapForm.blend`,关闭
|
||||
`show_group_selector`,不为每个对象复制节点组。修改器公开以下参数:
|
||||
`show_group_selector`,不为每个对象复制节点组。MoGe-2 使用单独的 `O MoGe2 Depth Plane`,只保留
|
||||
网格细分、metric depth 位移、mask 删点和材质设置;材质使用 `O MoGe2 Depth Plane Layer`,直接接入
|
||||
模型 normal,不携带 DA3 的材质置换与背景深度分支。DA3 修改器公开以下参数:
|
||||
- `Displacement`:位于 Geometry 之后的第一项,菜单包含 `Geometry`(默认)与 `Material`。`Geometry`
|
||||
在节点组内沿法线修改网格,`Material` 保持网格平面并启用材质置换;
|
||||
- `Subdivision`:位于第二项,默认 `5`。节点读取基础平面的 Bounding Box,短边分段数为 `2^Subdivision`,
|
||||
@@ -259,7 +262,7 @@ Height = sqrt(Area / Aspect)
|
||||
参考深度和尺寸。
|
||||
|
||||
材质始终导入深度 Image Texture,并连接到 Displacement 节点和 Material Output;材质 Surface
|
||||
Displacement 设置为 `Displacement Only`,AI Generated 与 Original 都使用 `Midlevel = 0`、`Scale = 1`。
|
||||
Displacement 设置为 `Displacement Only`,DA3 与 Original 都使用 `Midlevel = 0`、`Scale = 1`。MoGe-2 的位移只在专用 Geometry Nodes 中处理。
|
||||
`o_material_displacement` 为 `False` 时,材质把 Height 乘以零,置换只在几何节点中生效;为 `True` 时,
|
||||
几何节点关闭结构置换,材质 Height 恢复。色彩贴图的 Alpha 二值结果同时用于选择原始深度或有符号的
|
||||
`Background Depth`,使两种置换路径保持一致;未移除背景的普通不透明图片因此全部视为前景。
|
||||
@@ -408,10 +411,10 @@ Python 3.12 与全部依赖。`uv` 会缓存在扩展存储目录下,后续安
|
||||
`SNAPFORM_TORCH_INDEX`(PyTorch 索引)、`HF_ENDPOINT`(Hugging Face 镜像)、
|
||||
`HTTP(S)_PROXY`/`ALL_PROXY`(代理)。未指定 PyPI 镜像时,
|
||||
pip 安装失败会自动依次重试镜像(清华、阿里云);模型下载不预探测,官方 Hugging Face
|
||||
失败后自动回退 ModelScope(`modelscope.cn`,仓库名与 Hugging Face 一致)。
|
||||
失败后自动回退 OmooLab R2(`models.omoolab.xyz`),并校验文件大小和 SHA-256。
|
||||
GitHub 不再参与安装:DA3、BEN2、uv 已内置,缺少内置文件会直接报错提示重新安装,不再下载兜底。
|
||||
单独下载模型时遵循同样的顺序:Hugging Face(可用 `HF_ENDPOINT` 指定其他镜像)失败后自动切换到
|
||||
ModelScope。
|
||||
单独下载模型时遵循同样的顺序:Hugging Face(可用 `HF_ENDPOINT` 指定其他入口)失败后自动切换到
|
||||
OmooLab R2,不再使用 ModelScope。
|
||||
Windows 上代理若只在系统设置里而未写入环境变量,安装程序会尝试自动从系统代理注入子进程。
|
||||
|
||||
- PyTorch 2.9.1
|
||||
|
||||
22
patches/moge/pyproject.toml
Normal file
22
patches/moge/pyproject.toml
Normal file
@@ -0,0 +1,22 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "moge"
|
||||
version = "2.0.0"
|
||||
description = "MoGe monocular geometry estimation"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
dependencies = [
|
||||
"huggingface_hub",
|
||||
"numpy",
|
||||
"pillow",
|
||||
"torch>=2.0.0",
|
||||
"utils3d",
|
||||
]
|
||||
requires-python = ">=3.9"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["moge*"]
|
||||
16
patches/utils3d/pyproject.toml
Normal file
16
patches/utils3d/pyproject.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "utils3d"
|
||||
version = "1.7"
|
||||
description = "Utilities for 3D computer vision"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
dependencies = ["numpy", "scipy"]
|
||||
requires-python = ">=3.8"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["utils3d*"]
|
||||
@@ -5,6 +5,7 @@ from .image_depth import (
|
||||
ApplyRemovedBackground,
|
||||
BuildDepthPlane,
|
||||
BuildOriginalDepthPlane,
|
||||
BuildMoge2DepthPlane,
|
||||
ConvertImageToDepthPlane,
|
||||
RemoveImageBackground,
|
||||
SnapFormImageMenu,
|
||||
@@ -24,6 +25,7 @@ from .panel import (
|
||||
DebugPanel,
|
||||
AiServicePanel,
|
||||
ModelsPanel,
|
||||
Moge2ApiDebugPanel,
|
||||
)
|
||||
from .preferences import SnapFormPreferences
|
||||
from .properties import SnapFormSettings
|
||||
@@ -41,6 +43,7 @@ CLASSES = (
|
||||
RemoveImageBackground,
|
||||
BuildDepthPlane,
|
||||
BuildOriginalDepthPlane,
|
||||
BuildMoge2DepthPlane,
|
||||
ConvertImageToDepthPlane,
|
||||
BuildImageMesh,
|
||||
ConvertImageToMesh,
|
||||
@@ -48,6 +51,7 @@ CLASSES = (
|
||||
ModelsPanel,
|
||||
DebugPanel,
|
||||
Da3OutputDebugPanel,
|
||||
Moge2ApiDebugPanel,
|
||||
Ben2RemovalDebugPanel,
|
||||
)
|
||||
|
||||
|
||||
Binary file not shown.
@@ -29,6 +29,7 @@ from .models import (
|
||||
DA3_COMMIT = "3d835ec1a5802d64a8b8b15f817a1ab54809bfe4"
|
||||
BEN2_COMMIT = "2c99a5da477b5523585bfa5c893888a6e818a8f6"
|
||||
MANIFEST_FILENAME = "manifest.json"
|
||||
MANIFEST_REVISION = 5
|
||||
SERVICE_ADDRESS = "127.0.0.1"
|
||||
DEFAULT_STORAGE_ROOT = Path.home() / ".snapform"
|
||||
JOB_FOLDER_NAMES = {
|
||||
@@ -36,6 +37,8 @@ JOB_FOLDER_NAMES = {
|
||||
"DOWNLOAD_MODEL": "download-model",
|
||||
"RECONSTRUCT": "export-reconstruction",
|
||||
"IMAGE_DEPTH": "generate-depth-plane",
|
||||
"MOGE2_DEPTH": "generate-moge2-depth-plane",
|
||||
"MOGE2_DEBUG": "test-moge2-api",
|
||||
"IMAGE_MESH": "generate-image-mesh",
|
||||
"REMOVE_BACKGROUND": "remove-background",
|
||||
"IMAGE_BACKGROUND": "remove-image-background",
|
||||
@@ -109,7 +112,10 @@ def env_installed():
|
||||
manifest = json.loads(ready_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return False
|
||||
return isinstance(manifest, dict)
|
||||
return (
|
||||
isinstance(manifest, dict)
|
||||
and manifest.get("manifest_revision") == MANIFEST_REVISION
|
||||
)
|
||||
|
||||
|
||||
def models_directory():
|
||||
@@ -121,6 +127,13 @@ def models_directory():
|
||||
def model_directory(model_key):
|
||||
root = models_directory()
|
||||
directory = root / model_directory_name(model_key)
|
||||
old_moge_directory = root / "moge2_base_normal"
|
||||
if (
|
||||
model_key == "MOGE2_VITB_NORMAL"
|
||||
and not directory.is_dir()
|
||||
and old_moge_directory.is_dir()
|
||||
):
|
||||
old_moge_directory.rename(directory)
|
||||
legacy_directory = root / model_key.lower()
|
||||
if (
|
||||
not directory.is_dir()
|
||||
@@ -132,9 +145,11 @@ def model_directory(model_key):
|
||||
|
||||
|
||||
def model_ready(model_key):
|
||||
model = get_downloadable_model(model_key)
|
||||
path = model_directory(model_key)
|
||||
return (path / "config.json").is_file() and any(
|
||||
path.glob("*.safetensors")
|
||||
return all(
|
||||
any(path.glob(pattern))
|
||||
for pattern in model.ready_patterns
|
||||
)
|
||||
|
||||
|
||||
@@ -743,6 +758,14 @@ def download_model_command(model_key, status_path):
|
||||
]
|
||||
for pattern in model.download_patterns:
|
||||
command.extend(["--allow-pattern", pattern])
|
||||
for pattern in model.ready_patterns:
|
||||
command.extend(["--required-pattern", pattern])
|
||||
if model.r2_directory:
|
||||
command.extend(["--r2-directory", model.r2_directory])
|
||||
for filename, size, checksum in model.r2_files:
|
||||
command.extend(
|
||||
["--r2-file", f"{filename}|{size}|{checksum}"]
|
||||
)
|
||||
return command
|
||||
|
||||
|
||||
@@ -875,6 +898,55 @@ def reconstruct_request(
|
||||
)
|
||||
|
||||
|
||||
def moge_request(
|
||||
settings,
|
||||
status_path,
|
||||
result_directory,
|
||||
input_path=None,
|
||||
resolution_level=None,
|
||||
include_points=False,
|
||||
remove_background=False,
|
||||
start_frame=1,
|
||||
device=None,
|
||||
model_key=None,
|
||||
):
|
||||
from .models import BEN2_MODEL, MOGE2_MODEL
|
||||
|
||||
selected_model_key = model_key or MOGE2_MODEL.key
|
||||
|
||||
parameters = {
|
||||
"input": str(
|
||||
Path(input_path)
|
||||
if input_path is not None
|
||||
else Path(bpy.path.abspath(settings.input_path))
|
||||
),
|
||||
"model_dir": str(model_directory(selected_model_key)),
|
||||
"output_dir": str(result_directory),
|
||||
"status": str(status_path),
|
||||
"resolution_level": int(
|
||||
settings.moge_resolution_level
|
||||
if resolution_level is None
|
||||
else resolution_level
|
||||
),
|
||||
"include_points": bool(include_points),
|
||||
"video_frames": settings.video_frames,
|
||||
"start_frame": start_frame,
|
||||
"device": (device or settings.device).lower(),
|
||||
"remove_background_model_dir": (
|
||||
str(model_directory(BEN2_MODEL.key))
|
||||
if remove_background
|
||||
else ""
|
||||
),
|
||||
}
|
||||
return JobRequest(
|
||||
{
|
||||
"job_id": job_id_from_status(status_path),
|
||||
"command": "moge",
|
||||
"parameters": parameters,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def creation_flags():
|
||||
if os.name != "nt":
|
||||
return 0
|
||||
|
||||
@@ -13,12 +13,14 @@ ORIGINAL_DISPLACEMENT_STRENGTH = 1.0
|
||||
ORIGINAL_DISPLACEMENT_MIDLEVEL = 0.0
|
||||
BUMP_BASE_DISTANCE = 0.001
|
||||
DEPTH_PLANE_NODE_GROUP_NAME = "O DA3 Depth Plane"
|
||||
MOGE_DEPTH_PLANE_NODE_GROUP_NAME = "O MoGe2 Depth Plane"
|
||||
NODE_ASSET_NAME = "O_SnapForm.blend"
|
||||
MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement"
|
||||
BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth"
|
||||
DEPTH_SCALE_ATTRIBUTE = "o_depth_scale"
|
||||
SURFACE_MATERIAL_NODE_GROUP_NAME = "O DA3 Surface Layer"
|
||||
DEPTH_PLANE_MATERIAL_NODE_GROUP_NAME = "O DA3 Depth Plane Layer"
|
||||
MOGE_MATERIAL_NODE_GROUP_NAME = "O MoGe2 Depth Plane Layer"
|
||||
DA3_PATCH_SIZE = 14
|
||||
DA3_MIN_PROCESS_RES = 252
|
||||
DA3_MAX_PROCESS_RES = 1008
|
||||
@@ -34,6 +36,8 @@ IMAGE_NAME_SUFFIXES = (
|
||||
".bmp",
|
||||
".tga",
|
||||
)
|
||||
|
||||
|
||||
VIDEO_NAME_SUFFIXES = (
|
||||
".mp4",
|
||||
".mov",
|
||||
@@ -48,17 +52,12 @@ VIDEO_NAME_SUFFIXES = (
|
||||
|
||||
|
||||
def image_depth_model_items(owner, context):
|
||||
from .models import model_enum_items
|
||||
from .client import model_ready
|
||||
from .models import depth_plane_model_enum_items
|
||||
|
||||
return [
|
||||
item
|
||||
for item in model_enum_items(owner, context)
|
||||
if model_ready(item[0])
|
||||
]
|
||||
return depth_plane_model_enum_items(owner, context)
|
||||
|
||||
|
||||
_last_depth_plane_model = "BASE"
|
||||
_last_depth_plane_model = "MOGE2_VITB_NORMAL"
|
||||
|
||||
|
||||
def is_image_empty(candidate):
|
||||
@@ -800,6 +799,264 @@ def create_depth_image(name, depth_values):
|
||||
return image
|
||||
|
||||
|
||||
def create_moge_mask_image(name, mask_values):
|
||||
import numpy as np
|
||||
|
||||
height, width = mask_values.shape
|
||||
image = bpy.data.images.new(
|
||||
name,
|
||||
width=width,
|
||||
height=height,
|
||||
alpha=True,
|
||||
float_buffer=True,
|
||||
)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
values = np.flipud(mask_values.astype(np.float32))
|
||||
rgba = np.ones((height, width, 4), dtype=np.float32)
|
||||
rgba[..., :3] = values[..., None]
|
||||
rgba[..., 3] = values
|
||||
image.pixels.foreach_set(rgba.ravel())
|
||||
image.pack()
|
||||
return image
|
||||
|
||||
|
||||
def create_moge_normal_image(name, normal_values):
|
||||
import numpy as np
|
||||
|
||||
converted = normal_values.astype(np.float32, copy=True)
|
||||
converted *= np.array([1.0, -1.0, -1.0], dtype=np.float32)
|
||||
converted = np.clip(converted * 0.5 + 0.5, 0.0, 1.0)
|
||||
height, width = converted.shape[:2]
|
||||
image = bpy.data.images.new(
|
||||
name,
|
||||
width=width,
|
||||
height=height,
|
||||
alpha=False,
|
||||
float_buffer=True,
|
||||
)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
rgba = np.ones((height, width, 4), dtype=np.float32)
|
||||
rgba[..., :3] = np.flipud(converted)
|
||||
image.pixels.foreach_set(rgba.ravel())
|
||||
image.pack()
|
||||
return image
|
||||
|
||||
|
||||
def create_moge_sequence_image(name, frames, scene, directory, prefix, kind):
|
||||
import numpy as np
|
||||
|
||||
directory = Path(directory)
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
height, width = frames[0].shape[:2]
|
||||
image = bpy.data.images.new(
|
||||
name,
|
||||
width=width,
|
||||
height=height,
|
||||
alpha=True,
|
||||
float_buffer=True,
|
||||
)
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
image_settings = scene.render.image_settings
|
||||
original_format = image_settings.file_format
|
||||
paths = []
|
||||
try:
|
||||
image_settings.file_format = "OPEN_EXR"
|
||||
for index, values in enumerate(frames):
|
||||
rgba = np.ones((height, width, 4), dtype=np.float32)
|
||||
if kind == "depth":
|
||||
flipped = np.flipud(values.astype(np.float32))
|
||||
rgba[..., :3] = flipped[..., None]
|
||||
elif kind == "mask":
|
||||
flipped = np.flipud(values.astype(np.float32))
|
||||
rgba[..., :3] = flipped[..., None]
|
||||
rgba[..., 3] = flipped
|
||||
else:
|
||||
converted = values.astype(np.float32, copy=True)
|
||||
converted *= np.array([1.0, -1.0, -1.0], dtype=np.float32)
|
||||
rgba[..., :3] = np.flipud(
|
||||
np.clip(converted * 0.5 + 0.5, 0.0, 1.0)
|
||||
)
|
||||
image.pixels.foreach_set(rgba.ravel())
|
||||
path = directory / f"{prefix}.{index + 1:04d}.exr"
|
||||
image.save_render(str(path), scene=scene)
|
||||
paths.append(path)
|
||||
finally:
|
||||
image_settings.file_format = original_format
|
||||
bpy.data.images.remove(image, do_unlink=True)
|
||||
sequence = bpy.data.images.load(str(paths[0]), check_existing=False)
|
||||
force_sequence_source(sequence)
|
||||
sequence.name = name
|
||||
sequence.colorspace_settings.name = "Non-Color"
|
||||
pack_sequence_if_possible(sequence, len(paths))
|
||||
return sequence
|
||||
|
||||
|
||||
def moge_material_node_group():
|
||||
existing = bpy.data.node_groups.get(MOGE_MATERIAL_NODE_GROUP_NAME)
|
||||
if existing is not None:
|
||||
return existing
|
||||
group = bpy.data.node_groups.new(
|
||||
MOGE_MATERIAL_NODE_GROUP_NAME,
|
||||
"ShaderNodeTree",
|
||||
)
|
||||
for name, socket_type in (
|
||||
("Color", "NodeSocketColor"),
|
||||
("Alpha", "NodeSocketFloat"),
|
||||
("Depth", "NodeSocketFloat"),
|
||||
("Bump Scale", "NodeSocketFloat"),
|
||||
):
|
||||
socket = group.interface.new_socket(
|
||||
name=name,
|
||||
in_out="INPUT",
|
||||
socket_type=socket_type,
|
||||
)
|
||||
if name == "Alpha":
|
||||
socket.default_value = 1.0
|
||||
if name == "Bump Scale":
|
||||
socket.default_value = 1.0
|
||||
for name, socket_type in (
|
||||
("Color", "NodeSocketColor"),
|
||||
("Roughness", "NodeSocketFloat"),
|
||||
("Alpha", "NodeSocketFloat"),
|
||||
("Normal", "NodeSocketVector"),
|
||||
("Displacement", "NodeSocketVector"),
|
||||
):
|
||||
group.interface.new_socket(
|
||||
name=name,
|
||||
in_out="OUTPUT",
|
||||
socket_type=socket_type,
|
||||
)
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
group_input = nodes.new("NodeGroupInput")
|
||||
group_input.location = (-900.0, 100.0)
|
||||
bump = nodes.new("ShaderNodeBump")
|
||||
bump.location = (250.0, -100.0)
|
||||
bump_distance = nodes.new("ShaderNodeMath")
|
||||
bump_distance.operation = "MULTIPLY"
|
||||
bump_distance.inputs[1].default_value = BUMP_BASE_DISTANCE
|
||||
bump_distance.location = (0.0, -100.0)
|
||||
background = nodes.new("ShaderNodeAttribute")
|
||||
background.attribute_type = "GEOMETRY"
|
||||
background.attribute_name = BACKGROUND_DEPTH_ATTRIBUTE
|
||||
background.location = (-650.0, -400.0)
|
||||
material_mode = nodes.new("ShaderNodeAttribute")
|
||||
material_mode.attribute_type = "GEOMETRY"
|
||||
material_mode.attribute_name = MATERIAL_DISPLACEMENT_ATTRIBUTE
|
||||
material_mode.location = (-650.0, -600.0)
|
||||
strength = nodes.new("ShaderNodeAttribute")
|
||||
strength.attribute_type = "GEOMETRY"
|
||||
strength.attribute_name = DEPTH_SCALE_ATTRIBUTE
|
||||
strength.location = (-400.0, -600.0)
|
||||
clamp_depth = nodes.new("ShaderNodeMath")
|
||||
clamp_depth.operation = "MAXIMUM"
|
||||
clamp_depth.location = (-350.0, -350.0)
|
||||
enable_depth = nodes.new("ShaderNodeMath")
|
||||
enable_depth.operation = "MULTIPLY"
|
||||
enable_depth.location = (-100.0, -350.0)
|
||||
scale_depth = nodes.new("ShaderNodeMath")
|
||||
scale_depth.operation = "MULTIPLY"
|
||||
scale_depth.location = (150.0, -350.0)
|
||||
displacement = nodes.new("ShaderNodeDisplacement")
|
||||
displacement.inputs["Midlevel"].default_value = 0.0
|
||||
displacement.inputs["Scale"].default_value = 1.0
|
||||
displacement.location = (400.0, -350.0)
|
||||
group_output = nodes.new("NodeGroupOutput")
|
||||
group_output.location = (300.0, 100.0)
|
||||
links.new(group_input.outputs["Color"], group_output.inputs["Color"])
|
||||
links.new(group_input.outputs["Alpha"], group_output.inputs["Alpha"])
|
||||
links.new(group_input.outputs["Color"], bump.inputs["Height"])
|
||||
links.new(group_input.outputs["Bump Scale"], bump_distance.inputs[0])
|
||||
links.new(bump_distance.outputs[0], bump.inputs["Distance"])
|
||||
links.new(bump.outputs["Normal"], group_output.inputs["Normal"])
|
||||
links.new(group_input.outputs["Depth"], clamp_depth.inputs[0])
|
||||
links.new(background.outputs["Fac"], clamp_depth.inputs[1])
|
||||
links.new(clamp_depth.outputs[0], enable_depth.inputs[0])
|
||||
links.new(material_mode.outputs["Fac"], enable_depth.inputs[1])
|
||||
links.new(enable_depth.outputs[0], scale_depth.inputs[0])
|
||||
links.new(strength.outputs["Fac"], scale_depth.inputs[1])
|
||||
links.new(scale_depth.outputs[0], displacement.inputs["Height"])
|
||||
links.new(displacement.outputs["Displacement"], group_output.inputs["Displacement"])
|
||||
group_output.inputs["Roughness"].default_value = 0.5
|
||||
group.color_tag = "SHADER"
|
||||
group.description = "Apply MoGe-2 color and derive bump detail from it."
|
||||
group.use_fake_user = True
|
||||
return group
|
||||
|
||||
|
||||
def create_moge_material(
|
||||
name,
|
||||
color_image,
|
||||
depth_image,
|
||||
depth_scale,
|
||||
unshaded=False,
|
||||
frame_count=1,
|
||||
frame_start=1,
|
||||
use_cyclic=False,
|
||||
):
|
||||
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()
|
||||
links = material.node_tree.links
|
||||
uv = nodes.new("ShaderNodeUVMap")
|
||||
uv.uv_map = "UVMap"
|
||||
uv.location = (-900.0, 200.0)
|
||||
color = nodes.new("ShaderNodeTexImage")
|
||||
color.image = color_image
|
||||
color.location = (-650.0, 300.0)
|
||||
depth = nodes.new("ShaderNodeTexImage")
|
||||
depth.image = depth_image
|
||||
depth.interpolation = "Linear"
|
||||
depth.location = (-650.0, -100.0)
|
||||
if frame_count > 1:
|
||||
configure_image_user(
|
||||
color.image_user,
|
||||
frame_count,
|
||||
frame_start=frame_start,
|
||||
use_cyclic=use_cyclic,
|
||||
)
|
||||
configure_image_user(
|
||||
depth.image_user,
|
||||
frame_count,
|
||||
frame_start=frame_start,
|
||||
use_cyclic=use_cyclic,
|
||||
)
|
||||
layer = nodes.new("ShaderNodeGroup")
|
||||
layer.node_tree = moge_material_node_group()
|
||||
layer.name = MOGE_MATERIAL_NODE_GROUP_NAME
|
||||
layer.location = (-300.0, 200.0)
|
||||
output = nodes.new("ShaderNodeOutputMaterial")
|
||||
output.location = (500.0, 200.0)
|
||||
links.new(uv.outputs["UV"], color.inputs["Vector"])
|
||||
links.new(uv.outputs["UV"], depth.inputs["Vector"])
|
||||
links.new(color.outputs["Color"], layer.inputs["Color"])
|
||||
links.new(color.outputs["Alpha"], layer.inputs["Alpha"])
|
||||
links.new(depth.outputs["Color"], layer.inputs["Depth"])
|
||||
layer.inputs["Bump Scale"].default_value = depth_scale
|
||||
if unshaded:
|
||||
shader = nodes.new("ShaderNodeEmission")
|
||||
transparent = nodes.new("ShaderNodeBsdfTransparent")
|
||||
mix = nodes.new("ShaderNodeMixShader")
|
||||
links.new(layer.outputs["Color"], shader.inputs["Color"])
|
||||
links.new(layer.outputs["Alpha"], mix.inputs["Fac"])
|
||||
links.new(transparent.outputs["BSDF"], mix.inputs[1])
|
||||
links.new(shader.outputs["Emission"], mix.inputs[2])
|
||||
links.new(mix.outputs["Shader"], output.inputs["Surface"])
|
||||
return material
|
||||
shader = nodes.new("ShaderNodeBsdfPrincipled")
|
||||
links.new(layer.outputs["Color"], shader.inputs["Base Color"])
|
||||
links.new(layer.outputs["Alpha"], shader.inputs["Alpha"])
|
||||
links.new(layer.outputs["Roughness"], shader.inputs["Roughness"])
|
||||
links.new(layer.outputs["Normal"], shader.inputs["Normal"])
|
||||
links.new(shader.outputs["BSDF"], output.inputs["Surface"])
|
||||
links.new(layer.outputs["Displacement"], output.inputs["Displacement"])
|
||||
set_displacement_only(material)
|
||||
return material
|
||||
|
||||
|
||||
def grid_segments(bounds, subdivision_level):
|
||||
if subdivision_level < 0:
|
||||
raise ValueError("Subdivision must not be negative")
|
||||
@@ -1193,8 +1450,8 @@ def node_asset_path():
|
||||
return Path(__file__).with_name("assets") / NODE_ASSET_NAME
|
||||
|
||||
|
||||
def load_depth_plane_node_group():
|
||||
existing = bpy.data.node_groups.get(DEPTH_PLANE_NODE_GROUP_NAME)
|
||||
def load_depth_plane_node_group(group_name=DEPTH_PLANE_NODE_GROUP_NAME):
|
||||
existing = bpy.data.node_groups.get(group_name)
|
||||
if existing is not None:
|
||||
return existing
|
||||
asset_path = node_asset_path()
|
||||
@@ -1204,26 +1461,26 @@ def load_depth_plane_node_group():
|
||||
if not use_pack:
|
||||
directory = str(asset_path) + "/NodeTree/"
|
||||
result = bpy.ops.wm.append(
|
||||
filepath=directory + DEPTH_PLANE_NODE_GROUP_NAME,
|
||||
filepath=directory + group_name,
|
||||
directory=directory,
|
||||
filename=DEPTH_PLANE_NODE_GROUP_NAME,
|
||||
filename=group_name,
|
||||
link=False,
|
||||
do_reuse_local_id=True,
|
||||
)
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Unable to append the Geometry Nodes asset")
|
||||
node_group = bpy.data.node_groups.get(DEPTH_PLANE_NODE_GROUP_NAME)
|
||||
node_group = bpy.data.node_groups.get(group_name)
|
||||
if node_group is None:
|
||||
raise RuntimeError("Appended Geometry Nodes asset is unavailable")
|
||||
node_group.use_fake_user = True
|
||||
return node_group
|
||||
with bpy.data.libraries.load(str(asset_path), link=True) as (source, target):
|
||||
if DEPTH_PLANE_NODE_GROUP_NAME not in source.node_groups:
|
||||
if group_name not in source.node_groups:
|
||||
raise RuntimeError(
|
||||
"Geometry Nodes asset is missing "
|
||||
f"{DEPTH_PLANE_NODE_GROUP_NAME}"
|
||||
f"{group_name}"
|
||||
)
|
||||
target.node_groups = [DEPTH_PLANE_NODE_GROUP_NAME]
|
||||
target.node_groups = [group_name]
|
||||
linked_group = target.node_groups[0]
|
||||
node_group = bpy.data.pack_linked_ids_hierarchy(root_id=linked_group)
|
||||
if linked_group != node_group and linked_group.users == 0:
|
||||
@@ -1278,6 +1535,42 @@ def add_depth_plane_processing(
|
||||
return modifier
|
||||
|
||||
|
||||
def add_moge_depth_plane_processing(
|
||||
plane_object,
|
||||
depth_image,
|
||||
material,
|
||||
frame_start=1,
|
||||
frame_duration=1,
|
||||
use_cyclic=False,
|
||||
animate_frame=False,
|
||||
):
|
||||
node_group = load_depth_plane_node_group(
|
||||
MOGE_DEPTH_PLANE_NODE_GROUP_NAME
|
||||
)
|
||||
modifier = plane_object.modifiers.new(
|
||||
name=MOGE_DEPTH_PLANE_NODE_GROUP_NAME,
|
||||
type="NODES",
|
||||
)
|
||||
modifier.node_group = node_group
|
||||
modifier.show_group_selector = False
|
||||
for name, value in (
|
||||
("Depth Image", depth_image),
|
||||
("Material", material),
|
||||
("Frame", 1),
|
||||
("Frame Start", frame_start),
|
||||
("Frames", max(int(frame_duration or 1), 1)),
|
||||
("Cyclic", use_cyclic),
|
||||
):
|
||||
modifier[modifier_input_identifier(node_group, name)] = value
|
||||
if animate_frame:
|
||||
frame_identifier = modifier_input_identifier(node_group, "Frame")
|
||||
driver = modifier.driver_add(f'["{frame_identifier}"]')
|
||||
driver.driver.type = "SCRIPTED"
|
||||
driver.driver.expression = "frame"
|
||||
plane_object.update_tag(refresh={"DATA"})
|
||||
return modifier
|
||||
|
||||
|
||||
def finish_depth_plane(
|
||||
context,
|
||||
source_object,
|
||||
@@ -1573,6 +1866,152 @@ class BuildDepthPlane(bpy.types.Operator):
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
def build_moge2_depth_plane(
|
||||
context,
|
||||
source_object,
|
||||
npz_path,
|
||||
delete_source,
|
||||
unshaded=False,
|
||||
color_image=None,
|
||||
foreground_image=None,
|
||||
data_frames_directory=None,
|
||||
):
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
with np.load(npz_path, allow_pickle=False) as archive:
|
||||
depth = np.asarray(archive["depth"], dtype=np.float32)
|
||||
mask = np.asarray(archive["mask"], dtype=bool)
|
||||
intrinsics = np.asarray(archive["intrinsics"], dtype=np.float32)
|
||||
except (KeyError, OSError) as error:
|
||||
raise ValueError(f"Invalid MoGe-2 NPZ: {npz_path}") from error
|
||||
if depth.ndim == 2:
|
||||
depth = depth[None, ...]
|
||||
mask = mask[None, ...]
|
||||
if intrinsics.ndim == 2:
|
||||
intrinsics = intrinsics[None, ...]
|
||||
if not (len(depth) == len(mask) == len(intrinsics)):
|
||||
raise ValueError("MoGe-2 arrays have different frame counts")
|
||||
|
||||
first_valid = mask[0] & np.isfinite(depth[0]) & (depth[0] > 0.0)
|
||||
if not first_valid.any():
|
||||
raise ValueError("MoGe-2 mask contains no valid depth")
|
||||
reference_depth = float(np.median(depth[0][first_valid]))
|
||||
height, width = depth[0].shape
|
||||
pixel_intrinsics = intrinsics[0].copy()
|
||||
pixel_intrinsics[0] *= width
|
||||
pixel_intrinsics[1] *= height
|
||||
bounds, _ = camera_plane_bounds(
|
||||
source_object,
|
||||
depth[0],
|
||||
pixel_intrinsics,
|
||||
1.0,
|
||||
reference_depth=reference_depth,
|
||||
)
|
||||
displacement = [reference_depth - frame for frame in depth]
|
||||
source_frame_start, _, _, source_use_cyclic = image_user_settings(
|
||||
source_object
|
||||
)
|
||||
mesh = create_plane_mesh(source_object, bounds, None)
|
||||
plane_object = bpy.data.objects.new(source_object.name, mesh)
|
||||
context.collection.objects.link(plane_object)
|
||||
depth_scale = apply_plane_transform(plane_object, source_object, bounds)
|
||||
apply_object_scale_to_mesh(plane_object)
|
||||
depth_image = create_depth_image(
|
||||
f"{depth_data_name(source_object)}.moge2",
|
||||
displacement[0] * depth_scale,
|
||||
)
|
||||
if len(depth) > 1 and data_frames_directory is not None:
|
||||
bpy.data.images.remove(depth_image, do_unlink=True)
|
||||
depth_image = create_moge_sequence_image(
|
||||
f"{depth_data_name(source_object)}.moge2",
|
||||
[frame * depth_scale for frame in displacement],
|
||||
context.scene,
|
||||
data_frames_directory,
|
||||
"moge_depth",
|
||||
"depth",
|
||||
)
|
||||
material = create_moge_material(
|
||||
source_object.name,
|
||||
foreground_image or color_image or source_object.data,
|
||||
depth_image,
|
||||
depth_scale,
|
||||
unshaded=unshaded,
|
||||
frame_count=len(depth),
|
||||
frame_start=source_frame_start,
|
||||
use_cyclic=source_use_cyclic,
|
||||
)
|
||||
mesh.materials.append(material)
|
||||
modifier = add_moge_depth_plane_processing(
|
||||
plane_object,
|
||||
depth_image,
|
||||
material,
|
||||
frame_start=source_frame_start,
|
||||
frame_duration=len(depth),
|
||||
use_cyclic=source_use_cyclic,
|
||||
animate_frame=is_animated_image(source_object.data),
|
||||
)
|
||||
plane_object["snapform_reference_depth"] = reference_depth
|
||||
plane_object["snapform_depth_scale"] = depth_scale
|
||||
plane_object["snapform_frame_count"] = len(depth)
|
||||
return finish_depth_plane(
|
||||
context,
|
||||
source_object,
|
||||
plane_object,
|
||||
delete_source,
|
||||
)
|
||||
|
||||
|
||||
class BuildMoge2DepthPlane(bpy.types.Operator):
|
||||
bl_idname = "snapform.build_moge2_depth_plane"
|
||||
bl_label = "Build MoGe-2 Depth Plane"
|
||||
bl_options = {"INTERNAL"}
|
||||
|
||||
snapform_source_object_name: bpy.props.StringProperty(options={"HIDDEN"})
|
||||
snapform_npz_path: bpy.props.StringProperty(options={"HIDDEN"})
|
||||
snapform_background_path: bpy.props.StringProperty(options={"HIDDEN"})
|
||||
snapform_color_frames_dir: bpy.props.StringProperty(options={"HIDDEN"})
|
||||
snapform_data_frames_dir: bpy.props.StringProperty(options={"HIDDEN"})
|
||||
snapform_delete_source: bpy.props.BoolProperty(default=True, options={"HIDDEN"})
|
||||
snapform_unshaded: bpy.props.BoolProperty(default=False, options={"HIDDEN"})
|
||||
|
||||
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"}
|
||||
try:
|
||||
foreground_image = None
|
||||
if self.snapform_background_path:
|
||||
foreground_image = create_removed_background_image(
|
||||
source_object,
|
||||
self.snapform_background_path,
|
||||
assign_to_source=False,
|
||||
)
|
||||
color_image = None
|
||||
if self.snapform_color_frames_dir:
|
||||
color_image = create_sequence_image(
|
||||
f"{image_base_name(source_object.data)}.frame",
|
||||
self.snapform_color_frames_dir,
|
||||
)
|
||||
build_moge2_depth_plane(
|
||||
context,
|
||||
source_object,
|
||||
Path(self.snapform_npz_path),
|
||||
self.snapform_delete_source,
|
||||
unshaded=self.snapform_unshaded,
|
||||
color_image=color_image,
|
||||
foreground_image=foreground_image,
|
||||
data_frames_directory=(
|
||||
self.snapform_data_frames_dir or None
|
||||
),
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
return {"FINISHED"}
|
||||
|
||||
|
||||
class BuildOriginalDepthPlane(bpy.types.Operator):
|
||||
bl_idname = "snapform.build_original_depth_plane"
|
||||
bl_label = "Build Original Depth Plane"
|
||||
@@ -1746,19 +2185,6 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
||||
bl_description = "Convert an image to a displaced depth plane"
|
||||
bl_options = {"REGISTER"}
|
||||
|
||||
snapform_depth_source: bpy.props.EnumProperty(
|
||||
name="Depth from",
|
||||
description="Source used to displace the plane",
|
||||
items=(
|
||||
(
|
||||
"ESTIMATE",
|
||||
"AI Generated",
|
||||
"Generate depth with an AI model",
|
||||
),
|
||||
("ORIGINAL", "Original", "Use the original image as depth"),
|
||||
),
|
||||
default="ORIGINAL",
|
||||
)
|
||||
snapform_unshaded: bpy.props.BoolProperty(
|
||||
name="Unshaded",
|
||||
description="Display the source color through an Emission shader",
|
||||
@@ -1771,8 +2197,9 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
||||
)
|
||||
snapform_model: bpy.props.EnumProperty(
|
||||
name="Model",
|
||||
description="Model used to estimate image depth",
|
||||
description="Model or original image used to build depth",
|
||||
items=image_depth_model_items,
|
||||
default=1,
|
||||
)
|
||||
snapform_use_ray_pose: bpy.props.BoolProperty(
|
||||
name="Use Ray Pose",
|
||||
@@ -1780,6 +2207,13 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
||||
default=True,
|
||||
)
|
||||
snapform_process_res_factor: process_resolution_scale_property()
|
||||
snapform_moge_resolution_level: bpy.props.IntProperty(
|
||||
name="Resolution Level",
|
||||
description="MoGe-2 inference resolution level; higher is finer and slower",
|
||||
default=5,
|
||||
min=0,
|
||||
max=9,
|
||||
)
|
||||
snapform_keep_original: bpy.props.BoolProperty(
|
||||
name="Keep Original",
|
||||
description="Keep the source Image Empty after conversion",
|
||||
@@ -1789,14 +2223,12 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
||||
@classmethod
|
||||
def poll(cls, context):
|
||||
from .operators import active_job
|
||||
from .client import service_busy, model_ready
|
||||
from .models import MODELS
|
||||
from .client import service_busy
|
||||
|
||||
return (
|
||||
is_image_empty(context.object)
|
||||
and active_job() is None
|
||||
and not service_busy()
|
||||
and any(model_ready(model_key) for model_key in MODELS)
|
||||
)
|
||||
|
||||
def invoke(self, context, _event):
|
||||
@@ -1816,17 +2248,19 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
layout.prop(self, "snapform_depth_source")
|
||||
if self.snapform_depth_source == "ESTIMATE":
|
||||
layout.prop(self, "snapform_model")
|
||||
if self.snapform_model in {"SMALL", "BASE"}:
|
||||
estimate_box = layout.box()
|
||||
available_models = image_depth_model_items(self, context)
|
||||
if not available_models:
|
||||
estimate_box.label(text="No DA3 models installed", icon="ERROR")
|
||||
estimate_box.label(text="Download one in Models")
|
||||
else:
|
||||
estimate_box.prop(self, "snapform_model")
|
||||
estimate_box.prop(self, "snapform_use_ray_pose")
|
||||
draw_process_resolution(estimate_box, self, context.object.data)
|
||||
elif self.snapform_model.startswith("MOGE2_"):
|
||||
from .client import model_ready
|
||||
|
||||
moge_box = layout.box()
|
||||
if not model_ready(self.snapform_model):
|
||||
moge_box.label(text="MoGe-2 model is not installed", icon="ERROR")
|
||||
moge_box.label(text="Download it in Models")
|
||||
moge_box.prop(self, "snapform_moge_resolution_level")
|
||||
layout.prop(self, "snapform_remove_background")
|
||||
layout.prop(self, "snapform_unshaded")
|
||||
layout.separator(type="LINE")
|
||||
@@ -1835,6 +2269,7 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
||||
def execute(self, context):
|
||||
from .operators import (
|
||||
IMAGE_DEPTH_JOB,
|
||||
MOGE2_DEPTH_JOB,
|
||||
ORIGINAL_BACKGROUND_JOB,
|
||||
ORIGINAL_FRAMES_JOB,
|
||||
)
|
||||
@@ -1844,7 +2279,7 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
||||
context.scene.snapform_settings.video_frames = (
|
||||
default_extract_frames(source_object)
|
||||
)
|
||||
if self.snapform_depth_source == "ORIGINAL":
|
||||
if self.snapform_model == "ORIGINAL":
|
||||
try:
|
||||
source_path, delete_input = prepare_image_input(
|
||||
source_object.data,
|
||||
@@ -1871,6 +2306,34 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
||||
remove_temporary_input(source_path)
|
||||
return {"CANCELLED"}
|
||||
|
||||
if self.snapform_model.startswith("MOGE2_"):
|
||||
try:
|
||||
source_path, delete_input = prepare_image_input(
|
||||
source_object.data,
|
||||
context.scene,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
self.report({"ERROR"}, str(error))
|
||||
return {"CANCELLED"}
|
||||
result = bpy.ops.snapform.run_job(
|
||||
job_type=MOGE2_DEPTH_JOB,
|
||||
snapform_input_path=str(source_path),
|
||||
snapform_source_object_name=source_object.name,
|
||||
snapform_moge_resolution_level=(
|
||||
self.snapform_moge_resolution_level
|
||||
),
|
||||
snapform_model_key=self.snapform_model,
|
||||
snapform_delete_source=not self.snapform_keep_original,
|
||||
snapform_delete_input=delete_input,
|
||||
snapform_unshaded=self.snapform_unshaded,
|
||||
snapform_remove_background=self.snapform_remove_background,
|
||||
)
|
||||
if "CANCELLED" not in result:
|
||||
return {"FINISHED"}
|
||||
if delete_input:
|
||||
remove_temporary_input(source_path)
|
||||
return {"CANCELLED"}
|
||||
|
||||
image = source_object.data
|
||||
try:
|
||||
process_res = scaled_process_resolution(
|
||||
|
||||
@@ -20,7 +20,7 @@ SCRIPT_DIRECTORY = Path(__file__).resolve().parent
|
||||
if str(SCRIPT_DIRECTORY) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPT_DIRECTORY))
|
||||
|
||||
from models import BEN2_MODEL, MODELS
|
||||
from models import BEN2_MODEL, DOWNLOADABLE_MODELS, MOGE2_MODEL
|
||||
|
||||
|
||||
PYPI_MIRRORS = (
|
||||
@@ -34,12 +34,13 @@ XFORMERS_VERSION = "0.0.33.post2"
|
||||
ADDICT_VERSION = "2.4.0"
|
||||
FASTAPI_VERSION = "0.139.2"
|
||||
UVICORN_VERSION = "0.51.0"
|
||||
MANIFEST_REVISION = 5
|
||||
TORCH_INDEXES = {
|
||||
"cu128": "https://download.pytorch.org/whl/cu128",
|
||||
"cpu": "https://download.pytorch.org/whl/cpu",
|
||||
}
|
||||
_resolved_pypi_index = None
|
||||
DEFAULT_MODEL_KEYS = ("SMALL", BEN2_MODEL.key)
|
||||
DEFAULT_MODEL_KEYS = (MOGE2_MODEL.key, BEN2_MODEL.key)
|
||||
RUNTIME_STAGE_COUNT = 3
|
||||
ENVIRONMENT_SLICE = (0.02, 0.06)
|
||||
PYTORCH_SLICE = (0.06, 0.48)
|
||||
@@ -61,6 +62,7 @@ CHECK_CODE = (
|
||||
"import json; import torch; "
|
||||
"from depth_anything_3.api import DepthAnything3; "
|
||||
"from ben2 import BEN_Base; "
|
||||
"from moge.model.v2 import MoGeModel; "
|
||||
"import fastapi, uvicorn; "
|
||||
"mps = getattr(torch.backends, 'mps', None); "
|
||||
"print('SNAPFORM_DEVICES=' + json.dumps({"
|
||||
@@ -70,6 +72,14 @@ CHECK_CODE = (
|
||||
)
|
||||
|
||||
|
||||
def configure_standard_streams():
|
||||
"""Windows 图形进程可能继承 GBK stdout,安装日志统一写为 UTF-8。"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
reconfigure = getattr(stream, "reconfigure", None)
|
||||
if callable(reconfigure):
|
||||
reconfigure(encoding="utf-8", errors="backslashreplace")
|
||||
|
||||
|
||||
def write_status(
|
||||
path,
|
||||
progress,
|
||||
@@ -98,6 +108,8 @@ def write_status(
|
||||
def child_environment(environment=None):
|
||||
"""Inherit the current environment and inject system/registry proxy settings into child processes (uv only reads proxy env variables)."""
|
||||
process_environment = os.environ.copy()
|
||||
process_environment["PYTHONIOENCODING"] = "utf-8:backslashreplace"
|
||||
process_environment["PYTHONUTF8"] = "1"
|
||||
for key, value in urllib.request.getproxies().items():
|
||||
env_key = f"{key.upper()}_PROXY"
|
||||
if env_key not in process_environment:
|
||||
@@ -334,6 +346,14 @@ def install_stages(args, venv_dir, python, uv, vendor_dir=None):
|
||||
"ben2",
|
||||
"ben2.tar.gz",
|
||||
)
|
||||
utils3d_requirement = archive_requirement(
|
||||
"utils3d",
|
||||
"utils3d.tar.gz",
|
||||
)
|
||||
moge_requirement = archive_requirement(
|
||||
"moge",
|
||||
"moge.tar.gz",
|
||||
)
|
||||
torch_command = [
|
||||
uv,
|
||||
"pip",
|
||||
@@ -371,6 +391,8 @@ def install_stages(args, venv_dir, python, uv, vendor_dir=None):
|
||||
da3_requirement,
|
||||
f"addict=={ADDICT_VERSION}",
|
||||
ben2_requirement,
|
||||
utils3d_requirement,
|
||||
moge_requirement,
|
||||
f"fastapi=={FASTAPI_VERSION}",
|
||||
f"uvicorn=={UVICORN_VERSION}",
|
||||
"setuptools",
|
||||
@@ -398,7 +420,7 @@ def install_stages(args, venv_dir, python, uv, vendor_dir=None):
|
||||
2,
|
||||
("AI components", xformers_command, None, "install"),
|
||||
)
|
||||
models = {**MODELS, BEN2_MODEL.key: BEN2_MODEL}
|
||||
models = DOWNLOADABLE_MODELS
|
||||
worker_script = Path(__file__).with_name("worker.py")
|
||||
models_dir = Path(args.models_dir)
|
||||
for model_key in DEFAULT_MODEL_KEYS:
|
||||
@@ -418,6 +440,16 @@ def install_stages(args, venv_dir, python, uv, vendor_dir=None):
|
||||
]
|
||||
for pattern in model.download_patterns:
|
||||
download_command.extend(["--allow-pattern", pattern])
|
||||
for pattern in model.ready_patterns:
|
||||
download_command.extend(["--required-pattern", pattern])
|
||||
if model.r2_directory:
|
||||
download_command.extend(
|
||||
["--r2-directory", model.r2_directory]
|
||||
)
|
||||
for filename, size, checksum in model.r2_files:
|
||||
download_command.extend(
|
||||
["--r2-file", f"{filename}|{size}|{checksum}"]
|
||||
)
|
||||
stages.append(
|
||||
(
|
||||
model.label,
|
||||
@@ -437,7 +469,10 @@ def env_installed(venv_dir, manifest_path=None):
|
||||
payload = json.loads(ready_path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return False
|
||||
return isinstance(payload, dict)
|
||||
return (
|
||||
isinstance(payload, dict)
|
||||
and payload.get("manifest_revision") == MANIFEST_REVISION
|
||||
)
|
||||
|
||||
|
||||
def installed_devices(args):
|
||||
@@ -557,16 +592,66 @@ def bundled_uv_archive():
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def vendor_overlay_directory(filename):
|
||||
names = {
|
||||
"depth-anything-3.tar.gz": "depth-anything-3",
|
||||
"moge.tar.gz": "moge",
|
||||
"utils3d.tar.gz": "utils3d",
|
||||
}
|
||||
overlay_name = names.get(filename)
|
||||
if overlay_name is None:
|
||||
return None
|
||||
project_root = Path(__file__).resolve().parents[2]
|
||||
overlay = project_root / "patches" / overlay_name
|
||||
return overlay if overlay.is_dir() else None
|
||||
|
||||
|
||||
def copy_archive_with_overlay(source, target, overlay):
|
||||
"""开发目录使用原始 vendor 时,应用与正式打包相同的源码覆盖。"""
|
||||
with tarfile.open(source, "r:gz") as input_archive:
|
||||
members = input_archive.getmembers()
|
||||
top_level = members[0].name.split("/", 1)[0]
|
||||
with tarfile.open(target, "w:gz") as output_archive:
|
||||
for member in members:
|
||||
if member.isdir() or member.islnk() or member.issym():
|
||||
output_archive.addfile(member)
|
||||
continue
|
||||
relative = member.name[len(top_level) + 1 :]
|
||||
replacement = overlay / relative
|
||||
if replacement.is_file():
|
||||
info = tarfile.TarInfo(member.name)
|
||||
info.size = replacement.stat().st_size
|
||||
info.mode = member.mode
|
||||
with replacement.open("rb") as stream:
|
||||
output_archive.addfile(info, stream)
|
||||
continue
|
||||
with input_archive.extractfile(member) as stream:
|
||||
output_archive.addfile(member, stream)
|
||||
|
||||
|
||||
def copy_vendor_inputs(bundled_dir, vendor_dir):
|
||||
"""Copy bundled installation inputs into a temporary directory."""
|
||||
for name in ("depth-anything-3.tar.gz", "ben2.tar.gz"):
|
||||
for name in (
|
||||
"depth-anything-3.tar.gz",
|
||||
"ben2.tar.gz",
|
||||
"utils3d.tar.gz",
|
||||
"moge.tar.gz",
|
||||
):
|
||||
bundled = Path(bundled_dir) / name
|
||||
if not bundled.is_file():
|
||||
continue
|
||||
target = Path(vendor_dir) / name
|
||||
if target.is_file() and filecmp.cmp(bundled, target, shallow=False):
|
||||
overlay = vendor_overlay_directory(name)
|
||||
if overlay is None and target.is_file() and filecmp.cmp(
|
||||
bundled,
|
||||
target,
|
||||
shallow=False,
|
||||
):
|
||||
continue
|
||||
Path(vendor_dir).mkdir(parents=True, exist_ok=True)
|
||||
if overlay is not None and tarfile.is_tarfile(bundled):
|
||||
copy_archive_with_overlay(bundled, target, overlay)
|
||||
else:
|
||||
shutil.copy2(bundled, target)
|
||||
|
||||
|
||||
@@ -574,7 +659,12 @@ def remove_legacy_vendor(storage_root):
|
||||
"""Remove installation inputs persisted by older development builds."""
|
||||
root = Path(storage_root)
|
||||
shutil.rmtree(root / "vendor", ignore_errors=True)
|
||||
for name in ("depth-anything-3.tar.gz", "ben2.tar.gz"):
|
||||
for name in (
|
||||
"depth-anything-3.tar.gz",
|
||||
"ben2.tar.gz",
|
||||
"utils3d.tar.gz",
|
||||
"moge.tar.gz",
|
||||
):
|
||||
(root / name).unlink(missing_ok=True)
|
||||
|
||||
|
||||
@@ -799,6 +889,7 @@ def install(args):
|
||||
ready_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"manifest_revision": MANIFEST_REVISION,
|
||||
"python": "3.12",
|
||||
"torch": TORCH_VERSION,
|
||||
"xformers": XFORMERS_VERSION,
|
||||
@@ -870,6 +961,7 @@ def parse_args():
|
||||
|
||||
|
||||
def main():
|
||||
configure_standard_streams()
|
||||
args = parse_args()
|
||||
try:
|
||||
install(args)
|
||||
|
||||
@@ -11,6 +11,10 @@ class ModelSpec:
|
||||
description: str
|
||||
supports_gs: bool
|
||||
download_patterns: tuple[str, ...] = ()
|
||||
ready_patterns: tuple[str, ...] = ("config.json", "*.safetensors")
|
||||
enabled: bool = True
|
||||
r2_directory: str = ""
|
||||
r2_files: tuple[tuple[str, int, str], ...] = ()
|
||||
|
||||
|
||||
MODELS = {
|
||||
@@ -22,6 +26,11 @@ MODELS = {
|
||||
license_name="Apache 2.0",
|
||||
description="Fastest any-view model with Apache 2.0 weights",
|
||||
supports_gs=False,
|
||||
r2_directory="DA3Small",
|
||||
r2_files=(
|
||||
("config.json", 1202, "a486e29e82b7ab4a7d4cefc1ea4526cfe2ae438a572c8ca98917cfbcde7447d2"),
|
||||
("model.safetensors", 137248940, "364492e38a3a06d221ac75da7f6621ada3f2361cd24fde11ba79091e9f40efcf"),
|
||||
),
|
||||
),
|
||||
"BASE": ModelSpec(
|
||||
key="BASE",
|
||||
@@ -31,6 +40,12 @@ MODELS = {
|
||||
license_name="Apache 2.0",
|
||||
description="Balanced any-view model with Apache 2.0 weights",
|
||||
supports_gs=False,
|
||||
r2_directory="DA3Base",
|
||||
r2_files=(
|
||||
("config.json", 1205, "5e34115ebc17bd2d8d43033c5f72e9446ac8833fd61d3fa160b7e67e0bb5b7b5"),
|
||||
("configuration.json", 74, "b684d553257b2ca379fd5dfdf96a6e4a633eec3b969c97119c9875465a7c07a8"),
|
||||
("model.safetensors", 541518028, "e01067dc1659613083d9145a9a2547ccdbe6ccbbf83c4fe7b3e8a4e2bdae78b5"),
|
||||
),
|
||||
),
|
||||
"LARGE_1_1": ModelSpec(
|
||||
key="LARGE_1_1",
|
||||
@@ -40,6 +55,7 @@ MODELS = {
|
||||
license_name="CC BY-NC 4.0",
|
||||
description="Higher quality non-commercial model with larger VRAM use",
|
||||
supports_gs=False,
|
||||
enabled=False,
|
||||
),
|
||||
"GIANT_1_1": ModelSpec(
|
||||
key="GIANT_1_1",
|
||||
@@ -49,6 +65,7 @@ MODELS = {
|
||||
license_name="CC BY-NC 4.0",
|
||||
description="Non-commercial Giant model with Gaussian head",
|
||||
supports_gs=True,
|
||||
enabled=False,
|
||||
),
|
||||
"NESTED_GIANT_LARGE_1_1": ModelSpec(
|
||||
key="NESTED_GIANT_LARGE_1_1",
|
||||
@@ -58,6 +75,7 @@ MODELS = {
|
||||
license_name="CC BY-NC 4.0",
|
||||
description="Non-commercial nested model with metric depth and Gaussian head",
|
||||
supports_gs=True,
|
||||
enabled=False,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -70,8 +88,51 @@ BEN2_MODEL = ModelSpec(
|
||||
description="Fast high-quality background removal model",
|
||||
supports_gs=False,
|
||||
download_patterns=("config.json", "model.safetensors"),
|
||||
r2_directory="BEN2Base",
|
||||
r2_files=(
|
||||
("config.json", 124, "01ac876925577b52fa7c6af3f5b1d1420bc9b40016b6c25e2de89b38d8fff865"),
|
||||
("model.safetensors", 380577976, "ea8b7907176a09667c86343dc7d00de6a6d871076cb90bb5f753618fd6fb3ebb"),
|
||||
),
|
||||
)
|
||||
DOWNLOADABLE_MODELS = {**MODELS, BEN2_MODEL.key: BEN2_MODEL}
|
||||
def moge2_model(key, label, directory_name, repository, description, r2_directory, size, checksum):
|
||||
return ModelSpec(
|
||||
key=key,
|
||||
label=label,
|
||||
directory_name=directory_name,
|
||||
repository=repository,
|
||||
license_name="MIT",
|
||||
description=description,
|
||||
supports_gs=False,
|
||||
download_patterns=("model.pt",),
|
||||
ready_patterns=("model.pt",),
|
||||
r2_directory=r2_directory,
|
||||
r2_files=(("model.pt", size, checksum),),
|
||||
)
|
||||
|
||||
|
||||
MOGE2_MODELS = {
|
||||
"MOGE2_VITS_NORMAL": moge2_model(
|
||||
"MOGE2_VITS_NORMAL", "MoGe-2 ViT-S Normal", "moge2_vits_normal",
|
||||
"Ruicheng/moge-2-vits-normal", "Fastest MoGe-2 model", "MoGe2ViTSNormal",
|
||||
140550416, "79a16621928c2bf0ed04659218c55c01075e950507f40bb3332fb4c873d3e1dc",
|
||||
),
|
||||
"MOGE2_VITB_NORMAL": moge2_model(
|
||||
"MOGE2_VITB_NORMAL", "MoGe-2 ViT-B Normal", "moge2_vitb_normal",
|
||||
"Ruicheng/moge-2-vitb-normal", "Balanced MoGe-2 model", "MoGe2ViTBNormal",
|
||||
419110160, "16b8110e86d5dc5a849db120ca96ef3a223fd30b0c9146d1d81db504073da5f6",
|
||||
),
|
||||
"MOGE2_VITL_NORMAL": moge2_model(
|
||||
"MOGE2_VITL_NORMAL", "MoGe-2 ViT-L Normal", "moge2_vitl_normal",
|
||||
"Ruicheng/moge-2-vitl-normal", "Highest-quality MoGe-2 model", "MoGe2ViTLNormal",
|
||||
1323815904, "280741fd09bc3f403ccff9967784c2a391b52d2c0742ae3efdb21d9f90cc1a01",
|
||||
),
|
||||
}
|
||||
MOGE2_MODEL = MOGE2_MODELS["MOGE2_VITB_NORMAL"]
|
||||
DOWNLOADABLE_MODELS = {
|
||||
**MODELS,
|
||||
BEN2_MODEL.key: BEN2_MODEL,
|
||||
**MOGE2_MODELS,
|
||||
}
|
||||
MODEL_NUMBERS = {
|
||||
model_key: number for number, model_key in enumerate(MODELS)
|
||||
}
|
||||
@@ -88,7 +149,8 @@ def enum_items(supports_gs=None, show_download_status=False):
|
||||
models = [
|
||||
model
|
||||
for model in MODELS.values()
|
||||
if supports_gs is None or model.supports_gs == supports_gs
|
||||
if model.enabled
|
||||
and (supports_gs is None or model.supports_gs == supports_gs)
|
||||
]
|
||||
download_status = (
|
||||
tuple(model_ready(model.key) for model in models)
|
||||
@@ -130,6 +192,42 @@ def gs_model_enum_items(_owner, _context):
|
||||
return enum_items(supports_gs=True, show_download_status=True)
|
||||
|
||||
|
||||
def moge2_model_enum_items(_owner, _context):
|
||||
from .client import model_ready
|
||||
|
||||
return [
|
||||
(
|
||||
model.key,
|
||||
model.label if model_ready(model.key) else f"{model.label} (Missing)",
|
||||
f"{model.description}; license: {model.license_name}",
|
||||
index,
|
||||
)
|
||||
for index, model in enumerate(MOGE2_MODELS.values())
|
||||
]
|
||||
|
||||
|
||||
def depth_plane_model_enum_items(_owner, _context):
|
||||
from .client import model_ready
|
||||
|
||||
models = [
|
||||
*MOGE2_MODELS.values(),
|
||||
*(model for model in MODELS.values() if model.enabled),
|
||||
]
|
||||
items = []
|
||||
for index, model in enumerate(models):
|
||||
label = model.label if model_ready(model.key) else f"{model.label} (Missing)"
|
||||
items.append(
|
||||
(
|
||||
model.key,
|
||||
label,
|
||||
f"{model.description}; license: {model.license_name}",
|
||||
index,
|
||||
)
|
||||
)
|
||||
items.append(("ORIGINAL", "Original", "Use the original image as depth", len(items)))
|
||||
return items
|
||||
|
||||
|
||||
def selected_model_key(settings, export_format=None):
|
||||
selected_format = export_format or settings.export_format
|
||||
if selected_format == "gs_ply":
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -9,6 +10,7 @@ import bpy
|
||||
from . import client
|
||||
from .models import (
|
||||
BEN2_MODEL,
|
||||
MOGE2_MODEL,
|
||||
get_downloadable_model,
|
||||
get_model,
|
||||
selected_model_key,
|
||||
@@ -17,6 +19,8 @@ from .models import (
|
||||
|
||||
RECONSTRUCTION_JOB = "RECONSTRUCT"
|
||||
IMAGE_DEPTH_JOB = "IMAGE_DEPTH"
|
||||
MOGE2_DEPTH_JOB = "MOGE2_DEPTH"
|
||||
MOGE2_DEBUG_JOB = "MOGE2_DEBUG"
|
||||
IMAGE_MESH_JOB = "IMAGE_MESH"
|
||||
BACKGROUND_REMOVAL_JOB = "REMOVE_BACKGROUND"
|
||||
IMAGE_BACKGROUND_JOB = "IMAGE_BACKGROUND"
|
||||
@@ -26,6 +30,7 @@ DA3_IMAGE_JOBS = frozenset({IMAGE_DEPTH_JOB, IMAGE_MESH_JOB})
|
||||
IMAGE_EMPTY_JOBS = frozenset(
|
||||
{
|
||||
*DA3_IMAGE_JOBS,
|
||||
MOGE2_DEPTH_JOB,
|
||||
IMAGE_BACKGROUND_JOB,
|
||||
ORIGINAL_BACKGROUND_JOB,
|
||||
ORIGINAL_FRAMES_JOB,
|
||||
@@ -34,6 +39,7 @@ IMAGE_EMPTY_JOBS = frozenset(
|
||||
PRODUCTION_AI_JOBS = frozenset(
|
||||
{
|
||||
*DA3_IMAGE_JOBS,
|
||||
MOGE2_DEPTH_JOB,
|
||||
IMAGE_BACKGROUND_JOB,
|
||||
ORIGINAL_BACKGROUND_JOB,
|
||||
}
|
||||
@@ -156,6 +162,8 @@ class RunJob(bpy.types.Operator):
|
||||
("DOWNLOAD_MODEL", "Download Model", ""),
|
||||
(RECONSTRUCTION_JOB, "Export DA3 Result", ""),
|
||||
(IMAGE_DEPTH_JOB, "Convert Image to Depth Plane", ""),
|
||||
(MOGE2_DEPTH_JOB, "Convert Image with MoGe-2", ""),
|
||||
(MOGE2_DEBUG_JOB, "Test MoGe-2 API", ""),
|
||||
(IMAGE_MESH_JOB, "Convert Image to Surface", ""),
|
||||
(BACKGROUND_REMOVAL_JOB, "Test BEN2 Background Removal", ""),
|
||||
(IMAGE_BACKGROUND_JOB, "Remove Image Empty Background", ""),
|
||||
@@ -210,6 +218,12 @@ class RunJob(bpy.types.Operator):
|
||||
default=False,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
snapform_moge_resolution_level: bpy.props.IntProperty(
|
||||
default=5,
|
||||
min=0,
|
||||
max=9,
|
||||
options={"HIDDEN", "SKIP_SAVE"},
|
||||
)
|
||||
|
||||
def execute(self, context):
|
||||
global _active_job
|
||||
@@ -236,6 +250,8 @@ class RunJob(bpy.types.Operator):
|
||||
export_format = "mini_npz"
|
||||
elif self.job_type == IMAGE_MESH_JOB:
|
||||
export_format = "mesh_data"
|
||||
elif self.job_type in {MOGE2_DEPTH_JOB, MOGE2_DEBUG_JOB}:
|
||||
export_format = "moge_npz"
|
||||
|
||||
job = ActiveJob(
|
||||
kind=self.job_type,
|
||||
@@ -300,12 +316,20 @@ class RunJob(bpy.types.Operator):
|
||||
if job.kind == "INSTALL_ENV":
|
||||
client.stop_service()
|
||||
job.log_file = job.log_path.open("w", encoding="utf-8")
|
||||
process_environment = None
|
||||
if job.kind == "INSTALL_ENV":
|
||||
process_environment = os.environ.copy()
|
||||
process_environment["PYTHONIOENCODING"] = (
|
||||
"utf-8:backslashreplace"
|
||||
)
|
||||
process_environment["PYTHONUTF8"] = "1"
|
||||
job.process = subprocess.Popen(
|
||||
command,
|
||||
stdout=job.log_file,
|
||||
stderr=subprocess.STDOUT,
|
||||
cwd=working_directory,
|
||||
creationflags=creation_flags,
|
||||
env=process_environment,
|
||||
)
|
||||
job.started = True
|
||||
if job.cancelled:
|
||||
@@ -401,6 +425,35 @@ class RunJob(bpy.types.Operator):
|
||||
),
|
||||
paths,
|
||||
)
|
||||
if self.job_type in {MOGE2_DEPTH_JOB, MOGE2_DEBUG_JOB}:
|
||||
model_key = self.snapform_model_key or MOGE2_MODEL.key
|
||||
model = get_downloadable_model(model_key)
|
||||
if not client.model_ready(model_key):
|
||||
raise RuntimeError(f"Download {model.label} first")
|
||||
if remove_background and not client.model_ready(BEN2_MODEL.key):
|
||||
raise RuntimeError(f"Download {BEN2_MODEL.label} first")
|
||||
return (
|
||||
client.moge_request(
|
||||
settings,
|
||||
paths["status"],
|
||||
paths["result"],
|
||||
input_path=input_path,
|
||||
resolution_level=(
|
||||
self.snapform_moge_resolution_level
|
||||
if self.job_type == MOGE2_DEPTH_JOB
|
||||
else settings.moge_resolution_level
|
||||
),
|
||||
include_points=self.job_type == MOGE2_DEBUG_JOB,
|
||||
remove_background=(
|
||||
self.job_type == MOGE2_DEPTH_JOB
|
||||
and remove_background
|
||||
),
|
||||
start_frame=start_frame,
|
||||
device=self._task_device(settings),
|
||||
model_key=model_key,
|
||||
),
|
||||
paths,
|
||||
)
|
||||
if self.job_type not in {
|
||||
RECONSTRUCTION_JOB,
|
||||
IMAGE_DEPTH_JOB,
|
||||
@@ -478,6 +531,8 @@ class RunJob(bpy.types.Operator):
|
||||
return f"Starting {settings.export_format} export"
|
||||
if self.job_type == IMAGE_DEPTH_JOB:
|
||||
return "Starting depth estimation"
|
||||
if self.job_type in {MOGE2_DEPTH_JOB, MOGE2_DEBUG_JOB}:
|
||||
return "Starting MoGe-2 inference"
|
||||
if self.job_type == IMAGE_MESH_JOB:
|
||||
return "Starting perspective surface generation"
|
||||
if self.job_type == BACKGROUND_REMOVAL_JOB:
|
||||
@@ -626,6 +681,7 @@ class RunJob(bpy.types.Operator):
|
||||
if job.kind in {
|
||||
RECONSTRUCTION_JOB,
|
||||
BACKGROUND_REMOVAL_JOB,
|
||||
MOGE2_DEBUG_JOB,
|
||||
}:
|
||||
try:
|
||||
open_job_directory(job.result_directory)
|
||||
@@ -635,8 +691,10 @@ class RunJob(bpy.types.Operator):
|
||||
message = (
|
||||
f"Exported {job.export_format} to {job.result_directory}"
|
||||
)
|
||||
else:
|
||||
elif job.kind == BACKGROUND_REMOVAL_JOB:
|
||||
message = f"Removed background to {job.result_directory}"
|
||||
else:
|
||||
message = f"Exported MoGe-2 data to {job.result_directory}"
|
||||
self.report({"INFO"}, message)
|
||||
if job.kind == IMAGE_DEPTH_JOB:
|
||||
try:
|
||||
@@ -653,6 +711,23 @@ class RunJob(bpy.types.Operator):
|
||||
self._redraw_ui(context, force=True)
|
||||
return {"CANCELLED"}
|
||||
status["message"] = "Depth plane is ready"
|
||||
if job.kind == MOGE2_DEPTH_JOB:
|
||||
try:
|
||||
settings.status = "Building MoGe-2 depth plane"
|
||||
settings.progress = 0.96
|
||||
self._redraw_ui(context, force=True)
|
||||
self._build_moge2_depth_plane(job)
|
||||
except (OSError, RuntimeError, ValueError) as import_error:
|
||||
settings.status = "MoGe-2 depth import failed"
|
||||
settings.progress = 0.0
|
||||
self.report({"ERROR"}, str(import_error))
|
||||
client.mark_service_job_complete(
|
||||
client.job_id_from_status(job.status_path)
|
||||
)
|
||||
close_active_job()
|
||||
self._redraw_ui(context, force=True)
|
||||
return {"CANCELLED"}
|
||||
status["message"] = "MoGe-2 depth plane is ready"
|
||||
if job.kind == IMAGE_MESH_JOB:
|
||||
try:
|
||||
settings.status = "Building perspective surface"
|
||||
@@ -790,6 +865,35 @@ class RunJob(bpy.types.Operator):
|
||||
if "FINISHED" not in undo_result:
|
||||
raise RuntimeError("Unable to create the depth plane Undo step")
|
||||
|
||||
def _build_moge2_depth_plane(self, job):
|
||||
result_directory = job.result_directory
|
||||
is_sequence = bool(list(result_directory.glob("frame.*")))
|
||||
foreground_sequence = bool(
|
||||
list(result_directory.glob("foreground.*.png"))
|
||||
)
|
||||
result = bpy.ops.snapform.build_moge2_depth_plane(
|
||||
snapform_source_object_name=job.source_object_name,
|
||||
snapform_npz_path=str(result_directory / "results.npz"),
|
||||
snapform_background_path=(
|
||||
str(result_directory)
|
||||
if job.remove_background and foreground_sequence
|
||||
else str(result_directory / "foreground.png")
|
||||
if job.remove_background
|
||||
else ""
|
||||
),
|
||||
snapform_color_frames_dir=(
|
||||
str(result_directory) if is_sequence else ""
|
||||
),
|
||||
snapform_data_frames_dir=str(result_directory),
|
||||
snapform_delete_source=job.delete_source,
|
||||
snapform_unshaded=job.unshaded,
|
||||
)
|
||||
if "FINISHED" not in result:
|
||||
raise RuntimeError("Unable to build the MoGe-2 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 _apply_removed_background(self, job, push_undo=True):
|
||||
result_directory = job.result_directory
|
||||
foreground_paths = sorted(result_directory.glob("foreground.*.png"))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import bpy
|
||||
|
||||
from .models import BEN2_MODEL, MODELS, selected_model_key
|
||||
from .models import BEN2_MODEL, MODELS, MOGE2_MODELS, selected_model_key
|
||||
from .operators import active_job
|
||||
from . import client
|
||||
from .client import model_ready, env_installed
|
||||
@@ -126,6 +126,9 @@ class ModelsPanel(bpy.types.Panel):
|
||||
running = active_job() is not None or client.service_busy()
|
||||
|
||||
for model in MODELS.values():
|
||||
if model.enabled:
|
||||
self._draw_model(layout, model, running)
|
||||
for model in MOGE2_MODELS.values():
|
||||
self._draw_model(layout, model, running)
|
||||
self._draw_model(layout, BEN2_MODEL, running)
|
||||
|
||||
@@ -236,6 +239,36 @@ class Da3OutputDebugPanel(bpy.types.Panel):
|
||||
operator.job_type = "RECONSTRUCT"
|
||||
|
||||
|
||||
class Moge2ApiDebugPanel(bpy.types.Panel):
|
||||
bl_label = "MoGe-2 API Test"
|
||||
bl_idname = "SNAPFORM_PT_debug_moge2_api"
|
||||
bl_parent_id = DebugPanel.bl_idname
|
||||
bl_space_type = "VIEW_3D"
|
||||
bl_region_type = "UI"
|
||||
bl_category = "SnapForm"
|
||||
bl_options = {"DEFAULT_CLOSED"}
|
||||
bl_order = 1
|
||||
|
||||
def draw(self, context):
|
||||
layout = self.layout
|
||||
settings = context.scene.snapform_settings
|
||||
layout.prop(settings, "input_path")
|
||||
layout.prop(settings, "moge_resolution_level")
|
||||
layout.prop(settings, "moge_model")
|
||||
run_row = layout.row()
|
||||
run_row.enabled = (
|
||||
active_job() is None
|
||||
and not client.service_busy()
|
||||
and model_ready(settings.moge_model)
|
||||
)
|
||||
operator = run_row.operator(
|
||||
"snapform.run_job",
|
||||
text="Generate MoGe-2 Output",
|
||||
icon="EXPORT",
|
||||
)
|
||||
operator.job_type = "MOGE2_DEBUG"
|
||||
|
||||
|
||||
class Ben2RemovalDebugPanel(bpy.types.Panel):
|
||||
bl_label = "BEN2 Removal Test"
|
||||
bl_idname = "SNAPFORM_PT_debug_ben2_removal"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import bpy
|
||||
|
||||
from .models import gs_model_enum_items, model_enum_items
|
||||
from .models import gs_model_enum_items, model_enum_items, moge2_model_enum_items
|
||||
from .client import device_enum_items
|
||||
|
||||
|
||||
@@ -65,6 +65,19 @@ class SnapFormSettings(bpy.types.PropertyGroup):
|
||||
),
|
||||
default="upper_bound_resize",
|
||||
)
|
||||
moge_resolution_level: bpy.props.IntProperty(
|
||||
name="Resolution Level",
|
||||
description="MoGe-2 inference resolution level; higher is finer and slower",
|
||||
default=5,
|
||||
min=0,
|
||||
max=9,
|
||||
)
|
||||
moge_model: bpy.props.EnumProperty(
|
||||
name="Model",
|
||||
description="MoGe-2 model used for depth estimation",
|
||||
items=moge2_model_enum_items,
|
||||
default=1,
|
||||
)
|
||||
conf_thresh_percentile: bpy.props.FloatProperty(
|
||||
name="Confidence Threshold",
|
||||
description="Remove the lowest-confidence percentile of points or surface faces",
|
||||
|
||||
@@ -20,6 +20,9 @@ import worker
|
||||
|
||||
|
||||
CLIENT_TIMEOUT_SECONDS = 10.0
|
||||
JOB_COMMANDS = frozenset(
|
||||
{"remove-background", "reconstruct", "extract-frames", "moge"}
|
||||
)
|
||||
IDLE_SHUTDOWN_SECONDS = 20.0
|
||||
|
||||
|
||||
@@ -78,6 +81,8 @@ class ModelCache:
|
||||
self.ben2_key = None
|
||||
self.da3_model = None
|
||||
self.da3_key = None
|
||||
self.moge_model = None
|
||||
self.moge_key = None
|
||||
|
||||
def get_ben2(self, model_dir, device):
|
||||
key = (str(Path(model_dir).resolve()), device.type)
|
||||
@@ -88,6 +93,7 @@ class ModelCache:
|
||||
return self._load_ben2(model_dir, device, key)
|
||||
except self.torch.cuda.OutOfMemoryError:
|
||||
self.release_da3()
|
||||
self.release_moge()
|
||||
return self._load_ben2(model_dir, device, key)
|
||||
|
||||
def _load_ben2(self, model_dir, device, key):
|
||||
@@ -111,6 +117,7 @@ class ModelCache:
|
||||
return self._load_da3(model_dir, device, key)
|
||||
except self.torch.cuda.OutOfMemoryError:
|
||||
self.release_ben2()
|
||||
self.release_moge()
|
||||
return self._load_da3(model_dir, device, key)
|
||||
|
||||
def _load_da3(self, model_dir, device, key):
|
||||
@@ -123,6 +130,30 @@ class ModelCache:
|
||||
self.da3_key = key
|
||||
return model
|
||||
|
||||
def get_moge(self, model_dir, device):
|
||||
key = (str(Path(model_dir).resolve()), device.type)
|
||||
if self.moge_model is not None and self.moge_key == key:
|
||||
return self.moge_model
|
||||
self.release_moge()
|
||||
try:
|
||||
return self._load_moge(model_dir, device, key)
|
||||
except self.torch.cuda.OutOfMemoryError:
|
||||
self.release_da3()
|
||||
self.release_ben2()
|
||||
return self._load_moge(model_dir, device, key)
|
||||
|
||||
def _load_moge(self, model_dir, device, key):
|
||||
worker.prepare_moge_runtime()
|
||||
from moge.model.v2 import MoGeModel
|
||||
|
||||
checkpoint = Path(model_dir) / "model.pt"
|
||||
model = MoGeModel.from_pretrained(checkpoint)
|
||||
model = model.to(device=device)
|
||||
model.eval()
|
||||
self.moge_model = model
|
||||
self.moge_key = key
|
||||
return model
|
||||
|
||||
def release_ben2(self):
|
||||
self.ben2_model = None
|
||||
self.ben2_key = None
|
||||
@@ -133,11 +164,18 @@ class ModelCache:
|
||||
self.da3_key = None
|
||||
self._release_cuda_cache()
|
||||
|
||||
def release_moge(self):
|
||||
self.moge_model = None
|
||||
self.moge_key = None
|
||||
self._release_cuda_cache()
|
||||
|
||||
def close(self):
|
||||
self.ben2_model = None
|
||||
self.da3_model = None
|
||||
self.moge_model = None
|
||||
self.ben2_key = None
|
||||
self.da3_key = None
|
||||
self.moge_key = None
|
||||
self._release_cuda_cache()
|
||||
|
||||
def status(self):
|
||||
@@ -150,6 +188,11 @@ class ModelCache:
|
||||
if self.da3_key is not None
|
||||
else ""
|
||||
),
|
||||
"moge2": (
|
||||
Path(getattr(self, "moge_key", ("",))[0]).name
|
||||
if getattr(self, "moge_key", None) is not None
|
||||
else ""
|
||||
),
|
||||
}
|
||||
|
||||
def _release_cuda_cache(self):
|
||||
@@ -281,6 +324,12 @@ class ServiceState:
|
||||
args,
|
||||
cancel_check=cancel_check,
|
||||
)
|
||||
elif payload["command"] == "moge":
|
||||
worker.run_moge(
|
||||
args,
|
||||
model_cache=self.models,
|
||||
cancel_check=cancel_check,
|
||||
)
|
||||
else:
|
||||
worker.reconstruct(
|
||||
args,
|
||||
@@ -429,11 +478,7 @@ def create_app(token, instance_id="test"):
|
||||
client_id = str(payload.get("client_id", ""))
|
||||
command = payload.get("command")
|
||||
parameters = payload.get("parameters")
|
||||
if not job_id or command not in {
|
||||
"remove-background",
|
||||
"reconstruct",
|
||||
"extract-frames",
|
||||
}:
|
||||
if not job_id or command not in JOB_COMMANDS:
|
||||
raise HTTPException(status_code=422, detail="Invalid job request")
|
||||
if not isinstance(parameters, dict) or not parameters.get("status"):
|
||||
raise HTTPException(status_code=422, detail="Missing job parameters")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import argparse
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -7,7 +7,7 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
@@ -33,9 +33,8 @@ EXPORT_FORMATS = (
|
||||
"mesh_data",
|
||||
)
|
||||
HF_ENDPOINT = "https://huggingface.co"
|
||||
MODELSCOPE_ENDPOINT = "https://modelscope.cn"
|
||||
MODELSCOPE_REVISION = "master"
|
||||
MODELSCOPE_TIMEOUT = 60.0
|
||||
R2_ENDPOINT = "https://models.omoolab.xyz"
|
||||
DOWNLOAD_TIMEOUT = 60.0
|
||||
|
||||
|
||||
class TaskCancelled(RuntimeError):
|
||||
@@ -925,8 +924,14 @@ def download_model(args):
|
||||
"stages": getattr(args, "stages", None),
|
||||
"stage_label": getattr(args, "stage_label", None),
|
||||
}
|
||||
failures = []
|
||||
try:
|
||||
from huggingface_hub import snapshot_download
|
||||
|
||||
except Exception as error:
|
||||
snapshot_download = None
|
||||
failures.append((HF_ENDPOINT, error))
|
||||
progress_class = None
|
||||
if snapshot_download:
|
||||
progress_class = create_download_progress_class(
|
||||
args.status,
|
||||
args.repository,
|
||||
@@ -935,11 +940,7 @@ def download_model(args):
|
||||
label=label,
|
||||
**stage_kwargs,
|
||||
)
|
||||
failures = []
|
||||
for endpoint in download_endpoints():
|
||||
if not hf_endpoint_reachable(endpoint):
|
||||
failures.append((endpoint, "endpoint unreachable"))
|
||||
continue
|
||||
for endpoint in download_endpoints() if snapshot_download else ():
|
||||
os.environ["HF_ENDPOINT"] = endpoint
|
||||
message = f"Downloading {label}"
|
||||
if endpoint != HF_ENDPOINT:
|
||||
@@ -953,7 +954,10 @@ def download_model(args):
|
||||
allow_patterns=args.allow_pattern or None,
|
||||
endpoint=endpoint,
|
||||
)
|
||||
_verify_model_complete(model_dir)
|
||||
_verify_model_complete(
|
||||
model_dir,
|
||||
getattr(args, "required_pattern", None),
|
||||
)
|
||||
except Exception as error:
|
||||
failures.append((endpoint, error))
|
||||
continue
|
||||
@@ -964,15 +968,22 @@ def download_model(args):
|
||||
**stage_kwargs,
|
||||
)
|
||||
return
|
||||
# Hugging Face 官方源与已配置镜像均失败时,回退 ModelScope(仓库名与 Hugging Face 一致)
|
||||
# Hugging Face 失败后使用由项目维护的静态模型镜像。
|
||||
r2_directory = getattr(args, "r2_directory", "")
|
||||
r2_files = getattr(args, "r2_file", [])
|
||||
if not r2_directory or not r2_files:
|
||||
detail = "; ".join(
|
||||
f"{source}: {error}" for source, error in failures
|
||||
)
|
||||
raise RuntimeError(f"All download sources failed: {detail}")
|
||||
try:
|
||||
write_status(
|
||||
args.status,
|
||||
start,
|
||||
f"Downloading {label} | ModelScope mirror",
|
||||
f"Downloading {label} | OmooLab mirror",
|
||||
**stage_kwargs,
|
||||
)
|
||||
download_from_modelscope(
|
||||
download_from_r2(
|
||||
args,
|
||||
model_dir,
|
||||
start=start,
|
||||
@@ -980,9 +991,12 @@ def download_model(args):
|
||||
label=label,
|
||||
stage_kwargs=stage_kwargs,
|
||||
)
|
||||
_verify_model_complete(model_dir)
|
||||
_verify_model_complete(
|
||||
model_dir,
|
||||
getattr(args, "required_pattern", None),
|
||||
)
|
||||
except Exception as error:
|
||||
failures.append((MODELSCOPE_ENDPOINT, error))
|
||||
failures.append((R2_ENDPOINT, error))
|
||||
else:
|
||||
write_status(
|
||||
args.status,
|
||||
@@ -998,33 +1012,23 @@ def download_model(args):
|
||||
|
||||
|
||||
def download_endpoints():
|
||||
"""Hugging Face download candidates: configured mirror first, official endpoint otherwise."""
|
||||
"""Return the configured Hugging Face endpoint or official source."""
|
||||
endpoint = os.environ.get("HF_ENDPOINT") or HF_ENDPOINT
|
||||
return [endpoint.rstrip("/")]
|
||||
|
||||
|
||||
def hf_endpoint_reachable(endpoint, timeout=10.0):
|
||||
"""Quick connectivity probe so slow or blocked Hugging Face endpoints fail fast."""
|
||||
try:
|
||||
request = Request(
|
||||
f"{endpoint.rstrip('/')}/api/models",
|
||||
method="HEAD",
|
||||
headers={"User-Agent": "SnapForm"},
|
||||
)
|
||||
with urlopen(request, timeout=timeout):
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _verify_model_complete(model_dir):
|
||||
def _verify_model_complete(model_dir, required_patterns=None):
|
||||
"""Refuse to report success when the required model files are missing."""
|
||||
model_dir = Path(model_dir)
|
||||
missing = []
|
||||
if not (model_dir / "config.json").is_file():
|
||||
missing.append("config.json")
|
||||
if not any(model_dir.glob("*.safetensors")):
|
||||
missing.append("*.safetensors")
|
||||
required_patterns = required_patterns or (
|
||||
"config.json",
|
||||
"*.safetensors",
|
||||
)
|
||||
missing = [
|
||||
pattern
|
||||
for pattern in required_patterns
|
||||
if not any(model_dir.glob(pattern))
|
||||
]
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
"Model download reported success but files are missing: "
|
||||
@@ -1032,59 +1036,42 @@ def _verify_model_complete(model_dir):
|
||||
)
|
||||
|
||||
|
||||
def _hidden_repo_path(path):
|
||||
return any(part.startswith(".") for part in Path(path).parts)
|
||||
|
||||
|
||||
def _model_file_allowed(path, patterns):
|
||||
if not patterns:
|
||||
return True
|
||||
return any(fnmatch.fnmatch(path, pattern) for pattern in patterns)
|
||||
|
||||
|
||||
def modelscope_file_list(repository, revision=MODELSCOPE_REVISION):
|
||||
"""List (path, size) file entries of a ModelScope repository."""
|
||||
url = (
|
||||
f"{MODELSCOPE_ENDPOINT}/api/v1/models/{repository}/repo/files?"
|
||||
+ urlencode({"Revision": revision, "Recursive": "true"})
|
||||
)
|
||||
request = Request(url, headers={"User-Agent": "SnapForm"})
|
||||
with urlopen(request, timeout=MODELSCOPE_TIMEOUT) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
entries = []
|
||||
for entry in payload.get("Data", {}).get("Files", []):
|
||||
path = entry.get("Path")
|
||||
if path and entry.get("Type") == "blob" and not _hidden_repo_path(path):
|
||||
entries.append((path, int(entry.get("Size") or 0)))
|
||||
return entries
|
||||
|
||||
|
||||
def _download_modelscope_file(
|
||||
repository,
|
||||
path,
|
||||
def _download_r2_file(
|
||||
directory,
|
||||
filename,
|
||||
destination,
|
||||
expected_size,
|
||||
expected_checksum,
|
||||
progress=None,
|
||||
retries=2,
|
||||
):
|
||||
"""Download one ModelScope file atomically with a per-chunk progress callback."""
|
||||
url = (
|
||||
f"{MODELSCOPE_ENDPOINT}/models/{repository}/resolve/"
|
||||
f"{MODELSCOPE_REVISION}/{quote(path)}"
|
||||
)
|
||||
"""Download and verify one OmooLab R2 model file atomically."""
|
||||
url = f"{R2_ENDPOINT}/{quote(directory)}/{quote(filename)}"
|
||||
request = Request(url, headers={"User-Agent": "SnapForm"})
|
||||
last_error = None
|
||||
for _attempt in range(retries):
|
||||
temporary = destination.with_name(f"{destination.name}.part")
|
||||
try:
|
||||
with urlopen(request, timeout=MODELSCOPE_TIMEOUT) as response:
|
||||
checksum = hashlib.sha256()
|
||||
downloaded = 0
|
||||
with urlopen(request, timeout=DOWNLOAD_TIMEOUT) as response:
|
||||
with open(temporary, "wb") as output:
|
||||
while True:
|
||||
chunk = response.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
output.write(chunk)
|
||||
checksum.update(chunk)
|
||||
downloaded += len(chunk)
|
||||
if progress is not None:
|
||||
progress(len(chunk))
|
||||
if downloaded != expected_size:
|
||||
raise RuntimeError(
|
||||
f"Size mismatch for {filename}: "
|
||||
f"{downloaded} != {expected_size}"
|
||||
)
|
||||
if checksum.hexdigest().lower() != expected_checksum.lower():
|
||||
raise RuntimeError(f"SHA-256 mismatch for {filename}")
|
||||
temporary.replace(destination)
|
||||
return
|
||||
except Exception as error:
|
||||
@@ -1096,7 +1083,7 @@ def _download_modelscope_file(
|
||||
raise last_error
|
||||
|
||||
|
||||
def download_from_modelscope(
|
||||
def download_from_r2(
|
||||
args,
|
||||
model_dir,
|
||||
start=0.05,
|
||||
@@ -1104,19 +1091,12 @@ def download_from_modelscope(
|
||||
label=None,
|
||||
stage_kwargs=None,
|
||||
):
|
||||
"""Download a model from ModelScope using only standard library requests."""
|
||||
"""Download the declared model files from OmooLab R2."""
|
||||
repository = args.repository
|
||||
files = modelscope_file_list(repository)
|
||||
patterns = args.allow_pattern or None
|
||||
selected = [
|
||||
(path, size)
|
||||
for path, size in files
|
||||
if _model_file_allowed(path, patterns)
|
||||
]
|
||||
if not selected:
|
||||
raise RuntimeError(
|
||||
f"No matching files in ModelScope repository: {repository}"
|
||||
)
|
||||
selected = []
|
||||
for value in args.r2_file:
|
||||
filename, size, checksum = value.split("|", 2)
|
||||
selected.append((filename, int(size), checksum))
|
||||
reporter = DownloadProgressReporter(
|
||||
args.status,
|
||||
repository,
|
||||
@@ -1125,7 +1105,7 @@ def download_from_modelscope(
|
||||
label=label or repository,
|
||||
**(stage_kwargs or {}),
|
||||
)
|
||||
total_size = sum(size for _path, size in selected)
|
||||
total_size = sum(size for _path, size, _checksum in selected)
|
||||
downloaded = 0
|
||||
|
||||
def on_chunk(count):
|
||||
@@ -1133,13 +1113,15 @@ def download_from_modelscope(
|
||||
downloaded += count
|
||||
reporter.report(downloaded, total_size)
|
||||
|
||||
for path, _size in selected:
|
||||
for path, size, checksum in selected:
|
||||
destination = Path(model_dir) / path
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
_download_modelscope_file(
|
||||
repository,
|
||||
_download_r2_file(
|
||||
args.r2_directory,
|
||||
path,
|
||||
destination,
|
||||
size,
|
||||
checksum,
|
||||
progress=on_chunk,
|
||||
)
|
||||
|
||||
@@ -1500,6 +1482,168 @@ def flatten_exported_paths(
|
||||
return flattened_paths
|
||||
|
||||
|
||||
def _save_moge_preview(output_directory, index, depth, normal, mask):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
suffix = "" if index is None else f".{index + 1:04d}"
|
||||
valid = mask & np.isfinite(depth) & (depth > 0)
|
||||
preview = np.zeros(depth.shape, dtype=np.uint8)
|
||||
if np.any(valid):
|
||||
low, high = np.percentile(depth[valid], (2.0, 98.0))
|
||||
span = max(float(high - low), 1e-6)
|
||||
preview[valid] = np.clip(
|
||||
(depth[valid] - low) / span * 255.0,
|
||||
0.0,
|
||||
255.0,
|
||||
).astype(np.uint8)
|
||||
Image.fromarray(preview, mode="L").save(
|
||||
output_directory / f"depth{suffix}.png"
|
||||
)
|
||||
normal_rgb = np.clip(
|
||||
(normal * np.array([1.0, -1.0, -1.0], dtype=np.float32) + 1.0)
|
||||
* 127.5,
|
||||
0.0,
|
||||
255.0,
|
||||
).astype(np.uint8)
|
||||
Image.fromarray(normal_rgb, mode="RGB").save(
|
||||
output_directory / f"normal{suffix}.png"
|
||||
)
|
||||
Image.fromarray(mask.astype(np.uint8) * 255, mode="L").save(
|
||||
output_directory / f"mask{suffix}.png"
|
||||
)
|
||||
|
||||
|
||||
def run_moge(args, model_cache=None, cancel_check=None):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
prepare_moge_runtime()
|
||||
write_status(args.status, 0.02, "Starting MoGe-2")
|
||||
if model_cache is None:
|
||||
import torch
|
||||
from moge.model.v2 import MoGeModel
|
||||
else:
|
||||
torch = model_cache.torch
|
||||
output_directory = Path(args.output_dir)
|
||||
output_directory.mkdir(parents=True, exist_ok=True)
|
||||
images, temporary_directory = prepare_input(
|
||||
args.input,
|
||||
args.video_frames,
|
||||
output_directory,
|
||||
start_frame=getattr(args, "start_frame", 1),
|
||||
cancel_check=cancel_check,
|
||||
)
|
||||
try:
|
||||
device = select_device(args.device, torch)
|
||||
write_status(args.status, 0.08, f"Loading MoGe-2 on {device.type}")
|
||||
if model_cache is None:
|
||||
checkpoint = Path(args.model_dir) / "model.pt"
|
||||
model = MoGeModel.from_pretrained(checkpoint)
|
||||
model = model.to(device=device)
|
||||
model.eval()
|
||||
else:
|
||||
model = model_cache.get_moge(args.model_dir, device)
|
||||
|
||||
depths = []
|
||||
normals = []
|
||||
masks = []
|
||||
points = []
|
||||
intrinsics = []
|
||||
for index, image_path in enumerate(images):
|
||||
check_cancelled(cancel_check)
|
||||
write_status(
|
||||
args.status,
|
||||
0.15 + 0.68 * index / len(images),
|
||||
f"Estimating MoGe-2 geometry ({index + 1}/{len(images)})",
|
||||
)
|
||||
with Image.open(image_path) as opened:
|
||||
image = np.asarray(opened.convert("RGB"), dtype=np.float32) / 255.0
|
||||
image_tensor = torch.from_numpy(image).permute(2, 0, 1).to(device)
|
||||
with torch.inference_mode():
|
||||
prediction = model.infer(
|
||||
image_tensor,
|
||||
resolution_level=int(args.resolution_level),
|
||||
force_projection=True,
|
||||
apply_mask=False,
|
||||
use_fp16=device.type == "cuda",
|
||||
)
|
||||
depth = prediction["depth"].detach().float().cpu().numpy()
|
||||
normal = prediction["normal"].detach().float().cpu().numpy()
|
||||
mask = prediction["mask"].detach().cpu().numpy().astype(bool)
|
||||
point = prediction["points"].detach().float().cpu().numpy()
|
||||
intrinsic = prediction["intrinsics"].detach().float().cpu().numpy()
|
||||
depths.append(depth)
|
||||
normals.append(normal)
|
||||
masks.append(mask)
|
||||
intrinsics.append(intrinsic)
|
||||
if bool(getattr(args, "include_points", False)):
|
||||
points.append(point)
|
||||
_save_moge_preview(
|
||||
output_directory,
|
||||
None if len(images) == 1 else index,
|
||||
depth,
|
||||
normal,
|
||||
mask,
|
||||
)
|
||||
|
||||
shapes = {array.shape for array in depths}
|
||||
if len(shapes) != 1:
|
||||
raise RuntimeError("MoGe-2 frame sizes must match")
|
||||
result = {
|
||||
"depth": np.stack(depths).astype(np.float32),
|
||||
"normal": np.stack(normals).astype(np.float32),
|
||||
"mask": np.stack(masks),
|
||||
"intrinsics": np.stack(intrinsics).astype(np.float32),
|
||||
}
|
||||
if points:
|
||||
result["points"] = np.stack(points).astype(np.float32)
|
||||
np.savez(output_directory / "results.npz", **result)
|
||||
|
||||
remove_background_model_dir = getattr(
|
||||
args, "remove_background_model_dir", ""
|
||||
)
|
||||
if remove_background_model_dir:
|
||||
write_status(args.status, 0.87, "Removing image background")
|
||||
if len(images) == 1:
|
||||
foreground, _, _ = infer_foreground(
|
||||
images[0],
|
||||
remove_background_model_dir,
|
||||
device,
|
||||
torch,
|
||||
model_cache=model_cache,
|
||||
cancel_check=cancel_check,
|
||||
)
|
||||
foreground.save(output_directory / "foreground.png")
|
||||
else:
|
||||
infer_foreground_frames(
|
||||
images,
|
||||
remove_background_model_dir,
|
||||
device,
|
||||
torch,
|
||||
output_directory,
|
||||
args.status,
|
||||
0.87,
|
||||
0.08,
|
||||
model_cache=model_cache,
|
||||
cancel_check=cancel_check,
|
||||
)
|
||||
copy_frames(images, output_directory)
|
||||
write_status(
|
||||
args.status,
|
||||
1.0,
|
||||
f"Exported MoGe-2 data for {len(images)} frame(s)",
|
||||
)
|
||||
finally:
|
||||
if temporary_directory is not None:
|
||||
shutil.rmtree(temporary_directory, ignore_errors=True)
|
||||
|
||||
|
||||
def prepare_moge_runtime():
|
||||
"""Use PyTorch SDPA because xFormers does not support every CUDA GPU."""
|
||||
os.environ["XFORMERS_DISABLED"] = "1"
|
||||
|
||||
|
||||
def reconstruct(args, model_cache=None, cancel_check=None):
|
||||
write_status(args.status, 0.02, "Starting Environment")
|
||||
check_cancelled(cancel_check)
|
||||
@@ -1686,6 +1830,9 @@ def parser():
|
||||
download.add_argument("--model-dir", required=True)
|
||||
download.add_argument("--status", required=True)
|
||||
download.add_argument("--allow-pattern", action="append", default=[])
|
||||
download.add_argument("--required-pattern", action="append", default=[])
|
||||
download.add_argument("--r2-directory", default="")
|
||||
download.add_argument("--r2-file", action="append", default=[])
|
||||
download.add_argument("--stage", type=int, default=None)
|
||||
download.add_argument("--stages", type=int, default=None)
|
||||
download.add_argument("--stage-label", default=None)
|
||||
|
||||
@@ -138,6 +138,15 @@ class AddonTest(unittest.TestCase):
|
||||
self.addon.BuildDepthPlane.bl_options,
|
||||
{"INTERNAL"},
|
||||
)
|
||||
self.assertIn(
|
||||
"snapform_color_frames_dir",
|
||||
self.addon.BuildDepthPlane.__annotations__,
|
||||
)
|
||||
self.assertIn(
|
||||
"snapform_depth_frames_dir",
|
||||
self.addon.BuildDepthPlane.__annotations__,
|
||||
)
|
||||
self.assertIn("execute", self.addon.BuildDepthPlane.__dict__)
|
||||
self.assertFalse(hasattr(self.addon, "ImportDepthPlane"))
|
||||
|
||||
def test_registers_perspective_mesh_operators(self):
|
||||
@@ -553,7 +562,7 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
def test_original_depth_background_starts_independent_job(self):
|
||||
operator = self.addon.ConvertImageToDepthPlane()
|
||||
operator.snapform_depth_source = "ORIGINAL"
|
||||
operator.snapform_model = "ORIGINAL"
|
||||
operator.snapform_unshaded = False
|
||||
operator.snapform_remove_background = True
|
||||
operator.snapform_keep_original = True
|
||||
@@ -586,7 +595,7 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
def test_original_depth_animation_extracts_frames_and_background(self):
|
||||
operator = self.addon.ConvertImageToDepthPlane()
|
||||
operator.snapform_depth_source = "ORIGINAL"
|
||||
operator.snapform_model = "ORIGINAL"
|
||||
operator.snapform_unshaded = False
|
||||
operator.snapform_remove_background = True
|
||||
operator.snapform_keep_original = True
|
||||
@@ -630,7 +639,7 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
def test_original_depth_without_background_uses_frame_extraction(self):
|
||||
operator = self.addon.ConvertImageToDepthPlane()
|
||||
operator.snapform_depth_source = "ORIGINAL"
|
||||
operator.snapform_model = "ORIGINAL"
|
||||
operator.snapform_unshaded = False
|
||||
operator.snapform_remove_background = False
|
||||
operator.snapform_keep_original = False
|
||||
@@ -664,10 +673,7 @@ class AddonTest(unittest.TestCase):
|
||||
def test_conversion_dialog_exposes_requested_settings(self):
|
||||
operator = self.addon.ConvertImageToDepthPlane
|
||||
|
||||
self.assertNotIn(
|
||||
"default",
|
||||
operator.__annotations__["snapform_model"],
|
||||
)
|
||||
self.assertEqual(operator.__annotations__["snapform_model"]["default"], 1)
|
||||
self.assertTrue(operator.__annotations__["snapform_use_ray_pose"]["default"])
|
||||
self.assertEqual(
|
||||
operator.__annotations__["snapform_process_res_factor"]["default"],
|
||||
@@ -693,23 +699,16 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
set(operator.__annotations__),
|
||||
{
|
||||
"snapform_depth_source",
|
||||
"snapform_unshaded",
|
||||
"snapform_model",
|
||||
"snapform_use_ray_pose",
|
||||
"snapform_process_res_factor",
|
||||
"snapform_moge_resolution_level",
|
||||
"snapform_remove_background",
|
||||
"snapform_keep_original",
|
||||
},
|
||||
)
|
||||
depth_sources = operator.__annotations__["snapform_depth_source"]
|
||||
self.assertEqual(depth_sources["default"], "ORIGINAL")
|
||||
self.assertEqual(
|
||||
[item[0] for item in depth_sources["items"]],
|
||||
["ESTIMATE", "ORIGINAL"],
|
||||
)
|
||||
self.assertEqual(depth_sources["items"][0][1], "AI Generated")
|
||||
self.assertEqual(depth_sources["name"], "Depth from")
|
||||
self.assertEqual(operator.__annotations__["snapform_model"]["name"], "Model")
|
||||
self.assertEqual(
|
||||
operator.__annotations__["snapform_unshaded"]["name"],
|
||||
"Unshaded",
|
||||
@@ -724,11 +723,12 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertEqual(
|
||||
[item[0] for item in model_items],
|
||||
[
|
||||
"MOGE2_VITS_NORMAL",
|
||||
"MOGE2_VITB_NORMAL",
|
||||
"MOGE2_VITL_NORMAL",
|
||||
"SMALL",
|
||||
"BASE",
|
||||
"LARGE_1_1",
|
||||
"GIANT_1_1",
|
||||
"NESTED_GIANT_LARGE_1_1",
|
||||
"ORIGINAL",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -758,7 +758,7 @@ class AddonTest(unittest.TestCase):
|
||||
result = operator.invoke(context, None)
|
||||
|
||||
self.assertIs(result, operator)
|
||||
self.assertEqual(operator.snapform_model, "GIANT_1_1")
|
||||
self.assertNotEqual(getattr(operator, "snapform_model", ""), "GIANT_1_1")
|
||||
|
||||
def test_remove_background_defaults_to_empty_image_frames(self):
|
||||
source_object = SimpleNamespace(
|
||||
@@ -808,7 +808,6 @@ class AddonTest(unittest.TestCase):
|
||||
)
|
||||
context = SimpleNamespace(object=source_object, scene=scene)
|
||||
operator = self.addon.ConvertImageToDepthPlane()
|
||||
operator.snapform_depth_source = "ESTIMATE"
|
||||
operator.snapform_model = "BASE"
|
||||
operator.snapform_use_ray_pose = True
|
||||
operator.snapform_process_res_factor = 50.0
|
||||
@@ -839,7 +838,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertTrue(calls[0]["snapform_remove_background"])
|
||||
self.assertNotIn("snapform_background_depth_distance", calls[0])
|
||||
|
||||
def test_conversion_dialog_only_lists_installed_models(self):
|
||||
def test_conversion_dialog_marks_missing_models(self):
|
||||
items_callback = self.addon.ConvertImageToDepthPlane.__annotations__[
|
||||
"snapform_model"
|
||||
]["items"]
|
||||
@@ -850,7 +849,20 @@ class AddonTest(unittest.TestCase):
|
||||
):
|
||||
items = items_callback(None, None)
|
||||
|
||||
self.assertEqual([item[0] for item in items], ["SMALL", "BASE"])
|
||||
self.assertEqual(
|
||||
[item[0] for item in items],
|
||||
[
|
||||
"MOGE2_VITS_NORMAL",
|
||||
"MOGE2_VITB_NORMAL",
|
||||
"MOGE2_VITL_NORMAL",
|
||||
"SMALL",
|
||||
"BASE",
|
||||
"ORIGINAL",
|
||||
],
|
||||
)
|
||||
labels = {item[0]: item[1] for item in items}
|
||||
self.assertEqual(labels["BASE"], "DA3 Base")
|
||||
self.assertEqual(labels["MOGE2_VITB_NORMAL"], "MoGe-2 ViT-B Normal (Missing)")
|
||||
|
||||
def test_update_status_formats_stage_progress(self):
|
||||
operator = self.addon.RunJob()
|
||||
@@ -936,7 +948,6 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
def test_packed_image_is_exported_without_unpacking(self):
|
||||
operator = self.addon.ConvertImageToDepthPlane()
|
||||
operator.snapform_depth_source = "ESTIMATE"
|
||||
operator.snapform_model = "GIANT_1_1"
|
||||
operator.snapform_use_ray_pose = True
|
||||
operator.snapform_process_res_factor = 50.0
|
||||
@@ -1869,7 +1880,7 @@ class AddonTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(
|
||||
identifiers,
|
||||
["GIANT_1_1", "NESTED_GIANT_LARGE_1_1"],
|
||||
[],
|
||||
)
|
||||
|
||||
def test_device_enums_list_only_supported_devices(self):
|
||||
@@ -1966,7 +1977,7 @@ class AddonTest(unittest.TestCase):
|
||||
"model_ready",
|
||||
return_value=False,
|
||||
):
|
||||
self.assertFalse(
|
||||
self.assertTrue(
|
||||
self.addon.ConvertImageToDepthPlane.poll(context)
|
||||
)
|
||||
|
||||
@@ -1977,6 +1988,7 @@ class AddonTest(unittest.TestCase):
|
||||
)
|
||||
panels = (
|
||||
self.addon.Da3OutputDebugPanel,
|
||||
self.addon.Moge2ApiDebugPanel,
|
||||
self.addon.Ben2RemovalDebugPanel,
|
||||
)
|
||||
for panel in panels:
|
||||
@@ -1984,7 +1996,7 @@ class AddonTest(unittest.TestCase):
|
||||
self.assertIn("DEFAULT_CLOSED", panel.bl_options)
|
||||
self.assertEqual(
|
||||
[panel.bl_label for panel in panels],
|
||||
["DA3 Output Test", "BEN2 Removal Test"],
|
||||
["DA3 Output Test", "MoGe-2 API Test", "BEN2 Removal Test"],
|
||||
)
|
||||
|
||||
def test_service_panel_exposes_shared_service_state_and_controls(self):
|
||||
|
||||
@@ -168,7 +168,7 @@ class DownloadProgressTest(unittest.TestCase):
|
||||
|
||||
self.assertFalse(status_path.exists())
|
||||
|
||||
def test_download_endpoints_keeps_direct_endpoint(self):
|
||||
def test_download_endpoints_uses_direct_endpoint(self):
|
||||
with patch.dict(os.environ, {"HF_ENDPOINT": ""}):
|
||||
endpoints = self.worker.download_endpoints()
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -32,6 +32,8 @@ class InstallEnvTest(unittest.TestCase):
|
||||
vendor.mkdir()
|
||||
(vendor / "depth-anything-3.tar.gz").write_bytes(b"da3")
|
||||
(vendor / "ben2.tar.gz").write_bytes(b"ben2")
|
||||
(vendor / "utils3d.tar.gz").write_bytes(b"utils3d")
|
||||
(vendor / "moge.tar.gz").write_bytes(b"moge")
|
||||
return vendor
|
||||
|
||||
def test_installs_da3_from_archives_without_gsplat(self):
|
||||
@@ -155,19 +157,13 @@ class InstallEnvTest(unittest.TestCase):
|
||||
if any(
|
||||
repository in command
|
||||
for repository in (
|
||||
"depth-anything/DA3-SMALL",
|
||||
"depth-anything/DA3-BASE",
|
||||
"Ruicheng/moge-2-vitb-normal",
|
||||
"PramaLLC/BEN2",
|
||||
)
|
||||
)
|
||||
]
|
||||
self.assertEqual(len(default_downloads), 2)
|
||||
self.assertFalse(
|
||||
any(
|
||||
"depth-anything/DA3-BASE" in map(str, command)
|
||||
for command in default_downloads
|
||||
)
|
||||
)
|
||||
self.assertTrue(any("Ruicheng/moge-2-vitb-normal" in map(str, command) for command in default_downloads))
|
||||
ben2_download = next(
|
||||
command
|
||||
for command in default_downloads
|
||||
@@ -663,6 +659,32 @@ class InstallEnvTest(unittest.TestCase):
|
||||
env["PIP_INDEX_URL"],
|
||||
"https://pypi.tuna.tsinghua.edu.cn/simple",
|
||||
)
|
||||
self.assertEqual(
|
||||
env["PYTHONIOENCODING"],
|
||||
"utf-8:backslashreplace",
|
||||
)
|
||||
self.assertEqual(env["PYTHONUTF8"], "1")
|
||||
|
||||
def test_standard_streams_are_reconfigured_for_unicode_logs(self):
|
||||
bootstrap = load_install_env()
|
||||
stdout = Mock()
|
||||
stderr = Mock()
|
||||
|
||||
with patch.object(bootstrap.sys, "stdout", stdout), patch.object(
|
||||
bootstrap.sys,
|
||||
"stderr",
|
||||
stderr,
|
||||
):
|
||||
bootstrap.configure_standard_streams()
|
||||
|
||||
stdout.reconfigure.assert_called_once_with(
|
||||
encoding="utf-8",
|
||||
errors="backslashreplace",
|
||||
)
|
||||
stderr.reconfigure.assert_called_once_with(
|
||||
encoding="utf-8",
|
||||
errors="backslashreplace",
|
||||
)
|
||||
|
||||
def test_ready_json_records_installed_packages(self):
|
||||
bootstrap = load_install_env()
|
||||
@@ -706,6 +728,10 @@ class InstallEnvTest(unittest.TestCase):
|
||||
["numpy==1.26.4", "torch==2.9.1+cu128"],
|
||||
)
|
||||
self.assertEqual(ready["devices"], ["cuda", "cpu"])
|
||||
self.assertEqual(
|
||||
ready["manifest_revision"],
|
||||
bootstrap.MANIFEST_REVISION,
|
||||
)
|
||||
|
||||
def test_broken_env_is_cleaned_before_reinstall(self):
|
||||
bootstrap = load_install_env()
|
||||
@@ -752,7 +778,9 @@ class InstallEnvTest(unittest.TestCase):
|
||||
(venv_dir / "Scripts").mkdir()
|
||||
(venv_dir / "Scripts" / "python.exe").write_text("python")
|
||||
(root / "manifest.json").write_text(
|
||||
json.dumps({}),
|
||||
json.dumps(
|
||||
{"manifest_revision": bootstrap.MANIFEST_REVISION}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(venv_dir / "marker.txt").write_text("keep")
|
||||
@@ -786,7 +814,7 @@ class InstallEnvTest(unittest.TestCase):
|
||||
|
||||
def fake_run(command, environment=None):
|
||||
commands.append(command)
|
||||
if any("DA3-SMALL" in str(value) for value in command):
|
||||
if any("moge-2-vitb-normal" in str(value) for value in command):
|
||||
raise subprocess.CalledProcessError(1, command)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
@@ -880,6 +908,8 @@ class InstallEnvTest(unittest.TestCase):
|
||||
bundled.mkdir()
|
||||
(bundled / "depth-anything-3.tar.gz").write_bytes(b"da3")
|
||||
(bundled / "ben2.tar.gz").write_bytes(b"ben2")
|
||||
(bundled / "utils3d.tar.gz").write_bytes(b"utils3d")
|
||||
(bundled / "moge.tar.gz").write_bytes(b"moge")
|
||||
target, archive_name = bootstrap.uv_download_target()
|
||||
archive = bundled / f"uv-{target}.{archive_name}"
|
||||
if archive_name.endswith("zip"):
|
||||
@@ -1025,6 +1055,8 @@ class InstallEnvTest(unittest.TestCase):
|
||||
bundled.mkdir()
|
||||
(bundled / "depth-anything-3.tar.gz").write_bytes(b"da3")
|
||||
(bundled / "ben2.tar.gz").write_bytes(b"ben2")
|
||||
(bundled / "utils3d.tar.gz").write_bytes(b"utils3d")
|
||||
(bundled / "moge.tar.gz").write_bytes(b"moge")
|
||||
vendor = root / "vendor"
|
||||
|
||||
bootstrap.copy_vendor_inputs(bundled, vendor)
|
||||
@@ -1046,6 +1078,8 @@ class InstallEnvTest(unittest.TestCase):
|
||||
bundled.mkdir()
|
||||
(bundled / "depth-anything-3.tar.gz").write_bytes(b"new")
|
||||
(bundled / "ben2.tar.gz").write_bytes(b"ben2")
|
||||
(bundled / "utils3d.tar.gz").write_bytes(b"utils3d")
|
||||
(bundled / "moge.tar.gz").write_bytes(b"moge")
|
||||
vendor = root / "vendor"
|
||||
vendor.mkdir()
|
||||
(vendor / "depth-anything-3.tar.gz").write_bytes(b"old")
|
||||
@@ -1086,6 +1120,8 @@ class InstallEnvTest(unittest.TestCase):
|
||||
vendor.mkdir()
|
||||
(vendor / "depth-anything-3.tar.gz").write_bytes(b"da3")
|
||||
(vendor / "ben2.tar.gz").write_bytes(b"ben2")
|
||||
(vendor / "utils3d.tar.gz").write_bytes(b"utils3d")
|
||||
(vendor / "moge.tar.gz").write_bytes(b"moge")
|
||||
uv_path = root / "uv"
|
||||
uv_path.write_bytes(b"uv")
|
||||
args = SimpleNamespace(
|
||||
@@ -1126,6 +1162,8 @@ class InstallEnvTest(unittest.TestCase):
|
||||
vendor.mkdir()
|
||||
(vendor / "depth-anything-3.tar.gz").write_bytes(b"da3")
|
||||
(vendor / "ben2.tar.gz").write_bytes(b"ben2")
|
||||
(vendor / "utils3d.tar.gz").write_bytes(b"utils3d")
|
||||
(vendor / "moge.tar.gz").write_bytes(b"moge")
|
||||
uv_path = root / "uv"
|
||||
uv_path.write_bytes(b"uv")
|
||||
args = SimpleNamespace(
|
||||
@@ -1150,9 +1188,15 @@ class InstallEnvTest(unittest.TestCase):
|
||||
for label, command, _environment, kind in stages
|
||||
if kind == "download"
|
||||
}
|
||||
self.assertIn("da3_small", " ".join(map(str, downloads["DA3 Small"])))
|
||||
self.assertIn(
|
||||
"moge2_vitb_normal",
|
||||
" ".join(map(str, downloads["MoGe-2 ViT-B Normal"])),
|
||||
)
|
||||
self.assertIn("ben2_base", " ".join(map(str, downloads["BEN2 Base"])))
|
||||
self.assertNotIn("DA3 Base", downloads)
|
||||
self.assertEqual(
|
||||
set(downloads),
|
||||
{"MoGe-2 ViT-B Normal", "BEN2 Base"},
|
||||
)
|
||||
|
||||
def test_install_does_not_verify_env(self):
|
||||
bootstrap = load_install_env()
|
||||
|
||||
@@ -34,11 +34,18 @@ class ModelTest(unittest.TestCase):
|
||||
model = self.models.get_model("LARGE_1_1")
|
||||
|
||||
self.assertEqual(model.license_name, "CC BY-NC 4.0")
|
||||
self.assertFalse(model.enabled)
|
||||
|
||||
def test_giant_models_expose_gaussian_head(self):
|
||||
for model_key in ("GIANT_1_1", "NESTED_GIANT_LARGE_1_1"):
|
||||
with self.subTest(model_key=model_key):
|
||||
self.assertTrue(self.models.get_model(model_key).supports_gs)
|
||||
self.assertFalse(self.models.get_model(model_key).enabled)
|
||||
|
||||
def test_only_uploaded_da3_models_are_enabled(self):
|
||||
enabled = [key for key, model in self.models.MODELS.items() if model.enabled]
|
||||
|
||||
self.assertEqual(enabled, ["SMALL", "BASE"])
|
||||
|
||||
def test_unknown_model_is_rejected(self):
|
||||
with self.assertRaisesRegex(ValueError, "Unknown DA3 model"):
|
||||
@@ -72,3 +79,22 @@ class ModelTest(unittest.TestCase):
|
||||
self.models.model_directory_name("BEN2_BASE"),
|
||||
"ben2_base",
|
||||
)
|
||||
|
||||
def test_moge2_uses_single_checkpoint_and_mit_license(self):
|
||||
model = self.models.get_downloadable_model("MOGE2_VITB_NORMAL")
|
||||
|
||||
self.assertEqual(model.repository, "Ruicheng/moge-2-vitb-normal")
|
||||
self.assertEqual(model.license_name, "MIT")
|
||||
self.assertEqual(model.download_patterns, ("model.pt",))
|
||||
self.assertEqual(model.ready_patterns, ("model.pt",))
|
||||
self.assertFalse(model.supports_gs)
|
||||
|
||||
def test_moge2_models_match_uploaded_r2_variants(self):
|
||||
self.assertEqual(
|
||||
list(self.models.MOGE2_MODELS),
|
||||
["MOGE2_VITS_NORMAL", "MOGE2_VITB_NORMAL", "MOGE2_VITL_NORMAL"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[model.r2_directory for model in self.models.MOGE2_MODELS.values()],
|
||||
["MoGe2ViTSNormal", "MoGe2ViTBNormal", "MoGe2ViTLNormal"],
|
||||
)
|
||||
|
||||
@@ -131,6 +131,9 @@ class ServiceTest(unittest.TestCase):
|
||||
state.reserve("other", "second", "reconstruct", "other.json")
|
||||
)
|
||||
|
||||
def test_service_accepts_moge_command(self):
|
||||
self.assertIn("moge", self.service.JOB_COMMANDS)
|
||||
|
||||
def test_cancelling_job_keeps_service_and_model_cache_alive(self):
|
||||
fake_models = SimpleNamespace(
|
||||
status=lambda: {"ben2": "BEN2_BASE", "da3": ""},
|
||||
|
||||
@@ -436,7 +436,13 @@ class WorkerTest(unittest.TestCase):
|
||||
with self.assertRaisesRegex(RuntimeError, "Unsupported input type"):
|
||||
self.worker.prepare_input(input_path, 16, directory)
|
||||
|
||||
def test_download_model_skips_unreachable_endpoint(self):
|
||||
def test_moge_runtime_disables_unsupported_xformers_attention(self):
|
||||
with patch.dict(os.environ, {}, clear=True):
|
||||
self.worker.prepare_moge_runtime()
|
||||
|
||||
self.assertEqual(os.environ["XFORMERS_DISABLED"], "1")
|
||||
|
||||
def test_download_model_uses_actual_request_without_preflight(self):
|
||||
calls = []
|
||||
fake_hugging_face = ModuleType("huggingface_hub")
|
||||
fake_hugging_face.snapshot_download = lambda **options: calls.append(
|
||||
@@ -457,13 +463,6 @@ class WorkerTest(unittest.TestCase):
|
||||
"create_download_progress_class",
|
||||
return_value=object,
|
||||
), patch.dict(os.environ, {"HF_ENDPOINT": ""}), patch.object(
|
||||
self.worker,
|
||||
"hf_endpoint_reachable",
|
||||
return_value=False,
|
||||
), patch.object(
|
||||
self.worker,
|
||||
"download_from_modelscope",
|
||||
) as mocked_modelscope, patch.object(
|
||||
self.worker,
|
||||
"_verify_model_complete",
|
||||
return_value=None,
|
||||
@@ -472,8 +471,7 @@ class WorkerTest(unittest.TestCase):
|
||||
args.status = str(Path(directory) / "status.json")
|
||||
self.worker.download_model(args)
|
||||
|
||||
self.assertEqual(calls, [])
|
||||
self.assertEqual(mocked_modelscope.call_count, 1)
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
def test_download_model_refuses_success_when_files_missing(self):
|
||||
fake_hugging_face = ModuleType("huggingface_hub")
|
||||
@@ -492,10 +490,7 @@ class WorkerTest(unittest.TestCase):
|
||||
self.worker,
|
||||
"create_download_progress_class",
|
||||
return_value=object,
|
||||
), patch.dict(os.environ, {"HF_ENDPOINT": ""}), patch.object(
|
||||
self.worker,
|
||||
"download_from_modelscope",
|
||||
):
|
||||
), patch.dict(os.environ, {"HF_ENDPOINT": ""}):
|
||||
args.model_dir = str(Path(directory) / "model")
|
||||
args.status = str(Path(directory) / "status.json")
|
||||
with self.assertRaisesRegex(
|
||||
@@ -527,39 +522,35 @@ class WorkerTest(unittest.TestCase):
|
||||
return_value=object,
|
||||
), patch.dict(os.environ, {"HF_ENDPOINT": ""}), patch.object(
|
||||
self.worker,
|
||||
"download_from_modelscope",
|
||||
"download_from_r2",
|
||||
side_effect=OSError("mirror down"),
|
||||
):
|
||||
args.r2_directory = "DA3Small"
|
||||
args.r2_file = ["config.json|100|abc"]
|
||||
args.model_dir = str(Path(directory) / "model")
|
||||
args.status = str(Path(directory) / "status.json")
|
||||
with self.assertRaisesRegex(RuntimeError, "modelscope.cn"):
|
||||
with self.assertRaisesRegex(RuntimeError, "models.omoolab.xyz"):
|
||||
self.worker.download_model(args)
|
||||
|
||||
def test_download_from_modelscope_downloads_selected_files(self):
|
||||
def test_download_from_r2_downloads_declared_files(self):
|
||||
args = SimpleNamespace(
|
||||
repository="depth-anything/DA3-SMALL",
|
||||
model_dir="model",
|
||||
status="status.json",
|
||||
allow_pattern=["config.json"],
|
||||
r2_directory="DA3Small",
|
||||
r2_file=["config.json|100|abc"],
|
||||
)
|
||||
downloaded = []
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory, patch.object(
|
||||
self.worker,
|
||||
"modelscope_file_list",
|
||||
return_value=[
|
||||
("config.json", 100),
|
||||
("model.safetensors", 200),
|
||||
(".gitattributes", 10),
|
||||
],
|
||||
), patch.object(
|
||||
self.worker,
|
||||
"_download_modelscope_file",
|
||||
"_download_r2_file",
|
||||
side_effect=lambda *args, **kwargs: downloaded.append(args[1]),
|
||||
), patch.object(self.worker, "write_status"):
|
||||
args.model_dir = str(Path(directory) / "model")
|
||||
args.status = str(Path(directory) / "status.json")
|
||||
self.worker.download_from_modelscope(args, args.model_dir)
|
||||
self.worker.download_from_r2(args, args.model_dir)
|
||||
|
||||
self.assertEqual(downloaded, ["config.json"])
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import bpy
|
||||
|
||||
GROUP_NAME = "O DA3 Surface"
|
||||
DEPTH_PLANE_GROUP_NAME = "O DA3 Depth Plane"
|
||||
MOGE_DEPTH_PLANE_GROUP_NAME = "O MoGe2 Depth Plane"
|
||||
MATERIAL_DISPLACEMENT_ATTRIBUTE = "o_material_displacement"
|
||||
BACKGROUND_DEPTH_ATTRIBUTE = "o_background_depth"
|
||||
DEPTH_SCALE_ATTRIBUTE = "o_depth_scale"
|
||||
@@ -689,7 +690,7 @@ def build_depth_plane_group():
|
||||
"INPUT",
|
||||
"NodeSocketInt",
|
||||
)
|
||||
subdivision.default_value = 5
|
||||
subdivision.default_value = 6
|
||||
subdivision.min_value = 0
|
||||
subdivision.max_value = 10
|
||||
displacement_strength = new_socket(
|
||||
@@ -708,7 +709,7 @@ def build_depth_plane_group():
|
||||
"INPUT",
|
||||
"NodeSocketFloat",
|
||||
)
|
||||
background_depth.default_value = -1.0
|
||||
background_depth.default_value = 1.0
|
||||
background_depth.min_value = -10000.0
|
||||
background_depth.max_value = 10000.0
|
||||
background_depth.subtype = "DISTANCE"
|
||||
@@ -786,6 +787,14 @@ def build_depth_plane_group():
|
||||
(350.0, 250.0),
|
||||
{"Background Depth"},
|
||||
)
|
||||
background_depth_value = nodes.new("ShaderNodeMath")
|
||||
background_depth_value.operation = "MULTIPLY"
|
||||
background_depth_value.inputs[1].default_value = -1.0
|
||||
background_depth_value.location = (550.0, 250.0)
|
||||
links.new(
|
||||
background_input.outputs["Background Depth"],
|
||||
background_depth_value.inputs[0],
|
||||
)
|
||||
displacement_input = new_group_input(
|
||||
nodes,
|
||||
(950.0, -150.0),
|
||||
@@ -906,7 +915,7 @@ def build_depth_plane_group():
|
||||
clamp_foreground.location = (600.0, 650.0)
|
||||
links.new(depth_texture.outputs["Color"], clamp_foreground.inputs[0])
|
||||
links.new(
|
||||
background_input.outputs["Background Depth"],
|
||||
background_depth_value.outputs[0],
|
||||
clamp_foreground.inputs[1],
|
||||
)
|
||||
foreground_mask = new_compare(
|
||||
@@ -925,7 +934,7 @@ def build_depth_plane_group():
|
||||
masked_depth.location = (800.0, 650.0)
|
||||
links.new(foreground_mask.outputs["Result"], masked_depth.inputs[0])
|
||||
links.new(
|
||||
background_input.outputs["Background Depth"],
|
||||
background_depth_value.outputs[0],
|
||||
masked_depth.inputs[2],
|
||||
)
|
||||
links.new(clamp_foreground.outputs[0], masked_depth.inputs[3])
|
||||
@@ -989,7 +998,7 @@ def build_depth_plane_group():
|
||||
(
|
||||
BACKGROUND_DEPTH_ATTRIBUTE,
|
||||
"FLOAT",
|
||||
attributes_input.outputs["Background Depth"],
|
||||
background_depth_value.outputs[0],
|
||||
),
|
||||
(
|
||||
DEPTH_SCALE_ATTRIBUTE,
|
||||
@@ -1020,11 +1029,225 @@ def build_depth_plane_group():
|
||||
return group
|
||||
|
||||
|
||||
def build_moge_depth_plane_group():
|
||||
group = bpy.data.node_groups.new(
|
||||
MOGE_DEPTH_PLANE_GROUP_NAME,
|
||||
"GeometryNodeTree",
|
||||
)
|
||||
group.is_modifier = True
|
||||
interface = group.interface
|
||||
new_socket(interface, "Geometry", "INPUT", "NodeSocketGeometry")
|
||||
displacement = new_socket(
|
||||
interface, "Displacement", "INPUT", "NodeSocketMenu"
|
||||
)
|
||||
subdivision = new_socket(
|
||||
interface, "Subdivision", "INPUT", "NodeSocketInt"
|
||||
)
|
||||
subdivision.default_value = 6
|
||||
subdivision.min_value = 0
|
||||
subdivision.max_value = 10
|
||||
depth_scale = new_socket(
|
||||
interface, "Depth Scale", "INPUT", "NodeSocketFloat"
|
||||
)
|
||||
depth_scale.default_value = 1.0
|
||||
depth_scale.structure_type = "SINGLE"
|
||||
background_depth = new_socket(
|
||||
interface, "Background Depth", "INPUT", "NodeSocketFloat"
|
||||
)
|
||||
background_depth.default_value = 1.0
|
||||
background_depth.min_value = -10000.0
|
||||
background_depth.max_value = 10000.0
|
||||
background_depth.subtype = "DISTANCE"
|
||||
background_depth.structure_type = "SINGLE"
|
||||
data_panel = interface.new_panel(name="Data", default_closed=True)
|
||||
depth_image = new_socket(
|
||||
interface, "Depth Image", "INPUT", "NodeSocketImage", data_panel
|
||||
)
|
||||
material = new_socket(
|
||||
interface, "Material", "INPUT", "NodeSocketMaterial", data_panel
|
||||
)
|
||||
for socket in (depth_image, material):
|
||||
socket.hide_in_modifier = True
|
||||
frame_sockets = []
|
||||
for name, socket_type, default in (
|
||||
("Frame", "NodeSocketInt", 1),
|
||||
("Frame Start", "NodeSocketInt", 1),
|
||||
("Frames", "NodeSocketInt", 1),
|
||||
("Cyclic", "NodeSocketBool", False),
|
||||
):
|
||||
socket = new_socket(interface, name, "INPUT", socket_type, data_panel)
|
||||
socket.default_value = default
|
||||
socket.hide_in_modifier = True
|
||||
frame_sockets.append(socket)
|
||||
new_socket(interface, "Geometry", "OUTPUT", "NodeSocketGeometry")
|
||||
|
||||
nodes = group.nodes
|
||||
links = group.links
|
||||
geometry_input = new_group_input(nodes, (-1600.0, 650.0), {"Geometry"})
|
||||
controls = new_group_input(
|
||||
nodes,
|
||||
(-1600.0, 150.0),
|
||||
{"Subdivision", "Displacement", "Depth Scale", "Background Depth"},
|
||||
)
|
||||
background_depth_value = nodes.new("ShaderNodeMath")
|
||||
background_depth_value.operation = "MULTIPLY"
|
||||
background_depth_value.inputs[1].default_value = -1.0
|
||||
background_depth_value.location = (-1350.0, -150.0)
|
||||
links.new(
|
||||
controls.outputs["Background Depth"],
|
||||
background_depth_value.inputs[0],
|
||||
)
|
||||
images = new_group_input(
|
||||
nodes, (-500.0, 1100.0), {"Depth Image"}
|
||||
)
|
||||
frames = new_group_input(
|
||||
nodes,
|
||||
(-500.0, 1500.0),
|
||||
{"Frame", "Frame Start", "Frames", "Cyclic"},
|
||||
)
|
||||
material_input = new_group_input(nodes, (900.0, 200.0), {"Material"})
|
||||
output = nodes.new("NodeGroupOutput")
|
||||
output.location = (1700.0, 650.0)
|
||||
|
||||
bounds = nodes.new("GeometryNodeBoundBox")
|
||||
bounds.location = (-1400.0, 650.0)
|
||||
links.new(geometry_input.outputs["Geometry"], bounds.inputs["Geometry"])
|
||||
dimensions = nodes.new("ShaderNodeVectorMath")
|
||||
dimensions.operation = "SUBTRACT"
|
||||
dimensions.location = (-1200.0, 650.0)
|
||||
links.new(bounds.outputs["Max"], dimensions.inputs[0])
|
||||
links.new(bounds.outputs["Min"], dimensions.inputs[1])
|
||||
separate = nodes.new("ShaderNodeSeparateXYZ")
|
||||
separate.location = (-1000.0, 650.0)
|
||||
links.new(dimensions.outputs["Vector"], separate.inputs[0])
|
||||
segments = nodes.new("ShaderNodeMath")
|
||||
segments.operation = "POWER"
|
||||
segments.inputs[0].default_value = 2.0
|
||||
segments.location = (-1200.0, 100.0)
|
||||
links.new(controls.outputs["Subdivision"], segments.inputs[1])
|
||||
vertices = nodes.new("ShaderNodeMath")
|
||||
vertices.operation = "ADD"
|
||||
vertices.inputs[1].default_value = 1.0
|
||||
vertices.location = (-1000.0, 100.0)
|
||||
links.new(segments.outputs[0], vertices.inputs[0])
|
||||
grid = nodes.new("GeometryNodeMeshGrid")
|
||||
grid.location = (-750.0, 650.0)
|
||||
links.new(separate.outputs["X"], grid.inputs["Size X"])
|
||||
links.new(separate.outputs["Y"], grid.inputs["Size Y"])
|
||||
links.new(vertices.outputs[0], grid.inputs["Vertices X"])
|
||||
links.new(vertices.outputs[0], grid.inputs["Vertices Y"])
|
||||
store_uv = nodes.new("GeometryNodeStoreNamedAttribute")
|
||||
store_uv.data_type = "FLOAT2"
|
||||
store_uv.domain = "CORNER"
|
||||
store_uv.inputs["Name"].default_value = "UVMap"
|
||||
store_uv.location = (-500.0, 650.0)
|
||||
links.new(grid.outputs["Mesh"], store_uv.inputs["Geometry"])
|
||||
links.new(grid.outputs["UV Map"], store_uv.inputs["Value"])
|
||||
|
||||
center = nodes.new("ShaderNodeVectorMath")
|
||||
center.operation = "ADD"
|
||||
center.location = (-1000.0, 900.0)
|
||||
links.new(bounds.outputs["Min"], center.inputs[0])
|
||||
links.new(bounds.outputs["Max"], center.inputs[1])
|
||||
half = nodes.new("ShaderNodeVectorMath")
|
||||
half.operation = "SCALE"
|
||||
half.inputs[3].default_value = 0.5
|
||||
half.location = (-750.0, 900.0)
|
||||
links.new(center.outputs["Vector"], half.inputs[0])
|
||||
transform = nodes.new("GeometryNodeTransform")
|
||||
transform.location = (-250.0, 650.0)
|
||||
links.new(store_uv.outputs["Geometry"], transform.inputs["Geometry"])
|
||||
links.new(half.outputs["Vector"], transform.inputs["Translation"])
|
||||
|
||||
depth_texture = nodes.new("GeometryNodeImageTexture")
|
||||
depth_texture.interpolation = "Linear"
|
||||
depth_texture.extension = "EXTEND"
|
||||
depth_texture.location = (-250.0, 1100.0)
|
||||
links.new(images.outputs["Depth Image"], depth_texture.inputs["Image"])
|
||||
links.new(grid.outputs["UV Map"], depth_texture.inputs["Vector"])
|
||||
frame = build_sequence_frame(
|
||||
nodes, links, frames.outputs["Frame"], frames, 1800.0
|
||||
)
|
||||
links.new(frame, depth_texture.inputs["Frame"])
|
||||
|
||||
clamp_depth = nodes.new("ShaderNodeMath")
|
||||
clamp_depth.operation = "MAXIMUM"
|
||||
clamp_depth.location = (0.0, 750.0)
|
||||
links.new(depth_texture.outputs["Color"], clamp_depth.inputs[0])
|
||||
links.new(background_depth_value.outputs[0], clamp_depth.inputs[1])
|
||||
displacement_menu = nodes.new("GeometryNodeMenuSwitch")
|
||||
displacement_menu.data_type = "FLOAT"
|
||||
displacement_menu.enum_items.clear()
|
||||
displacement_menu.enum_items.new("Geometry")
|
||||
displacement_menu.enum_items.new("Material")
|
||||
displacement_menu.inputs["Geometry"].default_value = 1.0
|
||||
displacement_menu.inputs["Material"].default_value = 0.0
|
||||
displacement_menu.location = (0.0, 300.0)
|
||||
links.new(controls.outputs["Displacement"], displacement_menu.inputs["Menu"])
|
||||
displacement.default_value = "Geometry"
|
||||
material_mode = nodes.new("ShaderNodeMath")
|
||||
material_mode.operation = "SUBTRACT"
|
||||
material_mode.inputs[0].default_value = 1.0
|
||||
material_mode.location = (250.0, 250.0)
|
||||
links.new(displacement_menu.outputs[0], material_mode.inputs[1])
|
||||
geometry_strength = nodes.new("ShaderNodeMath")
|
||||
geometry_strength.operation = "MULTIPLY"
|
||||
geometry_strength.location = (250.0, 500.0)
|
||||
links.new(displacement_menu.outputs[0], geometry_strength.inputs[0])
|
||||
links.new(controls.outputs["Depth Scale"], geometry_strength.inputs[1])
|
||||
scaled_depth = nodes.new("ShaderNodeMath")
|
||||
scaled_depth.operation = "MULTIPLY"
|
||||
scaled_depth.location = (250.0, 750.0)
|
||||
links.new(clamp_depth.outputs[0], scaled_depth.inputs[0])
|
||||
links.new(geometry_strength.outputs[0], scaled_depth.inputs[1])
|
||||
normal = nodes.new("GeometryNodeInputNormal")
|
||||
normal.location = (0.0, 500.0)
|
||||
offset = nodes.new("ShaderNodeVectorMath")
|
||||
offset.operation = "SCALE"
|
||||
offset.location = (250.0, 700.0)
|
||||
links.new(normal.outputs["Normal"], offset.inputs[0])
|
||||
links.new(scaled_depth.outputs[0], offset.inputs[3])
|
||||
set_position = nodes.new("GeometryNodeSetPosition")
|
||||
set_position.location = (500.0, 650.0)
|
||||
links.new(transform.outputs["Geometry"], set_position.inputs["Geometry"])
|
||||
links.new(offset.outputs["Vector"], set_position.inputs["Offset"])
|
||||
|
||||
set_material = nodes.new("GeometryNodeSetMaterial")
|
||||
set_material.location = (750.0, 650.0)
|
||||
links.new(set_position.outputs["Geometry"], set_material.inputs["Geometry"])
|
||||
links.new(material_input.outputs["Material"], set_material.inputs["Material"])
|
||||
smooth = nodes.new("GeometryNodeSetShadeSmooth")
|
||||
smooth.domain = "FACE"
|
||||
smooth.inputs["Shade Smooth"].default_value = True
|
||||
smooth.location = (1000.0, 650.0)
|
||||
links.new(set_material.outputs["Geometry"], smooth.inputs["Geometry"])
|
||||
geometry = smooth.outputs["Geometry"]
|
||||
for index, (name, data_type, value) in enumerate((
|
||||
(MATERIAL_DISPLACEMENT_ATTRIBUTE, "BOOLEAN", material_mode.outputs[0]),
|
||||
(BACKGROUND_DEPTH_ATTRIBUTE, "FLOAT", background_depth_value.outputs[0]),
|
||||
(DEPTH_SCALE_ATTRIBUTE, "FLOAT", controls.outputs["Depth Scale"]),
|
||||
)):
|
||||
store = nodes.new("GeometryNodeStoreNamedAttribute")
|
||||
store.data_type = data_type
|
||||
store.domain = "POINT"
|
||||
store.inputs["Name"].default_value = name
|
||||
store.location = (1200.0 + index * 200.0, 650.0)
|
||||
links.new(geometry, store.inputs["Geometry"])
|
||||
links.new(value, store.inputs["Value"])
|
||||
geometry = store.outputs["Geometry"]
|
||||
links.new(geometry, output.inputs["Geometry"])
|
||||
group.color_tag = "GEOMETRY"
|
||||
group.description = "Build a MoGe-2 depth plane with geometry or material displacement."
|
||||
group.use_fake_user = True
|
||||
return group
|
||||
|
||||
|
||||
def main():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.context.preferences.filepaths.save_version = 0
|
||||
build_group()
|
||||
build_depth_plane_group()
|
||||
build_moge_depth_plane_group()
|
||||
OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(OUTPUT_PATH), check_existing=False)
|
||||
|
||||
|
||||
@@ -29,7 +29,15 @@ def main():
|
||||
)
|
||||
assert background_depth.subtype == "DISTANCE"
|
||||
assert background_depth.structure_type == "SINGLE"
|
||||
assert background_depth.default_value == -1.0
|
||||
assert background_depth.default_value == 1.0
|
||||
subdivision = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Subdivision"
|
||||
)
|
||||
assert subdivision.default_value == 6
|
||||
frame_input = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
|
||||
79
tools/validate_moge_depth_plane.py
Normal file
79
tools/validate_moge_depth_plane.py
Normal file
@@ -0,0 +1,79 @@
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parents[1]
|
||||
sys.path.insert(0, str(PROJECT_ROOT / "src"))
|
||||
|
||||
from snapform.image_depth import (
|
||||
MOGE_DEPTH_PLANE_NODE_GROUP_NAME,
|
||||
MOGE_MATERIAL_NODE_GROUP_NAME,
|
||||
build_moge2_depth_plane,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("MoGe Test", width=64, height=48, alpha=True)
|
||||
source = bpy.data.objects.new("MoGe Test", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
bpy.context.collection.objects.link(source)
|
||||
depth = np.linspace(1.0, 2.0, 48 * 64, dtype=np.float32).reshape(1, 48, 64)
|
||||
normal = np.zeros((1, 48, 64, 3), dtype=np.float32)
|
||||
normal[..., 2] = -1.0
|
||||
mask = np.ones((1, 48, 64), dtype=bool)
|
||||
mask[:, :5] = False
|
||||
intrinsics = np.array(
|
||||
[[[1.0, 0.0, 0.5], [0.0, 1.0, 0.5], [0.0, 0.0, 1.0]]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
path = Path(temporary) / "results.npz"
|
||||
np.savez(
|
||||
path,
|
||||
depth=depth,
|
||||
normal=normal,
|
||||
mask=mask,
|
||||
intrinsics=intrinsics,
|
||||
)
|
||||
plane = build_moge2_depth_plane(
|
||||
bpy.context,
|
||||
source,
|
||||
path,
|
||||
delete_source=True,
|
||||
)
|
||||
assert plane.modifiers[0].node_group.name == MOGE_DEPTH_PLANE_NODE_GROUP_NAME
|
||||
assert MOGE_MATERIAL_NODE_GROUP_NAME in bpy.data.node_groups
|
||||
geometry_group = plane.modifiers[0].node_group
|
||||
inputs = {
|
||||
item.name
|
||||
for item in geometry_group.interface.items_tree
|
||||
if getattr(item, "in_out", None) == "INPUT"
|
||||
}
|
||||
assert "Displacement" in inputs
|
||||
assert "Background Depth" in inputs
|
||||
assert "Mask Image" not in inputs
|
||||
assert "Use Mask" not in inputs
|
||||
sockets = {
|
||||
item.name: item
|
||||
for item in geometry_group.interface.items_tree
|
||||
if getattr(item, "in_out", None) == "INPUT"
|
||||
}
|
||||
assert sockets["Subdivision"].default_value == 6
|
||||
assert sockets["Depth Scale"].structure_type == "SINGLE"
|
||||
assert sockets["Background Depth"].structure_type == "SINGLE"
|
||||
assert sockets["Background Depth"].subtype == "DISTANCE"
|
||||
assert sockets["Background Depth"].default_value == 1.0
|
||||
material_group = bpy.data.node_groups[MOGE_MATERIAL_NODE_GROUP_NAME]
|
||||
assert not any(node.bl_idname == "ShaderNodeNormalMap" for node in material_group.nodes)
|
||||
assert any(node.bl_idname == "ShaderNodeBump" for node in material_group.nodes)
|
||||
print("MoGe-2 depth plane validation passed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -13,6 +13,20 @@ filename = "ben2.tar.gz"
|
||||
url = "https://codeload.github.com/PramaLLC/BEN2/tar.gz/2c99a5da477b5523585bfa5c893888a6e818a8f6"
|
||||
sha256 = "01fd26716f3ff3a3f18ad2336c768af5b759edfc7c38bb3bf733b7c86eee6282"
|
||||
|
||||
[artifacts.moge]
|
||||
revision = "925b8ed835a7a9cdb7578ba15c658a0afc969030"
|
||||
filename = "moge.tar.gz"
|
||||
url = "https://codeload.github.com/microsoft/MoGe/tar.gz/925b8ed835a7a9cdb7578ba15c658a0afc969030"
|
||||
sha256 = "c6d90b2564d643f0f3ae65b5ffe163abaea3b55d194c6e2ba9976d67f552bd26"
|
||||
patches = "moge"
|
||||
|
||||
[artifacts.utils3d]
|
||||
revision = "3fab839f0be9931dac7c8488eb0e1600c236e183"
|
||||
filename = "utils3d.tar.gz"
|
||||
url = "https://codeload.github.com/EasternJournalist/utils3d/tar.gz/3fab839f0be9931dac7c8488eb0e1600c236e183"
|
||||
sha256 = "f1f167b1f68809bea391a66dd0d2ad0e14f7382cbddf087b655181c31a285abb"
|
||||
patches = "utils3d"
|
||||
|
||||
[uv]
|
||||
version = "0.11.3"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user