feat(selection): separate selection geometry from image content processing
- Introduced a new design for selection rasterization that decouples the selection geometry from the image content, ensuring that the SelectionMask is independent of the source image's alpha and color. - Updated the `rasterize_selection_path` function to only accept selection paths and image dimensions, removing parameters related to source alpha and alpha thresholds. - Implemented a new approach for handling selection paths using non-zero winding fill, ensuring overlapping areas are preserved. - Enhanced performance by utilizing a scanline algorithm for rasterization, significantly reducing processing time for high-resolution images. - Updated internal documentation and added comprehensive tests to validate the new behavior and performance improvements. - Ensured backward compatibility by maintaining existing interfaces where necessary while introducing breaking changes in the selection processing logic.
This commit is contained in:
@@ -6,7 +6,7 @@ import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import ModuleType, SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE_ROOT = PROJECT_ROOT / "src"
|
||||
@@ -90,6 +90,9 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
cls.convert_to_plane = importlib.import_module(
|
||||
"anyimage.operators.convert_to_plane.conversion"
|
||||
)
|
||||
cls.convert_to_plane_object = importlib.import_module(
|
||||
"anyimage.operators.convert_to_plane.object"
|
||||
)
|
||||
cls.cutout_main = importlib.import_module(
|
||||
"anyimage.operators.cutout_tool.main"
|
||||
)
|
||||
@@ -851,6 +854,19 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
self.assertNotIn("draw", operator.__dict__)
|
||||
self.assertIn("REGISTER", operator.bl_options)
|
||||
self.assertIn("UNDO", operator.bl_options)
|
||||
self.assertEqual(operator.__annotations__["mesh_detail"]["default"], 0)
|
||||
self.assertEqual(
|
||||
self.convert_to_plane.ConvertToDepthPlane.__annotations__["mesh_detail"][
|
||||
"default"
|
||||
],
|
||||
6,
|
||||
)
|
||||
self.assertEqual(
|
||||
self.convert_to_plane.GenerateDepthPlane.__annotations__["mesh_detail"][
|
||||
"default"
|
||||
],
|
||||
6,
|
||||
)
|
||||
|
||||
image_empty = SimpleNamespace(
|
||||
type="EMPTY",
|
||||
@@ -923,6 +939,128 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
self.assertNotIn("shape", parameters)
|
||||
self.assertNotIn("polygon", parameters)
|
||||
|
||||
def test_depth_plane_result_connects_the_tangent_normal_material(self):
|
||||
module = self.convert_to_plane_object
|
||||
source = SimpleNamespace(data=SimpleNamespace(name="Source"))
|
||||
color_image = SimpleNamespace(name="")
|
||||
depth_image = SimpleNamespace(users=0)
|
||||
normal_image = SimpleNamespace(users=0)
|
||||
material = SimpleNamespace(users=0)
|
||||
metadata = {"reference_depth": 1.0}
|
||||
files = {
|
||||
"depth_metadata": Path("depth.json"),
|
||||
"z_depth": Path("z-depth.exr"),
|
||||
"tangent_normal": Path("tangent-normal.png"),
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
directory=Path("result"),
|
||||
file=lambda key: files[key],
|
||||
)
|
||||
operator = SimpleNamespace(source_object_name="Source", mesh_detail=6)
|
||||
context = SimpleNamespace(scene=object())
|
||||
ops = SimpleNamespace(
|
||||
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(module, "require_image_empty", return_value=source),
|
||||
patch.object(module, "load_color_result_image", return_value=color_image),
|
||||
patch.object(module, "load_depth_metadata", return_value=metadata),
|
||||
patch.object(module, "load_depth_result_image", return_value=depth_image),
|
||||
patch.object(
|
||||
module,
|
||||
"load_normal_result_image",
|
||||
return_value=normal_image,
|
||||
) as load_normal,
|
||||
patch.object(
|
||||
module,
|
||||
"create_image_material",
|
||||
return_value=material,
|
||||
) as create_material,
|
||||
patch.object(module, "create_depth_plane_object") as create_object,
|
||||
patch.object(module.bpy, "ops", ops, create=True),
|
||||
):
|
||||
module.create_depth_plane_from_result(context, result, operator)
|
||||
|
||||
load_normal.assert_called_once_with(files["tangent_normal"], source)
|
||||
create_material.assert_called_once_with(
|
||||
source,
|
||||
color_image,
|
||||
0.0,
|
||||
normal_image=normal_image,
|
||||
normal_space="TANGENT",
|
||||
ior=1.2,
|
||||
scene=context.scene,
|
||||
)
|
||||
create_object.assert_called_once_with(
|
||||
context,
|
||||
source,
|
||||
6,
|
||||
material,
|
||||
depth_image,
|
||||
metadata,
|
||||
)
|
||||
|
||||
def test_depth_plane_result_cleans_unused_depth_and_normal_on_failure(self):
|
||||
module = self.convert_to_plane_object
|
||||
source = SimpleNamespace(data=SimpleNamespace(name="Source"))
|
||||
color_image = SimpleNamespace(name="")
|
||||
depth_image = SimpleNamespace(users=0)
|
||||
normal_image = SimpleNamespace(users=0)
|
||||
material = SimpleNamespace(users=0)
|
||||
files = {
|
||||
"depth_metadata": Path("depth.json"),
|
||||
"z_depth": Path("z-depth.exr"),
|
||||
"tangent_normal": Path("tangent-normal.png"),
|
||||
}
|
||||
result = SimpleNamespace(
|
||||
directory=Path("result"),
|
||||
file=lambda key: files[key],
|
||||
)
|
||||
remove_material = Mock()
|
||||
remove_image = Mock()
|
||||
data = SimpleNamespace(
|
||||
materials=SimpleNamespace(remove=remove_material),
|
||||
images=SimpleNamespace(remove=remove_image),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(module, "require_image_empty", return_value=source),
|
||||
patch.object(module, "load_color_result_image", return_value=color_image),
|
||||
patch.object(
|
||||
module,
|
||||
"load_depth_metadata",
|
||||
return_value={"reference_depth": 1.0},
|
||||
),
|
||||
patch.object(module, "load_depth_result_image", return_value=depth_image),
|
||||
patch.object(module, "load_normal_result_image", return_value=normal_image),
|
||||
patch.object(module, "create_image_material", return_value=material),
|
||||
patch.object(
|
||||
module,
|
||||
"create_depth_plane_object",
|
||||
side_effect=RuntimeError("failed"),
|
||||
),
|
||||
patch.object(module.bpy, "data", data),
|
||||
self.assertRaisesRegex(RuntimeError, "failed"),
|
||||
):
|
||||
module.create_depth_plane_from_result(
|
||||
SimpleNamespace(scene=object()),
|
||||
result,
|
||||
SimpleNamespace(source_object_name="Source", mesh_detail=6),
|
||||
)
|
||||
|
||||
remove_material.assert_called_once_with(material, do_unlink=True)
|
||||
self.assertEqual(
|
||||
[entry.args[0] for entry in remove_image.call_args_list],
|
||||
[depth_image, normal_image],
|
||||
)
|
||||
self.assertTrue(
|
||||
all(
|
||||
entry.kwargs == {"do_unlink": True}
|
||||
for entry in remove_image.call_args_list
|
||||
)
|
||||
)
|
||||
|
||||
def test_surface_and_balloon_without_ai_build_locally(self):
|
||||
import numpy as np
|
||||
|
||||
@@ -932,7 +1070,7 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
context = SimpleNamespace(
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
||||
)
|
||||
selection_mask = self.cutout_main.SelectionMask(
|
||||
selection_mask = self.image_selection.SelectionMask(
|
||||
np.ones((8, 8), dtype=np.float32), (1, 1, 9, 9)
|
||||
)
|
||||
source_rgba = np.ones((10, 10, 4), dtype=np.float32)
|
||||
@@ -941,7 +1079,10 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
operator = SimpleNamespace(
|
||||
source_object_name="Source",
|
||||
selection_path_json=json.dumps(
|
||||
{"points": [[1, 1], [9, 1], [9, 9], [1, 9]]}
|
||||
{
|
||||
"points": [[1, 1], [9, 1], [9, 9], [1, 9]],
|
||||
"invert": False,
|
||||
}
|
||||
),
|
||||
shape=shape,
|
||||
mesh_resolution=64,
|
||||
@@ -961,12 +1102,12 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main, "image_rgba", return_value=source_rgba
|
||||
),
|
||||
) as read_rgba,
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"rasterize_selection_path",
|
||||
return_value=selection_mask,
|
||||
),
|
||||
) as rasterize,
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"configured_max_ai_input_size",
|
||||
@@ -977,6 +1118,16 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
"configured_max_cutout_mesh_resolution",
|
||||
return_value=1024,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"_cutout_content_values",
|
||||
return_value=selection_mask.values,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"create_cropped_color_image",
|
||||
return_value="Color",
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main, "create_cutout_shape"
|
||||
) as convert,
|
||||
@@ -986,15 +1137,164 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
read_rgba.assert_called_once_with(source.data)
|
||||
rasterize.assert_called_once()
|
||||
convert.assert_called_once_with(
|
||||
context,
|
||||
source,
|
||||
shape,
|
||||
64,
|
||||
selection_mask,
|
||||
source_rgba=source_rgba,
|
||||
selection_mask.values,
|
||||
selection_mask.bounds,
|
||||
color_image="Color",
|
||||
)
|
||||
|
||||
def test_ai_cutout_writes_the_selection_bounds_image(self):
|
||||
import numpy as np
|
||||
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(4096, 2048)),
|
||||
select_set=lambda _value: None,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
||||
)
|
||||
source_rgba = np.ones((4, 8, 4), dtype=np.float32)
|
||||
selection_mask = self.image_selection.SelectionMask(
|
||||
np.ones((2, 6), dtype=np.float32),
|
||||
(1, 1, 7, 3),
|
||||
)
|
||||
operator = self.cutout_main.CutoutSelectionToShape()
|
||||
operator.source_object_name = "Source"
|
||||
operator.selection_path_json = json.dumps(
|
||||
{
|
||||
"points": [[1, 1], [7, 1], [7, 3], [1, 3]],
|
||||
"invert": False,
|
||||
}
|
||||
)
|
||||
operator.shape = "DEPTH_SURFACE"
|
||||
operator.mesh_resolution = 64
|
||||
operator.generate_normal = False
|
||||
operator.refine_selection = False
|
||||
operator.report = lambda *_args: None
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(self.cutout_main, "video_total_frames", return_value=1),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"image_rgba",
|
||||
return_value=source_rgba,
|
||||
) as read_rgba,
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"rasterize_selection_path",
|
||||
return_value=selection_mask,
|
||||
) as rasterize,
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"configured_max_ai_input_size",
|
||||
return_value=2048,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"configured_max_cutout_mesh_resolution",
|
||||
return_value=1024,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"invoke_ai_setup_if_needed",
|
||||
return_value=None,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"cutout_geometry_settings",
|
||||
return_value=("MODEL", 5),
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"prepare_image_bounds_input",
|
||||
return_value=Path(__file__),
|
||||
) as prepare_input,
|
||||
patch.object(
|
||||
self.cutout_main.JobOperatorBase,
|
||||
"execute",
|
||||
return_value={"FINISHED"},
|
||||
),
|
||||
):
|
||||
result = operator.execute(context)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
read_rgba.assert_called_once_with(source.data)
|
||||
rasterize.assert_called_once()
|
||||
prepare_input.assert_called_once_with(
|
||||
source.data,
|
||||
source_rgba,
|
||||
selection_mask.bounds,
|
||||
)
|
||||
|
||||
def test_empty_cutout_selection_is_rejected_after_shape_choice(self):
|
||||
source = SimpleNamespace(
|
||||
data=SimpleNamespace(size=(4096, 2048)),
|
||||
select_set=lambda _value: None,
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
|
||||
)
|
||||
reports = []
|
||||
operator = SimpleNamespace(
|
||||
source_object_name="Source",
|
||||
selection_path_json=json.dumps(
|
||||
{
|
||||
"points": [[1, 1], [7, 1], [7, 3], [1, 3]],
|
||||
"invert": False,
|
||||
}
|
||||
),
|
||||
shape="SURFACE",
|
||||
mesh_resolution=64,
|
||||
generate_normal=False,
|
||||
refine_selection=False,
|
||||
report=lambda level, message: reports.append((level, message)),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(self.cutout_main, "video_total_frames", return_value=1),
|
||||
patch.object(self.cutout_main, "image_rgba"),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"rasterize_selection_path",
|
||||
side_effect=self.image_selection.ImageEditWarning(
|
||||
"The selection contains no visible image pixels"
|
||||
),
|
||||
),
|
||||
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
|
||||
):
|
||||
result = self.cutout_main.CutoutSelectionToShape.execute(
|
||||
operator,
|
||||
context,
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"CANCELLED"})
|
||||
create_shape.assert_not_called()
|
||||
self.assertEqual(
|
||||
reports,
|
||||
[
|
||||
(
|
||||
{"WARNING"},
|
||||
"The selection contains no visible image pixels",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
def test_cutout_request_describes_ai_capabilities(self):
|
||||
operator = self.cutout_main.CutoutSelectionToShape()
|
||||
operator.input_path = __file__
|
||||
@@ -1343,34 +1643,27 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
(1, 1),
|
||||
)
|
||||
|
||||
even_odd = self.image_selection.rasterize_selection_path(
|
||||
selection = self.image_selection.rasterize_selection_path(
|
||||
(12, 9),
|
||||
self.image_selection.SelectionPath(points=path, fill_rule="EVEN_ODD"),
|
||||
).full_values((12, 9))
|
||||
nonzero = self.image_selection.rasterize_selection_path(
|
||||
(12, 9),
|
||||
self.image_selection.SelectionPath(points=path, fill_rule="NONZERO"),
|
||||
self.image_selection.SelectionPath(points=path),
|
||||
).full_values((12, 9))
|
||||
|
||||
self.assertFalse(even_odd[3, 5])
|
||||
self.assertTrue(nonzero[3, 5])
|
||||
self.assertTrue(selection[3, 5])
|
||||
|
||||
def preview_area(fill_rule):
|
||||
triangles = self.image_interaction.scanline_fill_triangles(path, fill_rule)
|
||||
return sum(
|
||||
abs(
|
||||
self.image_interaction._triangle_cross(
|
||||
triangles[index],
|
||||
triangles[index + 1],
|
||||
triangles[index + 2],
|
||||
)
|
||||
triangles = self.image_interaction.scanline_fill_triangles(path)
|
||||
preview_area = sum(
|
||||
abs(
|
||||
self.image_interaction._triangle_cross(
|
||||
triangles[index],
|
||||
triangles[index + 1],
|
||||
triangles[index + 2],
|
||||
)
|
||||
* 0.5
|
||||
for index in range(0, len(triangles), 3)
|
||||
)
|
||||
* 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)
|
||||
self.assertAlmostEqual(preview_area, 54.0)
|
||||
|
||||
def test_expected_empty_image_edits_are_reported_as_warnings(self):
|
||||
reports = []
|
||||
@@ -1405,6 +1698,7 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
self.assertIn(operator, self.anyimage.CLASSES)
|
||||
self.assertIn(self.edit_mask.RefineImageSelection, self.anyimage.CLASSES)
|
||||
self.assertIn(self.edit_mask.ActivateImageTool, self.anyimage.CLASSES)
|
||||
self.assertNotIn("selection_path_json", operator.__annotations__)
|
||||
tool_cases = (
|
||||
(self.image_box.ImageBoxTool, "BOX", "ops.sculpt.border_mask"),
|
||||
(self.image_lasso.ImageLassoTool, "LASSO", "ops.sculpt.lasso_mask"),
|
||||
@@ -1438,6 +1732,14 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
"anyimage.operators.cutout_tool"
|
||||
).CutoutTool
|
||||
cutout_module = sys.modules[cutout_tool.__module__]
|
||||
self.assertNotIn(
|
||||
"selection_path_json",
|
||||
cutout_module.SelectCutoutSelection.__annotations__,
|
||||
)
|
||||
self.assertIn(
|
||||
"selection_path_json",
|
||||
cutout_module.CutoutSelectionToShape.__annotations__,
|
||||
)
|
||||
drawn_settings.clear()
|
||||
with (
|
||||
patch.object(
|
||||
@@ -1894,7 +2196,7 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
|
||||
def test_image_mask_preview_fills_a_self_intersecting_lasso(self):
|
||||
polygon = ((0, 0), (4, 4), (0, 4), (4, 0))
|
||||
triangles = self.image_interaction.preview_fill_triangles(polygon, "NONZERO")
|
||||
triangles = self.image_interaction.preview_fill_triangles(polygon)
|
||||
area = sum(
|
||||
abs(
|
||||
self.image_interaction._triangle_cross(
|
||||
@@ -2010,19 +2312,20 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
(),
|
||||
)
|
||||
|
||||
def test_image_box_completion_serializes_the_image_selection(self):
|
||||
def test_image_box_completion_submits_the_image_selection_path(self):
|
||||
source = SimpleNamespace(data=SimpleNamespace(size=(64, 32)))
|
||||
finished = []
|
||||
submitted = []
|
||||
operator = SimpleNamespace(
|
||||
source_object_name="Source",
|
||||
source_matrix_data="matrix",
|
||||
gesture="BOX",
|
||||
selection_path_json="",
|
||||
invert=False,
|
||||
preview_fill_rule="EVEN_ODD",
|
||||
_screen_path=lambda: ((1, 1), (8, 1), (8, 6), (1, 6)),
|
||||
_finish=lambda context: finished.append(context),
|
||||
execute=lambda _context: {"FINISHED"},
|
||||
_submit_selection_path=lambda context, selection_path: (
|
||||
submitted.append((context, selection_path)) or {"FINISHED"}
|
||||
),
|
||||
)
|
||||
context = SimpleNamespace(region=object(), region_data=object())
|
||||
polygon = ((1, 2), (3, 4), (5, 6))
|
||||
@@ -2042,12 +2345,296 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
result = self.image_interaction.ImageSelectionGesture._complete(operator, context)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
self.assertEqual(
|
||||
json.loads(operator.selection_path_json)["points"],
|
||||
[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
|
||||
)
|
||||
self.assertEqual(submitted[0][0], context)
|
||||
self.assertEqual(submitted[0][1].points, polygon)
|
||||
self.assertEqual(finished, [context])
|
||||
|
||||
def test_local_image_edit_builds_one_selection(self):
|
||||
import numpy as np
|
||||
|
||||
source = SimpleNamespace(data=SimpleNamespace(size=(65536, 32768)))
|
||||
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
|
||||
selection_path = self.image_selection.SelectionPath(
|
||||
points=((1, 1), (7, 1), (7, 5), (1, 5))
|
||||
)
|
||||
selection_mask = self.image_selection.SelectionMask(
|
||||
np.ones((4, 6), dtype=np.float32),
|
||||
(1, 1, 7, 5),
|
||||
)
|
||||
operator = SimpleNamespace(
|
||||
source_object_name="Source",
|
||||
refine_selection=False,
|
||||
report=lambda *_args: None,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.edit_mask,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(self.edit_mask, "video_total_frames", return_value=1),
|
||||
patch.object(
|
||||
self.edit_mask,
|
||||
"image_rgba",
|
||||
return_value=source_rgba,
|
||||
) as read_rgba,
|
||||
patch.object(
|
||||
self.edit_mask,
|
||||
"rasterize_selection_path",
|
||||
return_value=selection_mask,
|
||||
) as rasterize,
|
||||
patch.object(self.edit_mask, "_edit_image_object") as edit,
|
||||
):
|
||||
result = self.edit_mask.EditImageMask._submit_selection_path(
|
||||
operator,
|
||||
SimpleNamespace(),
|
||||
selection_path,
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
read_rgba.assert_called_once_with(source.data)
|
||||
rasterize.assert_called_once()
|
||||
edit.assert_called_once_with(source, source_rgba, selection_mask)
|
||||
|
||||
def test_image_edit_applies_the_local_selection_mask(self):
|
||||
import numpy as np
|
||||
|
||||
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
|
||||
source_rgba[:, :, 0] = np.arange(8, dtype=np.float32)[None, :]
|
||||
values = np.ones((4, 6), dtype=np.float32)
|
||||
values[0, :] = 0.0
|
||||
values[:, 0] = 0.0
|
||||
selection_mask = self.image_selection.SelectionMask(
|
||||
values,
|
||||
(1, 1, 7, 5),
|
||||
)
|
||||
|
||||
pixels, bounds = self.edit_mask._edit_image_mask_pixels(
|
||||
source_rgba,
|
||||
selection_mask,
|
||||
)
|
||||
|
||||
self.assertEqual(bounds, (2, 2, 7, 5))
|
||||
rgba = np.flipud(pixels.reshape((3, 5, 4)))
|
||||
self.assertTrue(np.all(rgba[:, :, 3] == 1.0))
|
||||
self.assertTrue(np.all(rgba[:, :, 0] == np.arange(2, 7)[None, :]))
|
||||
|
||||
def test_refined_image_edit_writes_the_selection_bounds_image(self):
|
||||
import numpy as np
|
||||
|
||||
source = SimpleNamespace(data=SimpleNamespace(size=(8, 6)))
|
||||
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
|
||||
selection_path = self.image_selection.SelectionPath(
|
||||
points=((1, 1), (7, 1), (7, 5), (1, 5))
|
||||
)
|
||||
selection_mask = self.image_selection.SelectionMask(
|
||||
np.ones((4, 6), dtype=np.float32),
|
||||
(1, 1, 7, 5),
|
||||
)
|
||||
operator = SimpleNamespace(
|
||||
source_object_name="Source",
|
||||
refine_selection=True,
|
||||
report=lambda *_args: None,
|
||||
)
|
||||
refine = Mock(return_value={"FINISHED"})
|
||||
ops = SimpleNamespace(
|
||||
anyimage=SimpleNamespace(refine_image_selection=refine)
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.edit_mask,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(self.edit_mask, "video_total_frames", return_value=1),
|
||||
patch.object(
|
||||
self.edit_mask,
|
||||
"image_rgba",
|
||||
return_value=source_rgba,
|
||||
) as read_rgba,
|
||||
patch.object(
|
||||
self.edit_mask,
|
||||
"rasterize_selection_path",
|
||||
return_value=selection_mask,
|
||||
) as rasterize,
|
||||
patch.object(
|
||||
self.edit_mask,
|
||||
"prepare_image_bounds_input",
|
||||
return_value=Path(__file__),
|
||||
) as prepare_input,
|
||||
patch.object(self.edit_mask.bpy, "ops", ops, create=True),
|
||||
):
|
||||
result = self.edit_mask.EditImageMask._submit_selection_path(
|
||||
operator,
|
||||
SimpleNamespace(),
|
||||
selection_path,
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"FINISHED"})
|
||||
read_rgba.assert_called_once_with(source.data)
|
||||
rasterize.assert_called_once()
|
||||
prepare_input.assert_called_once_with(
|
||||
source.data,
|
||||
source_rgba,
|
||||
selection_mask.bounds,
|
||||
)
|
||||
refine.assert_called_once()
|
||||
|
||||
def test_refined_image_response_uses_the_encoded_result_directly(self):
|
||||
source_image = SimpleNamespace(
|
||||
name="Poster.png",
|
||||
colorspace_settings=SimpleNamespace(name="sRGB"),
|
||||
alpha_mode="STRAIGHT",
|
||||
)
|
||||
source = SimpleNamespace(data=source_image)
|
||||
refined = SimpleNamespace(users=0)
|
||||
files = {
|
||||
"selection_bounds": SimpleNamespace(
|
||||
read_text=lambda encoding: "[2,1,5,4]"
|
||||
),
|
||||
"selection_image": Path(__file__),
|
||||
}
|
||||
result = SimpleNamespace(file=lambda key: files[key])
|
||||
operator = self.edit_mask.RefineImageSelection()
|
||||
operator.source_object_name = "Source"
|
||||
operator.selection_bounds_json = "[10,20,18,26]"
|
||||
ops = SimpleNamespace(
|
||||
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.edit_mask,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(
|
||||
self.edit_mask,
|
||||
"load_packed_rgba_image",
|
||||
return_value=refined,
|
||||
) as load_result,
|
||||
patch.object(self.edit_mask, "replace_empty_image") as replace,
|
||||
patch.object(self.edit_mask, "image_rgba") as read_rgba,
|
||||
patch.object(
|
||||
self.edit_mask,
|
||||
"create_pixel_result_image",
|
||||
) as create_pixels,
|
||||
patch.object(self.edit_mask.bpy, "ops", ops, create=True),
|
||||
):
|
||||
message = operator.response(None, result)
|
||||
|
||||
self.assertEqual(message, "Image Selection refined")
|
||||
load_result.assert_called_once_with(
|
||||
Path(__file__),
|
||||
source_image,
|
||||
"Poster",
|
||||
)
|
||||
replace.assert_called_once_with(
|
||||
source,
|
||||
refined,
|
||||
placement_bounds=(12, 21, 15, 24),
|
||||
)
|
||||
read_rgba.assert_not_called()
|
||||
create_pixels.assert_not_called()
|
||||
|
||||
def test_cutout_ai_response_reuses_color_image_and_keeps_lasso_for_no_refine(self):
|
||||
import numpy as np
|
||||
|
||||
source = SimpleNamespace(data=SimpleNamespace())
|
||||
color_image = SimpleNamespace(users=0)
|
||||
pure_values = np.ones((3, 4), dtype=np.float32)
|
||||
pure_values[:, 0] = 0.0
|
||||
selection_mask = self.image_selection.SelectionMask(
|
||||
pure_values,
|
||||
(10, 20, 14, 23),
|
||||
)
|
||||
operator = self.cutout_main.CutoutSelectionToShape()
|
||||
operator.source_object_name = "Source"
|
||||
operator.selection_bounds_json = "[10,20,14,23]"
|
||||
operator.input_path = __file__
|
||||
operator.shape = "SURFACE"
|
||||
operator.mesh_resolution = 32
|
||||
operator.generate_normal = True
|
||||
operator.refine_selection = False
|
||||
operator._selection_mask = selection_mask
|
||||
result = SimpleNamespace(value={}, file=lambda _key: Path(__file__))
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"_generated_color_result",
|
||||
return_value=(
|
||||
color_image,
|
||||
np.ones((3, 4), dtype=np.float32),
|
||||
selection_mask.bounds,
|
||||
),
|
||||
),
|
||||
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
|
||||
):
|
||||
message = operator.response(SimpleNamespace(), result)
|
||||
|
||||
self.assertEqual(message, "Surface is ready")
|
||||
content_values = create_shape.call_args.args[4]
|
||||
np.testing.assert_array_equal(content_values, pure_values)
|
||||
self.assertEqual(create_shape.call_args.args[5], selection_mask.bounds)
|
||||
self.assertIs(create_shape.call_args.kwargs["color_image"], color_image)
|
||||
|
||||
def test_cutout_refine_keeps_ben2_content_outside_the_lasso(self):
|
||||
import numpy as np
|
||||
|
||||
source = SimpleNamespace(data=SimpleNamespace())
|
||||
color_image = SimpleNamespace(users=0)
|
||||
operator = self.cutout_main.CutoutSelectionToShape()
|
||||
operator.source_object_name = "Source"
|
||||
operator.selection_bounds_json = "[10,20,16,24]"
|
||||
operator.input_path = __file__
|
||||
operator.shape = "SURFACE"
|
||||
operator.mesh_resolution = 32
|
||||
operator.generate_normal = False
|
||||
operator.refine_selection = True
|
||||
operator._selection_mask = self.image_selection.SelectionMask(
|
||||
np.zeros((4, 6), dtype=np.float32),
|
||||
(10, 20, 16, 24),
|
||||
)
|
||||
result = SimpleNamespace(
|
||||
value={"selection_image": "selection.png"},
|
||||
file=lambda _key: Path(__file__),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"require_image_empty",
|
||||
return_value=source,
|
||||
),
|
||||
patch.object(
|
||||
self.cutout_main,
|
||||
"_generated_color_result",
|
||||
return_value=(
|
||||
color_image,
|
||||
np.ones((2, 3), dtype=np.float32),
|
||||
(12, 21, 15, 23),
|
||||
),
|
||||
),
|
||||
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
|
||||
):
|
||||
operator.response(SimpleNamespace(), result)
|
||||
|
||||
content_values = create_shape.call_args.args[4]
|
||||
np.testing.assert_array_equal(
|
||||
content_values,
|
||||
np.ones((2, 3), dtype=np.float32),
|
||||
)
|
||||
self.assertEqual(create_shape.call_args.args[5], (12, 21, 15, 23))
|
||||
self.assertIs(create_shape.call_args.kwargs["color_image"], color_image)
|
||||
|
||||
def test_image_selection_uses_white_dashed_segments(self):
|
||||
segments = self.image_interaction.dashed_line_segments(
|
||||
((0, 0), (10, 0)), dash_length=3.0, gap_length=2.0
|
||||
@@ -2066,7 +2653,6 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
_path=[polygon[0]],
|
||||
_area_pointer=1,
|
||||
_region_pointer=2,
|
||||
preview_fill_rule="NONZERO",
|
||||
_fill_polygon=None,
|
||||
_fill_triangles=(),
|
||||
_outline_polygon=None,
|
||||
@@ -2091,7 +2677,7 @@ class BlenderAddonTest(unittest.TestCase):
|
||||
self.image_interaction.ImageSelectionGesture._draw_selection(operator)
|
||||
self.image_interaction.ImageSelectionGesture._draw_selection(operator)
|
||||
|
||||
build_fill.assert_called_once_with(polygon, "NONZERO")
|
||||
build_fill.assert_called_once_with(polygon)
|
||||
build_outline.assert_called_once_with((*polygon, polygon[0]))
|
||||
self.assertEqual(draw_fill.call_count, 2)
|
||||
draw_fill.assert_called_with(
|
||||
|
||||
Reference in New Issue
Block a user