feat(cutout_tool): Implement cutout region to shape functionality

- Added interaction helpers for cutout region validation and shape menu interaction.
- Created main operator for converting image regions into cutout shapes, including AI artifact generation.
- Introduced object creation logic for cutout shapes, handling depth and normal textures.
- Defined cutout shape identifiers and node groups for various shape types.
- Implemented depth texture writing and prediction artifact generation for MoGe-2.
- Developed server jobs for generating cutout artifacts, depth planes, and refining image regions.
- Added region refinement logic to enhance gesture masks using BEN2.
This commit is contained in:
2026-08-31 11:36:27 +08:00
parent 08309f719d
commit 87d7721128
80 changed files with 4823 additions and 4297 deletions

View File

@@ -0,0 +1,80 @@
"""Load and scale Depth file artifacts shared by Blender workflows."""
import json
from pathlib import Path
import bpy
from .image_data import depth_data_name, image_empty_bounds
def load_depth_metadata(path):
"""Read and validate the compact camera metadata beside a Depth texture."""
metadata = json.loads(Path(path).read_text(encoding="utf-8"))
try:
image_size = tuple(int(value) for value in metadata["image_size"])
intrinsics = tuple(
tuple(float(value) for value in row)
for row in metadata["intrinsics"]
)
reference_depth = float(metadata["reference_depth"])
except (KeyError, TypeError, ValueError) as error:
raise ValueError("Depth metadata is invalid") from error
if (
len(image_size) != 2
or min(image_size) <= 0
or len(intrinsics) != 3
or any(len(row) != 3 for row in intrinsics)
or intrinsics[0][0] <= 0.0
or intrinsics[1][1] <= 0.0
or reference_depth <= 0.0
):
raise ValueError("Depth metadata is invalid")
return {
"image_size": image_size,
"intrinsics": intrinsics,
"reference_depth": reference_depth,
}
def load_depth_result_image(path, source_object, metadata):
"""Load, validate, name, configure, and pack a generated Depth texture."""
image = bpy.data.images.load(str(path), check_existing=False)
try:
if tuple(int(value) for value in image.size) != metadata["image_size"]:
raise ValueError("Depth texture dimensions do not match its metadata")
image.name = depth_data_name(source_object)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
image.pack()
return image
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
def camera_depth_values(image):
"""Return top-down camera Z and validity from a loaded Depth texture."""
import numpy as np
width, height = (int(value) for value in image.size)
pixels = np.empty(width * height * 4, dtype=np.float32)
image.pixels.foreach_get(pixels)
pixels = np.flipud(pixels.reshape((height, width, 4)))
return pixels[..., 2], pixels[..., 3] > 0.5
def depth_uniform_scale(source_object, metadata, bounds=None):
"""Convert model camera units uniformly into Image Empty local distance."""
import numpy as np
intrinsics = metadata["intrinsics"]
width, height = metadata["image_size"]
reference_depth = metadata["reference_depth"]
x_min, x_max, y_min, y_max = bounds or image_empty_bounds(source_object)
plane_width = x_max - x_min
plane_height = y_max - y_min
fx, fy = float(intrinsics[0][0]), float(intrinsics[1][1])
scale_x = plane_width * fx / (max(width - 1, 1) * reference_depth)
scale_y = plane_height * fy / (max(height - 1, 1) * reference_depth)
return float(np.sqrt(scale_x * scale_y))

View File

@@ -371,7 +371,7 @@ def force_sequence_source(image):
def load_normal_result_image(path, source_object):
"""Load, name, configure, and pack a tangent normal image."""
"""Load, name, configure, and pack a generated normal image."""
image = bpy.data.images.load(str(path), check_existing=False)
image.name = normal_data_name(source_object)
image.colorspace_settings.name = "Non-Color"

View File

@@ -148,22 +148,16 @@ def active_view3d_tool_id(context):
return getattr(tool, "idname", None)
def refine_selection_setting_name(operator):
if getattr(operator, "active_tool_id", None) == "anyimage.cutout_lasso":
return "cutout_refine_selection"
return {
"BOX": "image_box_refine_selection",
"LASSO": "image_lasso_refine_selection",
}.get(operator.gesture)
def refine_region_setting_name(operator):
setting_names = getattr(operator, "refine_region_settings", {})
return setting_names.get(operator.gesture)
def apply_region_settings(operator, context):
settings = context.scene.anyimage_settings
setting_name = refine_selection_setting_name(operator)
operator.refine_selection = (
setting_name is not None
and ai_ready()
and getattr(settings, setting_name)
setting_name = refine_region_setting_name(operator)
operator.refine_region = (
setting_name is not None and ai_ready() and getattr(settings, setting_name)
)
@@ -174,7 +168,7 @@ def draw_region_operator_settings(layout, operator):
status = ai_status()
row = layout.row()
row.enabled = status["ready"]
row.prop(operator, "refine_selection")
row.prop(operator, "refine_region")
layout.prop(operator, "invert")
if not status["ready"]:
layout.operator(
@@ -200,7 +194,9 @@ def draw_region_settings(context, layout, setting_name):
def dashed_line_segments(vertices, dash_length=4.0, gap_length=4.0):
if dash_length <= 0.0 or gap_length < 0.0:
raise ValueError("Dash length must be positive and gap length cannot be negative")
raise ValueError(
"Dash length must be positive and gap length cannot be negative"
)
pattern_length = dash_length + gap_length
segments = []
distance_offset = 0.0
@@ -259,16 +255,19 @@ def draw_dashed_line(
def _signed_area(vertices):
return sum(
start[0] * end[1] - end[0] * start[1]
for start, end in zip(vertices, (*vertices[1:], vertices[0]))
) * 0.5
return (
sum(
start[0] * end[1] - end[0] * start[1]
for start, end in zip(vertices, (*vertices[1:], vertices[0]))
)
* 0.5
)
def _triangle_cross(start, middle, end):
return (middle[0] - start[0]) * (end[1] - start[1]) - (
middle[1] - start[1]
) * (end[0] - start[0])
return (middle[0] - start[0]) * (end[1] - start[1]) - (middle[1] - start[1]) * (
end[0] - start[0]
)
def _point_in_triangle(point, start, middle, end, orientation, epsilon):
@@ -329,9 +328,7 @@ def triangulated_polygon_vertices(vertices):
try:
import mathutils
points = [
mathutils.Vector((vertex[0], vertex[1], 0.0)) for vertex in vertices
]
points = [mathutils.Vector((vertex[0], vertex[1], 0.0)) for vertex in vertices]
triangles = mathutils.geometry.tessellate_polygon([points])
return tuple(
(float(point[0]), float(point[1]))
@@ -342,12 +339,12 @@ def triangulated_polygon_vertices(vertices):
return _triangulated_polygon_vertices_python(vertices)
def scanline_fill_triangles(vertices, fill_rule='EVEN_ODD', step=2.0):
def scanline_fill_triangles(vertices, fill_rule="EVEN_ODD", step=2.0):
vertices = tuple(tuple(float(value) for value in vertex) for vertex in vertices)
if len(vertices) < 3:
return ()
if fill_rule not in {'EVEN_ODD', 'NONZERO'}:
raise ValueError(f'Unknown polygon fill rule: {fill_rule}')
if fill_rule not in {"EVEN_ODD", "NONZERO"}:
raise ValueError(f"Unknown polygon fill rule: {fill_rule}")
minimum_y = min(vertex[1] for vertex in vertices)
maximum_y = max(vertex[1] for vertex in vertices)
band_bottom = minimum_y
@@ -358,9 +355,7 @@ def scanline_fill_triangles(vertices, fill_rule='EVEN_ODD', step=2.0):
sample_y = (band_bottom + band_top) * 0.5
events = []
for start, end in edges:
if (start[1] <= sample_y < end[1]) or (
end[1] <= sample_y < start[1]
):
if (start[1] <= sample_y < end[1]) or (end[1] <= sample_y < start[1]):
factor = (sample_y - start[1]) / (end[1] - start[1])
events.append(
(
@@ -382,7 +377,7 @@ def scanline_fill_triangles(vertices, fill_rule='EVEN_ODD', step=2.0):
count += 1
index += 1
was_inside = winding != 0
if fill_rule == 'EVEN_ODD':
if fill_rule == "EVEN_ODD":
if count % 2:
winding = 0 if was_inside else 1
else:
@@ -412,9 +407,7 @@ def scanline_fill_triangles(vertices, fill_rule='EVEN_ODD', step=2.0):
def _point_on_segment(point, start, end, epsilon=1e-8):
return (
min(start[0], end[0]) - epsilon
<= point[0]
<= max(start[0], end[0]) + epsilon
min(start[0], end[0]) - epsilon <= point[0] <= max(start[0], end[0]) + epsilon
and min(start[1], end[1]) - epsilon
<= point[1]
<= max(start[1], end[1]) + epsilon
@@ -428,9 +421,9 @@ def _segments_intersect(first_start, first_end, second_start, second_end):
_triangle_cross(second_start, second_end, first_start),
_triangle_cross(second_start, second_end, first_end),
)
if (crosses[0] > 1e-8) != (crosses[1] > 1e-8) and (
crosses[2] > 1e-8
) != (crosses[3] > 1e-8):
if (crosses[0] > 1e-8) != (crosses[1] > 1e-8) and (crosses[2] > 1e-8) != (
crosses[3] > 1e-8
):
return True
return any(
abs(cross) <= 1e-8 and _point_on_segment(point, start, end)
@@ -456,14 +449,10 @@ def polygon_self_intersects(vertices, cell_size=32.0):
bottom = math.floor(min(start[1], end[1]) / cell_size)
top = math.floor(max(start[1], end[1]) / cell_size)
edge_cells = tuple(
(x, y)
for x in range(left, right + 1)
for y in range(bottom, top + 1)
(x, y) for x in range(left, right + 1) for y in range(bottom, top + 1)
)
candidates = {
candidate
for cell in edge_cells
for candidate in cells.get(cell, ())
candidate for cell in edge_cells for candidate in cells.get(cell, ())
}
for candidate in candidates:
if abs(edge_index - candidate) <= 1 or {
@@ -478,7 +467,7 @@ def polygon_self_intersects(vertices, cell_size=32.0):
return False
def preview_fill_triangles(vertices, fill_rule='EVEN_ODD'):
def preview_fill_triangles(vertices, fill_rule="EVEN_ODD"):
if not polygon_self_intersects(vertices):
triangles = triangulated_polygon_vertices(vertices)
if triangles:
@@ -541,10 +530,9 @@ def append_lasso_point(path, point):
anchor[0] + segment_x * factor,
anchor[1] + segment_y * factor,
)
forward = (
(previous[0] - anchor[0]) * (point[0] - previous[0])
+ (previous[1] - anchor[1]) * (point[1] - previous[1])
)
forward = (previous[0] - anchor[0]) * (point[0] - previous[0]) + (
previous[1] - anchor[1]
) * (point[1] - previous[1])
if (
0.0 <= factor <= 1.0
and forward >= 0.0
@@ -634,7 +622,7 @@ def image_line_half_plane_polygon(line, image_size):
class ImageRegionGesture:
active_tool_id = None
preview_fill_rule = 'EVEN_ODD'
preview_fill_rule = "EVEN_ODD"
_path = None
_cursor = None
_handle = None
@@ -675,7 +663,7 @@ class ImageRegionGesture:
polygon = self._screen_path()
if polygon != self._outline_polygon:
self._outline_polygon = polygon
if self.gesture == 'LINE':
if self.gesture == "LINE":
outline = (self._path[0], self._cursor)
else:
outline = (*polygon, polygon[0]) if len(polygon) > 1 else ()

View File

@@ -1,4 +1,3 @@
from dataclasses import dataclass
from pathlib import Path
import bpy
@@ -11,49 +10,6 @@ from .image_data import (
)
@dataclass(frozen=True)
class ImageGeometry:
points: object
intrinsics: object
valid: object
calibration_valid: object
image_size: tuple
reference_depth: float
region_transparent_fraction: float
def load_image_geometry(path):
"""Load and validate the shared image geometry Job artifact."""
import numpy as np
with np.load(path, allow_pickle=False) as archive:
geometry = ImageGeometry(
points=np.asarray(archive["points"], dtype=np.float32),
intrinsics=np.asarray(archive["intrinsics"], dtype=np.float32),
valid=np.asarray(archive["valid"], dtype=bool),
calibration_valid=np.asarray(archive["calibration_valid"], dtype=bool),
image_size=tuple(int(value) for value in archive["image_size"]),
reference_depth=float(archive["reference_depth"]),
region_transparent_fraction=float(
archive["region_transparent_fraction"]
),
)
if (
geometry.points.ndim != 3
or geometry.points.shape[2] != 3
or geometry.valid.shape != geometry.points.shape[:2]
or geometry.calibration_valid.shape != geometry.points.shape[:2]
):
raise ValueError("The mesh point field is invalid")
if (
geometry.intrinsics.shape != (3, 3)
or not geometry.reference_depth > 0.0
or not 0.0 <= geometry.region_transparent_fraction <= 1.0
):
raise ValueError("The mesh camera field is invalid")
return geometry
NODE_ASSET_NAME = "O_AnyImage.blend"
@@ -161,12 +117,9 @@ def set_material_color_space(image, scene=None):
preferred = material_color_space_name(scene)
original = settings.name
pixels = None
if (
preferred != original
and (
getattr(image, "source", None) == "GENERATED"
or getattr(image, "is_dirty", False)
)
if preferred != original and (
getattr(image, "source", None) == "GENERATED"
or getattr(image, "is_dirty", False)
):
pixels = image_pixels(image)
for color_space in dict.fromkeys((preferred, "sRGB", original)):
@@ -389,6 +342,7 @@ def create_image_material(
use_cyclic=None,
normal_image=None,
normal_space="TANGENT",
ior=None,
scene=None,
):
material = bpy.data.materials.new(image_base_name(source_object.data))
@@ -483,6 +437,8 @@ def create_image_material(
else:
shader = nodes.new("ShaderNodeBsdfPrincipled")
shader.location = (250.0, 400.0)
if ior is not None:
shader.inputs["IOR"].default_value = float(ior)
links.new(
material_inputs.outputs["Color"],

View File

@@ -1,162 +0,0 @@
import bpy
from ..common.mesh_result import load_image_geometry
from ..common.image_data import (
color_data_name,
depth_data_name,
image_pixels,
load_color_result_image,
load_normal_result_image,
image_empty_bounds,
require_image_empty,
)
from ..common.mesh_result import create_image_material
from ..common.mesh_result import modifier_input_identifier, set_modifier_input
from ..common.mesh_result import finalize_object_result
from .mesh_plane import create_mesh_plane_mesh, create_mesh_plane_object
from .normal_map import bake_tangent_normal_image
from .nodes import depth_plane_node_group
def copy_color_image(source_object):
source = source_object.data
image = source.copy()
if getattr(source, "source", None) == "GENERATED" or getattr(
source, "is_dirty", False
):
pixels = image_pixels(source)
image.pixels.foreach_set(pixels)
image.update()
image.name = color_data_name(source_object)
return image
def build_mesh_plane(context, source_object, subdivisions, material):
source_name = source_object.name
plane = create_mesh_plane_object(
context,
source_name,
image_empty_bounds(source_object),
material,
matrix_world=source_object.matrix_world,
subdivisions=subdivisions,
)
for polygon in plane.data.polygons:
polygon.use_smooth = True
plane["anyimage_mesh_shape"] = "PLANE"
return finalize_object_result(context, source_object, plane)
def anchored_point_field(source_object, field):
import numpy as np
points = field.points
intrinsics = field.intrinsics
valid = field.valid
width, height = field.image_size
x_min, x_max, y_min, y_max = image_empty_bounds(source_object)
plane_width = x_max - x_min
plane_height = y_max - y_min
fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1])
cx, cy = float(intrinsics[0, 2]), float(intrinsics[1, 2])
if fx <= 0.0 or fy <= 0.0:
raise ValueError("Camera intrinsics contain an invalid focal length")
scale_x = plane_width * fx / (max(width - 1, 1) * field.reference_depth)
scale_y = plane_height * fy / (max(height - 1, 1) * field.reference_depth)
scale_z = float(np.sqrt(scale_x * scale_y))
camera_x = x_min + plane_width * cx / max(width - 1, 1)
camera_y = y_max - plane_height * cy / max(height - 1, 1)
anchored = np.empty_like(points, dtype=np.float32)
anchored[..., 0] = camera_x + points[..., 0] * scale_x
anchored[..., 1] = camera_y - points[..., 1] * scale_y
anchored[..., 2] = (field.reference_depth - points[..., 2]) * scale_z
return anchored, valid, points[..., 2]
def create_depth_image(source_object, anchored_points):
import numpy as np
values = np.asarray(anchored_points, dtype=np.float32)[..., 2]
height, width = values.shape
pixels = np.empty((height, width, 4), dtype=np.float32)
pixels[:, :, :3] = values[:, :, None]
pixels[:, :, 3] = 1.0
image = bpy.data.images.new(
depth_data_name(source_object),
width=width,
height=height,
alpha=True,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
image.pixels.foreach_set(np.flipud(pixels).ravel())
image.pack()
return image
def build_depth_plane(context, source_object, subdivisions, material, field):
source_name = source_object.name
mesh = create_mesh_plane_mesh(source_name, image_empty_bounds(source_object))
for polygon in mesh.polygons:
polygon.use_smooth = True
mesh.materials.append(material)
plane = bpy.data.objects.new(source_name, mesh)
context.collection.objects.link(plane)
plane.matrix_world = source_object.matrix_world.copy()
plane["anyimage_mesh_shape"] = "DEPTH_PLANE"
node_group = depth_plane_node_group()
modifier = plane.modifiers.new(name=node_group.name, type="NODES")
modifier.node_group = node_group
modifier.show_group_selector = False
depth_image = create_depth_image(
source_object, anchored_point_field(source_object, field)[0]
)
for name, value in (
("Subdivide", max(0, min(10, int(subdivisions)))),
("Material", material),
("Thickness", 0.1),
("Depth Amount", 1.0),
("Depth Image", depth_image),
):
set_modifier_input(
modifier,
modifier_input_identifier(node_group, name),
value,
)
return finalize_object_result(context, source_object, plane)
def build_depth_plane_response(context, result, operator):
source_object = require_image_empty(operator.source_object_name)
color_image = load_color_result_image(source_object, result.directory)
color_image.name = color_data_name(source_object)
normal_path = result.directory / "normal.png"
normal_image = (
load_normal_result_image(normal_path, source_object)
if normal_path.is_file()
else None
)
material = create_image_material(
source_object,
color_image,
0.0,
normal_image=normal_image,
scene=context.scene,
)
plane = build_depth_plane(
context,
source_object,
operator.mesh_detail,
material,
load_image_geometry(result.file("image_geometry")),
)
if normal_image is not None:
bake_tangent_normal_image(
context,
source_object,
plane,
normal_image,
)
if "FINISHED" not in bpy.ops.ed.undo_push(message="Convert to Depth Plane"):
raise RuntimeError("Unable to create the Convert to Depth Plane Undo step")

View File

@@ -8,6 +8,14 @@ from ...server.media.resolution import (
MAX_MAX_AI_INPUT_SIZE,
MIN_MAX_AI_INPUT_SIZE,
)
from ..common.ai_job import (
invoke_ai_setup_if_needed,
moge2_parameters,
production_device,
require_environment,
require_input_path,
require_model,
)
from ..common.image_data import (
cleanup_image_input,
image_user_settings,
@@ -17,18 +25,11 @@ from ..common.image_data import (
video_total_frames,
)
from ..common.mesh_result import create_image_material
from ..common.ai_job import (
da3_parameters,
require_input_path,
invoke_ai_setup_if_needed,
moge2_parameters,
production_device,
require_environment,
require_model,
from .object import (
create_color_image,
create_depth_plane_from_result,
create_plane_object,
)
from .build import build_depth_plane_response, build_mesh_plane, copy_color_image
from .resolution import scaled_process_resolution
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
@@ -36,23 +37,21 @@ _HIDDEN = {"HIDDEN", "SKIP_SAVE"}
def depth_plane_settings(context):
preferences = addon_preferences(context)
return (
getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY),
float(getattr(preferences, "mesh_process_res_factor", 100.0)),
int(getattr(preferences, "mesh_resolution_level", 5)),
getattr(preferences, "geometry_model", DEFAULT_MOGE2_MODEL_KEY),
int(getattr(preferences, "geometry_resolution_level", 5)),
)
class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.generate_depth_plane"
bl_label = "Generate Depth Plane"
job_type = "generate-depth-plane"
job_type = "generate-depth-plane-geometry"
starting_message = "Preparing Depth Plane"
input_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
mesh_detail: bpy.props.IntProperty(default=0, min=0, max=10, options=_HIDDEN)
model: bpy.props.StringProperty(default=DEFAULT_MOGE2_MODEL_KEY, options=_HIDDEN)
process_res: bpy.props.IntProperty(default=1008, min=1, options=_HIDDEN)
resolution_level: bpy.props.IntProperty(default=5, min=0, max=9, options=_HIDDEN)
max_input_size: bpy.props.IntProperty(
default=DEFAULT_MAX_AI_INPUT_SIZE,
@@ -65,38 +64,27 @@ class GenerateDepthPlane(JobOperatorBase, bpy.types.Operator):
def request(self, context):
require_environment()
require_model(self.model)
input_path = require_input_path(self.input_path)
parameters = {
"input": str(require_input_path(self.input_path)),
"input": str(input_path),
"device": production_device().lower(),
"video_frames": 1,
"start_frame": source_start_frame(self.source_object_name),
"max_input_size": int(self.max_input_size),
}
settings = context.scene.anyimage_settings
if self.model.startswith("MOGE2_"):
parameters.update(
moge2_parameters(
settings,
require_input_path(self.input_path),
self.model,
self.resolution_level,
source_start_frame(self.source_object_name),
)
)
else:
parameters.update(
da3_parameters(
settings,
require_input_path(self.input_path),
self.model,
self.process_res,
source_start_frame(self.source_object_name),
)
parameters.update(
moge2_parameters(
context.scene.anyimage_settings,
input_path,
self.model,
self.resolution_level,
source_start_frame(self.source_object_name),
)
)
return parameters
def response(self, context, result):
build_depth_plane_response(context, result, self)
create_depth_plane_from_result(context, result, self)
return "Depth Plane is ready"
def cleanup(self):
@@ -116,12 +104,7 @@ def start_depth_plane_job(operator, context):
source_object.data,
context.scene,
)
model, process_factor, resolution_level = depth_plane_settings(context)
process_res = (
scaled_process_resolution(source_object.data, process_factor)
if not model.startswith("MOGE2_")
else 1
)
model, resolution_level = depth_plane_settings(context)
except (RuntimeError, ValueError) as error:
operator.report({"ERROR"}, str(error))
return {"CANCELLED"}
@@ -130,14 +113,12 @@ def start_depth_plane_job(operator, context):
source_object_name=source_object.name,
mesh_detail=operator.mesh_detail,
model=model,
process_res=process_res,
resolution_level=resolution_level,
max_input_size=configured_max_ai_input_size(context),
delete_input=delete_input,
)
if "CANCELLED" in result:
if delete_input:
cleanup_image_input(source_path, True)
if "CANCELLED" in result and delete_input:
cleanup_image_input(source_path, True)
return {"CANCELLED"} if "CANCELLED" in result else {"FINISHED"}
@@ -155,7 +136,7 @@ class ConvertToPlane(bpy.types.Operator):
def execute(self, context):
source_object = context.object
color_image = copy_color_image(source_object)
color_image = create_color_image(source_object)
frame_start, frame_offset, frame_duration, use_cyclic = image_user_settings(
source_object
)
@@ -169,7 +150,7 @@ class ConvertToPlane(bpy.types.Operator):
use_cyclic=use_cyclic,
scene=context.scene,
)
build_mesh_plane(context, source_object, self.mesh_detail, material)
create_plane_object(context, source_object, self.mesh_detail, material)
return {"FINISHED"}

View File

@@ -53,6 +53,14 @@ def depth_plane_node_group():
depth_amount = _interface_socket(group, "Depth Amount", "INPUT", "NodeSocketFloat")
depth_amount.default_value = 1.0
depth_amount.min_value = 0.0
reference_depth = _interface_socket(
group, "Reference Depth", "INPUT", "NodeSocketFloat"
)
reference_depth.hide_in_modifier = True
uniform_scale = _interface_socket(
group, "Uniform Scale", "INPUT", "NodeSocketFloat"
)
uniform_scale.hide_in_modifier = True
depth_image = _interface_socket(group, "Depth Image", "INPUT", "NodeSocketImage")
depth_image.hide_in_modifier = True
material = _interface_socket(group, "Material", "INPUT", "NodeSocketMaterial")
@@ -71,10 +79,14 @@ def depth_plane_node_group():
(-1250.0, 850.0),
{"Thickness", "Depth Amount"},
)
image_input = _group_input(nodes, (-1050.0, 50.0), {"Depth Image"})
image_input = _group_input(
nodes,
(-1050.0, 50.0),
{"Depth Image", "Reference Depth", "Uniform Scale"},
)
cleanup_input = _group_input(nodes, (650.0, -200.0), {"Thickness"})
group_output = nodes.new("NodeGroupOutput")
group_output.location = (1600.0, 450.0)
group_output.location = (1850.0, 450.0)
mesh_plane = nodes.new("GeometryNodeGroup")
mesh_plane.node_tree = mesh_plane_node_group()
mesh_plane.inputs["Thickness"].default_value = 0.0
@@ -103,41 +115,51 @@ def depth_plane_node_group():
separate = nodes.new("ShaderNodeSeparateXYZ")
separate.location = (-600.0, 50.0)
links.new(texture.outputs["Color"], separate.inputs["Vector"])
relative_depth = nodes.new("ShaderNodeMath")
relative_depth.operation = "SUBTRACT"
relative_depth.location = (-600.0, -150.0)
links.new(image_input.outputs["Reference Depth"], relative_depth.inputs[0])
links.new(separate.outputs["X"], relative_depth.inputs[1])
local_depth = nodes.new("ShaderNodeMath")
local_depth.operation = "MULTIPLY"
local_depth.location = (-350.0, -150.0)
links.new(relative_depth.outputs[0], local_depth.inputs[0])
links.new(image_input.outputs["Uniform Scale"], local_depth.inputs[1])
raw_depth = nodes.new("ShaderNodeMath")
raw_depth.operation = "MULTIPLY"
raw_depth.location = (-350.0, 150.0)
links.new(separate.outputs["X"], raw_depth.inputs[0])
raw_depth.location = (-100.0, 150.0)
links.new(local_depth.outputs[0], raw_depth.inputs[0])
links.new(shape_input.outputs["Depth Amount"], raw_depth.inputs[1])
inward_limit = nodes.new("ShaderNodeMath")
inward_limit.operation = "MULTIPLY"
inward_limit.inputs[1].default_value = -0.98
inward_limit.location = (-350.0, -50.0)
inward_limit.location = (-100.0, -50.0)
links.new(shape_input.outputs["Thickness"], inward_limit.inputs[0])
clamp = nodes.new("ShaderNodeMath")
clamp.operation = "MAXIMUM"
clamp.location = (-100.0, 50.0)
clamp.location = (150.0, 50.0)
links.new(raw_depth.outputs[0], clamp.inputs[0])
links.new(inward_limit.outputs[0], clamp.inputs[1])
has_thickness = nodes.new("ShaderNodeMath")
has_thickness.operation = "GREATER_THAN"
has_thickness.inputs[1].default_value = 1e-6
has_thickness.location = (-350.0, -250.0)
has_thickness.location = (-100.0, -350.0)
links.new(shape_input.outputs["Thickness"], has_thickness.inputs[0])
choose_depth = nodes.new("GeometryNodeSwitch")
choose_depth.input_type = "FLOAT"
choose_depth.location = (150.0, 50.0)
choose_depth.location = (400.0, 50.0)
links.new(has_thickness.outputs[0], choose_depth.inputs["Switch"])
links.new(raw_depth.outputs[0], choose_depth.inputs["False"])
links.new(clamp.outputs[0], choose_depth.inputs["True"])
combine = nodes.new("ShaderNodeCombineXYZ")
combine.location = (350.0, 50.0)
combine.location = (600.0, 50.0)
links.new(choose_depth.outputs["Output"], combine.inputs["Z"])
front = nodes.new("FunctionNodeBooleanMath")
front.operation = "NOT"
front.location = (150.0, 300.0)
front.location = (400.0, 300.0)
links.new(extrude.outputs["Top"], front.inputs[0])
set_position = nodes.new("GeometryNodeSetPosition")
set_position.location = (550.0, 450.0)
set_position.location = (800.0, 450.0)
links.new(geometry, set_position.inputs["Geometry"])
links.new(front.outputs[0], set_position.inputs["Selection"])
links.new(combine.outputs["Vector"], set_position.inputs["Offset"])
@@ -146,27 +168,27 @@ def depth_plane_node_group():
zero_thickness = nodes.new("ShaderNodeMath")
zero_thickness.operation = "LESS_THAN"
zero_thickness.inputs[1].default_value = 1e-6
zero_thickness.location = (750.0, -150.0)
zero_thickness.location = (1000.0, -150.0)
links.new(cleanup_input.outputs["Thickness"], zero_thickness.inputs[0])
back_or_side = nodes.new("FunctionNodeBooleanMath")
back_or_side.operation = "OR"
back_or_side.location = (750.0, 150.0)
back_or_side.location = (1000.0, 150.0)
links.new(extrude.outputs["Top"], back_or_side.inputs[0])
links.new(extrude.outputs["Side"], back_or_side.inputs[1])
delete_selection = nodes.new("FunctionNodeBooleanMath")
delete_selection.operation = "AND"
delete_selection.location = (950.0, 50.0)
delete_selection.location = (1200.0, 50.0)
links.new(zero_thickness.outputs[0], delete_selection.inputs[0])
links.new(back_or_side.outputs[0], delete_selection.inputs[1])
delete_geometry = nodes.new("GeometryNodeDeleteGeometry")
delete_geometry.domain = "FACE"
delete_geometry.location = (1100.0, 450.0)
delete_geometry.location = (1350.0, 450.0)
links.new(geometry, delete_geometry.inputs["Geometry"])
links.new(delete_selection.outputs[0], delete_geometry.inputs["Selection"])
shade_smooth = nodes.new("GeometryNodeSetShadeSmooth")
shade_smooth.domain = "FACE"
shade_smooth.inputs["Shade Smooth"].default_value = True
shade_smooth.location = (1350.0, 450.0)
shade_smooth.location = (1600.0, 450.0)
links.new(delete_geometry.outputs["Geometry"], shade_smooth.inputs["Geometry"])
links.new(shade_smooth.outputs["Geometry"], group_output.inputs["Geometry"])
group.use_fake_user = True

View File

@@ -1,159 +0,0 @@
from ..common.image_data import image_pixels
def _normalized(values, fallback):
import numpy as np
values = np.asarray(values, dtype=np.float64)
lengths = np.linalg.norm(values, axis=-1)
result = np.empty_like(values)
usable = lengths > 1e-10
result[usable] = values[usable] / lengths[usable, None]
result[~usable] = fallback
return result
def rasterize_tangent_normal(
object_normal,
triangle_uv,
triangle_normal,
triangle_tangent,
triangle_sign,
return_coverage=False,
):
"""Convert object-space pixels through a smooth per-corner UV tangent frame."""
import numpy as np
object_normal = _normalized(object_normal, (0.0, 0.0, 1.0))
height, width = object_normal.shape[:2]
output = np.zeros((height, width, 3), dtype=np.float32)
output[..., 2] = 1.0
coverage = np.zeros((height, width), dtype=bool)
scale = np.asarray((max(width - 1, 1), max(height - 1, 1)))
for uv, corner_normal, corner_tangent, corner_sign in zip(
triangle_uv,
triangle_normal,
triangle_tangent,
triangle_sign,
strict=True,
):
pixel = np.asarray(uv, dtype=np.float64) * scale
x_min = max(int(np.ceil(np.min(pixel[:, 0]))), 0)
x_max = min(int(np.floor(np.max(pixel[:, 0]))), width - 1)
y_min = max(int(np.ceil(np.min(pixel[:, 1]))), 0)
y_max = min(int(np.floor(np.max(pixel[:, 1]))), height - 1)
if x_min > x_max or y_min > y_max:
continue
a, b, c = pixel
denominator = (b[1] - c[1]) * (a[0] - c[0]) + (
c[0] - b[0]
) * (a[1] - c[1])
if abs(denominator) <= 1e-12:
continue
grid_x, grid_y = np.meshgrid(
np.arange(x_min, x_max + 1, dtype=np.float64),
np.arange(y_min, y_max + 1, dtype=np.float64),
)
first = (
(b[1] - c[1]) * (grid_x - c[0])
+ (c[0] - b[0]) * (grid_y - c[1])
) / denominator
second = (
(c[1] - a[1]) * (grid_x - c[0])
+ (a[0] - c[0]) * (grid_y - c[1])
) / denominator
third = 1.0 - first - second
inside = (first >= -1e-6) & (second >= -1e-6) & (third >= -1e-6)
if not inside.any():
continue
weights = np.stack((first[inside], second[inside], third[inside]), axis=-1)
normal = _normalized(weights @ corner_normal, (0.0, 0.0, 1.0))
tangent = weights @ corner_tangent
tangent -= normal * np.sum(tangent * normal, axis=-1, keepdims=True)
tangent = _normalized(tangent, (1.0, 0.0, 0.0))
sign = np.where(weights @ corner_sign >= 0.0, 1.0, -1.0)
bitangent = np.cross(normal, tangent) * sign[:, None]
rows = grid_y[inside].astype(np.int32)
columns = grid_x[inside].astype(np.int32)
source = object_normal[rows, columns]
output[rows, columns, 0] = np.sum(source * tangent, axis=-1)
output[rows, columns, 1] = np.sum(source * bitangent, axis=-1)
output[rows, columns, 2] = np.sum(source * normal, axis=-1)
coverage[rows, columns] = True
output[coverage] = _normalized(output[coverage], (0.0, 0.0, 1.0))
return (output, coverage) if return_coverage else output
def bake_tangent_normal_image(context, source_object, target_object, image):
"""Bake a MoGe object-space image against the evaluated target mesh TBN."""
import numpy as np
width, height = (int(value) for value in image.size)
rgba = image_pixels(image).reshape((height, width, 4))
source_normal = rgba[..., :3].astype(np.float64) * 2.0 - 1.0
source_to_target = (
target_object.matrix_world.inverted().to_3x3()
@ source_object.matrix_world.to_3x3()
)
normal_matrix = source_to_target.inverted().transposed()
transform = np.asarray(normal_matrix, dtype=np.float64)
target_normal = source_normal @ transform.T
target_normal = _normalized(target_normal, (0.0, 0.0, 1.0))
front = _normalized(
np.asarray((0.0, 0.0, 1.0), dtype=np.float64)[None, :] @ transform.T,
(0.0, 0.0, 1.0),
)[0]
context.view_layer.update()
depsgraph = context.evaluated_depsgraph_get()
evaluated = target_object.evaluated_get(depsgraph)
mesh = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
try:
uv_layer = mesh.uv_layers.active
if uv_layer is None:
raise ValueError("The target mesh has no active UV map")
mesh.calc_loop_triangles()
mesh.calc_tangents(uvmap=uv_layer.name)
loop_count = len(mesh.loops)
uv = np.empty((loop_count, 2), dtype=np.float64)
uv_layer.data.foreach_get("uv", uv.ravel())
tangent = np.empty((loop_count, 3), dtype=np.float64)
sign = np.empty(loop_count, dtype=np.float64)
mesh.loops.foreach_get("tangent", tangent.ravel())
mesh.loops.foreach_get("bitangent_sign", sign)
normal = np.asarray(
[tuple(loop.normal) for loop in mesh.loops],
dtype=np.float64,
)
triangles = np.asarray(
[tuple(triangle.loops) for triangle in mesh.loop_triangles],
dtype=np.int32,
)
triangle_normal = np.asarray(
[tuple(triangle.normal) for triangle in mesh.loop_triangles],
dtype=np.float64,
)
front_facing = triangle_normal @ front > 1e-6
triangles = triangles[front_facing]
tangent_normal, coverage = rasterize_tangent_normal(
target_normal,
uv[triangles],
normal[triangles],
tangent[triangles],
sign[triangles],
return_coverage=True,
)
finally:
evaluated.to_mesh_clear()
rgba[coverage, :3] = tangent_normal[coverage] * 0.5 + 0.5
image.pixels.foreach_set(rgba.ravel())
image.update()
image.pack()
return image

View File

@@ -0,0 +1,125 @@
"""Create Blender Image, Material, Mesh, and Object data for Plane conversion."""
import bpy
from ..common.image_data import (
color_data_name,
image_empty_bounds,
image_pixels,
load_color_result_image,
require_image_empty,
)
from ..common.depth_data import (
depth_uniform_scale,
load_depth_metadata,
load_depth_result_image,
)
from ..common.mesh_result import (
create_image_material,
finalize_object_result,
modifier_input_identifier,
set_modifier_input,
)
from .mesh_plane import create_mesh_plane_mesh, create_mesh_plane_object
from .nodes import depth_plane_node_group
def create_color_image(source_object):
source = source_object.data
image = source.copy()
if getattr(source, "source", None) == "GENERATED" or getattr(
source, "is_dirty", False
):
pixels = image_pixels(source)
image.pixels.foreach_set(pixels)
image.update()
image.name = color_data_name(source_object)
return image
def create_plane_object(context, source_object, subdivisions, material):
source_name = source_object.name
plane = create_mesh_plane_object(
context,
source_name,
image_empty_bounds(source_object),
material,
matrix_world=source_object.matrix_world,
subdivisions=subdivisions,
)
for polygon in plane.data.polygons:
polygon.use_smooth = True
plane["anyimage_mesh_shape"] = "PLANE"
return finalize_object_result(context, source_object, plane)
def create_depth_plane_object(
context,
source_object,
subdivisions,
material,
depth_image,
depth_metadata,
):
source_name = source_object.name
mesh = create_mesh_plane_mesh(source_name, image_empty_bounds(source_object))
for polygon in mesh.polygons:
polygon.use_smooth = True
mesh.materials.append(material)
plane = bpy.data.objects.new(source_name, mesh)
context.collection.objects.link(plane)
plane.matrix_world = source_object.matrix_world.copy()
plane["anyimage_mesh_shape"] = "DEPTH_PLANE"
node_group = depth_plane_node_group()
modifier = plane.modifiers.new(name=node_group.name, type="NODES")
modifier.node_group = node_group
modifier.show_group_selector = False
for name, value in (
("Subdivide", max(0, min(10, int(subdivisions)))),
("Material", material),
("Thickness", 0.1),
("Depth Amount", 1.0),
("Reference Depth", depth_metadata["reference_depth"]),
("Uniform Scale", depth_uniform_scale(source_object, depth_metadata)),
("Depth Image", depth_image),
):
set_modifier_input(
modifier,
modifier_input_identifier(node_group, name),
value,
)
return finalize_object_result(context, source_object, plane)
def create_depth_plane_from_result(context, result, operator):
source_object = require_image_empty(operator.source_object_name)
color_image = load_color_result_image(source_object, result.directory)
color_image.name = color_data_name(source_object)
material = create_image_material(
source_object,
color_image,
0.0,
scene=context.scene,
)
depth_metadata = load_depth_metadata(result.file("depth_metadata"))
depth_image = load_depth_result_image(
result.file("z_depth"),
source_object,
depth_metadata,
)
try:
create_depth_plane_object(
context,
source_object,
operator.mesh_detail,
material,
depth_image,
depth_metadata,
)
except Exception:
if depth_image.users == 0:
bpy.data.images.remove(depth_image, do_unlink=True)
raise
if "FINISHED" not in bpy.ops.ed.undo_push(message="Convert to Depth Plane"):
raise RuntimeError("Unable to create the Convert to Depth Plane Undo step")

View File

@@ -1,16 +0,0 @@
DA3_PATCH_SIZE = 14
DA3_MIN_PROCESS_RES = 252
DA3_MAX_PROCESS_RES = 1008
def scaled_process_resolution(image, scale_percent):
"""Return the scaled long edge clamped and aligned for DA3 inference."""
width, height = image.size
if width <= 0 or height <= 0:
raise ValueError("The source image has no readable dimensions")
if scale_percent < 1.0 or scale_percent > 100.0:
raise ValueError("Resolution Scale must be between 1% and 100%")
process_res = max(width, height) * scale_percent / 100.0
process_res = max(DA3_MIN_PROCESS_RES, process_res)
process_res = min(DA3_MAX_PROCESS_RES, process_res)
return int(process_res / DA3_PATCH_SIZE + 0.5) * DA3_PATCH_SIZE

View File

@@ -1,5 +1,4 @@
from .region_to_shape import CutoutRegionToShape
from .select_region import CutoutTool, SelectCutoutRegion
from .main import CutoutRegionToShape, CutoutTool, SelectCutoutRegion
__all__ = (
"CutoutRegionToShape",

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
"""Cutout 的内存几何算法。"""

View File

@@ -1,402 +0,0 @@
import math
DEFAULT_INFLATION = 0.45
def distances_to_contour(points, contour):
import numpy as np
points = np.asarray(points, dtype=np.float64)
contour = np.asarray(contour, dtype=np.float64)
squared = np.full(len(points), np.inf, dtype=np.float64)
for start, end in zip(contour, np.roll(contour, -1, axis=0)):
segment = end - start
length_squared = float(segment.dot(segment))
if length_squared <= 1e-16:
continue
factor = np.clip(
(points - start) @ segment / length_squared,
0.0,
1.0,
)
nearest = start + factor[:, None] * segment
squared = np.minimum(
squared,
np.sum((points - nearest) ** 2, axis=1),
)
return np.sqrt(squared)
def chordal_axis(contour, epsilon=1e-9):
import numpy as np
from mathutils import Vector
from mathutils.geometry import delaunay_2d_cdt
contour = np.asarray(contour, dtype=np.float64)
boundary_count = len(contour)
boundary_edges = [
(index, (index + 1) % boundary_count)
for index in range(boundary_count)
]
result = delaunay_2d_cdt(
[Vector(point) for point in contour],
boundary_edges,
[list(range(boundary_count))],
1,
epsilon,
)
points = np.asarray(
[tuple(point) for point in result[0]],
dtype=np.float64,
)
triangles = [tuple(face) for face in result[2] if len(face) == 3]
boundary = {
tuple(sorted(edge))
for edge in boundary_edges
}
node_indices = {}
nodes = []
segments = set()
def edge_node(edge):
key = ("edge", *tuple(sorted(edge)))
if key not in node_indices:
node_indices[key] = len(nodes)
nodes.append((points[edge[0]] + points[edge[1]]) * 0.5)
return node_indices[key]
for face_index, face in enumerate(triangles):
edges = [
(face[0], face[1]),
(face[1], face[2]),
(face[2], face[0]),
]
internal = [
edge
for edge in edges
if tuple(sorted(edge)) not in boundary
]
internal_nodes = [edge_node(edge) for edge in internal]
if len(internal_nodes) == 2:
segments.add(tuple(sorted(internal_nodes)))
elif len(internal_nodes) == 3:
key = ("face", face_index)
center_index = len(nodes)
node_indices[key] = center_index
nodes.append(points[list(face)].mean(axis=0))
for midpoint_index in internal_nodes:
segments.add(tuple(sorted((center_index, midpoint_index))))
if not nodes:
center = contour.mean(axis=0)
return np.asarray((center,), dtype=np.float64), [], np.asarray(
(distances_to_contour((center,), contour)[0],),
dtype=np.float64,
)
nodes = np.asarray(nodes, dtype=np.float64)
radii = distances_to_contour(nodes, contour)
active = set(segments)
while len(active) > 1:
degree = [0] * len(nodes)
for start, end in active:
degree[start] += 1
degree[end] += 1
removable = []
for edge in active:
start, end = edge
leaf = start if degree[start] == 1 else end if degree[end] == 1 else None
if leaf is None:
continue
length = float(np.linalg.norm(nodes[start] - nodes[end]))
if length < max(radii[leaf] * 0.5, epsilon):
removable.append(edge)
if not removable:
break
active.difference_update(removable)
if active:
used = sorted({index for edge in active for index in edge})
remap = {old: new for new, old in enumerate(used)}
nodes = nodes[used]
radii = radii[used]
active = {
tuple(sorted((remap[start], remap[end])))
for start, end in active
}
else:
widest = int(np.argmax(radii))
nodes = nodes[[widest]]
radii = radii[[widest]]
return nodes, sorted(active), radii
def smooth_interior_heights(
heights,
planar_faces,
boundary_mask,
iterations=2,
factor=0.3,
):
import numpy as np
values = np.asarray(heights, dtype=np.float64).copy()
boundary_mask = np.asarray(boundary_mask, dtype=bool)
neighbors = [set() for _index in range(len(values))]
for face in planar_faces:
for start, end in zip(face, face[1:] + face[:1]):
neighbors[start].add(end)
neighbors[end].add(start)
for _iteration in range(iterations):
updated = values.copy()
for index in np.flatnonzero(~boundary_mask):
adjacent = [
neighbor
for neighbor in neighbors[index]
if not boundary_mask[neighbor]
]
if adjacent:
average = float(values[adjacent].mean())
updated[index] = values[index] * (1.0 - factor) + average * factor
updated[boundary_mask] = 0.0
values = updated
return values
def teddy_balloon_heights(
planar_points,
planar_faces,
contour,
boundary_mask,
inflation,
):
import numpy as np
points = np.asarray(planar_points, dtype=np.float64)
boundary_mask = np.asarray(boundary_mask, dtype=bool)
nodes, segments, radii = chordal_axis(contour)
squared_heights = np.zeros(len(points), dtype=np.float64)
for start, end in segments:
axis_start = nodes[start]
axis_end = nodes[end]
axis = axis_end - axis_start
length_squared = float(axis.dot(axis))
if length_squared <= 1e-16:
continue
factors = np.clip(
(points - axis_start) @ axis / length_squared,
0.0,
1.0,
)
centers = axis_start + factors[:, None] * axis
local_radii = radii[start] + factors * (radii[end] - radii[start])
distance_squared = np.sum((points - centers) ** 2, axis=1)
squared_heights = np.maximum(
squared_heights,
local_radii * local_radii - distance_squared,
)
degree = np.zeros(len(nodes), dtype=np.int64)
for start, end in segments:
degree[start] += 1
degree[end] += 1
for index in np.flatnonzero(degree != 2):
distance_squared = np.sum((points - nodes[index]) ** 2, axis=1)
squared_heights = np.maximum(
squared_heights,
radii[index] * radii[index] - distance_squared,
)
missing = (~boundary_mask) & (squared_heights <= 0.0)
if np.any(missing):
fallback = distances_to_contour(points[missing], contour)
squared_heights[missing] = fallback * fallback
heights = inflation * np.sqrt(np.maximum(squared_heights, 0.0))
heights[boundary_mask] = 0.0
return smooth_interior_heights(
heights,
planar_faces,
boundary_mask,
)
DEFAULT_ALGORITHM = "POISSON"
def cotangent_poisson_field(
planar_points,
planar_faces,
boundary_mask,
tolerance=1e-8,
):
import numpy as np
points = np.asarray(planar_points, dtype=np.float64)
faces = np.asarray(planar_faces, dtype=np.int64)
if faces.size == 0:
faces = np.empty((0, 3), dtype=np.int64)
if faces.ndim != 2 or faces.shape[1] != 3:
raise ValueError("Balloon profile faces must be triangles")
boundary_mask = np.asarray(boundary_mask, dtype=bool)
triangles = points[faces]
a, b, c = (triangles[:, index] for index in range(3))
cross = np.abs(
(b[:, 0] - a[:, 0]) * (c[:, 1] - a[:, 1])
- (b[:, 1] - a[:, 1]) * (c[:, 0] - a[:, 0])
)
usable = cross > 1e-14
faces = faces[usable]
a, b, c = a[usable], b[usable], c[usable]
cross = cross[usable]
loads = np.bincount(
faces.ravel(),
weights=np.repeat(cross / 6.0, 3),
minlength=len(points),
)
edges = np.concatenate(
(
faces[:, (1, 2)],
faces[:, (0, 2)],
faces[:, (0, 1)],
),
axis=0,
)
edge_weights = np.concatenate(
(
np.sum((b - a) * (c - a), axis=1),
np.sum((a - b) * (c - b), axis=1),
np.sum((a - c) * (b - c), axis=1),
)
) / np.tile(cross * 2.0, 3)
edges.sort(axis=1)
edges, inverse = np.unique(edges, axis=0, return_inverse=True)
edge_weights = np.bincount(
inverse,
weights=edge_weights,
minlength=len(edges),
)
interior = np.flatnonzero(~boundary_mask)
field = np.zeros(len(points), dtype=np.float64)
if not len(interior):
return field
compact = np.full(len(points), -1, dtype=np.int64)
compact[interior] = np.arange(len(interior), dtype=np.int64)
# Constrained triangulation can create negative cotangent weights.
# Clamping them keeps the Dirichlet system positive definite.
edge_weights = np.maximum(edge_weights, 0.0)
usable = edge_weights > 1e-14
compact_edges = compact[edges[usable]]
edge_weights = edge_weights[usable]
start_compact = compact_edges[:, 0]
end_compact = compact_edges[:, 1]
start_usable = start_compact >= 0
end_usable = end_compact >= 0
diagonal = np.zeros(len(interior), dtype=np.float64)
diagonal += np.bincount(
start_compact[start_usable],
weights=edge_weights[start_usable],
minlength=len(interior),
)
diagonal += np.bincount(
end_compact[end_usable],
weights=edge_weights[end_usable],
minlength=len(interior),
)
if np.any(diagonal <= 1e-14):
raise ValueError("Unable to build a stable Balloon profile")
interior_edges = start_usable & end_usable
edge_starts = start_compact[interior_edges]
edge_ends = end_compact[interior_edges]
weights = edge_weights[interior_edges]
directed_starts = np.concatenate((edge_starts, edge_ends))
directed_ends = np.concatenate((edge_ends, edge_starts))
directed_weights = np.concatenate((weights, weights))
def multiply(values):
result = diagonal * values
if len(directed_weights):
result -= np.bincount(
directed_starts,
weights=directed_weights * values[directed_ends],
minlength=len(values),
)
return result
right_hand_side = loads[interior]
solution = np.zeros(len(interior), dtype=np.float64)
residual = right_hand_side.copy()
preconditioned = residual / diagonal
direction = preconditioned.copy()
residual_product = float(residual.dot(preconditioned))
target = max(float(np.linalg.norm(right_hand_side)) * tolerance, 1e-12)
max_iterations = max(64, min(len(interior) * 4, 4000))
for _iteration in range(max_iterations):
if float(np.linalg.norm(residual)) <= target:
break
multiplied = multiply(direction)
denominator = float(direction.dot(multiplied))
if denominator <= 0.0 or not math.isfinite(denominator):
raise ValueError("Unable to solve the Balloon profile")
step = residual_product / denominator
solution += step * direction
residual -= step * multiplied
preconditioned = residual / diagonal
next_product = float(residual.dot(preconditioned))
if not math.isfinite(next_product):
raise ValueError("Unable to solve the Balloon profile")
if abs(residual_product) <= 1e-30:
break
direction = preconditioned + next_product / residual_product * direction
residual_product = next_product
field[interior] = np.maximum(solution, 0.0)
return field
def poisson_balloon_profile(
planar_points,
planar_faces,
boundary_mask,
inflation=DEFAULT_INFLATION,
):
import numpy as np
field = cotangent_poisson_field(
planar_points,
planar_faces,
boundary_mask,
)
return 2.0 * float(inflation) * np.sqrt(field)
def balloon_profile(
algorithm,
planar_points,
planar_faces,
boundary_mask,
components,
inflation=DEFAULT_INFLATION,
):
import numpy as np
algorithm = str(algorithm).upper()
if algorithm == "POISSON":
return poisson_balloon_profile(
planar_points,
planar_faces,
boundary_mask,
inflation,
)
if algorithm != "TEDDY":
raise ValueError(f"Unknown Balloon profile algorithm: {algorithm}")
result = np.zeros(len(planar_points), dtype=np.float64)
for start, end, component_faces, outer_contour in components:
result[start:end] = teddy_balloon_heights(
np.asarray(planar_points)[start:end],
component_faces,
outer_contour,
np.asarray(boundary_mask)[start:end],
inflation,
)
return result

View File

@@ -1,175 +0,0 @@
"""Calibrate metric depth for Cutout Depth Balloon and Depth Surface."""
PLANE_VARIANCE_FLOOR_RATIO = 0.025
DEPTH_REFERENCE_PERCENTILE = 95.0
ADAPTIVE_DEPTH_TRANSPARENCY_RATIO = 0.05
DEFAULT_DEPTH_BASELINE = 8.0
DEFAULT_DEPTH_DIRECTION = (0.0, 0.0, 1.0)
def use_adaptive_depth_calibration(region_transparent_fraction):
"""Return whether the Region input has enough transparent context."""
return (
float(region_transparent_fraction)
>= ADAPTIVE_DEPTH_TRANSPARENCY_RATIO
)
def metric_depth_reference(camera_depth, valid, uniform_scale):
"""Return the shared upper-percentile scene depth for both Depth shapes."""
import numpy as np
camera_depth = np.asarray(camera_depth, dtype=np.float32)
valid = np.asarray(valid, dtype=bool)
if valid.shape != camera_depth.shape:
raise ValueError("Depth values and validity mask dimensions do not match")
usable = valid & np.isfinite(camera_depth) & (camera_depth > 0.0)
if not usable.any():
raise ValueError("The depth field contains no valid metric depth")
if not uniform_scale > 0.0:
raise ValueError("Depth Uniform Scale must be positive")
reference = np.percentile(
camera_depth[usable],
DEPTH_REFERENCE_PERCENTILE,
)
return float(reference) * float(uniform_scale)
def _sample_valid_bilinear(values, valid, x, y):
import numpy as np
height, width = values.shape
x = np.clip(x, 0.0, max(width - 1, 0))
y = np.clip(y, 0.0, max(height - 1, 0))
x0 = np.floor(x).astype(np.int64)
y0 = np.floor(y).astype(np.int64)
x1 = np.minimum(x0 + 1, width - 1)
y1 = np.minimum(y0 + 1, height - 1)
fx = x - x0
fy = y - y0
samples = np.stack(
(
values[y0, x0],
values[y0, x1],
values[y1, x0],
values[y1, x1],
),
axis=1,
)
weights = np.stack(
(
(1.0 - fx) * (1.0 - fy),
fx * (1.0 - fy),
(1.0 - fx) * fy,
fx * fy,
),
axis=1,
)
sample_valid = np.stack(
(
valid[y0, x0],
valid[y0, x1],
valid[y1, x0],
valid[y1, x1],
),
axis=1,
)
weights *= sample_valid
totals = weights.sum(axis=1)
result = np.zeros(len(x), dtype=np.float32)
usable = totals > 1e-8
result[usable] = (samples[usable] * weights[usable]).sum(axis=1) / totals[usable]
return result, usable
def _weighted_plane(coordinates, values, weights):
import numpy as np
total = float(np.sum(weights))
if total <= 1e-12:
return np.zeros(3, dtype=np.float32)
center = np.sum(coordinates * weights[:, None], axis=0) / total
value_center = float(np.sum(values * weights) / total)
centered = coordinates - center
covariance = (centered * weights[:, None]).T @ centered / total
cross = centered.T @ (weights * (values - value_center)) / total
eigenvalues, eigenvectors = np.linalg.eigh(covariance)
largest = max(float(eigenvalues[-1]), 0.0)
if largest <= 1e-12:
slopes = np.zeros(2, dtype=np.float64)
else:
supported = np.maximum(eigenvalues, largest * PLANE_VARIANCE_FLOOR_RATIO)
slopes = eigenvectors @ ((eigenvectors.T @ cross) / supported)
intercept = value_center - float(center @ slopes)
return np.asarray((*slopes, intercept), dtype=np.float32)
def _stable_plane(coordinates, values):
import numpy as np
coordinates = np.asarray(coordinates, dtype=np.float64)
values = np.asarray(values, dtype=np.float64)
if len(coordinates) < 2:
return np.asarray((0.0, 0.0, np.median(values)), dtype=np.float32)
weights = np.ones(len(values), dtype=np.float64)
plane = np.zeros(3, dtype=np.float32)
for _iteration in range(4):
plane = _weighted_plane(coordinates, values, weights)
residual = values - coordinates @ plane[:2] - plane[2]
centered = residual - np.median(residual)
scale = max(float(np.median(np.abs(centered))) * 1.4826, 1e-6)
weights = np.minimum(
1.0,
(2.5 * scale) / np.maximum(np.abs(centered), 1e-12),
)
return plane
def depth_balloon_projection_parameters(
vertices,
vertex_uv,
camera_depth,
valid,
uniform_scale,
):
"""Fit a metric base plane and return its center depth and direction."""
import numpy as np
vertices = np.asarray(vertices, dtype=np.float32)
uv = np.asarray(vertex_uv, dtype=np.float32)
if vertices.ndim != 2 or vertices.shape[1] != 3:
raise ValueError("Depth Balloon vertices must have three components")
if uv.shape != (len(vertices), 2):
raise ValueError("Depth Balloon UV coordinates do not match its vertices")
camera_depth = np.asarray(camera_depth, dtype=np.float32)
valid = np.asarray(valid, dtype=bool)
if valid.shape != camera_depth.shape:
raise ValueError("Depth Balloon depth and mask dimensions do not match")
if not uniform_scale > 0.0:
raise ValueError("Depth Balloon Uniform Scale must be positive")
height, width = camera_depth.shape
sample_x = uv[:, 0] * max(width - 1, 0)
sample_y = (1.0 - uv[:, 1]) * max(height - 1, 0)
calibration_valid = valid & np.isfinite(camera_depth) & (camera_depth > 0.0)
sampled, usable = _sample_valid_bilinear(
camera_depth,
calibration_valid,
sample_x,
sample_y,
)
if not usable.any():
raise ValueError("MoGe-2 found no valid Depth Balloon samples")
local_coordinates = vertices[:, 1:]
metric_depth = sampled[usable] * float(uniform_scale)
plane = _stable_plane(local_coordinates[usable], metric_depth)
reference = metric_depth_reference(
camera_depth,
calibration_valid,
uniform_scale,
)
direction = np.asarray((-plane[0], plane[1], 1.0), dtype=np.float64)
direction /= np.linalg.norm(direction)
return reference, tuple(float(value) for value in direction)

View File

@@ -1,241 +0,0 @@
"""Extract Cutout Region contours from a Region Mask."""
from dataclasses import dataclass
@dataclass(frozen=True)
class RegionContours:
components: tuple
image_size: tuple[int, int]
def __post_init__(self):
components = tuple(
tuple(
tuple((float(x), float(y)) for x, y in contour)
for contour in component
)
for component in self.components
)
image_size = tuple(int(value) for value in self.image_size)
if not components or any(not component for component in components):
raise ValueError("The Region has no usable contour components")
if any(
len(contour) < 3
for component in components
for contour in component
):
raise ValueError("A Region contour has fewer than three points")
if len(image_size) != 2 or min(image_size) <= 0:
raise ValueError("The Region contour image size is invalid")
object.__setattr__(self, "components", components)
object.__setattr__(self, "image_size", image_size)
def _choose_boundary_edge(start, direction, candidates):
right = (-direction[1], direction[0])
left = (direction[1], -direction[0])
back = (-direction[0], -direction[1])
preference = {right: 0, direction: 1, left: 2, back: 3}
return min(
candidates,
key=lambda end: preference.get(
(end[0] - start[0], end[1] - start[1]),
4,
),
)
def _simplify_axis_aligned_contour(points):
simplified = []
for index, current in enumerate(points):
previous = points[index - 1]
following = points[(index + 1) % len(points)]
first = (current[0] - previous[0], current[1] - previous[1])
second = (following[0] - current[0], following[1] - current[1])
if first[0] * second[1] - first[1] * second[0] != 0:
simplified.append(current)
return simplified
def _trace_region_contours(mask):
import numpy as np
mask = np.asarray(mask, dtype=bool)
if mask.ndim != 2:
raise ValueError("Cutout Alpha must be two-dimensional")
height, width = mask.shape
edges = {}
def add_edge(start, end):
edges.setdefault(start, []).append(end)
top = mask.copy()
top[1:] &= ~mask[:-1]
for y, x in np.argwhere(top):
add_edge((int(x), int(y)), (int(x) + 1, int(y)))
right = mask.copy()
right[:, :-1] &= ~mask[:, 1:]
for y, x in np.argwhere(right):
add_edge((int(x) + 1, int(y)), (int(x) + 1, int(y) + 1))
bottom = mask.copy()
bottom[:-1] &= ~mask[1:]
for y, x in np.argwhere(bottom):
add_edge((int(x) + 1, int(y) + 1), (int(x), int(y) + 1))
left = mask.copy()
left[:, 1:] &= ~mask[:, :-1]
for y, x in np.argwhere(left):
add_edge((int(x), int(y) + 1), (int(x), int(y)))
if not edges:
raise ValueError("The Cutout region has no usable boundary")
contours = []
edge_count = sum(len(values) for values in edges.values())
while edges:
start = min(edges, key=lambda point: (point[1], point[0]))
current = start
direction = (1, 0)
contour = []
for _index in range(edge_count + 1):
contour.append(current)
candidates = edges.get(current)
if not candidates:
raise ValueError("The Cutout boundary is open")
following = _choose_boundary_edge(current, direction, candidates)
candidates.remove(following)
if not candidates:
del edges[current]
direction = (following[0] - current[0], following[1] - current[1])
current = following
if current == start:
break
else:
raise ValueError("The Cutout boundary could not be closed")
contour = _simplify_axis_aligned_contour(contour)
if len(contour) >= 3:
contours.append(contour)
if not contours:
raise ValueError("The Cutout region is too small")
return contours
def _signed_contour_area(contour):
return 0.5 * sum(
start[0] * end[1] - end[0] * start[1]
for start, end in zip(contour, contour[1:] + contour[:1])
)
def _point_inside_contour(point, contour):
inside = False
previous = contour[-1]
for current in contour:
if (current[1] > point[1]) != (previous[1] > point[1]):
crossing_x = current[0] + (
(point[1] - current[1])
* (previous[0] - current[0])
/ (previous[1] - current[1])
)
inside ^= point[0] < crossing_x
previous = current
return inside
def _group_region_contours(mask):
contours = _trace_region_contours(mask)
outer = [contour for contour in contours if _signed_contour_area(contour) > 0.0]
holes = [contour for contour in contours if _signed_contour_area(contour) < 0.0]
if not outer:
raise ValueError("The Cutout region has no outer boundary")
regions = [[contour] for contour in outer]
outer_areas = [abs(_signed_contour_area(contour)) for contour in outer]
for hole in holes:
start = hole[0]
end = hole[1]
edge_x = end[0] - start[0]
edge_y = end[1] - start[1]
length = max((edge_x * edge_x + edge_y * edge_y) ** 0.5, 1e-12)
sample = (
(start[0] + end[0]) * 0.5 - edge_y * 0.25 / length,
(start[1] + end[1]) * 0.5 + edge_x * 0.25 / length,
)
owners = [
index
for index, contour in enumerate(outer)
if _point_inside_contour(sample, contour)
]
if not owners:
raise ValueError("The Cutout hole has no enclosing region")
owner = min(owners, key=outer_areas.__getitem__)
regions[owner].append(hole)
return regions
def _resize_values(values, size):
import numpy as np
target_width, target_height = (int(value) for value in size)
source_height, source_width = values.shape
if (target_width, target_height) == (source_width, source_height):
return values
x = (np.arange(target_width, dtype=np.float32) + 0.5) * (
source_width / target_width
) - 0.5
y = (np.arange(target_height, dtype=np.float32) + 0.5) * (
source_height / target_height
) - 0.5
x0_unclipped = np.floor(x).astype(np.int32)
y0_unclipped = np.floor(y).astype(np.int32)
x_weight = x - x0_unclipped
y_weight = y - y0_unclipped
x0 = np.clip(x0_unclipped, 0, source_width - 1)
y0 = np.clip(y0_unclipped, 0, source_height - 1)
x1 = np.clip(x0_unclipped + 1, 0, source_width - 1)
y1 = np.clip(y0_unclipped + 1, 0, source_height - 1)
horizontal = (
values[:, x0] * (1.0 - x_weight)[None, :]
+ values[:, x1] * x_weight[None, :]
)
return (
horizontal[y0] * (1.0 - y_weight)[:, None]
+ horizontal[y1] * y_weight[:, None]
)
def extract_region_contours(region_mask, max_resolution=512, threshold=0.5):
import numpy as np
values = np.asarray(region_mask.values)
if values.ndim != 2:
raise ValueError("Cutout Alpha must be two-dimensional")
if np.issubdtype(values.dtype, np.floating):
values = np.clip(values, 0.0, 1.0) * 255.0
else:
values = np.clip(values, 0, 255).astype(np.float32)
source_size = (values.shape[1], values.shape[0])
scale = min(1.0, float(max_resolution) / max(source_size))
sampled_size = tuple(max(1, round(value * scale)) for value in source_size)
if sampled_size != source_size:
values = _resize_values(values, sampled_size)
mask = values >= 255.0 * threshold
components = _group_region_contours(mask)
scale_x = source_size[0] / sampled_size[0]
scale_y = source_size[1] / sampled_size[1]
return RegionContours(
components=tuple(
tuple(
tuple(
(
min(max(float(x) * scale_x, 0.0), source_size[0]),
min(max(float(y) * scale_y, 0.0), source_size[1]),
)
for x, y in contour
)
for contour in component
)
for component in components
),
image_size=source_size,
)

View File

@@ -1,436 +0,0 @@
import math
POINT_SPACING = 0.1
def contour_area(points):
import numpy as np
points = np.asarray(points, dtype=np.float64)
following = np.roll(points, -1, axis=0)
return 0.5 * float(
np.sum(
points[:, 0] * following[:, 1]
- following[:, 0] * points[:, 1]
)
)
def resample_closed_contour(contour, spacing):
import numpy as np
points = np.asarray(contour, dtype=np.float64)
if len(points) < 3:
raise ValueError("The lasso does not define a valid image region")
segments = np.roll(points, -1, axis=0) - points
lengths = np.linalg.norm(segments, axis=1)
points = points[lengths > 1e-8]
if len(points) < 3:
raise ValueError("The lasso region is too small to create a Cutout")
segments = np.roll(points, -1, axis=0) - points
lengths = np.linalg.norm(segments, axis=1)
perimeter = float(lengths.sum())
if perimeter <= spacing * 3.0:
raise ValueError("The lasso region is too small to create a Cutout")
count = max(3, int(round(perimeter / spacing)))
distances = np.arange(count, dtype=np.float64) * perimeter / count
cumulative = np.concatenate(([0.0], np.cumsum(lengths)))
indices = np.searchsorted(cumulative[1:], distances, side="right")
factors = (distances - cumulative[indices]) / lengths[indices]
return points[indices] + segments[indices] * factors[:, None]
def smooth_region_contour(contour, spacing, iterations=16):
contour = resample_closed_contour(contour, spacing * 0.25)
original_area = abs(contour_area(contour))
for _index in range(iterations):
import numpy as np
contour = (
np.roll(contour, 1, axis=0) * 0.125
+ contour * 0.75
+ np.roll(contour, -1, axis=0) * 0.125
)
smooth_area = abs(contour_area(contour))
if original_area > 0.0 and smooth_area > 0.0:
center = contour.mean(axis=0)
contour = center + (contour - center) * math.sqrt(
original_area / smooth_area
)
contour = resample_closed_contour(contour, spacing)
if contour_area(contour) < 0.0:
contour = contour[::-1].copy()
return contour
def _contour_edges(contours):
import numpy as np
starts = []
ends = []
for contour in contours:
contour = np.asarray(contour, dtype=np.float64)
following = np.roll(contour, -1, axis=0)
usable = np.abs(following[:, 1] - contour[:, 1]) > 1e-12
starts.append(contour[usable])
ends.append(following[usable])
if not starts:
empty = np.empty((0, 2), dtype=np.float64)
return empty, empty
return np.concatenate(starts), np.concatenate(ends)
def _edge_bucket_indices(starts, ends, minimum_y, bucket_height, bucket_count):
import numpy as np
lower = np.minimum(starts[:, 1], ends[:, 1])
upper = np.maximum(starts[:, 1], ends[:, 1])
first = np.floor((lower - minimum_y) / bucket_height).astype(np.int64)
last = np.floor((np.nextafter(upper, lower) - minimum_y) / bucket_height).astype(
np.int64
)
first = np.clip(first, 0, bucket_count - 1)
last = np.clip(last, 0, bucket_count - 1)
buckets = [[] for _index in range(bucket_count)]
for edge_index, (start, stop) in enumerate(zip(first, last)):
for bucket_index in range(int(start), int(stop) + 1):
buckets[bucket_index].append(edge_index)
return buckets
def points_inside_contours(points, contours):
import numpy as np
points = np.asarray(points, dtype=np.float64)
inside = np.zeros(len(points), dtype=bool)
if not len(points):
return inside
starts, ends = _contour_edges(contours)
if not len(starts):
return inside
minimum_y = min(float(points[:, 1].min()), float(starts[:, 1].min()))
maximum_y = max(float(points[:, 1].max()), float(starts[:, 1].max()))
typical_span = float(np.median(np.abs(ends[:, 1] - starts[:, 1])))
bucket_height = max(typical_span, (maximum_y - minimum_y) / 4096.0, 1e-9)
bucket_count = max(1, int(math.ceil((maximum_y - minimum_y) / bucket_height)))
edge_buckets = _edge_bucket_indices(
starts, ends, minimum_y, bucket_height, bucket_count
)
point_buckets = np.floor(
(points[:, 1] - minimum_y) / bucket_height
).astype(np.int64)
point_buckets = np.clip(point_buckets, 0, bucket_count - 1)
order = np.argsort(point_buckets, kind="stable")
ordered_buckets = point_buckets[order]
split = np.flatnonzero(np.diff(ordered_buckets)) + 1
for indices in np.split(order, split):
edge_indices = edge_buckets[int(point_buckets[indices[0]])]
if not edge_indices:
continue
selected_starts = starts[edge_indices]
selected_ends = ends[edge_indices]
sample_y = points[indices, 1, None]
crosses = (selected_starts[None, :, 1] > sample_y) != (
selected_ends[None, :, 1] > sample_y
)
crossing_x = selected_starts[None, :, 0] + (
(sample_y - selected_starts[None, :, 1])
* (selected_ends[None, :, 0] - selected_starts[None, :, 0])
/ (selected_ends[None, :, 1] - selected_starts[None, :, 1])
)
inside[indices] = np.count_nonzero(
crosses & (points[indices, 0, None] < crossing_x), axis=1
) % 2 == 1
return inside
def uniform_interior_points_for_contours(contours, spacing):
import numpy as np
contours = [np.asarray(contour, dtype=np.float64) for contour in contours]
boundary_points = np.concatenate(contours, axis=0)
minimum = boundary_points.min(axis=0)
maximum = boundary_points.max(axis=0)
row_spacing = spacing * math.sqrt(3.0) * 0.5
row_count = int(math.ceil((maximum[1] - minimum[1]) / row_spacing)) + 1
column_count = int(math.ceil((maximum[0] - minimum[0]) / spacing)) + 1
rows = np.arange(row_count, dtype=np.float64)
columns = np.arange(column_count, dtype=np.float64)
y = minimum[1] + rows * row_spacing
x = (
minimum[0]
+ columns[None, :] * spacing
+ (rows.astype(np.int64) % 2)[:, None] * spacing * 0.5
)
x = np.broadcast_to(x, (row_count, column_count))
points = np.column_stack((x.ravel(), np.repeat(y, column_count)))
inside = points_inside_contours(points, contours).reshape(
row_count, column_count
)
clearance = spacing * 0.4
near_boundary = np.zeros_like(inside)
for contour in contours:
for start, end in zip(contour, np.roll(contour, -1, axis=0)):
segment = end - start
length_squared = float(segment.dot(segment))
if length_squared <= 1e-16:
continue
first_row = max(
0,
int(
math.ceil(
(min(start[1], end[1]) - clearance - minimum[1])
/ row_spacing
)
),
)
last_row = min(
row_count - 1,
int(
math.floor(
(max(start[1], end[1]) + clearance - minimum[1])
/ row_spacing
)
),
)
for row in range(first_row, last_row + 1):
offset = spacing * 0.5 if row % 2 else 0.0
first_column = max(
0,
int(
math.ceil(
(min(start[0], end[0]) - clearance - minimum[0] - offset)
/ spacing
)
),
)
last_column = min(
column_count - 1,
int(
math.floor(
(max(start[0], end[0]) + clearance - minimum[0] - offset)
/ spacing
)
),
)
if last_column < first_column:
continue
selected = np.arange(first_column, last_column + 1)
row_points = np.column_stack(
(x[row, selected], np.full(len(selected), y[row]))
)
factor = np.clip(
(row_points - start) @ segment / length_squared, 0.0, 1.0
)
nearest = start + factor[:, None] * segment
close = np.sum((row_points - nearest) ** 2, axis=1) < clearance**2
near_boundary[row, selected[close]] = True
usable = inside & ~near_boundary
return np.column_stack(
(x[usable], np.broadcast_to(y[:, None], x.shape)[usable])
)
def metric_transform(world_metric=None):
import numpy as np
metric = (
np.eye(2, dtype=np.float64)
if world_metric is None
else np.asarray(world_metric, dtype=np.float64)
)
if metric.shape != (2, 2):
raise ValueError("The Image Empty has an invalid world transform")
try:
return np.linalg.cholesky(metric)
except np.linalg.LinAlgError as error:
raise ValueError(
"The Image Empty has a degenerate world transform"
) from error
def prepare_planar_contours(
contours,
image_size,
local_bounds,
spacing=POINT_SPACING,
world_metric=None,
):
import numpy as np
if spacing <= 0.0:
raise ValueError("Cutout point spacing must be greater than zero")
image_width, image_height = image_size
x_min, x_max, y_min, y_max = local_bounds
local_width = x_max - x_min
local_height = y_max - y_min
transform = metric_transform(world_metric)
prepared = []
for contour in contours:
pixels = np.asarray(contour, dtype=np.float64)
if pixels.ndim != 2 or pixels.shape[0] < 3 or pixels.shape[1] != 2:
continue
local = np.empty_like(pixels)
local[:, 0] = x_min + local_width * pixels[:, 0] / image_width
local[:, 1] = y_max - local_height * pixels[:, 1] / image_height
planar = local @ transform
perimeter = float(
np.linalg.norm(np.roll(planar, -1, axis=0) - planar, axis=1).sum()
)
if perimeter <= 1e-12:
continue
contour_spacing = min(spacing, perimeter / 8.0)
smoothed = smooth_region_contour(planar, contour_spacing)
smoothed_local = smoothed @ np.linalg.inv(transform)
smoothed_local[:, 0] = np.clip(smoothed_local[:, 0], x_min, x_max)
smoothed_local[:, 1] = np.clip(smoothed_local[:, 1], y_min, y_max)
prepared.append(smoothed_local @ transform)
if not prepared:
raise ValueError("The Cutout has no region large enough for this resolution")
interior = uniform_interior_points_for_contours(prepared, spacing)
return prepared, interior, spacing
def _prepare_region_contours(
region_contours,
local_bounds,
spacing=POINT_SPACING,
world_metric=None,
):
prepared = []
errors = []
for component in region_contours.components:
try:
contours, interior, _spacing = prepare_planar_contours(
component,
region_contours.image_size,
local_bounds,
spacing=spacing,
world_metric=world_metric,
)
except ValueError as error:
errors.append(error)
continue
prepared.append((contours, interior))
if not prepared:
if errors:
raise errors[0]
raise ValueError("The Cutout has no usable region")
return prepared, spacing
def _triangulate_contour_component(contours, interior, spacing):
import numpy as np
from mathutils import Vector
from mathutils.geometry import delaunay_2d_cdt
input_points = np.concatenate((*contours, interior), axis=0)
boundary_edges = []
offset = 0
for contour in contours:
count = len(contour)
boundary_edges.extend(
(offset + index, offset + (index + 1) % count)
for index in range(count)
)
offset += count
result = delaunay_2d_cdt(
[Vector(point) for point in input_points],
boundary_edges,
[],
0,
max(spacing * 1e-6, 1e-9),
False,
)
planar_points = np.asarray(
[tuple(point) for point in result[0]],
dtype=np.float64,
)
faces = np.asarray(
[tuple(face) for face in result[2] if len(face) == 3],
dtype=np.int32,
)
if len(faces):
centers = planar_points[faces].mean(axis=1)
faces = faces[points_inside_contours(centers, contours)]
if not len(faces):
raise ValueError("Unable to triangulate the Cutout regions")
used = np.unique(faces)
if len(used) != len(planar_points):
remap = np.full(len(planar_points), -1, dtype=np.int32)
remap[used] = np.arange(len(used), dtype=np.int32)
planar_points = planar_points[used]
faces = remap[faces]
boundary = boundary_vertices_from_faces(len(planar_points), faces)
planar_faces = [tuple(int(value) for value in face) for face in faces]
return planar_points, planar_faces, boundary
def boundary_vertices_from_faces(vertex_count, faces):
import numpy as np
faces = np.asarray(faces, dtype=np.int32)
boundary = np.zeros(vertex_count, dtype=bool)
if not len(faces):
return boundary
edges = np.concatenate(
[
faces[:, (index, (index + 1) % faces.shape[1])]
for index in range(faces.shape[1])
],
axis=0,
)
edges.sort(axis=1)
unique_edges, users = np.unique(edges, axis=0, return_counts=True)
boundary[unique_edges[users == 1].ravel()] = True
return boundary
def triangulate_region_contours(
region_contours,
local_bounds,
spacing=POINT_SPACING,
world_metric=None,
return_components=False,
):
import numpy as np
prepared, spacing = _prepare_region_contours(
region_contours,
local_bounds,
spacing=spacing,
world_metric=world_metric,
)
points = []
faces = []
boundaries = []
components = []
offset = 0
for contours, interior in prepared:
region_points, region_faces, region_boundary = _triangulate_contour_component(
contours,
interior,
spacing,
)
components.append(
(
offset,
offset + len(region_points),
region_faces,
contours[0],
)
)
points.append(region_points)
faces.extend(
tuple(offset + value for value in face) for face in region_faces
)
boundaries.append(region_boundary)
offset += len(region_points)
if not points:
raise ValueError("Unable to triangulate the Cutout regions")
result = (
np.concatenate(points, axis=0),
faces,
np.concatenate(boundaries),
)
return (*result, components) if return_components else result

View File

@@ -0,0 +1,126 @@
"""Cutout Region validation and Shape-menu interaction helpers."""
from ...preferences import (
MAX_CUTOUT_MESH_RESOLUTION,
MIN_CUTOUT_MESH_RESOLUTION,
)
from ..common.image_data import image_rgba
from ..common.image_region import rasterize_region_path
from .geometry import CUTOUT_ALPHA_THRESHOLD
def validate_cutout_region(
source_object,
region_path,
):
image = source_object.data
rgba = image_rgba(image)
rasterize_region_path(
tuple(image.size),
region_path,
source_alpha=rgba[:, :, 3],
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
antialias=True,
)
def cutout_mesh_resolution(image_size, point_spacing):
longest_edge = max(int(value) for value in image_size)
detail = round(longest_edge / max(int(point_spacing), 1))
return max(
MIN_CUTOUT_MESH_RESOLUTION,
min(MAX_CUTOUT_MESH_RESOLUTION, detail),
)
def _pie_button(
pie,
shape,
label,
icon,
source_name,
region_path_json,
refine_region,
generate_normal,
mesh_resolution,
):
operator = pie.operator(
"anyimage.cutout_region_to_shape",
text=label,
icon=icon,
)
operator.source_object_name = source_name
operator.region_path_json = region_path_json
operator.shape = shape
operator.refine_region = refine_region
operator.generate_normal = generate_normal
operator.mesh_resolution = mesh_resolution
def open_shape_pie(
context,
event,
source_object,
region_path,
refine_region=False,
):
source_name = source_object.name
region_path_json = region_path.to_json()
settings = context.scene.anyimage_settings
point_spacing = settings.cutout_point_spacing
generate_normal = bool(getattr(settings, "cutout_generate_normal", False))
mesh_resolution = cutout_mesh_resolution(source_object.data.size, point_spacing)
def draw(menu, _context):
pie = menu.layout.menu_pie()
_pie_button(
pie,
"SURFACE",
"Surface",
"MESH_GRID",
source_name,
region_path_json,
refine_region,
generate_normal,
mesh_resolution,
)
_pie_button(
pie,
"BALLOON",
"Balloon",
"MESH_UVSPHERE",
source_name,
region_path_json,
refine_region,
generate_normal,
mesh_resolution,
)
_pie_button(
pie,
"DEPTH_BALLOON",
"Depth Balloon",
"META_BALL",
source_name,
region_path_json,
refine_region,
generate_normal,
mesh_resolution,
)
_pie_button(
pie,
"DEPTH_SURFACE",
"Depth Surface",
"SURFACE_NCURVE",
source_name,
region_path_json,
refine_region,
generate_normal,
mesh_resolution,
)
context.window_manager.popup_menu_pie(
event,
draw,
title="Cutout Shape",
icon="MESH_DATA",
)

View File

@@ -0,0 +1,455 @@
"""Convert one image Region into a Cutout Shape."""
import json
import bpy
from ...preferences import (
MAX_CUTOUT_MESH_RESOLUTION,
MIN_CUTOUT_MESH_RESOLUTION,
addon_preferences,
configured_max_ai_input_size,
configured_max_cutout_mesh_resolution,
)
from ...properties import (
BEN2_MODEL_KEY,
DEFAULT_MOGE2_MODEL_KEY,
refine_region_property,
)
from ...runtime import JobOperatorBase
from ...server.media.resolution import (
DEFAULT_MAX_AI_INPUT_SIZE,
MAX_MAX_AI_INPUT_SIZE,
MIN_MAX_AI_INPUT_SIZE,
)
from ..common.ai_job import (
invoke_ai_setup_if_needed,
moge2_parameters,
production_device,
require_environment,
require_input_path,
require_model,
)
from ..common.image_data import (
cleanup_image_input,
image_rgba,
load_normal_result_image,
prepare_region_input,
require_image_empty,
source_start_frame,
video_total_frames,
)
from ..common.depth_data import load_depth_metadata, load_depth_result_image
from ..common.image_interaction import (
ImageRegionGesture,
apply_region_settings,
draw_region_settings,
gesture_property,
report_image_edit_exception,
resolve_image_edit_click,
serialize_matrix,
)
from ..common.image_region import (
ImageEditWarning,
RegionMask,
RegionPath,
rasterize_region_path,
)
from .geometry import (
CUTOUT_ALPHA_THRESHOLD,
build_base_shape,
limit_cutout_mesh_resolution,
)
from .interaction import open_shape_pie, validate_cutout_region
from .object import NormalMode, create_shape_object
from .shape import CUTOUT_SHAPES, DEPTH_CUTOUT_SHAPES
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
def normal_mode_for_shape(shape, generate_normal):
if not generate_normal:
return NormalMode.NONE
if shape == "DEPTH_SURFACE":
return NormalMode.OBJECT
return NormalMode.TANGENT
def depth_mode_for_shape(shape):
if shape == "DEPTH_SURFACE":
return "VECTOR"
if shape == "DEPTH_BALLOON":
return "Z"
return "NONE"
def create_cutout_shape(
context,
source_object,
shape,
mesh_resolution,
region_mask,
*,
depth_image=None,
depth_metadata=None,
normal_image=None,
normal_mode=NormalMode.NONE,
source_rgba=None,
):
base_shape = build_base_shape(
context,
source_object,
mesh_resolution,
region_mask,
)
result = create_shape_object(
context,
source_object,
shape,
mesh_resolution,
region_mask,
base_shape,
depth_image=depth_image,
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
source_rgba=source_rgba,
)
undo_message = f"Convert to {shape.replace('_', ' ').title()}"
if "FINISHED" not in bpy.ops.ed.undo_push(message=undo_message):
raise RuntimeError(f"Unable to create the {undo_message} Undo step")
return result
def _region_mask_from_image(path, bounds):
image = bpy.data.images.load(str(path), check_existing=False)
try:
return RegionMask(
image_rgba(image)[:, :, 3],
tuple(int(value) for value in bounds),
)
finally:
bpy.data.images.remove(image, do_unlink=True)
def _generated_region_mask(result, input_path, source_bounds):
if not isinstance(result.value, dict) or "region_image" not in result.value:
return _region_mask_from_image(input_path, source_bounds)
local_bounds = tuple(
int(value)
for value in json.loads(
result.file("region_bounds").read_text(encoding="utf-8")
)
)
bounds = (
source_bounds[0] + local_bounds[0],
source_bounds[1] + local_bounds[1],
source_bounds[0] + local_bounds[2],
source_bounds[1] + local_bounds[3],
)
return _region_mask_from_image(result.file("region_image"), bounds)
def cutout_geometry_settings(context):
preferences = addon_preferences(context)
model = getattr(preferences, "geometry_model", DEFAULT_MOGE2_MODEL_KEY)
return (
model,
int(getattr(preferences, "geometry_resolution_level", 5)),
)
class CutoutRegionToShape(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.cutout_region_to_shape"
bl_label = "Cutout Region to Shape"
bl_description = "Build the selected image Region as a Cutout shape"
bl_options = {"INTERNAL"}
job_type = "generate-cutout-artifacts"
starting_message = "Generating Cutout artifacts"
input_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
region_path_json: bpy.props.StringProperty(options=_HIDDEN)
region_bounds_json: bpy.props.StringProperty(options=_HIDDEN)
shape: bpy.props.StringProperty(options=_HIDDEN)
mesh_resolution: bpy.props.IntProperty(
default=128,
min=MIN_CUTOUT_MESH_RESOLUTION,
max=MAX_CUTOUT_MESH_RESOLUTION,
options=_HIDDEN,
)
model: bpy.props.StringProperty(
default=DEFAULT_MOGE2_MODEL_KEY,
options=_HIDDEN,
)
resolution_level: bpy.props.IntProperty(
default=5,
min=0,
max=9,
options=_HIDDEN,
)
max_ai_input_size: bpy.props.IntProperty(
default=DEFAULT_MAX_AI_INPUT_SIZE,
min=MIN_MAX_AI_INPUT_SIZE,
max=MAX_MAX_AI_INPUT_SIZE,
options=_HIDDEN,
)
generate_normal: bpy.props.BoolProperty(default=False, options=_HIDDEN)
refine_region: bpy.props.BoolProperty(default=False, options=_HIDDEN)
def _needs_generation(self):
return (
self.refine_region
or self.shape in DEPTH_CUTOUT_SHAPES
or self.generate_normal
)
def execute(self, context):
try:
source_object = require_image_empty(self.source_object_name)
if video_total_frames(source_object.data) != 1:
raise ValueError("Cutout currently supports still images")
if self.shape not in CUTOUT_SHAPES:
raise ValueError(f"Unsupported Cutout shape: {self.shape}")
region_path = RegionPath.from_json(self.region_path_json)
source_rgba = image_rgba(source_object.data)
region_mask = rasterize_region_path(
tuple(source_object.data.size),
region_path,
source_alpha=source_rgba[:, :, 3],
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
antialias=True,
)
context.view_layer.objects.active = source_object
source_object.select_set(True)
crop_width = region_mask.bounds[2] - region_mask.bounds[0]
crop_height = region_mask.bounds[3] - region_mask.bounds[1]
self.mesh_resolution = limit_cutout_mesh_resolution(
self.mesh_resolution,
(crop_width, crop_height),
configured_max_cutout_mesh_resolution(context),
)
self.max_ai_input_size = configured_max_ai_input_size(context)
if not self._needs_generation():
create_cutout_shape(
context,
source_object,
self.shape,
self.mesh_resolution,
region_mask,
source_rgba=source_rgba,
)
return {"FINISHED"}
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
self.model, self.resolution_level = cutout_geometry_settings(context)
self.region_bounds_json = json.dumps(
region_mask.bounds,
separators=(",", ":"),
)
self.input_path = str(prepare_region_input(source_object.data, region_mask))
except (
ImageEditWarning,
OSError,
RuntimeError,
TypeError,
ValueError,
) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
result = super().execute(context)
if "CANCELLED" in result:
cleanup_image_input(self.input_path, True)
return result
def request(self, context):
require_environment()
source_path = require_input_path(self.input_path)
depth_mode = depth_mode_for_shape(self.shape)
normal_mode = normal_mode_for_shape(self.shape, self.generate_normal)
if self.refine_region:
require_model(BEN2_MODEL_KEY)
if depth_mode != "NONE" or normal_mode != NormalMode.NONE:
require_model(self.model)
parameters = {
"input": str(source_path),
"device": production_device().lower(),
"refine_region": self.refine_region,
"depth_mode": depth_mode,
"normal_mode": normal_mode.value,
"max_input_size": int(self.max_ai_input_size),
}
if self.refine_region:
parameters["refine_model"] = BEN2_MODEL_KEY
if depth_mode != "NONE" or normal_mode != NormalMode.NONE:
start_frame = source_start_frame(self.source_object_name)
parameters.update(
model=self.model,
resolution_level=self.resolution_level,
)
parameters.update(
moge2_parameters(
context.scene.anyimage_settings,
source_path,
self.model,
self.resolution_level,
start_frame,
)
)
return parameters
def response(self, context, result):
source_object = require_image_empty(self.source_object_name)
source_bounds = tuple(json.loads(self.region_bounds_json))
region_mask = _generated_region_mask(
result,
self.input_path,
source_bounds,
)
value = result.value if isinstance(result.value, dict) else {}
normal_image = None
depth_image = None
try:
normal_mode = normal_mode_for_shape(self.shape, self.generate_normal)
normal_key = f"{normal_mode.value.lower()}_normal"
if normal_key in value:
normal_image = load_normal_result_image(
result.file(normal_key),
source_object,
)
depth_metadata = None
depth_mode = depth_mode_for_shape(self.shape)
if depth_mode != "NONE":
depth_metadata = load_depth_metadata(result.file("depth_metadata"))
depth_image = load_depth_result_image(
result.file(f"{depth_mode.lower()}_depth"),
source_object,
depth_metadata,
)
create_cutout_shape(
context,
source_object,
self.shape,
self.mesh_resolution,
region_mask,
depth_image=depth_image,
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
)
except Exception:
if normal_image is not None and normal_image.users == 0:
bpy.data.images.remove(normal_image, do_unlink=True)
if depth_image is not None and depth_image.users == 0:
bpy.data.images.remove(depth_image, do_unlink=True)
raise
return f"{self.shape.replace('_', ' ').title()} is ready"
def cleanup(self):
cleanup_image_input(self.input_path, True)
class SelectCutoutRegion(ImageRegionGesture, bpy.types.Operator):
bl_idname = "anyimage.select_cutout_region"
bl_label = "Select Cutout Region"
bl_description = "Lasso an Image Empty region, then choose its mesh shape"
bl_options = {"INTERNAL"}
active_tool_id = "anyimage.cutout_lasso"
refine_region_settings = {"LASSO": "cutout_refine_region"}
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
region_path_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
refine_region: refine_region_property()
invert: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
gesture: gesture_property()
full_image: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
preview_fill_rule = "NONZERO"
_pie_event = None
def _set_status(self, context):
invert = "On" if self.invert else "Off"
context.workspace.status_text_set(
f"Cutout Lasso: drag and release for shapes; "
f"F toggles Invert ({invert}); RMB or Esc cancels"
)
def invoke(self, context, event):
self._pie_event = event
if not self.full_image and event.value != "DOUBLE_CLICK":
return super().invoke(context, event)
source_object, should_edit = resolve_image_edit_click(
context, event, self.report
)
if not should_edit:
return {"FINISHED"} if source_object is not None else {"CANCELLED"}
if source_object is None:
return {"CANCELLED"}
if video_total_frames(source_object.data) > 1:
self.report({"WARNING"}, "Image tools only support a still image")
return {"CANCELLED"}
apply_region_settings(self, context)
self.invert = False
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
width, height = (int(value) for value in source_object.data.size)
self.region_path_json = RegionPath(
points=((0, 0), (width, 0), (width, height), (0, height)),
fill_rule="NONZERO",
).to_json()
return self.execute(context)
def modal(self, context, event):
self._pie_event = event
return super().modal(context, event)
def execute(self, context):
try:
source_object = require_image_empty(self.source_object_name)
except RuntimeError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
try:
region_path = RegionPath.from_json(self.region_path_json)
validate_cutout_region(source_object, region_path)
except (ImageEditWarning, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
open_shape_pie(
context,
self._pie_event,
source_object,
region_path,
self.refine_region,
)
return {"FINISHED"}
class CutoutTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = "anyimage.cutout_lasso"
bl_label = "Cutout"
bl_description = "Lasso an Image Empty and choose a mesh shape"
bl_icon = "ops.sculpt.lasso_mask"
bl_operator = SelectCutoutRegion.bl_idname
bl_keymap = (
(
SelectCutoutRegion.bl_idname,
{"type": "LEFTMOUSE", "value": "DOUBLE_CLICK"},
{"properties": [("gesture", "LASSO"), ("full_image", True)]},
),
(
SelectCutoutRegion.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("gesture", "LASSO")]},
),
)
def draw_settings(context, layout, _tool):
settings = context.scene.anyimage_settings
draw_region_settings(context, layout, "cutout_refine_region")
layout.prop(settings, "cutout_point_spacing")
layout.prop(settings, "cutout_generate_normal")

View File

@@ -0,0 +1,292 @@
"""Create Blender data for a Cutout Shape."""
from enum import StrEnum
import bpy
from ..common.image_data import (
color_data_name,
image_rgba,
)
from ..common.depth_data import camera_depth_values, depth_uniform_scale
from ..common.mesh_result import (
create_image_material,
finalize_object_result,
load_asset_node_group,
modifier_input_identifier,
set_modifier_input,
)
from .geometry import (
DEFAULT_DEPTH_BASELINE,
DEFAULT_DEPTH_DIRECTION,
crop_plane_bounds,
cutout_cleanup_value,
cutout_point_spacing,
depth_balloon_projection_parameters,
metric_depth_reference,
region_calibration,
use_adaptive_depth_calibration,
world_plane_metric,
)
from .shape import (
BALLOON_ATTRIBUTE_NAME,
CUTOUT_NODE_GROUP_NAME,
CUTOUT_SHAPE_INDICES,
DEPTH_CUTOUT_SHAPES,
)
class NormalMode(StrEnum):
NONE = "NONE"
TANGENT = "TANGENT"
OBJECT = "OBJECT"
CUTOUT_AXIS_TO_SOURCE = (
(0.0, -1.0, 0.0, 0.0),
(0.0, 0.0, 1.0, 0.0),
(-1.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
def _cutout_world_matrix(source_matrix, crop_center):
from mathutils import Matrix
center_x, center_y = (float(value) for value in crop_center)
return (
source_matrix
@ Matrix.Translation((center_x, center_y, 0.0))
@ Matrix(CUTOUT_AXIS_TO_SOURCE)
)
def _assign_uv(mesh, uv):
import numpy as np
for layer in list(mesh.uv_layers):
mesh.uv_layers.remove(layer)
uv_layer = mesh.uv_layers.new(name="UVMap")
loop_vertices = np.empty(len(mesh.loops), dtype=np.int32)
mesh.loops.foreach_get("vertex_index", loop_vertices)
loop_uv = np.asarray(uv, dtype=np.float32)[loop_vertices]
uv_layer.data.foreach_set("uv", loop_uv.ravel())
def _assign_float_point_attribute(mesh, name, values):
import numpy as np
attribute = mesh.attributes.new(name=name, type="FLOAT", domain="POINT")
attribute.data.foreach_set("value", np.asarray(values, dtype=np.float32))
def _cropped_color_image(source_object, region_mask, source_rgba):
crop_bounds = region_mask.bounds
import numpy as np
source = source_object.data
rgba = np.asarray(source_rgba)
left, top, right, bottom = crop_bounds
width = right - left
height = bottom - top
if width <= 0 or height <= 0:
raise ValueError("The Cutout crop dimensions must be positive")
cropped = np.zeros((height, width, 4), dtype=np.float32)
source_height, source_width = rgba.shape[:2]
source_left = max(0, left)
source_top = max(0, top)
source_right = min(source_width, right)
source_bottom = min(source_height, bottom)
if source_right > source_left and source_bottom > source_top:
cropped[
source_top - top : source_bottom - top,
source_left - left : source_right - left,
] = rgba[source_top:source_bottom, source_left:source_right]
image = bpy.data.images.new(
color_data_name(source_object),
width=width,
height=height,
alpha=True,
)
try:
image.colorspace_settings.name = source.colorspace_settings.name
image.alpha_mode = source.alpha_mode
image.pixels.foreach_set(np.flipud(cropped).ravel())
image.pack()
image.update()
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def load_cutout_node_group():
return load_asset_node_group(CUTOUT_NODE_GROUP_NAME)
def create_shape_object(
context,
source_object,
shape,
mesh_resolution,
region_mask,
base_shape,
*,
depth_image=None,
depth_metadata=None,
normal_image=None,
normal_mode=NormalMode.NONE,
source_rgba=None,
):
import numpy as np
normal_mode = NormalMode(normal_mode)
bounds = crop_plane_bounds(
source_object,
region_mask.bounds,
tuple(source_object.data.size),
)
crop_center = (
(bounds[0] + bounds[1]) * 0.5,
(bounds[2] + bounds[3]) * 0.5,
)
world_metric, _world_z_scale = world_plane_metric(source_object.matrix_world)
cleanup_value = cutout_cleanup_value(
cutout_point_spacing(source_object, mesh_resolution),
world_metric,
)
color_image = _cropped_color_image(
source_object,
region_mask,
image_rgba(source_object.data) if source_rgba is None else source_rgba,
)
material = None
mesh = None
mesh_object = None
try:
material = create_image_material(
source_object,
color_image,
0.0,
normal_image=normal_image,
normal_space=(
NormalMode.TANGENT.value
if normal_mode == NormalMode.NONE
else normal_mode.value
),
ior=1.2,
scene=context.scene,
)
depth_reference = None
uniform_scale = None
depth_direction = None
if shape in DEPTH_CUTOUT_SHAPES:
if depth_image is None or depth_metadata is None:
raise ValueError("Depth Shape requires a Depth texture and metadata")
camera_depth, valid = camera_depth_values(depth_image)
calibration_valid, transparent_fraction = region_calibration(
region_mask, valid.shape
)
calibration_valid &= valid
uniform_scale = depth_uniform_scale(
source_object,
depth_metadata,
bounds,
)
if not use_adaptive_depth_calibration(transparent_fraction):
depth_reference = DEFAULT_DEPTH_BASELINE
if shape == "DEPTH_BALLOON":
depth_direction = DEFAULT_DEPTH_DIRECTION
elif shape == "DEPTH_SURFACE":
depth_reference = metric_depth_reference(
camera_depth,
calibration_valid,
uniform_scale,
)
else:
depth_reference, depth_direction = depth_balloon_projection_parameters(
base_shape.vertices,
base_shape.uv,
camera_depth,
calibration_valid,
uniform_scale,
)
source_name = source_object.name
mesh = bpy.data.meshes.new(source_name)
mesh.from_pydata(
np.asarray(base_shape.vertices).tolist(),
[],
base_shape.faces,
)
for face in mesh.polygons:
face.use_smooth = True
_assign_uv(mesh, base_shape.uv)
_assign_float_point_attribute(
mesh,
BALLOON_ATTRIBUTE_NAME,
base_shape.balloon,
)
mesh.materials.append(material)
mesh.update()
mesh_object = bpy.data.objects.new(source_name, mesh)
context.collection.objects.link(mesh_object)
mesh_object.matrix_world = _cutout_world_matrix(
source_object.matrix_world,
crop_center,
)
mesh_object["anyimage_mesh_shape"] = shape
node_group = load_cutout_node_group()
modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES")
modifier.node_group = node_group
modifier.show_group_selector = False
for name, value in (
("Shape", CUTOUT_SHAPE_INDICES[shape]),
("Cleanup", cleanup_value),
("Smooth Iterations", 0),
("Smooth", 1.0),
(
"Thickness",
1.0 if shape in {"BALLOON", "DEPTH_BALLOON"} else 0.0,
),
):
set_modifier_input(
modifier,
modifier_input_identifier(node_group, name),
value,
)
if shape in DEPTH_CUTOUT_SHAPES:
for name, value in (
("Depth Scale", 1.0),
("Uniform Scale", uniform_scale),
("Base Plane Depth", depth_reference),
("Depth Image", depth_image),
):
set_modifier_input(
modifier,
modifier_input_identifier(node_group, name),
value,
)
if shape == "DEPTH_BALLOON":
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Depth Direction"),
depth_direction,
)
return finalize_object_result(
context,
source_object,
mesh_object,
keep_source=True,
)
except Exception:
if mesh_object is not None:
bpy.data.objects.remove(mesh_object, do_unlink=True)
if mesh is not None and mesh.users == 0:
bpy.data.meshes.remove(mesh)
if material is not None and material.users == 0:
bpy.data.materials.remove(material)
if color_image.users == 0:
bpy.data.images.remove(color_image, do_unlink=True)
raise

View File

@@ -1,842 +0,0 @@
import json
from dataclasses import dataclass
from enum import StrEnum
import bpy
from ...preferences import addon_preferences, configured_max_ai_input_size
from ...properties import BEN2_MODEL_KEY, DEFAULT_MOGE2_MODEL_KEY
from ...runtime import JobOperatorBase
from ...server.media.resolution import limited_image_size
from ...server.media.resolution import (
DEFAULT_MAX_AI_INPUT_SIZE,
MAX_MAX_AI_INPUT_SIZE,
MIN_MAX_AI_INPUT_SIZE,
)
from ..common.image_data import (
cleanup_image_input,
color_data_name,
depth_data_name,
image_rgba,
load_normal_result_image,
image_empty_bounds,
prepare_region_input,
require_image_empty,
source_start_frame,
video_total_frames,
)
from ..common.image_interaction import report_image_edit_exception
from ..common.image_region import (
ImageEditWarning,
RegionMask,
RegionPath,
rasterize_region_path,
)
from ..common.mesh_result import (
create_image_material,
finalize_object_result,
load_asset_node_group,
load_image_geometry,
modifier_input_identifier,
set_modifier_input,
)
from ..common.ai_job import (
invoke_ai_setup_if_needed,
moge2_parameters,
production_device,
require_environment,
require_input_path,
require_model,
)
from .geometry.depth_calibration import (
DEFAULT_DEPTH_BASELINE,
DEFAULT_DEPTH_DIRECTION,
depth_balloon_projection_parameters,
metric_depth_reference,
use_adaptive_depth_calibration,
)
from .geometry.triangulation import (
metric_transform,
triangulate_region_contours,
)
from .geometry.balloon import balloon_profile as calculate_balloon_profile
from .geometry.region_contours import extract_region_contours
CUTOUT_ALPHA_THRESHOLD = 0.1
CUTOUT_CLEANUP_UI_SCALE = 100.0
BALLOON_ATTRIBUTE_NAME = "o_balloon"
CUTOUT_NODE_GROUP_NAME = "O Image Cutout"
CUTOUT_NODE_GROUP_NAMES = {
"SURFACE": "O Image Surface",
"BALLOON": "O Image Balloon",
"DEPTH_BALLOON": "O Image Depth Balloon",
"DEPTH_SURFACE": "O Image Depth Surface",
}
CUTOUT_SHAPE_INDICES = {
shape: index for index, shape in enumerate(CUTOUT_NODE_GROUP_NAMES)
}
CUTOUT_SHAPES = frozenset(CUTOUT_NODE_GROUP_NAMES)
DEPTH_CUTOUT_SHAPES = frozenset({"DEPTH_BALLOON", "DEPTH_SURFACE"})
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
class NormalMode(StrEnum):
NONE = "NONE"
TANGENT = "TANGENT"
OBJECT = "OBJECT"
@dataclass(frozen=True)
class BaseShape:
vertices: object
faces: object
uv: object
balloon: object
CUTOUT_AXIS_TO_SOURCE = (
(0.0, -1.0, 0.0, 0.0),
(0.0, 0.0, 1.0, 0.0),
(-1.0, 0.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
def _cutout_depth_image(source_object, points, valid, camera_depth):
import numpy as np
points = np.asarray(points, dtype=np.float32)
valid = np.asarray(valid, dtype=bool)
height, width = valid.shape
if points.shape != (height, width, 3):
raise ValueError("Depth Surface point dimensions do not match")
discontinuity = _depth_discontinuity_map(camera_depth, valid)
discontinuity[~valid] = -1.0
pixels = np.empty((height, width, 4), dtype=np.float32)
pixels[:, :, :3] = points
pixels[:, :, 3] = discontinuity
image = bpy.data.images.new(
depth_data_name(source_object),
width=width,
height=height,
alpha=True,
float_buffer=True,
)
image.colorspace_settings.name = "Non-Color"
image.alpha_mode = "CHANNEL_PACKED"
image.pixels.foreach_set(np.flipud(pixels).ravel())
image.pack()
return image
def _cutout_world_matrix(source_matrix, crop_center):
from mathutils import Matrix
center_x, center_y = (float(value) for value in crop_center)
return (
source_matrix
@ Matrix.Translation((center_x, center_y, 0.0))
@ Matrix(CUTOUT_AXIS_TO_SOURCE)
)
def _orient_cutout_vertices(vertices):
import numpy as np
vertices = np.asarray(vertices, dtype=np.float64)
return np.column_stack(
(
-vertices[:, 2],
-vertices[:, 0],
vertices[:, 1],
)
)
def _cutout_domain(
region_contours,
bounds,
spacing,
world_metric,
balloon_algorithm="POISSON",
):
import numpy as np
x_min, x_max, y_min, y_max = bounds
planar, planar_faces, boundary, components = triangulate_region_contours(
region_contours,
bounds,
spacing=spacing,
world_metric=world_metric,
return_components=True,
)
profile = calculate_balloon_profile(
balloon_algorithm, planar, planar_faces, boundary, components
)
faces = np.asarray(planar_faces, dtype=np.int32)
local = planar @ np.linalg.inv(metric_transform(world_metric))
uv = np.column_stack(
(
(local[:, 0] - x_min) / (x_max - x_min),
(local[:, 1] - y_min) / (y_max - y_min),
)
).astype(np.float32)
crop_center = np.asarray(
((x_min + x_max) * 0.5, (y_min + y_max) * 0.5),
dtype=np.float64,
)
local -= crop_center
vertices = np.column_stack((local, np.zeros(len(local), dtype=np.float64)))
return vertices, faces, uv, boundary, planar, profile
def _world_plane_metric(matrix_world):
from mathutils import Vector
linear = matrix_world.to_3x3()
x = linear @ Vector((1.0, 0.0, 0.0))
y = linear @ Vector((0.0, 1.0, 0.0))
z = linear @ Vector((0.0, 0.0, 1.0))
return ((x.dot(x), x.dot(y)), (x.dot(y), y.dot(y))), z.length
def _cutout_cleanup_value(spacing, world_metric):
import numpy as np
if spacing <= 0.0:
raise ValueError("Cutout point spacing must be greater than zero")
area_scale = abs(float(np.linalg.det(metric_transform(world_metric))))
if area_scale <= 1e-12:
raise ValueError("The Image Empty has a degenerate world transform")
return CUTOUT_CLEANUP_UI_SCALE * float(spacing) ** 2 / area_scale
def _depth_uniform_scale(
source_object,
field,
bounds=None,
):
import numpy as np
intrinsics = field.intrinsics
width, height = field.image_size
x_min, x_max, y_min, y_max = bounds or image_empty_bounds(source_object)
plane_width = x_max - x_min
plane_height = y_max - y_min
fx, fy = float(intrinsics[0, 0]), float(intrinsics[1, 1])
if fx <= 0.0 or fy <= 0.0:
raise ValueError("Camera intrinsics contain an invalid focal length")
scale_x = plane_width * fx / (max(width - 1, 1) * field.reference_depth)
scale_y = plane_height * fy / (max(height - 1, 1) * field.reference_depth)
return float(np.sqrt(scale_x * scale_y))
def _depth_discontinuity_map(depth, valid):
import numpy as np
depth = np.asarray(depth, dtype=np.float32)
valid = np.asarray(valid, dtype=bool) & np.isfinite(depth) & (depth > 0.0)
padded_depth = np.pad(depth, 1, mode="edge")
padded_valid = np.pad(valid, 1, mode="edge")
windows = np.lib.stride_tricks.sliding_window_view(padded_depth, (3, 3))
usable = np.lib.stride_tricks.sliding_window_view(padded_valid, (3, 3))
minimum = np.min(np.where(usable, windows, np.inf), axis=(-2, -1))
maximum = np.max(np.where(usable, windows, -np.inf), axis=(-2, -1))
result = (maximum - minimum) / np.maximum(np.abs(depth), 1e-6)
result[~usable.all(axis=(-2, -1))] = 1.0
return result.astype(np.float32)
def _counter_clockwise_faces(points, faces):
import numpy as np
points = np.asarray(points, dtype=np.float64)
result = np.asarray(faces, dtype=np.int32).copy()
polygons = points[result]
following = np.roll(polygons, -1, axis=1)
signed_area = np.sum(
polygons[..., 0] * following[..., 1]
- polygons[..., 1] * following[..., 0],
axis=1,
)
result[signed_area < 0.0] = result[signed_area < 0.0, ::-1]
return result
def _assign_uv(mesh, uv):
import numpy as np
for layer in list(mesh.uv_layers):
mesh.uv_layers.remove(layer)
uv_layer = mesh.uv_layers.new(name="UVMap")
loop_vertices = np.empty(len(mesh.loops), dtype=np.int32)
mesh.loops.foreach_get("vertex_index", loop_vertices)
loop_uv = np.asarray(uv, dtype=np.float32)[loop_vertices]
uv_layer.data.foreach_set("uv", loop_uv.ravel())
def _assign_float_point_attribute(mesh, name, values):
import numpy as np
attribute = mesh.attributes.new(name=name, type="FLOAT", domain="POINT")
attribute.data.foreach_set("value", np.asarray(values, dtype=np.float32))
def _crop_plane_bounds(source_object, crop_bounds, source_size):
source_width, source_height = (float(value) for value in source_size)
left, top, right, bottom = (float(value) for value in crop_bounds)
x_min, x_max, y_min, y_max = image_empty_bounds(source_object)
width = x_max - x_min
height = y_max - y_min
return (
x_min + left * width / source_width,
x_min + right * width / source_width,
y_max - bottom * height / source_height,
y_max - top * height / source_height,
)
def _cropped_color_image(source_object, crop_bounds, source_rgba):
import numpy as np
source = source_object.data
rgba = np.asarray(source_rgba)
left, top, right, bottom = crop_bounds
width = right - left
height = bottom - top
if width <= 0 or height <= 0:
raise ValueError("The Cutout crop dimensions must be positive")
cropped = np.zeros((height, width, 4), dtype=np.float32)
source_height, source_width = rgba.shape[:2]
source_left = max(0, left)
source_top = max(0, top)
source_right = min(source_width, right)
source_bottom = min(source_height, bottom)
if source_right > source_left and source_bottom > source_top:
cropped[
source_top - top : source_bottom - top,
source_left - left : source_right - left,
] = rgba[source_top:source_bottom, source_left:source_right]
image = bpy.data.images.new(
color_data_name(source_object),
width=width,
height=height,
alpha=True,
)
try:
image.colorspace_settings.name = source.colorspace_settings.name
image.alpha_mode = source.alpha_mode
image.pixels.foreach_set(np.flipud(cropped).ravel())
image.pack()
image.update()
except Exception:
bpy.data.images.remove(image, do_unlink=True)
raise
return image
def _extract_cutout_contours(
region_mask,
mesh_detail,
threshold=CUTOUT_ALPHA_THRESHOLD,
):
contour_resolution = max(512, min(int(mesh_detail) * 8, 1024))
return extract_region_contours(
region_mask,
max_resolution=contour_resolution,
threshold=threshold,
)
def cutout_node_group(shape):
if shape not in CUTOUT_SHAPES:
raise ValueError(f"Unsupported Cutout shape: {shape}")
return load_asset_node_group(CUTOUT_NODE_GROUP_NAME)
def limited_cutout_mesh_detail(mesh_detail, crop_size, max_input_size):
crop_width, crop_height = (int(value) for value in crop_size)
limited_width, limited_height = limited_image_size(
(crop_width, crop_height),
max_input_size,
)
scale = max(limited_width, limited_height) / max(crop_width, crop_height)
return max(4, min(1024, round(int(mesh_detail) * scale)))
def _cutout_point_spacing(source_object, detail):
import math
x_min, x_max, y_min, y_max = image_empty_bounds(source_object)
metric, _world_z_scale = _world_plane_metric(source_object.matrix_world)
width = (x_max - x_min) * math.sqrt(max(metric[0][0], 0.0))
height = (y_max - y_min) * math.sqrt(max(metric[1][1], 0.0))
return max(width, height) / max(int(detail), 8)
def build_base_shape(context, source_object, detail, region_mask):
import numpy as np
bounds = _crop_plane_bounds(
source_object,
region_mask.bounds,
tuple(source_object.data.size),
)
region_contours = _extract_cutout_contours(
region_mask,
detail,
threshold=CUTOUT_ALPHA_THRESHOLD,
)
world_metric, world_z_scale = _world_plane_metric(source_object.matrix_world)
if world_z_scale <= 1e-10:
raise ValueError("The Image Empty has a degenerate world transform")
preferences = addon_preferences(context)
balloon_algorithm = getattr(
preferences, "balloon_profile_algorithm", "POISSON"
)
point_spacing = _cutout_point_spacing(source_object, detail)
domain = _cutout_domain(
region_contours,
bounds,
point_spacing,
world_metric,
balloon_algorithm,
)
source_vertices, faces, uv, _boundary, planar, balloon = domain
return BaseShape(
vertices=_orient_cutout_vertices(source_vertices),
faces=_counter_clockwise_faces(planar, faces),
uv=np.asarray(uv, dtype=np.float32),
balloon=np.asarray(balloon, dtype=np.float64) / world_z_scale,
)
def create_shape_object(
context,
source_object,
shape,
detail,
region_mask,
base_shape,
*,
field=None,
normal_image=None,
normal_mode=NormalMode.NONE,
source_rgba=None,
):
import numpy as np
normal_mode = NormalMode(normal_mode)
bounds = _crop_plane_bounds(
source_object,
region_mask.bounds,
tuple(source_object.data.size),
)
crop_center = (
(bounds[0] + bounds[1]) * 0.5,
(bounds[2] + bounds[3]) * 0.5,
)
world_metric, _world_z_scale = _world_plane_metric(source_object.matrix_world)
cleanup_value = _cutout_cleanup_value(
_cutout_point_spacing(source_object, detail),
world_metric,
)
color_image = _cropped_color_image(
source_object,
region_mask.bounds,
image_rgba(source_object.data) if source_rgba is None else source_rgba,
)
material = None
depth_image = None
mesh = None
mesh_object = None
try:
material = create_image_material(
source_object,
color_image,
0.0,
normal_image=normal_image,
normal_space=(
NormalMode.TANGENT.value
if normal_mode == NormalMode.NONE
else normal_mode.value
),
scene=context.scene,
)
depth_reference = None
depth_uniform_scale = None
depth_direction = None
if shape in DEPTH_CUTOUT_SHAPES:
points = field.points
valid = field.valid
calibration_valid = field.calibration_valid
depth_image = _cutout_depth_image(
source_object, points, valid, points[..., 2]
)
depth_uniform_scale = _depth_uniform_scale(
source_object,
field,
bounds,
)
if not use_adaptive_depth_calibration(
field.region_transparent_fraction
):
depth_reference = DEFAULT_DEPTH_BASELINE
if shape == "DEPTH_BALLOON":
depth_direction = DEFAULT_DEPTH_DIRECTION
elif shape == "DEPTH_SURFACE":
depth_reference = metric_depth_reference(
points[..., 2],
calibration_valid,
depth_uniform_scale,
)
else:
depth_reference, depth_direction = (
depth_balloon_projection_parameters(
base_shape.vertices,
base_shape.uv,
points[..., 2],
calibration_valid,
depth_uniform_scale,
)
)
source_name = source_object.name
mesh = bpy.data.meshes.new(source_name)
mesh.from_pydata(
np.asarray(base_shape.vertices).tolist(),
[],
base_shape.faces,
)
for face in mesh.polygons:
face.use_smooth = True
_assign_uv(mesh, base_shape.uv)
_assign_float_point_attribute(
mesh,
BALLOON_ATTRIBUTE_NAME,
base_shape.balloon,
)
mesh.materials.append(material)
mesh.update()
mesh_object = bpy.data.objects.new(source_name, mesh)
context.collection.objects.link(mesh_object)
mesh_object.matrix_world = _cutout_world_matrix(
source_object.matrix_world,
crop_center,
)
mesh_object["anyimage_mesh_shape"] = shape
node_group = cutout_node_group(shape)
modifier = mesh_object.modifiers.new(name=node_group.name, type="NODES")
modifier.node_group = node_group
modifier.show_group_selector = False
for name, value in (
("Shape", CUTOUT_SHAPE_INDICES[shape]),
("Cleanup", cleanup_value),
("Smooth Iterations", 0),
("Smooth", 1.0),
(
"Thickness",
1.0 if shape in {"BALLOON", "DEPTH_BALLOON"} else 0.0,
),
):
set_modifier_input(
modifier,
modifier_input_identifier(node_group, name),
value,
)
if shape in DEPTH_CUTOUT_SHAPES:
for name, value in (
("Depth Scale", 1.0),
("Uniform Scale", depth_uniform_scale),
("Base Plane Depth", depth_reference),
("Depth Image", depth_image),
):
set_modifier_input(
modifier,
modifier_input_identifier(node_group, name),
value,
)
if shape == "DEPTH_BALLOON":
set_modifier_input(
modifier,
modifier_input_identifier(node_group, "Depth Direction"),
depth_direction,
)
return finalize_object_result(
context,
source_object,
mesh_object,
keep_source=True,
)
except Exception:
if mesh_object is not None:
bpy.data.objects.remove(mesh_object, do_unlink=True)
if mesh is not None and mesh.users == 0:
bpy.data.meshes.remove(mesh)
if material is not None and material.users == 0:
bpy.data.materials.remove(material)
if color_image.users == 0:
bpy.data.images.remove(color_image, do_unlink=True)
if normal_image is not None and normal_image.users == 0:
bpy.data.images.remove(normal_image, do_unlink=True)
if depth_image is not None and depth_image.users == 0:
bpy.data.images.remove(depth_image, do_unlink=True)
raise
def region_to_shape(
context,
source_object,
shape,
detail,
region_mask,
*,
field=None,
normal_image=None,
normal_mode=NormalMode.NONE,
source_rgba=None,
):
crop_width = region_mask.bounds[2] - region_mask.bounds[0]
crop_height = region_mask.bounds[3] - region_mask.bounds[1]
detail = limited_cutout_mesh_detail(
detail,
(crop_width, crop_height),
configured_max_ai_input_size(context),
)
base_shape = build_base_shape(
context,
source_object,
detail,
region_mask,
)
result = create_shape_object(
context,
source_object,
shape,
detail,
region_mask,
base_shape,
field=field,
normal_image=normal_image,
normal_mode=normal_mode,
source_rgba=source_rgba,
)
undo_message = f"Convert to {shape.replace('_', ' ').title()}"
if "FINISHED" not in bpy.ops.ed.undo_push(message=undo_message):
raise RuntimeError(f"Unable to create the {undo_message} Undo step")
return result
def _region_mask_from_image(path, bounds):
image = bpy.data.images.load(str(path), check_existing=False)
try:
return RegionMask(
image_rgba(image)[:, :, 3],
tuple(int(value) for value in bounds),
)
finally:
bpy.data.images.remove(image, do_unlink=True)
def _generated_region_mask(result, input_path, source_bounds):
if not isinstance(result.value, dict) or "region" not in result.value:
return _region_mask_from_image(input_path, source_bounds)
local_bounds = tuple(
int(value)
for value in json.loads(
result.file("region_bounds").read_text(encoding="utf-8")
)
)
bounds = (
source_bounds[0] + local_bounds[0],
source_bounds[1] + local_bounds[1],
source_bounds[0] + local_bounds[2],
source_bounds[1] + local_bounds[3],
)
return _region_mask_from_image(result.file("region"), bounds)
def cutout_moge_settings(context):
preferences = addon_preferences(context)
model = getattr(preferences, "mesh_depth_model", DEFAULT_MOGE2_MODEL_KEY)
if not model.startswith("MOGE2_"):
model = DEFAULT_MOGE2_MODEL_KEY
return (
model,
int(getattr(preferences, "mesh_resolution_level", 5)),
)
class CutoutRegionToShape(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.cutout_region_to_shape"
bl_label = "Cutout Region to Shape"
bl_description = "Build the selected image Region as a Cutout shape"
bl_options = {"INTERNAL"}
job_type = "generate-region-data"
starting_message = "Generating Cutout Region data"
input_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
region_path_json: bpy.props.StringProperty(options=_HIDDEN)
region_bounds_json: bpy.props.StringProperty(options=_HIDDEN)
shape: bpy.props.StringProperty(options=_HIDDEN)
mesh_detail: bpy.props.IntProperty(
default=128,
min=4,
max=1024,
options=_HIDDEN,
)
model: bpy.props.StringProperty(
default=DEFAULT_MOGE2_MODEL_KEY,
options=_HIDDEN,
)
resolution_level: bpy.props.IntProperty(
default=5,
min=0,
max=9,
options=_HIDDEN,
)
max_input_size: bpy.props.IntProperty(
default=DEFAULT_MAX_AI_INPUT_SIZE,
min=MIN_MAX_AI_INPUT_SIZE,
max=MAX_MAX_AI_INPUT_SIZE,
options=_HIDDEN,
)
normal_mode: bpy.props.StringProperty(
default=NormalMode.NONE.value,
options=_HIDDEN,
)
refine_selection: bpy.props.BoolProperty(default=False, options=_HIDDEN)
def _needs_generation(self):
return (
self.refine_selection
or self.shape in DEPTH_CUTOUT_SHAPES
or NormalMode(self.normal_mode) != NormalMode.NONE
)
def execute(self, context):
try:
source_object = require_image_empty(self.source_object_name)
if video_total_frames(source_object.data) != 1:
raise ValueError("Cutout currently supports still images")
if self.shape not in CUTOUT_SHAPES:
raise ValueError(f"Unsupported Cutout shape: {self.shape}")
self.normal_mode = NormalMode(self.normal_mode).value
region_path = RegionPath.from_json(self.region_path_json)
source_rgba = image_rgba(source_object.data)
region_mask = rasterize_region_path(
tuple(source_object.data.size),
region_path,
source_alpha=source_rgba[:, :, 3],
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
antialias=True,
)
context.view_layer.objects.active = source_object
source_object.select_set(True)
self.max_input_size = configured_max_ai_input_size(context)
if not self._needs_generation():
region_to_shape(
context,
source_object,
self.shape,
self.mesh_detail,
region_mask,
source_rgba=source_rgba,
)
return {"FINISHED"}
setup_result = invoke_ai_setup_if_needed()
if setup_result is not None:
return setup_result
self.model, self.resolution_level = cutout_moge_settings(context)
self.region_bounds_json = json.dumps(
region_mask.bounds,
separators=(",", ":"),
)
self.input_path = str(
prepare_region_input(source_object.data, region_mask)
)
except (
ImageEditWarning,
OSError,
RuntimeError,
TypeError,
ValueError,
) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
result = super().execute(context)
if "CANCELLED" in result:
cleanup_image_input(self.input_path, True)
return result
def request(self, context):
require_environment()
source_path = require_input_path(self.input_path)
output_depth = self.shape in DEPTH_CUTOUT_SHAPES
normal_mode = NormalMode(self.normal_mode)
if self.refine_selection:
require_model(BEN2_MODEL_KEY)
if output_depth or normal_mode != NormalMode.NONE:
require_model(self.model)
parameters = {
"input": str(source_path),
"device": production_device().lower(),
"refine_region": self.refine_selection,
"output_depth": output_depth,
"normal_mode": normal_mode.value,
"max_input_size": int(self.max_input_size),
}
if self.refine_selection:
parameters["refine_model"] = BEN2_MODEL_KEY
if output_depth or normal_mode != NormalMode.NONE:
start_frame = source_start_frame(self.source_object_name)
parameters.update(
model=self.model,
resolution_level=self.resolution_level,
)
parameters.update(
moge2_parameters(
context.scene.anyimage_settings,
source_path,
self.model,
self.resolution_level,
start_frame,
)
)
return parameters
def response(self, context, result):
source_object = require_image_empty(self.source_object_name)
source_bounds = tuple(json.loads(self.region_bounds_json))
region_mask = _generated_region_mask(
result,
self.input_path,
source_bounds,
)
value = result.value if isinstance(result.value, dict) else {}
normal_image = (
load_normal_result_image(result.file("normal"), source_object)
if "normal" in value
else None
)
field = (
load_image_geometry(result.file("image_geometry"))
if self.shape in DEPTH_CUTOUT_SHAPES
else None
)
region_to_shape(
context,
source_object,
self.shape,
self.mesh_detail,
region_mask,
field=field,
normal_image=normal_image,
normal_mode=NormalMode(self.normal_mode),
)
return f"{self.shape.replace('_', ' ').title()} is ready"
def cleanup(self):
cleanup_image_input(self.input_path, True)

View File

@@ -1,240 +0,0 @@
import bpy
from ...properties import (
refine_selection_property,
)
from ..common.image_data import image_rgba, require_image_empty, video_total_frames
from ..common.image_interaction import (
apply_region_settings,
draw_region_settings,
report_image_edit_exception,
resolve_image_edit_click,
)
from ..common.image_interaction import serialize_matrix
from ..common.image_interaction import ImageRegionGesture, gesture_property
from ..common.image_region import (
ImageEditWarning,
RegionPath,
rasterize_region_path,
)
CUTOUT_DETAIL_MIN = 4
CUTOUT_DETAIL_MAX = 1024
def _validate_cutout_region(
source_object,
region_path,
):
image = source_object.data
rgba = image_rgba(image)
rasterize_region_path(
tuple(image.size),
region_path,
source_alpha=rgba[:, :, 3],
alpha_threshold=0.1,
antialias=True,
)
def cutout_mesh_detail(image_size, point_spacing):
longest_edge = max(int(value) for value in image_size)
detail = round(longest_edge / max(int(point_spacing), 1))
return max(CUTOUT_DETAIL_MIN, min(CUTOUT_DETAIL_MAX, detail))
def _pie_button(
pie,
shape,
label,
icon,
source_name,
region_path_json,
refine_selection,
normal_mode,
mesh_detail,
):
operator = pie.operator(
"anyimage.cutout_region_to_shape",
text=label,
icon=icon,
)
operator.source_object_name = source_name
operator.region_path_json = region_path_json
operator.shape = shape
operator.refine_selection = refine_selection
operator.normal_mode = normal_mode
operator.mesh_detail = mesh_detail
def _open_shape_pie(
context,
event,
source_object,
region_path,
refine_selection=False,
):
source_name = source_object.name
region_path_json = region_path.to_json()
settings = context.scene.anyimage_settings
point_spacing = settings.cutout_point_spacing
normal_mode = str(getattr(settings, "cutout_normal_mode", "NONE"))
mesh_detail = cutout_mesh_detail(source_object.data.size, point_spacing)
def draw(menu, _context):
pie = menu.layout.menu_pie()
_pie_button(
pie,
"SURFACE",
"Surface",
"MESH_GRID",
source_name,
region_path_json,
refine_selection,
normal_mode,
mesh_detail,
)
_pie_button(
pie,
"BALLOON",
"Balloon",
"MESH_UVSPHERE",
source_name,
region_path_json,
refine_selection,
normal_mode,
mesh_detail,
)
_pie_button(
pie,
"DEPTH_BALLOON",
"Depth Balloon",
"META_BALL",
source_name,
region_path_json,
refine_selection,
normal_mode,
mesh_detail,
)
_pie_button(
pie,
"DEPTH_SURFACE",
"Depth Surface",
"SURFACE_NCURVE",
source_name,
region_path_json,
refine_selection,
normal_mode,
mesh_detail,
)
context.window_manager.popup_menu_pie(
event,
draw,
title="Cutout Shape",
icon="MESH_DATA",
)
class SelectCutoutRegion(ImageRegionGesture, bpy.types.Operator):
bl_idname = "anyimage.select_cutout_region"
bl_label = "Select Cutout Region"
bl_description = "Lasso an Image Empty region, then choose its mesh shape"
bl_options = {"INTERNAL"}
active_tool_id = "anyimage.cutout_lasso"
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
region_path_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
refine_selection: refine_selection_property()
invert: bpy.props.BoolProperty(default=False, options={"SKIP_SAVE"})
gesture: gesture_property()
full_image: bpy.props.BoolProperty(default=False, options={"HIDDEN", "SKIP_SAVE"})
preview_fill_rule = 'NONZERO'
_pie_event = None
def _set_status(self, context):
invert = "On" if self.invert else "Off"
context.workspace.status_text_set(
f"Cutout Lasso: drag and release for shapes; "
f"F toggles Invert ({invert}); RMB or Esc cancels"
)
def invoke(self, context, event):
self._pie_event = event
if not self.full_image and event.value != "DOUBLE_CLICK":
return super().invoke(context, event)
source_object, should_edit = resolve_image_edit_click(
context, event, self.report
)
if not should_edit:
return {"FINISHED"} if source_object is not None else {"CANCELLED"}
if source_object is None:
return {"CANCELLED"}
if video_total_frames(source_object.data) > 1:
self.report({"WARNING"}, "Image tools only support a still image")
return {"CANCELLED"}
apply_region_settings(self, context)
self.invert = False
self.source_object_name = source_object.name
self.source_matrix_data = serialize_matrix(source_object.matrix_world)
width, height = (int(value) for value in source_object.data.size)
self.region_path_json = RegionPath(
points=((0, 0), (width, 0), (width, height), (0, height)),
fill_rule="NONZERO",
).to_json()
return self.execute(context)
def modal(self, context, event):
self._pie_event = event
return super().modal(context, event)
def execute(self, context):
try:
source_object = require_image_empty(self.source_object_name)
except RuntimeError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
try:
region_path = RegionPath.from_json(self.region_path_json)
_validate_cutout_region(source_object, region_path)
except (ImageEditWarning, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
_open_shape_pie(
context,
self._pie_event,
source_object,
region_path,
self.refine_selection,
)
return {"FINISHED"}
class CutoutTool(bpy.types.WorkSpaceTool):
bl_space_type = "VIEW_3D"
bl_context_mode = "OBJECT"
bl_idname = "anyimage.cutout_lasso"
bl_label = "Cutout"
bl_description = "Lasso an Image Empty and choose a mesh shape"
bl_icon = "ops.sculpt.lasso_mask"
bl_operator = SelectCutoutRegion.bl_idname
bl_keymap = (
(
SelectCutoutRegion.bl_idname,
{"type": "LEFTMOUSE", "value": "DOUBLE_CLICK"},
{"properties": [("gesture", "LASSO"), ("full_image", True)]},
),
(
SelectCutoutRegion.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("gesture", "LASSO")]},
),
)
def draw_settings(context, layout, _tool):
settings = context.scene.anyimage_settings
layout.prop(settings, "cutout_point_spacing")
layout.prop(settings, "cutout_normal_mode")
draw_region_settings(context, layout, "cutout_refine_selection")

View File

@@ -0,0 +1,21 @@
"""Cutout Shape identifiers shared by runtime and node-asset tools."""
BALLOON_ATTRIBUTE_NAME = "o_balloon"
CUTOUT_NODE_GROUP_NAME = "O Image Cutout"
CUTOUT_NODE_GROUP_NAMES = {
"SURFACE": "O Image Surface",
"BALLOON": "O Image Balloon",
"DEPTH_BALLOON": "O Image Depth Balloon",
"DEPTH_SURFACE": "O Image Depth Surface",
}
CUTOUT_SHAPE_LABELS = {
"SURFACE": "Surface",
"BALLOON": "Balloon",
"DEPTH_BALLOON": "Depth Balloon",
"DEPTH_SURFACE": "Depth Surface",
}
CUTOUT_SHAPE_INDICES = {
shape: index for index, shape in enumerate(CUTOUT_NODE_GROUP_NAMES)
}
CUTOUT_SHAPES = frozenset(CUTOUT_NODE_GROUP_NAMES)
DEPTH_CUTOUT_SHAPES = frozenset({"DEPTH_BALLOON", "DEPTH_SURFACE"})

View File

@@ -1,13 +1,12 @@
from .box import ImageBoxTool
from .edit_mask import ActivateImageTool, EditImageMask, RefineImageRegion
from .lasso import ImageLassoTool
from .line import ImageLineTool
from .edit_mask import ActivateImageTool, EditImageMask, RefineImageMask
from .polyline import ImagePolylineTool
CLASSES = (
EditImageMask,
RefineImageMask,
RefineImageRegion,
ActivateImageTool,
)

View File

@@ -21,4 +21,4 @@ class ImageBoxTool(bpy.types.WorkSpaceTool):
)
def draw_settings(context, layout, _tool):
draw_region_settings(context, layout, "image_box_refine_selection")
draw_region_settings(context, layout, "image_box_refine_region")

View File

@@ -2,35 +2,35 @@ import json
import bpy
from ...properties import BEN2_MODEL_KEY, refine_selection_property
from ...properties import BEN2_MODEL_KEY, refine_region_property
from ...runtime import JobOperatorBase
from ..common.ai_job import (
production_device,
require_environment,
require_model,
)
from ..common.image_data import (
cleanup_image_input,
create_pixel_result_image,
image_rgba,
image_pixels,
image_rgba,
prepare_region_input,
replace_empty_image,
require_image_empty,
video_total_frames,
)
from ..common.image_interaction import (
ImageRegionGesture,
gesture_property,
image_edit_poll,
report_image_edit_exception,
)
from ..common.image_interaction import ImageRegionGesture, gesture_property
from ..common.image_region import (
ImageEditWarning,
RegionPath,
crop_pixels_to_alpha,
rasterize_region_path,
)
from ..common.ai_job import (
production_device,
require_environment,
require_model,
)
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
@@ -41,9 +41,7 @@ def _refined_mask_from_pixels(mask_pixels, mask_size, bounds, source_size):
mask_width, mask_height = (int(value) for value in mask_size)
source_width, source_height = (int(value) for value in source_size)
local = np.flipud(
np.asarray(mask_pixels, dtype=np.float32).reshape(
(mask_height, mask_width, 4)
)
np.asarray(mask_pixels, dtype=np.float32).reshape((mask_height, mask_width, 4))
)[:, :, 3]
result = np.zeros((source_height, source_width), dtype=np.float32)
left, top, right, bottom = (int(value) for value in bounds)
@@ -107,6 +105,10 @@ def _edit_image_object(source_object, region_mask, invert=False):
class EditImageMask(ImageRegionGesture, bpy.types.Operator):
refine_region_settings = {
"BOX": "image_box_refine_region",
"LASSO": "image_lasso_refine_region",
}
bl_idname = "anyimage.edit_image_mask"
bl_label = "Edit Image Mask"
bl_description = "Edit and tightly crop an Image Empty with a gesture"
@@ -115,7 +117,7 @@ class EditImageMask(ImageRegionGesture, bpy.types.Operator):
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
region_path_json: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
refine_selection: refine_selection_property()
refine_region: refine_region_property()
invert: bpy.props.BoolProperty(
name="Invert",
description="Keep the opposite side of the refined gesture mask",
@@ -124,7 +126,6 @@ class EditImageMask(ImageRegionGesture, bpy.types.Operator):
)
gesture: gesture_property()
def execute(self, context):
try:
source_object = require_image_empty(self.source_object_name)
@@ -140,12 +141,12 @@ class EditImageMask(ImageRegionGesture, bpy.types.Operator):
region_path,
source_alpha=image_rgba(source_object.data)[:, :, 3],
)
if self.refine_selection:
if self.refine_region:
source_path = prepare_region_input(
source_object.data,
region_mask,
)
result = bpy.ops.anyimage.refine_image_mask(
result = bpy.ops.anyimage.refine_image_region(
input_path=str(source_path),
source_object_name=self.source_object_name,
region_bounds_json=json.dumps(region_mask.bounds),
@@ -164,10 +165,10 @@ class EditImageMask(ImageRegionGesture, bpy.types.Operator):
return {"FINISHED"}
class RefineImageMask(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.refine_image_mask"
bl_label = "Refine Image Mask"
job_type = "refine-image-mask"
class RefineImageRegion(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.refine_image_region"
bl_label = "Refine Image Region"
job_type = "refine-image-region"
starting_message = "Refining image selection"
input_path: bpy.props.StringProperty(options=_HIDDEN)
@@ -185,11 +186,10 @@ class RefineImageMask(JobOperatorBase, bpy.types.Operator):
def response(self, _context, result):
source_object = require_image_empty(self.source_object_name)
import numpy as np
source_bounds = tuple(json.loads(self.region_bounds_json))
local_bounds = tuple(
json.loads(result.file("bounds").read_text(encoding="utf-8"))
json.loads(result.file("region_bounds").read_text(encoding="utf-8"))
)
bounds = (
source_bounds[0] + local_bounds[0],
@@ -198,7 +198,7 @@ class RefineImageMask(JobOperatorBase, bpy.types.Operator):
source_bounds[1] + local_bounds[3],
)
refined_image = bpy.data.images.load(
str(result.file("image")), check_existing=False
str(result.file("region_image")), check_existing=False
)
try:
refined_pixels = image_pixels(refined_image)
@@ -217,7 +217,7 @@ class RefineImageMask(JobOperatorBase, bpy.types.Operator):
return str(error)
if "FINISHED" not in bpy.ops.ed.undo_push(message="Edit Image Mask"):
raise RuntimeError("Unable to create the Edit Image Mask Undo step")
return "Image selection refined"
return "Image Region refined"
def cleanup(self):
cleanup_image_input(self.input_path, True)

View File

@@ -21,4 +21,4 @@ class ImageLassoTool(bpy.types.WorkSpaceTool):
)
def draw_settings(context, layout, _tool):
draw_region_settings(context, layout, "image_lasso_refine_selection")
draw_region_settings(context, layout, "image_lasso_refine_region")