import importlib import json import sys import tempfile import threading import unittest from pathlib import Path from types import ModuleType, SimpleNamespace from unittest.mock import patch PROJECT_ROOT = Path(__file__).resolve().parents[1] SOURCE_ROOT = PROJECT_ROOT / "src" class BlenderAddonTest(unittest.TestCase): @classmethod def setUpClass(cls): cls.remove_modules() def append_status_bar(draw): draw._owner = "anyimage" cls.status_bar_draws.append(draw) fake_bpy = ModuleType("bpy") fake_bpy.props = SimpleNamespace( BoolProperty=lambda **options: options, EnumProperty=lambda **options: options, FloatProperty=lambda **options: options, FloatVectorProperty=lambda **options: options, IntProperty=lambda **options: options, PointerProperty=lambda **options: options, StringProperty=lambda **options: options, ) fake_bpy.types = SimpleNamespace( AddonPreferences=object, Menu=object, Operator=object, Panel=object, PropertyGroup=object, WorkSpaceTool=object, Image=type("Image", (), {}), Scene=type("Scene", (), {}), STATUSBAR_HT_header=SimpleNamespace( append=append_status_bar, remove=lambda draw: cls.status_bar_draws.remove(draw), ), VIEW3D_MT_object_context_menu=SimpleNamespace( append=lambda draw: cls.context_menu_draws.append(draw), prepend=lambda draw: cls.context_menu_draws.insert(0, draw), remove=lambda draw: cls.context_menu_draws.remove(draw), ), OUTLINER_MT_object=SimpleNamespace( append=lambda draw: cls.outliner_menu_draws.append(draw), prepend=lambda draw: cls.outliner_menu_draws.insert(0, draw), remove=lambda draw: cls.outliner_menu_draws.remove(draw), ), ) cls.registered = [] cls.registered_tools = [] cls.status_bar_draws = [] cls.context_menu_draws = [] cls.outliner_menu_draws = [] fake_bpy.utils = SimpleNamespace( register_class=cls.registered.append, unregister_class=cls.registered.remove, register_tool=lambda tool, **options: cls.registered_tools.append( (tool, options) ), unregister_tool=lambda tool: cls.registered_tools.remove( next(item for item in cls.registered_tools if item[0] is tool) ), ) fake_bpy.context = SimpleNamespace( preferences=SimpleNamespace(addons={}), ) fake_bpy.app = SimpleNamespace(online_access=True) fake_bpy.path = SimpleNamespace(abspath=lambda path: path) fake_bpy.data = SimpleNamespace( objects=SimpleNamespace(get=lambda _name: None), ) sys.modules["bpy"] = fake_bpy sys.path.insert(0, str(SOURCE_ROOT)) cls.paths_before_import = tuple(sys.path) cls.anyimage = importlib.import_module("anyimage") cls.runtime = importlib.import_module("anyimage.runtime") cls.ai_setup = importlib.import_module("anyimage.operators.ai_setup") cls.ai = importlib.import_module("anyimage.common.ai") cls.image_data = importlib.import_module("anyimage.common.image") cls.debug_operators = importlib.import_module("anyimage.operators.debug") cls.convert_to_plane = importlib.import_module( "anyimage.operators.convert_to_plane.conversion" ) cls.cutout_main = importlib.import_module( "anyimage.operators.cutout_tool.main" ) cls.remove_background = importlib.import_module( "anyimage.operators.remove_background" ) cls.upscale = importlib.import_module("anyimage.operators.upscale") cls.image_interaction = importlib.import_module("anyimage.common.viewport") cls.edit_mask = importlib.import_module( "anyimage.operators.image_tool.edit_mask" ) cls.image_selection = importlib.import_module("anyimage.common.selection") cls.image_box = importlib.import_module("anyimage.operators.image_tool.box") cls.image_lasso = importlib.import_module("anyimage.operators.image_tool.lasso") cls.image_line = importlib.import_module("anyimage.operators.image_tool.line") cls.image_polyline = importlib.import_module( "anyimage.operators.image_tool.polyline" ) cls.image_perspective = importlib.import_module( "anyimage.operators.perspective_tool.edit_perspective" ) cls.cutout_geometry = importlib.import_module( "anyimage.operators.cutout_tool.geometry" ) cls.paths_after_import = tuple(sys.path) cls.fake_bpy = fake_bpy @classmethod def tearDownClass(cls): sys.path.remove(str(SOURCE_ROOT)) sys.modules.pop("bpy", None) cls.remove_modules() @staticmethod def remove_modules(): for module_name in tuple(sys.modules): if module_name == "anyimage" or module_name.startswith("anyimage."): sys.modules.pop(module_name) def setUp(self): preferences = SimpleNamespace( storage_root="", device="CUDA", upscale_model="REALESRGAN_GENERAL_WDN_X4V3", max_ai_input_size=2048, ) self.fake_bpy.context.preferences.addons = { "anyimage": SimpleNamespace(preferences=preferences) } def tearDown(self): self.anyimage.runtime.close_active() self.registered.clear() self.registered_tools.clear() self.status_bar_draws.clear() self.context_menu_draws.clear() self.outliner_menu_draws.clear() self.fake_bpy.data = SimpleNamespace( objects=SimpleNamespace(get=lambda _name: None), ) if hasattr(self.fake_bpy.types.Scene, "anyimage_settings"): del self.fake_bpy.types.Scene.anyimage_settings if hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"): del self.fake_bpy.types.Image.anyimage_upscale_source def test_mps_requires_m4_or_newer(self): properties = self.anyimage.properties parse = properties.parse_apple_silicon_generation self.assertEqual(parse("Apple M2"), 2) self.assertEqual(parse("Apple M4 Pro"), 4) self.assertEqual(parse("Apple M10 Max"), 10) self.assertIsNone(parse("Intel(R) Core(TM) i9")) self.assertFalse(properties.supports_mps(3)) self.assertTrue(properties.supports_mps(4)) def test_unsupported_mac_hides_manifest_mps_device(self): properties = self.anyimage.properties manifest = {"devices": ["mps", "cpu"]} with ( patch.object( self.anyimage.runtime, "environment_manifest", return_value=manifest, ), patch.object(properties, "supports_mps", return_value=False), ): self.assertEqual(properties.supported_devices(), ["cpu"]) with ( patch.object( self.anyimage.runtime, "environment_manifest", return_value=manifest, ), patch.object(properties, "supports_mps", return_value=True), ): self.assertEqual(properties.supported_devices(), ["mps", "cpu"]) def test_windows_keeps_default_directml_device(self): properties = self.anyimage.properties with ( patch.object( self.anyimage.runtime, "environment_manifest", return_value={}, ), patch.object(properties.sys, "platform", "win32"), ): self.assertEqual( properties.supported_devices(), ["directml", "cpu"], ) def test_import_does_not_add_addon_directories_to_sys_path(self): self.assertEqual(self.paths_after_import, self.paths_before_import) def test_operator_properties_use_plain_names(self): operators = self.anyimage.operators for operator in operators.CLASSES: for name in getattr(operator, "__annotations__", {}): self.assertFalse(name.startswith("anyimage_"), (operator, name)) def test_concrete_job_operator_is_the_blender_operator(self): for operator, identifier in ( (self.anyimage.operators.GenerateDepthPlane, "anyimage.generate_depth_plane"), ( self.anyimage.operators.CutoutSelectionToShape, "anyimage.cutout_selection_to_shape", ), ): self.assertEqual(operator.bl_idname, identifier) self.assertIn(self.runtime.JobOperatorBase, operator.__mro__) self.assertIn(self.fake_bpy.types.Operator, operator.__mro__) def test_upscale_debug_operator_accepts_each_catalog_model(self): with tempfile.TemporaryDirectory() as directory: image_path = Path(directory) / "input.png" image_path.touch() settings = SimpleNamespace( input_path=str(image_path), device="CPU", video_frames=3, ) context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings)) for model_key in self.anyimage.properties.shared_model_catalog.UPSCALE_MODELS: operator = self.debug_operators.DebugUpscale() operator.model = model_key with ( self.subTest(model=model_key), patch.object(self.debug_operators, "require_environment"), patch.object(self.debug_operators, "require_model") as require_model, ): request = operator.request(context) require_model.assert_called_once_with(model_key) self.assertEqual(request["model"], model_key) self.assertEqual(request["input"], str(image_path)) self.assertIn(self.debug_operators.DebugUpscale, self.anyimage.CLASSES) panel = importlib.import_module("anyimage.panel") self.assertIn(panel.UpscaleDebugPanel, self.anyimage.CLASSES) def test_default_request_sends_every_operator_property(self): class Example(self.runtime.JobOperatorBase): value: object() enabled: object() operator = Example() operator.value = 42 operator.enabled = True self.assertEqual( operator.request(object()), {"value": 42, "enabled": True}, ) def test_overridden_request_purely_replaces_default_properties(self): class Example(self.runtime.JobOperatorBase): value: object() def request(self, context): return {"replacement": context.value} operator = Example() operator.value = 42 self.assertEqual( operator.request(SimpleNamespace(value=7)), {"replacement": 7}, ) def test_runtime_submits_job_type_and_plain_dict(self): calls = [] submitted = threading.Event() class Controller: def submit(self, job_type, parameters): calls.append((job_type, parameters)) submitted.set() return {"job_id": "job", "directory": "."} def cancel(self, _job_id): pass def mark_job_complete(self, _job_id): pass class Example(self.runtime.JobOperatorBase): job_type = "example" value: object() def controller(self, _runtime): return Controller() timers = [] window_manager = SimpleNamespace( progress_begin=lambda *_args: None, progress_update=lambda _value: None, event_timer_add=lambda *_args, **_kwargs: ( timers.append(object()) or timers[-1] ), event_timer_remove=lambda _timer: None, modal_handler_add=lambda _operator: None, ) context = SimpleNamespace( window_manager=window_manager, window=object(), ) operator = Example() operator.value = 9 self.assertEqual(operator.execute(context), {"RUNNING_MODAL"}) self.assertTrue(submitted.wait(1.0)) self.assertEqual(calls, [("example", {"value": 9})]) def test_runtime_owns_environment_and_storage_paths(self): runtime = self.anyimage.runtime with tempfile.TemporaryDirectory() as directory: root = Path(directory) with patch.object(runtime, "storage_root_factory", root): self.assertEqual(runtime.storage_root(), root.resolve()) self.assertEqual(runtime.environment_directory(), root / ".venv") self.assertEqual( runtime.install_status_path(), root / "install-status.json" ) self.assertEqual(runtime.install_log_path(), root / "install.log") self.assertEqual(runtime.server_log_path(), root / "server.log") def test_runtime_checks_its_environment_dictionary_hash(self): runtime = self.anyimage.runtime with tempfile.TemporaryDirectory() as directory: root = Path(directory) python = ( root / ".venv" / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") ) python.parent.mkdir(parents=True) python.touch() (root / "manifest.json").write_text( json.dumps({"environment_hash": runtime.environment_hash}), encoding="utf-8", ) with patch.object(runtime, "storage_root_factory", root): self.assertTrue(runtime.environment_ready()) def test_runtime_builds_the_standard_installer_command(self): runtime = self.anyimage.runtime with tempfile.TemporaryDirectory() as directory: root = Path(directory) with patch.object(runtime, "storage_root_factory", root): command = runtime.install_command() self.assertEqual(command[0], sys.executable) self.assertEqual(Path(command[1]).name, "environment.py") self.assertEqual(Path(command[1]).parent.name, "installer") self.assertIn("--storage-root", command) self.assertIn("--config", command) self.assertNotIn("--venv-dir", command) self.assertNotIn("--models-dir", command) self.assertNotIn("--stages", command) def test_runtime_exposes_declared_environment_and_post_install(self): runtime = self.anyimage.runtime self.assertEqual(runtime.environment["python"], "3.12") self.assertIn("numpy==2.4.2", runtime.environment["packages"]) self.assertFalse( any( "blendjob" in package.lower() for package in runtime.environment["packages"] ) ) self.assertTrue(callable(runtime.post_install)) def test_anyimage_post_install_is_ordinary_code_using_runtime_request(self): calls = [] fake_runtime = SimpleNamespace( request=lambda job_type, parameters: calls.append((job_type, parameters)) ) self.runtime.post_install(fake_runtime) self.assertEqual( calls, [ ("download-model", {"model": "MOGE2_VITS_NORMAL"}), ("download-model", {"model": "BEN2_BASE"}), ( "download-model", {"model": "REALESRGAN_GENERAL_WDN_X4V3"}, ), ], ) def test_anyimage_post_install_names_each_failed_model(self): calls = [] def request(_job_type, parameters): calls.append(parameters["model"]) raise RuntimeError("network unavailable") with self.assertRaisesRegex( RuntimeError, ( "MoGe-2 ViT-S Normal: network unavailable; " "BEN2 Base: network unavailable; " "Real-ESRGAN General WDN x4v3: network unavailable" ), ): self.runtime.post_install(SimpleNamespace(request=request)) self.assertEqual( calls, [ "MOGE2_VITS_NORMAL", "BEN2_BASE", "REALESRGAN_GENERAL_WDN_X4V3", ], ) def test_environment_controller_runs_arbitrary_post_install_callable(self): runtime_module = importlib.import_module("blendjob.runtime") runtime = self.anyimage.runtime calls = [] started = threading.Event() release = threading.Event() def post_install(received): calls.append(received) started.set() release.wait(1.0) controller = runtime_module.EnvironmentController(runtime) controller.process = SimpleNamespace(poll=lambda: 0, returncode=0) with ( tempfile.TemporaryDirectory() as directory, patch.object( runtime, "post_install", post_install, ), patch.object( runtime, "install_status_path", return_value=Path(directory) / "missing.json", ), ): self.assertEqual(controller.status("environment")["state"], "running") self.assertTrue(started.wait(1.0)) release.set() controller.post_thread.join(1.0) status = controller.status("environment") self.assertEqual(calls, [runtime]) self.assertEqual(status["state"], "succeeded") def test_environment_controller_reports_post_install_failure(self): runtime_module = importlib.import_module("blendjob.runtime") runtime = self.anyimage.runtime def post_install(_runtime): raise RuntimeError("BEN2 download failed") controller = runtime_module.EnvironmentController(runtime) controller.process = SimpleNamespace(poll=lambda: 0, returncode=0) with ( tempfile.TemporaryDirectory() as directory, patch.object( runtime, "post_install", post_install, ), patch.object( runtime, "install_status_path", return_value=Path(directory) / "missing.json", ), ): controller.status("environment") controller.post_thread.join(1.0) status = controller.status("environment") self.assertEqual(status["state"], "failed") self.assertEqual(status["error"], "BEN2 download failed") def test_post_install_progress_resets_without_stage_fraction(self): runtime_module = importlib.import_module("blendjob.runtime") controller = runtime_module.EnvironmentController(self.anyimage.runtime) controller._update_post_progress( {"job_id": "first", "job_type": "download-model", "progress": 1.0} ) self.assertEqual(controller.post_status["job_id"], "first") controller._update_post_progress( {"job_id": "second", "job_type": "download-model", "progress": 0.1} ) self.assertEqual(controller.post_status["job_id"], "second") self.assertEqual(controller.post_status["progress"], 0.1) self.assertNotIn("stage", controller.post_status) self.assertNotIn("stages", controller.post_status) def test_production_geometry_models_exclude_da3(self): properties = importlib.import_module("anyimage.properties") catalog = ( { "key": "SMALL", "family": "da3", "enabled": True, "label": "DA3 Small", "description": "DA3", "license": "Apache", }, { "key": "BASE", "family": "da3", "enabled": True, "label": "DA3 Base", "description": "DA3", "license": "Apache", }, { "key": "MOGE2_VITS_NORMAL", "family": "moge2", "enabled": True, "label": "MoGe-2 ViT-S", "description": "MoGe", "license": "MIT", }, { "key": "MOGE2_VITB_NORMAL", "family": "moge2", "enabled": True, "label": "MoGe-2 ViT-B", "description": "MoGe", "license": "MIT", }, { "key": "MOGE2_VITL_NORMAL", "family": "moge2", "enabled": True, "label": "MoGe-2 ViT-L", "description": "MoGe", "license": "MIT", }, ) with patch.object(properties, "model_catalog", return_value=catalog): depth_items = properties.depth_plane_model_enum_items(None, None) mesh_depth_items = properties.geometry_model_enum_items(None, None) expected = ( "MOGE2_VITS_NORMAL", "MOGE2_VITB_NORMAL", "MOGE2_VITL_NORMAL", ) self.assertEqual(tuple(item[0] for item in depth_items[:-1]), expected) self.assertEqual(tuple(item[0] for item in mesh_depth_items), expected) expected_labels = ( "MoGe-2 ViT-S", "MoGe-2 ViT-B", "MoGe-2 ViT-L", ) self.assertEqual( tuple(item[1] for item in depth_items[:-1]), expected_labels ) self.assertEqual(tuple(item[1] for item in mesh_depth_items), expected_labels) def test_required_model_operator_requests_both_models(self): operator = self.anyimage.operators.DownloadRequiredModels() with patch.object(self.ai_setup, "require_environment"): parameters = operator.request(None) self.assertEqual( parameters, { "models": [ "MOGE2_VITS_NORMAL", "BEN2_BASE", "REALESRGAN_GENERAL_WDN_X4V3", ] }, ) def test_ai_status_checks_environment_and_required_model_files(self): properties = self.anyimage.properties with ( patch.object(self.anyimage.runtime, "environment_ready", return_value=True), patch.object( properties, "model_catalog", return_value=( {"key": "MOGE2_VITS_NORMAL", "ready": True}, {"key": "BEN2_BASE", "ready": False}, { "key": "REALESRGAN_GENERAL_WDN_X4V3", "ready": True, }, ), ), ): status = properties.ai_status() self.assertTrue(status["environment_ready"]) self.assertEqual(status["missing_models"], ("BEN2_BASE",)) self.assertFalse(status["ready"]) self.assertEqual(properties.ai_setup_label(status), "Download Required Models…") with ( patch.object(self.anyimage.runtime, "environment_ready", return_value=False), patch.object( properties, "model_catalog", return_value=tuple( {"key": key, "ready": True} for key in properties.REQUIRED_MODEL_KEYS ), ), ): status = properties.ai_status() self.assertFalse(status["ready"]) self.assertEqual( properties.ai_setup_label(status), "Set Up AI Environment…" ) def test_setup_ai_environment_selects_the_needed_install_step(self): calls = [] ops = SimpleNamespace( anyimage=SimpleNamespace( install_environment=lambda *_args: ( calls.append("official") or {"FINISHED"} ), install_environment_mirror=lambda *_args: ( calls.append("mirror") or {"FINISHED"} ), download_required_models=lambda *_args: ( calls.append("models") or {"FINISHED"} ), ) ) operator = self.ai_setup.SetupAIEnvironment() with patch.object(self.fake_bpy, "ops", ops, create=True): operator.use_mirror = True with patch.object( self.ai_setup, "ai_status", return_value={ "environment_ready": False, "missing_models": ("MOGE2_VITS_NORMAL", "BEN2_BASE"), "ready": False, }, ): self.assertEqual(operator.execute(None), {"FINISHED"}) with patch.object( self.ai_setup, "ai_status", return_value={ "environment_ready": True, "missing_models": ("BEN2_BASE",), "ready": False, }, ): self.assertEqual(operator.execute(None), {"FINISHED"}) self.assertEqual(calls, ["mirror", "models"]) def test_ai_environment_ui_types_are_registered(self): self.assertIn(self.ai_setup.SetupAIEnvironment, self.anyimage.CLASSES) self.assertIn( self.ai_setup.OpenAIEnvironmentSettings, self.anyimage.CLASSES, ) def test_global_ai_input_size_defaults_and_clamps(self): preference_module = self.anyimage.preferences preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences self.assertEqual( preference_module.configured_max_ai_input_size(), 2048, ) preferences.max_ai_input_size = 10000 self.assertEqual( preference_module.configured_max_ai_input_size(), 8192, ) preferences.max_ai_input_size = 1 self.assertEqual( preference_module.configured_max_ai_input_size(), 256, ) def test_cutout_mesh_resolution_defaults_and_clamps_independently(self): preference_module = self.anyimage.preferences preferences = self.fake_bpy.context.preferences.addons["anyimage"].preferences self.assertEqual( preference_module.configured_max_cutout_mesh_resolution(), 1024, ) preferences.max_cutout_mesh_resolution = 2048 self.assertEqual( preference_module.configured_max_cutout_mesh_resolution(), 1024, ) preferences.max_cutout_mesh_resolution = 1 self.assertEqual( preference_module.configured_max_cutout_mesh_resolution(), 4, ) def test_preferences_draws_upscale_model_download(self): preference_module = importlib.import_module("anyimage.preferences") downloads = [] properties = [] labels = [] panels = [] class Layout: enabled = True def prop(self, _owner, name, **_options): properties.append(name) def separator(self, **_options): return None def label(self, **_options): labels.append(_options.get("text")) def row(self, **_options): return self def panel(self, panel_id, **options): panels.append((panel_id, options.get("default_closed"))) return self, self def operator(self, identifier, **_options): operator = SimpleNamespace(model="") if identifier == "anyimage.download_model": downloads.append(operator) return operator preferences = preference_module.AnyImagePreferences() preferences.layout = Layout() preferences.geometry_model = "MOGE2_VITS_NORMAL" preferences.upscale_model = "SAFMN_REAL_X4" catalog = tuple( { "key": key, "label": key, "family": family, "enabled": True, "ready": False, } for key, family in ( ("BASE", "da3"), ("MOGE2_VITS_NORMAL", "moge2"), ("BEN2_BASE", "ben2"), ("REALESRGAN_GENERAL_WDN_X4V3", "upscale"), ("SAFMN_REAL_X4", "upscale"), ) ) with ( patch.object( self.anyimage.properties, "ai_status", return_value={ "environment_ready": True, "missing_models": (), "ready": True, }, ), patch.object( self.anyimage.properties, "model_catalog", return_value=catalog, ), patch.object(self.anyimage.runtime, "server_busy", return_value=False), ): preferences.draw(None) self.assertEqual( [download.model for download in downloads], ["MOGE2_VITS_NORMAL", "BEN2_BASE", "SAFMN_REAL_X4"], ) self.assertEqual( [ label for label in labels if label in { "Environment", "Depth & Normal", "Cutout", "Remove Background", "Upscale", "Diagnostics", } ], [ "Environment", "Diagnostics", "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), ], ) self.assertNotIn("Normal Model", labels) self.assertIn("upscale_model", properties) self.assertIn("max_ai_input_size", properties) self.assertIn("max_cutout_mesh_resolution", properties) self.assertIn("balloon_profile_algorithm", properties) balloon_profile = preference_module.AnyImagePreferences.__annotations__[ "balloon_profile_algorithm" ] self.assertEqual(balloon_profile["default"], "POISSON") self.assertEqual( tuple(item[0] for item in balloon_profile["items"]), ("POISSON", "TEDDY"), ) self.assertEqual( preference_module.AnyImagePreferences.__annotations__["upscale_model"][ "name" ], "Default Upscale Model", ) def test_semantic_mesh_operators_are_registered_without_dialog_options(self): operator = self.convert_to_plane.ConvertToPlane self.assertIn(operator, self.anyimage.CLASSES) self.assertIn(self.convert_to_plane.ConvertToDepthPlane, self.anyimage.CLASSES) self.assertIn(self.cutout_main.CutoutSelectionToShape, self.anyimage.CLASSES) self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane") self.assertNotIn("shadeless", operator.__annotations__) self.assertNotIn("keep_original", operator.__annotations__) self.assertNotIn("invoke", operator.__dict__) self.assertNotIn("draw", operator.__dict__) self.assertIn("REGISTER", operator.bl_options) self.assertIn("UNDO", operator.bl_options) image_empty = SimpleNamespace( type="EMPTY", empty_display_type="IMAGE", data=object(), ) mesh_plane = SimpleNamespace( type="MESH", modifiers=[ SimpleNamespace(node_group=SimpleNamespace(name="O Mesh Plane")) ], ) plain_mesh = SimpleNamespace(type="MESH", modifiers=[]) self.assertTrue(operator.poll(SimpleNamespace(object=image_empty))) self.assertFalse(operator.poll(SimpleNamespace(object=mesh_plane))) self.assertFalse(operator.poll(SimpleNamespace(object=plain_mesh))) def test_plane_depth_off_accepts_animated_image_without_server(self): operator = SimpleNamespace( depth_mode="OFF", remove_background=False, shadeless=False, profile="SURFACE", mesh_detail=64, thickness=0.1, inflation=0.1, algorithm="POISSON", keep_original=False, ) source = SimpleNamespace(data=SimpleNamespace(name="Sequence")) context = SimpleNamespace(object=source, scene=object()) with ( patch.object(self.convert_to_plane, "video_total_frames", return_value=24), patch.object( self.convert_to_plane, "image_user_settings", return_value=(1, 0, 24, True), ), patch.object(self.convert_to_plane, "create_image_material", return_value="Material"), patch.object(self.convert_to_plane, "create_color_image", return_value="Color"), patch.object(self.convert_to_plane, "create_plane_object") as build_mesh, ): result = self.convert_to_plane.ConvertToPlane.execute(operator, context) self.assertEqual(result, {"FINISHED"}) build_mesh.assert_called_once() def test_depth_plane_request_includes_the_global_ai_input_limit(self): operator = self.convert_to_plane.GenerateDepthPlane() operator.input_path = __file__ operator.analysis_input_path = __file__ operator.source_object_name = "Source" operator.mesh_detail = 0 operator.model = self.anyimage.properties.DEFAULT_MOGE2_MODEL_KEY operator.resolution_level = 5 operator.max_input_size = 1536 context = SimpleNamespace( scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)) ) with ( patch.object(self.convert_to_plane, "require_environment"), patch.object(self.convert_to_plane, "require_model"), patch.object(self.convert_to_plane, "production_device", return_value="CPU"), patch.object(self.convert_to_plane, "source_start_frame", return_value=1), ): parameters = operator.request(context) self.assertEqual(parameters["max_input_size"], 1536) self.assertNotIn("shape", parameters) self.assertNotIn("polygon", parameters) def test_surface_and_balloon_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.cutout_main.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 ("SURFACE", "BALLOON"): 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, mesh_resolution=64, generate_normal=False, refine_selection=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, "video_total_frames", return_value=1 ), patch.object( self.cutout_main, "image_rgba", return_value=source_rgba ), patch.object( self.cutout_main, "rasterize_selection_path", return_value=selection_mask, ), patch.object( self.cutout_main, "configured_max_ai_input_size", return_value=2048, ), patch.object( self.cutout_main, "configured_max_cutout_mesh_resolution", return_value=1024, ), patch.object( self.cutout_main, "create_cutout_shape" ) as convert, ): result = self.cutout_main.CutoutSelectionToShape.execute( operator, context ) self.assertEqual(result, {"FINISHED"}) convert.assert_called_once_with( context, source, shape, 64, selection_mask, source_rgba=source_rgba, ) def test_cutout_request_describes_ai_capabilities(self): operator = self.cutout_main.CutoutSelectionToShape() operator.input_path = __file__ operator.source_object_name = "Source" operator.shape = "DEPTH_SURFACE" operator.model = self.anyimage.properties.DEFAULT_MOGE2_MODEL_KEY operator.resolution_level = 5 operator.max_ai_input_size = 2048 operator.generate_normal = True operator.refine_selection = 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, "production_device", return_value="CPU" ), patch.object(self.cutout_main, "source_start_frame", return_value=1), 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], [self.anyimage.properties.BEN2_MODEL_KEY, operator.model], ) self.assertTrue(parameters["refine_selection"]) self.assertEqual(parameters["depth_mode"], "VECTOR") self.assertEqual(parameters["normal_mode"], "OBJECT") self.assertNotIn("polygon", parameters) self.assertNotIn("shape", parameters) self.assertNotIn("normal_analysis", parameters) 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, ): settings = self.cutout_main.cutout_geometry_settings(None) self.assertEqual( settings, ("MOGE2_VITB_NORMAL", 7), ) def test_image_menu_keeps_ai_features_clickable_and_offers_models(self): calls = [] labels = {} class Layout: operator_context = "" enabled = True def operator(self, identifier, **options): calls.append((identifier, self.enabled)) labels[identifier] = options.get("text") return SimpleNamespace() def separator(self): calls.append(("separator", self.enabled)) menu = self.anyimage.AnyImageImageMenu() menu.layout = Layout() with patch( "anyimage.properties.ai_status", return_value={ "environment_ready": True, "missing_models": ("BEN2_BASE",), "ready": False, }, ): menu.draw( SimpleNamespace( object=SimpleNamespace( type="EMPTY", empty_display_type="IMAGE", data=SimpleNamespace(size=(640, 480)), ) ) ) self.assertEqual( calls, [ ("anyimage.convert_to_plane", True), ("anyimage.convert_to_depth_plane", True), ("separator", True), ("anyimage.remove_image_background", True), ("anyimage.upscale_image", True), ("separator", True), ("anyimage.setup_ai_environment", True), ], ) self.assertEqual( labels["anyimage.setup_ai_environment"], "Download Required Models…", ) self.assertEqual( labels["anyimage.upscale_image"], "Upscale (2560 × 1920)", ) def test_image_menu_opens_environment_settings_when_ai_is_ready(self): calls = [] class Layout: operator_context = "" enabled = True def operator(self, identifier, **_options): calls.append((identifier, self.enabled)) return SimpleNamespace() def separator(self): calls.append(("separator", self.enabled)) menu = self.anyimage.AnyImageImageMenu() menu.layout = Layout() with patch( "anyimage.properties.ai_status", return_value={ "environment_ready": True, "missing_models": (), "ready": True, }, ): menu.draw(None) self.assertIn(("anyimage.open_ai_environment_settings", True), calls) self.assertNotIn(("anyimage.setup_ai_environment", True), calls) def test_upscale_operator_defaults_to_wdn_and_sends_selected_model(self): operator_type = self.upscale.UpscaleImage self.assertIn(operator_type, self.anyimage.CLASSES) self.assertEqual( operator_type.__annotations__["model"]["default"], "REALESRGAN_GENERAL_WDN_X4V3", ) self.assertEqual( operator_type.__annotations__["max_input_size"]["default"], 2048, ) self.assertIn(self.runtime.JobOperatorBase, operator_type.__mro__) self.assertEqual(operator_type.bl_options, {"UNDO"}) operator = operator_type() operator.input_path = "input.png" operator.source_object_name = "Source" operator.model = "SAFMN_REAL_X4" operator.max_input_size = 1024 context = SimpleNamespace( scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=7)) ) with ( patch.object(self.upscale, "require_environment"), patch.object(self.upscale, "require_model") as require_model, patch.object( self.upscale, "require_input_path", return_value=Path("input.png") ), patch.object(self.upscale, "production_device", return_value="CUDA"), patch.object(self.upscale, "source_start_frame", return_value=4), ): request = operator.request(context) require_model.assert_called_once_with("SAFMN_REAL_X4") self.assertEqual( request, { "input": "input.png", "model": "SAFMN_REAL_X4", "device": "cuda", "video_frames": 7, "start_frame": 4, "max_input_size": 1024, }, ) def test_upscale_execute_reads_model_and_size_from_preferences(self): source_image = SimpleNamespace( name="Original", source="FILE", anyimage_upscale_source=None, ) source_object = SimpleNamespace( name="Source", type="EMPTY", empty_display_type="IMAGE", data=source_image, ) context = SimpleNamespace( object=source_object, scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)), ) operator = self.upscale.UpscaleImage() with ( patch.object( self.upscale, "configured_upscale_model", return_value="SAFMN_REAL_X4", ), patch.object( self.upscale, "configured_max_ai_input_size", return_value=1024, ), patch.object( self.upscale, "prepare_image_input", return_value=(Path("input.png"), False), ), patch.object( self.runtime.JobOperatorBase, "execute", return_value={"RUNNING_MODAL"}, ), ): result = operator.execute(context) self.assertEqual(result, {"RUNNING_MODAL"}) self.assertEqual(operator.model, "SAFMN_REAL_X4") self.assertEqual(operator.max_input_size, 1024) def test_upscale_operator_opens_setup_when_required_ai_is_missing(self): expected = {"CANCELLED"} with patch.object( self.upscale, "invoke_ai_setup_if_needed", return_value=expected, ) as setup: result = self.upscale.UpscaleImage().invoke(None, None) setup.assert_called_once_with() self.assertIs(result, expected) def test_upscale_operator_runs_immediately_when_ai_is_ready(self): operator = self.upscale.UpscaleImage() context = object() expected = {"FINISHED"} with ( patch.object( self.upscale, "invoke_ai_setup_if_needed", return_value=None, ), patch.object( operator, "execute", return_value=expected, ) as execute, ): result = operator.invoke(context, None) execute.assert_called_once_with(context) self.assertIs(result, expected) self.assertNotIn("REGISTER", operator.bl_options) def test_packed_image_export_does_not_use_scene_render_transform(self): with tempfile.TemporaryDirectory() as directory: output_directory = Path(directory) calls = [] class Image: file_format = "JPEG" def save(self, **options): calls.append(options) Path(options["filepath"]).write_bytes(b"raw image") image = Image() scene = SimpleNamespace( view_settings=SimpleNamespace(look="AgX - Medium High Contrast") ) with patch.object( self.image_data.tempfile, "mkdtemp", return_value=str(output_directory), ): output = self.image_data.save_packed_image(image, scene) self.assertEqual(output, output_directory / "input.png") self.assertEqual( calls, [{"filepath": str(output), "save_copy": True}], ) self.assertEqual(image.file_format, "JPEG") def test_cutout_contour_is_smooth_and_evenly_resampled(self): import numpy as np contour = self.cutout_geometry.smooth_selection_contour( ((0, 0), (3, 0), (4, 0), (4, 2), (0, 2)), 0.25, ) closed = np.vstack((contour, contour[0])) lengths = np.linalg.norm(np.diff(closed, axis=0), axis=1) self.assertGreater(len(contour), 20) self.assertGreater(self.cutout_geometry.contour_area(contour), 0.0) self.assertLess(float(lengths.std() / lengths.mean()), 0.15) def test_cutout_contour_smooths_pixel_stairs_before_coarse_resampling(self): import numpy as np staircase = [(0.0, 0.0)] for value in range(16): staircase.append((value + 1.0, float(value))) staircase.append((value + 1.0, value + 1.0)) staircase.append((0.0, 16.0)) smooth = self.cutout_geometry.smooth_selection_contour(staircase, 2.0) def diagonal_noise(points): points = np.asarray(points) selected = ( (points[:, 0] > 2.0) & (points[:, 0] < 14.0) & (np.abs(points[:, 1] - points[:, 0]) < 2.0) ) return float(np.std(points[selected, 1] - points[selected, 0])) self.assertLess(diagonal_noise(smooth), 0.05) def test_selection_mask_keeps_nonzero_winding_overlap(self): path = ( (1, 1), (7, 1), (7, 7), (1, 7), (1, 1), (4, 1), (10, 1), (10, 7), (4, 7), (4, 1), (1, 1), ) even_odd = self.image_selection.rasterize_selection_path( (12, 9), self.image_selection.SelectionPath(points=path, fill_rule="EVEN_ODD"), ).full_values((12, 9)) nonzero = self.image_selection.rasterize_selection_path( (12, 9), self.image_selection.SelectionPath(points=path, fill_rule="NONZERO"), ).full_values((12, 9)) self.assertFalse(even_odd[3, 5]) self.assertTrue(nonzero[3, 5]) def preview_area(fill_rule): triangles = self.image_interaction.scanline_fill_triangles(path, fill_rule) return sum( abs( self.image_interaction._triangle_cross( triangles[index], triangles[index + 1], triangles[index + 2], ) ) * 0.5 for index in range(0, len(triangles), 3) ) self.assertAlmostEqual(preview_area("EVEN_ODD"), 36.0) self.assertAlmostEqual(preview_area("NONZERO"), 54.0) def test_expected_empty_image_edits_are_reported_as_warnings(self): reports = [] operator = SimpleNamespace( report=lambda level, message: reports.append((level, message)) ) self.image_interaction.report_image_edit_exception( operator, self.image_interaction.ImageEditWarning( "The edit contains no visible image pixels" ), ) self.image_interaction.report_image_edit_exception( operator, ValueError("Unexpected invalid data"), ) self.assertEqual( reports, [ ( {"WARNING"}, "The edit contains no visible image pixels", ), ({"ERROR"}, "Unexpected invalid data"), ], ) def test_image_edit_tools_and_operators_are_registered(self): operator = self.edit_mask.EditImageMask self.assertIn(operator, self.anyimage.CLASSES) self.assertIn(self.edit_mask.RefineImageSelection, self.anyimage.CLASSES) self.assertIn(self.edit_mask.ActivateImageTool, self.anyimage.CLASSES) tool_cases = ( (self.image_box.ImageBoxTool, "BOX", "ops.sculpt.border_mask"), (self.image_lasso.ImageLassoTool, "LASSO", "ops.sculpt.lasso_mask"), (self.image_line.ImageLineTool, "LINE", "ops.sculpt.line_mask"), ( self.image_polyline.ImagePolylineTool, "POLYLINE", "ops.sculpt.polyline_mask", ), ) for tool, gesture, icon in tool_cases: self.assertNotIn(tool, self.anyimage.CLASSES) self.assertEqual(tool.bl_operator, operator.bl_idname) self.assertEqual(tool.bl_icon, icon) self.assertEqual(tool.bl_keymap[0][2]["properties"], [("gesture", gesture)]) self.assertNotIn("bl_cursor", tool.__dict__) self.assertIn("draw_settings", self.image_box.ImageBoxTool.__dict__) self.assertIn("draw_settings", self.image_lasso.ImageLassoTool.__dict__) context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=object())) drawn_settings = [] layout = SimpleNamespace( prop=lambda _settings, name: drawn_settings.append(name) ) with patch.object(self.image_box, "draw_selection_settings") as draw: self.image_box.ImageBoxTool.draw_settings(context, layout, None) draw.assert_called_once_with(context, layout, "image_box_refine_selection") with patch.object(self.image_lasso, "draw_selection_settings") as draw: self.image_lasso.ImageLassoTool.draw_settings(context, layout, None) draw.assert_called_once_with(context, layout, "image_lasso_refine_selection") cutout_tool = importlib.import_module( "anyimage.operators.cutout_tool" ).CutoutTool cutout_module = sys.modules[cutout_tool.__module__] drawn_settings.clear() 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) setup.assert_called_once_with(layout) self.assertEqual(draw.call_count, 2) self.assertEqual( drawn_settings, [ "cutout_refine_selection", "cutout_generate_normal", "cutout_mesh_detail", ], ) self.assertNotIn("draw_settings", self.image_line.ImageLineTool.__dict__) self.assertNotIn( "draw_settings", self.image_polyline.ImagePolylineTool.__dict__ ) self.assertEqual(operator.__annotations__["gesture"]["default"], "BOX") self.assertIn("invert", operator.__annotations__) self.assertNotIn("continuous", operator.__annotations__) self.assertNotIn("padding", operator.__annotations__) settings = self.anyimage.properties.AnyImageSettings.__annotations__ for name in ( "cutout_refine_selection", "image_box_refine_selection", "image_lasso_refine_selection", ): self.assertFalse(settings[name]["default"]) self.assertNotIn("refine_selection", settings) self.assertNotIn("image_edit_refine_selection", settings) self.assertEqual(settings["cutout_mesh_detail"]["default"], "LOW") self.assertEqual( [item[:2] for item in settings["cutout_mesh_detail"]["items"]], [ ("LOW", "Low"), ("MEDIUM", "Medium"), ("HIGH", "High"), ("ULTRA", "Ultra"), ], ) self.assertFalse(settings["cutout_generate_normal"]["default"]) self.assertEqual( settings["cutout_refine_selection"]["name"], "Refine Selection" ) self.assertEqual( settings["cutout_mesh_detail"]["name"], "Mesh Detail" ) self.assertEqual( settings["cutout_generate_normal"]["name"], "Normal Map", ) self.assertNotIn("image_extract_continuous", settings) 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.ai, "ai_status", return_value=missing): for tool, setting_name in ( (self.image_box.ImageBoxTool, "image_box_refine_selection"), (self.image_lasso.ImageLassoTool, "image_lasso_refine_selection"), ): 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_selection", False), ("prop", "cutout_generate_normal", False), ("prop", "cutout_mesh_detail", True), ], ) layout = Layout() operator = SimpleNamespace(gesture="BOX") self.image_interaction.draw_selection_operator_settings(layout, operator) self.assertEqual( layout.events, [ setup, ("prop", "refine_selection", False), ("prop", "invert", True), ], ) 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_refine_selection_is_independent_for_each_area_tool(self): settings = SimpleNamespace( cutout_refine_selection=True, image_box_refine_selection=False, image_lasso_refine_selection=True, ) context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings)) with patch.object(self.image_interaction, "ai_ready", return_value=True): for gesture in ("LINE", "POLYLINE"): operator = SimpleNamespace( gesture=gesture, refine_selection=True, ) self.image_interaction.apply_selection_settings(operator, context) self.assertFalse(operator.refine_selection) box = SimpleNamespace( gesture="BOX", refine_selection=True, refine_selection_settings={"BOX": "image_box_refine_selection"}, ) self.image_interaction.apply_selection_settings(box, context) self.assertFalse(box.refine_selection) lasso = SimpleNamespace( gesture="LASSO", refine_selection=False, refine_selection_settings={"LASSO": "image_lasso_refine_selection"}, ) self.image_interaction.apply_selection_settings(lasso, context) self.assertTrue(lasso.refine_selection) cutout = SimpleNamespace( gesture="LASSO", active_tool_id="anyimage.cutout_lasso", refine_selection=False, refine_selection_settings={"LASSO": "cutout_refine_selection"}, ) self.image_interaction.apply_selection_settings(cutout, context) self.assertTrue(cutout.refine_selection) def test_image_edit_first_click_selects_another_image_without_editing(self): selected = [] def image(name): return SimpleNamespace( name=name, type="EMPTY", empty_display_type="IMAGE", data=object(), select_set=lambda value: selected.append((name, value)), ) active = image("Active") clicked = image("Clicked") context = SimpleNamespace( active_object=active, selected_objects=(active,), view_layer=SimpleNamespace(objects=SimpleNamespace(active=active)), ) event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20) previous_ops = getattr(self.fake_bpy, "ops", None) previous_objects = self.fake_bpy.data.objects def select(**_kwargs): context.active_object = clicked self.fake_bpy.ops = SimpleNamespace( view3d=SimpleNamespace(select=select), object=SimpleNamespace(select_all=lambda **_kwargs: None), ) self.fake_bpy.data.objects = { active.name: active, clicked.name: clicked, } try: source, should_edit = self.image_interaction.resolve_image_edit_click( context, event, lambda *_args: None ) finally: self.fake_bpy.data.objects = previous_objects if previous_ops is None: del self.fake_bpy.ops else: self.fake_bpy.ops = previous_ops self.assertIs(source, clicked) self.assertFalse(should_edit) self.assertEqual(selected, []) def test_image_edit_first_click_on_non_image_keeps_active_image(self): selection_calls = [] active = SimpleNamespace( name="Active", type="EMPTY", empty_display_type="IMAGE", data=object(), select_set=lambda value: selection_calls.append(value), ) clicked = SimpleNamespace(name="Mesh", type="MESH", data=object()) view_objects = SimpleNamespace(active=active) context = SimpleNamespace( active_object=active, selected_objects=(active,), view_layer=SimpleNamespace(objects=view_objects), ) event = SimpleNamespace(mouse_region_x=10, mouse_region_y=20) previous_ops = getattr(self.fake_bpy, "ops", None) previous_objects = self.fake_bpy.data.objects def select(**_kwargs): context.active_object = clicked self.fake_bpy.ops = SimpleNamespace( view3d=SimpleNamespace(select=select), object=SimpleNamespace( select_all=lambda **_kwargs: selection_calls.append(False) ), ) self.fake_bpy.data.objects = {active.name: active, clicked.name: clicked} try: source, should_edit = self.image_interaction.resolve_image_edit_click( context, event, lambda *_args: None ) finally: self.fake_bpy.data.objects = previous_objects if previous_ops is None: del self.fake_bpy.ops else: self.fake_bpy.ops = previous_ops self.assertIs(source, active) self.assertTrue(should_edit) self.assertIs(view_objects.active, active) self.assertEqual(selection_calls, [False, True]) def test_image_perspective_rectifies_identity_pixels(self): import numpy as np top_down = np.arange(3 * 4 * 4, dtype=np.float32).reshape((3, 4, 4)) top_down /= float(top_down.max()) top_down[:, :, 3] = 1.0 result = self.image_perspective.warp_perspective_pixels( np.flipud(top_down).ravel(), (4, 3), ((0, 0), (4, 0), (4, 3), (0, 3)), (4, 3), ) np.testing.assert_allclose( np.flipud(np.asarray(result).reshape((3, 4, 4))), top_down, 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 top_down = np.zeros((4, 4, 4), dtype=np.float32) top_down[1:3, 1:3] = (0.2, 0.4, 0.6, 1.0) pixels, output_size, placement_bounds = ( self.image_perspective.extract_perspective_pixels( np.flipud(top_down).ravel(), (4, 4), ((0, 0), (4, 0), (4, 4), (0, 4)), 1.0, ) ) result = np.flipud(np.asarray(pixels).reshape((2, 2, 4))) self.assertEqual(output_size, (2, 2)) self.assertEqual(placement_bounds, (1.0, 1.0, 3.0, 3.0)) self.assertTrue(np.all(result[:, :, 3] == 1.0)) def test_image_perspective_rejects_non_convex_corners(self): with self.assertRaisesRegex(ValueError, "convex"): self.image_perspective.validate_perspective_quad( ((0, 0), (10, 0), (4, 4), (0, 10)) ) def test_image_perspective_corner_order_is_click_order_independent(self): import itertools import numpy as np corners = ((10, 20), (80, 15), (90, 70), (5, 75)) ordered = self.image_perspective.canonical_perspective_quad(corners) for permutation in itertools.permutations(corners): np.testing.assert_allclose( self.image_perspective.canonical_perspective_quad(permutation), ordered, ) def test_image_perspective_detects_source_image_overlap(self): overlaps = self.image_perspective.perspective_quad_overlaps_image self.assertFalse(overlaps(((20, 20), (30, 20), (30, 30), (20, 30)), (10, 10))) self.assertTrue(overlaps(((5, 5), (15, 5), (15, 15), (5, 15)), (10, 10))) self.assertTrue(overlaps(((-5, -5), (15, -5), (15, 15), (-5, 15)), (10, 10))) def test_image_mask_fill_triangulates_a_concave_polygon_without_overlap(self): polygon = ( (0, 0), (4, 0), (4, 4), (3, 4), (3, 1), (1, 1), (1, 4), (0, 4), ) triangles = self.image_interaction.triangulated_polygon_vertices(polygon) area = sum( abs( self.image_interaction._triangle_cross( triangles[index], triangles[index + 1], triangles[index + 2], ) ) * 0.5 for index in range(0, len(triangles), 3) ) self.assertEqual(len(triangles), 18) self.assertAlmostEqual(area, 10.0) def test_image_mask_fill_uses_blender_geometry_without_stopping_the_draw(self): mathutils = ModuleType("mathutils") mathutils.Vector = lambda values: values mathutils.geometry = SimpleNamespace( tessellate_polygon=lambda _loops: ( ((0.0, 0.0, 0.0), (2.0, 0.0, 0.0), (0.0, 2.0, 0.0)), ) ) with patch.dict(sys.modules, {"mathutils": mathutils}): triangles = self.image_interaction.triangulated_polygon_vertices( ((0, 0), (2, 0), (0, 2)) ) self.assertEqual( triangles, ((0.0, 0.0), (2.0, 0.0), (0.0, 2.0)), ) def test_image_mask_fill_falls_back_when_blender_tessellation_rejects_path(self): mathutils = ModuleType("mathutils") mathutils.Vector = lambda values: values mathutils.geometry = SimpleNamespace( tessellate_polygon=lambda _loops: (_ for _ in ()).throw(TypeError()) ) with patch.dict(sys.modules, {"mathutils": mathutils}): triangles = self.image_interaction.triangulated_polygon_vertices( ((0, 0), (2, 0), (0, 2)) ) self.assertEqual(len(triangles), 3) def test_image_mask_preview_fills_a_self_intersecting_lasso(self): polygon = ((0, 0), (4, 4), (0, 4), (4, 0)) triangles = self.image_interaction.preview_fill_triangles(polygon, "NONZERO") area = sum( abs( self.image_interaction._triangle_cross( triangles[index], triangles[index + 1], triangles[index + 2], ) ) * 0.5 for index in range(0, len(triangles), 3) ) self.assertTrue(triangles) self.assertAlmostEqual(area, 8.0) def test_image_mask_preview_detects_only_actual_self_intersections(self): self.assertTrue( self.image_interaction.polygon_self_intersects( ((0, 0), (4, 4), (0, 4), (4, 0)) ) ) self.assertFalse( self.image_interaction.polygon_self_intersects( ((0, 0), (4, 0), (2, 2), (4, 4), (0, 4)) ) ) def test_image_perspective_limits_output_pixel_count(self): width, height = self.image_perspective.perspective_output_size( ((0, 0), (10000, 0), (10000, 10000), (0, 10000)), 1.0, ) self.assertLessEqual(width * height, self.image_perspective.MAX_OUTPUT_PIXELS) def test_image_perspective_mouse_adjusts_preview_aspect_exponentially(self): adjust = self.image_perspective.aspect_ratio_from_mouse self.assertAlmostEqual(adjust(1.5, 240.0), 3.0) self.assertAlmostEqual(adjust(1.5, -240.0), 0.75) self.assertEqual( adjust(1.0, 100000.0), self.image_perspective.MAX_INTERACTIVE_ASPECT, ) self.assertEqual( self.image_perspective.snapped_aspect_ratio(1.7), (16.0 / 9.0, "16 : 9"), ) def test_image_perspective_preview_is_centered_at_requested_aspect(self): left, bottom, width, height = self.image_perspective.preview_draw_bounds( (1000, 800), 2.0 ) self.assertAlmostEqual(width / height, 2.0) self.assertAlmostEqual(left + width * 0.5, 500.0) self.assertAlmostEqual(bottom + height * 0.5, 400.0) def test_image_perspective_uses_quad_edges_for_initial_aspect(self): ratio = self.image_perspective.perspective_quad_aspect( ((0, 0), (8, 0), (8, 4), (0, 4)) ) self.assertAlmostEqual(ratio, 2.0) def test_image_perspective_ignores_an_already_removed_preview(self): operator = self.image_perspective.EditImagePerspective() operator._preview_image = object() def remove(*_args, **_options): raise ReferenceError("StructRNA of type Image has been removed") self.fake_bpy.data = SimpleNamespace(images=SimpleNamespace(remove=remove)) operator._remove_preview() self.assertIsNone(operator._preview_image) def test_image_perspective_maps_crop_to_source_plane_bounds(self): bounds = self.image_perspective.perspective_placement_bounds( ((10, 20), (30, 20), (30, 40), (10, 40)), (100, 50), (-10, -5, 110, 55), ) self.assertEqual(bounds, (8.0, 18.0, 32.0, 42.0)) def test_image_perspective_tool_registration_and_properties(self): operator = self.image_perspective.EditImagePerspective tool = self.image_perspective.ImagePerspectiveTool settings = self.anyimage.properties.AnyImageSettings.__annotations__ self.assertIn(operator, self.anyimage.CLASSES) self.assertNotIn(tool, self.anyimage.CLASSES) self.assertEqual(tool.bl_operator, operator.bl_idname) self.assertEqual(tool.bl_icon, "ops.sculpt.line_project") self.assertNotIn("bl_cursor", tool.__dict__) self.assertNotIn("draw_settings", tool.__dict__) self.assertNotIn("image_perspective_aspect_mode", settings) self.assertNotIn("image_perspective_aspect_ratio", settings) self.assertNotIn("aspect_mode", operator.__annotations__) self.assertIn("aspect_ratio", operator.__annotations__) self.assertNotIn("refine_selection", operator.__annotations__) self.assertNotIn("continuous", operator.__annotations__) self.assertNotIn("padding", operator.__annotations__) def test_image_box_builds_a_rectangular_screen_path(self): self.assertEqual( self.image_interaction.box_screen_path((2, 3), (8, 9)), ((2, 3), (8, 3), (8, 9), (2, 9)), ) self.assertEqual( self.image_interaction.box_screen_path((2, 3), (3, 9)), (), ) def test_image_box_completion_serializes_the_image_selection(self): source = SimpleNamespace(data=SimpleNamespace(size=(64, 32))) finished = [] operator = SimpleNamespace( source_object_name="Source", source_matrix_data="matrix", gesture="BOX", selection_path_json="", invert=False, preview_fill_rule="EVEN_ODD", _screen_path=lambda: ((1, 1), (8, 1), (8, 6), (1, 6)), _finish=lambda context: finished.append(context), execute=lambda _context: {"FINISHED"}, ) context = SimpleNamespace(region=object(), region_data=object()) polygon = ((1, 2), (3, 4), (5, 6)) with ( patch.object( self.image_interaction, "require_image_empty", return_value=source ), patch.object(self.image_interaction, "deserialize_matrix"), patch.object(self.image_interaction, "image_empty_bounds"), patch.object( self.image_interaction, "screen_path_to_image_pixels", return_value=polygon, ), ): result = self.image_interaction.ImageSelectionGesture._complete(operator, context) self.assertEqual(result, {"FINISHED"}) self.assertEqual( json.loads(operator.selection_path_json)["points"], [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]], ) self.assertEqual(finished, [context]) def test_image_selection_uses_white_dashed_segments(self): segments = self.image_interaction.dashed_line_segments( ((0, 0), (10, 0)), dash_length=3.0, gap_length=2.0 ) self.assertEqual( segments, ((0.0, 0.0), (3.0, 0.0), (5.0, 0.0), (8.0, 0.0)), ) def test_image_lasso_preview_uses_fill_and_a_closed_dashed_outline(self): polygon = ((1.0, 2.0), (8.0, 3.0), (4.0, 9.0)) operator = SimpleNamespace( gesture="LASSO", invert=False, _path=[polygon[0]], _area_pointer=1, _region_pointer=2, preview_fill_rule="NONZERO", _fill_polygon=None, _fill_triangles=(), _outline_polygon=None, _outline_segments=(), _screen_path=lambda: polygon, ) with ( patch.object(self.image_interaction, "drawing_in_region", return_value=True), patch.object( self.image_interaction, "dashed_line_segments", return_value=("segments",), ) as build_outline, patch.object( self.image_interaction, "preview_fill_triangles", return_value=("triangles",), ) as build_fill, patch.object(self.image_interaction, "draw_polygon_fill") as draw_fill, patch.object(self.image_interaction, "draw_dashed_line") as draw_outline, ): self.image_interaction.ImageSelectionGesture._draw_selection(operator) self.image_interaction.ImageSelectionGesture._draw_selection(operator) build_fill.assert_called_once_with(polygon, "NONZERO") build_outline.assert_called_once_with((*polygon, polygon[0])) self.assertEqual(draw_fill.call_count, 2) draw_fill.assert_called_with( polygon, ("triangles",), (1.0, 1.0, 1.0, 0.16), ) self.assertEqual(draw_outline.call_count, 2) draw_outline.assert_called_with((), segments=("segments",)) def test_image_lasso_does_not_add_stationary_or_nearly_collinear_points(self): path = [(0.0, 0.0)] self.assertFalse(self.image_interaction.append_lasso_point(path, (0.5, 0.5))) self.assertEqual(path, [(0.0, 0.0)]) self.assertTrue(self.image_interaction.append_lasso_point(path, (3.0, 0.0))) self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 0.2))) self.assertEqual(len(path), 2) self.assertEqual(path[-1], (6.0, 0.2)) self.assertTrue(self.image_interaction.append_lasso_point(path, (6.0, 4.0))) self.assertEqual(len(path), 3) def test_image_edit_activation_defaults_to_box(self): calls = [] previous_ops = getattr(self.fake_bpy, "ops", None) self.fake_bpy.ops = SimpleNamespace( wm=SimpleNamespace( tool_set_by_id=lambda **options: calls.append(options) or {"FINISHED"} ) ) try: result = self.edit_mask.ActivateImageTool().execute(None) finally: if previous_ops is None: del self.fake_bpy.ops else: self.fake_bpy.ops = previous_ops self.assertEqual(result, {"FINISHED"}) self.assertEqual( calls, [{"name": "anyimage.image_box", "space_type": "VIEW_3D"}], ) def test_image_line_snaps_and_keeps_the_previewed_half_plane(self): import math endpoint = self.image_interaction.snapped_line_endpoint((0, 0), (10, 1), True) self.assertAlmostEqual(math.degrees(math.atan2(endpoint[1], endpoint[0])), 5.0) preview = self.image_interaction.line_half_plane_polygon((0, 5), (10, 5), (10, 10)) image = self.image_interaction.image_line_half_plane_polygon( ((0, 5), (10, 5)), (10, 10) ) self.assertEqual( self.image_selection.selection_path_bounds(preview), (0, 5, 10, 10), ) self.assertEqual( self.image_selection.selection_path_bounds(image), (0, 0, 10, 5), ) def test_image_operators_stay_clickable_until_server_is_busy(self): context = SimpleNamespace( object=SimpleNamespace(data=SimpleNamespace()), ) operators = ( (self.convert_to_plane, self.convert_to_plane.ConvertToDepthPlane), (self.remove_background, self.remove_background.RemoveImageBackground), ) for busy, expected in ((False, True), (True, False)): with ( self.subTest(busy=busy), patch.object( self.anyimage.runtime, "server_busy", return_value=busy, ), ): for module, operator in operators: with ( self.subTest(operator=operator.__name__), patch.object( module, "is_image_empty", return_value=True, ), patch.object( module, "video_total_frames", return_value=1, ), ): self.assertEqual(operator.poll(context), expected) def test_ui_reads_required_model_files_without_starting_server(self): properties = importlib.import_module("anyimage.properties") with tempfile.TemporaryDirectory() as directory: root = Path(directory) moge = root / "models" / "moge-2-vits-normal-onnx" ben2 = root / "models" / "BEN2-ONNX" / "onnx" wdn = root / "models" / "realesr-general-wdn-x4v3" moge.mkdir(parents=True) ben2.mkdir(parents=True) wdn.mkdir(parents=True) (moge / "model.onnx").touch() (ben2 / "model_fp16.onnx").touch() (wdn / "realesr-general-wdn-x4v3.onnx").touch() with ( patch.object( self.anyimage.runtime, "storage_root", return_value=root, ), patch.object( self.anyimage.runtime, "resource", side_effect=AssertionError("UI must not start the Server"), ), ): self.assertTrue(properties.required_models_ready()) def test_runtime_request_uses_request_response_shape(self): result = object() responses = [] with patch.object( self.anyimage.runtime.server, "request", return_value=result, ) as request: returned = self.anyimage.runtime.request( "example", {"value": 3}, response=responses.append, ) self.assertIs(returned, result) request.assert_called_once_with( "example", {"value": 3}, response=responses.append, progress=None, cancel_check=None, ) def test_server_request_returns_job_result_and_calls_response(self): controller_type = importlib.import_module( "blendjob.controller" ).ServerController controller = controller_type.__new__(controller_type) statuses = iter( ( {"state": "running", "progress": 0.5}, { "state": "succeeded", "progress": 1.0, "result": {"value": 7}, }, ) ) controller.submit = lambda _job_type, _parameters: { "job_id": "job", "directory": ".", } controller.status = lambda _job_id: next(statuses) controller.mark_job_complete = lambda _job_id: None responses = [] result = controller.request( "example", {"input": 3}, response=responses.append, poll_interval=0.01, ) self.assertEqual(result.value, {"value": 7}) self.assertEqual(responses, [result]) def test_runtime_keeps_server_as_an_entrypoint_string(self): path, attribute = self.anyimage.runtime.server_entrypoint.rsplit(":", 1) self.assertEqual(Path(path).name, "__init__.py") self.assertEqual(Path(path).parent.name, "server") self.assertEqual(attribute, "server") def test_runtime_registers_system_operators_and_status_bar(self): self.assertIn( self.anyimage.clipboard_image.PasteClipboardImage, self.anyimage.CLASSES, ) self.assertIn( self.anyimage.clipboard_image.TrackNativeCopy, self.anyimage.CLASSES, ) existing_context_draw = object() existing_outliner_draw = object() self.context_menu_draws.append(existing_context_draw) self.outliner_menu_draws.append(existing_outliner_draw) self.anyimage.register() system_classes = self.anyimage.runtime.operator_classes() self.assertTrue(set(system_classes) <= set(self.registered)) self.assertEqual( self.registered_tools, [ ( self.image_box.ImageBoxTool, { "group": True, "separator": True, }, ), ( self.image_lasso.ImageLassoTool, { "after": {self.image_box.ImageBoxTool.bl_idname}, "separator": False, }, ), ( self.image_line.ImageLineTool, { "after": {self.image_lasso.ImageLassoTool.bl_idname}, "separator": False, }, ), ( self.image_polyline.ImagePolylineTool, { "after": {self.image_line.ImageLineTool.bl_idname}, "separator": False, }, ), ( self.image_perspective.ImagePerspectiveTool, { "separator": False, }, ), ( self.anyimage.CutoutTool, { "after": { self.image_perspective.ImagePerspectiveTool.bl_idname }, "separator": True, }, ), ], ) self.assertEqual(len(self.status_bar_draws), 1) self.assertEqual(self.status_bar_draws[0]._owner, "anyimage") self.assertIs( self.context_menu_draws[0], self.anyimage.draw_image_context_menu, ) self.assertIs( self.outliner_menu_draws[0], self.anyimage.draw_image_context_menu, ) self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")) self.anyimage.unregister() self.assertEqual(self.registered, []) self.assertEqual(self.registered_tools, []) self.assertEqual(self.status_bar_draws, []) self.assertEqual(self.context_menu_draws, [existing_context_draw]) self.assertEqual(self.outliner_menu_draws, [existing_outliner_draw]) self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")) def test_blender_image_exports_do_not_use_scene_render_color_management(self): source_root = PROJECT_ROOT / "src" / "anyimage" offenders = [] for path in source_root.rglob("*.py"): if ".save_render(" in path.read_text(encoding="utf-8"): offenders.append(path.relative_to(source_root)) self.assertEqual(offenders, []) def test_status_bar_reads_runtime_state(self): calls = [] class Layout: def row(self, **_options): return self def progress(self, **options): calls.append(("progress", options)) def operator(self, identifier, **_options): calls.append(("operator", identifier)) runtime = self.anyimage.runtime runtime.active_job = object() runtime.progress = 0.4 runtime.message = "Working" runtime._draw_status_bar(SimpleNamespace(layout=Layout()), None) runtime.active_job = None self.assertEqual(calls[0][1]["factor"], 0.4) self.assertEqual(calls[0][1]["text"], "Working") self.assertEqual(calls[1], ("operator", "anyimage.cancel_job")) if __name__ == "__main__": unittest.main()