Refactor Cutout and Image Editing Logic

- Renamed "Base Plane Depth" to "Reference Depth" across validate_cutout.py for consistency.
- Introduced a new specification for Cutout mesh sampling to ensure source pixel spacing is preserved and limits are applied correctly.
- Unified naming conventions for image editing results to maintain consistency across Remove Background, Upscale, and Crop operations.
- Updated the handling of Refine Selection to ensure it retains the original bounds and does not apply unnecessary cropping.
- Enhanced material generation logic to connect image color directly to IOR Level, improving the rendering of dark areas.
- Established clear requirements for geometry depth controls and image result materials to ensure consistent behavior across different components.
- Removed outdated logic and ensured all changes are documented and tested for regression.
This commit is contained in:
2026-09-03 01:02:10 +08:00
parent a6041cbe03
commit bdbde1a10d
47 changed files with 1120 additions and 257 deletions

View File

@@ -4,7 +4,6 @@ import math
from dataclasses import dataclass
from ...preferences import addon_preferences
from ...server.media.resolution import limited_image_size
from ...common.image import image_empty_bounds
@@ -1342,10 +1341,12 @@ def crop_plane_bounds(source_object, crop_bounds, source_size):
def extract_cutout_contours(
content_values,
mesh_resolution,
point_spacing,
threshold=CUTOUT_ALPHA_THRESHOLD,
):
contour_resolution = max(512, min(int(mesh_resolution) * 8, 1024))
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,
max_resolution=contour_resolution,
@@ -1353,24 +1354,28 @@ def extract_cutout_contours(
)
def limit_cutout_mesh_resolution(mesh_resolution, crop_size, max_input_size):
def limit_cutout_point_spacing(point_spacing, crop_size, max_mesh_resolution):
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_resolution) * scale)))
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 cutout_sampling_spacing(source_object, mesh_resolution):
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))
return max(width, height) / max(int(mesh_resolution), 8)
image_width, image_height = (int(value) for value in source_object.data.size)
return max(width / image_width, height / image_height) * float(point_spacing)
def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD):
@@ -1397,7 +1402,7 @@ def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_T
def build_base_shape(
context,
source_object,
mesh_resolution,
point_spacing,
content_values,
content_bounds,
):
@@ -1410,7 +1415,7 @@ def build_base_shape(
)
selection_contours = extract_cutout_contours(
content_values,
mesh_resolution,
point_spacing,
threshold=CUTOUT_ALPHA_THRESHOLD,
)
world_metric, world_z_scale = world_plane_metric(source_object.matrix_world)
@@ -1418,7 +1423,7 @@ def build_base_shape(
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, mesh_resolution)
point_spacing = cutout_sampling_spacing(source_object, point_spacing)
domain = cutout_domain(
selection_contours,
bounds,

View File

@@ -1,9 +1,5 @@
"""Cutout Selection validation and Shape-menu interaction helpers."""
from ...preferences import (
MAX_CUTOUT_MESH_RESOLUTION,
MIN_CUTOUT_MESH_RESOLUTION,
)
from ...properties import ai_ready
from .shape import CUTOUT_SHAPE_LABELS
@@ -22,15 +18,6 @@ CUTOUT_MESH_DETAIL_SPACING = {
}
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 cutout_mesh_detail_spacing(mesh_detail):
return CUTOUT_MESH_DETAIL_SPACING[str(mesh_detail)]
@@ -44,7 +31,7 @@ def _pie_button(
selection_path_json,
refine_selection,
generate_normal,
mesh_resolution,
point_spacing,
inward_axis,
):
operator = pie.operator(
@@ -57,7 +44,7 @@ def _pie_button(
operator.shape = shape
operator.refine_selection = refine_selection
operator.generate_normal = generate_normal
operator.mesh_resolution = mesh_resolution
operator.point_spacing = point_spacing
operator.inward_axis = inward_axis
@@ -77,7 +64,6 @@ def open_shape_pie(
generate_normal = ready and bool(
getattr(settings, "cutout_generate_normal", False)
)
mesh_resolution = cutout_mesh_resolution(source_object.data.size, point_spacing)
inward_axis = str(getattr(settings, "cutout_inward_axis", "NEGATIVE_Z"))
def draw(menu, _context):
@@ -93,7 +79,7 @@ def open_shape_pie(
selection_path_json,
refine_selection,
generate_normal,
mesh_resolution,
point_spacing,
inward_axis,
)

View File

@@ -5,8 +5,6 @@ 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,
@@ -60,7 +58,7 @@ from ...common.selection import (
from .geometry import (
CUTOUT_ALPHA_THRESHOLD,
build_base_shape,
limit_cutout_mesh_resolution,
limit_cutout_point_spacing,
)
from .interaction import open_shape_pie
from .object import (
@@ -95,7 +93,7 @@ def create_cutout_shape(
context,
source_object,
shape,
mesh_resolution,
point_spacing,
content_values,
content_bounds,
*,
@@ -110,7 +108,7 @@ def create_cutout_shape(
base_shape = build_base_shape(
context,
source_object,
mesh_resolution,
point_spacing,
content_values,
content_bounds,
)
@@ -118,7 +116,7 @@ def create_cutout_shape(
context,
source_object,
shape,
mesh_resolution,
point_spacing,
content_values,
content_bounds,
base_shape,
@@ -164,21 +162,8 @@ def _generated_color_result(result, input_path, source_object, source_bounds):
value = result.value if isinstance(result.value, dict) else {}
if "selection_image" not in value:
path = input_path
bounds = source_bounds
else:
path = result.file("selection_image")
local_bounds = tuple(
int(value)
for value in json.loads(
result.file("selection_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],
)
image = load_packed_rgba_image(
path,
source_object.data,
@@ -190,7 +175,7 @@ def _generated_color_result(result, input_path, source_object, source_bounds):
if image.users == 0:
bpy.data.images.remove(image, do_unlink=True)
raise
return image, alpha, bounds
return image, alpha, source_bounds
def cutout_geometry_settings(context):
@@ -215,10 +200,9 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
selection_path_json: bpy.props.StringProperty(options=_HIDDEN)
selection_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,
point_spacing: bpy.props.FloatProperty(
default=32.0,
min=0.01,
options=_HIDDEN,
)
model: bpy.props.StringProperty(
@@ -274,8 +258,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
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.mesh_resolution = limit_cutout_mesh_resolution(
self.mesh_resolution,
self.point_spacing = limit_cutout_point_spacing(
self.point_spacing,
(crop_width, crop_height),
configured_max_cutout_mesh_resolution(context),
)
@@ -295,7 +279,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
context,
source_object,
self.shape,
self.mesh_resolution,
self.point_spacing,
content_values,
selection_mask.bounds,
inward_axis=self.inward_axis,
@@ -387,20 +371,14 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
depth_image = None
shape_started = False
try:
if "selection_image" in value:
content_values = _cutout_content_values(
source_alpha,
)
content_bounds = color_bounds
else:
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,
)
content_bounds = selection_mask.bounds
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,
)
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:
@@ -424,7 +402,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
context,
source_object,
self.shape,
self.mesh_resolution,
self.point_spacing,
content_values,
content_bounds,
depth_image=depth_image,

View File

@@ -156,7 +156,7 @@ def create_shape_object(
context,
source_object,
shape,
mesh_resolution,
point_spacing,
content_values,
content_bounds,
base_shape,
@@ -183,7 +183,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, mesh_resolution),
cutout_sampling_spacing(source_object, point_spacing),
world_metric,
)
material = None
@@ -293,7 +293,7 @@ def create_shape_object(
for name, value in (
("Depth Scale", 1.0),
("Uniform Scale", uniform_scale),
("Base Plane Depth", depth_reference),
("Reference Depth", depth_reference),
("Depth Image", depth_image),
):
set_modifier_input(