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:
2026-09-01 22:36:20 +08:00
parent 2ff7b04f56
commit 7f6e47432e
54 changed files with 2831 additions and 634 deletions

View File

@@ -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(

View File

@@ -283,7 +283,7 @@ def test_selection_contours_group_holes_with_their_connected_component():
alpha[5:15, 5:15] = 255
selection_contours = extract_selection_contours(
SelectionMask(alpha, (0, 0, 200, 120)),
alpha,
max_resolution=200,
)
@@ -306,7 +306,7 @@ def test_selection_contours_accept_blender_float_mask_with_a_hole():
alpha[45:75, 80:120] = 0.0
selection_contours = extract_selection_contours(
SelectionMask(alpha, (0, 0, 200, 120)),
alpha,
max_resolution=200,
)
@@ -337,7 +337,7 @@ def test_depth_surface_cutout_uses_half_alpha_boundary():
alpha[4:8, 5:11] = 0.51
selection_contours = cutout_geometry.extract_cutout_contours(
SelectionMask(alpha, (0, 0, 16, 12)),
alpha,
64,
threshold=0.5,
)
@@ -364,7 +364,7 @@ def test_blender_cutout_alpha_pipeline_does_not_import_pillow():
)
antialiased = selection_mask.full_values((48, 32))
selection_contours = extract_selection_contours(
SelectionMask(antialiased, (0, 0, 48, 32)),
antialiased,
max_resolution=24,
)
@@ -380,8 +380,8 @@ def test_blender_cutout_contour_resolution_tracks_mesh_detail():
)
selection_mask = SelectionMask(np.ones((2, 2), dtype=np.float32), (0, 0, 2, 2))
with patch.object(cutout_geometry, "extract_selection_contours", return_value=expected) as trace:
assert cutout_geometry.extract_cutout_contours(selection_mask, 32) == expected
assert cutout_geometry.extract_cutout_contours(selection_mask, 256) == expected
assert cutout_geometry.extract_cutout_contours(selection_mask.values, 32) == expected
assert cutout_geometry.extract_cutout_contours(selection_mask.values, 256) == expected
assert trace.call_args_list[0].kwargs["max_resolution"] == 512
assert trace.call_args_list[1].kwargs["max_resolution"] == 1024
@@ -580,7 +580,10 @@ def test_selection_calibration_uses_the_final_selection_mask():
(0, 0, 2, 2),
)
valid, transparent_fraction = cutout_geometry.selection_calibration(selection_mask, (2, 2))
valid, transparent_fraction = cutout_geometry.selection_calibration(
selection_mask.values,
(2, 2),
)
assert np.array_equal(valid, ((True, True), (True, False)))
assert transparent_fraction == 0.25

View File

@@ -1,9 +1,8 @@
import json
import sys
from pathlib import Path
from types import MethodType, SimpleNamespace
import bpy
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
@@ -83,6 +82,15 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
context = cutout_context(window_manager, mesh_detail="MEDIUM", generate_normal=True)
source = cutout_source(size=(4096, 2048))
monkeypatch.setattr(interaction, "ai_ready", lambda: True)
serialized = []
original_to_json = SelectionPath.to_json
def record_to_json(selection_path):
value = original_to_json(selection_path)
serialized.append(value)
return value
monkeypatch.setattr(SelectionPath, "to_json", record_to_json)
interaction.open_shape_pie(
context,
@@ -103,6 +111,12 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
]
assert all(call[2].refine_selection for call in window_manager.pie.calls)
assert all(call[2].generate_normal for call in window_manager.pie.calls)
assert len(serialized) == 1
assert set(json.loads(serialized[0])) == {"points", "invert"}
assert all(
call[2].selection_path_json == serialized[0]
for call in window_manager.pie.calls
)
assert all(
SelectionPath.from_json(call[2].selection_path_json).invert
for call in window_manager.pie.calls
@@ -132,12 +146,7 @@ def test_cutout_mesh_detail_maps_to_denser_sampling():
def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypatch):
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Source", width=320, height=180)
source = bpy.data.objects.new("Source", None)
source.empty_display_type = "IMAGE"
source.data = image
bpy.context.collection.objects.link(source)
source = cutout_source(size=(65536, 32768))
window_manager = WindowManager()
context = cutout_context(window_manager)
@@ -145,15 +154,32 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
cutout_main, "resolve_image_edit_click", lambda *_args: (source, True)
)
monkeypatch.setattr(cutout_main, "video_total_frames", lambda _image: 1)
monkeypatch.setattr(cutout_main, "require_image_empty", lambda _name: source)
monkeypatch.setattr(cutout_main, "apply_selection_settings", lambda *_args: None)
monkeypatch.setattr(cutout_main, "validate_cutout_selection", lambda *_args: None)
monkeypatch.setattr(cutout_main, "serialize_matrix", lambda _matrix: "matrix")
monkeypatch.setattr(
cutout_main,
"image_rgba",
lambda *_args: (_ for _ in ()).throw(AssertionError("unexpected pixel read")),
)
monkeypatch.setattr(
cutout_main,
"rasterize_selection_path",
lambda *_args, **_kwargs: (_ for _ in ()).throw(
AssertionError("unexpected Selection rasterization")
),
)
operator = SimpleNamespace(
full_image=True,
refine_selection=False,
report=lambda *_args: None,
)
operator.execute = MethodType(cutout_main.SelectCutoutSelection.execute, operator)
source.matrix_world = object()
operator._submit_selection_path = MethodType(
cutout_main.SelectCutoutSelection._submit_selection_path,
operator,
)
result = cutout_main.SelectCutoutSelection.invoke(
operator, context, SimpleNamespace(value="DOUBLE_CLICK")
)
@@ -161,8 +187,13 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
assert issubclass(cutout_main.SelectCutoutSelection, ImageSelectionGesture)
assert result == {"FINISHED"}
selection_path = SelectionPath.from_json(window_manager.pie.calls[0][2].selection_path_json)
assert selection_path.points == ((0, 0), (320, 0), (320, 180), (0, 180))
assert window_manager.pie.calls[0][2].mesh_resolution == 10
assert selection_path.points == (
(0, 0),
(65536, 0),
(65536, 32768),
(0, 32768),
)
assert window_manager.pie.calls[0][2].mesh_resolution == 1024
assert not hasattr(cutout_main, "pending_selection")

View File

@@ -5,7 +5,8 @@ from unittest.mock import patch
import bpy
import numpy as np
from mathutils import Vector
import pytest
from mathutils import Matrix, Vector
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
@@ -16,10 +17,12 @@ from anyimage.common import material as material_data
from anyimage.common import node as node_asset
from anyimage.common import object as object_data
from anyimage.common.selection import (
ImageEditWarning,
SelectionMask,
SelectionPath,
rasterize_selection_path,
)
from anyimage.operators.clipboard_image import actions as clipboard_actions
from anyimage.operators.convert_to_plane import object as plane_object
from anyimage.operators.cutout_tool import geometry as cutout_geometry
from anyimage.operators.cutout_tool import main as cutout_main
@@ -64,21 +67,30 @@ def _create_cutout_shape(
if selection_values is None:
selection_values = np.ones((bottom - top, right - left), dtype=np.float32)
selection_mask = SelectionMask(selection_values, bounds)
source_rgba = image_data.image_rgba(source.data)
color_image = cutout_object.create_cropped_color_image(
source,
selection_mask.bounds,
source_rgba,
)
base_shape = cutout_geometry.build_base_shape(
bpy.context,
source,
detail,
selection_mask,
selection_mask.values,
selection_mask.bounds,
)
return cutout_object.create_shape_object(
bpy.context,
source,
shape,
detail,
selection_mask,
selection_mask.values,
selection_mask.bounds,
base_shape,
depth_image=depth_image,
depth_metadata=depth_metadata,
color_image=color_image,
)
@@ -177,16 +189,15 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
cutout_object.load_cutout_node_group() == cutout
for shape in cutout_shape.CUTOUT_SHAPES
)
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"]
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness"]
assert _inputs(depth_plane) == [
"Geometry",
"Subdivide",
"Thickness",
"Depth Amount",
"Reference Depth",
"Depth Scale",
"Base Plane Depth",
"Uniform Scale",
"Depth Image",
"Material",
]
assert _inputs(cutout) == [
"Geometry",
@@ -391,6 +402,12 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
if item.item_type == "SOCKET" and item.name == "Thickness"
)
assert thickness.subtype == "DISTANCE"
depth_subdivide = next(
item
for item in depth_plane.interface.items_tree
if item.item_type == "SOCKET" and item.name == "Subdivide"
)
assert depth_subdivide.default_value == 6
for group in (
child_groups["BALLOON"],
child_groups["DEPTH_BALLOON"],
@@ -504,6 +521,31 @@ def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
evaluated_mesh = evaluated.to_mesh()
try:
assert len(evaluated_mesh.polygons) > 0
assert result.data.materials[0] == material
assert evaluated_mesh.materials[0].original == material
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
finally:
evaluated.to_mesh_clear()
def test_clipboard_plane_uses_the_mesh_material_slot():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Clipboard Plane", width=128, height=64, alpha=True)
with patch.object(clipboard_actions, "_place_in_view"):
result = clipboard_actions.add_image_plane(bpy.context, image)
assert _inputs(result.modifiers[0].node_group) == [
"Geometry",
"Subdivide",
"Thickness",
]
assert len(result.data.materials) == 1
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
evaluated_mesh = evaluated.to_mesh()
try:
assert evaluated_mesh.materials[0].original == result.data.materials[0]
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
finally:
evaluated.to_mesh_clear()
@@ -787,6 +829,192 @@ def _evaluated_positions(obj):
evaluated.to_mesh_clear()
def test_converted_planes_center_the_origin_without_moving_world_corners():
bpy.ops.wm.read_factory_settings(use_empty=True)
def create_source(name):
image = bpy.data.images.new(name, width=200, height=100)
source = bpy.data.objects.new(name, None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
source.empty_image_offset = (0.25, -0.2)
source.matrix_world = (
Matrix.Translation((3.0, -2.0, 5.0))
@ Matrix.Rotation(0.4, 4, "Z")
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
)
bpy.context.collection.objects.link(source)
return source
def assert_centered(result, bounds, source_matrix):
x_min, x_max, y_min, y_max = bounds
center = Vector(
((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, 0.0)
)
assert np.allclose(
tuple(result.matrix_world.translation),
tuple(source_matrix @ center),
atol=1e-6,
)
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
assert np.allclose(local[:, :2].min(axis=0), (-1.0, -0.5), atol=1e-6)
assert np.allclose(local[:, :2].max(axis=0), (1.0, 0.5), atol=1e-6)
expected = sorted(
tuple(source_matrix @ Vector((x, y, 0.0)))
for x in (x_min, x_max)
for y in (y_min, y_max)
)
actual = sorted(
tuple(result.matrix_world @ vertex.co)
for vertex in result.data.vertices
)
assert np.allclose(actual, expected, atol=1e-6)
plane_source = create_source("Centered Plane")
plane_bounds = image_data.image_empty_bounds(plane_source)
plane_matrix = plane_source.matrix_world.copy()
plane_material = bpy.data.materials.new("Centered Plane Material")
plane = plane_object.create_plane_object(
bpy.context,
plane_source,
0,
plane_material,
)
assert_centered(plane, plane_bounds, plane_matrix)
assert plane["anyimage_mesh_shape"] == "PLANE"
depth_source = create_source("Centered Depth Plane")
depth_bounds = image_data.image_empty_bounds(depth_source)
depth_matrix = depth_source.matrix_world.copy()
depth_values = np.ones((2, 2, 3), dtype=np.float32)
depth_image = _depth_image(depth_values, np.ones((2, 2), dtype=bool))
depth_material = bpy.data.materials.new("Centered Depth Material")
depth_plane = plane_object.create_depth_plane_object(
bpy.context,
depth_source,
0,
depth_material,
depth_image,
_depth_metadata(
((0.5, 0.0, 0.5), (0.0, 0.5, 0.5), (0.0, 0.0, 1.0)),
(2, 2),
1.0,
),
)
assert_centered(depth_plane, depth_bounds, depth_matrix)
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
depth_modifier = depth_plane.modifiers[0]
uniform_scale = depth_modifier[
object_data.modifier_input_identifier(
depth_modifier.node_group,
"Uniform Scale",
)
]
assert np.isclose(
depth_modifier[
object_data.modifier_input_identifier(
depth_modifier.node_group,
"Base Plane Depth",
)
],
uniform_scale,
)
def test_depth_plane_builds_a_fixed_base_terrain_block():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Terrain", width=4, height=4)
source = bpy.data.objects.new("Terrain", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
material = bpy.data.materials.new("Terrain Material")
depth_image = _depth_image(
np.ones((4, 4, 3), dtype=np.float32),
np.ones((4, 4), dtype=bool),
)
result = plane_object.create_depth_plane_object(
bpy.context,
source,
2,
material,
depth_image,
_depth_metadata(
((1.5, 0.0, 1.5), (0.0, 1.5, 1.5), (0.0, 0.0, 1.0)),
(4, 4),
1.0,
),
)
modifier = result.modifiers[0]
group = modifier.node_group
def set_input(name, value):
object_data.set_modifier_input(
modifier,
object_data.modifier_input_identifier(group, name),
value,
)
result.update_tag(refresh={"DATA"})
bpy.context.view_layer.update()
set_input("Depth Scale", 0.0)
baseline = _evaluated_positions(result)
assert np.isclose(baseline[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(baseline[:, 2].max(), 0.1, atol=1e-6)
baseline_base = {
(round(float(x), 6), round(float(y), 6))
for x, y, z in baseline
if np.isclose(z, 0.0, atol=1e-6)
}
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
evaluated_mesh = evaluated.to_mesh()
try:
assert result.data.materials[0] == material
assert evaluated_mesh.materials[0].original == material
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
assert "UVMap" in evaluated_mesh.uv_layers
finally:
evaluated.to_mesh_clear()
set_input("Depth Scale", 1.0)
set_input("Base Plane Depth", 1.2)
outward = _evaluated_positions(result)
assert np.isclose(outward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(outward[:, 2].max(), 0.3, atol=1e-5)
outward_base = {
(round(float(x), 6), round(float(y), 6))
for x, y, z in outward
if np.isclose(z, 0.0, atol=1e-6)
}
assert outward_base == baseline_base
z_levels = np.unique(np.round(outward[:, 2], 6))
assert len(z_levels) >= 4
assert np.allclose(np.diff(z_levels), np.diff(z_levels)[0], atol=1e-5)
set_input("Base Plane Depth", 0.95)
inward = _evaluated_positions(result)
assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6)
assert np.isclose(inward[:, 2].max(), 0.05, atol=1e-5)
set_input("Base Plane Depth", 0.0)
limited = _evaluated_positions(result)
assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6)
assert limited[:, 2].max() > 0.0
assert limited[:, 2].max() < 0.001
set_input("Base Plane Depth", 1.3)
outward_beyond_thickness = _evaluated_positions(result)
assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5)
set_input("Thickness", 0.0)
set_input("Base Plane Depth", 1.2)
sheet = _evaluated_positions(result)
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5)
def _add_cutout_modifier(obj, shape):
group = cutout_object.load_cutout_node_group()
modifier = obj.modifiers.new(group.name, "NODES")
@@ -1206,6 +1434,41 @@ def test_converted_color_images_are_new_and_use_blender_duplicate_suffix():
assert second.name == "Poster_color.png.001"
def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Bounds Source", width=5, height=4, alpha=True)
rgba = np.arange(4 * 5 * 4, dtype=np.float32).reshape((4, 5, 4)) / 100.0
rgba[:, :, 3] = np.asarray(
(
(0.0, 0.1, 0.2, 0.3, 0.4),
(0.5, 0.6, 0.7, 0.8, 0.9),
(1.0, 0.9, 0.8, 0.7, 0.6),
(0.5, 0.4, 0.3, 0.2, 0.1),
),
dtype=np.float32,
)
path = image_data.prepare_image_bounds_input(image, rgba, (1, 1, 5, 4))
result = None
try:
result = image_data.load_packed_rgba_image(
path,
image,
"Bounds Result",
)
loaded = image_data.image_rgba(result)
np.testing.assert_allclose(loaded, rgba[1:4, 1:5], atol=1.0 / 255.0)
assert result.packed_file is not None
image_data.cleanup_image_input(path, True)
assert not path.exists()
assert result.packed_file is not None
finally:
image_data.cleanup_image_input(path, True)
if result is not None:
bpy.data.images.remove(result, do_unlink=True)
def test_local_cutout_selection_matches_uint8_tenth_alpha_boundary():
top_down = np.ones((4, 4, 4), dtype=np.float32)
top_down[1, 1, 3] = 25.0 / 255.0
@@ -1214,17 +1477,30 @@ def test_local_cutout_selection_matches_uint8_tenth_alpha_boundary():
(4, 4),
SelectionPath(
points=((0, 0), (4, 0), (4, 4), (0, 4)),
fill_rule="NONZERO",
),
source_alpha=top_down[:, :, 3],
alpha_threshold=0.1,
)
selection = selection_mask.full_values((4, 4))
selection = cutout_main._cutout_content_values(
top_down[:, :, 3],
selection_mask.values,
)
assert selection[1, 1] == 0.0
assert selection[1, 1] <= cutout_geometry.CUTOUT_ALPHA_THRESHOLD
assert selection[1, 2] > 0.1
def test_cutout_content_merge_keeps_the_geometric_selection_unchanged():
selection = np.asarray(((0.25, 1.0), (0.5, 0.75)), dtype=np.float32)
original = selection.copy()
alpha = np.asarray(((1.0, 0.5), (0.0, 0.25)), dtype=np.float32)
content = cutout_main._cutout_content_values(alpha, selection)
np.testing.assert_array_equal(selection, original)
np.testing.assert_allclose(content, alpha * original)
with pytest.raises(ImageEditWarning, match="no visible image pixels"):
cutout_main._cutout_content_values(np.zeros((2, 2)), selection)
def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Local Cutout", width=64, height=48, alpha=True)
@@ -1239,7 +1515,6 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
bpy.context.view_layer.objects.active = source
selection_path = SelectionPath(
points=((-8, 8), (72, 8), (72, 40), (-8, 40)),
fill_rule="NONZERO",
)
source_bounds = image_data.image_empty_bounds(source)
@@ -1252,17 +1527,26 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
selection_mask = rasterize_selection_path(
tuple(image.size),
selection_path,
source_alpha=rgba[:, :, 3],
alpha_threshold=cutout_geometry.CUTOUT_ALPHA_THRESHOLD,
antialias=True,
)
left, top, right, bottom = selection_mask.bounds
content_values = cutout_main._cutout_content_values(
rgba[top:bottom, left:right, 3],
selection_mask.values,
)
color_image = cutout_object.create_cropped_color_image(
source,
selection_mask.bounds,
rgba,
)
cutout_main.create_cutout_shape(
bpy.context,
source,
"SURFACE",
16,
selection_mask,
source_rgba=rgba,
content_values,
selection_mask.bounds,
color_image=color_image,
)
read_rgba.assert_called_once_with(image)
@@ -1315,10 +1599,10 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
if node.bl_idname == "ShaderNodeTexImage"
)
assert color_texture.image is not image
assert tuple(color_texture.image.size) == (64, 34)
copied = np.empty(34 * 64 * 4, dtype=np.float32)
assert tuple(color_texture.image.size) == (64, 36)
copied = np.empty(36 * 64 * 4, dtype=np.float32)
color_texture.image.pixels.foreach_get(copied)
copied = copied.reshape((34, 64, 4))
copied = copied.reshape((36, 64, 4))
assert np.allclose(copied[:, :, :3], 1.0)
left, top, right, bottom = selection_mask.bounds
assert np.allclose(

232
tests/test_selection.py Normal file
View File

@@ -0,0 +1,232 @@
import json
import sys
from pathlib import Path
import numpy as np
import pytest
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
from anyimage.common import selection
def reference_selection_mask(
image_size,
selection_path,
*,
antialias=False,
):
width, height = map(int, image_size)
points = np.asarray(selection_path.points, dtype=np.float64)
x = np.arange(width, dtype=np.float64) + 0.5
y = np.arange(height, dtype=np.float64) + 0.5
winding = np.zeros((height, width), dtype=np.int16)
previous = points[-1]
for current in points:
crosses = (current[1] > y) != (previous[1] > y)
edge_x = (previous[0] - current[0]) * (y - current[1]) / (
previous[1] - current[1] + 1e-300
) + current[0]
selected = crosses[:, None] & (x[None, :] < edge_x[:, None])
winding += selected * (1 if current[1] > previous[1] else -1)
previous = current
mask = winding != 0
mask = (
selection._antialias_mask(mask)
if antialias
else mask.astype(np.float32)
)
if selection_path.invert:
mask = 1.0 - mask
bounds = selection._selection_canvas_bounds(
image_size,
selection_path,
2 if antialias else 0,
)
left, top, right, bottom = bounds
mask = mask[top:bottom, left:right]
if not np.any(mask > 0.0):
raise selection.ImageEditWarning(
"The selection contains no visible image pixels"
)
return selection.SelectionMask(mask, bounds)
def assert_matches_reference(image_size, selection_path, **options):
try:
expected = reference_selection_mask(image_size, selection_path, **options)
except selection.ImageEditWarning:
with pytest.raises(selection.ImageEditWarning):
selection.rasterize_selection_path(
image_size,
selection_path,
**options,
)
return
actual = selection.rasterize_selection_path(
image_size,
selection_path,
**options,
)
assert actual.bounds == expected.bounds
np.testing.assert_array_equal(actual.values, expected.values)
def test_selection_path_json_contains_only_points_and_invert():
selection_path = selection.SelectionPath(
points=((1, 2), (8, 2), (4, 9)),
invert=True,
)
value = selection_path.to_json()
assert set(json.loads(value)) == {"points", "invert"}
assert selection.SelectionPath.from_json(value) == selection_path
with pytest.raises(ValueError, match="Selection Path data is invalid"):
selection.SelectionPath.from_json(
json.dumps(
{
"points": selection_path.points,
"invert": selection_path.invert,
"fill_rule": "NONZERO",
}
)
)
def test_nonzero_winding_keeps_same_direction_overlap():
points = (
(1, 1),
(7, 1),
(7, 7),
(1, 7),
(1, 1),
(4, 1),
(10, 1),
(10, 7),
(4, 7),
(4, 1),
(1, 1),
)
mask = selection.rasterize_selection_path(
(12, 9),
selection.SelectionPath(points),
).full_values((12, 9))
assert mask[3, 5] == 1.0
def test_scanline_matches_reference_for_edge_cases():
paths = (
((1, 1), (15, 1), (15, 10), (1, 10)),
((0.2, 0.3), (15.8, 2.1), (8.4, 11.7)),
((1, 1), (15, 1), (15, 5), (7, 5), (7, 11), (1, 11)),
((1, 1), (15, 11), (1, 11), (15, 1)),
((-8, -3), (10, 1), (23, 8), (7, 18), (-4, 10)),
)
for points in paths:
for invert in (False, True):
for antialias in (False, True):
assert_matches_reference(
(18, 14),
selection.SelectionPath(points, invert=invert),
antialias=antialias,
)
def test_scanline_matches_reference_for_random_paths():
random = np.random.default_rng(20260901)
for _index in range(40):
point_count = int(random.integers(3, 60))
points = tuple(
map(
tuple,
random.uniform((-8, -6), (40, 30), size=(point_count, 2)),
)
)
for invert in (False, True):
for antialias in (False, True):
assert_matches_reference(
(32, 24),
selection.SelectionPath(points, invert=invert),
antialias=antialias,
)
def test_rasterizer_has_no_image_content_parameters():
import inspect
parameters = inspect.signature(selection.rasterize_selection_path).parameters
assert "source_alpha" not in parameters
assert "alpha_threshold" not in parameters
def test_non_inverted_antialias_uses_only_padded_local_canvas(monkeypatch):
selection_path = selection.SelectionPath(
((100, 200), (500, 200), (500, 600), (100, 600))
)
calls = []
rasterize = selection._rasterize_path
def record(bounds, points):
calls.append(bounds)
return rasterize(bounds, points)
monkeypatch.setattr(selection, "_rasterize_path", record)
selection.rasterize_selection_path(
(5120, 2880),
selection_path,
antialias=True,
)
assert calls == [(98, 198, 502, 602)]
def test_inverted_selection_uses_full_canvas(monkeypatch):
selection_path = selection.SelectionPath(
((100, 200), (500, 200), (500, 600), (100, 600)),
invert=True,
)
calls = []
rasterize = selection._rasterize_path
def record(bounds, points):
calls.append(bounds)
return rasterize(bounds, points)
monkeypatch.setattr(selection, "_rasterize_path", record)
selection.rasterize_selection_path((5120, 2880), selection_path)
assert calls == [(0, 0, 5120, 2880)]
def test_high_resolution_lasso_allocates_one_local_scanline_table(monkeypatch):
angles = np.arange(1000, dtype=np.float64) * (2.0 * np.pi / 1000.0)
points = tuple(
zip(
2560.0 + 500.0 * np.cos(angles),
1440.0 + 300.0 * np.sin(angles),
)
)
bounds = selection.selection_path_bounds(points)
allocations = []
zeros = np.zeros
def record(shape, *args, **kwargs):
allocations.append(shape)
return zeros(shape, *args, **kwargs)
monkeypatch.setattr(np, "zeros", record)
mask = selection._rasterize_path(bounds, points)
width = bounds[2] - bounds[0]
height = bounds[3] - bounds[1]
assert mask.shape == (height, width)
assert allocations == [(height, width + 1)]
assert (height, width) != (2880, 5120)

View File

@@ -126,6 +126,7 @@ class ServerJobsTest(unittest.TestCase):
cls.debug_da3_outputs = load_server_module("outputs.debug_da3")
cls.debug_moge2_outputs = load_server_module("outputs.debug_moge2")
cls.debug_jobs = load_server_module("jobs.debug_predictions")
cls.depth_plane_job = load_server_module("jobs.depth_plane")
cls.upscale_job = load_server_module("jobs.upscale")
cls.download = load_server_module("model_download")
@@ -139,6 +140,51 @@ class ServerJobsTest(unittest.TestCase):
"Upscaling image (2/3)",
)
def test_depth_plane_job_requests_z_depth_and_tangent_normal_together(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
input_path = root / "input.png"
input_path.touch()
context = FakeJobContext(root / "result", "generate-depth-plane-geometry")
z_depth = context.directory / "z-depth.exr"
metadata = context.directory / "depth.json"
tangent_normal = context.directory / "tangent-normal.png"
parameters = {"input": str(input_path)}
with (
patch.object(
self.depth_plane_job,
"input_frames",
return_value=nullcontext([input_path]),
),
patch.object(self.depth_plane_job, "copy_color_images"),
patch.object(
self.depth_plane_job,
"generate_moge2_artifacts",
return_value={
"z_depth": z_depth,
"depth_metadata": metadata,
"tangent_normal": tangent_normal,
},
) as generate,
):
result = self.depth_plane_job.run(context, parameters)
self.assertEqual(
result,
{
"z_depth": "z-depth.exr",
"depth_metadata": "depth.json",
"tangent_normal": "tangent-normal.png",
},
)
generate.assert_called_once_with(
context,
parameters,
input_path,
depth_mode="Z",
normal_mode="TANGENT",
)
def test_collect_images_is_sorted_and_filters_suffixes(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)

View File

@@ -691,6 +691,7 @@ class ServerRuntimeTest(unittest.TestCase):
normal_mode="OBJECT",
)
infer.assert_called_once()
self.assertEqual(
set(result),
{"vector_depth", "depth_metadata", "object_normal"},
@@ -749,7 +750,7 @@ class ServerRuntimeTest(unittest.TestCase):
normal_mode="OBJECT",
)
def test_generate_cutout_artifacts_refine_an_already_rasterized_selection(self):
def test_generate_cutout_artifacts_refines_the_bounds_image(self):
job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png"
input_path.write_bytes(b"selection")