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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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
232
tests/test_selection.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user