Files
AnyImage/tests/test_addon.py
nan c5b77c5c85 Add ONNX model inference implementations for MoGe-2, BEN2, and DA3
- Introduced `onnx_moge2.py` for MoGe-2 ONNX inference with NumPy post-processing.
- Added `onnx_ben2.py` for BEN2 ONNX inference with Pillow and NumPy preprocessing.
- Created `onnx_da3.py` for Depth Anything 3 inference with ONNX Runtime and NumPy.
- Implemented shared ONNX Runtime session helpers in `onnx_runtime.py`.
- Developed unit tests for server functionalities in `test_server.py`, including model caching and job management.
- Enhanced job server to handle frame extraction without requiring models.
2026-08-17 10:37:55 +08:00

524 lines
19 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 = "snapform"
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,
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,
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.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,
)
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.addon = importlib.import_module("snapform")
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 == "snapform" or module_name.startswith("snapform."):
sys.modules.pop(module_name)
def setUp(self):
preferences = SimpleNamespace(
storage_root="",
device="CUDA",
)
self.fake_bpy.context.preferences.addons = {
"snapform": SimpleNamespace(preferences=preferences)
}
def tearDown(self):
self.addon.runtime.close_active()
self.registered.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, "snapform_settings"):
del self.fake_bpy.types.Scene.snapform_settings
def test_snapform_has_no_runtime_boundary_modules(self):
for name in (
"client.py",
"environment.py",
"storage.py",
"models.py",
"status_bar.py",
):
self.assertFalse((SOURCE_ROOT / "snapform" / name).exists(), name)
self.assertFalse(hasattr(self.addon, "client"))
def test_job_request_is_not_part_of_blendjob(self):
blendjob = importlib.import_module("snapform.blendjob")
self.assertFalse(hasattr(blendjob, "JobRequest"))
def test_job_operators_are_flat_and_use_plain_properties(self):
operators = self.addon.operators
for name in (
"SnapFormJobMixin",
"ImageInputJobOperator",
"GeometryImageJobOperator",
"Da3ImageJobOperator",
"Moge2ImageJobOperator",
"BackgroundRequestMixin",
):
self.assertFalse(hasattr(operators, name), name)
for operator in operators.JOB_OPERATOR_CLASSES:
for name in getattr(operator, "__annotations__", {}):
self.assertFalse(name.startswith("snapform_"), (operator, name))
def test_concrete_job_operator_is_the_blender_operator(self):
operator = self.addon.operators.GenerateMoge2Depth
self.assertEqual(operator.bl_idname, "snapform.generate_moge2_depth")
self.assertIn(self.addon.job_runtime.JobOperatorBase, operator.__mro__)
self.assertIn(self.fake_bpy.types.Operator, operator.__mro__)
def test_default_request_sends_every_operator_property(self):
class Example(self.addon.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.addon.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.addon.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.environment_status_path(), root / "environment-status.json")
self.assertEqual(runtime.environment_log_path(), root / "environment.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.assertEqual(command[command.index("--stages") + 1], "1")
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.assertTrue(callable(runtime.post_install))
def test_snapform_post_install_is_ordinary_code_using_runtime_request(self):
calls = []
fake_runtime = SimpleNamespace(
request=lambda job_type, parameters: calls.append(
(job_type, parameters)
)
)
self.addon.job_runtime.post_install(fake_runtime)
self.assertEqual(
calls,
[
("download-model", {"model": "MOGE2_VITS_NORMAL"}),
("download-model", {"model": "BEN2_BASE"}),
],
)
def test_environment_controller_runs_arbitrary_post_install_callable(self):
runtime_module = importlib.import_module("snapform.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,
"environment_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_post_install_progress_resets_without_stage_fraction(self):
runtime_module = importlib.import_module("snapform.blendjob.runtime")
controller = runtime_module.EnvironmentController(self.addon.runtime)
controller._update_post_progress(
{"job_id": "first", "job_type": "download-model", "progress": 1.0}
)
first_stage = controller.post_status["stage"]
controller._update_post_progress(
{"job_id": "second", "job_type": "download-model", "progress": 0.1}
)
self.assertGreater(controller.post_status["stage"], first_stage)
self.assertNotIn("stages", controller.post_status)
def test_conversion_model_lists_default_to_moge2_vits(self):
properties = importlib.import_module("snapform.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)
self.assertEqual(depth_items[0][0], "MOGE2_VITS_NORMAL")
self.assertEqual(surface_items[0][0], "MOGE2_VITS_NORMAL")
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):
blendjob = importlib.import_module("snapform.blendjob")
controller = blendjob.ServerController.__new__(blendjob.ServerController)
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_locates_the_package_exporting_server(self):
entrypoint = self.addon.runtime._server_entrypoint()
self.assertEqual(entrypoint.name, "__init__.py")
self.assertEqual(entrypoint.parent.name, "server")
def test_runtime_registers_system_operators_and_status_bar(self):
self.addon.register()
system_classes = self.addon.runtime.operator_classes()
self.assertTrue(set(system_classes) <= set(self.registered))
self.assertEqual(len(self.status_bar_draws), 1)
self.assertEqual(self.status_bar_draws[0]._owner, "snapform")
self.addon.unregister()
self.assertEqual(self.registered, [])
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", "snapform.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(snapform_settings=settings)
)
with patch.object(
self.addon.operators, "require_environment"
), patch.object(self.addon.operators, "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.unshaded = False
result = object()
context = object()
with patch.object(
self.addon.operators, "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,
unshaded=False,
)
def test_entry_operator_dispatches_plain_properties(self):
operator = self.addon.ConvertToDepthPlane()
operator.model = "MOGE2_VITS_NORMAL"
operator.resolution_level = 7
operator.keep_original = False
operator.unshaded = 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(snapform_settings=settings),
)
calls = []
self.fake_bpy.ops = SimpleNamespace(
snapform=SimpleNamespace(
generate_moge2_depth=lambda **options: calls.append(options)
or {"RUNNING_MODAL"}
)
)
with patch.object(
self.addon.image_depth,
"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("snapform_") for key in calls[0]))
def test_models_are_owned_by_the_server_resource(self):
self.assertFalse((SOURCE_ROOT / "snapform" / "models.py").exists())
with tempfile.TemporaryDirectory() as directory:
manager = self.addon.server.ModelManager(Path(directory))
snapshot = manager.snapshot()
self.assertIn("catalog", snapshot)
self.assertTrue(snapshot["catalog"])
if __name__ == "__main__":
unittest.main()