- 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.
449 lines
18 KiB
Python
449 lines
18 KiB
Python
from contextlib import nullcontext
|
|
import importlib
|
|
import json
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, patch
|
|
from tests.support.blender import BlenderTestCase
|
|
|
|
|
|
class CutoutOperatorsTest(BlenderTestCase):
|
|
def test_cutout_gestures_start_on_empty_pick_and_lasso_finishes_on_release(self):
|
|
viewport = importlib.import_module("anyimage.common.viewport")
|
|
source = SimpleNamespace(name="Source", matrix_world="matrix", data=object())
|
|
context = SimpleNamespace(
|
|
area=SimpleNamespace(as_pointer=lambda: 1, tag_redraw=Mock()),
|
|
region=SimpleNamespace(as_pointer=lambda: 2),
|
|
workspace=SimpleNamespace(status_text_set=Mock()),
|
|
window_manager=SimpleNamespace(modal_handler_add=Mock()),
|
|
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(mask_gesture="BRUSH")),
|
|
)
|
|
event = SimpleNamespace(type="LEFTMOUSE", value="PRESS", mouse_region_x=12, mouse_region_y=18)
|
|
with (
|
|
patch.object(viewport, "resolve_image_edit_click", return_value=(source, True)),
|
|
patch.object(viewport, "is_animated_image", return_value=False),
|
|
patch.object(viewport, "serialize_matrix", return_value="matrix"),
|
|
patch.object(viewport, "active_view3d_tool_id", return_value=None),
|
|
patch.object(self.fake_bpy.types, "SpaceView3D", SimpleNamespace(draw_handler_add=Mock()), create=True),
|
|
):
|
|
for gesture in ("LASSO", "POLYLINE"):
|
|
with self.subTest(gesture=gesture):
|
|
context.scene.anyimage_settings.cutout_gesture = gesture
|
|
operator = self.cutout_main.SelectCutoutSelection()
|
|
operator.report = Mock()
|
|
self.assertEqual(operator.invoke(context, event), {"RUNNING_MODAL"})
|
|
self.assertEqual(operator._path, [(12.0, 18.0)])
|
|
self.assertEqual(operator.gesture, gesture)
|
|
self.assertEqual(context.scene.anyimage_settings.mask_gesture, "BRUSH")
|
|
operator._complete = Mock(return_value={"FINISHED"})
|
|
release = SimpleNamespace(type="LEFTMOUSE", value="RELEASE", mouse_region_x=20, mouse_region_y=30)
|
|
result = operator.modal(context, release)
|
|
if gesture == "LASSO":
|
|
self.assertEqual(result, {"FINISHED"})
|
|
operator._complete.assert_called_once_with(context)
|
|
else:
|
|
operator._complete.assert_not_called()
|
|
status = context.workspace.status_text_set.call_args.args[0]
|
|
self.assertIn("Polyline", status)
|
|
self.assertIn("Backspace", status)
|
|
|
|
def test_flat_and_solid_without_ai_build_locally(self):
|
|
import numpy as np
|
|
|
|
source = SimpleNamespace(
|
|
data=SimpleNamespace(size=(10, 10)), select_set=lambda _value: None
|
|
)
|
|
context = SimpleNamespace(
|
|
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
|
)
|
|
selection_mask = self.image_selection.SelectionMask(
|
|
np.ones((8, 8), dtype=np.float32), (1, 1, 9, 9)
|
|
)
|
|
source_rgba = np.ones((10, 10, 4), dtype=np.float32)
|
|
for shape in ("FLAT", "SOLID"):
|
|
with self.subTest(shape=shape):
|
|
operator = SimpleNamespace(
|
|
source_object_name="Source",
|
|
selection_path_json=json.dumps(
|
|
{"points": [[1, 1], [9, 1], [9, 9], [1, 9]]}
|
|
),
|
|
shape=shape,
|
|
gesture="POLYLINE",
|
|
edge_length=0.1,
|
|
alpha_threshold=0.25,
|
|
fine_outline=False,
|
|
generate_normal=False,
|
|
report=lambda *_args: None,
|
|
_needs_generation=lambda: False,
|
|
)
|
|
with (
|
|
patch.object(
|
|
self.cutout_main,
|
|
"require_image_empty",
|
|
return_value=source,
|
|
),
|
|
patch.object(
|
|
self.cutout_main, "is_animated_image", return_value=False
|
|
),
|
|
patch.object(
|
|
self.cutout_main, "image_rgba", return_value=source_rgba
|
|
) as read_rgba,
|
|
patch.object(
|
|
self.cutout_main,
|
|
"rasterize_selection_path",
|
|
return_value=selection_mask,
|
|
) as rasterize,
|
|
patch.object(
|
|
self.cutout_main,
|
|
"configured_max_ai_input_size",
|
|
return_value=2048,
|
|
),
|
|
patch.object(
|
|
self.cutout_main,
|
|
"_cutout_content_values",
|
|
return_value=selection_mask.values,
|
|
),
|
|
patch.object(
|
|
self.cutout_main,
|
|
"material_color_image",
|
|
return_value=nullcontext("Color"),
|
|
),
|
|
patch.object(
|
|
self.cutout_main, "create_cutout_shape"
|
|
) as convert,
|
|
):
|
|
result = self.cutout_main.CutoutSelectionToShape.execute(
|
|
operator, context
|
|
)
|
|
|
|
self.assertEqual(result, {"FINISHED"})
|
|
read_rgba.assert_called_once_with(source.data)
|
|
rasterize.assert_called_once()
|
|
convert.assert_called_once_with(
|
|
context,
|
|
source,
|
|
shape,
|
|
0.1,
|
|
selection_mask.values,
|
|
selection_mask.bounds,
|
|
alpha_threshold=0.25,
|
|
fine_outline=False,
|
|
color_image="Color",
|
|
gesture="POLYLINE",
|
|
report=operator.report,
|
|
)
|
|
|
|
|
|
def test_ai_cutout_writes_the_selection_bounds_image(self):
|
|
import numpy as np
|
|
|
|
source = SimpleNamespace(
|
|
data=SimpleNamespace(size=(4096, 2048)),
|
|
select_set=lambda _value: None,
|
|
)
|
|
context = SimpleNamespace(
|
|
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
|
)
|
|
source_rgba = np.ones((4, 8, 4), dtype=np.float32)
|
|
selection_mask = self.image_selection.SelectionMask(
|
|
np.ones((2, 6), dtype=np.float32),
|
|
(1, 1, 7, 3),
|
|
)
|
|
operator = self.cutout_main.CutoutSelectionToShape()
|
|
operator.alpha_threshold = 0.25
|
|
operator.fine_outline = False
|
|
operator.source_object_name = "Source"
|
|
operator.selection_path_json = json.dumps(
|
|
{"points": [[1, 1], [7, 1], [7, 3], [1, 3]]}
|
|
)
|
|
operator.shape = "DEPTH_SOLID"
|
|
operator.report = lambda *_args: None
|
|
operator.edge_length = 0.1
|
|
operator.generate_normal = False
|
|
operator.report = lambda *_args: None
|
|
|
|
with (
|
|
patch.object(
|
|
self.cutout_main,
|
|
"require_image_empty",
|
|
return_value=source,
|
|
),
|
|
patch.object(self.cutout_main, "is_animated_image", return_value=False),
|
|
patch.object(
|
|
self.cutout_main,
|
|
"image_rgba",
|
|
return_value=source_rgba,
|
|
) as read_rgba,
|
|
patch.object(
|
|
self.cutout_main,
|
|
"rasterize_selection_path",
|
|
return_value=selection_mask,
|
|
) as rasterize,
|
|
patch.object(
|
|
self.cutout_main,
|
|
"configured_max_ai_input_size",
|
|
return_value=2048,
|
|
),
|
|
patch.object(
|
|
self.cutout_main,
|
|
"invoke_ai_setup_if_needed",
|
|
return_value=None,
|
|
),
|
|
patch.object(
|
|
self.cutout_main,
|
|
"cutout_geometry_settings",
|
|
return_value=("MODEL", 5),
|
|
),
|
|
patch.object(
|
|
self.cutout_main,
|
|
"prepare_material_color_input",
|
|
return_value=Path(__file__),
|
|
) as prepare_input,
|
|
patch.object(self.cutout_main, "material_analysis_input", return_value=Path(__file__)),
|
|
patch.object(
|
|
self.cutout_main.JobOperatorBase,
|
|
"execute",
|
|
return_value={"FINISHED"},
|
|
),
|
|
):
|
|
result = operator.execute(context)
|
|
|
|
self.assertEqual(result, {"FINISHED"})
|
|
read_rgba.assert_called_once_with(source.data)
|
|
rasterize.assert_called_once()
|
|
prepare_input.assert_called_once_with(
|
|
source.data,
|
|
selection_mask.bounds,
|
|
source_rgba,
|
|
)
|
|
|
|
|
|
def test_empty_cutout_selection_is_rejected_after_shape_choice(self):
|
|
source = SimpleNamespace(
|
|
data=SimpleNamespace(size=(4096, 2048)),
|
|
select_set=lambda _value: None,
|
|
)
|
|
context = SimpleNamespace(
|
|
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
|
)
|
|
reports = []
|
|
operator = SimpleNamespace(
|
|
source_object_name="Source",
|
|
selection_path_json=json.dumps(
|
|
{"points": [[1, 1], [7, 1], [7, 3], [1, 3]]}
|
|
),
|
|
shape="FLAT",
|
|
edge_length=0.1,
|
|
generate_normal=False,
|
|
report=lambda level, message: reports.append((level, message)),
|
|
)
|
|
|
|
with (
|
|
patch.object(
|
|
self.cutout_main,
|
|
"require_image_empty",
|
|
return_value=source,
|
|
),
|
|
patch.object(self.cutout_main, "is_animated_image", return_value=False),
|
|
patch.object(self.cutout_main, "image_rgba"),
|
|
patch.object(
|
|
self.cutout_main,
|
|
"rasterize_selection_path",
|
|
side_effect=self.image_selection.ImageEditWarning(
|
|
"The selection contains no visible image pixels"
|
|
),
|
|
),
|
|
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
|
|
):
|
|
result = self.cutout_main.CutoutSelectionToShape.execute(
|
|
operator,
|
|
context,
|
|
)
|
|
|
|
self.assertEqual(result, {"CANCELLED"})
|
|
create_shape.assert_not_called()
|
|
self.assertEqual(
|
|
reports,
|
|
[
|
|
(
|
|
{"WARNING"},
|
|
"The selection contains no visible image pixels",
|
|
)
|
|
],
|
|
)
|
|
|
|
|
|
def test_cutout_request_describes_ai_capabilities(self):
|
|
operator = self.cutout_main.CutoutSelectionToShape()
|
|
operator.alpha_threshold = 0.25
|
|
operator.fine_outline = False
|
|
operator.input_path = __file__
|
|
operator.color_path = __file__
|
|
operator.source_object_name = "Source"
|
|
operator.shape = "DEPTH_SOLID"
|
|
operator.model = self.anyimage.preferences.DEFAULT_GEOMETRY_MODEL_KEY
|
|
operator.resolution_level = 5
|
|
operator.max_ai_input_size = 2048
|
|
operator.generate_normal = True
|
|
context = SimpleNamespace(
|
|
scene=SimpleNamespace(anyimage_settings=SimpleNamespace())
|
|
)
|
|
with (
|
|
patch.object(self.cutout_main, "require_environment"),
|
|
patch.object(self.cutout_main, "require_model") as require_model,
|
|
patch.object(self.cutout_main, "require_input_path", return_value=Path("input.png")),
|
|
patch.object(
|
|
self.cutout_main, "production_device", return_value="CPU"
|
|
),
|
|
patch.object(
|
|
self.cutout_main,
|
|
"moge2_parameters",
|
|
return_value={"model": operator.model, "resolution_level": 5},
|
|
),
|
|
):
|
|
parameters = operator.request(context)
|
|
|
|
self.assertEqual(
|
|
[call.args[0] for call in require_model.call_args_list],
|
|
[operator.model],
|
|
)
|
|
self.assertTrue(parameters["generate_depth"])
|
|
self.assertEqual(parameters["alpha_threshold"], 0.25)
|
|
self.assertEqual(parameters["normal_mode"], "OBJECT")
|
|
|
|
|
|
def test_cutout_geometry_settings_use_configured_moge(self):
|
|
preferences = SimpleNamespace(
|
|
geometry_model="MOGE2_VITB_NORMAL",
|
|
geometry_resolution_level=7,
|
|
)
|
|
with patch.object(self.cutout_main, "addon_preferences", return_value=preferences), patch.object(
|
|
self.anyimage.preferences, "addon_preferences", return_value=preferences
|
|
):
|
|
settings = self.cutout_main.cutout_geometry_settings(None)
|
|
|
|
self.assertEqual(
|
|
settings,
|
|
("MOGE2_VITB_NORMAL", 7),
|
|
)
|
|
|
|
|
|
def test_cutout_tool_settings_gate_normal_generation(self):
|
|
class Row:
|
|
def __init__(self, events):
|
|
self.events = events
|
|
self.enabled = True
|
|
|
|
def prop(self, _owner, name, **_options):
|
|
self.events.append(("prop", name, self.enabled))
|
|
|
|
class Layout:
|
|
def __init__(self):
|
|
self.events = []
|
|
|
|
def operator(self, identifier, **_options):
|
|
self.events.append(("operator", identifier, True))
|
|
|
|
def row(self):
|
|
return Row(self.events)
|
|
|
|
def prop(self, _owner, name, **_options):
|
|
self.events.append(("prop", name, True))
|
|
|
|
context = SimpleNamespace(
|
|
scene=SimpleNamespace(anyimage_settings=SimpleNamespace())
|
|
)
|
|
missing = {
|
|
"environment_ready": False,
|
|
"missing_models": (),
|
|
"ready": False,
|
|
}
|
|
setup = ("operator", "anyimage.setup_ai_environment", True)
|
|
with patch.object(self.ai, "ai_status", return_value=missing):
|
|
cutout_tool = importlib.import_module(
|
|
"anyimage.operators.cutout_tool"
|
|
).CutoutTool
|
|
layout = Layout()
|
|
cutout_tool.draw_settings(context, layout, None)
|
|
self.assertEqual(
|
|
layout.events,
|
|
[
|
|
("prop", "cutout_gesture", True),
|
|
("prop", "cutout_edge_length", True),
|
|
("prop", "cutout_fine_outline", True),
|
|
("prop", "cutout_alpha_threshold", True),
|
|
setup,
|
|
("prop", "cutout_generate_normal", False),
|
|
],
|
|
)
|
|
|
|
ready = {"environment_ready": True, "missing_models": (), "ready": True}
|
|
with patch.object(self.ai, "ai_status", return_value=ready):
|
|
layout = Layout()
|
|
cutout_tool.draw_settings(context, layout, None)
|
|
self.assertNotIn(setup, layout.events)
|
|
self.assertTrue(all(event[2] for event in layout.events))
|
|
|
|
|
|
def test_cutout_ai_response_reuses_bounds_color_and_gesture_snapshot(self):
|
|
import numpy as np
|
|
|
|
source = SimpleNamespace(data=SimpleNamespace())
|
|
color_image = SimpleNamespace(users=0)
|
|
pure_values = np.ones((3, 4), dtype=np.float32)
|
|
pure_values[:, 0] = 0.0
|
|
selection_mask = self.image_selection.SelectionMask(
|
|
pure_values,
|
|
(10, 20, 14, 23),
|
|
)
|
|
operator = self.cutout_main.CutoutSelectionToShape()
|
|
operator.alpha_threshold = 0.25
|
|
operator.fine_outline = False
|
|
operator.source_object_name = "Source"
|
|
operator.selection_bounds_json = "[10,20,14,23]"
|
|
operator.input_path = __file__
|
|
operator.color_path = __file__
|
|
operator.shape = "FLAT"
|
|
operator.gesture = "POLYLINE"
|
|
operator.report = lambda *_args: None
|
|
operator.edge_length = 0.1
|
|
operator.generate_normal = True
|
|
operator._selection_mask = selection_mask
|
|
result = SimpleNamespace(value={}, file=lambda _key: Path(__file__))
|
|
|
|
with (
|
|
patch.object(
|
|
self.cutout_main,
|
|
"require_image_empty",
|
|
return_value=source,
|
|
),
|
|
patch.object(
|
|
self.cutout_main,
|
|
"_generated_color_result",
|
|
return_value=(
|
|
color_image,
|
|
np.ones((3, 4), dtype=np.float32),
|
|
selection_mask.bounds,
|
|
),
|
|
) as color_result,
|
|
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
|
|
):
|
|
context = SimpleNamespace(scene=SimpleNamespace(
|
|
anyimage_settings=SimpleNamespace(cutout_gesture="LASSO")
|
|
))
|
|
message = operator.response(context, result)
|
|
|
|
color_result.assert_called_once_with(
|
|
__file__,
|
|
selection_mask.bounds,
|
|
)
|
|
|
|
self.assertEqual(message, "Flat is ready")
|
|
self.assertEqual(create_shape.call_args.kwargs["gesture"], "POLYLINE")
|
|
content_values = create_shape.call_args.args[4]
|
|
np.testing.assert_array_equal(content_values, pure_values)
|
|
self.assertEqual(create_shape.call_args.args[5], selection_mask.bounds)
|
|
self.assertIs(create_shape.call_args.kwargs["color_image"], color_image)
|
|
self.assertEqual(create_shape.call_args.kwargs["alpha_threshold"], 0.25)
|
|
self.assertIs(create_shape.call_args.kwargs["fine_outline"], False)
|