feat: Enhance AI integration for Cutout, Image Box, and Image Lasso tools with unified settings and improved shape selection

This commit is contained in:
2026-08-31 16:10:08 +08:00
parent e7aeeb21d4
commit 14d63001d2
15 changed files with 473 additions and 89 deletions

View File

@@ -1433,13 +1433,23 @@ class BlenderAddonTest(unittest.TestCase):
).CutoutTool
cutout_module = sys.modules[cutout_tool.__module__]
drawn_settings.clear()
with patch.object(
cutout_module,
"draw_region_settings",
side_effect=lambda _context, _layout, name: drawn_settings.append(name),
) as draw:
with (
patch.object(
cutout_module,
"draw_ai_setup",
return_value={"ready": True},
) as setup,
patch.object(
cutout_module,
"draw_ai_property",
side_effect=lambda _layout, _settings, name, _status: (
drawn_settings.append(name)
),
) as draw,
):
cutout_tool.draw_settings(context, layout, None)
draw.assert_called_once_with(context, layout, "cutout_refine_region")
setup.assert_called_once_with(layout)
self.assertEqual(draw.call_count, 2)
self.assertEqual(
drawn_settings,
[
@@ -1481,6 +1491,83 @@ class BlenderAddonTest(unittest.TestCase):
self.assertNotIn("image_extract_padding", settings)
self.assertNotIn("image_extract_remove_background", settings)
def test_image_area_tool_settings_gate_ai_options(self):
class Row:
def __init__(self, events):
self.events = events
self.enabled = True
def prop(self, _owner, name):
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):
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.image_interaction, "ai_status", return_value=missing):
for tool, setting_name in (
(self.image_box.ImageBoxTool, "image_box_refine_region"),
(self.image_lasso.ImageLassoTool, "image_lasso_refine_region"),
):
layout = Layout()
tool.draw_settings(context, layout, None)
self.assertEqual(
layout.events,
[setup, ("prop", setting_name, False)],
)
cutout_tool = importlib.import_module(
"anyimage.operators.cutout_tool"
).CutoutTool
layout = Layout()
cutout_tool.draw_settings(context, layout, None)
self.assertEqual(
layout.events,
[
setup,
("prop", "cutout_refine_region", False),
("prop", "cutout_point_spacing", True),
("prop", "cutout_generate_normal", False),
],
)
layout = Layout()
operator = SimpleNamespace(gesture="BOX")
self.image_interaction.draw_region_operator_settings(layout, operator)
self.assertEqual(
layout.events,
[
setup,
("prop", "refine_region", False),
("prop", "invert", True),
],
)
ready = {"environment_ready": True, "missing_models": (), "ready": True}
with patch.object(self.image_interaction, "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_refine_region_is_independent_for_each_area_tool(self):
settings = SimpleNamespace(
cutout_refine_region=True,
@@ -1636,6 +1723,57 @@ class BlenderAddonTest(unittest.TestCase):
atol=1e-6,
)
def test_image_perspective_preview_uses_srgb_without_changing_pixels(self):
written = []
preview = SimpleNamespace(
colorspace_settings=SimpleNamespace(name="Non-Color"),
alpha_mode=None,
pixels=SimpleNamespace(foreach_set=written.extend),
update=lambda: None,
)
previous_data = self.fake_bpy.data
self.fake_bpy.data = SimpleNamespace(
images=SimpleNamespace(new=lambda *_args, **_options: preview)
)
pixels = (0.1, 0.2, 0.3, 0.4)
source = SimpleNamespace(
name="Source.png",
colorspace_settings=SimpleNamespace(name="Non-Color"),
)
try:
result = self.image_perspective.create_perspective_preview_image(
source, pixels
)
finally:
self.fake_bpy.data = previous_data
self.assertIs(result, preview)
self.assertEqual(preview.colorspace_settings.name, "sRGB")
self.assertEqual(preview.alpha_mode, "STRAIGHT")
self.assertEqual(written, list(pixels))
def test_image_perspective_preview_skips_cursor_at_last_clicked_point(self):
points = ((10.0, 10.0), (80.0, 10.0), (70.0, 60.0))
stationary = self.image_perspective.interactive_preview_polygon(
points, points[-1]
)
moving = self.image_perspective.interactive_preview_polygon(
points, (20.0, 70.0)
)
self.assertEqual(len(stationary), 3)
self.assertEqual(len(moving), 4)
def test_image_perspective_draws_scene_linear_texture_as_srgb_in_blender_5(self):
draw_options = self.image_perspective.preview_texture_draw_options
self.assertEqual(draw_options((4, 5, 3)), {})
self.assertEqual(
draw_options((5, 0, 0)),
{"is_scene_linear_with_rec709_srgb_target": True},
)
def test_image_perspective_tightly_crops_alpha(self):
import numpy as np

View File

@@ -574,6 +574,24 @@ class ClipboardImageTest(unittest.TestCase):
finally:
self.actions.bpy.data = original_data
def test_names_new_packed_image_clipboard(self):
image = FakeLoadedImage()
images = FakeLoadedImages(image)
original_data = self.actions.bpy.data
self.actions.bpy.data = SimpleNamespace(images=images)
try:
loaded_image, reused = self.actions.acquire_packed_image(
b"image-data",
".png",
)
finally:
self.actions.bpy.data = original_data
self.assertIs(loaded_image, image)
self.assertFalse(reused)
self.assertEqual(image.name, "Clipboard")
self.assertTrue(image.packed)
def test_sets_and_reuses_stencil_brush_texture(self):
textures = FakeTextures()
original_data = self.actions.bpy.data
@@ -865,6 +883,28 @@ class FakeImage:
return self.properties.get(property_name, default)
class FakeLoadedImage(dict):
def __init__(self):
super().__init__()
self.name = None
self.packed = False
def pack(self):
self.packed = True
class FakeLoadedImages(list):
def __init__(self, loaded_image):
super().__init__()
self.loaded_image = loaded_image
def load(self, _path, check_existing):
return self.loaded_image
def remove(self, image):
super().remove(image)
class FakeTexture(dict):
def __init__(self, name, texture_type):
super().__init__()

View File

@@ -47,29 +47,27 @@ def cutout_source(name="Source", size=(2048, 1024)):
return SimpleNamespace(name=name, data=SimpleNamespace(size=size))
def test_cutout_pie_uses_four_semantic_shapes_in_cardinal_order():
def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
window_manager = WindowManager()
context = cutout_context(window_manager)
context = cutout_context(window_manager, generate_normal=True)
source = cutout_source()
monkeypatch.setattr(interaction, "ai_ready", lambda: False)
interaction.open_shape_pie(
context,
object(),
source,
RegionPath(points=((0, 0), (10, 0), (0, 10))),
refine_region=True,
)
assert [call[2].shape for call in window_manager.pie.calls] == [
"SURFACE",
"BALLOON",
"DEPTH_BALLOON",
"DEPTH_SURFACE",
]
assert [call[1]["text"] for call in window_manager.pie.calls] == [
"Surface",
"Balloon",
"Depth Balloon",
"Depth Surface",
]
assert all(
call[0] == "anyimage.cutout_region_to_shape"
@@ -80,10 +78,11 @@ def test_cutout_pie_uses_four_semantic_shapes_in_cardinal_order():
assert all(call[2].mesh_resolution == 64 for call in window_manager.pie.calls)
def test_cutout_pie_passes_image_lasso_options_to_every_shape():
def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
window_manager = WindowManager()
context = cutout_context(window_manager, point_spacing=16, generate_normal=True)
source = cutout_source(size=(4096, 2048))
monkeypatch.setattr(interaction, "ai_ready", lambda: True)
interaction.open_shape_pie(
context,
@@ -96,6 +95,12 @@ def test_cutout_pie_passes_image_lasso_options_to_every_shape():
refine_region=True,
)
assert [call[2].shape for call in window_manager.pie.calls] == [
"SURFACE",
"BALLOON",
"DEPTH_BALLOON",
"DEPTH_SURFACE",
]
assert all(call[2].refine_region for call in window_manager.pie.calls)
assert all(call[2].generate_normal for call in window_manager.pie.calls)
assert all(

View File

@@ -91,6 +91,49 @@ class ImageDataTest(unittest.TestCase):
PROJECT_ROOT / "src" / "anyimage" / "assets" / "O_AnyImage.blend",
)
def test_runtime_modifier_enum_input_uses_string_identifier(self):
enum_items = (
SimpleNamespace(identifier="SURFACE"),
SimpleNamespace(identifier="BALLOON"),
)
value_property = SimpleNamespace(type="ENUM", enum_items=enum_items)
input_slot = SimpleNamespace(
value=None,
bl_rna=SimpleNamespace(properties={"value": value_property}),
)
modifier = SimpleNamespace(
properties=SimpleNamespace(
inputs=SimpleNamespace(Shape=input_slot),
)
)
self.image_depth.set_modifier_input(modifier, "Shape", 1)
self.assertEqual(input_slot.value, "BALLOON")
def test_runtime_modifier_non_enum_and_legacy_inputs_keep_values(self):
value_property = SimpleNamespace(type="FLOAT")
input_slot = SimpleNamespace(
value=None,
bl_rna=SimpleNamespace(properties={"value": value_property}),
)
runtime_modifier = SimpleNamespace(
properties=SimpleNamespace(
inputs=SimpleNamespace(Thickness=input_slot),
)
)
self.image_depth.set_modifier_input(runtime_modifier, "Thickness", 2.0)
self.assertEqual(input_slot.value, 2.0)
class LegacyModifier(dict):
properties = None
legacy_modifier = LegacyModifier()
self.image_depth.set_modifier_input(legacy_modifier, "Shape", 1)
self.assertEqual(legacy_modifier["Shape"], 1)
def test_material_color_space_matches_scene_view_transform(self):
mappings = {
"ACES 1.3": "ACES 1.3 sRGB",