Refactor and enhance SnapForm functionality
- Introduced MODEL_NUMBERS for better model indexing. - Improved enum_items function to cache results and handle download status. - Updated ActiveJob class to manage temporary input paths and added displacement_via property. - Changed IMAGE_DEPTH_PROCESS_RES_METHOD from "lower_bound_resize" to "upper_bound_resize". - Enhanced RunJob operator to include new properties and improved error messages. - Modified panel layout for better user experience and added runtime installation checks. - Updated SnapFormSettings to reflect new model properties and defaults. - Added temporary input path management in runtime. - Refined worker functions to improve error handling and messaging. - Expanded tests to cover new functionality and ensure backward compatibility. - Fixed packaging script to align with versioning conventions.
This commit is contained in:
58
README.md
58
README.md
@@ -19,8 +19,8 @@
|
|||||||
- 使用一个导出类型下拉菜单和一个 `Export` 按钮
|
- 使用一个导出类型下拉菜单和一个 `Export` 按钮
|
||||||
- 只显示当前格式的专属参数;GLB 与 PLY 设置不会同时出现
|
- 只显示当前格式的专属参数;GLB 与 PLY 设置不会同时出现
|
||||||
- Debug 的模型选项按格式过滤;PLY 只提供两个带 Gaussian head 的 Giant 模型
|
- Debug 的模型选项按格式过滤;PLY 只提供两个带 Gaussian head 的 Giant 模型
|
||||||
- Debug 在模型选项同一行用勾选或警告图标表示下载状态,不显示状态文字、License 和
|
- Debug 与 Convert to Depth Plane 的模型选项直接显示下载状态;未下载模型带
|
||||||
已完成任务的文件计数
|
`(Missing)`,已下载模型保持原名
|
||||||
- 分别调用 DA3 的 `glb`、`gs_ply`、`depth_vis` 和 `mini_npz` exporter
|
- 分别调用 DA3 的 `glb`、`gs_ply`、`depth_vis` 和 `mini_npz` exporter
|
||||||
- Image Empty 右键菜单可直接运行 DA3,根据 `mini_npz` 深度生成面片
|
- Image Empty 右键菜单可直接运行 DA3,根据 `mini_npz` 深度生成面片
|
||||||
- 使用浮点深度贴图、Simple Subdivision、Displace Modifier 和原图 Bump
|
- 使用浮点深度贴图、Simple Subdivision、Displace Modifier 和原图 Bump
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
- Debug 导出完成后只打开对应 job 文件夹,不向当前 Blender 文件添加任何数据
|
- Debug 导出完成后只打开对应 job 文件夹,不向当前 Blender 文件添加任何数据
|
||||||
- 完整保留每次任务生成的所有官方文件
|
- 完整保留每次任务生成的所有官方文件
|
||||||
- 安装、模型下载和推理均在后台子进程运行,状态栏实时显示阶段、百分比进度条与取消按钮
|
- 安装、模型下载和推理均在后台子进程运行,状态栏实时显示阶段、百分比进度条与取消按钮
|
||||||
|
- 后台任务运行时,Install、Download 和 Export 按钮保持原位置并变灰,任务完成后恢复
|
||||||
|
|
||||||
## 使用
|
## 使用
|
||||||
|
|
||||||
@@ -44,21 +45,27 @@
|
|||||||
## Image Empty 转深度面片
|
## Image Empty 转深度面片
|
||||||
|
|
||||||
1. 在 3D View 选中 `Object Type = Empty`、`Display As = Image` 的对象。
|
1. 在 3D View 选中 `Object Type = Empty`、`Display As = Image` 的对象。
|
||||||
2. 右键选择 `Convert to Depth Plane`,在对话框中选择 Model、Use Ray Pose、Process Resolution、Subdivision,
|
2. 右键选择 `Convert to Depth Plane`,通过 `Depth from` 选择深度来源:
|
||||||
以及是否删除原 Image Empty。默认分辨率为 `1024`、Subdivision 为 `5`,并默认删除原对象。
|
- `AI Generated`:显示 Model、Use Ray Pose 和 Resolution Scale。实际 Process Res 为图片长边像素数
|
||||||
Model 可选择 `DA3 Small`、`DA3 Base`、`DA3 Large 1.1`、`DA3 Giant 1.1` 或
|
乘以 Resolution Scale;范围为 `0.1–1.0`,默认 `0.5`。计算后的 Process Res 会直接显示在
|
||||||
`DA3 Nested Giant Large 1.1`,默认 `DA3 Base`;Use Ray Pose
|
`Resolution Scale (… px)` 标签中。
|
||||||
默认开启。转换导出 `mini_npz`,使用前需要在 `Setup` 下载所选模型,并固定使用
|
转换导出 `mini_npz`,使用前需要在
|
||||||
`process_res_method="lower_bound_resize"`;设备和参考视图策略继续使用场景设置。
|
`Setup` 下载所选模型,并固定使用 `process_res_method="upper_bound_resize"`;设备和参考视图策略
|
||||||
3. 推理完成后会自动创建面片。面片几何先由 `mini_npz` 的相机内参与深度确定,再通过对象
|
继续使用场景设置。
|
||||||
Transform 匹配原 Image Empty 的可见尺寸、位置、旋转和 Offset。XY 拟合得到的统一倍率
|
- `Original`:直接把原图作为深度贴图,不显示估算参数,也不启动 Runtime 或要求源图片存在于磁盘。
|
||||||
同时应用于 Z 轴,使面片厚度与宽高按同一比例变化。开启 `Delete Original` 时,成功生成后
|
`Original` 是默认深度来源。选择 `AI Generated` 时,其 Model、Use Ray Pose 和 Resolution Scale
|
||||||
|
参数会集中显示在独立边框中。两种模式始终显示 Displace via、Subdivision 与 Delete Original。
|
||||||
|
Displace via 可选 `Modifier`(默认)或 `Material`;
|
||||||
|
Subdivision 默认为 `5`,并默认删除原对象。
|
||||||
|
3. AI Generated 完成后会自动创建面片。面片几何先由 `mini_npz` 的相机内参与深度确定,再通过对象
|
||||||
|
Transform 匹配原 Image Empty 的可见尺寸、位置、旋转和 Offset。Original 模式直接使用源 Empty
|
||||||
|
的可见尺寸、Offset 与 Transform。开启 `Delete Original` 时,成功生成后
|
||||||
删除源 Empty。生成面片 Object 使用源 Empty 的原名;关闭 `Delete Original` 时不处理
|
删除源 Empty。生成面片 Object 使用源 Empty 的原名;关闭 `Delete Original` 时不处理
|
||||||
同名冲突,由 Blender 自动为生成面片追加编号。
|
同名冲突,由 Blender 自动为生成面片追加编号。
|
||||||
4. 短边分段数为 `2^Subdivision`,长边分段数根据图像宽高比取最接近的整数。例如高:宽为
|
4. 短边分段数为 `2^Subdivision`,长边分段数根据图像宽高比取最接近的整数。例如高:宽为
|
||||||
`2:3`、Subdivision 为 `1` 时,直接生成 `2×3` 个面;Subdivision 为 `3` 时生成
|
`2:3`、Subdivision 为 `1` 时,直接生成 `2×3` 个面;Subdivision 为 `3` 时生成
|
||||||
`8×12` 个面。随后的 Simple Subdivision 修改器使用原生名称,Viewport Levels 固定为
|
`8×12` 个面。随后的 Simple Subdivision 修改器使用原生名称,Viewport Levels 固定为
|
||||||
`1`,Render Levels 固定为 `2`,不再随 Process Resolution 或 Subdivision 动态变化。
|
`1`,Render Levels 固定为 `2`,不再随 Resolution Scale 或 Subdivision 动态变化。
|
||||||
|
|
||||||
所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的紧凑进度条内部,取消按钮
|
所有任务使用统一的状态栏进度显示。变化中的阶段说明显示在右侧的紧凑进度条内部,取消按钮
|
||||||
位于进度条右侧。Image Depth 的阶段包括 Runtime 启动、输入准备、模型加载、深度估算、缓存
|
位于进度条右侧。Image Depth 的阶段包括 Runtime 启动、输入准备、模型加载、深度估算、缓存
|
||||||
@@ -86,12 +93,18 @@ Height = sqrt(Area / Aspect)
|
|||||||
`snapform_camera_plane_height`,便于检查实际采用的
|
`snapform_camera_plane_height`,便于检查实际采用的
|
||||||
参考深度和尺寸。
|
参考深度和尺寸。
|
||||||
|
|
||||||
Displace Modifier 的深度贴图使用 `Extension = Extend`。材质不再使用生成的深度图执行
|
选择 `Modifier` 时,AI Generated 的 Displace Modifier 深度贴图使用 `Extension = Extend`,
|
||||||
Displacement,其 `Strength` 固定为 `-1`、`Midlevel` 固定为 `1`;原始彩色贴图同时连接
|
`Strength` 固定为 `-1`、`Midlevel` 固定为 `1`。Original 把原图指定给 Displace Texture,并设置
|
||||||
|
`Texture Coordinates = UV`、`Midlevel = 0`;其余修改器设置保留 Blender 默认值。
|
||||||
|
选择 `Material` 时,不创建 Displace Modifier;深度 Image Texture 的 Color 连接到 Displacement
|
||||||
|
节点的 Height,再连接 Material Output 的 Displacement,并把材质 Surface Displacement 设置为
|
||||||
|
`Displacement Only`。AI Generated 使用 `Midlevel = 1`、`Scale = -1`;Original 使用
|
||||||
|
`Midlevel = 0`、`Scale = 1`,与对应修改器值一致。无论选择哪种呈现方式,原始彩色贴图都会连接
|
||||||
Principled BSDF 的 `Base Color`,并通过
|
Principled BSDF 的 `Base Color`,并通过
|
||||||
`Bump.Height → Bump.Normal → Principled.Normal` 提供表面细节。Bump 的基础 Distance 为
|
`Bump.Height → Bump.Normal → Principled.Normal` 提供表面细节。Bump 的基础 Distance 为
|
||||||
`0.001`,会乘以面片 Transform 的统一缩放倍率。原图 Color 还会经过 `Float Curve` 连接到
|
`0.001`,会乘以面片 Transform 的统一缩放倍率。原图 Color 还会经过 `Float Curve` 连接到
|
||||||
Principled BSDF 的 `Roughness`,曲线两端输出分别为 `0.5` 和 `0.1`;材质 IOR 默认为 `1.2`。
|
Principled BSDF 的 `Roughness`,曲线两端输出分别为 `0.5` 和 `0.1`;材质 IOR 保持
|
||||||
|
Principled BSDF 默认值 `1.5`。
|
||||||
|
|
||||||
这里的“原始距离”是 DA3 模型输出的尺度。普通 Small、Base、Large 和 Giant 模型的深度不应
|
这里的“原始距离”是 DA3 模型输出的尺度。普通 Small、Base、Large 和 Giant 模型的深度不应
|
||||||
被解释为现实世界的米;需要现实尺度时应选择带 metric depth 的
|
被解释为现实世界的米;需要现实尺度时应选择带 metric depth 的
|
||||||
@@ -99,15 +112,18 @@ Principled BSDF 的 `Roughness`,曲线两端输出分别为 `0.5` 和 `0.1`;
|
|||||||
相机内参逐像素反投影得到的完整 camera-space 网格;中位深度截面为现有 Displace Modifier
|
相机内参逐像素反投影得到的完整 camera-space 网格;中位深度截面为现有 Displace Modifier
|
||||||
工作流提供正确的整体高宽尺度。
|
工作流提供正确的整体高宽尺度。
|
||||||
|
|
||||||
生成完成后会直接创建面片,不提供缓存重建入口或单独的导入参数。
|
生成完成后会直接创建面片,不提供缓存重建入口或单独的导入参数。重新选择
|
||||||
重新选择 `Convert to Depth Plane` 会再次运行 DA3。
|
`Convert to Depth Plane` 会按当前 Depth from 再次转换。
|
||||||
面片、深度 Image、Texture 与 Material 会在同一个 Blender Undo 步骤中创建;转换完成后执行
|
面片、深度 Image、Texture 与 Material 会在同一个 Blender Undo 步骤中创建;转换完成后执行
|
||||||
`Ctrl+Z` 会完整撤销本次转换,不会越过转换去回退更早的 Image Empty 操作。
|
`Ctrl+Z` 会完整撤销本次转换,不会越过转换去回退更早的 Image Empty 操作。
|
||||||
|
|
||||||
当前转换入口要求 Image Empty 引用仍然存在的本地图像文件。DA3 的原始
|
Image Empty 使用 Packed Image 时,SnapForm 会通过 Blender 写出临时 PNG 交给 DA3,
|
||||||
|
不会 Unpack 或修改源 Image;任务结束后自动删除临时文件。普通图片仍直接读取源文件。DA3 的原始
|
||||||
`results.npz` 保留在对应 job 的 `exports/mini_npz/` 中,生成的浮点深度 Image 会 Pack 到
|
`results.npz` 保留在对应 job 的 `exports/mini_npz/` 中,生成的浮点深度 Image 会 Pack 到
|
||||||
当前 `.blend`。源 Empty 名为 `img` 时,深度 Image 与 Displace Texture 都命名为
|
当前 `.blend`。Depth Image 与 Displace Texture 根据源 Image datablock 命名。例如 Empty Object
|
||||||
`img.depth`。Pack 使用 OpenEXR,因而不会把大于 `1.0` 的原始深度截断。
|
名为 `obj_img`、引用的 Image 名为 `img.png` 时,Depth Image 与 Texture 都命名为 `img.depth`;
|
||||||
|
生成的 Object、Mesh 与 Material 仍命名为 `obj_img`。Pack 使用 OpenEXR,因而不会把大于 `1.0`
|
||||||
|
的原始深度截断。已有同名 datablock 时由 Blender 自动追加 `.001` 等编号。
|
||||||
|
|
||||||
选择 PLY 后,模型菜单只会提供 `DA3 Giant 1.1` 和
|
选择 PLY 后,模型菜单只会提供 `DA3 Giant 1.1` 和
|
||||||
`DA3 Nested Giant Large 1.1`。Small、Base 和 Large 没有 Gaussian head,无法由
|
`DA3 Nested Giant Large 1.1`。Small、Base 和 Large 没有 Gaussian head,无法由
|
||||||
@@ -145,7 +161,7 @@ CC BY-NC 4.0,仅限非商业用途。
|
|||||||
`gs_views_interval` 仅在选择 `PLY (3DGS)` 时显示:
|
`gs_views_interval` 仅在选择 `PLY (3DGS)` 时显示:
|
||||||
|
|
||||||
- `process_res=1024`
|
- `process_res=1024`
|
||||||
- `process_res_method="lower_bound_resize"`
|
- `process_res_method="upper_bound_resize"`
|
||||||
- `use_ray_pose=False`
|
- `use_ray_pose=False`
|
||||||
- `ref_view_strategy="saddle_balanced"`
|
- `ref_view_strategy="saddle_balanced"`
|
||||||
- `conf_thresh_percentile=40.0`
|
- `conf_thresh_percentile=40.0`
|
||||||
|
|||||||
4
pack.py
4
pack.py
@@ -12,10 +12,8 @@ def build_extension():
|
|||||||
manifest = tomllib.loads(
|
manifest = tomllib.loads(
|
||||||
(SOURCE_DIR / "blender_manifest.toml").read_text(encoding="utf-8")
|
(SOURCE_DIR / "blender_manifest.toml").read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
blender_version = manifest["blender_version_min"].split(".")
|
|
||||||
blender_tag = f"b{blender_version[0]}{blender_version[1]}"
|
|
||||||
output_path = DIST_DIR / (
|
output_path = DIST_DIR / (
|
||||||
f"{manifest['name']}.v{manifest['version']}.{blender_tag}.zip"
|
f"{manifest['name']}.v{manifest['version']}.zip"
|
||||||
)
|
)
|
||||||
DIST_DIR.mkdir(exist_ok=True)
|
DIST_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
|||||||
@@ -5,36 +5,29 @@ import bpy
|
|||||||
|
|
||||||
DEPTH_KEY_CANDIDATES = ("depth", "depths")
|
DEPTH_KEY_CANDIDATES = ("depth", "depths")
|
||||||
INTRINSICS_KEY_CANDIDATES = ("intrinsics", "ixts")
|
INTRINSICS_KEY_CANDIDATES = ("intrinsics", "ixts")
|
||||||
IMAGE_DEPTH_MODEL_ITEMS = (
|
|
||||||
(
|
|
||||||
"SMALL",
|
|
||||||
"DA3 Small",
|
|
||||||
"Fastest model with Apache 2.0 weights",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"BASE",
|
|
||||||
"DA3 Base",
|
|
||||||
"Balanced model with Apache 2.0 weights",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"LARGE_1_1",
|
|
||||||
"DA3 Large 1.1",
|
|
||||||
"Higher quality model with CC BY-NC 4.0 weights",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"GIANT_1_1",
|
|
||||||
"DA3 Giant 1.1",
|
|
||||||
"Giant model with Gaussian head and CC BY-NC 4.0 weights",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"NESTED_GIANT_LARGE_1_1",
|
|
||||||
"DA3 Nested Giant Large 1.1",
|
|
||||||
"Nested Giant model with metric depth and CC BY-NC 4.0 weights",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
DISPLACEMENT_STRENGTH = -1.0
|
DISPLACEMENT_STRENGTH = -1.0
|
||||||
DISPLACEMENT_MIDLEVEL = 1.0
|
DISPLACEMENT_MIDLEVEL = 1.0
|
||||||
|
ORIGINAL_DISPLACEMENT_STRENGTH = 1.0
|
||||||
|
ORIGINAL_DISPLACEMENT_MIDLEVEL = 0.0
|
||||||
BUMP_BASE_DISTANCE = 0.001
|
BUMP_BASE_DISTANCE = 0.001
|
||||||
|
IMAGE_NAME_SUFFIXES = (
|
||||||
|
".jpeg",
|
||||||
|
".jpg",
|
||||||
|
".png",
|
||||||
|
".webp",
|
||||||
|
".tiff",
|
||||||
|
".tif",
|
||||||
|
".exr",
|
||||||
|
".hdr",
|
||||||
|
".bmp",
|
||||||
|
".tga",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def image_depth_model_items(owner, context):
|
||||||
|
from .models import model_enum_items
|
||||||
|
|
||||||
|
return model_enum_items(owner, context)
|
||||||
|
|
||||||
|
|
||||||
def is_image_empty(candidate):
|
def is_image_empty(candidate):
|
||||||
@@ -58,6 +51,60 @@ def image_path(image):
|
|||||||
return Path(bpy.path.abspath(filepath)).expanduser()
|
return Path(bpy.path.abspath(filepath)).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def image_is_packed(image):
|
||||||
|
if getattr(image, "packed_file", None) is not None:
|
||||||
|
return True
|
||||||
|
return bool(getattr(image, "packed_files", ()))
|
||||||
|
|
||||||
|
|
||||||
|
def image_base_name(image):
|
||||||
|
name = image.name
|
||||||
|
lower_name = name.lower()
|
||||||
|
for suffix in IMAGE_NAME_SUFFIXES:
|
||||||
|
suffix_index = lower_name.rfind(suffix)
|
||||||
|
if suffix_index <= 0:
|
||||||
|
continue
|
||||||
|
trailing = name[suffix_index + len(suffix) :]
|
||||||
|
if trailing and not (
|
||||||
|
trailing.startswith(".") and trailing[1:].isdigit()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
return f"{name[:suffix_index]}{trailing}"
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def depth_data_name(source_object):
|
||||||
|
return f"{image_base_name(source_object.data)}.depth"
|
||||||
|
|
||||||
|
|
||||||
|
def scaled_process_resolution(image, scale):
|
||||||
|
width, height = image.size
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
raise ValueError("The source image has no readable dimensions")
|
||||||
|
if scale < 0.1 or scale > 1.0:
|
||||||
|
raise ValueError("Resolution Scale must be between 0.1 and 1.0")
|
||||||
|
return int(max(width, height) * scale + 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def save_packed_image(image, scene):
|
||||||
|
from .runtime import temporary_input_path
|
||||||
|
|
||||||
|
output_path = temporary_input_path()
|
||||||
|
image_settings = scene.render.image_settings
|
||||||
|
original_file_format = image_settings.file_format
|
||||||
|
try:
|
||||||
|
image_settings.file_format = "PNG"
|
||||||
|
image.save_render(str(output_path), scene=scene)
|
||||||
|
except (OSError, RuntimeError) as error:
|
||||||
|
output_path.unlink(missing_ok=True)
|
||||||
|
raise RuntimeError("Unable to export the packed image") from error
|
||||||
|
finally:
|
||||||
|
image_settings.file_format = original_file_format
|
||||||
|
if not output_path.is_file():
|
||||||
|
raise RuntimeError("Blender did not export the packed image")
|
||||||
|
return output_path
|
||||||
|
|
||||||
|
|
||||||
def plane_bounds(source_object):
|
def plane_bounds(source_object):
|
||||||
image_width, image_height = source_object.data.size
|
image_width, image_height = source_object.data.size
|
||||||
if image_width <= 0 or image_height <= 0:
|
if image_width <= 0 or image_height <= 0:
|
||||||
@@ -352,7 +399,7 @@ def create_plane_mesh(source_object, bounds=None, subdivision_level=5):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
mesh = bpy.data.meshes.new(f"{source_object.name}.DA3Depth")
|
mesh = bpy.data.meshes.new(source_object.name)
|
||||||
mesh.from_pydata(vertices, (), faces)
|
mesh.from_pydata(vertices, (), faces)
|
||||||
mesh.update()
|
mesh.update()
|
||||||
for polygon in mesh.polygons:
|
for polygon in mesh.polygons:
|
||||||
@@ -369,6 +416,9 @@ def create_material(
|
|||||||
name,
|
name,
|
||||||
color_image,
|
color_image,
|
||||||
depth_scale,
|
depth_scale,
|
||||||
|
displacement_image=None,
|
||||||
|
displacement_midlevel=0.0,
|
||||||
|
displacement_scale=1.0,
|
||||||
):
|
):
|
||||||
material = bpy.data.materials.new(name)
|
material = bpy.data.materials.new(name)
|
||||||
material.use_nodes = True
|
material.use_nodes = True
|
||||||
@@ -387,8 +437,6 @@ def create_material(
|
|||||||
color_texture.interpolation = "Linear"
|
color_texture.interpolation = "Linear"
|
||||||
color_texture.extension = "EXTEND"
|
color_texture.extension = "EXTEND"
|
||||||
bump.inputs["Distance"].default_value = BUMP_BASE_DISTANCE * depth_scale
|
bump.inputs["Distance"].default_value = BUMP_BASE_DISTANCE * depth_scale
|
||||||
shader.inputs["IOR"].default_value = 1.2
|
|
||||||
|
|
||||||
roughness_curve.mapping.initialize()
|
roughness_curve.mapping.initialize()
|
||||||
curve_points = roughness_curve.mapping.curves[0].points
|
curve_points = roughness_curve.mapping.curves[0].points
|
||||||
curve_points[0].location = (0.0, 0.5)
|
curve_points[0].location = (0.0, 0.5)
|
||||||
@@ -415,15 +463,118 @@ def create_material(
|
|||||||
shader.inputs["Roughness"],
|
shader.inputs["Roughness"],
|
||||||
)
|
)
|
||||||
links.new(shader.outputs["BSDF"], output.inputs["Surface"])
|
links.new(shader.outputs["BSDF"], output.inputs["Surface"])
|
||||||
|
|
||||||
|
if displacement_image is not None:
|
||||||
|
depth_texture = nodes.new("ShaderNodeTexImage")
|
||||||
|
displacement = nodes.new("ShaderNodeDisplacement")
|
||||||
|
depth_texture.image = displacement_image
|
||||||
|
depth_texture.interpolation = "Linear"
|
||||||
|
depth_texture.extension = "EXTEND"
|
||||||
|
displacement.inputs["Midlevel"].default_value = (
|
||||||
|
displacement_midlevel
|
||||||
|
)
|
||||||
|
displacement.inputs["Scale"].default_value = displacement_scale
|
||||||
|
depth_texture.location = (-300.0, -360.0)
|
||||||
|
displacement.location = (80.0, -260.0)
|
||||||
|
links.new(
|
||||||
|
depth_texture.outputs["Color"],
|
||||||
|
displacement.inputs["Height"],
|
||||||
|
)
|
||||||
|
links.new(
|
||||||
|
displacement.outputs["Displacement"],
|
||||||
|
output.inputs["Displacement"],
|
||||||
|
)
|
||||||
|
set_displacement_only(material)
|
||||||
return material
|
return material
|
||||||
|
|
||||||
|
|
||||||
|
def set_displacement_only(material):
|
||||||
|
for owner, property_name in (
|
||||||
|
(material, "surface_displacement_method"),
|
||||||
|
(material, "displacement_method"),
|
||||||
|
(getattr(material, "cycles", None), "displacement_method"),
|
||||||
|
):
|
||||||
|
if owner is not None and hasattr(owner, property_name):
|
||||||
|
setattr(owner, property_name, "DISPLACEMENT")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def finish_depth_plane(
|
||||||
|
context,
|
||||||
|
source_object,
|
||||||
|
plane_object,
|
||||||
|
delete_source,
|
||||||
|
):
|
||||||
|
source_name = source_object.name
|
||||||
|
for selected in context.selected_objects:
|
||||||
|
selected.select_set(False)
|
||||||
|
plane_object.select_set(True)
|
||||||
|
context.view_layer.objects.active = plane_object
|
||||||
|
if delete_source:
|
||||||
|
bpy.data.objects.remove(source_object, do_unlink=True)
|
||||||
|
plane_object.name = source_name
|
||||||
|
return plane_object
|
||||||
|
|
||||||
|
|
||||||
|
def build_image_depth_plane(
|
||||||
|
context,
|
||||||
|
source_object,
|
||||||
|
subdivision_level,
|
||||||
|
delete_source,
|
||||||
|
displacement_via="MODIFIER",
|
||||||
|
):
|
||||||
|
source_name = source_object.name
|
||||||
|
mesh = create_plane_mesh(
|
||||||
|
source_object,
|
||||||
|
subdivision_level=subdivision_level,
|
||||||
|
)
|
||||||
|
plane_object = bpy.data.objects.new(source_name, mesh)
|
||||||
|
context.collection.objects.link(plane_object)
|
||||||
|
plane_object.matrix_world = source_object.matrix_world.copy()
|
||||||
|
|
||||||
|
subdivision = plane_object.modifiers.new("Subdivision", "SUBSURF")
|
||||||
|
subdivision.subdivision_type = "SIMPLE"
|
||||||
|
subdivision.levels = 1
|
||||||
|
subdivision.render_levels = 2
|
||||||
|
|
||||||
|
if displacement_via == "MODIFIER":
|
||||||
|
texture = bpy.data.textures.new(
|
||||||
|
depth_data_name(source_object),
|
||||||
|
type="IMAGE",
|
||||||
|
)
|
||||||
|
texture.image = source_object.data
|
||||||
|
displace = plane_object.modifiers.new("Displace", "DISPLACE")
|
||||||
|
displace.texture = texture
|
||||||
|
displace.texture_coords = "UV"
|
||||||
|
displace.mid_level = ORIGINAL_DISPLACEMENT_MIDLEVEL
|
||||||
|
|
||||||
|
depth_scale = abs(float(plane_object.scale.z))
|
||||||
|
material = create_material(
|
||||||
|
source_name,
|
||||||
|
source_object.data,
|
||||||
|
depth_scale,
|
||||||
|
displacement_image=(
|
||||||
|
source_object.data if displacement_via == "MATERIAL" else None
|
||||||
|
),
|
||||||
|
displacement_midlevel=ORIGINAL_DISPLACEMENT_MIDLEVEL,
|
||||||
|
displacement_scale=ORIGINAL_DISPLACEMENT_STRENGTH,
|
||||||
|
)
|
||||||
|
mesh.materials.append(material)
|
||||||
|
return finish_depth_plane(
|
||||||
|
context,
|
||||||
|
source_object,
|
||||||
|
plane_object,
|
||||||
|
delete_source,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_depth_plane(
|
def build_depth_plane(
|
||||||
context,
|
context,
|
||||||
source_object,
|
source_object,
|
||||||
npz_path,
|
npz_path,
|
||||||
subdivision_level,
|
subdivision_level,
|
||||||
delete_source,
|
delete_source,
|
||||||
|
displacement_via="MODIFIER",
|
||||||
):
|
):
|
||||||
source_name = source_object.name
|
source_name = source_object.name
|
||||||
depth = extract_depth_array(npz_path)
|
depth = extract_depth_array(npz_path)
|
||||||
@@ -435,7 +586,7 @@ def build_depth_plane(
|
|||||||
intrinsics,
|
intrinsics,
|
||||||
DISPLACEMENT_STRENGTH,
|
DISPLACEMENT_STRENGTH,
|
||||||
)
|
)
|
||||||
depth_name = f"{source_name}.depth"
|
depth_name = depth_data_name(source_object)
|
||||||
depth_image = create_depth_image(
|
depth_image = create_depth_image(
|
||||||
depth_name,
|
depth_name,
|
||||||
depth_values,
|
depth_values,
|
||||||
@@ -457,23 +608,29 @@ def build_depth_plane(
|
|||||||
subdivision.levels = 1
|
subdivision.levels = 1
|
||||||
subdivision.render_levels = 2
|
subdivision.render_levels = 2
|
||||||
|
|
||||||
texture = bpy.data.textures.new(
|
if displacement_via == "MODIFIER":
|
||||||
depth_name,
|
texture = bpy.data.textures.new(
|
||||||
type="IMAGE",
|
depth_name,
|
||||||
)
|
type="IMAGE",
|
||||||
texture.image = depth_image
|
)
|
||||||
texture.extension = "EXTEND"
|
texture.image = depth_image
|
||||||
displace = plane_object.modifiers.new("Displace", "DISPLACE")
|
texture.extension = "EXTEND"
|
||||||
displace.texture = texture
|
displace = plane_object.modifiers.new("Displace", "DISPLACE")
|
||||||
displace.texture_coords = "UV"
|
displace.texture = texture
|
||||||
displace.direction = "NORMAL"
|
displace.texture_coords = "UV"
|
||||||
displace.strength = DISPLACEMENT_STRENGTH
|
displace.direction = "NORMAL"
|
||||||
displace.mid_level = DISPLACEMENT_MIDLEVEL
|
displace.strength = DISPLACEMENT_STRENGTH
|
||||||
|
displace.mid_level = DISPLACEMENT_MIDLEVEL
|
||||||
|
|
||||||
material = create_material(
|
material = create_material(
|
||||||
f"{source_name}.DA3DepthMaterial",
|
source_name,
|
||||||
source_object.data,
|
source_object.data,
|
||||||
depth_scale,
|
depth_scale,
|
||||||
|
displacement_image=(
|
||||||
|
depth_image if displacement_via == "MATERIAL" else None
|
||||||
|
),
|
||||||
|
displacement_midlevel=DISPLACEMENT_MIDLEVEL,
|
||||||
|
displacement_scale=DISPLACEMENT_STRENGTH,
|
||||||
)
|
)
|
||||||
mesh.materials.append(material)
|
mesh.materials.append(material)
|
||||||
plane_object["snapform_reference_depth"] = reference_depth
|
plane_object["snapform_reference_depth"] = reference_depth
|
||||||
@@ -481,14 +638,12 @@ def build_depth_plane(
|
|||||||
plane_object["snapform_camera_plane_height"] = bounds[3] - bounds[2]
|
plane_object["snapform_camera_plane_height"] = bounds[3] - bounds[2]
|
||||||
plane_object["snapform_depth_scale"] = depth_scale
|
plane_object["snapform_depth_scale"] = depth_scale
|
||||||
|
|
||||||
for selected in context.selected_objects:
|
return finish_depth_plane(
|
||||||
selected.select_set(False)
|
context,
|
||||||
plane_object.select_set(True)
|
source_object,
|
||||||
context.view_layer.objects.active = plane_object
|
plane_object,
|
||||||
if delete_source:
|
delete_source,
|
||||||
bpy.data.objects.remove(source_object, do_unlink=True)
|
)
|
||||||
plane_object.name = source_name
|
|
||||||
return plane_object
|
|
||||||
|
|
||||||
|
|
||||||
class BuildDepthPlane(bpy.types.Operator):
|
class BuildDepthPlane(bpy.types.Operator):
|
||||||
@@ -512,6 +667,14 @@ class BuildDepthPlane(bpy.types.Operator):
|
|||||||
default=True,
|
default=True,
|
||||||
options={"HIDDEN", "SKIP_SAVE"},
|
options={"HIDDEN", "SKIP_SAVE"},
|
||||||
)
|
)
|
||||||
|
snapform_displacement_via: bpy.props.EnumProperty(
|
||||||
|
items=(
|
||||||
|
("MODIFIER", "Modifier", "Use a Displace modifier"),
|
||||||
|
("MATERIAL", "Material", "Use material displacement"),
|
||||||
|
),
|
||||||
|
default="MODIFIER",
|
||||||
|
options={"HIDDEN", "SKIP_SAVE"},
|
||||||
|
)
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
source_object = bpy.data.objects.get(self.snapform_source_object_name)
|
source_object = bpy.data.objects.get(self.snapform_source_object_name)
|
||||||
@@ -526,7 +689,7 @@ class BuildDepthPlane(bpy.types.Operator):
|
|||||||
if not npz_path.is_file():
|
if not npz_path.is_file():
|
||||||
self.report(
|
self.report(
|
||||||
{"ERROR"},
|
{"ERROR"},
|
||||||
f"DA3 Mini NPZ does not exist: {npz_path}",
|
f"Depth result does not exist: {npz_path}",
|
||||||
)
|
)
|
||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
|
|
||||||
@@ -537,6 +700,7 @@ class BuildDepthPlane(bpy.types.Operator):
|
|||||||
npz_path,
|
npz_path,
|
||||||
self.snapform_subdivision_level,
|
self.snapform_subdivision_level,
|
||||||
self.snapform_delete_source,
|
self.snapform_delete_source,
|
||||||
|
self.snapform_displacement_via,
|
||||||
)
|
)
|
||||||
except (OSError, RuntimeError, ValueError) as error:
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
self.report({"ERROR"}, str(error))
|
self.report({"ERROR"}, str(error))
|
||||||
@@ -547,27 +711,49 @@ class BuildDepthPlane(bpy.types.Operator):
|
|||||||
class ConvertImageToDepthPlane(bpy.types.Operator):
|
class ConvertImageToDepthPlane(bpy.types.Operator):
|
||||||
bl_idname = "snapform.convert_image_to_depth_plane"
|
bl_idname = "snapform.convert_image_to_depth_plane"
|
||||||
bl_label = "Convert to Depth Plane"
|
bl_label = "Convert to Depth Plane"
|
||||||
bl_description = "Run DA3 and convert the image to a displaced depth plane"
|
bl_description = "Convert an image to a displaced depth plane"
|
||||||
bl_options = {"REGISTER"}
|
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_displacement_via: bpy.props.EnumProperty(
|
||||||
|
name="Displace via",
|
||||||
|
description="How the depth displacement is presented",
|
||||||
|
items=(
|
||||||
|
("MODIFIER", "Modifier", "Use a Displace modifier"),
|
||||||
|
("MATERIAL", "Material", "Use material displacement"),
|
||||||
|
),
|
||||||
|
default="MODIFIER",
|
||||||
|
)
|
||||||
snapform_model: bpy.props.EnumProperty(
|
snapform_model: bpy.props.EnumProperty(
|
||||||
name="Model",
|
name="Model",
|
||||||
description="DA3 model used to estimate image depth",
|
description="Model used to estimate image depth",
|
||||||
items=IMAGE_DEPTH_MODEL_ITEMS,
|
items=image_depth_model_items,
|
||||||
default="BASE",
|
|
||||||
)
|
)
|
||||||
snapform_use_ray_pose: bpy.props.BoolProperty(
|
snapform_use_ray_pose: bpy.props.BoolProperty(
|
||||||
name="Use Ray Pose",
|
name="Use Ray Pose",
|
||||||
description="Use ray-based camera pose estimation",
|
description="Use ray-based camera pose estimation",
|
||||||
default=True,
|
default=True,
|
||||||
)
|
)
|
||||||
snapform_process_res: bpy.props.IntProperty(
|
snapform_process_res_factor: bpy.props.FloatProperty(
|
||||||
name="Process Resolution",
|
name="Resolution Scale",
|
||||||
description="DA3 processing resolution",
|
description="Scale applied to the source image long-edge resolution",
|
||||||
default=1024,
|
default=0.5,
|
||||||
min=14,
|
min=0.1,
|
||||||
max=2016,
|
max=1.0,
|
||||||
step=14,
|
step=10,
|
||||||
|
precision=2,
|
||||||
)
|
)
|
||||||
snapform_delete_original: bpy.props.BoolProperty(
|
snapform_delete_original: bpy.props.BoolProperty(
|
||||||
name="Delete Original",
|
name="Delete Original",
|
||||||
@@ -589,13 +775,27 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
|||||||
return is_image_empty(context.object) and active_job() is None
|
return is_image_empty(context.object) and active_job() is None
|
||||||
|
|
||||||
def invoke(self, context, _event):
|
def invoke(self, context, _event):
|
||||||
|
if not self.properties.is_property_set("snapform_model"):
|
||||||
|
self.snapform_model = "BASE"
|
||||||
return context.window_manager.invoke_props_dialog(self, width=340)
|
return context.window_manager.invoke_props_dialog(self, width=340)
|
||||||
|
|
||||||
def draw(self, _context):
|
def draw(self, context):
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
layout.prop(self, "snapform_model")
|
layout.prop(self, "snapform_depth_source")
|
||||||
layout.prop(self, "snapform_use_ray_pose")
|
if self.snapform_depth_source == "ESTIMATE":
|
||||||
layout.prop(self, "snapform_process_res")
|
estimate_box = layout.box()
|
||||||
|
estimate_box.prop(self, "snapform_model")
|
||||||
|
estimate_box.prop(self, "snapform_use_ray_pose")
|
||||||
|
process_res = scaled_process_resolution(
|
||||||
|
context.object.data,
|
||||||
|
self.snapform_process_res_factor,
|
||||||
|
)
|
||||||
|
estimate_box.prop(
|
||||||
|
self,
|
||||||
|
"snapform_process_res_factor",
|
||||||
|
text=f"Resolution Scale ({process_res} px)",
|
||||||
|
)
|
||||||
|
layout.prop(self, "snapform_displacement_via")
|
||||||
layout.prop(self, "snapform_subdivision_level")
|
layout.prop(self, "snapform_subdivision_level")
|
||||||
layout.prop(self, "snapform_delete_original")
|
layout.prop(self, "snapform_delete_original")
|
||||||
|
|
||||||
@@ -603,7 +803,47 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
|||||||
from .operators import IMAGE_DEPTH_JOB
|
from .operators import IMAGE_DEPTH_JOB
|
||||||
|
|
||||||
source_object = context.object
|
source_object = context.object
|
||||||
source_path = image_path(source_object.data)
|
if self.snapform_depth_source == "ORIGINAL":
|
||||||
|
try:
|
||||||
|
build_image_depth_plane(
|
||||||
|
context,
|
||||||
|
source_object,
|
||||||
|
self.snapform_subdivision_level,
|
||||||
|
self.snapform_delete_original,
|
||||||
|
self.snapform_displacement_via,
|
||||||
|
)
|
||||||
|
except (OSError, RuntimeError, ValueError) as error:
|
||||||
|
self.report({"ERROR"}, str(error))
|
||||||
|
return {"CANCELLED"}
|
||||||
|
undo_result = bpy.ops.ed.undo_push(
|
||||||
|
message="Convert to Depth Plane",
|
||||||
|
)
|
||||||
|
if "FINISHED" not in undo_result:
|
||||||
|
self.report(
|
||||||
|
{"ERROR"},
|
||||||
|
"Unable to create the depth plane Undo step",
|
||||||
|
)
|
||||||
|
return {"CANCELLED"}
|
||||||
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
image = source_object.data
|
||||||
|
try:
|
||||||
|
process_res = scaled_process_resolution(
|
||||||
|
image,
|
||||||
|
self.snapform_process_res_factor,
|
||||||
|
)
|
||||||
|
except ValueError as error:
|
||||||
|
self.report({"ERROR"}, str(error))
|
||||||
|
return {"CANCELLED"}
|
||||||
|
source_path = image_path(image)
|
||||||
|
delete_input = False
|
||||||
|
if image_is_packed(image):
|
||||||
|
try:
|
||||||
|
source_path = save_packed_image(image, context.scene)
|
||||||
|
except RuntimeError as error:
|
||||||
|
self.report({"ERROR"}, str(error))
|
||||||
|
return {"CANCELLED"}
|
||||||
|
delete_input = True
|
||||||
if not source_path.is_file():
|
if not source_path.is_file():
|
||||||
self.report(
|
self.report(
|
||||||
{"ERROR"},
|
{"ERROR"},
|
||||||
@@ -617,11 +857,15 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
|
|||||||
snapform_source_object_name=source_object.name,
|
snapform_source_object_name=source_object.name,
|
||||||
snapform_model_key=self.snapform_model,
|
snapform_model_key=self.snapform_model,
|
||||||
snapform_use_ray_pose=self.snapform_use_ray_pose,
|
snapform_use_ray_pose=self.snapform_use_ray_pose,
|
||||||
snapform_process_res=self.snapform_process_res,
|
snapform_process_res=process_res,
|
||||||
snapform_subdivision_level=self.snapform_subdivision_level,
|
snapform_subdivision_level=self.snapform_subdivision_level,
|
||||||
snapform_delete_source=self.snapform_delete_original,
|
snapform_delete_source=self.snapform_delete_original,
|
||||||
|
snapform_delete_input=delete_input,
|
||||||
|
snapform_displacement_via=self.snapform_displacement_via,
|
||||||
)
|
)
|
||||||
if "CANCELLED" in result:
|
if "CANCELLED" in result:
|
||||||
|
if delete_input:
|
||||||
|
source_path.unlink(missing_ok=True)
|
||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
return {"FINISHED"}
|
return {"FINISHED"}
|
||||||
|
|
||||||
|
|||||||
@@ -53,18 +53,62 @@ MODELS = {
|
|||||||
supports_gs=True,
|
supports_gs=True,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
MODEL_NUMBERS = {
|
||||||
|
model_key: number for number, model_key in enumerate(MODELS)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def enum_items(supports_gs=None):
|
_ENUM_ITEMS_CACHE = {}
|
||||||
return [
|
|
||||||
(
|
|
||||||
model.key,
|
def enum_items(supports_gs=None, show_download_status=False):
|
||||||
model.label,
|
model_ready = None
|
||||||
f"{model.description}; license: {model.license_name}",
|
if show_download_status:
|
||||||
)
|
from .runtime import model_ready
|
||||||
|
|
||||||
|
models = [
|
||||||
|
model
|
||||||
for model in MODELS.values()
|
for model in MODELS.values()
|
||||||
if supports_gs is None or model.supports_gs == supports_gs
|
if supports_gs is None or model.supports_gs == supports_gs
|
||||||
]
|
]
|
||||||
|
download_status = (
|
||||||
|
tuple(model_ready(model.key) for model in models)
|
||||||
|
if model_ready is not None
|
||||||
|
else ()
|
||||||
|
)
|
||||||
|
cache_key = (
|
||||||
|
supports_gs,
|
||||||
|
show_download_status,
|
||||||
|
download_status,
|
||||||
|
)
|
||||||
|
cached_items = _ENUM_ITEMS_CACHE.get(cache_key)
|
||||||
|
if cached_items is not None:
|
||||||
|
return cached_items
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for index, model in enumerate(models):
|
||||||
|
label = model.label
|
||||||
|
if download_status and not download_status[index]:
|
||||||
|
label = f"{label} (Missing)"
|
||||||
|
items.append(
|
||||||
|
(
|
||||||
|
model.key,
|
||||||
|
label,
|
||||||
|
f"{model.description}; license: {model.license_name}",
|
||||||
|
MODEL_NUMBERS[model.key],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
_ENUM_ITEMS_CACHE[cache_key] = items
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def model_enum_items(_owner, _context):
|
||||||
|
return enum_items(show_download_status=True)
|
||||||
|
|
||||||
|
|
||||||
|
def gs_model_enum_items(_owner, _context):
|
||||||
|
return enum_items(supports_gs=True, show_download_status=True)
|
||||||
|
|
||||||
|
|
||||||
def selected_model_key(settings, export_format=None):
|
def selected_model_key(settings, export_format=None):
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from .models import get_model, selected_model_key
|
|||||||
RECONSTRUCTION_JOB = "RECONSTRUCT"
|
RECONSTRUCTION_JOB = "RECONSTRUCT"
|
||||||
IMAGE_DEPTH_JOB = "IMAGE_DEPTH"
|
IMAGE_DEPTH_JOB = "IMAGE_DEPTH"
|
||||||
IMAGE_DEPTH_DEFAULT_PROCESS_RES = 1024
|
IMAGE_DEPTH_DEFAULT_PROCESS_RES = 1024
|
||||||
IMAGE_DEPTH_PROCESS_RES_METHOD = "lower_bound_resize"
|
IMAGE_DEPTH_PROCESS_RES_METHOD = "upper_bound_resize"
|
||||||
|
|
||||||
|
|
||||||
def open_job_directory(result_directory):
|
def open_job_directory(result_directory):
|
||||||
@@ -36,6 +36,8 @@ class ActiveJob:
|
|||||||
process_res: int = IMAGE_DEPTH_DEFAULT_PROCESS_RES
|
process_res: int = IMAGE_DEPTH_DEFAULT_PROCESS_RES
|
||||||
subdivision_level: int = 5
|
subdivision_level: int = 5
|
||||||
delete_source: bool = True
|
delete_source: bool = True
|
||||||
|
displacement_via: str = "MODIFIER"
|
||||||
|
temporary_input_path: Path | None = None
|
||||||
cancelled: bool = False
|
cancelled: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +64,11 @@ def close_active_job():
|
|||||||
if _active_job is None:
|
if _active_job is None:
|
||||||
return
|
return
|
||||||
_active_job.log_file.close()
|
_active_job.log_file.close()
|
||||||
|
if _active_job.temporary_input_path is not None:
|
||||||
|
try:
|
||||||
|
_active_job.temporary_input_path.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
_active_job = None
|
_active_job = None
|
||||||
|
|
||||||
|
|
||||||
@@ -98,20 +105,32 @@ class RunJob(bpy.types.Operator):
|
|||||||
)
|
)
|
||||||
snapform_process_res: bpy.props.IntProperty(
|
snapform_process_res: bpy.props.IntProperty(
|
||||||
default=IMAGE_DEPTH_DEFAULT_PROCESS_RES,
|
default=IMAGE_DEPTH_DEFAULT_PROCESS_RES,
|
||||||
min=14,
|
min=1,
|
||||||
max=2016,
|
max=65_536,
|
||||||
options={"HIDDEN", "SKIP_SAVE"},
|
options={"HIDDEN", "SKIP_SAVE"},
|
||||||
)
|
)
|
||||||
snapform_delete_source: bpy.props.BoolProperty(
|
snapform_delete_source: bpy.props.BoolProperty(
|
||||||
default=True,
|
default=True,
|
||||||
options={"HIDDEN", "SKIP_SAVE"},
|
options={"HIDDEN", "SKIP_SAVE"},
|
||||||
)
|
)
|
||||||
|
snapform_delete_input: bpy.props.BoolProperty(
|
||||||
|
default=False,
|
||||||
|
options={"HIDDEN", "SKIP_SAVE"},
|
||||||
|
)
|
||||||
snapform_subdivision_level: bpy.props.IntProperty(
|
snapform_subdivision_level: bpy.props.IntProperty(
|
||||||
default=5,
|
default=5,
|
||||||
min=0,
|
min=0,
|
||||||
max=10,
|
max=10,
|
||||||
options={"HIDDEN", "SKIP_SAVE"},
|
options={"HIDDEN", "SKIP_SAVE"},
|
||||||
)
|
)
|
||||||
|
snapform_displacement_via: bpy.props.EnumProperty(
|
||||||
|
items=(
|
||||||
|
("MODIFIER", "Modifier", ""),
|
||||||
|
("MATERIAL", "Material", ""),
|
||||||
|
),
|
||||||
|
default="MODIFIER",
|
||||||
|
options={"HIDDEN", "SKIP_SAVE"},
|
||||||
|
)
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
global _active_job
|
global _active_job
|
||||||
@@ -138,7 +157,7 @@ class RunJob(bpy.types.Operator):
|
|||||||
)
|
)
|
||||||
except OSError as error:
|
except OSError as error:
|
||||||
log_file.close()
|
log_file.close()
|
||||||
self.report({"ERROR"}, f"Unable to start DA3 task: {error}")
|
self.report({"ERROR"}, f"Unable to start task: {error}")
|
||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
|
|
||||||
_active_job = ActiveJob(
|
_active_job = ActiveJob(
|
||||||
@@ -157,6 +176,13 @@ class RunJob(bpy.types.Operator):
|
|||||||
process_res=self.snapform_process_res,
|
process_res=self.snapform_process_res,
|
||||||
subdivision_level=self.snapform_subdivision_level,
|
subdivision_level=self.snapform_subdivision_level,
|
||||||
delete_source=self.snapform_delete_source,
|
delete_source=self.snapform_delete_source,
|
||||||
|
displacement_via=self.snapform_displacement_via,
|
||||||
|
temporary_input_path=(
|
||||||
|
Path(self.snapform_input_path)
|
||||||
|
if self.job_type == IMAGE_DEPTH_JOB
|
||||||
|
and self.snapform_delete_input
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
settings.status = self._starting_message(settings)
|
settings.status = self._starting_message(settings)
|
||||||
settings.progress = 0.0
|
settings.progress = 0.0
|
||||||
@@ -245,9 +271,9 @@ class RunJob(bpy.types.Operator):
|
|||||||
"DOWNLOAD_MODEL": "Starting model download",
|
"DOWNLOAD_MODEL": "Starting model download",
|
||||||
}
|
}
|
||||||
if self.job_type == RECONSTRUCTION_JOB:
|
if self.job_type == RECONSTRUCTION_JOB:
|
||||||
return f"Starting DA3 {settings.export_format} export"
|
return f"Starting {settings.export_format} export"
|
||||||
if self.job_type == IMAGE_DEPTH_JOB:
|
if self.job_type == IMAGE_DEPTH_JOB:
|
||||||
return "Starting DA3 image depth conversion"
|
return "Starting depth estimation"
|
||||||
if self.job_type == "DOWNLOAD_MODEL":
|
if self.job_type == "DOWNLOAD_MODEL":
|
||||||
return f"Starting {get_model(self.model_key).label} download"
|
return f"Starting {get_model(self.model_key).label} download"
|
||||||
return labels[self.job_type]
|
return labels[self.job_type]
|
||||||
@@ -298,7 +324,7 @@ class RunJob(bpy.types.Operator):
|
|||||||
settings.progress = 0.0
|
settings.progress = 0.0
|
||||||
self.report(
|
self.report(
|
||||||
{"ERROR"},
|
{"ERROR"},
|
||||||
f"{error or 'DA3 subprocess failed'}; log: {job.log_path}",
|
f"{error or 'Task subprocess failed'}; log: {job.log_path}",
|
||||||
)
|
)
|
||||||
close_active_job()
|
close_active_job()
|
||||||
self._redraw_ui(context, force=True)
|
self._redraw_ui(context, force=True)
|
||||||
@@ -326,7 +352,7 @@ class RunJob(bpy.types.Operator):
|
|||||||
close_active_job()
|
close_active_job()
|
||||||
self._redraw_ui(context, force=True)
|
self._redraw_ui(context, force=True)
|
||||||
return {"CANCELLED"}
|
return {"CANCELLED"}
|
||||||
status["message"] = "DA3 depth plane is ready"
|
status["message"] = "Depth plane is ready"
|
||||||
|
|
||||||
settings.status = status.get("message", "Task complete")
|
settings.status = status.get("message", "Task complete")
|
||||||
settings.progress = 1.0
|
settings.progress = 1.0
|
||||||
@@ -346,6 +372,7 @@ class RunJob(bpy.types.Operator):
|
|||||||
snapform_npz_path=str(npz_path),
|
snapform_npz_path=str(npz_path),
|
||||||
snapform_subdivision_level=job.subdivision_level,
|
snapform_subdivision_level=job.subdivision_level,
|
||||||
snapform_delete_source=job.delete_source,
|
snapform_delete_source=job.delete_source,
|
||||||
|
snapform_displacement_via=job.displacement_via,
|
||||||
)
|
)
|
||||||
if "FINISHED" not in result:
|
if "FINISHED" not in result:
|
||||||
raise RuntimeError("Unable to build the depth plane")
|
raise RuntimeError("Unable to build the depth plane")
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
from .models import MODELS, selected_model_key
|
from .models import MODELS
|
||||||
from .operators import active_job
|
from .operators import active_job
|
||||||
from .runtime import model_ready, runtime_ready
|
from .runtime import model_ready, runtime_ready
|
||||||
|
|
||||||
@@ -18,13 +18,14 @@ class RuntimeModelsPanel(bpy.types.Panel):
|
|||||||
|
|
||||||
if not runtime_ready():
|
if not runtime_ready():
|
||||||
layout.label(text="Runtime is not installed", icon="ERROR")
|
layout.label(text="Runtime is not installed", icon="ERROR")
|
||||||
if not running:
|
install_row = layout.row()
|
||||||
operator = layout.operator(
|
install_row.enabled = not running
|
||||||
"snapform.run_job",
|
operator = install_row.operator(
|
||||||
text="Install Runtime",
|
"snapform.run_job",
|
||||||
icon="IMPORT",
|
text="Install Runtime",
|
||||||
)
|
icon="IMPORT",
|
||||||
operator.job_type = "INSTALL_RUNTIME"
|
)
|
||||||
|
operator.job_type = "INSTALL_RUNTIME"
|
||||||
|
|
||||||
for model in MODELS.values():
|
for model in MODELS.values():
|
||||||
self._draw_model(layout, model, running)
|
self._draw_model(layout, model, running)
|
||||||
@@ -34,16 +35,16 @@ class RuntimeModelsPanel(bpy.types.Panel):
|
|||||||
row.label(text=model.label)
|
row.label(text=model.label)
|
||||||
if model_ready(model.key):
|
if model_ready(model.key):
|
||||||
row.label(text="Ready", icon="CHECKMARK")
|
row.label(text="Ready", icon="CHECKMARK")
|
||||||
elif runtime_ready() and not running:
|
else:
|
||||||
operator = row.operator(
|
download_row = row.row(align=True)
|
||||||
|
download_row.enabled = runtime_ready() and not running
|
||||||
|
operator = download_row.operator(
|
||||||
"snapform.run_job",
|
"snapform.run_job",
|
||||||
text="Download",
|
text="Download",
|
||||||
icon="IMPORT",
|
icon="IMPORT",
|
||||||
)
|
)
|
||||||
operator.job_type = "DOWNLOAD_MODEL"
|
operator.job_type = "DOWNLOAD_MODEL"
|
||||||
operator.model_key = model.key
|
operator.model_key = model.key
|
||||||
else:
|
|
||||||
row.label(text="Not downloaded", icon="ERROR")
|
|
||||||
|
|
||||||
|
|
||||||
class DebugPanel(bpy.types.Panel):
|
class DebugPanel(bpy.types.Panel):
|
||||||
@@ -59,16 +60,10 @@ class DebugPanel(bpy.types.Panel):
|
|||||||
settings = context.scene.snapform_settings
|
settings = context.scene.snapform_settings
|
||||||
|
|
||||||
layout.prop(settings, "export_format")
|
layout.prop(settings, "export_format")
|
||||||
model_key = selected_model_key(settings)
|
|
||||||
model_property = (
|
model_property = (
|
||||||
"gs_model" if settings.export_format == "gs_ply" else "model"
|
"gs_model" if settings.export_format == "gs_ply" else "model"
|
||||||
)
|
)
|
||||||
model_row = layout.row(align=True)
|
layout.prop(settings, model_property)
|
||||||
model_row.prop(settings, model_property)
|
|
||||||
if model_ready(model_key):
|
|
||||||
model_row.label(text="", icon="CHECKMARK")
|
|
||||||
else:
|
|
||||||
model_row.label(text="", icon="ERROR")
|
|
||||||
|
|
||||||
layout.prop(settings, "input_path")
|
layout.prop(settings, "input_path")
|
||||||
input_path = bpy.path.abspath(settings.input_path)
|
input_path = bpy.path.abspath(settings.input_path)
|
||||||
@@ -103,10 +98,11 @@ class DebugPanel(bpy.types.Panel):
|
|||||||
ply_box.label(text="PLY (3DGS) Settings")
|
ply_box.label(text="PLY (3DGS) Settings")
|
||||||
ply_box.prop(settings, "gs_views_interval")
|
ply_box.prop(settings, "gs_views_interval")
|
||||||
|
|
||||||
if active_job() is None:
|
export_row = layout.row()
|
||||||
operator = layout.operator(
|
export_row.enabled = active_job() is None
|
||||||
"snapform.run_job",
|
operator = export_row.operator(
|
||||||
text="Export",
|
"snapform.run_job",
|
||||||
icon="EXPORT",
|
text="Export",
|
||||||
)
|
icon="EXPORT",
|
||||||
operator.job_type = "RECONSTRUCT"
|
)
|
||||||
|
operator.job_type = "RECONSTRUCT"
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class SnapFormPreferences(bpy.types.AddonPreferences):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def draw(self, _context):
|
def draw(self, _context):
|
||||||
|
from .operators import active_job
|
||||||
from .runtime import runtime_directory, runtime_ready
|
from .runtime import runtime_directory, runtime_ready
|
||||||
|
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
@@ -44,7 +45,9 @@ class SnapFormPreferences(bpy.types.AddonPreferences):
|
|||||||
if runtime_ready():
|
if runtime_ready():
|
||||||
layout.label(text="Runtime is ready", icon="CHECKMARK")
|
layout.label(text="Runtime is ready", icon="CHECKMARK")
|
||||||
else:
|
else:
|
||||||
operator = layout.operator(
|
install_row = layout.row()
|
||||||
|
install_row.enabled = active_job() is None
|
||||||
|
operator = install_row.operator(
|
||||||
"snapform.run_job",
|
"snapform.run_job",
|
||||||
text="Install Runtime",
|
text="Install Runtime",
|
||||||
icon="IMPORT",
|
icon="IMPORT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import bpy
|
import bpy
|
||||||
|
|
||||||
from .models import enum_items
|
from .models import gs_model_enum_items, model_enum_items
|
||||||
|
|
||||||
|
|
||||||
class SnapFormSettings(bpy.types.PropertyGroup):
|
class SnapFormSettings(bpy.types.PropertyGroup):
|
||||||
@@ -19,14 +19,12 @@ class SnapFormSettings(bpy.types.PropertyGroup):
|
|||||||
model: bpy.props.EnumProperty(
|
model: bpy.props.EnumProperty(
|
||||||
name="Model",
|
name="Model",
|
||||||
description="Depth Anything 3 model",
|
description="Depth Anything 3 model",
|
||||||
items=enum_items(),
|
items=model_enum_items,
|
||||||
default="BASE",
|
|
||||||
)
|
)
|
||||||
gs_model: bpy.props.EnumProperty(
|
gs_model: bpy.props.EnumProperty(
|
||||||
name="Model",
|
name="Model",
|
||||||
description="Depth Anything 3 model with a Gaussian head",
|
description="Depth Anything 3 model with a Gaussian head",
|
||||||
items=enum_items(supports_gs=True),
|
items=gs_model_enum_items,
|
||||||
default="GIANT_1_1",
|
|
||||||
)
|
)
|
||||||
device: bpy.props.EnumProperty(
|
device: bpy.props.EnumProperty(
|
||||||
name="Device",
|
name="Device",
|
||||||
@@ -60,7 +58,7 @@ class SnapFormSettings(bpy.types.PropertyGroup):
|
|||||||
"Make Process Res the shorter side",
|
"Make Process Res the shorter side",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
default="lower_bound_resize",
|
default="upper_bound_resize",
|
||||||
)
|
)
|
||||||
conf_thresh_percentile: bpy.props.FloatProperty(
|
conf_thresh_percentile: bpy.props.FloatProperty(
|
||||||
name="Conf Thresh Percentile",
|
name="Conf Thresh Percentile",
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ def jobs_directory():
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def temporary_input_path():
|
||||||
|
path = jobs_directory() / "inputs"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path / f"{uuid.uuid4().hex}.png"
|
||||||
|
|
||||||
|
|
||||||
def new_job_paths(job_type):
|
def new_job_paths(job_type):
|
||||||
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
token = uuid.uuid4().hex[:8]
|
token = uuid.uuid4().hex[:8]
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ def wait_for_exported_paths(output_directory, export_format, timeout=30.0):
|
|||||||
|
|
||||||
|
|
||||||
def reconstruct(args):
|
def reconstruct(args):
|
||||||
write_status(args.status, 0.02, "Starting DA3 runtime")
|
write_status(args.status, 0.02, "Starting AI runtime")
|
||||||
|
|
||||||
import torch
|
import torch
|
||||||
from depth_anything_3.api import DepthAnything3
|
from depth_anything_3.api import DepthAnything3
|
||||||
@@ -291,7 +291,7 @@ def reconstruct(args):
|
|||||||
)
|
)
|
||||||
if not result_paths:
|
if not result_paths:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"DA3 did not export any {args.export_format} files"
|
f"No {args.export_format} files were exported"
|
||||||
)
|
)
|
||||||
write_status(
|
write_status(
|
||||||
args.status,
|
args.status,
|
||||||
@@ -373,7 +373,7 @@ def main():
|
|||||||
else:
|
else:
|
||||||
reconstruct(args)
|
reconstruct(args)
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
write_status(args.status, 1.0, "DA3 task failed", str(error))
|
write_status(args.status, 1.0, "Task failed", str(error))
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -146,14 +146,14 @@ class AddonTest(unittest.TestCase):
|
|||||||
def test_conversion_dialog_exposes_requested_settings(self):
|
def test_conversion_dialog_exposes_requested_settings(self):
|
||||||
operator = self.addon.ConvertImageToDepthPlane
|
operator = self.addon.ConvertImageToDepthPlane
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertNotIn(
|
||||||
operator.__annotations__["snapform_model"]["default"],
|
"default",
|
||||||
"BASE",
|
operator.__annotations__["snapform_model"],
|
||||||
)
|
)
|
||||||
self.assertTrue(operator.__annotations__["snapform_use_ray_pose"]["default"])
|
self.assertTrue(operator.__annotations__["snapform_use_ray_pose"]["default"])
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
operator.__annotations__["snapform_process_res"]["default"],
|
operator.__annotations__["snapform_process_res_factor"]["default"],
|
||||||
1024,
|
0.5,
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
operator.__annotations__["snapform_delete_original"]["default"]
|
operator.__annotations__["snapform_delete_original"]["default"]
|
||||||
@@ -165,14 +165,62 @@ class AddonTest(unittest.TestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
set(operator.__annotations__),
|
set(operator.__annotations__),
|
||||||
{
|
{
|
||||||
|
"snapform_depth_source",
|
||||||
|
"snapform_displacement_via",
|
||||||
"snapform_model",
|
"snapform_model",
|
||||||
"snapform_use_ray_pose",
|
"snapform_use_ray_pose",
|
||||||
"snapform_process_res",
|
"snapform_process_res_factor",
|
||||||
"snapform_delete_original",
|
"snapform_delete_original",
|
||||||
"snapform_subdivision_level",
|
"snapform_subdivision_level",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
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")
|
||||||
|
displacement_via = operator.__annotations__[
|
||||||
|
"snapform_displacement_via"
|
||||||
|
]
|
||||||
|
self.assertEqual(displacement_via["default"], "MODIFIER")
|
||||||
|
self.assertEqual(displacement_via["name"], "Displace via")
|
||||||
|
self.assertEqual(
|
||||||
|
[item[0] for item in displacement_via["items"]],
|
||||||
|
["MODIFIER", "MATERIAL"],
|
||||||
|
)
|
||||||
|
draw_source = inspect.getsource(operator.draw)
|
||||||
|
self.assertIn(
|
||||||
|
'if self.snapform_depth_source == "ESTIMATE":',
|
||||||
|
draw_source,
|
||||||
|
)
|
||||||
|
self.assertLess(
|
||||||
|
draw_source.index(
|
||||||
|
'"snapform_process_res_factor",'
|
||||||
|
),
|
||||||
|
draw_source.index(
|
||||||
|
'layout.prop(self, "snapform_subdivision_level")'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'text=f"Resolution Scale ({process_res} px)"',
|
||||||
|
draw_source,
|
||||||
|
)
|
||||||
|
self.assertIn("estimate_box = layout.box()", draw_source)
|
||||||
|
self.assertIn(
|
||||||
|
'estimate_box.prop(self, "snapform_model")',
|
||||||
|
draw_source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'estimate_box.prop(self, "snapform_use_ray_pose")',
|
||||||
|
draw_source,
|
||||||
|
)
|
||||||
|
self.assertNotIn("layout.separator", draw_source)
|
||||||
|
self.assertNotIn("layout.label", draw_source)
|
||||||
model_items = operator.__annotations__["snapform_model"]["items"]
|
model_items = operator.__annotations__["snapform_model"]["items"]
|
||||||
|
model_items = model_items(None, None)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
[item[0] for item in model_items],
|
[item[0] for item in model_items],
|
||||||
[
|
[
|
||||||
@@ -184,6 +232,127 @@ class AddonTest(unittest.TestCase):
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
self.assertIn("invoke_props_dialog", inspect.getsource(operator.invoke))
|
self.assertIn("invoke_props_dialog", inspect.getsource(operator.invoke))
|
||||||
|
self.assertNotIn(
|
||||||
|
'"DA3"',
|
||||||
|
inspect.getsource(operator),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_depth_conversion_progress_is_provider_neutral(self):
|
||||||
|
starting_source = inspect.getsource(
|
||||||
|
self.addon.RunJob._starting_message
|
||||||
|
)
|
||||||
|
finish_source = inspect.getsource(self.addon.RunJob._finish)
|
||||||
|
|
||||||
|
self.assertIn('return "Starting depth estimation"', starting_source)
|
||||||
|
self.assertIn('"Depth plane is ready"', finish_source)
|
||||||
|
self.assertNotIn("DA3 image depth", starting_source)
|
||||||
|
self.assertNotIn("DA3 depth plane", finish_source)
|
||||||
|
|
||||||
|
def test_model_options_mark_only_models_that_are_not_downloaded(self):
|
||||||
|
items_callback = self.addon.SnapFormSettings.__annotations__["model"][
|
||||||
|
"items"
|
||||||
|
]
|
||||||
|
with patch.object(
|
||||||
|
self.addon.runtime,
|
||||||
|
"model_ready",
|
||||||
|
side_effect=lambda key: key == "BASE",
|
||||||
|
):
|
||||||
|
items = items_callback(None, None)
|
||||||
|
|
||||||
|
labels = {item[0]: item[1] for item in items}
|
||||||
|
self.assertEqual(labels["BASE"], "DA3 Base")
|
||||||
|
self.assertEqual(labels["SMALL"], "DA3 Small (Missing)")
|
||||||
|
|
||||||
|
def test_packed_image_is_exported_without_unpacking(self):
|
||||||
|
operator = self.addon.ConvertImageToDepthPlane()
|
||||||
|
operator.snapform_depth_source = "ESTIMATE"
|
||||||
|
operator.snapform_model = "BASE"
|
||||||
|
operator.snapform_use_ray_pose = True
|
||||||
|
operator.snapform_process_res_factor = 0.5
|
||||||
|
operator.snapform_displacement_via = "MATERIAL"
|
||||||
|
operator.snapform_subdivision_level = 5
|
||||||
|
operator.snapform_delete_original = True
|
||||||
|
image = SimpleNamespace(
|
||||||
|
filepath="",
|
||||||
|
packed_file=object(),
|
||||||
|
size=(1920, 1080),
|
||||||
|
)
|
||||||
|
context = SimpleNamespace(
|
||||||
|
object=SimpleNamespace(name="Reference", data=image),
|
||||||
|
scene=object(),
|
||||||
|
)
|
||||||
|
calls = []
|
||||||
|
self.fake_bpy.ops = SimpleNamespace(
|
||||||
|
snapform=SimpleNamespace(
|
||||||
|
run_job=lambda **options: calls.append(options)
|
||||||
|
or {"RUNNING_MODAL"},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
packed_path = Path(directory) / "packed.png"
|
||||||
|
packed_path.touch()
|
||||||
|
with patch.object(
|
||||||
|
self.addon.image_depth,
|
||||||
|
"save_packed_image",
|
||||||
|
return_value=packed_path,
|
||||||
|
) as save_packed_image:
|
||||||
|
result = operator.execute(context)
|
||||||
|
|
||||||
|
self.assertEqual(result, {"FINISHED"})
|
||||||
|
save_packed_image.assert_called_once_with(image, context.scene)
|
||||||
|
self.assertEqual(calls[0]["snapform_input_path"], str(packed_path))
|
||||||
|
self.assertEqual(calls[0]["snapform_process_res"], 960)
|
||||||
|
self.assertEqual(
|
||||||
|
calls[0]["snapform_displacement_via"],
|
||||||
|
"MATERIAL",
|
||||||
|
)
|
||||||
|
self.assertTrue(calls[0]["snapform_delete_input"])
|
||||||
|
|
||||||
|
def test_packed_image_export_writes_a_png_copy(self):
|
||||||
|
class PackedImage:
|
||||||
|
def save_render(self, filepath, scene):
|
||||||
|
self.filepath = filepath
|
||||||
|
self.scene = scene
|
||||||
|
self.file_format = scene.render.image_settings.file_format
|
||||||
|
Path(filepath).write_bytes(b"packed image")
|
||||||
|
|
||||||
|
image = PackedImage()
|
||||||
|
image_settings = SimpleNamespace(file_format="OPEN_EXR")
|
||||||
|
scene = SimpleNamespace(
|
||||||
|
render=SimpleNamespace(image_settings=image_settings),
|
||||||
|
)
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
output_path = Path(directory) / "input.png"
|
||||||
|
with patch.object(
|
||||||
|
self.addon.runtime,
|
||||||
|
"temporary_input_path",
|
||||||
|
return_value=output_path,
|
||||||
|
):
|
||||||
|
result = self.addon.image_depth.save_packed_image(image, scene)
|
||||||
|
|
||||||
|
self.assertEqual(result, output_path)
|
||||||
|
self.assertEqual(output_path.read_bytes(), b"packed image")
|
||||||
|
self.assertEqual(image.filepath, str(output_path))
|
||||||
|
self.assertIs(image.scene, scene)
|
||||||
|
self.assertEqual(image.file_format, "PNG")
|
||||||
|
self.assertEqual(image_settings.file_format, "OPEN_EXR")
|
||||||
|
|
||||||
|
def test_temporary_packed_input_is_removed_when_job_closes(self):
|
||||||
|
closed = []
|
||||||
|
with TemporaryDirectory() as directory:
|
||||||
|
input_path = Path(directory) / "packed.png"
|
||||||
|
input_path.touch()
|
||||||
|
self.addon.operators._active_job = SimpleNamespace(
|
||||||
|
log_file=SimpleNamespace(close=lambda: closed.append(True)),
|
||||||
|
temporary_input_path=input_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addon.operators.close_active_job()
|
||||||
|
|
||||||
|
self.assertFalse(input_path.exists())
|
||||||
|
self.assertEqual(closed, [True])
|
||||||
|
self.assertIsNone(self.addon.operators.active_job())
|
||||||
|
|
||||||
def test_image_depth_model_and_ray_pose_are_independent(self):
|
def test_image_depth_model_and_ray_pose_are_independent(self):
|
||||||
settings = SimpleNamespace(
|
settings = SimpleNamespace(
|
||||||
@@ -255,7 +424,7 @@ class AddonTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
reconstruct.call_args.kwargs["process_res_method"],
|
reconstruct.call_args.kwargs["process_res_method"],
|
||||||
"lower_bound_resize",
|
"upper_bound_resize",
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
reconstruct.call_args.kwargs["use_ray_pose"],
|
reconstruct.call_args.kwargs["use_ray_pose"],
|
||||||
@@ -279,7 +448,7 @@ class AddonTest(unittest.TestCase):
|
|||||||
str(runtime.DEFAULT_STORAGE_ROOT),
|
str(runtime.DEFAULT_STORAGE_ROOT),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_process_res_method_defaults_to_lower_bound_resize(self):
|
def test_process_res_method_defaults_to_upper_bound_resize(self):
|
||||||
process_res = self.addon.SnapFormSettings.__annotations__[
|
process_res = self.addon.SnapFormSettings.__annotations__[
|
||||||
"process_res"
|
"process_res"
|
||||||
]
|
]
|
||||||
@@ -290,7 +459,7 @@ class AddonTest(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(process_res["default"], 1024)
|
self.assertEqual(process_res["default"], 1024)
|
||||||
self.assertEqual(process_res_method["default"], "lower_bound_resize")
|
self.assertEqual(process_res_method["default"], "upper_bound_resize")
|
||||||
|
|
||||||
def test_reconstruct_command_preserves_da3_parameter_names(self):
|
def test_reconstruct_command_preserves_da3_parameter_names(self):
|
||||||
runtime = importlib.import_module("snapform.runtime")
|
runtime = importlib.import_module("snapform.runtime")
|
||||||
@@ -404,7 +573,7 @@ class AddonTest(unittest.TestCase):
|
|||||||
gs_model = self.addon.SnapFormSettings.__annotations__[
|
gs_model = self.addon.SnapFormSettings.__annotations__[
|
||||||
"gs_model"
|
"gs_model"
|
||||||
]
|
]
|
||||||
identifiers = [item[0] for item in gs_model["items"]]
|
identifiers = [item[0] for item in gs_model["items"](None, None)]
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
identifiers,
|
identifiers,
|
||||||
@@ -422,13 +591,13 @@ class AddonTest(unittest.TestCase):
|
|||||||
self.addon.DebugPanel.draw
|
self.addon.DebugPanel.draw
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(draw_source.count("layout.operator("), 1)
|
self.assertEqual(draw_source.count("export_row.operator("), 1)
|
||||||
self.assertIn('settings.export_format == "glb"', draw_source)
|
self.assertIn('settings.export_format == "glb"', draw_source)
|
||||||
self.assertIn('settings.export_format == "gs_ply"', draw_source)
|
self.assertIn('settings.export_format == "gs_ply"', draw_source)
|
||||||
self.assertIn('"gs_model" if settings.export_format', draw_source)
|
self.assertIn('"gs_model" if settings.export_format', draw_source)
|
||||||
self.assertIn("model_row.prop(settings, model_property)", draw_source)
|
self.assertIn("layout.prop(settings, model_property)", draw_source)
|
||||||
self.assertIn('model_row.label(text="", icon="CHECKMARK")', draw_source)
|
self.assertNotIn("model_row", draw_source)
|
||||||
self.assertIn('model_row.label(text="", icon="ERROR")', draw_source)
|
self.assertIn("export_row.enabled = active_job() is None", draw_source)
|
||||||
self.assertNotIn('text="Ready"', draw_source)
|
self.assertNotIn('text="Ready"', draw_source)
|
||||||
self.assertNotIn('text="Missing"', draw_source)
|
self.assertNotIn('text="Missing"', draw_source)
|
||||||
self.assertNotIn("License:", draw_source)
|
self.assertNotIn("License:", draw_source)
|
||||||
@@ -444,6 +613,11 @@ class AddonTest(unittest.TestCase):
|
|||||||
|
|
||||||
self.assertIn("for model in MODELS.values()", draw_source)
|
self.assertIn("for model in MODELS.values()", draw_source)
|
||||||
self.assertIn("operator.model_key = model.key", model_source)
|
self.assertIn("operator.model_key = model.key", model_source)
|
||||||
|
self.assertIn(
|
||||||
|
"download_row.enabled = runtime_ready() and not running",
|
||||||
|
model_source,
|
||||||
|
)
|
||||||
|
self.assertNotIn('label(text="Not downloaded"', model_source)
|
||||||
self.assertNotIn("Isolated Runtime ready", draw_source)
|
self.assertNotIn("Isolated Runtime ready", draw_source)
|
||||||
self.assertNotIn("License:", model_source)
|
self.assertNotIn("License:", model_source)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@@ -451,6 +625,14 @@ class AddonTest(unittest.TestCase):
|
|||||||
"Setup",
|
"Setup",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_preferences_install_button_is_disabled_while_busy(self):
|
||||||
|
draw_source = inspect.getsource(self.addon.SnapFormPreferences.draw)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"install_row.enabled = active_job() is None",
|
||||||
|
draw_source,
|
||||||
|
)
|
||||||
|
|
||||||
def test_completed_export_opens_job_directory(self):
|
def test_completed_export_opens_job_directory(self):
|
||||||
calls = []
|
calls = []
|
||||||
self.fake_bpy.ops = SimpleNamespace(
|
self.fake_bpy.ops = SimpleNamespace(
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ class ImageDepthTest(unittest.TestCase):
|
|||||||
self.assertAlmostEqual(value, expected)
|
self.assertAlmostEqual(value, expected)
|
||||||
|
|
||||||
def test_source_empty_is_only_removed_when_requested(self):
|
def test_source_empty_is_only_removed_when_requested(self):
|
||||||
source = inspect.getsource(self.image_depth.build_depth_plane)
|
source = inspect.getsource(self.image_depth.finish_depth_plane)
|
||||||
|
|
||||||
self.assertIn("source_name = source_object.name", source)
|
self.assertIn("source_name = source_object.name", source)
|
||||||
self.assertIn("if delete_source:", source)
|
self.assertIn("if delete_source:", source)
|
||||||
@@ -243,27 +243,88 @@ class ImageDepthTest(unittest.TestCase):
|
|||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
self.assertNotIn("source_object.name =", source)
|
self.assertNotIn("source_object.name =", source)
|
||||||
self.assertIn(
|
|
||||||
"bpy.data.objects.new(\n source_name,",
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"bpy.data.objects.remove(source_object, do_unlink=True)\n"
|
"bpy.data.objects.remove(source_object, do_unlink=True)\n"
|
||||||
" plane_object.name = source_name",
|
" plane_object.name = source_name",
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_original_depth_mode_uses_uv_and_zero_midlevel(self):
|
||||||
|
source = inspect.getsource(self.image_depth.build_image_depth_plane)
|
||||||
|
|
||||||
|
self.assertIn("texture.image = source_object.data", source)
|
||||||
|
self.assertIn('modifiers.new("Displace", "DISPLACE")', source)
|
||||||
|
self.assertIn("displace.texture = texture", source)
|
||||||
|
self.assertIn('displace.texture_coords = "UV"', source)
|
||||||
|
self.assertNotIn("displace.direction", source)
|
||||||
|
self.assertNotIn("displace.strength", source)
|
||||||
|
self.assertIn(
|
||||||
|
"displace.mid_level = ORIGINAL_DISPLACEMENT_MIDLEVEL",
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn("plane_object.matrix_world =", source)
|
||||||
|
|
||||||
|
def test_process_resolution_scales_the_image_long_edge(self):
|
||||||
|
image = SimpleNamespace(size=(1080, 1920))
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.image_depth.scaled_process_resolution(image, 0.5),
|
||||||
|
960,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mesh_uses_source_object_name(self):
|
||||||
|
source = inspect.getsource(self.image_depth.create_plane_mesh)
|
||||||
|
|
||||||
|
self.assertIn("bpy.data.meshes.new(source_object.name)", source)
|
||||||
|
|
||||||
|
def test_depth_data_uses_source_image_name(self):
|
||||||
|
source_object = SimpleNamespace(
|
||||||
|
name="obj_img",
|
||||||
|
data=SimpleNamespace(name="img.png"),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
self.image_depth.depth_data_name(source_object),
|
||||||
|
"img.depth",
|
||||||
|
)
|
||||||
|
source_object.data.name = "img.png.001"
|
||||||
|
self.assertEqual(
|
||||||
|
self.image_depth.depth_data_name(source_object),
|
||||||
|
"img.001.depth",
|
||||||
|
)
|
||||||
|
estimate_source = inspect.getsource(
|
||||||
|
self.image_depth.build_depth_plane
|
||||||
|
)
|
||||||
|
original_source = inspect.getsource(
|
||||||
|
self.image_depth.build_image_depth_plane
|
||||||
|
)
|
||||||
|
self.assertIn("depth_name = depth_data_name(source_object)", estimate_source)
|
||||||
|
self.assertIn("depth_data_name(source_object)", original_source)
|
||||||
|
|
||||||
|
def test_material_uses_source_object_name(self):
|
||||||
|
da3_source = inspect.getsource(self.image_depth.build_depth_plane)
|
||||||
|
image_source = inspect.getsource(
|
||||||
|
self.image_depth.build_image_depth_plane
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"create_material(\n source_name,",
|
||||||
|
da3_source,
|
||||||
|
)
|
||||||
|
self.assertIn("create_material(\n source_name,", image_source)
|
||||||
|
self.assertNotIn("DA3DepthMaterial", da3_source)
|
||||||
|
|
||||||
def test_displace_texture_uses_extend(self):
|
def test_displace_texture_uses_extend(self):
|
||||||
source = inspect.getsource(
|
source = inspect.getsource(
|
||||||
self.image_depth.build_depth_plane
|
self.image_depth.build_depth_plane
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn('depth_name = f"{source_name}.depth"', source)
|
self.assertIn("depth_name = depth_data_name(source_object)", source)
|
||||||
self.assertIn("create_depth_image(\n depth_name,", source)
|
self.assertIn("create_depth_image(\n depth_name,", source)
|
||||||
self.assertIn("bpy.data.textures.new(\n depth_name,", source)
|
self.assertIn("bpy.data.textures.new(\n depth_name,", source)
|
||||||
self.assertIn('texture.extension = "EXTEND"', source)
|
self.assertIn('texture.extension = "EXTEND"', source)
|
||||||
|
|
||||||
def test_original_image_drives_bump_instead_of_displacement(self):
|
def test_material_supports_bump_and_optional_displacement(self):
|
||||||
source = inspect.getsource(self.image_depth.create_material)
|
source = inspect.getsource(self.image_depth.create_material)
|
||||||
|
|
||||||
self.assertIn('nodes.new("ShaderNodeBump")', source)
|
self.assertIn('nodes.new("ShaderNodeBump")', source)
|
||||||
@@ -297,16 +358,94 @@ class ImageDepthTest(unittest.TestCase):
|
|||||||
'curve_points[-1].location = (1.0, 0.1)',
|
'curve_points[-1].location = (1.0, 0.1)',
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertNotIn('shader.inputs["IOR"]', source)
|
||||||
'shader.inputs["IOR"].default_value = 1.2',
|
|
||||||
source,
|
|
||||||
)
|
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
'roughness_curve.location = (-300.0, 320.0)',
|
'roughness_curve.location = (-300.0, 320.0)',
|
||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
self.assertIn('bump.location = (-300.0, -80.0)', source)
|
self.assertIn('bump.location = (-300.0, -80.0)', source)
|
||||||
self.assertNotIn("ShaderNodeDisplacement", source)
|
self.assertIn('nodes.new("ShaderNodeDisplacement")', source)
|
||||||
|
self.assertIn(
|
||||||
|
'depth_texture.outputs["Color"],\n'
|
||||||
|
' displacement.inputs["Height"]',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'displacement.outputs["Displacement"],\n'
|
||||||
|
' output.inputs["Displacement"]',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'displacement.inputs["Midlevel"].default_value',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
'displacement.inputs["Scale"].default_value',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
self.assertIn("set_displacement_only(material)", source)
|
||||||
|
|
||||||
|
def test_displacement_values_follow_depth_source(self):
|
||||||
|
estimate_source = inspect.getsource(
|
||||||
|
self.image_depth.build_depth_plane
|
||||||
|
)
|
||||||
|
original_source = inspect.getsource(
|
||||||
|
self.image_depth.build_image_depth_plane
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"displacement_midlevel=DISPLACEMENT_MIDLEVEL",
|
||||||
|
estimate_source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"displacement_scale=DISPLACEMENT_STRENGTH",
|
||||||
|
estimate_source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"displacement_midlevel=ORIGINAL_DISPLACEMENT_MIDLEVEL",
|
||||||
|
original_source,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"displacement_scale=ORIGINAL_DISPLACEMENT_STRENGTH",
|
||||||
|
original_source,
|
||||||
|
)
|
||||||
|
self.assertEqual(self.image_depth.DISPLACEMENT_MIDLEVEL, 1.0)
|
||||||
|
self.assertEqual(self.image_depth.DISPLACEMENT_STRENGTH, -1.0)
|
||||||
|
self.assertEqual(
|
||||||
|
self.image_depth.ORIGINAL_DISPLACEMENT_MIDLEVEL,
|
||||||
|
0.0,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
self.image_depth.ORIGINAL_DISPLACEMENT_STRENGTH,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_material_displacement_mode_skips_displace_modifier(self):
|
||||||
|
estimate_source = inspect.getsource(
|
||||||
|
self.image_depth.build_depth_plane
|
||||||
|
)
|
||||||
|
original_source = inspect.getsource(
|
||||||
|
self.image_depth.build_image_depth_plane
|
||||||
|
)
|
||||||
|
|
||||||
|
for source in (estimate_source, original_source):
|
||||||
|
self.assertIn('if displacement_via == "MODIFIER":', source)
|
||||||
|
self.assertIn(
|
||||||
|
'if displacement_via == "MATERIAL" else None',
|
||||||
|
source,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_surface_displacement_is_set_to_displacement_only(self):
|
||||||
|
class Material:
|
||||||
|
surface_displacement_method = "BUMP"
|
||||||
|
|
||||||
|
material = Material()
|
||||||
|
self.image_depth.set_displacement_only(material)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
material.surface_displacement_method,
|
||||||
|
"DISPLACEMENT",
|
||||||
|
)
|
||||||
|
|
||||||
def test_plane_transform_scales_depth_axis(self):
|
def test_plane_transform_scales_depth_axis(self):
|
||||||
source = inspect.getsource(self.image_depth.apply_plane_transform)
|
source = inspect.getsource(self.image_depth.apply_plane_transform)
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class PackTest(unittest.TestCase):
|
|||||||
finally:
|
finally:
|
||||||
pack.DIST_DIR = original_dist
|
pack.DIST_DIR = original_dist
|
||||||
|
|
||||||
self.assertEqual(output_path.name, "SnapForm.v0.9.27.b45.zip")
|
self.assertEqual(output_path.name, "SnapForm.v0.9.27.zip")
|
||||||
with ZipFile(output_path) as archive:
|
with ZipFile(output_path) as archive:
|
||||||
names = set(archive.namelist())
|
names = set(archive.namelist())
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class WorkerTest(unittest.TestCase):
|
|||||||
source = inspect.getsource(self.worker.reconstruct)
|
source = inspect.getsource(self.worker.reconstruct)
|
||||||
|
|
||||||
self.assertLess(
|
self.assertLess(
|
||||||
source.index('"Starting DA3 runtime"'),
|
source.index('"Starting AI runtime"'),
|
||||||
source.index("import torch"),
|
source.index("import torch"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user