Files
AnyImage/tests/test_addon.py
nan 1613bd3194 Add image perspective editing and related tools
- Implemented the Image Perspective tool for editing image corners in Blender.
- Created a new operator `EditImagePerspective` to handle the perspective transformation.
- Added a preview feature for the perspective adjustment.
- Introduced the `ImagePolylineTool` for editing image masks with polylines.
- Developed backend support for Real-ESRGAN model inference, including ONNX integration.
- Added functionality for image masking and background removal.
- Created unit tests for the Real-ESRGAN ONNX model to ensure correct preprocessing and inference.
2026-08-22 22:17:10 +08:00

2475 lines
94 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,
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),
remove=lambda draw: cls.context_menu_draws.remove(draw),
),
OUTLINER_MT_object=SimpleNamespace(
append=lambda draw: cls.outliner_menu_draws.append(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.models")
cls.image_depth = importlib.import_module("anyimage.operators.material")
cls.image_data = importlib.import_module("anyimage.operators.image")
cls.depth_operator = importlib.import_module(
"anyimage.operators.depth_plane.operator"
)
cls.debug = importlib.import_module("anyimage.operators.debug")
cls.depth_build = importlib.import_module(
"anyimage.operators.depth_plane.build"
)
cls.mesh_plane = importlib.import_module(
"anyimage.operators.mesh_plane"
)
cls.image_background = importlib.import_module("anyimage.operators.background")
cls.image_mesh = importlib.import_module("anyimage.operators.surface")
cls.image_edit_common = importlib.import_module(
"anyimage.operators.image_edit.common"
)
cls.image_mask = importlib.import_module(
"anyimage.operators.image_edit.mask"
)
cls.image_box = importlib.import_module(
"anyimage.operators.image_edit.box"
)
cls.image_lasso = importlib.import_module(
"anyimage.operators.image_edit.lasso"
)
cls.image_line = importlib.import_module(
"anyimage.operators.image_edit.line"
)
cls.image_polyline = importlib.import_module(
"anyimage.operators.image_edit.polyline"
)
cls.image_perspective = importlib.import_module(
"anyimage.operators.image_edit.perspective"
)
cls.balloon = importlib.import_module("anyimage.operators.balloon.operator")
cls.balloon_geometry = importlib.import_module(
"anyimage.operators.balloon.geometry"
)
cls.balloon_depth = importlib.import_module("anyimage.operators.balloon.depth")
cls.balloon_poisson = importlib.import_module(
"anyimage.operators.balloon.poisson"
)
cls.balloon_teddy = importlib.import_module("anyimage.operators.balloon.teddy")
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",
)
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
def test_runtime_and_operator_support_modules_have_explicit_boundaries(self):
for name in (
"client.py",
"environment.py",
"storage.py",
"models.py",
"status_bar.py",
):
self.assertFalse((SOURCE_ROOT / "anyimage" / name).exists(), name)
self.assertTrue((SOURCE_ROOT / "anyimage" / "runtime.py").is_file())
self.assertFalse((SOURCE_ROOT / "anyimage" / "job_runtime.py").exists())
self.assertFalse((SOURCE_ROOT / "anyimage" / "client").exists())
self.assertFalse((SOURCE_ROOT / "anyimage" / "ui").exists())
self.assertFalse((SOURCE_ROOT / "anyimage" / "operators.py").exists())
self.assertFalse((SOURCE_ROOT / "anyimage" / "blender").exists())
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)
self.assertFalse(
any(
name == "anyimage.server" or name.startswith("anyimage.server.")
for name in sys.modules
)
)
def test_job_request_is_not_part_of_blendjob(self):
blendjob = importlib.import_module("blendjob")
self.assertFalse(hasattr(blendjob, "JobRequest"))
def test_operator_modules_use_plain_properties(self):
operators = self.addon.operators
for name in (
"AnyImageJobMixin",
"ImageInputJobOperator",
"GeometryImageJobOperator",
"Da3ImageJobOperator",
"Moge2ImageJobOperator",
"BackgroundRequestMixin",
):
self.assertFalse(hasattr(operators, name), name)
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):
operator = self.addon.operators.GenerateMoge2Depth
self.assertEqual(operator.bl_idname, "anyimage.generate_moge2_depth")
self.assertIn(self.job_runtime.JobOperatorBase, operator.__mro__)
self.assertIn(self.fake_bpy.types.Operator, operator.__mro__)
def test_realesrgan_debug_operator_is_a_direct_model_request(self):
operator_type = self.debug.DebugRealesrganGeneralX4v3
self.assertIn(operator_type, self.addon.CLASSES)
self.assertEqual(
operator_type.bl_idname,
"anyimage.debug_realesrgan_general_x4v3",
)
self.assertEqual(
operator_type.job_type,
"debug-realesrgan-general-x4v3",
)
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)
)
with (
patch.object(self.debug, "require_environment"),
patch.object(self.debug, "require_model") as require_model,
):
request = operator_type().request(context)
require_model.assert_called_once_with(
self.addon.properties.REALESRGAN_MODEL_KEY
)
self.assertEqual(
request,
{
"input": str(image_path),
"model": "REALESRGAN_GENERAL_X4V3",
"device": "cpu",
"video_frames": 3,
"start_frame": 1,
},
)
self.assertNotIn("tile", request)
self.assertNotIn("strength", request)
panel = importlib.import_module("anyimage.panel")
self.assertIn(
panel.RealesrganGeneralX4v3DebugPanel,
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"}),
],
)
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",
):
self.job_runtime.post_install(SimpleNamespace(request=request))
self.assertEqual(calls, ["MOGE2_VITS_NORMAL", "BEN2_BASE"])
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_default_to_moge2_vits(self):
properties = importlib.import_module("anyimage.properties")
catalog = (
{
"key": "SMALL",
"family": "da3",
"enabled": True,
"label": "DA3 Small",
"description": "DA3",
"license": "Apache",
},
{
"key": "MOGE2_VITS_NORMAL",
"family": "moge2",
"enabled": True,
"label": "MoGe-2 ViT-S",
"description": "MoGe",
"license": "MIT",
},
)
with patch.object(properties, "model_catalog", return_value=catalog):
depth_items = properties.depth_plane_model_enum_items(None, None)
surface_items = properties.surface_model_enum_items(None, None)
balloon_items = properties.balloon_depth_model_enum_items(None, None)
self.assertEqual(depth_items[0][0], "MOGE2_VITS_NORMAL")
self.assertEqual(surface_items[0][0], "MOGE2_VITS_NORMAL")
self.assertEqual([item[0] for item in balloon_items], ["MOGE2_VITS_NORMAL"])
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"]},
)
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},
),
),
):
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,
)
panel = importlib.import_module("anyimage.panel")
self.assertFalse(hasattr(panel, "ModelsPanel"))
def test_preferences_draws_upscale_model_download(self):
preference_module = importlib.import_module("anyimage.preferences")
downloads = []
class Layout:
enabled = True
def prop(self, *_args, **_options):
return None
def separator(self, **_options):
return None
def label(self, **_options):
return None
def row(self, **_options):
return 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()
catalog = ({
"key": "REALESRGAN_GENERAL_X4V3",
"label": "Real-ESRGAN General x4v3",
"family": "upscale",
"enabled": True,
"ready": False,
},)
with (
patch.object(
preference_module,
"ai_status",
return_value={
"environment_ready": True,
"missing_models": (),
"ready": True,
},
),
patch.object(
preference_module,
"model_catalog",
return_value=catalog,
),
patch.object(preference_module.runtime, "server_busy", return_value=False),
):
preferences.draw(None)
self.assertEqual(len(downloads), 1)
self.assertEqual(downloads[0].model, "REALESRGAN_GENERAL_X4V3")
def test_mesh_plane_operator_is_registered_with_material_options(self):
operator = self.mesh_plane.ConvertToMeshPlane
self.assertIn(operator, self.addon.CLASSES)
self.assertEqual(operator.bl_idname, "anyimage.convert_to_mesh_plane")
self.assertFalse(operator.__annotations__["shadeless"]["default"])
self.assertFalse(operator.__annotations__["keep_original"]["default"])
self.assertIn("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.assertTrue(operator.poll(SimpleNamespace(object=mesh_plane)))
self.assertFalse(operator.poll(SimpleNamespace(object=plain_mesh)))
def test_mesh_plane_redo_keeps_the_original_world_matrix(self):
operator = self.mesh_plane.ConvertToMeshPlane()
operator.shadeless = False
operator.keep_original = True
operator.source_object_name = ""
original_matrix = (
(1.0, 0.0, 0.0, 3.0),
(0.0, 1.0, 0.0, -2.0),
(0.0, 0.0, 1.0, 7.0),
(0.0, 0.0, 0.0, 1.0),
)
source = SimpleNamespace(
name="Source Image",
type="EMPTY",
empty_display_type="IMAGE",
data=object(),
matrix_world=original_matrix,
)
context = SimpleNamespace(object=source)
matrices = []
def capture_build(*_args, **kwargs):
matrices.append(kwargs["matrix_world"])
with (
patch.object(
self.mesh_plane.bpy.data.objects,
"get",
return_value=source,
),
patch.object(
self.mesh_plane,
"restore_matrix",
side_effect=lambda values: tuple(values),
),
patch.object(
self.mesh_plane,
"build_mesh_plane",
side_effect=capture_build,
),
):
self.assertEqual(operator.invoke(context, None), {"FINISHED"})
source.matrix_world = (
(1.0, 0.0, 0.0, 0.0),
(0.0, 1.0, 0.0, 0.0),
(0.0, 0.0, 1.0, 0.0),
(0.0, 0.0, 0.0, 1.0),
)
self.assertEqual(operator.execute(context), {"FINISHED"})
expected = self.mesh_plane.flatten_matrix(original_matrix)
self.assertEqual(matrices, [expected, expected])
def test_mesh_plane_uses_a_single_quad_and_geometry_nodes(self):
import inspect
mesh_source = inspect.getsource(self.mesh_plane.create_mesh_plane_mesh)
processing_source = inspect.getsource(
self.mesh_plane.add_mesh_plane_processing
)
self.assertIn("((0, 1, 2, 3),)", mesh_source)
self.assertIn('("Subdivide", max(0, int(subdivisions)))', processing_source)
self.assertIn('("Thickness", max(0.0, float(thickness)))', processing_source)
self.assertIn('("Material", material)', processing_source)
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(None)
self.assertEqual(
calls,
[
("anyimage.convert_to_mesh_plane", True),
("anyimage.convert_to_depth_plane", True),
("anyimage.convert_to_surface", True),
("anyimage.remove_image_background", True),
("separator", True),
("anyimage.setup_ai_environment", True),
],
)
self.assertEqual(
labels["anyimage.setup_ai_environment"],
"Download Required Models…",
)
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_balloon_contour_is_smooth_and_evenly_resampled(self):
import numpy as np
contour = self.balloon_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.balloon_geometry.polygon_area(contour), 0.0)
self.assertLess(float(lengths.std() / lengths.mean()), 0.15)
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.polygon_mask(
(3, 3), ((0, 0), (3, 0), (0, 3))
)
pixels, bounds = self.image_mask.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.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.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.edit_image_mask_pixels(
np.ones(2 * 2 * 4, dtype=np.float32),
(2, 2),
np.zeros((2, 2), dtype=np.float32),
)
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.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_aligns_canvas_without_changing_object_transform(self):
matrix = object()
image_object = SimpleNamespace(
matrix_world=matrix,
empty_display_size=4.0,
empty_image_offset=(0.0, 0.0),
)
with patch.object(
self.image_edit_common,
"plane_bounds",
return_value=(-2.0, 2.0, -1.0, 1.0),
):
self.image_edit_common.align_empty_display_to_bounds(
image_object,
(100, 50),
(50, 25),
(50, 0, 100, 25),
)
self.assertIs(image_object.matrix_world, matrix)
self.assertEqual(image_object.empty_display_size, 2.0)
self.assertEqual(image_object.empty_image_offset, (0.0, 0.0))
def test_image_edit_tools_and_operators_are_registered(self):
import inspect
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.ActivateImageEditTool, 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__)
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__
self.assertFalse(settings["image_edit_refine_selection"]["default"])
self.assertNotIn("image_extract_continuous", settings)
self.assertNotIn("image_extract_padding", settings)
self.assertNotIn("image_extract_remove_background", settings)
source_picker = inspect.getsource(self.image_edit_common.resolve_image_edit_click)
self.assertIn("bpy.ops.view3d.select(", source_picker)
self.assertIn("Click an Image Empty", source_picker)
self.assertIn("_restore_selection(", source_picker)
modal_source = inspect.getsource(operator.modal)
self.assertIn('event.type == "F"', modal_source)
self.assertIn("self.invert = not self.invert", modal_source)
def test_image_line_and_polyline_disable_refine_selection(self):
settings = SimpleNamespace(image_edit_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)
operator = SimpleNamespace(gesture="BOX", refine_selection=False)
self.image_edit_common.apply_image_mask_settings(operator, context)
self.assertTrue(operator.refine_selection)
def test_image_line_click_is_cancelled_before_projection(self):
import inspect
modal_source = inspect.getsource(self.image_mask.EditImageMask.modal)
projection_source = inspect.getsource(
self.image_mask.image_line_half_plane_polygon
)
self.assertIn("math.dist(self._path[0], self._cursor) < 2.0", modal_source)
self.assertIn("if len(line) != 2", projection_source)
def test_image_polyline_closes_from_start_or_enter(self):
import inspect
modal_source = inspect.getsource(
self.image_mask.EditImageMask._modal_polyline
)
draw_source = inspect.getsource(
self.image_mask.EditImageMask._draw_selection
)
self.assertIn("return self._confirm_polyline(context)", modal_source)
self.assertNotIn("DOUBLE_CLICK", modal_source)
self.assertIn('event.type in {"RET", "NUMPAD_ENTER"}', modal_source)
self.assertIn("_draw_polyline_close_indicator()", draw_source)
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_perspective_builds_polyline_mask_fill_triangles(self):
self.assertEqual(
self.image_edit_common.triangle_fan_vertices(
((0, 0), (2, 0), (2, 2), (0, 2))
),
(
(0.0, 0.0),
(2.0, 0.0),
(2.0, 2.0),
(0.0, 0.0),
(2.0, 2.0),
(0.0, 2.0),
),
)
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_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_uses_four_clicks_and_deform_behavior(self):
import inspect
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__)
modal_source = inspect.getsource(operator.modal)
invoke_source = inspect.getsource(operator.invoke)
execute_source = inspect.getsource(operator.execute)
draw_source = inspect.getsource(operator._draw_overlay)
preview_source = inspect.getsource(operator._draw_preview)
finish_source = inspect.getsource(operator._finish)
self.assertIn("if len(self._points) < 4", modal_source)
self.assertIn("return self.execute(context)", modal_source)
self.assertIn('self._phase = "ASPECT"', modal_source)
self.assertIn("create_perspective_preview_image(", modal_source)
self.assertIn("self._update_aspect(context, event)", modal_source)
self.assertIn("context.window.cursor_warp(", modal_source)
self.assertIn("perspective_quad_aspect(polygon)", modal_source)
self.assertIn("active_view3d_tool_id(context)", modal_source)
self.assertIn("drawing_in_region(", modal_source)
self.assertNotIn("context.region_data.is_perspective", invoke_source)
self.assertIn("resolve_image_edit_click(", invoke_source)
self.assertIn("extract_perspective_pixels(", execute_source)
self.assertIn("create_perspective_result(", execute_source)
self.assertIn("draw_polygon_fill(vertices)", draw_source)
self.assertIn("self._draw_preview()", draw_source)
self.assertIn("draw_texture_2d(", preview_source)
self.assertIn("draw_preview_frame(", preview_source)
self.assertIn("draw_aspect_hud(", preview_source)
self.assertIn("event_timer_remove", finish_source)
self.assertIn("self._remove_preview()", finish_source)
self.assertNotIn('"RET"', modal_source)
def test_image_box_builds_a_rectangular_screen_path(self):
self.assertEqual(
self.image_mask.box_screen_path((2, 3), (8, 9)),
((2, 3), (8, 3), (8, 9), (2, 9)),
)
self.assertEqual(
self.image_mask.box_screen_path((2, 3), (3, 9)),
(),
)
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_edit_activation_defaults_to_box(self):
import inspect
source = inspect.getsource(self.image_mask.ActivateImageEditTool.execute)
self.assertIn('name="anyimage.image_box"', source)
def test_image_line_snaps_and_keeps_the_previewed_half_plane(self):
import math
endpoint = self.image_mask.snapped_line_endpoint((0, 0), (10, 1), True)
self.assertAlmostEqual(math.degrees(math.atan2(endpoint[1], endpoint[0])), 5.0)
preview = self.image_mask.line_half_plane_polygon(
(0, 5), (10, 5), (10, 10)
)
image = self.image_mask.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_balloon_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.balloon_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_balloon_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.balloon_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_balloon_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.balloon_geometry.prepare_planar_points(
polygon,
(1000, 1000),
(0.0, 1.0, 0.0, 1.0),
)
)
scaled_contour, scaled_interior, scaled_spacing = (
self.balloon_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_poisson_balloon_is_zero_on_boundary_and_positive_inside(self):
import numpy as np
points = np.asarray(
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
dtype=float,
)
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
heights = self.balloon_poisson.poisson_balloon_heights(
points, faces, boundary, 0.45
)
self.assertTrue((heights[:4] == 0.0).all())
self.assertGreater(heights[4], 0.0)
def test_poisson_balloon_scales_in_world_units(self):
import numpy as np
points = np.asarray(
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
dtype=float,
)
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
original = self.balloon_poisson.poisson_balloon_heights(
points, faces, boundary, 0.45
)
scaled = self.balloon_poisson.poisson_balloon_heights(
points * 2.0, faces, boundary, 0.45
)
self.assertAlmostEqual(scaled[4], original[4] * 2.0, places=6)
def test_poisson_balloon_makes_wide_regions_thicker(self):
import numpy as np
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
wide = np.asarray(
((0, 0), (4, 0), (4, 2), (0, 2), (2, 1)),
dtype=float,
)
narrow = np.asarray(
((0, 0), (4, 0), (4, 0.6), (0, 0.6), (2, 0.3)),
dtype=float,
)
wide_height = self.balloon_poisson.poisson_balloon_heights(
wide, faces, boundary, 0.45
)[4]
narrow_height = self.balloon_poisson.poisson_balloon_heights(
narrow, faces, boundary, 0.45
)[4]
self.assertGreater(wide_height, narrow_height)
def test_poisson_balloon_supports_resolution_002(self):
import numpy as np
spacing = 0.02
size = 51
axis = np.arange(size, dtype=float) * spacing
points = np.asarray(
[(x, y) for y in axis for x in axis],
dtype=float,
)
faces = []
for row in range(size - 1):
for column in range(size - 1):
bottom_left = row * size + column
bottom_right = bottom_left + 1
top_left = bottom_left + size
top_right = top_left + 1
faces.append((bottom_left, bottom_right, top_right))
faces.append((bottom_left, top_right, top_left))
boundary = np.zeros(len(points), dtype=bool)
boundary[:size] = True
boundary[-size:] = True
boundary[::size] = True
boundary[size - 1 :: size] = True
heights = self.balloon_poisson.poisson_balloon_heights(
points,
faces,
boundary,
0.45,
)
self.assertTrue(np.isfinite(heights).all())
self.assertTrue((heights[boundary] == 0.0).all())
self.assertGreater(heights[(size * size) // 2], 0.0)
def test_poisson_solver_has_no_absolute_scale_cutoff(self):
import numpy as np
points = np.asarray(
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
dtype=float,
)
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
original = self.balloon_poisson.poisson_balloon_heights(
points,
faces,
boundary,
0.45,
)
tiny = self.balloon_poisson.poisson_balloon_heights(
points * 1e-5,
faces,
boundary,
0.45,
)
self.assertAlmostEqual(tiny[4], original[4] * 1e-5, places=12)
def test_teddy_balloon_uses_chordal_axis_radius(self):
import numpy as np
points = np.asarray(
((0, 0), (4, 0), (4, 2), (0, 2), (2, 1)),
dtype=float,
)
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
nodes = np.asarray(((1, 1), (3, 1)), dtype=float)
with patch.object(
self.balloon_teddy,
"chordal_axis",
return_value=(nodes, [(0, 1)], np.asarray((1.0, 1.0))),
):
heights = self.balloon_teddy.teddy_balloon_heights(
points, faces, points[:4], boundary, 0.45
)
self.assertTrue((heights[:4] == 0.0).all())
self.assertAlmostEqual(heights[4], 0.45, places=6)
def test_balloon_dispatches_distinct_height_algorithms(self):
import numpy as np
points = np.asarray(((0, 0),), dtype=float)
boundary = np.asarray((True,))
with (
patch.object(
self.balloon_geometry,
"poisson_balloon_heights",
return_value=np.asarray((1.0,)),
) as poisson,
patch.object(
self.balloon_geometry,
"teddy_balloon_heights",
return_value=np.asarray((2.0,)),
) as teddy,
):
poisson_result = self.balloon_geometry.balloon_heights(
"POISSON", points, (), points, boundary, 0.45
)
teddy_result = self.balloon_geometry.balloon_heights(
"TEDDY", points, (), points, boundary, 0.45
)
self.assertEqual(poisson_result[0], 1.0)
self.assertEqual(teddy_result[0], 2.0)
poisson.assert_called_once()
teddy.assert_called_once()
def test_balloon_geometry_has_a_shared_closed_seam(self):
from collections import Counter
import numpy as np
planar = np.asarray(
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
dtype=float,
)
faces_2d = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
heights = np.asarray((0, 0, 0, 0, 1), dtype=float)
vertices, faces, uv = self.balloon_geometry.build_closed_geometry(
planar,
faces_2d,
boundary,
heights,
(0.0, 2.0, 0.0, 2.0),
)
edges = Counter()
for face in faces:
for start, end in zip(face, face[1:] + face[:1]):
edges[tuple(sorted((start, end)))] += 1
self.assertEqual(len(vertices), 6)
self.assertEqual(len(vertices), len(uv))
self.assertEqual(uv.dtype, np.float32)
self.assertTrue(edges)
self.assertTrue(all(users == 2 for users in edges.values()))
def test_balloon_tool_operators_are_registered(self):
self.assertIn(self.balloon.ExtractBalloon, self.addon.CLASSES)
self.assertIn(self.balloon.ActivateBalloonTool, self.addon.CLASSES)
self.assertNotIn(self.balloon.BalloonTool, self.addon.CLASSES)
self.assertLess(
self.addon.CLASSES.index(self.balloon.ExtractBalloon),
self.addon.CLASSES.index(self.balloon.ActivateBalloonTool),
)
def test_balloon_ai_analysis_is_registered_and_forwarded(self):
import inspect
self.assertIn(self.balloon.AnalyzeBalloon, self.addon.CLASSES)
operator = self.balloon.ExtractBalloon
self.assertIn("contour_analysis", operator.__annotations__)
self.assertIn("depth_analysis", operator.__annotations__)
self.assertIn("depth_model", operator.__annotations__)
settings = self.addon.properties.AnyImageSettings.__annotations__
for name in (
"balloon_contour_analysis",
"balloon_depth_analysis",
):
self.assertIn(name, settings)
self.assertFalse(settings[name]["default"])
self.assertIn("balloon_depth_model", settings)
self.assertEqual(settings["balloon_contour_analysis"]["name"], "Contour")
self.assertEqual(settings["balloon_depth_analysis"]["name"], "Depth")
execute_source = inspect.getsource(operator.execute)
invoke_source = inspect.getsource(operator.invoke)
request_source = inspect.getsource(self.balloon.AnalyzeBalloon.request)
response_source = inspect.getsource(self.balloon.AnalyzeBalloon.response)
tool_source = inspect.getsource(self.balloon.BalloonTool.draw_settings)
self.assertIn("bpy.ops.anyimage.analyze_balloon", execute_source)
self.assertIn("ready = ai_ready()", invoke_source)
self.assertIn("status = ai_status()", tool_source)
self.assertIn('"anyimage.setup_ai_environment"', tool_source)
self.assertIn("ai_setup_label(status)", tool_source)
self.assertIn("prepare_image_input(", execute_source)
self.assertIn(
"settings.balloon_contour_analysis",
invoke_source,
)
self.assertIn(
"settings.balloon_depth_analysis",
invoke_source,
)
self.assertIn("self.depth_model = settings.balloon_depth_model", invoke_source)
self.assertIn("require_model(BEN2_MODEL_KEY)", request_source)
self.assertIn(
"require_model(self.depth_model)",
request_source,
)
self.assertIn('"polygon": polygon', request_source)
self.assertIn('result.file("contour")', response_source)
self.assertIn('result.file("depth")', response_source)
self.assertIn("depth_data=depth_data", response_source)
self.assertIn('push_undo("Extract Balloon")', response_source)
self.assertIn('"balloon_contour_analysis"', tool_source)
self.assertIn('"balloon_depth_analysis"', tool_source)
self.assertIn('"balloon_depth_model"', tool_source)
def test_balloon_analysis_requires_only_enabled_models(self):
operator = self.balloon.AnalyzeBalloon()
operator.input_path = "input.png"
operator.selection_data = "[[0,0],[2,0],[0,2]]"
operator.resolution_level = 5
operator.depth_model = "MOGE2_VITB_NORMAL"
required = []
with (
patch.object(self.balloon, "require_environment"),
patch.object(
self.balloon,
"require_model",
side_effect=required.append,
),
patch.object(
self.balloon,
"production_device",
return_value="CUDA",
),
):
operator.contour_analysis = True
operator.depth_analysis = False
contour_request = operator.request(None)
operator.contour_analysis = False
operator.depth_analysis = True
depth_request = operator.request(None)
self.assertEqual(
required,
[
self.addon.properties.BEN2_MODEL_KEY,
"MOGE2_VITB_NORMAL",
],
)
self.assertTrue(contour_request["contour_analysis"])
self.assertFalse(contour_request["depth_analysis"])
self.assertFalse(depth_request["contour_analysis"])
self.assertTrue(depth_request["depth_analysis"])
self.assertEqual(depth_request["depth_model"], "MOGE2_VITB_NORMAL")
def test_balloon_depth_fields_and_shape_nodes(self):
import inspect
import numpy as np
depth_data = {
"depth_signal": np.asarray(
((1.0, -2.0), (0.5, 0.0)),
dtype=np.float32,
),
"baseline_vector": np.zeros(3, dtype=np.float32),
"valid": np.ones((2, 2), dtype=bool),
"origin": (0, 0),
"crop_size": (2, 2),
"source_size": (2, 2),
}
vertices = (
(0.0, 0.0, 2.0),
(0.0, 0.0, 2.0),
(0.0, 0.0, -2.0),
(0.0, 0.0, -2.0),
)
uv = ((0.0, 1.0), (1.0, 1.0), (0.0, 0.0), (1.0, 0.0))
values, local_plane = self.balloon_depth.balloon_depth_fields(
vertices,
uv,
depth_data,
)
np.testing.assert_allclose(values, (1.5, -4.5, 0.5, -0.5))
self.assertAlmostEqual(float(np.median(values)), 0.0)
np.testing.assert_allclose(local_plane, (0.0, 0.0, 0.0))
self.assertEqual(
self.balloon.orient_balloon_vertices(((1.0, 2.0, 3.0),)),
[(-3.0, -1.0, 2.0)],
)
restored = np.asarray(self.balloon.BALLOON_AXIS_TO_SOURCE) @ np.asarray(
(-3.0, -1.0, 2.0, 1.0)
)
np.testing.assert_allclose(restored, (1.0, 2.0, 3.0, 1.0))
self.assertEqual(
self.balloon.orient_balloon_base_plane((0.2, 0.3, 0.4)),
(0.2, -0.3, -0.4),
)
source = inspect.getsource(self.balloon_depth.balloon_shape_node_group)
controller_source = inspect.getsource(
self.balloon_depth.create_balloon_base_plane
)
self.assertIn('"Thickness"', source)
self.assertIn('"Depth Strength"', source)
self.assertNotIn('"Far Clamp"', source)
self.assertIn('"Double Sided Depth"', source)
self.assertIn('"Base Plane"', source)
self.assertIn('nodes.new("GeometryNodeObjectInfo")', source)
self.assertIn("controller.parent = balloon", controller_source)
self.assertIn(
'controller.empty_display_type = "SINGLE_ARROW"', controller_source
)
self.assertIn('profile_factor.operation = "ABSOLUTE"', source)
self.assertIn('profile_statistics.data_type = "FLOAT"', source)
self.assertIn('profile_max_safe.operation = "MAXIMUM"', source)
self.assertIn('profile_normalized.operation = "DIVIDE"', source)
self.assertIn('profile_smooth.interpolation_type = "SMOOTHERSTEP"', source)
self.assertIn('profile_active.operation = "MULTIPLY"', source)
self.assertIn('effective_normal.input_type = "VECTOR"', source)
self.assertNotIn("o_balloon_depth_mix", source)
self.assertIn('front.operation = "LESS_THAN"', source)
self.assertIn('depth_residual.operation = "ADD"', source)
self.assertIn('depth_calibration.inputs[1].default_value = 0.5', source)
self.assertIn('corrected_depth.operation = "ADD"', source)
self.assertIn(
'profile_statistics.outputs["Max"], corrected_depth.inputs[1]',
source,
)
self.assertNotIn("baseline_thickness", source)
self.assertIn('separate.outputs["X"], base_scale.inputs[0]', source)
self.assertIn('double_choice.outputs["Output"], combine.inputs["X"]', source)
self.assertIn('depth_mix = nodes.new("ShaderNodeMix")', source)
self.assertIn('depth_mix.data_type = "FLOAT"', source)
self.assertIn('depth_mix.clamp_factor = True', source)
self.assertIn('base_magnitude.outputs[0], depth_mix.inputs["A"]', source)
self.assertIn('depth_scale.outputs[0], depth_mix.inputs["B"]', source)
self.assertNotIn("profile_inverse", source)
self.assertNotIn("thickness_component", source)
self.assertNotIn("depth_component", source)
self.assertNotIn("blended_magnitude", source)
self.assertIn('plane_offset.operation = "DOT_PRODUCT"', source)
self.assertIn("controller.location = (0.0, 0.0, 0.0)", controller_source)
self.assertNotIn("clamp_distance", source)
self.assertNotIn("safety_clamped", source)
self.assertNotIn("crossing_choice", source)
self.assertIn('front_choice.input_type = "FLOAT"', source)
self.assertIn('double_choice.input_type = "FLOAT"', source)
self.assertNotIn("double_inverse", source)
self.assertNotIn("depth_limited", source)
self.assertNotIn("side_gate", source)
self.assertIn('base_frame.label = "Base Thickness"', source)
self.assertIn('plane_frame.label = "Middle Plane"', source)
self.assertIn('mix_frame.label = "Depth Mix"', source)
self.assertIn('output_frame.label = "Side Output"', source)
self.assertIn('side.operation = "SIGN"', source)
self.assertIn("BALLOON_DEPTH_ATTRIBUTE", source)
self.assertEqual(
self.balloon_depth.BALLOON_DEPTH_ATTRIBUTE,
"o_balloon_depth",
)
def test_balloon_redo_properties_use_mode_and_meter_resolution(self):
import inspect
spacing, inflation = self.balloon.balloon_geometry_parameters(2.0, 0.2)
scene = SimpleNamespace(
unit_settings=SimpleNamespace(scale_length=0.01),
)
self.assertEqual(spacing, 0.2)
self.assertEqual(inflation, 0.9)
self.assertEqual(
self.balloon.meters_to_world_distance(scene, 0.1),
10.0,
)
operator = self.balloon.ExtractBalloon
self.assertEqual(operator.bl_options, {"REGISTER", "UNDO"})
self.assertIn("mode", operator.__annotations__)
self.assertIn("thickness", operator.__annotations__)
self.assertIn("resolution", operator.__annotations__)
self.assertIn("reuse_material", operator.__annotations__)
self.assertIn("source_matrix_data", operator.__annotations__)
settings = self.addon.properties.AnyImageSettings.__annotations__
self.assertIn("balloon_mode", settings)
self.assertIn("balloon_thickness", settings)
self.assertIn("balloon_resolution", settings)
self.assertIn("balloon_reuse_material", settings)
self.assertIn("balloon_thickness_radial_zoom", settings)
self.assertIn("balloon_resolution_radial_zoom", settings)
self.assertEqual(
settings["balloon_thickness_radial_zoom"]["default"],
(100.0, 100.0),
)
self.assertEqual(
settings["balloon_resolution_radial_zoom"]["default"],
(1000.0, 1000.0),
)
resolution_source = inspect.getsource(
self.addon.properties.balloon_resolution_property
)
thickness_source = inspect.getsource(
self.addon.properties.balloon_thickness_property
)
self.assertIn('name="Resolution (m)"', resolution_source)
self.assertNotIn("subtype=", resolution_source)
self.assertNotIn("unit=", resolution_source)
self.assertIn("soft_max=5.0", thickness_source)
self.assertNotIn(" max=", thickness_source)
def test_balloon_workspace_tool_uses_one_top_level_operator_per_stroke(self):
import inspect
execute_source = inspect.getsource(self.balloon.ExtractBalloon.execute)
invoke_source = inspect.getsource(self.balloon.ExtractBalloon.invoke)
modal_source = inspect.getsource(self.balloon.ExtractBalloon.modal)
self.assertIn("json.loads(self.selection_data)", execute_source)
self.assertIn("deserialize_matrix(self.source_matrix_data)", execute_source)
self.assertIn("extract_balloon_object(", execute_source)
self.assertIn("select_result=False", execute_source)
self.assertIn("reuse_material=self.reuse_material", execute_source)
self.assertIn("meters_to_world_distance(", execute_source)
self.assertIn("serialize_matrix(source_object.matrix_world)", invoke_source)
self.assertIn("settings.balloon_thickness", invoke_source)
self.assertIn("settings.balloon_resolution", invoke_source)
self.assertIn("settings.balloon_reuse_material", invoke_source)
self.assertIn("return self.execute(context)", modal_source)
self.assertIn('return {"RUNNING_MODAL"}', modal_source)
self.assertIn('return {"PASS_THROUGH"}', modal_source)
self.assertNotIn("bpy.ops.anyimage.extract_balloon", modal_source)
tool = self.balloon.BalloonTool
self.assertEqual(tool.bl_space_type, "VIEW_3D")
self.assertEqual(tool.bl_context_mode, "OBJECT")
self.assertEqual(tool.bl_operator, self.balloon.ExtractBalloon.bl_idname)
self.assertEqual(tool.bl_icon, "ops.mesh.polybuild_hover")
self.assertEqual(tool.bl_cursor, "PAINT_BRUSH")
keymap_operators = tuple(item[0] for item in tool.bl_keymap)
self.assertEqual(keymap_operators[0], self.balloon.ExtractBalloon.bl_idname)
self.assertEqual(
keymap_operators[1:],
("wm.radial_control", "wm.radial_control"),
)
self.assertEqual(
tool.bl_keymap[1][2]["properties"],
[
(
"data_path_primary",
"scene.anyimage_settings.balloon_thickness",
),
(
"zoom_path",
"scene.anyimage_settings.balloon_thickness_radial_zoom",
),
],
)
self.assertEqual(
tool.bl_keymap[2][2]["properties"],
[
(
"data_path_primary",
"scene.anyimage_settings.balloon_resolution",
),
(
"zoom_path",
"scene.anyimage_settings.balloon_resolution_radial_zoom",
),
],
)
def test_balloon_draw_handlers_are_filtered_to_the_invoking_viewport(self):
import inspect
source = inspect.getsource(self.balloon.drawing_in_region)
operator_source = inspect.getsource(self.balloon.ExtractBalloon)
self.assertIn("area.as_pointer() == area_pointer", source)
self.assertIn("region.as_pointer() == region_pointer", source)
self.assertIn("drawing_in_region(", operator_source)
self.assertNotIn("wireframe_opacity", operator_source)
self.assertNotIn("shading.type", operator_source)
def test_balloon_matrix_serialization_keeps_all_components(self):
import json
matrix = tuple(
tuple(row * 4 + column for column in range(4)) for row in range(4)
)
values = json.loads(self.balloon.serialize_matrix(matrix))
self.assertEqual(values, list(range(16)))
def test_extract_balloon_preserves_the_source_object(self):
import inspect
source = inspect.getsource(self.balloon.extract_balloon_object)
self.assertNotIn("objects.remove(source_object", source)
self.assertIn("balloon_name = source_name", source)
self.assertIn(
"selected_object = balloon if select_result else source_object", source
)
self.assertIn(
"balloon.matrix_world = orient_balloon_world_matrix(matrix_world)",
source,
)
self.assertIn("create_material(", source)
self.assertIn("material_name = material_data_name(source_object)", source)
self.assertIn(
"normal_required=uses_normal",
source,
)
self.assertIn("mark_balloon_material(material, image, normal_image)", source)
self.assertIn("merge_balloon_normal_image(normal_image, update_normal)", source)
self.assertIn("if reuse_material", source)
self.assertIn("color_image = image.copy()", source)
self.assertIn("color_image.name = color_name", source)
self.assertIn(
"material_name,\n color_image,\n 1.0,",
source,
)
self.assertIn(
"else TEXTURE_MATERIAL_NODE_GROUP_NAME",
source,
)
self.assertIn("owns_material", source)
self.assertIn("owns_color_image", source)
self.assertIn("balloon_depth_fields(", source)
self.assertIn("assign_balloon_shape_attributes(", source)
self.assertIn("inflation=INFLATION", source)
self.assertIn("create_balloon_base_plane(", source)
self.assertIn(
"add_balloon_shape_modifier(balloon, thickness, base_plane)", source
)
self.assertIn("BALLOON_MATERIAL_NODE_GROUP_NAME", source)
self.assertIn("normal_path=", source)
self.assertEqual(
self.image_depth.TEXTURE_MATERIAL_NODE_GROUP_NAME,
"O Texture Layer",
)
def test_balloon_material_reuse_requires_matching_image_and_node_group(self):
class MarkedMaterial(dict):
def __init__(self, name, **properties):
super().__init__(properties)
self.name = name
self.node_tree = None
color_image = SimpleNamespace(name="cow.color")
other_image = SimpleNamespace(name="other")
texture_node = SimpleNamespace(type="TEX_IMAGE", image=color_image)
texture_layer = SimpleNamespace(
type="GROUP",
node_tree=SimpleNamespace(name="O Texture Layer"),
)
compatible = SimpleNamespace(
name="cow.001",
node_tree=SimpleNamespace(nodes=[texture_node, texture_layer]),
)
marked = MarkedMaterial(
"unrelated-name",
anyimage_balloon_material=True,
anyimage_source_image=color_image,
)
marked_other = MarkedMaterial(
"cow",
anyimage_balloon_material=True,
anyimage_source_image=other_image,
)
normal_image = SimpleNamespace(name="cow.normal")
marked_normal = MarkedMaterial(
"depth-material",
anyimage_balloon_material=True,
anyimage_source_image=color_image,
anyimage_balloon_normal_image=normal_image,
)
wrong_group = SimpleNamespace(
name="cow",
node_tree=SimpleNamespace(
nodes=[
texture_node,
SimpleNamespace(
type="GROUP",
node_tree=SimpleNamespace(name="O DA3 Surface Layer"),
),
]
),
)
self.fake_bpy.data = SimpleNamespace(
objects=SimpleNamespace(get=lambda _name: None),
materials=[
marked_other,
wrong_group,
compatible,
marked_normal,
marked,
],
images=[color_image],
)
self.assertIs(
self.balloon.reusable_balloon_material(
"cow",
"cow.color",
color_image,
),
marked,
)
self.assertIs(
self.balloon.reusable_balloon_material("cow", "cow.color"),
compatible,
)
self.assertIs(
self.balloon.reusable_balloon_material(
"cow",
"cow.color",
color_image,
normal_required=True,
),
marked_normal,
)
self.assertIs(
self.balloon.reusable_color_image("cow.color"),
color_image,
)
def test_surface_and_depth_plane_material_names_use_image_data(self):
import inspect
surface_source = inspect.getsource(self.image_mesh.build_image_mesh)
original_depth_source = inspect.getsource(
self.depth_build.build_image_depth_plane
)
depth_source = inspect.getsource(self.depth_build.build_depth_plane)
expected = "material_data_name(source_object)"
self.assertIn(expected, surface_source)
self.assertIn(expected, original_depth_source)
self.assertIn(expected, depth_source)
def test_image_operators_stay_clickable_until_server_is_busy(self):
context = SimpleNamespace(
object=SimpleNamespace(data=SimpleNamespace()),
)
operators = (
(self.depth_operator, self.depth_operator.ConvertToDepthPlane),
(self.image_mesh, self.image_mesh.ConvertToSurface),
(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"
moge.mkdir(parents=True)
ben2.mkdir(parents=True)
(moge / "model.onnx").touch()
(ben2 / "model_fp16.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,
)
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.balloon.BalloonTool,
{
"separator": False,
},
),
],
)
self.assertEqual(len(self.status_bar_draws), 1)
self.assertEqual(self.status_bar_draws[0]._owner, "anyimage")
self.addon.unregister()
self.assertEqual(self.registered, [])
self.assertEqual(self.registered_tools, [])
self.assertEqual(self.status_bar_draws, [])
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"))
def test_moge_depth_request_is_written_on_the_operator(self):
operator = self.addon.operators.GenerateMoge2Depth()
operator.input_path = __file__
operator.source_object_name = ""
operator.model = "MOGE2_VITS_NORMAL"
operator.resolution_level = 6
operator.remove_background = False
settings = SimpleNamespace(video_frames=12)
context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings))
with (
patch.object(self.depth_operator, "require_environment"),
patch.object(self.depth_operator, "require_model"),
):
parameters = operator.request(context)
self.assertEqual(parameters["input"], __file__)
self.assertEqual(parameters["model"], "MOGE2_VITS_NORMAL")
self.assertEqual(parameters["resolution_level"], 6)
self.assertNotIn("job_type", parameters)
def test_moge_depth_response_calls_an_ordinary_function(self):
operator = self.addon.operators.GenerateMoge2Depth()
operator.source_object_name = "Reference"
operator.remove_background = False
operator.delete_source = True
operator.shadeless = False
result = object()
context = object()
with patch.object(self.depth_operator, "build_moge2_depth_response") as build:
operator.response(context, result)
build.assert_called_once_with(
context,
result,
source_object_name="Reference",
remove_background=False,
delete_source=True,
shadeless=False,
)
def test_entry_operator_dispatches_plain_properties(self):
operator = self.depth_operator.ConvertToDepthPlane()
operator.model = "MOGE2_VITS_NORMAL"
operator.resolution_level = 7
operator.keep_original = False
operator.shadeless = True
operator.remove_background = False
source_object = SimpleNamespace(
name="Reference",
data=SimpleNamespace(source="FILE"),
)
settings = SimpleNamespace(video_frames=1)
context = SimpleNamespace(
object=source_object,
scene=SimpleNamespace(anyimage_settings=settings),
)
calls = []
self.fake_bpy.ops = SimpleNamespace(
anyimage=SimpleNamespace(
generate_moge2_depth=lambda **options: (
calls.append(options) or {"RUNNING_MODAL"}
)
)
)
with patch.object(
self.depth_operator,
"prepare_image_input",
return_value=(Path(__file__), False),
):
self.assertEqual(operator.execute(context), {"FINISHED"})
self.assertEqual(calls[0]["resolution_level"], 7)
self.assertEqual(calls[0]["source_object_name"], "Reference")
self.assertFalse(any(key.startswith("anyimage_") for key in calls[0]))
if __name__ == "__main__":
unittest.main()