- 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.
2475 lines
94 KiB
Python
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()
|