Add comprehensive tests for cutout and mesh functionalities

- Introduced `test_cutout.py` to validate cutout pie menu behavior, image lasso options, and mesh detail calculations.
- Added `test_mesh.py` to ensure correct tangent normal rasterization, plane source topology, and depth surface evaluations.
- Created `test_mesh_blender.py` to verify material node groups, tangent baking, and local cutout building from Blender images.
- Implemented tests for handling alpha regions and ensuring geometry nodes maintain expected behavior.
This commit is contained in:
2026-08-26 10:42:29 +08:00
parent 0cf04a13c6
commit 2b79a342b0
46 changed files with 5554 additions and 645 deletions

View File

@@ -8,7 +8,6 @@ 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"
@@ -94,27 +93,18 @@ class AddonTest(unittest.TestCase):
cls.depth_build = importlib.import_module(
"anyimage.operators.depth_plane.build"
)
cls.mesh_plane = importlib.import_module(
"anyimage.operators.mesh_plane"
)
cls.mesh_plane = importlib.import_module("anyimage.operators.mesh_plane")
cls.mesh = importlib.import_module("anyimage.operators.mesh")
cls.image_background = importlib.import_module("anyimage.operators.background")
cls.image_upscale = importlib.import_module("anyimage.operators.upscale")
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_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"
)
@@ -251,8 +241,8 @@ class AddonTest(unittest.TestCase):
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")
operator = self.addon.operators.GenerateMesh
self.assertEqual(operator.bl_idname, "anyimage.generate_mesh")
self.assertIn(self.job_runtime.JobOperatorBase, operator.__mro__)
self.assertIn(self.fake_bpy.types.Operator, operator.__mro__)
@@ -265,15 +255,15 @@ class AddonTest(unittest.TestCase):
device="CPU",
video_frames=3,
)
context = SimpleNamespace(
scene=SimpleNamespace(anyimage_settings=settings)
)
context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings))
for model_key in self.addon.properties.shared_model_catalog.UPSCALE_MODELS:
operator = self.debug.DebugUpscale()
operator.model = model_key
with self.subTest(model=model_key), patch.object(
self.debug, "require_environment"
), patch.object(self.debug, "require_model") as require_model:
with (
self.subTest(model=model_key),
patch.object(self.debug, "require_environment"),
patch.object(self.debug, "require_model") as require_model,
):
request = operator.request(context)
require_model.assert_called_once_with(model_key)
self.assertEqual(request["model"], model_key)
@@ -618,18 +608,23 @@ class AddonTest(unittest.TestCase):
):
status = properties.ai_status()
self.assertFalse(status["ready"])
self.assertEqual(properties.ai_setup_label(status), "Set Up AI Environment…")
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"},
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()
@@ -696,12 +691,20 @@ class AddonTest(unittest.TestCase):
preferences = preference_module.AnyImagePreferences()
preferences.layout = Layout()
catalog = tuple({
"key": key, "label": key, "family": "upscale",
"enabled": True, "ready": False,
} for key in (
"REALESRGAN_GENERAL_WDN_X4V3", "SAFMN_REAL_X4", "REALESRGAN_X4PLUS",
))
catalog = tuple(
{
"key": key,
"label": key,
"family": "upscale",
"enabled": True,
"ready": False,
}
for key in (
"REALESRGAN_GENERAL_WDN_X4V3",
"SAFMN_REAL_X4",
"REALESRGAN_X4PLUS",
)
)
with (
patch.object(
self.addon.properties,
@@ -728,19 +731,21 @@ class AddonTest(unittest.TestCase):
self.assertIn("upscale_model", properties)
self.assertIn("upscale_max_output_size", properties)
self.assertEqual(
preference_module.AnyImagePreferences.__annotations__[
"upscale_model"
]["name"],
preference_module.AnyImagePreferences.__annotations__["upscale_model"][
"name"
],
"Default Upscale Model",
)
def test_mesh_plane_operator_is_registered_with_material_options(self):
operator = self.mesh_plane.ConvertToMeshPlane
def test_semantic_mesh_operators_are_registered_without_dialog_options(self):
operator = self.mesh.ConvertToPlane
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.assertIn(self.mesh.ConvertToDepthPlane, self.addon.CLASSES)
self.assertIn(self.mesh.ConvertCutoutShape, self.addon.CLASSES)
self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane")
self.assertNotIn("shadeless", operator.__annotations__)
self.assertNotIn("keep_original", operator.__annotations__)
self.assertNotIn("invoke", operator.__dict__)
self.assertNotIn("draw", operator.__dict__)
self.assertIn("REGISTER", operator.bl_options)
self.assertIn("UNDO", operator.bl_options)
@@ -758,9 +763,246 @@ class AddonTest(unittest.TestCase):
)
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=mesh_plane)))
self.assertFalse(operator.poll(SimpleNamespace(object=plain_mesh)))
def test_plane_depth_off_accepts_animated_image_without_server(self):
operator = SimpleNamespace(
depth_mode="OFF",
remove_background=False,
shadeless=False,
profile="SURFACE",
mesh_detail=64,
thickness=0.1,
inflation=0.1,
algorithm="POISSON",
keep_original=False,
)
source = SimpleNamespace(data=SimpleNamespace(name="Sequence"))
context = SimpleNamespace(object=source, scene=object())
with (
patch.object(self.mesh, "video_total_frames", return_value=24),
patch.object(
self.mesh,
"image_user_settings",
return_value=(1, 0, 24, True),
),
patch.object(self.mesh, "create_material", return_value="Material"),
patch.object(self.mesh, "_copy_color_image", return_value="Color"),
patch.object(self.mesh, "_build_mesh_object") as build_mesh,
patch.object(
self.mesh,
"invoke_ai_setup_if_needed",
side_effect=AssertionError("local conversion must not start AI setup"),
),
):
result = self.mesh.ConvertToPlane.execute(operator, context)
self.assertEqual(result, {"FINISHED"})
build_mesh.assert_called_once()
def test_cutout_and_balloon_without_refine_build_locally(self):
source = SimpleNamespace(select_set=lambda _selected: None)
context = SimpleNamespace(
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
)
for shape in (self.mesh.CUTOUT, self.mesh.BALLOON):
with self.subTest(shape=shape):
operator = SimpleNamespace(
source_object_name="Source",
selection_data="[[0,0],[10,0],[0,10]]",
shape=shape,
mesh_detail=64,
refine_selection=False,
normal_analysis=False,
invert=True,
report=lambda *_args: None,
)
with (
patch.object(
self.mesh.bpy.data.objects,
"get",
return_value=source,
),
patch.object(self.mesh, "is_image_empty", return_value=True),
patch.object(self.mesh, "_build_local_cutout") as build_local,
patch.object(
self.mesh,
"_start_job",
side_effect=AssertionError("local Cutout must not start a Job"),
),
):
result = self.mesh.ConvertCutoutShape.execute(operator, context)
self.assertEqual(result, {"FINISHED"})
build_local.assert_called_once_with(
operator,
context,
source,
shape,
operator.selection_data,
True,
)
def test_refined_cutout_and_balloon_submit_blendjob(self):
source = SimpleNamespace(select_set=lambda _selected: None)
context = SimpleNamespace(
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
)
for shape in (self.mesh.CUTOUT, self.mesh.BALLOON):
with self.subTest(shape=shape):
operator = SimpleNamespace(
source_object_name="Source",
selection_data="[[0,0],[10,0],[0,10]]",
shape=shape,
mesh_detail=64,
refine_selection=True,
normal_analysis=False,
invert=False,
report=lambda *_args: None,
)
with (
patch.object(
self.mesh.bpy.data.objects,
"get",
return_value=source,
),
patch.object(self.mesh, "is_image_empty", return_value=True),
patch.object(
self.mesh,
"_build_local_cutout",
side_effect=AssertionError("Refine must run through the Job"),
),
patch.object(
self.mesh, "_start_job", return_value={"FINISHED"}
) as start_job,
):
result = self.mesh.ConvertCutoutShape.execute(operator, context)
self.assertEqual(result, {"FINISHED"})
start_job.assert_called_once_with(
operator,
context,
shape,
operator.selection_data,
True,
False,
False,
)
def test_plain_balloon_job_requests_moge_normal(self):
source = SimpleNamespace(
name="Source",
data=SimpleNamespace(size=(2048, 1024)),
)
context = SimpleNamespace(object=source, scene=SimpleNamespace())
operator = SimpleNamespace(mesh_detail=64, report=lambda *_args: None)
with (
patch.object(self.mesh, "video_total_frames", return_value=1),
patch.object(
self.mesh, "invoke_ai_setup_if_needed", return_value=None
) as setup,
patch.object(
self.mesh,
"prepare_image_analysis_inputs",
return_value=(
Path("input.png"),
False,
Path("input.png"),
False,
),
),
patch.object(
self.mesh,
"_depth_settings",
return_value=("MOGE2_VITL", 100.0, 5),
),
patch.object(
self.mesh.bpy.ops.anyimage,
"generate_mesh",
return_value={"FINISHED"},
create=True,
) as generate_mesh,
):
result = self.mesh._start_job(
operator, context, self.mesh.BALLOON, normal_analysis=True
)
self.assertEqual(result, {"FINISHED"})
setup.assert_called_once_with()
self.assertEqual(generate_mesh.call_args.kwargs["shape"], self.mesh.BALLOON)
self.assertEqual(generate_mesh.call_args.kwargs["mesh_detail"], 64)
self.assertEqual(
generate_mesh.call_args.kwargs["normal_model"],
self.addon.properties.DEFAULT_MOGE2_MODEL_KEY,
)
self.assertEqual(generate_mesh.call_args.kwargs["normal_resolution_level"], 5)
def test_all_cutout_shapes_request_moge_normal_when_enabled(self):
context = SimpleNamespace(
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1))
)
for shape in self.mesh.CUTOUT_SHAPES:
with self.subTest(shape=shape):
operator = self.mesh.GenerateMesh()
operator.input_path = __file__
operator.source_object_name = ""
operator.shape = shape
operator.selection_data = "[[0,0],[10,0],[0,10]]"
operator.model = self.addon.properties.DEFAULT_MOGE2_MODEL_KEY
operator.mesh_detail = 64
operator.process_res = 1
operator.resolution_level = 5
operator.normal_model = self.addon.properties.DEFAULT_MOGE2_MODEL_KEY
operator.normal_resolution_level = 5
operator.normal_analysis = True
operator.refine_selection = False
operator.invert = False
with (
patch.object(self.mesh, "require_environment"),
patch.object(self.mesh, "require_model") as require_model,
patch.object(self.mesh, "production_device", return_value="CPU"),
patch.object(self.mesh, "source_start_frame", return_value=1),
):
parameters = operator.request(context)
require_model.assert_called_once_with(
self.addon.properties.DEFAULT_MOGE2_MODEL_KEY
)
self.assertEqual(
parameters["normal_model"],
self.addon.properties.DEFAULT_MOGE2_MODEL_KEY,
)
self.assertEqual(parameters["normal_resolution_level"], 5)
self.assertEqual(parameters["mesh_detail"], 64)
def test_cutout_job_skips_moge_normal_model_when_disabled(self):
operator = self.mesh.GenerateMesh()
operator.input_path = __file__
operator.source_object_name = ""
operator.shape = self.mesh.CUTOUT
operator.selection_data = "[[0,0],[10,0],[0,10]]"
operator.mesh_detail = 64
operator.normal_analysis = False
operator.refine_selection = False
operator.invert = False
with (
patch.object(self.mesh, "require_environment"),
patch.object(self.mesh, "require_model") as require_model,
patch.object(self.mesh, "production_device", return_value="CPU"),
patch.object(self.mesh, "source_start_frame", return_value=1),
):
parameters = operator.request(SimpleNamespace(scene=SimpleNamespace()))
require_model.assert_not_called()
self.assertFalse(parameters["normal_analysis"])
self.assertNotIn("normal_model", parameters)
self.assertNotIn("normal_resolution_level", parameters)
def test_mesh_plane_redo_keeps_the_original_world_matrix(self):
operator = self.mesh_plane.ConvertToMeshPlane()
operator.shadeless = False
@@ -818,9 +1060,7 @@ class AddonTest(unittest.TestCase):
import inspect
mesh_source = inspect.getsource(self.mesh_plane.create_mesh_plane_mesh)
processing_source = inspect.getsource(
self.mesh_plane.add_mesh_plane_processing
)
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)
@@ -865,9 +1105,8 @@ class AddonTest(unittest.TestCase):
self.assertEqual(
calls,
[
("anyimage.convert_to_mesh_plane", True),
("anyimage.convert_to_plane", True),
("anyimage.convert_to_depth_plane", True),
("anyimage.convert_to_surface", True),
("separator", True),
("anyimage.remove_image_background", True),
("anyimage.upscale_image", True),
@@ -884,6 +1123,7 @@ class AddonTest(unittest.TestCase):
labels["anyimage.upscale_image"],
"Upscale (640 × 480)",
)
def test_image_menu_opens_environment_settings_when_ai_is_ready(self):
calls = []
@@ -933,9 +1173,7 @@ class AddonTest(unittest.TestCase):
operator.model = "SAFMN_REAL_X4"
operator.max_output_size = 6144
context = SimpleNamespace(
scene=SimpleNamespace(
anyimage_settings=SimpleNamespace(video_frames=7)
)
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=7))
)
with (
patch.object(self.image_upscale, "require_environment"),
@@ -943,12 +1181,8 @@ class AddonTest(unittest.TestCase):
patch.object(
self.image_upscale, "input_path", return_value=Path("input.png")
),
patch.object(
self.image_upscale, "production_device", return_value="CUDA"
),
patch.object(
self.image_upscale, "source_start_frame", return_value=4
),
patch.object(self.image_upscale, "production_device", return_value="CUDA"),
patch.object(self.image_upscale, "source_start_frame", return_value=4),
):
request = operator.request(context)
@@ -979,9 +1213,7 @@ class AddonTest(unittest.TestCase):
)
context = SimpleNamespace(
object=source_object,
scene=SimpleNamespace(
anyimage_settings=SimpleNamespace(video_frames=1)
),
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)),
)
operator = self.image_upscale.UpscaleImage()
with (
@@ -1109,6 +1341,22 @@ class AddonTest(unittest.TestCase):
):
operator.response(None, SimpleNamespace(directory=Path("result")))
def test_depth_analysis_follows_the_oldest_upscale_source(self):
original = SimpleNamespace(name="Original")
first_upscale = SimpleNamespace(
name="First",
anyimage_upscale_source=original,
)
second_upscale = SimpleNamespace(
name="Second",
anyimage_upscale_source=first_upscale,
)
self.assertIs(
self.image_data.analysis_source_image(second_upscale),
original,
)
def test_repeated_upscale_uses_original_image_and_reports_warning(self):
original_image = SimpleNamespace(name="Original", source="FILE")
current_image = SimpleNamespace(
@@ -1123,9 +1371,7 @@ class AddonTest(unittest.TestCase):
)
context = SimpleNamespace(
object=source_object,
scene=SimpleNamespace(
anyimage_settings=SimpleNamespace(video_frames=1)
),
scene=SimpleNamespace(anyimage_settings=SimpleNamespace(video_frames=1)),
)
reports = []
operator = self.image_upscale.UpscaleImage()
@@ -1287,14 +1533,53 @@ class AddonTest(unittest.TestCase):
self.assertGreater(self.balloon_geometry.polygon_area(contour), 0.0)
self.assertLess(float(lengths.std() / lengths.mean()), 0.15)
def test_balloon_contour_smooths_pixel_stairs_before_coarse_resampling(self):
import numpy as np
staircase = [(0.0, 0.0)]
for value in range(16):
staircase.append((value + 1.0, float(value)))
staircase.append((value + 1.0, value + 1.0))
staircase.append((0.0, 16.0))
smooth = self.balloon_geometry.smooth_lasso_contour(staircase, 2.0)
def diagonal_noise(points):
points = np.asarray(points)
selected = (
(points[:, 0] > 2.0)
& (points[:, 0] < 14.0)
& (np.abs(points[:, 1] - points[:, 0]) < 2.0)
)
return float(np.std(points[selected, 1] - points[selected, 0]))
self.assertLess(diagonal_noise(smooth), 0.05)
def test_cutout_polygon_mask_keeps_nonzero_winding_overlap(self):
polygon = (
(1, 1),
(7, 1),
(7, 7),
(1, 7),
(1, 1),
(4, 1),
(10, 1),
(10, 7),
(4, 7),
(4, 1),
(1, 1),
)
even_odd = self.image_mask.polygon_mask((12, 9), polygon)
nonzero = self.image_mask.polygon_mask((12, 9), polygon, "NONZERO")
self.assertFalse(even_odd[3, 5])
self.assertTrue(nonzero[3, 5])
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))
)
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),
@@ -1558,9 +1843,7 @@ class AddonTest(unittest.TestCase):
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.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__)
@@ -1574,10 +1857,16 @@ class AddonTest(unittest.TestCase):
self.assertNotIn("padding", operator.__annotations__)
settings = self.addon.properties.AnyImageSettings.__annotations__
self.assertFalse(settings["image_edit_refine_selection"]["default"])
self.assertEqual(settings["cutout_pixel_size"]["default"], 32)
self.assertEqual(settings["cutout_pixel_size"]["min"], 4)
self.assertEqual(settings["cutout_pixel_size"]["max"], 32)
self.assertFalse(settings["cutout_normal_analysis"]["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)
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)
@@ -1585,6 +1874,11 @@ class AddonTest(unittest.TestCase):
self.assertIn('event.type == "F"', modal_source)
self.assertIn("self.invert = not self.invert", modal_source)
self.assertIn("math.dist(point, self._path[-1]) >= 2.0", modal_source)
mask_source = inspect.getsource(self.image_mask.polygon_mask)
self.assertNotIn("np.indices", mask_source)
self.assertIn("range(top, bottom, 128)", mask_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))
@@ -1614,9 +1908,7 @@ class AddonTest(unittest.TestCase):
def test_image_area_click_is_cancelled_without_a_warning(self):
import inspect
completion_source = inspect.getsource(
self.image_mask.EditImageMask._complete
)
completion_source = inspect.getsource(self.image_mask.EditImageMask._complete)
self.assertIn("if len(path) < 3", completion_source)
self.assertIn('return {"CANCELLED"}', completion_source)
@@ -1625,12 +1917,8 @@ class AddonTest(unittest.TestCase):
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
)
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)
@@ -1671,10 +1959,8 @@ class AddonTest(unittest.TestCase):
clicked.name: clicked,
}
try:
source, should_edit = (
self.image_edit_common.resolve_image_edit_click(
context, event, lambda *_args: None
)
source, should_edit = self.image_edit_common.resolve_image_edit_click(
context, event, lambda *_args: None
)
finally:
self.fake_bpy.data.objects = previous_objects
@@ -1718,10 +2004,8 @@ class AddonTest(unittest.TestCase):
)
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
)
source, should_edit = self.image_edit_common.resolve_image_edit_click(
context, event, lambda *_args: None
)
finally:
self.fake_bpy.data.objects = previous_objects
@@ -1781,6 +2065,7 @@ class AddonTest(unittest.TestCase):
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))
@@ -1794,15 +2079,9 @@ class AddonTest(unittest.TestCase):
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))
)
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(
@@ -1825,9 +2104,7 @@ class AddonTest(unittest.TestCase):
1.0,
)
self.assertLessEqual(
width * height, self.image_perspective.MAX_OUTPUT_PIXELS
)
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
@@ -1866,9 +2143,7 @@ class AddonTest(unittest.TestCase):
def remove(*_args, **_options):
raise ReferenceError("StructRNA of type Image has been removed")
self.fake_bpy.data = SimpleNamespace(
images=SimpleNamespace(remove=remove)
)
self.fake_bpy.data = SimpleNamespace(images=SimpleNamespace(remove=remove))
operator._remove_preview()
self.assertIsNone(operator._preview_image)
@@ -1961,18 +2236,12 @@ class AddonTest(unittest.TestCase):
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)
)
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)
)
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
@@ -2087,6 +2356,64 @@ class AddonTest(unittest.TestCase):
self.assertGreater(wide_height, narrow_height)
def test_balloon_keeps_overlapping_regions_disconnected(self):
import numpy as np
regions = (
(
((20, 0), (90, 0), (90, 70), (20, 70)),
((50, 20), (50, 50), (70, 50), (70, 20)),
),
(
((0, 20), (40, 20), (40, 45), (0, 45)),
),
)
vertices, faces, uv = self.balloon_geometry.triangulate_balloon(
regions,
(100, 100),
(0.0, 1.0, 0.0, 1.0),
spacing=0.1,
world_metric=((1.0, 0.0), (0.0, 1.0)),
)
centers = uv[np.asarray(faces)].mean(axis=1)
in_hole = (
(centers[:, 0] > 0.55)
& (centers[:, 0] < 0.65)
& (centers[:, 1] > 0.57)
& (centers[:, 1] < 0.73)
)
in_left_region = (
(centers[:, 0] > 0.02)
& (centers[:, 0] < 0.12)
& (centers[:, 1] > 0.55)
& (centers[:, 1] < 0.8)
)
heights = np.asarray(vertices)[:, 2]
self.assertFalse(in_hole.any())
self.assertTrue(in_left_region.any())
self.assertGreater(float(heights.max()), 0.0)
self.assertLess(float(heights.min()), 0.0)
adjacency = {}
for face in faces:
for vertex in face:
adjacency.setdefault(int(vertex), set())
for start, end in zip(face, np.roll(face, -1)):
adjacency[int(start)].add(int(end))
adjacency[int(end)].add(int(start))
remaining = set(adjacency)
component_count = 0
while remaining:
component_count += 1
pending = [remaining.pop()]
while pending:
vertex = pending.pop()
connected = adjacency[vertex] & remaining
remaining.difference_update(connected)
pending.extend(connected)
self.assertEqual(component_count, 2)
def test_poisson_balloon_supports_resolution_002(self):
import numpy as np
@@ -2228,19 +2555,16 @@ class AddonTest(unittest.TestCase):
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)
def test_balloon_tool_operators_are_not_registered(self):
self.assertNotIn(self.balloon.ExtractBalloon, self.addon.CLASSES)
self.assertNotIn(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),
)
self.assertIn(self.mesh.GenerateMesh, self.addon.CLASSES)
def test_balloon_ai_analysis_is_registered_and_forwarded(self):
import inspect
self.assertIn(self.balloon.AnalyzeBalloon, self.addon.CLASSES)
self.assertNotIn(self.balloon.AnalyzeBalloon, self.addon.CLASSES)
operator = self.balloon.ExtractBalloon
self.assertIn("contour_analysis", operator.__annotations__)
self.assertIn("depth_analysis", operator.__annotations__)
@@ -2333,6 +2657,7 @@ class AddonTest(unittest.TestCase):
def test_balloon_depth_fields_and_shape_nodes(self):
import inspect
import numpy as np
depth_data = {
@@ -2379,8 +2704,12 @@ class AddonTest(unittest.TestCase):
controller_source = inspect.getsource(
self.balloon_depth.create_balloon_base_plane
)
modifier_source = inspect.getsource(
self.balloon_depth.add_balloon_shape_modifier
)
self.assertIn('"Thickness"', source)
self.assertIn('"Depth Strength"', source)
self.assertIn('"Depth Strength": 1.0', modifier_source)
self.assertNotIn('"Far Clamp"', source)
self.assertIn('"Double Sided Depth"', source)
self.assertIn('"Base Plane"', source)
@@ -2399,7 +2728,7 @@ class AddonTest(unittest.TestCase):
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("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]',
@@ -2410,7 +2739,7 @@ class AddonTest(unittest.TestCase):
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("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)
@@ -2438,7 +2767,6 @@ class AddonTest(unittest.TestCase):
"o_balloon_depth",
)
def test_balloon_redo_properties_use_mode_and_meter_resolution(self):
import inspect
@@ -2595,11 +2923,11 @@ class AddonTest(unittest.TestCase):
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,",
"material_name,\n color_image,\n 0.0,",
source,
)
self.assertIn(
"else TEXTURE_MATERIAL_NODE_GROUP_NAME",
"surface_group_name=IMAGE_MATERIAL_NODE_GROUP_NAME",
source,
)
self.assertIn("owns_material", source)
@@ -2608,14 +2936,13 @@ class AddonTest(unittest.TestCase):
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("add_balloon_shape_modifier(", source)
self.assertNotIn("depth_strength", source)
self.assertIn("IMAGE_MATERIAL_NODE_GROUP_NAME", source)
self.assertIn("normal_path=", source)
self.assertEqual(
self.image_depth.TEXTURE_MATERIAL_NODE_GROUP_NAME,
"O Texture Layer",
"O Image Layer",
)
def test_balloon_material_reuse_uses_explicit_source_markers(self):
@@ -2858,9 +3185,12 @@ class AddonTest(unittest.TestCase):
},
),
(
self.balloon.BalloonTool,
self.addon.CutoutTool,
{
"separator": False,
"after": {
self.image_perspective.ImagePerspectiveTool.bl_idname
},
"separator": True,
},
),
],
@@ -2875,9 +3205,7 @@ class AddonTest(unittest.TestCase):
self.outliner_menu_draws[0],
self.addon.draw_image_context_menu,
)
self.assertTrue(
hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")
)
self.assertTrue(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"))
self.addon.unregister()
self.assertEqual(self.registered, [])
@@ -2885,9 +3213,7 @@ class AddonTest(unittest.TestCase):
self.assertEqual(self.status_bar_draws, [])
self.assertEqual(self.context_menu_draws, [existing_context_draw])
self.assertEqual(self.outliner_menu_draws, [existing_outliner_draw])
self.assertFalse(
hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source")
)
self.assertFalse(hasattr(self.fake_bpy.types.Image, "anyimage_upscale_source"))
def test_blender_image_exports_do_not_use_scene_render_color_management(self):
source_root = PROJECT_ROOT / "src" / "anyimage"
@@ -2922,7 +3248,7 @@ class AddonTest(unittest.TestCase):
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 = self.depth_operator.GenerateMoge2Depth()
operator.input_path = __file__
operator.source_object_name = ""
operator.model = "MOGE2_VITS_NORMAL"
@@ -2943,7 +3269,7 @@ class AddonTest(unittest.TestCase):
self.assertNotIn("job_type", parameters)
def test_moge_depth_response_calls_an_ordinary_function(self):
operator = self.addon.operators.GenerateMoge2Depth()
operator = self.depth_operator.GenerateMoge2Depth()
operator.source_object_name = "Reference"
operator.remove_background = False
operator.delete_source = True
@@ -2990,8 +3316,13 @@ class AddonTest(unittest.TestCase):
with patch.object(
self.depth_operator,
"prepare_image_input",
return_value=(Path(__file__), False),
"prepare_image_analysis_inputs",
return_value=(
Path(__file__),
False,
Path(__file__),
False,
),
):
self.assertEqual(operator.execute(context), {"FINISHED"})