Add comprehensive tests for image processing and mask functionalities

- Introduced tests for color image handling, ensuring source preservation and premultiplied alpha usage.
- Implemented tests for brush stroke operations, validating segment boundaries and brush anti-aliasing.
- Added tests for frame anti-aliasing, confirming correct rendering behavior with various alpha values and depth settings.
- Created tests for frame projection, ensuring accurate overlap calculations and half-plane clipping.
- Developed tests for image premultiplied retention, verifying behavior during loading and packing processes.
- Added undo functionality tests for mask operations, ensuring single transaction behavior during edits.
This commit is contained in:
2026-09-05 21:33:11 +08:00
parent bbaa4bff46
commit 1994d38ccd
111 changed files with 7691 additions and 2341 deletions

View File

@@ -1213,7 +1213,7 @@ def depth_balloon_projection_parameters(
)
if not usable.any():
raise ValueError("MoGe-2 found no valid Depth Balloon samples")
local_coordinates = vertices[:, 1:]
local_coordinates = vertices[:, :2]
metric_depth = sampled[usable] * float(uniform_scale)
plane = _stable_plane(local_coordinates[usable], metric_depth)
reference = metric_depth_reference(
@@ -1221,7 +1221,7 @@ def depth_balloon_projection_parameters(
calibration_valid,
uniform_scale,
)
direction = np.asarray((-plane[0], plane[1], 1.0), dtype=np.float64)
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)
@@ -1238,19 +1238,6 @@ class BaseShape:
balloon: object
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(
selection_contours,
bounds,
@@ -1433,7 +1420,7 @@ def build_base_shape(
)
source_vertices, faces, uv, _boundary, planar, balloon = domain
return BaseShape(
vertices=orient_cutout_vertices(source_vertices),
vertices=np.asarray(source_vertices, dtype=np.float64),
faces=counter_clockwise_faces(planar, faces),
uv=np.asarray(uv, dtype=np.float32),
balloon=np.asarray(balloon, dtype=np.float64) / world_z_scale,

View File

@@ -29,7 +29,6 @@ def _pie_button(
icon,
source_name,
selection_path_json,
refine_selection,
generate_normal,
point_spacing,
inward_axis,
@@ -42,7 +41,6 @@ def _pie_button(
operator.source_object_name = source_name
operator.selection_path_json = selection_path_json
operator.shape = shape
operator.refine_selection = refine_selection
operator.generate_normal = generate_normal
operator.point_spacing = point_spacing
operator.inward_axis = inward_axis
@@ -53,14 +51,12 @@ def open_shape_pie(
event,
source_object,
selection_path,
refine_selection=False,
):
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)
ready = ai_ready()
refine_selection = ready and refine_selection
generate_normal = ready and bool(
getattr(settings, "cutout_generate_normal", False)
)
@@ -77,7 +73,6 @@ def open_shape_pie(
icon,
source_name,
selection_path_json,
refine_selection,
generate_normal,
point_spacing,
inward_axis,

View File

@@ -9,11 +9,7 @@ from ...preferences import (
configured_max_ai_input_size,
configured_max_cutout_mesh_resolution,
)
from ...properties import (
BEN2_MODEL_KEY,
DEFAULT_MOGE2_MODEL_KEY,
refine_selection_property,
)
from ...properties import DEFAULT_MOGE2_MODEL_KEY
from ...runtime import JobOperatorBase
from ...server.media.resolution import (
DEFAULT_MAX_AI_INPUT_SIZE,
@@ -43,9 +39,8 @@ from ...common.image import (
)
from ...common.depth import load_depth_metadata, load_depth_result_image
from ...common.viewport import (
CropSelectionGesture,
apply_crop_settings,
gesture_property,
ImageGesture,
image_edit_drag_keymap,
report_image_edit_exception,
resolve_image_edit_click,
serialize_matrix,
@@ -158,14 +153,9 @@ def _cutout_content_values(source_alpha, selection_values=None):
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
else:
path = result.file("selection_image")
def _generated_color_result(input_path, source_object, source_bounds):
image = load_packed_rgba_image(
path,
input_path,
source_object.data,
color_data_name(source_object),
)
@@ -222,18 +212,13 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
options=_HIDDEN,
)
generate_normal: bpy.props.BoolProperty(default=False, options=_HIDDEN)
refine_selection: bpy.props.BoolProperty(default=False, options=_HIDDEN)
inward_axis: bpy.props.StringProperty(
default=CutoutInwardAxis.NEGATIVE_Z.value,
options=_HIDDEN,
)
def _needs_generation(self):
return (
self.refine_selection
or self.shape in DEPTH_CUTOUT_SHAPES
or self.generate_normal
)
return self.shape in DEPTH_CUTOUT_SHAPES or self.generate_normal
def execute(self, context):
try:
@@ -321,20 +306,15 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
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_selection:
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_selection": self.refine_selection,
"depth_mode": depth_mode,
"normal_mode": normal_mode.value,
"max_input_size": int(self.max_ai_input_size),
}
if self.refine_selection:
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(
@@ -361,7 +341,6 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
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(
result,
self.input_path,
source_object,
source_bounds,
@@ -426,19 +405,16 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
cleanup_image_input(self.input_path, True)
class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator):
class SelectCutoutSelection(ImageGesture, bpy.types.Operator):
bl_idname = "anyimage.select_cutout_selection"
bl_label = "Start Cutout Selection"
bl_description = "Lasso an Image Empty selection, then choose its mesh shape"
bl_options = {"INTERNAL"}
active_tool_id = "anyimage.cutout_lasso"
refine_selection_settings = {"LASSO": "cutout_refine_selection"}
gesture = "LASSO"
source_object_name: bpy.props.StringProperty(options={"HIDDEN", "SKIP_SAVE"})
source_matrix_data: 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"})
_pie_event = None
@@ -462,8 +438,6 @@ class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator):
if video_total_frames(source_object.data) > 1:
self.report({"WARNING"}, "Cutout only supports a still image")
return {"CANCELLED"}
apply_crop_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)
@@ -474,7 +448,7 @@ class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator):
def modal(self, context, event):
self._pie_event = event
return CropSelectionGesture.modal(self, context, event)
return ImageGesture.modal(self, context, event)
def _submit_selection_path(self, context, selection_path):
try:
@@ -487,7 +461,6 @@ class SelectCutoutSelection(CropSelectionGesture, bpy.types.Operator):
self._pie_event,
source_object,
selection_path,
self.refine_selection,
)
return {"FINISHED"}
@@ -504,19 +477,17 @@ class CutoutTool(bpy.types.WorkSpaceTool):
(
SelectCutoutSelection.bl_idname,
{"type": "LEFTMOUSE", "value": "DOUBLE_CLICK"},
{"properties": [("gesture", "LASSO"), ("full_image", True)]},
{"properties": [("full_image", True)]},
),
(
*image_edit_drag_keymap(
SelectCutoutSelection.bl_idname,
{"type": "LEFTMOUSE", "value": "PRESS"},
{"properties": [("gesture", "LASSO")]},
deselect_on_empty=False,
),
)
def draw_settings(context, layout, _tool):
settings = context.scene.anyimage_settings
status = draw_ai_setup(layout)
draw_ai_property(layout, settings, "cutout_refine_selection", status)
draw_ai_property(layout, settings, "cutout_generate_normal", status)
layout.prop(settings, "cutout_mesh_detail")
layout.prop(settings, "cutout_inward_axis")

View File

@@ -4,7 +4,7 @@ from enum import StrEnum
import bpy
from ...common.image import color_data_name
from ...common.image import color_data_name, configure_color_alpha
from ...common.depth import camera_depth_values, depth_uniform_scale
from ...common.material import (
IMAGE_MATERIAL_NODE_GROUP_NAME,
@@ -31,7 +31,7 @@ from .geometry import (
from .shape import (
BALLOON_ATTRIBUTE_NAME,
BALLOON_CUTOUT_SHAPES,
CUTOUT_AXIS_TO_SOURCE,
CUTOUT_X_TO_Z,
CUTOUT_NODE_GROUP_NAME,
CUTOUT_SHAPE_INDICES,
DEPTH_CUTOUT_SHAPES,
@@ -55,13 +55,13 @@ def _cutout_world_matrix(source_matrix, crop_center, inward_axis):
center_x, center_y = (float(value) for value in crop_center)
matrix = source_matrix @ Matrix.Translation((center_x, center_y, 0.0))
if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.POSITIVE_X:
matrix @= Matrix(CUTOUT_AXIS_TO_SOURCE)
matrix @= Matrix(CUTOUT_X_TO_Z)
return matrix
def orient_object_normal_image(image, inward_axis):
"""Convert internal +X Cutout normals to the selected object axes."""
if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.POSITIVE_X:
"""Convert native Z-axis Cutout normals to the selected object axes."""
if CutoutInwardAxis(inward_axis) == CutoutInwardAxis.NEGATIVE_Z:
return image
import numpy as np
@@ -70,7 +70,7 @@ def orient_object_normal_image(image, inward_axis):
rgba = pixels.reshape((-1, 4))
normal = rgba[:, :3] * 2.0 - 1.0
rgba[:, :3] = np.column_stack(
(-normal[:, 1], normal[:, 2], -normal[:, 0])
(-normal[:, 2], -normal[:, 0], normal[:, 1])
) * 0.5 + 0.5
image.pixels.foreach_set(pixels)
image.update()
@@ -120,7 +120,7 @@ def create_cropped_color_image(source_object, crop_bounds, source_rgba):
)
try:
image.colorspace_settings.name = source.colorspace_settings.name
image.alpha_mode = source.alpha_mode
configure_color_alpha(image)
image.pixels.foreach_set(np.flipud(cropped).ravel())
image.pack()
image.update()

View File

@@ -2,12 +2,18 @@
BALLOON_ATTRIBUTE_NAME = "o_balloon"
CUTOUT_NODE_GROUP_NAME = "O Image Cutout"
CUTOUT_AXIS_TO_SOURCE = (
CUTOUT_X_TO_Z = (
(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),
)
CUTOUT_Z_TO_X = (
(0.0, 0.0, -1.0, 0.0),
(-1.0, 0.0, 0.0, 0.0),
(0.0, 1.0, 0.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
CUTOUT_NODE_GROUP_NAMES = {
"SURFACE": "O Image Surface",
"BALLOON": "O Image Balloon",