feat(selection): separate selection geometry from image content processing

- Introduced a new design for selection rasterization that decouples the selection geometry from the image content, ensuring that the SelectionMask is independent of the source image's alpha and color.
- Updated the `rasterize_selection_path` function to only accept selection paths and image dimensions, removing parameters related to source alpha and alpha thresholds.
- Implemented a new approach for handling selection paths using non-zero winding fill, ensuring overlapping areas are preserved.
- Enhanced performance by utilizing a scanline algorithm for rasterization, significantly reducing processing time for high-resolution images.
- Updated internal documentation and added comprehensive tests to validate the new behavior and performance improvements.
- Ensured backward compatibility by maintaining existing interfaces where necessary while introducing breaking changes in the selection processing logic.
This commit is contained in:
2026-09-01 22:36:20 +08:00
parent 2ff7b04f56
commit 7f6e47432e
54 changed files with 2831 additions and 634 deletions

View File

@@ -202,10 +202,10 @@ def _resize_values(values, size):
)
def extract_selection_contours(selection_mask, max_resolution=512, threshold=0.5):
def extract_selection_contours(selection_values, max_resolution=512, threshold=0.5):
import numpy as np
values = np.asarray(selection_mask.values)
values = np.asarray(selection_values)
if values.ndim != 2:
raise ValueError("Cutout Alpha must be two-dimensional")
if np.issubdtype(values.dtype, np.floating):
@@ -1341,13 +1341,13 @@ def crop_plane_bounds(source_object, crop_bounds, source_size):
def extract_cutout_contours(
selection_mask,
content_values,
mesh_resolution,
threshold=CUTOUT_ALPHA_THRESHOLD,
):
contour_resolution = max(512, min(int(mesh_resolution) * 8, 1024))
return extract_selection_contours(
selection_mask,
content_values,
max_resolution=contour_resolution,
threshold=threshold,
)
@@ -1373,11 +1373,11 @@ def cutout_sampling_spacing(source_object, mesh_resolution):
return max(width, height) / max(int(mesh_resolution), 8)
def selection_calibration(selection_mask, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD):
"""Return the final Selection mask and transparency ratio at geometry resolution."""
def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD):
"""Return Cutout validity and transparency ratio at geometry resolution."""
import numpy as np
values = np.asarray(selection_mask.values, dtype=np.float32)
values = np.asarray(content_values, dtype=np.float32)
target_height, target_width = (int(value) for value in target_shape)
source_height, source_width = values.shape
y = np.minimum(
@@ -1394,16 +1394,22 @@ def selection_calibration(selection_mask, target_shape, threshold=CUTOUT_ALPHA_T
return valid, transparent_fraction
def build_base_shape(context, source_object, mesh_resolution, selection_mask):
def build_base_shape(
context,
source_object,
mesh_resolution,
content_values,
content_bounds,
):
import numpy as np
bounds = crop_plane_bounds(
source_object,
selection_mask.bounds,
content_bounds,
tuple(source_object.data.size),
)
selection_contours = extract_cutout_contours(
selection_mask,
content_values,
mesh_resolution,
threshold=CUTOUT_ALPHA_THRESHOLD,
)

View File

@@ -5,9 +5,6 @@ from ...preferences import (
MIN_CUTOUT_MESH_RESOLUTION,
)
from ...properties import ai_ready
from ...common.image import image_rgba
from ...common.selection import rasterize_selection_path
from .geometry import CUTOUT_ALPHA_THRESHOLD
from .shape import CUTOUT_SHAPE_LABELS
CUTOUT_SHAPE_BUTTONS = (
@@ -25,21 +22,6 @@ CUTOUT_MESH_DETAIL_SPACING = {
}
def validate_cutout_selection(
source_object,
selection_path,
):
image = source_object.data
rgba = image_rgba(image)
rasterize_selection_path(
tuple(image.size),
selection_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))

View File

@@ -34,9 +34,11 @@ from ...common.ai import (
)
from ...common.image import (
cleanup_image_input,
color_data_name,
image_rgba,
load_normal_result_image,
prepare_selection_input,
load_packed_rgba_image,
prepare_image_bounds_input,
require_image_empty,
source_start_frame,
video_total_frames,
@@ -52,7 +54,6 @@ from ...common.viewport import (
)
from ...common.selection import (
ImageEditWarning,
SelectionMask,
SelectionPath,
rasterize_selection_path,
)
@@ -61,8 +62,12 @@ from .geometry import (
build_base_shape,
limit_cutout_mesh_resolution,
)
from .interaction import open_shape_pie, validate_cutout_selection
from .object import NormalMode, create_shape_object
from .interaction import open_shape_pie
from .object import (
NormalMode,
create_cropped_color_image,
create_shape_object,
)
from .shape import CUTOUT_SHAPES, DEPTH_CUTOUT_SHAPES
_HIDDEN = {"HIDDEN", "SKIP_SAVE"}
@@ -89,66 +94,99 @@ def create_cutout_shape(
source_object,
shape,
mesh_resolution,
selection_mask,
content_values,
content_bounds,
*,
depth_image=None,
depth_metadata=None,
normal_image=None,
normal_mode=NormalMode.NONE,
source_rgba=None,
color_image,
):
base_shape = build_base_shape(
context,
source_object,
mesh_resolution,
selection_mask,
)
result = create_shape_object(
context,
source_object,
shape,
mesh_resolution,
selection_mask,
base_shape,
depth_image=depth_image,
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
source_rgba=source_rgba,
)
try:
base_shape = build_base_shape(
context,
source_object,
mesh_resolution,
content_values,
content_bounds,
)
result = create_shape_object(
context,
source_object,
shape,
mesh_resolution,
content_values,
content_bounds,
base_shape,
depth_image=depth_image,
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
color_image=color_image,
)
except Exception:
if color_image.users == 0:
bpy.data.images.remove(color_image, do_unlink=True)
raise
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 _selection_mask_from_image(path, bounds):
image = bpy.data.images.load(str(path), check_existing=False)
def _cutout_content_values(source_alpha, selection_values=None):
"""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 > CUTOUT_ALPHA_THRESHOLD):
raise ImageEditWarning(
"The Cutout selection contains no visible image pixels"
)
return values
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,
color_data_name(source_object),
)
try:
return SelectionMask(
image_rgba(image)[:, :, 3],
tuple(int(value) for value in bounds),
)
finally:
bpy.data.images.remove(image, do_unlink=True)
def _generated_selection_mask(result, input_path, source_bounds):
if not isinstance(result.value, dict) or "selection_image" not in result.value:
return _selection_mask_from_image(input_path, source_bounds)
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],
)
return _selection_mask_from_image(result.file("selection_image"), bounds)
alpha = image_rgba(image)[:, :, 3]
except Exception:
if image.users == 0:
bpy.data.images.remove(image, do_unlink=True)
raise
return image, alpha, bounds
def cutout_geometry_settings(context):
@@ -213,14 +251,12 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
if self.shape not in CUTOUT_SHAPES:
raise ValueError(f"Unsupported Cutout shape: {self.shape}")
selection_path = SelectionPath.from_json(self.selection_path_json)
source_rgba = image_rgba(source_object.data)
selection_mask = rasterize_selection_path(
tuple(source_object.data.size),
selection_path,
source_alpha=source_rgba[:, :, 3],
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
antialias=True,
)
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]
@@ -232,13 +268,24 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
)
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,
)
color_image = create_cropped_color_image(
source_object,
selection_mask.bounds,
source_rgba,
)
create_cutout_shape(
context,
source_object,
self.shape,
self.mesh_resolution,
selection_mask,
source_rgba=source_rgba,
content_values,
selection_mask.bounds,
color_image=color_image,
)
return {"FINISHED"}
setup_result = invoke_ai_setup_if_needed()
@@ -249,7 +296,14 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
selection_mask.bounds,
separators=(",", ":"),
)
self.input_path = str(prepare_selection_input(source_object.data, selection_mask))
self._selection_mask = selection_mask
self.input_path = str(
prepare_image_bounds_input(
source_object.data,
source_rgba,
selection_mask.bounds,
)
)
except (
ImageEditWarning,
OSError,
@@ -303,15 +357,31 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
def response(self, context, result):
source_object = require_image_empty(self.source_object_name)
source_bounds = tuple(json.loads(self.selection_bounds_json))
selection_mask = _generated_selection_mask(
color_image, source_alpha, color_bounds = _generated_color_result(
result,
self.input_path,
source_object,
source_bounds,
)
value = result.value if isinstance(result.value, dict) else {}
normal_image = None
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
normal_mode = normal_mode_for_shape(self.shape, self.generate_normal)
normal_key = f"{normal_mode.value.lower()}_normal"
if normal_key in value:
@@ -328,22 +398,27 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
source_object,
depth_metadata,
)
shape_started = True
create_cutout_shape(
context,
source_object,
self.shape,
self.mesh_resolution,
selection_mask,
content_values,
content_bounds,
depth_image=depth_image,
depth_metadata=depth_metadata,
normal_image=normal_image,
normal_mode=normal_mode,
color_image=color_image,
)
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 not shape_started and color_image.users == 0:
bpy.data.images.remove(color_image, do_unlink=True)
raise
return f"{self.shape.replace('_', ' ').title()} is ready"
@@ -361,13 +436,11 @@ class SelectCutoutSelection(ImageSelectionGesture, bpy.types.Operator):
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
selection_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):
@@ -396,28 +469,21 @@ class SelectCutoutSelection(ImageSelectionGesture, bpy.types.Operator):
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.selection_path_json = SelectionPath(
selection_path = SelectionPath(
points=((0, 0), (width, 0), (width, height), (0, height)),
fill_rule="NONZERO",
).to_json()
return self.execute(context)
)
return self._submit_selection_path(context, selection_path)
def modal(self, context, event):
self._pie_event = event
return super().modal(context, event)
def execute(self, context):
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"}
try:
selection_path = SelectionPath.from_json(self.selection_path_json)
validate_cutout_selection(source_object, selection_path)
except (ImageEditWarning, TypeError, ValueError) as error:
report_image_edit_exception(self, error)
return {"CANCELLED"}
open_shape_pie(
context,
self._pie_event,

View File

@@ -4,10 +4,7 @@ from enum import StrEnum
import bpy
from ...common.image import (
color_data_name,
image_rgba,
)
from ...common.image import color_data_name
from ...common.depth import camera_depth_values, depth_uniform_scale
from ...common.material import (
IMAGE_MATERIAL_NODE_GROUP_NAME,
@@ -84,8 +81,8 @@ def _assign_float_point_attribute(mesh, name, values):
attribute.data.foreach_set("value", np.asarray(values, dtype=np.float32))
def _cropped_color_image(source_object, selection_mask, source_rgba):
crop_bounds = selection_mask.bounds
def create_cropped_color_image(source_object, crop_bounds, source_rgba):
"""Create a packed color image for one local Cutout bounds rectangle."""
import numpy as np
source = source_object.data
@@ -95,17 +92,10 @@ def _cropped_color_image(source_object, selection_mask, source_rgba):
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]
if left < 0 or top < 0 or right > source_width or bottom > source_height:
raise ValueError("The Cutout crop is outside the source RGBA")
cropped = rgba[top:bottom, left:right]
image = bpy.data.images.new(
color_data_name(source_object),
width=width,
@@ -151,21 +141,22 @@ def create_shape_object(
source_object,
shape,
mesh_resolution,
selection_mask,
content_values,
content_bounds,
base_shape,
*,
depth_image=None,
depth_metadata=None,
normal_image=None,
normal_mode=NormalMode.NONE,
source_rgba=None,
color_image,
):
import numpy as np
normal_mode = NormalMode(normal_mode)
bounds = crop_plane_bounds(
source_object,
selection_mask.bounds,
content_bounds,
tuple(source_object.data.size),
)
crop_center = (
@@ -177,11 +168,6 @@ def create_shape_object(
cutout_sampling_spacing(source_object, mesh_resolution),
world_metric,
)
color_image = _cropped_color_image(
source_object,
selection_mask,
image_rgba(source_object.data) if source_rgba is None else source_rgba,
)
material = None
mesh = None
mesh_object = None
@@ -209,7 +195,7 @@ def create_shape_object(
raise ValueError("Depth Shape requires a Depth texture and metadata")
camera_depth, valid = camera_depth_values(depth_image)
calibration_valid, transparent_fraction = selection_calibration(
selection_mask, valid.shape
content_values, valid.shape
)
calibration_valid &= valid
uniform_scale = depth_uniform_scale(
@@ -311,6 +297,4 @@ def create_shape_object(
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