- Implemented `build_mesh_plane.py` to create a geometry node group for mesh planes with customizable parameters. - Created `build_shadeless.py` for a shader node group that displays color without scene lighting while preserving shadows and reflections. - Added `validate.py` to validate the integrity of the newly created node groups and their inputs. - Introduced `validate_cutout.py` to ensure the functionality of cutout node groups and their dependencies. - Developed `validate_mesh_plane.py` to test the mesh plane creation and its modifier settings.
2824 lines
105 KiB
Python
2824 lines
105 KiB
Python
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 AddonTest(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.addon = importlib.import_module("anyimage")
|
||
cls.job_runtime = importlib.import_module("anyimage.runtime")
|
||
cls.model_operators = importlib.import_module("anyimage.operators.ai_setup")
|
||
cls.image_depth = importlib.import_module("anyimage.operators.common.material")
|
||
cls.image_data = importlib.import_module("anyimage.operators.common.image")
|
||
cls.debug = importlib.import_module("anyimage.operators.debug")
|
||
cls.mesh_plane = importlib.import_module("anyimage.operators.convert_to_plane.mesh_plane")
|
||
cls.mesh = importlib.import_module("anyimage.operators.convert_to_plane.conversion")
|
||
cls.cutout_conversion = importlib.import_module(
|
||
"anyimage.operators.cutout_tool.shape_conversion"
|
||
)
|
||
cls.cutout_build = importlib.import_module(
|
||
"anyimage.operators.cutout_tool.build_shapes"
|
||
)
|
||
cls.mesh_shapes = importlib.import_module(
|
||
"anyimage.operators.cutout_tool.shape_types"
|
||
)
|
||
cls.image_background = importlib.import_module(
|
||
"anyimage.operators.remove_background"
|
||
)
|
||
cls.image_upscale = importlib.import_module("anyimage.operators.upscale")
|
||
cls.image_edit_common = importlib.import_module(
|
||
"anyimage.operators.common.image_edit"
|
||
)
|
||
cls.image_mask = importlib.import_module(
|
||
"anyimage.operators.image_tool.edit_mask"
|
||
)
|
||
cls.image_mask_data = importlib.import_module(
|
||
"anyimage.operators.common.image_mask"
|
||
)
|
||
cls.image_gesture = importlib.import_module(
|
||
"anyimage.operators.common.image_gesture"
|
||
)
|
||
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.planar_mesh"
|
||
)
|
||
cls.cutout_depth = importlib.import_module(
|
||
"anyimage.operators.cutout_tool.depth_calibration"
|
||
)
|
||
cls.image_interaction = importlib.import_module(
|
||
"anyimage.operators.common.image_interaction"
|
||
)
|
||
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.addon.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.addon.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.addon.properties
|
||
manifest = {"devices": ["mps", "cpu"]}
|
||
with (
|
||
patch.object(
|
||
self.addon.runtime,
|
||
"environment_manifest",
|
||
return_value=manifest,
|
||
),
|
||
patch.object(properties, "supports_mps", return_value=False),
|
||
):
|
||
self.assertEqual(properties.supported_devices(), ["cpu"])
|
||
with (
|
||
patch.object(
|
||
self.addon.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.addon.properties
|
||
with (
|
||
patch.object(
|
||
self.addon.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.addon.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.addon.operators.GenerateDepthPlane, "anyimage.generate_depth_plane"),
|
||
(
|
||
self.addon.operators.GenerateCutoutShape,
|
||
"anyimage.generate_cutout_shape",
|
||
),
|
||
):
|
||
self.assertEqual(operator.bl_idname, identifier)
|
||
self.assertIn(self.job_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.addon.properties.shared_model_catalog.UPSCALE_MODELS:
|
||
operator = self.debug.DebugUpscale()
|
||
operator.model = model_key
|
||
with (
|
||
self.subTest(model=model_key),
|
||
patch.object(self.debug, "require_environment"),
|
||
patch.object(self.debug, "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.DebugUpscale, self.addon.CLASSES)
|
||
panel = importlib.import_module("anyimage.panel")
|
||
self.assertIn(panel.UpscaleDebugPanel, self.addon.CLASSES)
|
||
|
||
def test_default_request_sends_every_operator_property(self):
|
||
class Example(self.job_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.job_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.job_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.addon.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.addon.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.addon.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.addon.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.job_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.job_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.addon.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.addon.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.addon.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_conversion_model_lists_moge2_before_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.mesh_depth_model_enum_items(None, None)
|
||
|
||
expected = (
|
||
"MOGE2_VITS_NORMAL",
|
||
"MOGE2_VITB_NORMAL",
|
||
"MOGE2_VITL_NORMAL",
|
||
"SMALL",
|
||
"BASE",
|
||
)
|
||
self.assertEqual(tuple(item[0] for item in depth_items[:-1]), expected)
|
||
self.assertEqual(tuple(item[0] for item in mesh_depth_items), expected)
|
||
|
||
def test_required_model_operator_requests_both_models(self):
|
||
operator = self.addon.operators.DownloadRequiredModels()
|
||
with patch.object(self.model_operators, "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.addon.properties
|
||
with (
|
||
patch.object(self.addon.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.addon.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.model_operators.SetupAIEnvironment()
|
||
with patch.object(self.fake_bpy, "ops", ops, create=True):
|
||
operator.use_mirror = True
|
||
with patch.object(
|
||
self.model_operators,
|
||
"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.model_operators,
|
||
"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.model_operators.SetupAIEnvironment, self.addon.CLASSES)
|
||
self.assertIn(
|
||
self.model_operators.OpenAIEnvironmentSettings,
|
||
self.addon.CLASSES,
|
||
)
|
||
def test_global_ai_input_size_defaults_and_clamps(self):
|
||
preference_module = self.addon.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_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.mesh_depth_model = "BASE"
|
||
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.addon.properties,
|
||
"ai_status",
|
||
return_value={
|
||
"environment_ready": True,
|
||
"missing_models": (),
|
||
"ready": True,
|
||
},
|
||
),
|
||
patch.object(
|
||
self.addon.properties,
|
||
"model_catalog",
|
||
return_value=catalog,
|
||
),
|
||
patch.object(self.addon.runtime, "server_busy", return_value=False),
|
||
):
|
||
preferences.draw(None)
|
||
|
||
self.assertEqual(
|
||
[download.model for download in downloads],
|
||
["BASE", "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.assertIn("DA3 models cannot generate normals", labels)
|
||
self.assertNotIn("Normal Model", labels)
|
||
self.assertIn("upscale_model", properties)
|
||
self.assertIn("max_ai_input_size", 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.mesh.ConvertToPlane
|
||
self.assertIn(operator, self.addon.CLASSES)
|
||
self.assertIn(self.mesh.ConvertToDepthPlane, self.addon.CLASSES)
|
||
self.assertIn(self.cutout_conversion.ConvertCutoutShape, self.addon.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.mesh, "video_total_frames", return_value=24),
|
||
patch.object(
|
||
self.mesh,
|
||
"image_user_settings",
|
||
return_value=(1, 0, 24, True),
|
||
),
|
||
patch.object(self.mesh, "create_material", return_value="Material"),
|
||
patch.object(self.mesh, "copy_color_image", return_value="Color"),
|
||
patch.object(self.mesh, "build_mesh_plane") as build_mesh,
|
||
):
|
||
result = self.mesh.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.mesh.GenerateDepthPlane()
|
||
operator.input_path = __file__
|
||
operator.analysis_input_path = __file__
|
||
operator.source_object_name = "Source"
|
||
operator.mesh_detail = 0
|
||
operator.model = self.addon.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.mesh, "require_environment"),
|
||
patch.object(self.mesh, "require_model"),
|
||
patch.object(self.mesh, "production_device", return_value="CPU"),
|
||
patch.object(self.mesh, "source_start_frame", return_value=1),
|
||
):
|
||
parameters = operator.request(context)
|
||
|
||
self.assertEqual(parameters["max_input_size"], 1536)
|
||
self.assertEqual(parameters["shape"], "DEPTH_PLANE")
|
||
|
||
def test_cutout_and_balloon_without_refine_build_locally(self):
|
||
source = SimpleNamespace(select_set=lambda _selected: None)
|
||
context = SimpleNamespace(
|
||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
||
)
|
||
|
||
for shape in ("CUTOUT", "BALLOON"):
|
||
with self.subTest(shape=shape):
|
||
operator = SimpleNamespace(
|
||
source_object_name="Source",
|
||
selection_data="[[0,0],[10,0],[0,10]]",
|
||
shape=shape,
|
||
mesh_detail=64,
|
||
refine_selection=False,
|
||
normal_analysis=False,
|
||
invert=True,
|
||
report=lambda *_args: None,
|
||
)
|
||
with (
|
||
patch.object(
|
||
self.cutout_conversion.bpy.data.objects,
|
||
"get",
|
||
return_value=source,
|
||
),
|
||
patch.object(
|
||
self.cutout_conversion, "is_image_empty", return_value=True
|
||
),
|
||
patch.object(
|
||
self.cutout_conversion, "build_local_cutout"
|
||
) as build_local,
|
||
patch.object(
|
||
self.cutout_conversion,
|
||
"start_cutout_job",
|
||
side_effect=AssertionError("local Cutout must not start a Job"),
|
||
),
|
||
):
|
||
result = self.cutout_conversion.ConvertCutoutShape.execute(
|
||
operator, context
|
||
)
|
||
|
||
self.assertEqual(result, {"FINISHED"})
|
||
build_local.assert_called_once_with(
|
||
operator,
|
||
context,
|
||
source,
|
||
shape,
|
||
operator.selection_data,
|
||
True,
|
||
)
|
||
|
||
def test_refined_cutout_and_balloon_submit_blendjob(self):
|
||
source = SimpleNamespace(select_set=lambda _selected: None)
|
||
context = SimpleNamespace(
|
||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
||
)
|
||
|
||
for shape in ("CUTOUT", "BALLOON"):
|
||
with self.subTest(shape=shape):
|
||
operator = SimpleNamespace(
|
||
source_object_name="Source",
|
||
selection_data="[[0,0],[10,0],[0,10]]",
|
||
shape=shape,
|
||
mesh_detail=64,
|
||
refine_selection=True,
|
||
normal_analysis=False,
|
||
invert=False,
|
||
report=lambda *_args: None,
|
||
)
|
||
with (
|
||
patch.object(
|
||
self.cutout_conversion.bpy.data.objects,
|
||
"get",
|
||
return_value=source,
|
||
),
|
||
patch.object(
|
||
self.cutout_conversion, "is_image_empty", return_value=True
|
||
),
|
||
patch.object(
|
||
self.cutout_conversion,
|
||
"build_local_cutout",
|
||
side_effect=AssertionError("Refine must run through the Job"),
|
||
),
|
||
patch.object(
|
||
self.cutout_conversion,
|
||
"start_cutout_job",
|
||
return_value={"FINISHED"},
|
||
) as start_job,
|
||
):
|
||
result = self.cutout_conversion.ConvertCutoutShape.execute(
|
||
operator, context
|
||
)
|
||
|
||
self.assertEqual(result, {"FINISHED"})
|
||
start_job.assert_called_once_with(
|
||
operator,
|
||
context,
|
||
shape,
|
||
operator.selection_data,
|
||
True,
|
||
False,
|
||
False,
|
||
)
|
||
|
||
def test_plain_balloon_job_requests_moge_normal(self):
|
||
source = SimpleNamespace(
|
||
name="Source",
|
||
data=SimpleNamespace(size=(2048, 1024)),
|
||
)
|
||
context = SimpleNamespace(object=source, scene=SimpleNamespace())
|
||
operator = SimpleNamespace(mesh_detail=64, report=lambda *_args: None)
|
||
|
||
ops = SimpleNamespace(anyimage=SimpleNamespace())
|
||
with (
|
||
patch.object(self.fake_bpy, "ops", ops, create=True),
|
||
patch.object(
|
||
self.cutout_conversion, "video_total_frames", return_value=1
|
||
),
|
||
patch.object(
|
||
self.cutout_conversion,
|
||
"invoke_ai_setup_if_needed",
|
||
return_value=None,
|
||
) as setup,
|
||
patch.object(
|
||
self.cutout_conversion,
|
||
"prepare_image_input",
|
||
return_value=(Path("input.png"), False),
|
||
),
|
||
patch.object(
|
||
self.cutout_conversion,
|
||
"cutout_moge_settings",
|
||
return_value=("MOGE2_VITL", 5),
|
||
),
|
||
patch.object(
|
||
self.cutout_conversion.bpy.ops.anyimage,
|
||
"generate_cutout_shape",
|
||
return_value={"FINISHED"},
|
||
create=True,
|
||
) as generate_mesh,
|
||
):
|
||
result = self.cutout_conversion.start_cutout_job(
|
||
operator,
|
||
context,
|
||
"BALLOON",
|
||
"[[0,0],[10,0],[0,10]]",
|
||
normal_analysis=True,
|
||
)
|
||
|
||
self.assertEqual(result, {"FINISHED"})
|
||
setup.assert_called_once_with()
|
||
self.assertEqual(generate_mesh.call_args.kwargs["shape"], "BALLOON")
|
||
self.assertEqual(generate_mesh.call_args.kwargs["mesh_detail"], 64)
|
||
self.assertEqual(
|
||
generate_mesh.call_args.kwargs["model"],
|
||
"MOGE2_VITL",
|
||
)
|
||
self.assertEqual(generate_mesh.call_args.kwargs["resolution_level"], 5)
|
||
self.assertEqual(generate_mesh.call_args.kwargs["max_input_size"], 2048)
|
||
self.assertNotIn("process_res", generate_mesh.call_args.kwargs)
|
||
self.assertNotIn("normal_model", generate_mesh.call_args.kwargs)
|
||
|
||
def test_cutout_moge_settings_falls_back_from_da3(self):
|
||
preferences = SimpleNamespace(
|
||
mesh_depth_model="DA3_BASE",
|
||
mesh_resolution_level=7,
|
||
)
|
||
with patch.object(
|
||
self.cutout_conversion,
|
||
"addon_preferences",
|
||
return_value=preferences,
|
||
):
|
||
settings = self.cutout_conversion.cutout_moge_settings(None)
|
||
|
||
self.assertEqual(
|
||
settings,
|
||
(self.addon.properties.DEFAULT_MOGE2_MODEL_KEY, 7),
|
||
)
|
||
|
||
def test_all_cutout_shapes_request_one_moge_model_when_normal_is_enabled(self):
|
||
context = SimpleNamespace(
|
||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1))
|
||
)
|
||
for shape in self.mesh_shapes.CUTOUT_SHAPES:
|
||
with self.subTest(shape=shape):
|
||
operator = self.cutout_conversion.GenerateCutoutShape()
|
||
operator.input_path = __file__
|
||
operator.source_object_name = ""
|
||
operator.shape = shape
|
||
operator.selection_data = "[[0,0],[10,0],[0,10]]"
|
||
operator.model = self.addon.properties.DEFAULT_MOGE2_MODEL_KEY
|
||
operator.mesh_detail = 64
|
||
operator.resolution_level = 5
|
||
operator.max_input_size = 2048
|
||
operator.normal_analysis = True
|
||
operator.refine_selection = False
|
||
operator.invert = False
|
||
with (
|
||
patch.object(self.cutout_conversion, "require_environment"),
|
||
patch.object(
|
||
self.cutout_conversion, "require_model"
|
||
) as require_model,
|
||
patch.object(
|
||
self.cutout_conversion, "production_device", return_value="CPU"
|
||
),
|
||
patch.object(
|
||
self.cutout_conversion, "source_start_frame", return_value=1
|
||
),
|
||
):
|
||
parameters = operator.request(context)
|
||
|
||
require_model.assert_called_once_with(
|
||
self.addon.properties.DEFAULT_MOGE2_MODEL_KEY
|
||
)
|
||
self.assertEqual(
|
||
parameters["model"],
|
||
self.addon.properties.DEFAULT_MOGE2_MODEL_KEY,
|
||
)
|
||
self.assertEqual(parameters["resolution_level"], 5)
|
||
self.assertNotIn("normal_model", parameters)
|
||
self.assertNotIn("normal_resolution_level", parameters)
|
||
self.assertEqual(parameters["mesh_detail"], 64)
|
||
self.assertEqual(parameters["max_input_size"], 2048)
|
||
|
||
def test_plain_cutout_job_skips_moge_when_normal_is_disabled(self):
|
||
operator = self.cutout_conversion.GenerateCutoutShape()
|
||
operator.input_path = __file__
|
||
operator.source_object_name = ""
|
||
operator.shape = "CUTOUT"
|
||
operator.selection_data = "[[0,0],[10,0],[0,10]]"
|
||
operator.mesh_detail = 64
|
||
operator.max_input_size = 2048
|
||
operator.normal_analysis = False
|
||
operator.refine_selection = False
|
||
operator.invert = False
|
||
|
||
with (
|
||
patch.object(self.cutout_conversion, "require_environment"),
|
||
patch.object(self.cutout_conversion, "require_model") as require_model,
|
||
patch.object(
|
||
self.cutout_conversion, "production_device", return_value="CPU"
|
||
),
|
||
patch.object(
|
||
self.cutout_conversion, "source_start_frame", return_value=1
|
||
),
|
||
):
|
||
parameters = operator.request(SimpleNamespace(scene=SimpleNamespace()))
|
||
|
||
require_model.assert_not_called()
|
||
self.assertFalse(parameters["normal_analysis"])
|
||
self.assertEqual(parameters["max_input_size"], 2048)
|
||
self.assertNotIn("model", parameters)
|
||
self.assertNotIn("resolution_level", parameters)
|
||
|
||
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.addon.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 (640 × 480)",
|
||
)
|
||
|
||
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.addon.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.image_upscale.UpscaleImage
|
||
self.assertIn(operator_type, self.addon.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.job_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.image_upscale, "require_environment"),
|
||
patch.object(self.image_upscale, "require_model") as require_model,
|
||
patch.object(
|
||
self.image_upscale, "input_path", return_value=Path("input.png")
|
||
),
|
||
patch.object(self.image_upscale, "production_device", return_value="CUDA"),
|
||
patch.object(self.image_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.image_upscale.UpscaleImage()
|
||
with (
|
||
patch.object(
|
||
self.image_upscale,
|
||
"configured_upscale_model",
|
||
return_value="SAFMN_REAL_X4",
|
||
),
|
||
patch.object(
|
||
self.image_upscale,
|
||
"configured_max_ai_input_size",
|
||
return_value=1024,
|
||
),
|
||
patch.object(
|
||
self.image_upscale,
|
||
"prepare_image_input",
|
||
return_value=(Path("input.png"), False),
|
||
),
|
||
patch.object(
|
||
self.job_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.image_upscale,
|
||
"invoke_ai_setup_if_needed",
|
||
return_value=expected,
|
||
) as setup:
|
||
result = self.image_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.image_upscale.UpscaleImage()
|
||
context = object()
|
||
expected = {"FINISHED"}
|
||
with (
|
||
patch.object(
|
||
self.image_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_upscale_response_replaces_source_image(self):
|
||
original_image = SimpleNamespace(name="Original")
|
||
source_object = SimpleNamespace(
|
||
type="EMPTY",
|
||
empty_display_type="IMAGE",
|
||
data=original_image,
|
||
)
|
||
self.fake_bpy.data = SimpleNamespace(
|
||
objects=SimpleNamespace(
|
||
get=lambda name: source_object if name == "Source" else None
|
||
),
|
||
images=SimpleNamespace(
|
||
get=lambda name: original_image if name == "Original" else None
|
||
),
|
||
)
|
||
operator = self.image_upscale.UpscaleImage()
|
||
operator.source_object_name = "Source"
|
||
operator.source_image_name = "Original"
|
||
operator.model = "REALESRGAN_GENERAL_WDN_X4V3"
|
||
result = SimpleNamespace(directory=Path("result"))
|
||
result_image = SimpleNamespace(name="", anyimage_upscale_source=None)
|
||
with patch.object(
|
||
self.image_upscale,
|
||
"create_color_image",
|
||
return_value=result_image,
|
||
) as create_color:
|
||
message = operator.response(None, result)
|
||
|
||
create_color.assert_called_once_with(
|
||
source_object,
|
||
result.directory,
|
||
frame_prefix="upscale.",
|
||
)
|
||
self.assertEqual(result_image.name, "Original_upscale.png")
|
||
self.assertIs(
|
||
result_image.anyimage_upscale_source,
|
||
original_image,
|
||
)
|
||
self.assertEqual(
|
||
message,
|
||
"Image is upscaled with Real-ESRGAN General WDN x4v3",
|
||
)
|
||
|
||
def test_upscale_response_reports_removed_original_image(self):
|
||
source_object = SimpleNamespace(
|
||
type="EMPTY",
|
||
empty_display_type="IMAGE",
|
||
data=object(),
|
||
)
|
||
self.fake_bpy.data = SimpleNamespace(
|
||
objects=SimpleNamespace(get=lambda _name: source_object),
|
||
images=SimpleNamespace(get=lambda _name: None),
|
||
)
|
||
operator = self.image_upscale.UpscaleImage()
|
||
operator.source_object_name = "Source"
|
||
operator.source_image_name = "Removed"
|
||
|
||
with self.assertRaisesRegex(
|
||
RuntimeError,
|
||
"original source image is no longer available",
|
||
):
|
||
operator.response(None, SimpleNamespace(directory=Path("result")))
|
||
|
||
def test_depth_analysis_follows_the_oldest_upscale_source(self):
|
||
original = SimpleNamespace(name="Original")
|
||
first_upscale = SimpleNamespace(
|
||
name="First",
|
||
anyimage_upscale_source=original,
|
||
)
|
||
second_upscale = SimpleNamespace(
|
||
name="Second",
|
||
anyimage_upscale_source=first_upscale,
|
||
)
|
||
|
||
self.assertIs(
|
||
self.image_data.analysis_source_image(second_upscale),
|
||
original,
|
||
)
|
||
|
||
def test_repeated_upscale_uses_original_image_and_reports_warning(self):
|
||
original_image = SimpleNamespace(name="Original", source="FILE")
|
||
current_image = SimpleNamespace(
|
||
source="FILE",
|
||
anyimage_upscale_source=original_image,
|
||
)
|
||
source_object = SimpleNamespace(
|
||
name="Source",
|
||
type="EMPTY",
|
||
empty_display_type="IMAGE",
|
||
data=current_image,
|
||
)
|
||
context = SimpleNamespace(
|
||
object=source_object,
|
||
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)),
|
||
)
|
||
reports = []
|
||
operator = self.image_upscale.UpscaleImage()
|
||
operator.report = lambda levels, message: reports.append((levels, message))
|
||
with (
|
||
patch.object(
|
||
self.image_upscale,
|
||
"prepare_image_input",
|
||
return_value=(Path("original.png"), False),
|
||
) as prepare,
|
||
patch.object(
|
||
self.job_runtime.JobOperatorBase,
|
||
"execute",
|
||
return_value={"RUNNING_MODAL"},
|
||
),
|
||
):
|
||
result = operator.execute(context)
|
||
|
||
self.assertEqual(result, {"RUNNING_MODAL"})
|
||
prepare.assert_called_once_with(original_image, context.scene)
|
||
self.assertEqual(operator.source_image_name, "Original")
|
||
self.assertIn("rebuilding from the original", reports[0][1])
|
||
|
||
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_color_result_inherits_source_color_space(self):
|
||
with tempfile.TemporaryDirectory() as directory:
|
||
output_directory = Path(directory)
|
||
(output_directory / "image.0001.png").touch()
|
||
source_image = SimpleNamespace(
|
||
name="source.png",
|
||
source="FILE",
|
||
use_fake_user=False,
|
||
colorspace_settings=SimpleNamespace(name="ACEScg"),
|
||
)
|
||
|
||
class ResultImage:
|
||
def __init__(self):
|
||
self.alpha_mode = ""
|
||
self.colorspace_settings = SimpleNamespace(name="sRGB")
|
||
self.name = ""
|
||
|
||
def pack(self):
|
||
pass
|
||
|
||
result_image = ResultImage()
|
||
source_object = SimpleNamespace(
|
||
name="Source",
|
||
type="EMPTY",
|
||
empty_display_type="IMAGE",
|
||
data=source_image,
|
||
)
|
||
self.fake_bpy.data = SimpleNamespace(
|
||
images=SimpleNamespace(
|
||
load=lambda *_args, **_options: result_image,
|
||
remove=lambda *_args, **_options: None,
|
||
)
|
||
)
|
||
|
||
created = self.image_data.create_color_image(
|
||
source_object,
|
||
output_directory,
|
||
)
|
||
|
||
self.assertIs(created, result_image)
|
||
self.assertEqual(result_image.colorspace_settings.name, "ACEScg")
|
||
self.assertEqual(result_image.name, "source_color.png")
|
||
self.assertIs(source_object.data, result_image)
|
||
|
||
def test_background_result_replaces_only_an_unshared_image(self):
|
||
for remaining_users, removed_source, result_name in (
|
||
(0, True, "Source.png"),
|
||
(1, False, "Source_color.png"),
|
||
):
|
||
with self.subTest(remaining_users=remaining_users):
|
||
source_image = SimpleNamespace(
|
||
name="Source.png",
|
||
use_fake_user=False,
|
||
users=remaining_users,
|
||
)
|
||
source_object = SimpleNamespace(
|
||
name="Source",
|
||
type="EMPTY",
|
||
empty_display_type="IMAGE",
|
||
data=source_image,
|
||
)
|
||
result_image = SimpleNamespace(name="Source_color.png")
|
||
removed = []
|
||
self.fake_bpy.data = SimpleNamespace(
|
||
objects={"Source": source_object},
|
||
images=SimpleNamespace(
|
||
remove=lambda image: removed.append(image),
|
||
),
|
||
)
|
||
|
||
def create_result(target, _directory):
|
||
source_image.use_fake_user = True
|
||
target.data = result_image
|
||
return result_image
|
||
|
||
with (
|
||
patch.object(
|
||
self.image_data,
|
||
"create_color_image",
|
||
side_effect=create_result,
|
||
),
|
||
patch.object(self.image_background, "push_undo") as push_undo,
|
||
):
|
||
self.image_background.apply_background_response(
|
||
SimpleNamespace(directory=Path("result")),
|
||
"Source",
|
||
)
|
||
|
||
self.assertIs(source_object.data, result_image)
|
||
self.assertFalse(source_image.use_fake_user)
|
||
self.assertEqual(removed, [source_image] if removed_source else [])
|
||
self.assertEqual(result_image.name, result_name)
|
||
push_undo.assert_called_once_with("Remove Background")
|
||
|
||
def test_cutout_contour_is_smooth_and_evenly_resampled(self):
|
||
import numpy as np
|
||
|
||
contour = self.cutout_geometry.smooth_lasso_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.polygon_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_lasso_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_cutout_polygon_mask_keeps_nonzero_winding_overlap(self):
|
||
polygon = (
|
||
(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_mask_data.polygon_mask((12, 9), polygon)
|
||
nonzero = self.image_mask_data.polygon_mask((12, 9), polygon, "NONZERO")
|
||
|
||
self.assertFalse(even_odd[3, 5])
|
||
self.assertTrue(nonzero[3, 5])
|
||
|
||
def preview_area(fill_rule):
|
||
triangles = self.image_edit_common.scanline_fill_triangles(
|
||
polygon, fill_rule
|
||
)
|
||
return sum(
|
||
abs(
|
||
self.image_edit_common._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_image_mask_preserves_source_rgb_outside_the_selection(self):
|
||
import numpy as np
|
||
|
||
top_down = np.full((3, 3, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32)
|
||
source = np.flipud(top_down).ravel()
|
||
selection = self.image_mask_data.polygon_mask(
|
||
(3, 3), ((0, 0), (3, 0), (0, 3))
|
||
)
|
||
pixels, bounds = self.image_mask_data.edit_image_mask_pixels(
|
||
source,
|
||
(3, 3),
|
||
selection,
|
||
)
|
||
width = bounds[2] - bounds[0]
|
||
height = bounds[3] - bounds[1]
|
||
result = np.flipud(np.asarray(pixels).reshape((height, width, 4)))
|
||
|
||
self.assertEqual(bounds, (0, 0, 2, 2))
|
||
np.testing.assert_allclose(result[1, 1], (0.2, 0.4, 0.6, 0.0))
|
||
self.assertEqual(float(result[0, 0, 3]), 1.0)
|
||
|
||
def test_image_mask_preserves_hidden_rgb_from_source_transparency(self):
|
||
import numpy as np
|
||
|
||
top_down = np.full((2, 2, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32)
|
||
top_down[0, 0, 3] = 0.0
|
||
pixels, _bounds = self.image_mask_data.edit_image_mask_pixels(
|
||
np.flipud(top_down).ravel(),
|
||
(2, 2),
|
||
np.ones((2, 2), dtype=np.float32),
|
||
)
|
||
result = np.flipud(np.asarray(pixels).reshape((2, 2, 4)))
|
||
|
||
np.testing.assert_allclose(result[0, 0], (0.2, 0.4, 0.6, 0.0))
|
||
|
||
def test_image_mask_invert_runs_after_refinement(self):
|
||
import numpy as np
|
||
|
||
top_down = np.full((2, 2, 4), (0.2, 0.4, 0.6, 1.0), dtype=np.float32)
|
||
refined = np.asarray(((1.0, 0.25), (0.0, 1.0)), dtype=np.float32)
|
||
pixels, bounds = self.image_mask_data.edit_image_mask_pixels(
|
||
np.flipud(top_down).ravel(),
|
||
(2, 2),
|
||
refined,
|
||
invert=True,
|
||
)
|
||
result = np.flipud(np.asarray(pixels).reshape((2, 2, 4)))
|
||
|
||
self.assertEqual(bounds, (0, 0, 2, 2))
|
||
self.assertEqual(float(result[0, 0, 3]), 0.0)
|
||
self.assertAlmostEqual(float(result[0, 1, 3]), 0.75)
|
||
np.testing.assert_allclose(result[0, 0], (0.2, 0.4, 0.6, 0.0))
|
||
|
||
def test_image_mask_rejects_a_fully_transparent_result(self):
|
||
import numpy as np
|
||
|
||
with self.assertRaisesRegex(ValueError, "no visible"):
|
||
self.image_mask_data.edit_image_mask_pixels(
|
||
np.ones(2 * 2 * 4, dtype=np.float32),
|
||
(2, 2),
|
||
np.zeros((2, 2), dtype=np.float32),
|
||
)
|
||
|
||
def test_expected_empty_image_edits_are_reported_as_warnings(self):
|
||
reports = []
|
||
operator = SimpleNamespace(
|
||
report=lambda level, message: reports.append((level, message))
|
||
)
|
||
|
||
self.image_edit_common.report_image_edit_exception(
|
||
operator,
|
||
self.image_edit_common.ImageEditWarning(
|
||
"The edit contains no visible image pixels"
|
||
),
|
||
)
|
||
self.image_edit_common.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_mask_crops_to_visible_alpha_without_padding(self):
|
||
import numpy as np
|
||
|
||
rgba = np.full((4, 5, 4), (0.8, 0.7, 0.6, 0.0), dtype=np.float32)
|
||
rgba[1, 2] = (0.2, 0.4, 0.6, 1.0)
|
||
rgba[2, 3] = (0.2, 0.4, 0.6, 1.0)
|
||
pixels, bounds = self.image_edit_common.crop_pixels_to_alpha(
|
||
np.flipud(rgba).ravel(),
|
||
(10, 20, 15, 24),
|
||
)
|
||
result = np.flipud(np.asarray(pixels).reshape((2, 2, 4)))
|
||
|
||
self.assertEqual(bounds, (12, 21, 14, 23))
|
||
self.assertEqual(result.shape, (2, 2, 4))
|
||
np.testing.assert_allclose(result[0, 1], (0.8, 0.7, 0.6, 0.0))
|
||
|
||
def test_image_mask_restores_refined_alpha_to_source_coordinates(self):
|
||
import numpy as np
|
||
|
||
top_down = np.zeros((2, 2, 4), dtype=np.float32)
|
||
top_down[:, :, 3] = ((1.0, 0.5), (0.0, 1.0))
|
||
mask = self.image_mask_data.refined_mask_from_pixels(
|
||
np.flipud(top_down).ravel(),
|
||
(2, 2),
|
||
(1, 1, 3, 3),
|
||
(4, 4),
|
||
)
|
||
|
||
expected = np.zeros((4, 4), dtype=np.float32)
|
||
expected[1:3, 1:3] = top_down[:, :, 3]
|
||
np.testing.assert_allclose(mask, expected)
|
||
|
||
def test_image_edit_replaces_an_unshared_image_without_leaving_a_copy(self):
|
||
class Pixels:
|
||
values = None
|
||
|
||
def foreach_set(self, values):
|
||
self.values = tuple(values)
|
||
|
||
source_image = SimpleNamespace(
|
||
name="Source.png",
|
||
size=(4, 2),
|
||
colorspace_settings=SimpleNamespace(name="sRGB"),
|
||
use_fake_user=False,
|
||
users=0,
|
||
)
|
||
source_object = SimpleNamespace(
|
||
type="EMPTY",
|
||
empty_display_type="IMAGE",
|
||
data=source_image,
|
||
empty_display_size=1.0,
|
||
empty_image_offset=(0.0, 0.0),
|
||
)
|
||
result_image = SimpleNamespace(
|
||
name="",
|
||
alpha_mode="",
|
||
colorspace_settings=SimpleNamespace(name=""),
|
||
pixels=Pixels(),
|
||
pack=lambda: packed.append(True),
|
||
update=lambda: updated.append(True),
|
||
)
|
||
packed = []
|
||
updated = []
|
||
removed = []
|
||
self.fake_bpy.data = SimpleNamespace(
|
||
objects=[source_object],
|
||
images=SimpleNamespace(
|
||
new=lambda *_args, **_options: result_image,
|
||
remove=lambda image, **_options: removed.append(image),
|
||
),
|
||
)
|
||
pixels = tuple(float(index) for index in range(16))
|
||
with patch.object(
|
||
self.image_edit_common,
|
||
"empty_display_alignment",
|
||
return_value=(2.0, (0.25, -0.25)),
|
||
):
|
||
result = self.image_edit_common.replace_empty_image_pixels(
|
||
source_object, pixels, (2, 2), (0, 0, 2, 2)
|
||
)
|
||
|
||
self.assertIs(result, result_image)
|
||
self.assertIs(source_object.data, result_image)
|
||
self.assertEqual(removed, [source_image])
|
||
self.assertEqual(result_image.name, "Source.png")
|
||
self.assertEqual(result_image.pixels.values, pixels)
|
||
self.assertEqual(packed, [True])
|
||
self.assertEqual(updated, [True])
|
||
self.assertEqual(source_object.empty_display_size, 2.0)
|
||
self.assertEqual(source_object.empty_image_offset, (0.25, -0.25))
|
||
|
||
def test_image_edit_forks_an_image_shared_by_multiple_empties(self):
|
||
class Pixels:
|
||
values = None
|
||
|
||
def foreach_set(self, values):
|
||
self.values = tuple(values)
|
||
|
||
class ResultImage:
|
||
colorspace_settings = SimpleNamespace(name="")
|
||
pixels = Pixels()
|
||
alpha_mode = ""
|
||
|
||
def pack(self):
|
||
pass
|
||
|
||
def update(self):
|
||
pass
|
||
|
||
source_image = SimpleNamespace(
|
||
name="Source.png",
|
||
size=(4, 2),
|
||
colorspace_settings=SimpleNamespace(name="ACEScg"),
|
||
use_fake_user=False,
|
||
users=1,
|
||
)
|
||
source_object = SimpleNamespace(
|
||
type="EMPTY",
|
||
empty_display_type="IMAGE",
|
||
data=source_image,
|
||
empty_display_size=1.0,
|
||
empty_image_offset=(0.0, 0.0),
|
||
)
|
||
sibling = SimpleNamespace(
|
||
type="EMPTY",
|
||
empty_display_type="IMAGE",
|
||
data=source_image,
|
||
)
|
||
result_image = ResultImage()
|
||
created = []
|
||
|
||
def new_image(*args, **options):
|
||
created.append((args, options))
|
||
return result_image
|
||
|
||
self.fake_bpy.data = SimpleNamespace(
|
||
objects=[source_object, sibling],
|
||
images=SimpleNamespace(
|
||
new=new_image,
|
||
remove=lambda *_args, **_options: None,
|
||
),
|
||
)
|
||
pixels = tuple(float(index) for index in range(16))
|
||
with patch.object(
|
||
self.image_edit_common,
|
||
"empty_display_alignment",
|
||
return_value=(2.0, (0.25, -0.25)),
|
||
):
|
||
result = self.image_edit_common.replace_empty_image_pixels(
|
||
source_object, pixels, (2, 2), (0, 0, 2, 2)
|
||
)
|
||
|
||
self.assertIs(result, result_image)
|
||
self.assertIs(source_object.data, result_image)
|
||
self.assertIs(sibling.data, source_image)
|
||
self.assertEqual(len(created), 1)
|
||
self.assertEqual(result_image.colorspace_settings.name, "ACEScg")
|
||
self.assertEqual(result_image.pixels.values, pixels)
|
||
|
||
def test_image_edit_tools_and_operators_are_registered(self):
|
||
operator = self.image_mask.EditImageMask
|
||
self.assertIn(operator, self.addon.CLASSES)
|
||
self.assertIn(self.image_mask.RefineImageMask, self.addon.CLASSES)
|
||
self.assertIn(self.image_mask.ActivateImageTool, self.addon.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.addon.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()))
|
||
layout = SimpleNamespace(prop=lambda *_args: None)
|
||
with patch.object(self.image_box, "draw_image_mask_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_image_mask_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__]
|
||
with patch.object(cutout_module, "draw_image_mask_settings") as draw:
|
||
cutout_tool.draw_settings(context, layout, None)
|
||
draw.assert_called_once_with(context, layout, "cutout_refine_selection")
|
||
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.addon.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_point_spacing"]["default"], 32)
|
||
self.assertEqual(settings["cutout_point_spacing"]["min"], 4)
|
||
self.assertEqual(settings["cutout_point_spacing"]["max"], 32)
|
||
self.assertFalse(settings["cutout_normal_analysis"]["default"])
|
||
self.assertNotIn("image_extract_continuous", settings)
|
||
self.assertNotIn("image_extract_padding", settings)
|
||
self.assertNotIn("image_extract_remove_background", settings)
|
||
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_edit_common, "ai_ready", return_value=True):
|
||
for gesture in ("LINE", "POLYLINE"):
|
||
operator = SimpleNamespace(
|
||
gesture=gesture,
|
||
refine_selection=True,
|
||
)
|
||
self.image_edit_common.apply_image_mask_settings(operator, context)
|
||
self.assertFalse(operator.refine_selection)
|
||
|
||
box = SimpleNamespace(gesture="BOX", refine_selection=True)
|
||
self.image_edit_common.apply_image_mask_settings(box, context)
|
||
self.assertFalse(box.refine_selection)
|
||
|
||
lasso = SimpleNamespace(gesture="LASSO", refine_selection=False)
|
||
self.image_edit_common.apply_image_mask_settings(lasso, context)
|
||
self.assertTrue(lasso.refine_selection)
|
||
|
||
cutout = SimpleNamespace(
|
||
gesture="LASSO",
|
||
active_tool_id="anyimage.cutout_lasso",
|
||
refine_selection=False,
|
||
)
|
||
self.image_edit_common.apply_image_mask_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_edit_common.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_edit_common.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_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_edit_common.triangulated_polygon_vertices(polygon)
|
||
area = sum(
|
||
abs(
|
||
self.image_edit_common._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_edit_common.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_edit_common.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_edit_common.preview_fill_triangles(
|
||
polygon, 'NONZERO'
|
||
)
|
||
area = sum(
|
||
abs(
|
||
self.image_edit_common._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_edit_common.polygon_self_intersects(
|
||
((0, 0), (4, 4), (0, 4), (4, 0))
|
||
)
|
||
)
|
||
self.assertFalse(
|
||
self.image_edit_common.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.addon.properties.AnyImageSettings.__annotations__
|
||
|
||
self.assertIn(operator, self.addon.CLASSES)
|
||
self.assertNotIn(tool, self.addon.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_gesture.box_screen_path((2, 3), (8, 9)),
|
||
((2, 3), (8, 3), (8, 9), (2, 9)),
|
||
)
|
||
self.assertEqual(
|
||
self.image_gesture.box_screen_path((2, 3), (3, 9)),
|
||
(),
|
||
)
|
||
|
||
def test_image_box_completion_serializes_the_pixel_selection(self):
|
||
source = SimpleNamespace(data=SimpleNamespace(size=(64, 32)))
|
||
finished = []
|
||
operator = SimpleNamespace(
|
||
source_object_name="Source",
|
||
source_matrix_data="matrix",
|
||
gesture="BOX",
|
||
selection_data="",
|
||
_screen_polygon=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.fake_bpy.data.objects, "get", return_value=source
|
||
),
|
||
patch.object(self.image_gesture, "is_image_empty", return_value=True),
|
||
patch.object(self.image_gesture, "deserialize_matrix"),
|
||
patch.object(self.image_gesture, "plane_bounds"),
|
||
patch.object(
|
||
self.image_gesture,
|
||
"screen_path_to_image_pixels",
|
||
return_value=polygon,
|
||
),
|
||
):
|
||
result = self.image_gesture.ImageMaskGesture._complete(
|
||
operator, context
|
||
)
|
||
|
||
self.assertEqual(result, {"FINISHED"})
|
||
self.assertEqual(operator.selection_data, "[[1,2],[3,4],[5,6]]")
|
||
self.assertEqual(finished, [context])
|
||
|
||
def test_image_edit_selection_uses_white_dashed_segments(self):
|
||
segments = self.image_edit_common.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_polygon=lambda: polygon,
|
||
)
|
||
with (
|
||
patch.object(self.image_gesture, 'drawing_in_region', return_value=True),
|
||
patch.object(
|
||
self.image_gesture,
|
||
'dashed_line_segments',
|
||
return_value=('segments',),
|
||
) as build_outline,
|
||
patch.object(
|
||
self.image_gesture,
|
||
'preview_fill_triangles',
|
||
return_value=('triangles',),
|
||
) as build_fill,
|
||
patch.object(self.image_gesture, 'draw_polygon_fill') as draw_fill,
|
||
patch.object(self.image_gesture, 'draw_dashed_line') as draw_outline,
|
||
):
|
||
self.image_gesture.ImageMaskGesture._draw_selection(operator)
|
||
self.image_gesture.ImageMaskGesture._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_gesture.append_lasso_point(path, (0.5, 0.5)))
|
||
self.assertEqual(path, [(0.0, 0.0)])
|
||
self.assertTrue(self.image_gesture.append_lasso_point(path, (3.0, 0.0)))
|
||
self.assertTrue(self.image_gesture.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_gesture.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.image_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_gesture.snapped_line_endpoint((0, 0), (10, 1), True)
|
||
self.assertAlmostEqual(math.degrees(math.atan2(endpoint[1], endpoint[0])), 5.0)
|
||
preview = self.image_gesture.line_half_plane_polygon(
|
||
(0, 5), (10, 5), (10, 10)
|
||
)
|
||
image = self.image_gesture.image_line_half_plane_polygon(
|
||
((0, 5), (10, 5)), (10, 10)
|
||
)
|
||
self.assertEqual(self.image_edit_common.polygon_bounds(preview), (0, 5, 10, 10))
|
||
self.assertEqual(self.image_edit_common.polygon_bounds(image), (0, 0, 10, 5))
|
||
|
||
def test_cutout_interior_points_have_uniform_spacing(self):
|
||
import numpy as np
|
||
|
||
contour = np.asarray(((0, 0), (4, 0), (4, 4), (0, 4)), dtype=float)
|
||
points = self.cutout_geometry.uniform_interior_points(contour, 0.5)
|
||
distances = np.linalg.norm(points[:, None] - points[None, :], axis=2)
|
||
np.fill_diagonal(distances, np.inf)
|
||
nearest = distances.min(axis=1)
|
||
|
||
self.assertGreater(len(points), 20)
|
||
self.assertAlmostEqual(float(np.median(nearest)), 0.5, delta=0.02)
|
||
self.assertGreater(float(nearest.min()), 0.49)
|
||
|
||
def test_cutout_default_sampling_stays_coarse(self):
|
||
import numpy as np
|
||
|
||
polygon = np.asarray(
|
||
((100, 100), (900, 100), (950, 500), (800, 900), (200, 900), (50, 500)),
|
||
dtype=float,
|
||
)
|
||
contour, interior, _spacing = self.cutout_geometry.prepare_planar_points(
|
||
polygon,
|
||
(1000, 1000),
|
||
(0.0, 1.0, 0.0, 1.0),
|
||
)
|
||
|
||
self.assertLess(len(contour), 150)
|
||
self.assertLess(len(interior), 400)
|
||
|
||
def test_cutout_sampling_uses_world_distance(self):
|
||
import numpy as np
|
||
|
||
polygon = np.asarray(
|
||
((0, 0), (1000, 0), (1000, 1000), (0, 1000)),
|
||
dtype=float,
|
||
)
|
||
unit_contour, unit_interior, unit_spacing = (
|
||
self.cutout_geometry.prepare_planar_points(
|
||
polygon,
|
||
(1000, 1000),
|
||
(0.0, 1.0, 0.0, 1.0),
|
||
)
|
||
)
|
||
scaled_contour, scaled_interior, scaled_spacing = (
|
||
self.cutout_geometry.prepare_planar_points(
|
||
polygon,
|
||
(1000, 1000),
|
||
(0.0, 2.0, 0.0, 2.0),
|
||
world_metric=((0.25, 0.0), (0.0, 0.25)),
|
||
)
|
||
)
|
||
|
||
self.assertEqual(unit_spacing, 0.1)
|
||
self.assertEqual(scaled_spacing, 0.1)
|
||
self.assertEqual(len(unit_contour), len(scaled_contour))
|
||
self.assertEqual(len(unit_interior), len(scaled_interior))
|
||
|
||
def test_image_operators_stay_clickable_until_server_is_busy(self):
|
||
context = SimpleNamespace(
|
||
object=SimpleNamespace(data=SimpleNamespace()),
|
||
)
|
||
operators = (
|
||
(self.mesh, self.mesh.ConvertToDepthPlane),
|
||
(self.image_background, self.image_background.RemoveImageBackground),
|
||
)
|
||
|
||
for busy, expected in ((False, True), (True, False)):
|
||
with (
|
||
self.subTest(busy=busy),
|
||
patch.object(
|
||
self.addon.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.addon.runtime,
|
||
"storage_root",
|
||
return_value=root,
|
||
),
|
||
patch.object(
|
||
self.addon.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.addon.runtime.server,
|
||
"request",
|
||
return_value=result,
|
||
) as request:
|
||
returned = self.addon.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.addon.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.addon.clipboard_image.PasteClipboardImage,
|
||
self.addon.CLASSES,
|
||
)
|
||
self.assertIn(
|
||
self.addon.clipboard_image.TrackNativeCopy,
|
||
self.addon.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.addon.register()
|
||
system_classes = self.addon.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.addon.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.addon.draw_image_context_menu,
|
||
)
|
||
self.assertIs(
|
||
self.outliner_menu_draws[0],
|
||
self.addon.draw_image_context_menu,
|
||
)
|
||
self.assertTrue(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"))
|
||
|
||
self.addon.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.addon.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()
|