Files
AnyImage/tests/test_server.py
nan 08309f719d feat: Implement cutout tool and depth generation features
- Added a new cutout tool for selecting regions in images, allowing users to choose mesh shapes based on selected areas.
- Introduced depth generation capabilities for rasterized images, including normal data extraction.
- Created jobs for generating depth planes and region data, enhancing the AI data generation workflow.
- Developed a selection refinement process for cutout regions, improving accuracy in generated outputs.
- Established a new region module for handling rasterized image regions and their associated AI generation tasks.
- Enhanced node graph builders for asset generation, facilitating smoother integration of new features.
2026-08-30 23:09:13 +08:00

502 lines
18 KiB
Python

import importlib.util
import sys
import tempfile
import threading
import time
import unittest
from pathlib import Path
from types import ModuleType, SimpleNamespace
from unittest.mock import patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
def load_server_module():
server_directory = PROJECT_ROOT / "src" / "anyimage" / "server"
specification = importlib.util.spec_from_file_location(
"server",
server_directory / "__init__.py",
submodule_search_locations=[str(server_directory)],
)
module = importlib.util.module_from_spec(specification)
sys.modules["server"] = module
specification.loader.exec_module(module)
return module
class FakeCuda:
class OutOfMemoryError(RuntimeError):
pass
@staticmethod
def is_available():
return False
@staticmethod
def empty_cache():
return None
class ServerTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.server_module = load_server_module()
importlib.import_module("server.geometry.generate_depth")
importlib.import_module("server.jobs.generate_region_data")
from blendjob import JobServer
from server.model_manager import ModelManager, ModelSessionCache
cls.JobServer = JobServer
cls.ModelManager, cls.ModelSessionCache = ModelManager, ModelSessionCache
cls.server = cls.server_module.server
cls.app_module = sys.modules["server.app"]
def setUp(self):
self.jobs_temp = tempfile.TemporaryDirectory()
self.addCleanup(self.jobs_temp.cleanup)
self.storage_root = Path(self.jobs_temp.name)
def test_required_model_download_skips_ready_model(self):
manager = self.ModelManager(Path("."))
downloads = []
context = SimpleNamespace(progress=lambda *_args: None)
with (
patch.object(
manager,
"ready",
side_effect=lambda key: key == "BEN2_BASE",
),
patch.object(
manager,
"_download",
side_effect=lambda _context, key, _progress: downloads.append(key),
),
):
result = manager.download_missing(
context,
("MOGE2_VITS_NORMAL", "BEN2_BASE"),
)
self.assertEqual(downloads, ["MOGE2_VITS_NORMAL"])
self.assertEqual(result, {"downloaded": ["MOGE2_VITS_NORMAL"]})
def test_models_are_owned_by_the_server_resource(self):
with tempfile.TemporaryDirectory() as directory:
manager = self.ModelManager(Path(directory))
snapshot = manager.snapshot()
self.assertEqual(
snapshot,
{
"loaded": {
"ben2": "",
"geometry": "",
"upscale": "",
}
},
)
self.assertIn("server.model_catalog", sys.modules)
self.assertNotIn("model_catalog", sys.modules)
def create_cache(self):
cache = self.ModelSessionCache.__new__(self.ModelSessionCache)
cache.ben2_model = None
cache.ben2_key = None
cache.da3_model = None
cache.da3_key = None
cache.moge_model = None
cache.moge_key = None
cache.upscale_model = None
cache.upscale_key = None
return cache
def create_server(self):
source = self.server_module.server
server = self.JobServer("AnyImage Job Server", storage_root=self.storage_root)
server.handlers.update(source.handlers)
server.resource_factories.update(source.resource_factories)
server.bind(self.storage_root)
return server
def wait_for_job(self, context, timeout=2.0):
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if context._snapshot()["state"] in {"succeeded", "failed", "cancelled"}:
return context._snapshot()
time.sleep(0.01)
self.fail(f"Job did not finish: {context._snapshot()}")
def test_ben2_model_is_reused_for_matching_runtime_key(self):
loads = []
ben2 = ModuleType("server.models.onnx_ben2")
ben2.create_session = lambda model_dir, device: (
loads.append((model_dir, device)) or object()
)
cache = self.create_cache()
device = SimpleNamespace(type="cuda")
with patch.dict(sys.modules, {"server.models.onnx_ben2": ben2}):
first, _load_ms = cache.get_ben2("model", device)
second, cached_load_ms = cache.get_ben2("model", device)
self.assertIs(first, second)
self.assertEqual(loads, [("model", "cuda")])
self.assertEqual(cache.snapshot()["ben2"], "model")
self.assertEqual(cached_load_ms, 0.0)
def test_da3_model_is_reused_for_matching_runtime_key(self):
loads = []
da3 = ModuleType("server.models.onnx_da3")
da3.create_session = lambda model_dir, device: (
loads.append((model_dir, device)) or object()
)
cache = self.create_cache()
device = SimpleNamespace(type="cuda")
with patch.dict(
sys.modules,
{
"server.models.onnx_da3": da3,
},
):
first = cache.get_da3("model", device)
second = cache.get_da3("model", device)
self.assertIs(first, second)
self.assertEqual(loads, [("model", "cuda")])
self.assertEqual(cache.snapshot()["geometry"], "model")
def test_model_cache_keeps_three_categories(self):
cache = self.create_cache()
ben2 = object()
old_moge = object()
upscale = object()
cache.ben2_model = ben2
cache.ben2_key = ("ben2", "cuda")
cache.moge_model = old_moge
cache.moge_key = ("old-moge", "cuda")
cache.upscale_model = upscale
cache.upscale_key = ("upscale", "cuda")
device = SimpleNamespace(type="cuda")
da3 = object()
def load_da3(_model_dir, _device, key):
cache.da3_model = da3
cache.da3_key = key
return da3
with patch.object(cache, "_load_da3", side_effect=load_da3):
self.assertIs(cache.get_da3("da3", device), da3)
self.assertIs(cache.ben2_model, ben2)
self.assertIsNone(cache.moge_model)
self.assertIs(cache.upscale_model, upscale)
self.assertEqual(cache.snapshot()["geometry"], "da3")
new_moge = object()
fake_moge = ModuleType("server.models.onnx_moge2")
fake_moge.create_session = lambda _model_dir, _device: new_moge
with patch.dict(sys.modules, {"server.models.onnx_moge2": fake_moge}):
self.assertIs(cache.get_moge2("new-moge", "cuda"), new_moge)
self.assertIs(cache.ben2_model, ben2)
self.assertIsNone(cache.da3_model)
self.assertIs(cache.upscale_model, upscale)
self.assertEqual(cache.snapshot()["geometry"], "new-moge")
def test_upscale_model_is_reused_for_matching_runtime_key(self):
loads = []
upscale = ModuleType("server.models.onnx_upscale")
upscale.create_session = lambda model_dir, device: (
loads.append((model_dir, device)) or object()
)
cache = self.create_cache()
with patch.dict(
sys.modules,
{"server.models.onnx_upscale": upscale},
):
first = cache.get_upscale("model", "cpu")
second = cache.get_upscale("model", "cpu")
self.assertIs(first, second)
self.assertEqual(loads, [("model", "cpu")])
self.assertEqual(cache.snapshot()["upscale"], "model")
def test_dedicated_server_runs_only_one_job_at_a_time(self):
server = self.JobServer("Test", storage_root=self.storage_root)
first_started = threading.Event()
release_first = threading.Event()
order = []
@server.job("example")
def example(_context, parameters):
order.append(parameters["name"])
if parameters["name"] == "first":
first_started.set()
release_first.wait(1.0)
first = server.submit("example", {"name": "first"}, job_id="job")
self.assertTrue(first_started.wait(1.0))
second = server.submit("example", {"name": "second"}, job_id="other")
self.assertIsNotNone(first)
self.assertIsNotNone(second)
self.assertTrue(server.snapshot("instance")["busy"])
self.assertEqual(server.snapshot("instance")["queued_jobs"], 1)
self.assertNotIn("clients", server.snapshot("instance"))
release_first.set()
self.wait_for_job(first)
self.wait_for_job(second)
self.assertEqual(order, ["first", "second"])
server.close()
def test_server_registers_tasks_instead_of_model_names(self):
server = self.create_server()
self.assertEqual(
set(server.handlers),
{
"download-model",
"download-required-models",
"refine-image-mask",
"remove-background",
"upscale-image",
"generate-depth-plane",
"generate-region-data",
"debug-da3",
"debug-moge2",
"debug-ben2",
"debug-upscale",
},
)
def test_queued_job_can_be_cancelled_before_it_runs(self):
server = self.JobServer("Test", storage_root=self.storage_root)
first_started = threading.Event()
release_first = threading.Event()
executed = []
@server.job("example")
def example(_context, parameters):
executed.append(parameters["name"])
if parameters["name"] == "first":
first_started.set()
release_first.wait(1.0)
first = server.submit("example", {"name": "first"})
self.assertTrue(first_started.wait(1.0))
second = server.submit("example", {"name": "second"})
self.assertIs(server.cancel(second.job_id), second)
release_first.set()
self.wait_for_job(first)
second_status = self.wait_for_job(second)
self.assertEqual(second_status["state"], "cancelled")
self.assertEqual(executed, ["first"])
server.close()
def test_cancelling_job_keeps_server_and_model_cache_alive(self):
fake_models = SimpleNamespace(
snapshot=lambda: {"loaded": {"ben2": "BEN2_BASE", "da3": ""}},
clear=lambda: None,
close=lambda: None,
)
with patch.object(self.app_module, "ModelManager", return_value=fake_models):
server = self.create_server()
started = threading.Event()
@server.job("cancellable")
def cancellable(context, _parameters):
started.set()
while not context._is_cancelled():
time.sleep(0.001)
context.check_cancelled()
context = server.submit("cancellable", {}, job_id="job")
self.assertTrue(started.wait(1.0))
self.assertIsNotNone(server.cancel("job"))
self.assertTrue(context._is_cancelled())
status = self.wait_for_job(context)
self.assertEqual(status["state"], "cancelled")
self.assertFalse(server.shutdown_event.is_set())
self.assertEqual(
server.snapshot("instance")["resources"]["model_manager"]["loaded"]["ben2"],
"BEN2_BASE",
)
def test_job_progress_is_kept_in_server_memory(self):
server = self.JobServer("Test", storage_root=self.storage_root)
@server.job("example")
def example(context, _parameters):
context.progress(0.5, "Half way")
context = server.submit("example", {})
self.wait_for_job(context)
self.assertRegex(context.job_id, r"^[0-9a-f]{32}$")
self.assertEqual(context.directory.name, context.job_id)
self.assertEqual(context._snapshot()["state"], "succeeded")
self.assertEqual(context._snapshot()["progress"], 1.0)
def test_runtime_storage_root_owns_the_jobs_directory(self):
storage_root = self.storage_root
server = self.JobServer("Test")
server.bind(storage_root)
@server.job("example")
def example(_context, _parameters):
return {"value": 42}
context = server.submit("example", {})
self.wait_for_job(context)
self.assertEqual(server.jobs_directory, storage_root / "jobs")
self.assertEqual(context.storage_root, storage_root.resolve())
self.assertEqual(context._snapshot()["result"], {"value": 42})
def test_job_server_owns_and_clears_shared_resources(self):
events = []
resource = SimpleNamespace(
snapshot=lambda: {"loaded": "model"},
clear=lambda: events.append("clear"),
close=lambda: events.append("close"),
)
server = self.JobServer("Test", storage_root=self.storage_root)
server.add_resource("model_manager", resource)
@server.job("example")
def example(context, _parameters):
self.assertIs(context.resource("model_manager"), resource)
release.wait(1.0)
release = threading.Event()
context = server.submit("example", {}, job_id="job")
self.assertFalse(server.clear_resource("model_manager"))
release.set()
self.wait_for_job(context)
self.assertTrue(server.clear_resource("model_manager"))
server.close()
self.assertEqual(
server.snapshot("instance")["resources"]["model_manager"]["loaded"],
"model",
)
self.assertEqual(events, ["clear", "close"])
def test_region_transparent_fraction_uses_visible_alpha_threshold(self):
import numpy as np
from PIL import Image
depth = sys.modules["server.geometry.generate_depth"]
output_path = self.storage_root / "region.png"
pixels = np.full((10, 10, 4), 255, dtype=np.uint8)
pixels.reshape(-1, 4)[:5, 3] = 25
Image.fromarray(pixels, mode="RGBA").save(output_path)
self.assertEqual(depth.transparent_fraction(output_path), 0.05)
def test_image_geometry_keeps_model_and_region_validity_separate(self):
import numpy as np
depth = sys.modules["server.geometry.generate_depth"]
output_path = self.storage_root / "image_geometry.npz"
valid = np.asarray(((True, True, False), (True, True, True)))
calibration = np.asarray(((False, True, False), (False, False, True)))
frame = SimpleNamespace(
depth=np.asarray(((1.0, 2.0, 3.0), (4.0, 5.0, 6.0))),
points=np.ones((2, 3, 3), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
valid_mask=valid,
model_mask=None,
)
depth._write_image_geometry_archive(frame, output_path, calibration, 0.05)
with np.load(output_path) as archive:
np.testing.assert_array_equal(archive["valid"], valid)
np.testing.assert_array_equal(archive["calibration_valid"], calibration)
self.assertAlmostEqual(
float(archive["region_transparent_fraction"]),
0.05,
places=6,
)
def test_generate_region_data_only_returns_requested_outputs(self):
job = sys.modules["server.jobs.generate_region_data"]
input_path = self.storage_root / "input.png"
input_path.write_bytes(b"region")
context = SimpleNamespace(
directory=self.storage_root / "job",
progress=lambda *_args: None,
check_cancelled=lambda: None,
)
archive = context.directory / "image_geometry.npz"
normal = context.directory / "normal.png"
with patch.object(
job,
"generate_depth",
return_value=(archive, normal),
) as generate:
result = job.run(
context,
{
"input": str(input_path),
"output_depth": True,
"normal_mode": "OBJECT",
},
)
self.assertEqual(
result,
{"image_geometry": "image_geometry.npz", "normal": "normal.png"},
)
generate.assert_called_once_with(
context,
{
"input": str(input_path),
"output_depth": True,
"normal_mode": "OBJECT",
},
input_path,
output_depth=True,
normal_mode="OBJECT",
)
def test_generate_region_data_refines_an_already_rasterized_region(self):
job = sys.modules["server.jobs.generate_region_data"]
input_path = self.storage_root / "input.png"
input_path.write_bytes(b"region")
directory = self.storage_root / "job"
context = SimpleNamespace(
directory=directory,
progress=lambda *_args: None,
check_cancelled=lambda: None,
)
def generate(_context, _input, _model, _device, output):
output.write_bytes(b"refined")
return output, (2, 3, 8, 9)
with patch.object(job, "generate_selection", side_effect=generate):
result = job.run(
context,
{
"input": str(input_path),
"refine_region": True,
"refine_model": "BEN2_BASE",
"device": "cpu",
},
)
self.assertEqual(
result,
{"region": "region.png", "region_bounds": "region_bounds.json"},
)
self.assertEqual(
(directory / "region_bounds.json").read_text(encoding="utf-8"),
"[2,3,8,9]",
)