Files
AnyImage/src/anyimage/operators/cutout_tool/operators.py
nan 18799e8828 Refactor Cutout Tool to Use World Space Edge Length
- Replaced the Mesh Detail parameter with a new Edge Length property to define cutout sampling in physical units.
- Introduced Minimum Relative Edge Length preference to control effective spacing based on the bounding box of the cutout content.
- Updated tests to validate the new edge length functionality and ensure consistent behavior across different resolutions and canvas sizes.
- Removed deprecated parameters and adjusted related properties to align with the new edge length implementation.
- Enhanced geometry sampling logic to utilize effective world spacing for both standard and fine outline modes.
2026-09-12 23:03:24 +08:00

470 lines
17 KiB
Python

"""Convert one image Selection into a Cutout Shape."""
import json
import bpy
from ...preferences import (
addon_preferences,
configured_geometry_model,
configured_max_ai_input_size,
)
from ...properties import (
cutout_alpha_threshold_property,
cutout_fine_outline_property,
cutout_gesture_property,
cutout_edge_length_property,
)
from ...runtime import JobOperatorBase
from ...server.model_catalog import DEFAULT_GEOMETRY_MODEL_KEY
from ...server.media.resolution import (
DEFAULT_MAX_AI_INPUT_SIZE,
MAX_MAX_AI_INPUT_SIZE,
MIN_MAX_AI_INPUT_SIZE,
)
from ...common.ai import (
draw_ai_property,
draw_ai_setup,
invoke_ai_setup_if_needed,
moge2_parameters,
production_device,
require_environment,
require_input_path,
require_model,
)
from ...common.image import (
image_rgba,
load_normal_result_image,
require_image_empty,
is_animated_image,
)
from ...common.depth import load_depth_metadata, load_depth_result_image
from ...common.color_image import (
cleanup_material_color_input,
load_material_color_image,
material_analysis_input,
material_color_image,
prepare_material_color_input,
)
from ...common.viewport import (
ImageGesture,
image_edit_point_keymap,
report_image_edit_exception,
)
from ...common.selection import (
ImageEditWarning,
SelectionPath,
rasterize_selection_path,
)
from .geometry import (
CUTOUT_ALPHA_THRESHOLD,
build_base_shape,
effective_cutout_edge_length,
)
from .interaction import open_shape_pie
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 shape == "DEPTH_SOLID":
return NormalMode.OBJECT
if shape == "DEPTH_BALLOON":
return NormalMode.TANGENT
if not generate_normal:
return NormalMode.NONE
return NormalMode.TANGENT
def shape_uses_depth(shape):
return shape in {"DEPTH_SOLID", "DEPTH_BALLOON"}
def create_cutout_shape(
context,
source_object,
shape,
edge_length,
content_values,
content_bounds,
*,
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
fine_outline=False,
depth_image=None,
depth_metadata=None,
normal_image=None,
normal_mode=NormalMode.NONE,
color_image,
gesture="LASSO",
report=None,
):
effective_length = effective_cutout_edge_length(
context, source_object, edge_length, content_values, content_bounds, alpha_threshold
)
if report is not None and effective_length > edge_length * (1 + 1e-6):
units = context.scene.unit_settings
label = bpy.utils.units.to_string(
units.system if units.system != "NONE" else "METRIC", "LENGTH",
effective_length * units.scale_length, precision=4,
)
report({"INFO"}, f"Effective Edge Length: {label}")
base_shape = build_base_shape(
context,
source_object,
effective_length,
content_values,
content_bounds,
alpha_threshold=alpha_threshold,
fine_outline=fine_outline,
)
result = create_shape_object(
context,
source_object,
shape,
effective_length,
content_values,
content_bounds,
base_shape,
alpha_threshold=alpha_threshold,
depth_image=depth_image,
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
color_image=color_image,
gesture=gesture,
)
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 _cutout_content_values(
source_alpha, selection_values=None, *, alpha_threshold=CUTOUT_ALPHA_THRESHOLD
):
"""Combine local image Alpha with optional pure Selection geometry."""
import numpy as np
alpha = np.asarray(source_alpha, dtype=np.float32)
if alpha.ndim != 2:
raise ValueError("The Cutout source Alpha must be two-dimensional")
if selection_values is None:
values = alpha.copy()
else:
selection = np.asarray(selection_values, dtype=np.float32)
if alpha.shape != selection.shape:
raise ValueError("The Cutout source Alpha does not match the Selection")
values = alpha * selection
if not np.any((values > 0.0) & (values >= alpha_threshold)):
raise ImageEditWarning(
"The Cutout selection contains no visible image pixels"
)
return values
def _generated_color_result(input_path, source_bounds):
image = load_material_color_image(input_path)
try:
alpha = image_rgba(image)[:, :, 3]
except Exception:
if image.users == 0:
bpy.data.images.remove(image, do_unlink=True)
raise
return image, alpha, source_bounds
def cutout_geometry_settings(context):
preferences = addon_preferences(context)
model = configured_geometry_model(context)
return (
model,
int(getattr(preferences, "geometry_resolution_level", 5)),
)
class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
bl_idname = "anyimage.cutout_selection_to_shape"
bl_label = "Cutout Selection to Shape"
bl_description = "Build the image selection as a Cutout shape"
bl_options = {"INTERNAL"}
job_type = "generate-cutout-artifacts"
starting_message = "Generating Cutout artifacts"
input_path: bpy.props.StringProperty(options=_HIDDEN)
color_path: bpy.props.StringProperty(options=_HIDDEN)
source_object_name: bpy.props.StringProperty(options=_HIDDEN)
selection_path_json: bpy.props.StringProperty(options=_HIDDEN)
selection_bounds_json: bpy.props.StringProperty(options=_HIDDEN)
shape: bpy.props.StringProperty(default="SOLID", options=_HIDDEN)
gesture: bpy.props.StringProperty(default="LASSO", options=_HIDDEN)
edge_length: cutout_edge_length_property(options=_HIDDEN)
model: bpy.props.StringProperty(
default=DEFAULT_GEOMETRY_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)
alpha_threshold: cutout_alpha_threshold_property()
fine_outline: cutout_fine_outline_property()
def _needs_generation(self):
return 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 is_animated_image(source_object.data):
raise ValueError("Cutout currently supports still images")
if self.shape not in CUTOUT_SHAPES:
raise ValueError(f"Unsupported Cutout shape: {self.shape}")
selection_path = SelectionPath.from_json(self.selection_path_json)
selection_mask = rasterize_selection_path(
tuple(source_object.data.size),
selection_path,
antialias=True,
)
source_rgba = image_rgba(source_object.data)
context.view_layer.objects.active = source_object
source_object.select_set(True)
self.max_ai_input_size = configured_max_ai_input_size(context)
if not self._needs_generation():
left, top, right, bottom = selection_mask.bounds
content_values = _cutout_content_values(
source_rgba[top:bottom, left:right, 3],
selection_mask.values,
alpha_threshold=self.alpha_threshold,
)
with material_color_image(source_object.data, bounds=selection_mask.bounds, rgba=source_rgba) as color_image:
create_cutout_shape(
context, source_object, self.shape, self.edge_length,
content_values, selection_mask.bounds,
alpha_threshold=self.alpha_threshold,
fine_outline=self.fine_outline, color_image=color_image,
gesture=self.gesture,
report=self.report,
)
return {"FINISHED"}
setup_result = invoke_ai_setup_if_needed(self)
if setup_result is not None:
return setup_result
self.model, self.resolution_level = cutout_geometry_settings(context)
self.selection_bounds_json = json.dumps(
selection_mask.bounds,
separators=(",", ":"),
)
self._selection_mask = selection_mask
self.color_path = str(prepare_material_color_input(
source_object.data, selection_mask.bounds, source_rgba,
))
self.input_path = str(material_analysis_input(self.color_path))
except (
ImageEditWarning,
OSError,
RuntimeError,
TypeError,
ValueError,
) as error:
cleanup_material_color_input(getattr(self, "color_path", None))
report_image_edit_exception(self, error)
return {"CANCELLED"}
result = super().execute(context)
if "CANCELLED" in result:
cleanup_material_color_input(self.color_path)
return result
def request(self, context):
require_environment()
source_path = require_input_path(self.input_path)
generate_depth = shape_uses_depth(self.shape)
normal_mode = normal_mode_for_shape(self.shape, self.generate_normal)
if generate_depth or normal_mode != NormalMode.NONE:
require_model(self.model)
parameters = {
"input": str(source_path),
"device": production_device().lower(),
"generate_depth": generate_depth,
"normal_mode": normal_mode.value,
"max_input_size": int(self.max_ai_input_size),
"alpha_threshold": float(self.alpha_threshold),
}
if generate_depth or normal_mode != NormalMode.NONE:
parameters.update(
model=self.model,
resolution_level=self.resolution_level,
)
parameters.update(
moge2_parameters(
source_path,
self.model,
self.resolution_level,
)
)
return parameters
def response(self, context, result):
source_object = require_image_empty(self.source_object_name)
source_bounds = tuple(json.loads(self.selection_bounds_json))
color_image, source_alpha, color_bounds = _generated_color_result(
self.color_path,
source_bounds,
)
value = result.value if isinstance(result.value, dict) else {}
normal_image = None
depth_image = None
try:
selection_mask = getattr(self, "_selection_mask", None)
if selection_mask is None:
raise RuntimeError("The Cutout Selection is no longer available")
content_values = _cutout_content_values(
source_alpha,
selection_mask.values,
alpha_threshold=self.alpha_threshold,
)
content_bounds = color_bounds
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
if shape_uses_depth(self.shape):
depth_metadata = load_depth_metadata(result.file("depth_metadata"))
depth_image = load_depth_result_image(
result.file("depth"),
source_object,
depth_metadata,
)
create_cutout_shape(
context,
source_object,
self.shape,
self.edge_length,
content_values,
content_bounds,
depth_image=depth_image,
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
alpha_threshold=self.alpha_threshold,
fine_outline=self.fine_outline,
color_image=color_image,
gesture=self.gesture,
report=self.report,
)
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)
if color_image.users == 0:
bpy.data.images.remove(color_image, do_unlink=True)
raise
return f"{self.shape.replace('_', ' ').title()} is ready"
def cleanup(self):
cleanup_material_color_input(self.color_path)
class SelectCutoutSelection(ImageGesture, bpy.types.Operator):
bl_idname = "anyimage.select_cutout_selection"
bl_label = "Start Cutout Selection"
bl_description = "Select an Image Empty area with a lasso or polyline, then choose its mesh shape"
bl_options = {"INTERNAL"}
active_tool_id = "anyimage.cutout_lasso"
resolve_on_press = True
gesture: cutout_gesture_property()
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
_pie_event = None
_polyline_confirm_type = None
def _read_gesture_settings(self, context):
self.gesture = context.scene.anyimage_settings.cutout_gesture
def _set_status(self, context):
action = (
"click points; click start, double-click, or Enter for shapes; Backspace removes"
if self.gesture == "POLYLINE" else "drag and release for shapes"
)
context.workspace.status_text_set(
f"Cutout {self.gesture.title()}: {action}; RMB or Esc cancels"
)
def invoke(self, context, event):
self._polyline_confirm_type = None
self._pie_event = event
return super().invoke(context, event)
def modal(self, context, event):
self._pie_event = event
return ImageGesture.modal(self, context, event)
def _confirm_polyline(self, context):
if len(self._path) >= 3:
self._polyline_confirm_type = self._pie_event.type
return {"RUNNING_MODAL"}
def _modal_polyline(self, context, event):
if self._polyline_confirm_type is not None:
if event.type == self._polyline_confirm_type and event.value == "RELEASE":
self._polyline_confirm_type = None
return ImageGesture._confirm_polyline(self, context)
return {"RUNNING_MODAL"}
return ImageGesture._modal_polyline(self, context, event)
def _submit_selection_path(self, context, selection_path):
try:
source_object = require_image_empty(self.source_object_name)
except RuntimeError as error:
self.report({"ERROR"}, str(error))
return {"CANCELLED"}
open_shape_pie(
context,
self._pie_event,
source_object,
selection_path,
gesture=self.gesture,
)
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 = "Select an Image Empty area with a lasso or polyline and choose a mesh shape"
bl_icon = "ops.mesh.primitive_sphere_add_gizmo"
bl_operator = SelectCutoutSelection.bl_idname
bl_keymap = image_edit_point_keymap(
SelectCutoutSelection.bl_idname,
)
def draw_settings(context, layout, _tool):
settings = context.scene.anyimage_settings
layout.prop(settings, "cutout_gesture")
layout.prop(settings, "cutout_edge_length")
layout.prop(settings, "cutout_fine_outline")
layout.prop(settings, "cutout_alpha_threshold")
status = draw_ai_setup(layout)
draw_ai_property(layout, settings, "cutout_generate_normal", status)