Add image perspective editing and related tools

- Implemented the Image Perspective tool for editing image corners in Blender.
- Created a new operator `EditImagePerspective` to handle the perspective transformation.
- Added a preview feature for the perspective adjustment.
- Introduced the `ImagePolylineTool` for editing image masks with polylines.
- Developed backend support for Real-ESRGAN model inference, including ONNX integration.
- Added functionality for image masking and background removal.
- Created unit tests for the Real-ESRGAN ONNX model to ensure correct preprocessing and inference.
This commit is contained in:
2026-08-22 22:17:10 +08:00
parent f42cba1932
commit 1613bd3194
45 changed files with 1802 additions and 1133 deletions

View File

@@ -79,7 +79,14 @@ class ServerTest(unittest.TestCase):
snapshot = manager.snapshot()
self.assertEqual(
snapshot,
{"loaded": {"ben2": "", "da3": "", "moge2": ""}},
{
"loaded": {
"ben2": "",
"da3": "",
"moge2": "",
"realesrgan": "",
}
},
)
self.assertIn("server.model_catalog", sys.modules)
self.assertNotIn("model_catalog", sys.modules)
@@ -94,6 +101,8 @@ class ServerTest(unittest.TestCase):
cache.da3_key = None
cache.moge_model = None
cache.moge_key = None
cache.realesrgan_model = None
cache.realesrgan_key = None
return cache
def create_server(self):
@@ -184,6 +193,25 @@ class ServerTest(unittest.TestCase):
self.assertIsNone(cache.da3_model)
self.assertIsNotNone(cache.ben2_model)
def test_realesrgan_model_is_reused_for_matching_runtime_key(self):
loads = []
realesrgan = ModuleType("server.models.onnx_realesrgan")
realesrgan.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_realesrgan": realesrgan},
):
first = cache.get_realesrgan("model", "cpu")
second = cache.get_realesrgan("model", "cpu")
self.assertIs(first, second)
self.assertEqual(loads, [("model", "cpu")])
self.assertEqual(cache.snapshot()["realesrgan"], "model")
def test_dedicated_server_runs_only_one_job_at_a_time(self):
server = self.server_module.JobServer("Test", storage_root=self.storage_root)
first_started = threading.Event()
@@ -220,7 +248,7 @@ class ServerTest(unittest.TestCase):
"download-model",
"download-required-models",
"analyze-balloon",
"extract-image-background",
"refine-image-mask",
"remove-background",
"extract-frames",
"generate-depth",
@@ -228,6 +256,7 @@ class ServerTest(unittest.TestCase):
"debug-da3",
"debug-moge2",
"debug-ben2",
"debug-realesrgan-general-x4v3",
},
)
@@ -401,16 +430,16 @@ class ServerTest(unittest.TestCase):
self.assertEqual(tuple(result[0, 0]), (20, 40, 60, 64))
self.assertEqual(tuple(result[3, 3]), (0, 0, 0, 0))
def test_image_extract_canvas_clears_rgba_outside_the_selection(self):
def test_image_mask_canvas_clears_rgba_outside_the_selection(self):
import numpy as np
from PIL import Image
image_extract = sys.modules["server.jobs.image_extract"]
image_mask = sys.modules["server.jobs.image_mask"]
source_path = self.storage_root / "source.png"
pixels = np.full((2, 2, 4), (20, 40, 60, 255), dtype=np.uint8)
Image.fromarray(pixels, mode="RGBA").save(source_path)
canvas, _selection, bounds = image_extract.create_selection_canvas(
canvas, _selection, bounds = image_mask.create_selection_canvas(
source_path,
((-1, -1), (2, -1), (-1, 2)),
)
@@ -421,25 +450,22 @@ class ServerTest(unittest.TestCase):
self.assertEqual(tuple(result[2, 2]), (0, 0, 0, 0))
self.assertEqual(tuple(result[1, 1]), (20, 40, 60, 255))
def test_image_extract_crops_to_alpha_with_pixel_padding(self):
def test_image_mask_crops_tightly_to_alpha(self):
import numpy as np
from PIL import Image
image_extract = sys.modules["server.jobs.image_extract"]
image_mask = sys.modules["server.jobs.image_mask"]
pixels = np.zeros((4, 5, 4), dtype=np.uint8)
pixels[1:3, 2:4] = (20, 40, 60, 255)
result, bounds = image_extract.crop_to_alpha(
result, bounds = image_mask.crop_to_alpha(
Image.fromarray(pixels, mode="RGBA"),
(10, 20, 15, 24),
padding=1,
)
result = np.asarray(result)
self.assertEqual(bounds, (11, 20, 15, 24))
self.assertEqual(result.shape, (4, 4, 4))
self.assertTrue(np.all(result[[0, -1], :, 3] == 0))
self.assertTrue(np.all(result[:, [0, -1], 3] == 0))
self.assertTrue(np.all(result[1:3, 1:3, 3] == 255))
self.assertEqual(bounds, (12, 21, 14, 23))
self.assertEqual(result.shape, (2, 2, 4))
self.assertTrue(np.all(result[:, :, 3] == 255))
def test_balloon_job_sends_the_masked_crop_to_ben2(self):
import numpy as np