Add SnapForm panels, preferences, properties, and runtime management

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

View File

@@ -1,41 +0,0 @@
import bpy
from .operators import (
OMOO_DA3_OT_cancel_job,
OMOO_DA3_OT_run_job,
cancel_active_job,
close_active_job,
)
from .panel import OMOO_DA3_PT_debug, OMOO_DA3_PT_runtime_models
from .preferences import OMOO_DA3_preferences
from .properties import OMOO_DA3_PG_settings
from .status_bar import draw_status_bar
CLASSES = (
OMOO_DA3_preferences,
OMOO_DA3_PG_settings,
OMOO_DA3_OT_run_job,
OMOO_DA3_OT_cancel_job,
OMOO_DA3_PT_runtime_models,
OMOO_DA3_PT_debug,
)
def register():
for class_type in CLASSES:
bpy.utils.register_class(class_type)
bpy.types.Scene.omoo_da3_settings = bpy.props.PointerProperty(
type=OMOO_DA3_PG_settings,
)
bpy.types.STATUSBAR_HT_header.prepend(draw_status_bar)
def unregister():
cancel_active_job()
close_active_job()
bpy.types.STATUSBAR_HT_header.remove(draw_status_bar)
if hasattr(bpy.types.Scene, "omoo_da3_settings"):
del bpy.types.Scene.omoo_da3_settings
for class_type in reversed(CLASSES):
bpy.utils.unregister_class(class_type)

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

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

View File

@@ -1,9 +1,9 @@
schema_version = "1.0.0"
id = "omoo_da3"
version = "0.8.1"
name = "OmooLab DA3"
tagline = "Export official DA3 GLB, PLY, depth, and mini NPZ"
id = "snapform"
version = "0.9.27"
name = "SnapForm"
tagline = "Build spatial structures from images, fast"
maintainer = "OmooLab"
type = "add-on"

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,36 +1,33 @@
import bpy
from .operators import active_job
def draw_status_bar(self, context):
scene = getattr(context, "scene", None)
if active_job() is None or scene is None:
job = active_job()
if job is None or scene is None:
return
settings = getattr(scene, "omoo_da3_settings", None)
settings = getattr(scene, "snapform_settings", None)
if settings is None:
return
progress = min(max(settings.progress, 0.0), 1.0)
row = self.layout.row(align=True)
row.label(text=f"Omoo DA3: {settings.status}", icon="TIME")
progress_row = row.row(align=True)
progress_row.ui_units_x = 9.0
progress_row.ui_units_x = 12.0
progress_row.progress(
factor=progress,
type="BAR",
text=f"{progress:.0%}",
text=settings.status,
)
row.operator(
"omoo_da3.cancel_job",
"snapform.cancel_job",
text="",
icon="X",
)
def redraw_ui(context, force=False):
def tag_ui_regions(context):
window_manager = getattr(context, "window_manager", None)
if window_manager is None:
return
@@ -42,10 +39,9 @@ def redraw_ui(context, force=False):
for area in screen.areas:
if area.type in {"STATUSBAR", "VIEW_3D"}:
area.tag_redraw()
for region in getattr(area, "regions", ()):
region.tag_redraw()
if not force or getattr(context, "window", None) is None:
return
try:
bpy.ops.wm.redraw_timer(type="DRAW_WIN_SWAP", iterations=1)
except RuntimeError:
pass
def redraw_ui(context, force=False):
tag_ui_regions(context)

View File

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