Refactor image processing and cutout functionalities

- Updated the naming convention for upscaled images to include underscores and file extensions.
- Renamed properties related to cutout pixel size to point spacing for clarity.
- Adjusted alpha region calculations to use a half-alpha threshold for better accuracy.
- Enhanced the handling of foreground mesh masks to consider half-alpha values.
- Introduced new functions for unbounded selections and cutout images, ensuring proper handling of source and selection images.
- Improved test coverage for cutout functionalities, including bounds mapping and alpha preservation.
- Updated tests to reflect changes in naming conventions and functionality adjustments.
This commit is contained in:
2026-08-27 12:01:47 +08:00
parent 2b79a342b0
commit 6c8358f92f
27 changed files with 1058 additions and 223 deletions

View File

@@ -908,13 +908,8 @@ class AddonTest(unittest.TestCase):
) as setup,
patch.object(
self.mesh,
"prepare_image_analysis_inputs",
return_value=(
Path("input.png"),
False,
Path("input.png"),
False,
),
"prepare_image_input",
return_value=(Path("input.png"), False),
),
patch.object(
self.mesh,
@@ -936,6 +931,10 @@ class AddonTest(unittest.TestCase):
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["analysis_input_path"],
"input.png",
)
self.assertEqual(
generate_mesh.call_args.kwargs["normal_model"],
self.addon.properties.DEFAULT_MOGE2_MODEL_KEY,
@@ -1311,7 +1310,7 @@ class AddonTest(unittest.TestCase):
result.directory,
frame_prefix="upscale.",
)
self.assertEqual(result_image.name, "Original.upscale")
self.assertEqual(result_image.name, "Original_upscale.png")
self.assertIs(
result_image.anyimage_upscale_source,
original_image,
@@ -1466,13 +1465,13 @@ class AddonTest(unittest.TestCase):
self.assertIs(created, result_image)
self.assertEqual(result_image.colorspace_settings.name, "ACEScg")
self.assertEqual(result_image.name, "source.color")
self.assertEqual(result_image.name, "source_color.png")
self.assertIs(source_object.data, result_image)
def test_background_result_replaces_only_an_unshared_image(self):
for remaining_users, removed_source, result_name in (
(0, True, "Source.png"),
(1, False, "Source.color"),
(1, False, "Source_color.png"),
):
with self.subTest(remaining_users=remaining_users):
source_image = SimpleNamespace(
@@ -1486,7 +1485,7 @@ class AddonTest(unittest.TestCase):
empty_display_type="IMAGE",
data=source_image,
)
result_image = SimpleNamespace(name="Source.color")
result_image = SimpleNamespace(name="Source_color.png")
removed = []
self.fake_bpy.data = SimpleNamespace(
objects={"Source": source_object},
@@ -1574,6 +1573,25 @@ class AddonTest(unittest.TestCase):
self.assertFalse(even_odd[3, 5])
self.assertTrue(nonzero[3, 5])
def preview_area(fill_rule):
triangles = self.image_edit_common.scanline_fill_triangles(
polygon, fill_rule
)
return sum(
abs(
self.image_edit_common._triangle_cross(
triangles[index],
triangles[index + 1],
triangles[index + 2],
)
)
* 0.5
for index in range(0, len(triangles), 3)
)
self.assertAlmostEqual(preview_area('EVEN_ODD'), 36.0)
self.assertAlmostEqual(preview_area('NONZERO'), 54.0)
def test_image_mask_preserves_source_rgb_outside_the_selection(self):
import numpy as np
@@ -1857,9 +1875,9 @@ 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.assertEqual(settings["cutout_point_spacing"]["default"], 32)
self.assertEqual(settings["cutout_point_spacing"]["min"], 4)
self.assertEqual(settings["cutout_point_spacing"]["max"], 32)
self.assertFalse(settings["cutout_normal_analysis"]["default"])
self.assertNotIn("image_extract_continuous", settings)
self.assertNotIn("image_extract_padding", settings)
@@ -2083,19 +2101,94 @@ class AddonTest(unittest.TestCase):
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):
def test_image_mask_fill_triangulates_a_concave_polygon_without_overlap(self):
polygon = (
(0, 0),
(4, 0),
(4, 4),
(3, 4),
(3, 1),
(1, 1),
(1, 4),
(0, 4),
)
triangles = self.image_edit_common.triangulated_polygon_vertices(polygon)
area = sum(
abs(
self.image_edit_common._triangle_cross(
triangles[index],
triangles[index + 1],
triangles[index + 2],
)
)
* 0.5
for index in range(0, len(triangles), 3)
)
self.assertEqual(len(triangles), 18)
self.assertAlmostEqual(area, 10.0)
def test_image_mask_fill_uses_blender_geometry_without_stopping_the_draw(self):
mathutils = ModuleType('mathutils')
mathutils.Vector = lambda values: values
mathutils.geometry = SimpleNamespace(
tessellate_polygon=lambda _loops: (
((0.0, 0.0, 0.0), (2.0, 0.0, 0.0), (0.0, 2.0, 0.0)),
)
)
with patch.dict(sys.modules, {'mathutils': mathutils}):
triangles = self.image_edit_common.triangulated_polygon_vertices(
((0, 0), (2, 0), (0, 2))
)
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),
),
triangles,
((0.0, 0.0), (2.0, 0.0), (0.0, 2.0)),
)
def test_image_mask_fill_falls_back_when_blender_tessellation_rejects_path(self):
mathutils = ModuleType('mathutils')
mathutils.Vector = lambda values: values
mathutils.geometry = SimpleNamespace(
tessellate_polygon=lambda _loops: (_ for _ in ()).throw(TypeError())
)
with patch.dict(sys.modules, {'mathutils': mathutils}):
triangles = self.image_edit_common.triangulated_polygon_vertices(
((0, 0), (2, 0), (0, 2))
)
self.assertEqual(len(triangles), 3)
def test_image_mask_preview_fills_a_self_intersecting_lasso(self):
polygon = ((0, 0), (4, 4), (0, 4), (4, 0))
triangles = self.image_edit_common.preview_fill_triangles(
polygon, 'NONZERO'
)
area = sum(
abs(
self.image_edit_common._triangle_cross(
triangles[index],
triangles[index + 1],
triangles[index + 2],
)
)
* 0.5
for index in range(0, len(triangles), 3)
)
self.assertTrue(triangles)
self.assertAlmostEqual(area, 8.0)
def test_image_mask_preview_detects_only_actual_self_intersections(self):
self.assertTrue(
self.image_edit_common.polygon_self_intersects(
((0, 0), (4, 4), (0, 4), (4, 0))
)
)
self.assertFalse(
self.image_edit_common.polygon_self_intersects(
((0, 0), (4, 0), (2, 2), (4, 4), (0, 4))
)
)
def test_image_perspective_limits_output_pixel_count(self):
@@ -2225,6 +2318,62 @@ class AddonTest(unittest.TestCase):
((0.0, 0.0), (3.0, 0.0), (5.0, 0.0), (8.0, 0.0)),
)
def test_image_lasso_preview_uses_fill_and_a_closed_dashed_outline(self):
polygon = ((1.0, 2.0), (8.0, 3.0), (4.0, 9.0))
operator = SimpleNamespace(
gesture='LASSO',
invert=False,
_path=[polygon[0]],
_area_pointer=1,
_region_pointer=2,
preview_fill_rule='NONZERO',
_fill_polygon=None,
_fill_triangles=(),
_outline_polygon=None,
_outline_segments=(),
_screen_polygon=lambda: polygon,
)
with (
patch.object(self.image_mask, 'drawing_in_region', return_value=True),
patch.object(
self.image_mask,
'dashed_line_segments',
return_value=('segments',),
) as build_outline,
patch.object(
self.image_mask,
'preview_fill_triangles',
return_value=('triangles',),
) as build_fill,
patch.object(self.image_mask, 'draw_polygon_fill') as draw_fill,
patch.object(self.image_mask, 'draw_dashed_line') as draw_outline,
):
self.image_mask.ImageMaskGesture._draw_selection(operator)
self.image_mask.ImageMaskGesture._draw_selection(operator)
build_fill.assert_called_once_with(polygon, 'NONZERO')
build_outline.assert_called_once_with((*polygon, polygon[0]))
self.assertEqual(draw_fill.call_count, 2)
draw_fill.assert_called_with(
polygon,
('triangles',),
(1.0, 1.0, 1.0, 0.16),
)
self.assertEqual(draw_outline.call_count, 2)
draw_outline.assert_called_with((), segments=('segments',))
def test_image_lasso_does_not_add_stationary_or_nearly_collinear_points(self):
path = [(0.0, 0.0)]
self.assertFalse(self.image_mask.append_lasso_point(path, (0.5, 0.5)))
self.assertEqual(path, [(0.0, 0.0)])
self.assertTrue(self.image_mask.append_lasso_point(path, (3.0, 0.0)))
self.assertTrue(self.image_mask.append_lasso_point(path, (6.0, 0.2)))
self.assertEqual(len(path), 2)
self.assertEqual(path[-1], (6.0, 0.2))
self.assertTrue(self.image_mask.append_lasso_point(path, (6.0, 4.0)))
self.assertEqual(len(path), 3)
def test_image_edit_activation_defaults_to_box(self):
import inspect