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:
2026-08-03 13:47:15 +08:00
parent b02372c457
commit a503786c62
14 changed files with 827 additions and 174 deletions

View File

@@ -5,36 +5,29 @@ 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
ORIGINAL_DISPLACEMENT_STRENGTH = 1.0
ORIGINAL_DISPLACEMENT_MIDLEVEL = 0.0
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):
@@ -58,6 +51,60 @@ def image_path(image):
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):
image_width, image_height = source_object.data.size
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.update()
for polygon in mesh.polygons:
@@ -369,6 +416,9 @@ def create_material(
name,
color_image,
depth_scale,
displacement_image=None,
displacement_midlevel=0.0,
displacement_scale=1.0,
):
material = bpy.data.materials.new(name)
material.use_nodes = True
@@ -387,8 +437,6 @@ def create_material(
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)
@@ -415,15 +463,118 @@ def create_material(
shader.inputs["Roughness"],
)
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
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(
context,
source_object,
npz_path,
subdivision_level,
delete_source,
displacement_via="MODIFIER",
):
source_name = source_object.name
depth = extract_depth_array(npz_path)
@@ -435,7 +586,7 @@ def build_depth_plane(
intrinsics,
DISPLACEMENT_STRENGTH,
)
depth_name = f"{source_name}.depth"
depth_name = depth_data_name(source_object)
depth_image = create_depth_image(
depth_name,
depth_values,
@@ -457,23 +608,29 @@ def build_depth_plane(
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
if displacement_via == "MODIFIER":
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_name,
source_object.data,
depth_scale,
displacement_image=(
depth_image if displacement_via == "MATERIAL" else None
),
displacement_midlevel=DISPLACEMENT_MIDLEVEL,
displacement_scale=DISPLACEMENT_STRENGTH,
)
mesh.materials.append(material)
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_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
return finish_depth_plane(
context,
source_object,
plane_object,
delete_source,
)
class BuildDepthPlane(bpy.types.Operator):
@@ -512,6 +667,14 @@ class BuildDepthPlane(bpy.types.Operator):
default=True,
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):
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():
self.report(
{"ERROR"},
f"DA3 Mini NPZ does not exist: {npz_path}",
f"Depth result does not exist: {npz_path}",
)
return {"CANCELLED"}
@@ -537,6 +700,7 @@ class BuildDepthPlane(bpy.types.Operator):
npz_path,
self.snapform_subdivision_level,
self.snapform_delete_source,
self.snapform_displacement_via,
)
except (OSError, RuntimeError, ValueError) as error:
self.report({"ERROR"}, str(error))
@@ -547,27 +711,49 @@ class BuildDepthPlane(bpy.types.Operator):
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_description = "Convert an image to a displaced depth plane"
bl_options = {"REGISTER"}
snapform_depth_source: bpy.props.EnumProperty(
name="Depth from",
description="Source used to displace the plane",
items=(
(
"ESTIMATE",
"AI Generated",
"Generate depth with an AI model",
),
("ORIGINAL", "Original", "Use the original image as depth"),
),
default="ORIGINAL",
)
snapform_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(
name="Model",
description="DA3 model used to estimate image depth",
items=IMAGE_DEPTH_MODEL_ITEMS,
default="BASE",
description="Model used to estimate image depth",
items=image_depth_model_items,
)
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_process_res_factor: bpy.props.FloatProperty(
name="Resolution Scale",
description="Scale applied to the source image long-edge resolution",
default=0.5,
min=0.1,
max=1.0,
step=10,
precision=2,
)
snapform_delete_original: bpy.props.BoolProperty(
name="Delete Original",
@@ -589,13 +775,27 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
return is_image_empty(context.object) and active_job() is None
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)
def draw(self, _context):
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_depth_source")
if self.snapform_depth_source == "ESTIMATE":
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_delete_original")
@@ -603,7 +803,47 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
from .operators import IMAGE_DEPTH_JOB
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():
self.report(
{"ERROR"},
@@ -617,11 +857,15 @@ class ConvertImageToDepthPlane(bpy.types.Operator):
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_process_res=process_res,
snapform_subdivision_level=self.snapform_subdivision_level,
snapform_delete_source=self.snapform_delete_original,
snapform_delete_input=delete_input,
snapform_displacement_via=self.snapform_displacement_via,
)
if "CANCELLED" in result:
if delete_input:
source_path.unlink(missing_ok=True)
return {"CANCELLED"}
return {"FINISHED"}