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:
80
src/anyimage/operators/common/depth_data.py
Normal file
80
src/anyimage/operators/common/depth_data.py
Normal 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))
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ()
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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")
|
||||
@@ -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"}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
125
src/anyimage/operators/convert_to_plane/object.py
Normal file
125
src/anyimage/operators/convert_to_plane/object.py
Normal 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")
|
||||
@@ -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
|
||||
@@ -1,5 +1,4 @@
|
||||
from .region_to_shape import CutoutRegionToShape
|
||||
from .select_region import CutoutTool, SelectCutoutRegion
|
||||
from .main import CutoutRegionToShape, CutoutTool, SelectCutoutRegion
|
||||
|
||||
__all__ = (
|
||||
"CutoutRegionToShape",
|
||||
|
||||
1429
src/anyimage/operators/cutout_tool/geometry.py
Normal file
1429
src/anyimage/operators/cutout_tool/geometry.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1 +0,0 @@
|
||||
"""Cutout 的内存几何算法。"""
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
|
||||
126
src/anyimage/operators/cutout_tool/interaction.py
Normal file
126
src/anyimage/operators/cutout_tool/interaction.py
Normal 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",
|
||||
)
|
||||
455
src/anyimage/operators/cutout_tool/main.py
Normal file
455
src/anyimage/operators/cutout_tool/main.py
Normal 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")
|
||||
292
src/anyimage/operators/cutout_tool/object.py
Normal file
292
src/anyimage/operators/cutout_tool/object.py
Normal 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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
21
src/anyimage/operators/cutout_tool/shape.py
Normal file
21
src/anyimage/operators/cutout_tool/shape.py
Normal 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"})
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user