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.
This commit is contained in:
2026-09-12 23:03:24 +08:00
parent 938d95db0f
commit 18799e8828
28 changed files with 505 additions and 279 deletions

Binary file not shown.

View File

@@ -78,7 +78,7 @@ def support_flat_regions(points, faces):
return np.asarray(vertices), np.asarray(triangles, dtype=np.int32)
def build_fine_mesh(alpha, spacing, threshold):
def build_fine_mesh(alpha, spacing, threshold, *, pixel_transform=None):
"""Return pixel-space triangles and component ranges for height solving."""
alpha = np.asarray(alpha, dtype=float)
if alpha.ndim != 2 or not alpha.size or not np.isfinite(alpha).all():
@@ -92,18 +92,24 @@ def build_fine_mesh(alpha, spacing, threshold):
reference = g.extract_selection_contours(
filtered, max_resolution=max(mask.shape), threshold=threshold
)
width_sizes = WidthSamples(mask)
# Normalize geometric tolerances and support sampling to the target length.
transform = (
np.eye(2) if pixel_transform is None else np.asarray(pixel_transform)
) * (32 / spacing)
inverse = np.linalg.inv(transform)
width_sizes = WidthSamples(mask, transform=transform)
spacing = 32.0
points, faces, components = [], [], []
offset = 0
for component in reference.components:
contours = contours_for_component(component, spacing)
contours = contours_for_component([np.asarray(c) @ transform for c in component], spacing)
spine, path_edges = interior_paths(
contours, min(32, max(mask.shape) / 6), width_sizes
contours, spacing, width_sizes
)
contours, spine, path_edges = subdivide_constraints(
contours, spine, path_edges, spacing
)
free = relax_points(contours, spine, mask.shape, spacing)
free = relax_points(contours, spine, spacing)
vertices, triangles, constraints = triangulate(
contours, spine, path_edges, free, spacing
)
@@ -117,9 +123,9 @@ def build_fine_mesh(alpha, spacing, threshold):
raise ValueError(
"Fine Outline placed an interior constraint on the boundary"
)
points.append(vertices)
points.append(vertices @ inverse)
faces.append(triangles + offset)
components.append((offset, offset + len(vertices), triangles, contours[0]))
components.append((offset, offset + len(vertices), triangles, contours[0] @ inverse))
offset += len(vertices)
points, faces = np.concatenate(points), np.concatenate(faces)
return points, faces, g.boundary_vertices_from_faces(len(points), faces), components
@@ -181,7 +187,10 @@ def width_ridge_samples(image):
class WidthSamples:
"""Estimate local width from indexed distance-ridge disks."""
def __init__(self, mask):
def __init__(self, mask, transform=None):
transform = np.eye(2) if transform is None else transform
self.inverse = np.linalg.inv(transform)
self.size_scale = np.linalg.svd(transform, compute_uv=False).min()
ridge, distance = width_ridge_samples(np.pad(mask, 1))
ridge = ridge[1:-1, 1:-1]
self.distance = np.maximum(distance[1:-1, 1:-1] - 0.5, 0.3)
@@ -198,6 +207,7 @@ class WidthSamples:
)
def __call__(self, points):
points = points @ self.inverse
radius = np.full(len(points), 0.5)
pending = np.arange(len(points))
for ids, tree, reach in self.groups:
@@ -226,7 +236,7 @@ class WidthSamples:
mode="nearest",
)
return np.maximum(
0.7, np.minimum(32, np.minimum(16, radius * 0.9) + 0.4 * distance)
0.7, np.minimum(32, np.minimum(16, radius * self.size_scale * 0.9) + 0.4 * distance * self.size_scale)
)
@@ -486,7 +496,7 @@ def interior_paths(contours, spacing, width_sizes):
return (np.asarray(points).reshape(-1, 2), edges)
def relax_points(contours, spine, shape, spacing):
def relax_points(contours, spine, spacing):
free = g.uniform_interior_points_for_contours(contours, spacing)
if len(free) and len(spine):
free = free[cKDTree(spine).query(free)[0] > spacing * 0.55]
@@ -495,8 +505,8 @@ def relax_points(contours, spine, shape, spacing):
points = np.concatenate(contours)
minimum = points.min(axis=0)
maximum = points.max(axis=0)
xx = np.arange(phase, shape[1], step) + 0.5
yy = np.arange(phase, shape[0], step) + 0.5
xx = np.arange(minimum[0] + phase, maximum[0], step) + 0.5
yy = np.arange(minimum[1] + phase, maximum[1], step) + 0.5
xx = xx[(xx >= minimum[0]) & (xx <= maximum[0])]
yy = yy[(yy >= minimum[1]) & (yy <= maximum[1])]
xx, yy = np.meshgrid(xx, yy)

View File

@@ -3,7 +3,7 @@
import math
from dataclasses import dataclass
from ...preferences import addon_preferences
from ...preferences import addon_preferences, configured_min_cutout_relative_edge_length
from ...common.image import image_empty_bounds
from . import balloon
@@ -824,11 +824,9 @@ def crop_plane_bounds(source_object, crop_bounds, source_size):
def extract_cutout_contours(
content_values,
point_spacing,
sampling_resolution,
threshold=CUTOUT_ALPHA_THRESHOLD,
):
height, width = content_values.shape
sampling_resolution = max(width, height) / float(point_spacing)
contour_resolution = max(512, min(round(sampling_resolution * 8), 1024))
return extract_selection_contours(
content_values,
@@ -837,28 +835,28 @@ def extract_cutout_contours(
)
def limit_cutout_point_spacing(point_spacing, crop_size, max_mesh_resolution):
crop_width, crop_height = (int(value) for value in crop_size)
spacing = float(point_spacing)
if crop_width <= 0 or crop_height <= 0:
raise ValueError("The Cutout bounds must be positive")
if spacing <= 0.0:
raise ValueError("Cutout point spacing must be greater than zero")
maximum = int(max_mesh_resolution)
if maximum <= 0:
raise ValueError("Maximum Cutout mesh resolution must be positive")
return max(spacing, max(crop_width, crop_height) / maximum)
def effective_cutout_edge_length(
context, source_object, edge_length, content_values, content_bounds,
threshold=CUTOUT_ALPHA_THRESHOLD,
):
"""Resolve target spacing in Blender world units from visible content bounds."""
import numpy as np
def cutout_sampling_spacing(source_object, point_spacing):
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))
image_width, image_height = (int(value) for value in source_object.data.size)
return max(width / image_width, height / image_height) * float(point_spacing)
if not math.isfinite(edge_length) or edge_length <= 0:
raise ValueError("Cutout edge length must be greater than zero")
values = np.asarray(content_values)
visible = (values > 0) & (values >= threshold)
rows = np.flatnonzero(visible.any(axis=1))
columns = np.flatnonzero(visible.any(axis=0))
if not len(rows):
raise ValueError("The Cutout selection contains no visible image pixels")
bounds = crop_plane_bounds(source_object, content_bounds, tuple(source_object.data.size))
metric, _ = world_plane_metric(source_object.matrix_world)
metric_transform(metric)
width = (bounds[1] - bounds[0]) * (columns.max() + 1 - columns.min()) / values.shape[1]
height = (bounds[3] - bounds[2]) * (rows.max() + 1 - rows.min()) / values.shape[0]
longest = max(width * math.sqrt(metric[0][0]), height * math.sqrt(metric[1][1]))
return max(float(edge_length), longest * configured_min_cutout_relative_edge_length(context))
def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD):
@@ -885,7 +883,7 @@ def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_T
def build_base_shape(
context,
source_object,
point_spacing,
edge_length,
content_values,
content_bounds,
*,
@@ -899,31 +897,38 @@ def build_base_shape(
content_bounds,
tuple(source_object.data.size),
)
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")
pixel_transform = np.diag((
(bounds[1] - bounds[0]) / content_values.shape[1],
-(bounds[3] - bounds[2]) / content_values.shape[0],
)) @ metric_transform(world_metric)
longest = max(
np.linalg.norm(pixel_transform[0]) * content_values.shape[1],
np.linalg.norm(pixel_transform[1]) * content_values.shape[0],
)
fine_mesh = None
selection_contours = None
if fine_outline:
from .fine_mesh import build_fine_mesh
fine_mesh = (
*build_fine_mesh(content_values, point_spacing, alpha_threshold),
*build_fine_mesh(content_values, edge_length, alpha_threshold, pixel_transform=pixel_transform),
(content_values.shape[1], content_values.shape[0]),
)
else:
selection_contours = extract_cutout_contours(
content_values,
point_spacing,
longest / edge_length,
threshold=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_sampling_spacing(source_object, point_spacing)
domain = cutout_domain(
selection_contours,
bounds,
point_spacing,
edge_length,
world_metric,
balloon_algorithm,
fine_mesh=fine_mesh,

View File

@@ -10,17 +10,6 @@ CUTOUT_SHAPE_BUTTONS = (
("DEPTH_SOLID", "SURFACE_NCURVE"),
)
CUTOUT_MESH_DETAIL_SPACING = {
"LOW": 32,
"MEDIUM": 16,
"HIGH": 8,
"ULTRA": 4,
}
def cutout_mesh_detail_spacing(mesh_detail):
return CUTOUT_MESH_DETAIL_SPACING[str(mesh_detail)]
def _pie_button(
pie,
@@ -30,7 +19,7 @@ def _pie_button(
source_name,
selection_path_json,
generate_normal,
point_spacing,
edge_length,
alpha_threshold,
fine_outline,
gesture,
@@ -44,7 +33,7 @@ def _pie_button(
operator.selection_path_json = selection_path_json
operator.shape = shape
operator.generate_normal = generate_normal
operator.point_spacing = point_spacing
operator.edge_length = edge_length
operator.alpha_threshold = alpha_threshold
operator.fine_outline = fine_outline
operator.gesture = gesture
@@ -61,7 +50,7 @@ def open_shape_pie(
source_name = source_object.name
selection_path_json = selection_path.to_json()
settings = context.scene.anyimage_settings
point_spacing = cutout_mesh_detail_spacing(settings.cutout_mesh_detail)
edge_length = settings.cutout_edge_length
ready = ai_ready()
generate_normal = ready and bool(
getattr(settings, "cutout_generate_normal", False)
@@ -81,7 +70,7 @@ def open_shape_pie(
source_name,
selection_path_json,
generate_normal,
point_spacing,
edge_length,
alpha_threshold,
fine_outline,
gesture,

View File

@@ -26,7 +26,6 @@ from .geometry import (
CUTOUT_ALPHA_THRESHOLD,
crop_plane_bounds,
cutout_cleanup_value,
cutout_sampling_spacing,
selection_calibration,
world_plane_metric,
)
@@ -98,7 +97,7 @@ def create_shape_object(
context,
source_object,
shape,
point_spacing,
edge_length,
content_values,
content_bounds,
base_shape,
@@ -125,7 +124,7 @@ def create_shape_object(
)
world_metric, _world_z_scale = world_plane_metric(source_object.matrix_world)
cleanup_value = cutout_cleanup_value(
cutout_sampling_spacing(source_object, point_spacing),
edge_length,
world_metric,
)
material = None

View File

@@ -8,12 +8,12 @@ from ...preferences import (
addon_preferences,
configured_geometry_model,
configured_max_ai_input_size,
configured_max_cutout_mesh_resolution,
)
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
@@ -59,7 +59,7 @@ from ...common.selection import (
from .geometry import (
CUTOUT_ALPHA_THRESHOLD,
build_base_shape,
limit_cutout_point_spacing,
effective_cutout_edge_length,
)
from .interaction import open_shape_pie
from .object import (
@@ -89,7 +89,7 @@ def create_cutout_shape(
context,
source_object,
shape,
point_spacing,
edge_length,
content_values,
content_bounds,
*,
@@ -101,11 +101,22 @@ def create_cutout_shape(
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,
point_spacing,
effective_length,
content_values,
content_bounds,
alpha_threshold=alpha_threshold,
@@ -115,7 +126,7 @@ def create_cutout_shape(
context,
source_object,
shape,
point_spacing,
effective_length,
content_values,
content_bounds,
base_shape,
@@ -191,11 +202,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
selection_bounds_json: bpy.props.StringProperty(options=_HIDDEN)
shape: bpy.props.StringProperty(default="SOLID", options=_HIDDEN)
gesture: bpy.props.StringProperty(default="LASSO", options=_HIDDEN)
point_spacing: bpy.props.FloatProperty(
default=32.0,
min=0.01,
options=_HIDDEN,
)
edge_length: cutout_edge_length_property(options=_HIDDEN)
model: bpy.props.StringProperty(
default=DEFAULT_GEOMETRY_MODEL_KEY,
options=_HIDDEN,
@@ -234,13 +241,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
source_rgba = image_rgba(source_object.data)
context.view_layer.objects.active = source_object
source_object.select_set(True)
crop_width = selection_mask.bounds[2] - selection_mask.bounds[0]
crop_height = selection_mask.bounds[3] - selection_mask.bounds[1]
self.point_spacing = limit_cutout_point_spacing(
self.point_spacing,
(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():
left, top, right, bottom = selection_mask.bounds
@@ -251,11 +251,12 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
)
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.point_spacing,
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)
@@ -354,7 +355,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
context,
source_object,
self.shape,
self.point_spacing,
self.edge_length,
content_values,
content_bounds,
depth_image=depth_image,
@@ -365,6 +366,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
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:
@@ -460,7 +462,7 @@ class CutoutTool(bpy.types.WorkSpaceTool):
def draw_settings(context, layout, _tool):
settings = context.scene.anyimage_settings
layout.prop(settings, "cutout_gesture")
layout.prop(settings, "cutout_mesh_detail")
layout.prop(settings, "cutout_edge_length")
layout.prop(settings, "cutout_fine_outline")
layout.prop(settings, "cutout_alpha_threshold")
status = draw_ai_setup(layout)

View File

@@ -16,9 +16,6 @@ from .server.model_catalog import (
UPSCALE_MODELS,
)
DEFAULT_MAX_CUTOUT_MESH_RESOLUTION = 1024
MIN_CUTOUT_MESH_RESOLUTION = 4
MAX_CUTOUT_MESH_RESOLUTION = 1024
DEFAULT_MAX_FRAME_RESOLUTION = 2048
MIN_FRAME_RESOLUTION = 64
MAX_FRAME_RESOLUTION = 8192
@@ -87,19 +84,9 @@ def configured_material_view_adaptation(context=None):
)
def configured_max_cutout_mesh_resolution(context=None):
preferences = addon_preferences(context)
value = int(
getattr(
preferences,
"max_cutout_mesh_resolution",
DEFAULT_MAX_CUTOUT_MESH_RESOLUTION,
)
)
return min(
max(value, MIN_CUTOUT_MESH_RESOLUTION),
MAX_CUTOUT_MESH_RESOLUTION,
)
def configured_min_cutout_relative_edge_length(context=None):
percent = float(getattr(addon_preferences(context), "min_cutout_relative_edge_length", 1.0))
return min(max(percent, 0.001), 100.0) / 100.0
def configured_max_frame_resolution(context=None):
@@ -177,17 +164,13 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
step=256,
subtype="PIXEL",
)
max_cutout_mesh_resolution: bpy.props.IntProperty(
name="Maximum Cutout Mesh Resolution",
description=(
"Limit the longest image edge used to sample the Cutout mesh. "
"Higher values allow finer outlines and denser meshes."
),
default=DEFAULT_MAX_CUTOUT_MESH_RESOLUTION,
min=MIN_CUTOUT_MESH_RESOLUTION,
max=MAX_CUTOUT_MESH_RESOLUTION,
step=64,
subtype="PIXEL",
min_cutout_relative_edge_length: bpy.props.FloatProperty(
name="Minimum Relative Edge Length",
description="Minimum target edge length as a percentage of the Cutout bounds' longest side",
default=1.0,
min=0.001,
max=100.0,
subtype="PERCENTAGE",
)
max_frame_resolution: bpy.props.IntProperty(
name="Maximum Frame Resolution",
@@ -292,7 +275,7 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
tools.prop(self, "geometry_resolution_level")
tools.separator(type="LINE")
tools.label(text="Cutout Tool")
tools.prop(self, "max_cutout_mesh_resolution")
tools.prop(self, "min_cutout_relative_edge_length")
tools.prop(self, "balloon_profile_algorithm")
@staticmethod

View File

@@ -242,24 +242,39 @@ def cutout_fine_outline_property():
)
def cutout_mesh_detail_property():
return bpy.props.EnumProperty(
name="Mesh Detail",
description="Control mesh density. Higher levels use more vertices to represent finer shapes.",
items=(
("LOW", "Low", "32 source image pixels between neighboring mesh points"),
("MEDIUM", "Medium", "16 source image pixels between neighboring mesh points"),
("HIGH", "High", "8 source image pixels between neighboring mesh points"),
("ULTRA", "Ultra", "4 source image pixels between neighboring mesh points"),
),
default="LOW",
def _cutout_length_scale(owner):
scene = getattr(owner, "id_data", None)
if not isinstance(scene, bpy.types.Scene):
scene = bpy.context.scene
return scene.unit_settings.scale_length
def _get_cutout_edge_length(owner):
return owner.get("cutout_edge_length_meters", 0.1) / _cutout_length_scale(owner)
def _set_cutout_edge_length(owner, value):
owner["cutout_edge_length_meters"] = value * _cutout_length_scale(owner)
def cutout_edge_length_property(*, store_meters=False, **options):
if store_meters:
options.update(get=_get_cutout_edge_length, set=_set_cutout_edge_length)
return bpy.props.FloatProperty(
name="Edge Length",
description="Target mesh edge length in world space",
default=0.1,
min=1e-6,
unit="LENGTH",
subtype="DISTANCE",
**options,
)
class AnyImageSettings(bpy.types.PropertyGroup):
cutout_gesture: cutout_gesture_property()
cutout_alpha_threshold: cutout_alpha_threshold_property()
cutout_mesh_detail: cutout_mesh_detail_property()
cutout_edge_length: cutout_edge_length_property(store_meters=True)
cutout_fine_outline: cutout_fine_outline_property()
cutout_generate_normal: cutout_generate_normal_property()
mask_gesture: mask_gesture_property()