From 8d279e9ab32e0b6fe0135f9e6a797d8310ee8a8f Mon Sep 17 00:00:00 2001 From: nan Date: Wed, 2 Sep 2026 12:55:11 +0800 Subject: [PATCH] feat: Update AI environment labels and preferences for consistency --- .../correct-coreml-device-support/tasks.md | 2 +- src/anyimage/menu.py | 2 +- src/anyimage/operators/ai_setup.py | 4 +- src/anyimage/panel.py | 2 +- src/anyimage/preferences.py | 173 ++++++++---------- src/anyimage/properties.py | 2 +- tests/test_blender_addon.py | 143 ++++++++++++--- 7 files changed, 205 insertions(+), 123 deletions(-) diff --git a/openspec/changes/correct-coreml-device-support/tasks.md b/openspec/changes/correct-coreml-device-support/tasks.md index a5e2c4a..f69fc34 100644 --- a/openspec/changes/correct-coreml-device-support/tasks.md +++ b/openspec/changes/correct-coreml-device-support/tasks.md @@ -15,4 +15,4 @@ - [x] 3.1 更新用户设备说明和 `docs/internals/runtime.md`,统一使用 CoreML,并说明它由 Apple CPU、GPU 与 Neural Engine 调度而非 PyTorch MPS。 - [x] 3.2 运行 `uv run pytest tests/test_blender_addon.py tests/test_model_onnx_runtime.py tests/test_model_moge2_onnx.py tests/test_docs.py`。 -- [ ] 3.3 在可用的 M2 或 M4 设备选择 CoreML,分别完成 MoGe-2、BEN2 和 Upscale 的单次 smoke test,确认 MoGe-2 不再出现 CoreML 计划构建或预测执行错误。 +- [x] 3.3 在可用的 M2 或 M4 设备选择 CoreML,分别完成 MoGe-2、BEN2 和 Upscale 的单次 smoke test,确认 MoGe-2 不再出现 CoreML 计划构建或预测执行错误。 diff --git a/src/anyimage/menu.py b/src/anyimage/menu.py index 6f9ce3b..e0b46a3 100644 --- a/src/anyimage/menu.py +++ b/src/anyimage/menu.py @@ -39,7 +39,7 @@ class AnyImageImageMenu(bpy.types.Menu): if status["ready"]: layout.operator( "anyimage.open_ai_environment_settings", - text="AI Environment Settings", + text="Settings", icon="PREFERENCES", ) else: diff --git a/src/anyimage/operators/ai_setup.py b/src/anyimage/operators/ai_setup.py index adc0421..800e85a 100644 --- a/src/anyimage/operators/ai_setup.py +++ b/src/anyimage/operators/ai_setup.py @@ -17,7 +17,7 @@ def format_download_size(size): class SetupAIEnvironment(bpy.types.Operator): bl_idname = "anyimage.setup_ai_environment" - bl_label = "Set Up AI Environment" + bl_label = "Set Up AI Server" bl_description = "Install the environment and required AI models" use_mirror: bpy.props.BoolProperty( @@ -106,7 +106,7 @@ class SetupAIEnvironment(bpy.types.Operator): class OpenAIEnvironmentSettings(bpy.types.Operator): bl_idname = "anyimage.open_ai_environment_settings" - bl_label = "AI Environment Settings" + bl_label = "Settings" bl_description = "Open the AnyImage add-on preferences" def execute(self, context): diff --git a/src/anyimage/panel.py b/src/anyimage/panel.py index 0460a13..4c01577 100644 --- a/src/anyimage/panel.py +++ b/src/anyimage/panel.py @@ -27,7 +27,7 @@ class ServerPanel(bpy.types.Panel): setup_row.enabled = not busy setup_row.operator( "anyimage.setup_ai_environment", - text="Set Up AI Environment…", + text="Set Up AI Server…", icon="IMPORT", ) return diff --git a/src/anyimage/preferences.py b/src/anyimage/preferences.py index 1b30144..252346a 100644 --- a/src/anyimage/preferences.py +++ b/src/anyimage/preferences.py @@ -13,6 +13,7 @@ from .server.model_catalog import ( ) DEFAULT_MOGE2_MODEL_KEY = "MOGE2_VITS_NORMAL" +DEFAULT_BACKGROUND_MODEL_KEY = "BEN2_BASE" DEFAULT_MAX_CUTOUT_MESH_RESOLUTION = 1024 MIN_CUTOUT_MESH_RESOLUTION = 4 MAX_CUTOUT_MESH_RESOLUTION = 1024 @@ -24,6 +25,14 @@ UPSCALE_MODEL_ITEMS = tuple( for index, model in enumerate(UPSCALE_MODELS.values()) ) +BACKGROUND_MODEL_ITEMS = ( + ( + DEFAULT_BACKGROUND_MODEL_KEY, + "BEN2 Base", + "Model used for AI Remove Background", + ), +) + BALLOON_PROFILE_ALGORITHM_ITEMS = ( ( "POISSON", @@ -104,8 +113,8 @@ class AnyImagePreferences(bpy.types.AddonPreferences): default=0, ) upscale_model: bpy.props.EnumProperty( - name="Default Upscale Model", - description="Model used when starting a new Upscale operation", + name="Upscale Image Model", + description="Model used to upscale images", items=UPSCALE_MODEL_ITEMS, default=DEFAULT_UPSCALE_MODEL_KEY, ) @@ -136,9 +145,15 @@ class AnyImagePreferences(bpy.types.AddonPreferences): items=BALLOON_PROFILE_ALGORITHM_ITEMS, default="POISSON", ) + background_model: bpy.props.EnumProperty( + name="Remove Background Model", + description="Model used to remove image backgrounds", + items=BACKGROUND_MODEL_ITEMS, + default=DEFAULT_BACKGROUND_MODEL_KEY, + ) geometry_model: bpy.props.EnumProperty( - name="Depth & Normal Model", - description="MoGe-2 model used for geometry and normal generation", + name="Generate Depth Map Model", + description="Model used to generate depth maps", items=geometry_model_items, default=0, ) @@ -158,14 +173,11 @@ class AnyImagePreferences(bpy.types.AddonPreferences): status = ai_status() models = {model["key"]: model for model in model_catalog()} - self._draw_section(layout, "Environment") - layout.prop(self, "storage_root") - layout.prop(self, "device") - layout.prop(self, "max_ai_input_size") - layout.label(text=f"Environment: {runtime.environment_directory()}") + server = self._draw_box(layout, "Server") + server.prop(self, "storage_root") + server_row = server.row(align=True) if not status["environment_ready"]: - layout.label(text="Environment is not installed", icon="ERROR") - setup_row = layout.row() + setup_row = server_row.row(align=True) setup_row.enabled = not runtime.server_busy() setup_row.operator( "anyimage.setup_ai_environment", @@ -173,106 +185,83 @@ class AnyImagePreferences(bpy.types.AddonPreferences): icon="IMPORT", ) else: - layout.label(text="Environment is installed", icon="CHECKMARK") - if not status["ready"]: - required_row = layout.row() - required_row.enabled = not runtime.server_busy() - required_row.operator( - "anyimage.setup_ai_environment", - text=ai_setup_label(status), - icon="IMPORT", - ) - - self._draw_section(layout, "Diagnostics") - layout.operator( + server_row.label(text="Installed", icon="CHECKMARK") + server_row.operator( "anyimage.open_server_log", text="Open Server Log", icon="TEXT", ) - section = self._draw_panel(layout, "depth_normal", "Depth & Normal") - if section is not None: - self._draw_depth_normal( - section, + if status["environment_ready"]: + ai = self._draw_box(layout, "AI") + ai.prop(self, "device") + ai.prop(self, "max_ai_input_size") + ai.separator(type="LINE") + self._draw_model_property( + ai, + "geometry_model", models, - status["environment_ready"], + DEFAULT_MOGE2_MODEL_KEY, + "AI Generate Depth Map", ) - - section = self._draw_panel( - layout, - "remove_background", - "Remove Background", - ) - if section is not None: - self._draw_model( - section, - models.get("BEN2_BASE"), - status["environment_ready"], - ) - - section = self._draw_panel(layout, "upscale", "Upscale") - if section is not None: - self._draw_upscale( - section, + self._draw_model_property( + ai, + "background_model", models, - status["environment_ready"], + DEFAULT_BACKGROUND_MODEL_KEY, + "AI Remove Background", + ) + self._draw_model_property( + ai, + "upscale_model", + models, + DEFAULT_UPSCALE_MODEL_KEY, + "AI Upscale Image", ) - section = self._draw_panel(layout, "cutout", "Cutout") - if section is not None: - section.prop(self, "max_cutout_mesh_resolution") - section.prop(self, "balloon_profile_algorithm") + tools = self._draw_box(layout, "Tools") + tools.label(text="Depth & Normal") + tools.prop(self, "geometry_resolution_level") + tools.separator(type="LINE") + tools.label(text="Cutout Tool") + tools.prop(self, "max_cutout_mesh_resolution") + tools.prop(self, "balloon_profile_algorithm") - def _draw_depth_normal(self, layout, models, environment_ready): - layout.prop(self, "geometry_model") - geometry_model = getattr(self, "geometry_model", DEFAULT_MOGE2_MODEL_KEY) - if geometry_model not in models: - geometry_model = DEFAULT_MOGE2_MODEL_KEY - self._draw_model( - layout, - models.get(geometry_model), - environment_ready, - ) - layout.prop(self, "geometry_resolution_level") + @staticmethod + def _draw_box(layout, label): + box = layout.box() + box.label(text=label) + return box - def _draw_upscale(self, layout, models, environment_ready): - layout.prop(self, "upscale_model") - upscale_model = getattr(self, "upscale_model", DEFAULT_UPSCALE_MODEL_KEY) - if upscale_model not in models: - upscale_model = DEFAULT_UPSCALE_MODEL_KEY - self._draw_model( - layout, - models.get(upscale_model), - environment_ready, + def _draw_model_property( + self, + layout, + property_name, + models, + default_model_key, + ready_label, + ): + model_key = getattr(self, property_name, default_model_key) + if model_key not in models: + model_key = default_model_key + split = layout.split(factor=0.58, align=True) + split.column(align=True).prop(self, property_name, text="") + self._draw_model_status( + split.column(align=True), + models.get(model_key), + ready_label, ) @staticmethod - def _draw_section(layout, label): - layout.separator(type="LINE") - layout.label(text=label) - - @staticmethod - def _draw_panel(layout, panel_id, label): - header, body = layout.panel( - f"anyimage_preferences_{panel_id}", - default_closed=False, - ) - header.label(text=label) - return body - - def _draw_model(self, layout, model, environment_ready=True): + def _draw_model_status(layout, model, ready_label): from .runtime import runtime - if model is None: - layout.label(text="Model is unavailable", icon="ERROR") - return row = layout.row(align=True) - row.label(text=model["label"]) - if model.get("ready"): - row.label(text="Ready", icon="CHECKMARK") + if model is None: + row.label(text="Unavailable", icon="ERROR") return - if not environment_ready: - row.label(text="Install Environment First", icon="INFO") + if model.get("ready"): + row.label(text=ready_label, icon="CHECKMARK") return download_row = row.row(align=True) download_row.enabled = not runtime.server_busy() diff --git a/src/anyimage/properties.py b/src/anyimage/properties.py index bcb25ea..51be2ff 100644 --- a/src/anyimage/properties.py +++ b/src/anyimage/properties.py @@ -113,7 +113,7 @@ def ai_setup_label(status=None): return ( "Download Required Models…" if status["environment_ready"] - else "Set Up AI Environment…" + else "Set Up AI Server…" ) diff --git a/tests/test_blender_addon.py b/tests/test_blender_addon.py index 5e0494c..11a34ab 100644 --- a/tests/test_blender_addon.py +++ b/tests/test_blender_addon.py @@ -690,7 +690,7 @@ class BlenderAddonTest(unittest.TestCase): status = properties.ai_status() self.assertFalse(status["ready"]) self.assertEqual( - properties.ai_setup_label(status), "Set Up AI Environment…" + properties.ai_setup_label(status), "Set Up AI Server…" ) def test_setup_ai_environment_selects_the_needed_install_step(self): @@ -741,6 +741,10 @@ class BlenderAddonTest(unittest.TestCase): self.ai_setup.OpenAIEnvironmentSettings, self.anyimage.CLASSES, ) + self.assertEqual(self.ai_setup.SetupAIEnvironment.bl_label, "Set Up AI Server") + self.assertEqual( + self.ai_setup.OpenAIEnvironmentSettings.bl_label, "Settings" + ) def test_global_ai_input_size_defaults_and_clamps(self): preference_module = self.anyimage.preferences @@ -785,7 +789,7 @@ class BlenderAddonTest(unittest.TestCase): downloads = [] properties = [] labels = [] - panels = [] + boxes = [] class Layout: enabled = True @@ -802,9 +806,15 @@ class BlenderAddonTest(unittest.TestCase): def row(self, **_options): return self - def panel(self, panel_id, **options): - panels.append((panel_id, options.get("default_closed"))) - return self, self + def split(self, **_options): + return self + + def column(self, **_options): + return self + + def box(self): + boxes.append(self) + return self def operator(self, identifier, **_options): operator = SimpleNamespace(model="") @@ -815,6 +825,7 @@ class BlenderAddonTest(unittest.TestCase): preferences = preference_module.AnyImagePreferences() preferences.layout = Layout() preferences.geometry_model = "MOGE2_VITS_NORMAL" + preferences.background_model = "BEN2_BASE" preferences.upscale_model = "SAFMN_REAL_X4" catalog = tuple( { @@ -861,33 +872,24 @@ class BlenderAddonTest(unittest.TestCase): for label in labels if label in { - "Environment", + "Server", + "AI", + "Tools", "Depth & Normal", - "Cutout", - "Remove Background", - "Upscale", - "Diagnostics", + "Cutout Tool", } ], [ - "Environment", - "Diagnostics", + "Server", + "AI", + "Tools", "Depth & Normal", - "Remove Background", - "Upscale", - "Cutout", - ], - ) - self.assertEqual( - panels, - [ - ("anyimage_preferences_depth_normal", False), - ("anyimage_preferences_remove_background", False), - ("anyimage_preferences_upscale", False), - ("anyimage_preferences_cutout", False), + "Cutout Tool", ], ) + self.assertEqual(len(boxes), 3) self.assertNotIn("Normal Model", labels) + self.assertIn("background_model", properties) self.assertIn("upscale_model", properties) self.assertIn("max_ai_input_size", properties) self.assertIn("max_cutout_mesh_resolution", properties) @@ -904,7 +906,98 @@ class BlenderAddonTest(unittest.TestCase): preference_module.AnyImagePreferences.__annotations__["upscale_model"][ "name" ], - "Default Upscale Model", + "Upscale Image Model", + ) + self.assertEqual( + preference_module.AnyImagePreferences.__annotations__["geometry_model"][ + "name" + ], + "Generate Depth Map Model", + ) + self.assertEqual( + preference_module.AnyImagePreferences.__annotations__["background_model"][ + "name" + ], + "Remove Background Model", + ) + + def test_ready_models_show_their_ai_feature(self): + preference_module = importlib.import_module("anyimage.preferences") + labels = [] + + class Layout: + def row(self, **_options): + return self + + def label(self, **options): + labels.append((options.get("text"), options.get("icon"))) + + preferences = preference_module.AnyImagePreferences() + model = {"ready": True} + for ready_label in ( + "AI Generate Depth Map", + "AI Remove Background", + "AI Upscale Image", + ): + preferences._draw_model_status(Layout(), model, ready_label) + + self.assertEqual( + labels, + [ + ("AI Generate Depth Map", "CHECKMARK"), + ("AI Remove Background", "CHECKMARK"), + ("AI Upscale Image", "CHECKMARK"), + ], + ) + + def test_preferences_hide_ai_until_server_is_installed(self): + preference_module = importlib.import_module("anyimage.preferences") + labels = [] + operators = [] + + class Layout: + enabled = True + + def box(self): + return self + + def label(self, **options): + labels.append(options.get("text")) + + def prop(self, *_args, **_options): + return None + + def row(self, **_options): + return self + + def separator(self, **_options): + return None + + def operator(self, identifier, **_options): + operators.append(identifier) + return SimpleNamespace(model="") + + preferences = preference_module.AnyImagePreferences() + preferences.layout = Layout() + with ( + patch.object( + self.anyimage.properties, + "ai_status", + return_value={ + "environment_ready": False, + "missing_models": (), + "ready": False, + }, + ), + patch.object(self.anyimage.properties, "model_catalog", return_value=()), + patch.object(self.anyimage.runtime, "server_busy", return_value=False), + ): + preferences.draw(None) + + self.assertEqual(labels, ["Server", "Tools", "Depth & Normal", "Cutout Tool"]) + self.assertEqual( + operators, + ["anyimage.setup_ai_environment", "anyimage.open_server_log"], ) def test_semantic_mesh_operators_are_registered_without_dialog_options(self):