Files
AnyImage/tests/test_addon.py
nan 32bcd45d7b Add new node asset scripts for geometry and shader operations
- 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.
2026-08-30 00:17:29 +08:00

2824 lines
105 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()