Add image selection refinement functionality with BEN2 integration

- Implemented viewport.py for handling 3D view projections, gestures, and overlay drawing.
- Created refine_image_selection.py to manage the refinement of gesture masks and output image bounds.
- Added selection module with refine_selection.py to process and crop images based on alpha channel visibility.
- Introduced __init__.py for selection module to expose refine_selection function.
This commit is contained in:
2026-08-31 22:10:05 +08:00
parent 14d63001d2
commit c97b4a3e41
76 changed files with 2127 additions and 1137 deletions

View File

@@ -84,7 +84,8 @@ class BlenderAddonTest(unittest.TestCase):
cls.anyimage = importlib.import_module("anyimage")
cls.runtime = importlib.import_module("anyimage.runtime")
cls.ai_setup = importlib.import_module("anyimage.operators.ai_setup")
cls.image_data = importlib.import_module("anyimage.common.image_data")
cls.ai = importlib.import_module("anyimage.common.ai")
cls.image_data = importlib.import_module("anyimage.common.image")
cls.debug_operators = importlib.import_module("anyimage.operators.debug")
cls.convert_to_plane = importlib.import_module(
"anyimage.operators.convert_to_plane.conversion"
@@ -96,15 +97,11 @@ class BlenderAddonTest(unittest.TestCase):
"anyimage.operators.remove_background"
)
cls.upscale = importlib.import_module("anyimage.operators.upscale")
cls.image_interaction = importlib.import_module(
"anyimage.common.image_interaction"
)
cls.image_interaction = importlib.import_module("anyimage.common.viewport")
cls.edit_mask = importlib.import_module(
"anyimage.operators.image_tool.edit_mask"
)
cls.image_region = importlib.import_module(
"anyimage.common.image_region"
)
cls.image_selection = importlib.import_module("anyimage.common.selection")
cls.image_box = importlib.import_module("anyimage.operators.image_tool.box")
cls.image_lasso = importlib.import_module("anyimage.operators.image_tool.lasso")
cls.image_line = importlib.import_module("anyimage.operators.image_tool.line")
@@ -218,8 +215,8 @@ class BlenderAddonTest(unittest.TestCase):
for operator, identifier in (
(self.anyimage.operators.GenerateDepthPlane, "anyimage.generate_depth_plane"),
(
self.anyimage.operators.CutoutRegionToShape,
"anyimage.cutout_region_to_shape",
self.anyimage.operators.CutoutSelectionToShape,
"anyimage.cutout_selection_to_shape",
),
):
self.assertEqual(operator.bl_idname, identifier)
@@ -837,7 +834,7 @@ class BlenderAddonTest(unittest.TestCase):
operator = self.convert_to_plane.ConvertToPlane
self.assertIn(operator, self.anyimage.CLASSES)
self.assertIn(self.convert_to_plane.ConvertToDepthPlane, self.anyimage.CLASSES)
self.assertIn(self.cutout_main.CutoutRegionToShape, self.anyimage.CLASSES)
self.assertIn(self.cutout_main.CutoutSelectionToShape, self.anyimage.CLASSES)
self.assertEqual(operator.bl_idname, "anyimage.convert_to_plane")
self.assertNotIn("shadeless", operator.__annotations__)
self.assertNotIn("keep_original", operator.__annotations__)
@@ -926,7 +923,7 @@ class BlenderAddonTest(unittest.TestCase):
context = SimpleNamespace(
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None))
)
region_mask = self.cutout_main.RegionMask(
selection_mask = self.cutout_main.SelectionMask(
np.ones((8, 8), dtype=np.float32), (1, 1, 9, 9)
)
source_rgba = np.ones((10, 10, 4), dtype=np.float32)
@@ -934,13 +931,13 @@ class BlenderAddonTest(unittest.TestCase):
with self.subTest(shape=shape):
operator = SimpleNamespace(
source_object_name="Source",
region_path_json=json.dumps(
selection_path_json=json.dumps(
{"points": [[1, 1], [9, 1], [9, 9], [1, 9]]}
),
shape=shape,
mesh_resolution=64,
generate_normal=False,
refine_region=False,
refine_selection=False,
report=lambda *_args: None,
_needs_generation=lambda: False,
)
@@ -958,8 +955,8 @@ class BlenderAddonTest(unittest.TestCase):
),
patch.object(
self.cutout_main,
"rasterize_region_path",
return_value=region_mask,
"rasterize_selection_path",
return_value=selection_mask,
),
patch.object(
self.cutout_main,
@@ -975,7 +972,7 @@ class BlenderAddonTest(unittest.TestCase):
self.cutout_main, "create_cutout_shape"
) as convert,
):
result = self.cutout_main.CutoutRegionToShape.execute(
result = self.cutout_main.CutoutSelectionToShape.execute(
operator, context
)
@@ -985,12 +982,12 @@ class BlenderAddonTest(unittest.TestCase):
source,
shape,
64,
region_mask,
selection_mask,
source_rgba=source_rgba,
)
def test_cutout_request_describes_ai_capabilities(self):
operator = self.cutout_main.CutoutRegionToShape()
operator = self.cutout_main.CutoutSelectionToShape()
operator.input_path = __file__
operator.source_object_name = "Source"
operator.shape = "DEPTH_SURFACE"
@@ -998,7 +995,7 @@ class BlenderAddonTest(unittest.TestCase):
operator.resolution_level = 5
operator.max_ai_input_size = 2048
operator.generate_normal = True
operator.refine_region = True
operator.refine_selection = True
context = SimpleNamespace(
scene=SimpleNamespace(anyimage_settings=SimpleNamespace())
)
@@ -1021,7 +1018,7 @@ class BlenderAddonTest(unittest.TestCase):
[call.args[0] for call in require_model.call_args_list],
[self.anyimage.properties.BEN2_MODEL_KEY, operator.model],
)
self.assertTrue(parameters["refine_region"])
self.assertTrue(parameters["refine_selection"])
self.assertEqual(parameters["depth_mode"], "VECTOR")
self.assertEqual(parameters["normal_mode"], "OBJECT")
self.assertNotIn("polygon", parameters)
@@ -1290,7 +1287,7 @@ class BlenderAddonTest(unittest.TestCase):
def test_cutout_contour_is_smooth_and_evenly_resampled(self):
import numpy as np
contour = self.cutout_geometry.smooth_region_contour(
contour = self.cutout_geometry.smooth_selection_contour(
((0, 0), (3, 0), (4, 0), (4, 2), (0, 2)),
0.25,
)
@@ -1309,7 +1306,7 @@ class BlenderAddonTest(unittest.TestCase):
staircase.append((value + 1.0, float(value)))
staircase.append((value + 1.0, value + 1.0))
staircase.append((0.0, 16.0))
smooth = self.cutout_geometry.smooth_region_contour(staircase, 2.0)
smooth = self.cutout_geometry.smooth_selection_contour(staircase, 2.0)
def diagonal_noise(points):
points = np.asarray(points)
@@ -1322,7 +1319,7 @@ class BlenderAddonTest(unittest.TestCase):
self.assertLess(diagonal_noise(smooth), 0.05)
def test_region_mask_keeps_nonzero_winding_overlap(self):
def test_selection_mask_keeps_nonzero_winding_overlap(self):
path = (
(1, 1),
(7, 1),
@@ -1337,13 +1334,13 @@ class BlenderAddonTest(unittest.TestCase):
(1, 1),
)
even_odd = self.image_region.rasterize_region_path(
even_odd = self.image_selection.rasterize_selection_path(
(12, 9),
self.image_region.RegionPath(points=path, fill_rule="EVEN_ODD"),
self.image_selection.SelectionPath(points=path, fill_rule="EVEN_ODD"),
).full_values((12, 9))
nonzero = self.image_region.rasterize_region_path(
nonzero = self.image_selection.rasterize_selection_path(
(12, 9),
self.image_region.RegionPath(points=path, fill_rule="NONZERO"),
self.image_selection.SelectionPath(points=path, fill_rule="NONZERO"),
).full_values((12, 9))
self.assertFalse(even_odd[3, 5])
@@ -1397,7 +1394,7 @@ class BlenderAddonTest(unittest.TestCase):
def test_image_edit_tools_and_operators_are_registered(self):
operator = self.edit_mask.EditImageMask
self.assertIn(operator, self.anyimage.CLASSES)
self.assertIn(self.edit_mask.RefineImageRegion, self.anyimage.CLASSES)
self.assertIn(self.edit_mask.RefineImageSelection, self.anyimage.CLASSES)
self.assertIn(self.edit_mask.ActivateImageTool, self.anyimage.CLASSES)
tool_cases = (
(self.image_box.ImageBoxTool, "BOX", "ops.sculpt.border_mask"),
@@ -1422,12 +1419,12 @@ class BlenderAddonTest(unittest.TestCase):
layout = SimpleNamespace(
prop=lambda _settings, name: drawn_settings.append(name)
)
with patch.object(self.image_box, "draw_region_settings") as draw:
with patch.object(self.image_box, "draw_selection_settings") as draw:
self.image_box.ImageBoxTool.draw_settings(context, layout, None)
draw.assert_called_once_with(context, layout, "image_box_refine_region")
with patch.object(self.image_lasso, "draw_region_settings") as draw:
draw.assert_called_once_with(context, layout, "image_box_refine_selection")
with patch.object(self.image_lasso, "draw_selection_settings") as draw:
self.image_lasso.ImageLassoTool.draw_settings(context, layout, None)
draw.assert_called_once_with(context, layout, "image_lasso_refine_region")
draw.assert_called_once_with(context, layout, "image_lasso_refine_selection")
cutout_tool = importlib.import_module(
"anyimage.operators.cutout_tool"
).CutoutTool
@@ -1453,9 +1450,9 @@ class BlenderAddonTest(unittest.TestCase):
self.assertEqual(
drawn_settings,
[
"cutout_refine_region",
"cutout_point_spacing",
"cutout_refine_selection",
"cutout_generate_normal",
"cutout_mesh_detail",
],
)
self.assertNotIn("draw_settings", self.image_line.ImageLineTool.__dict__)
@@ -1468,24 +1465,33 @@ class BlenderAddonTest(unittest.TestCase):
self.assertNotIn("padding", operator.__annotations__)
settings = self.anyimage.properties.AnyImageSettings.__annotations__
for name in (
"cutout_refine_region",
"image_box_refine_region",
"image_lasso_refine_region",
"cutout_refine_selection",
"image_box_refine_selection",
"image_lasso_refine_selection",
):
self.assertFalse(settings[name]["default"])
self.assertNotIn("refine_region", settings)
self.assertNotIn("image_edit_refine_region", settings)
self.assertEqual(settings["cutout_point_spacing"]["default"], 32)
self.assertEqual(settings["cutout_point_spacing"]["min"], 4)
self.assertEqual(settings["cutout_point_spacing"]["max"], 32)
self.assertFalse(settings["cutout_generate_normal"]["default"])
self.assertEqual(settings["cutout_refine_region"]["name"], "Fit to Foreground")
self.assertNotIn("refine_selection", settings)
self.assertNotIn("image_edit_refine_selection", settings)
self.assertEqual(settings["cutout_mesh_detail"]["default"], "LOW")
self.assertEqual(
settings["cutout_point_spacing"]["name"], "Mesh Sampling Spacing"
[item[:2] for item in settings["cutout_mesh_detail"]["items"]],
[
("LOW", "Low"),
("MEDIUM", "Medium"),
("HIGH", "High"),
("ULTRA", "Ultra"),
],
)
self.assertFalse(settings["cutout_generate_normal"]["default"])
self.assertEqual(
settings["cutout_refine_selection"]["name"], "Refine Selection"
)
self.assertEqual(
settings["cutout_mesh_detail"]["name"], "Mesh Detail"
)
self.assertEqual(
settings["cutout_generate_normal"]["name"],
"Generate Normal Map",
"Normal Map",
)
self.assertNotIn("image_extract_continuous", settings)
self.assertNotIn("image_extract_padding", settings)
@@ -1522,10 +1528,10 @@ class BlenderAddonTest(unittest.TestCase):
"ready": False,
}
setup = ("operator", "anyimage.setup_ai_environment", True)
with patch.object(self.image_interaction, "ai_status", return_value=missing):
with patch.object(self.ai, "ai_status", return_value=missing):
for tool, setting_name in (
(self.image_box.ImageBoxTool, "image_box_refine_region"),
(self.image_lasso.ImageLassoTool, "image_lasso_refine_region"),
(self.image_box.ImageBoxTool, "image_box_refine_selection"),
(self.image_lasso.ImageLassoTool, "image_lasso_refine_selection"),
):
layout = Layout()
tool.draw_settings(context, layout, None)
@@ -1543,71 +1549,71 @@ class BlenderAddonTest(unittest.TestCase):
layout.events,
[
setup,
("prop", "cutout_refine_region", False),
("prop", "cutout_point_spacing", True),
("prop", "cutout_refine_selection", False),
("prop", "cutout_generate_normal", False),
("prop", "cutout_mesh_detail", True),
],
)
layout = Layout()
operator = SimpleNamespace(gesture="BOX")
self.image_interaction.draw_region_operator_settings(layout, operator)
self.image_interaction.draw_selection_operator_settings(layout, operator)
self.assertEqual(
layout.events,
[
setup,
("prop", "refine_region", False),
("prop", "refine_selection", False),
("prop", "invert", True),
],
)
ready = {"environment_ready": True, "missing_models": (), "ready": True}
with patch.object(self.image_interaction, "ai_status", return_value=ready):
with patch.object(self.ai, "ai_status", return_value=ready):
layout = Layout()
cutout_tool.draw_settings(context, layout, None)
self.assertNotIn(setup, layout.events)
self.assertTrue(all(event[2] for event in layout.events))
def test_refine_region_is_independent_for_each_area_tool(self):
def test_refine_selection_is_independent_for_each_area_tool(self):
settings = SimpleNamespace(
cutout_refine_region=True,
image_box_refine_region=False,
image_lasso_refine_region=True,
cutout_refine_selection=True,
image_box_refine_selection=False,
image_lasso_refine_selection=True,
)
context = SimpleNamespace(scene=SimpleNamespace(anyimage_settings=settings))
with patch.object(self.image_interaction, "ai_ready", return_value=True):
for gesture in ("LINE", "POLYLINE"):
operator = SimpleNamespace(
gesture=gesture,
refine_region=True,
refine_selection=True,
)
self.image_interaction.apply_region_settings(operator, context)
self.assertFalse(operator.refine_region)
self.image_interaction.apply_selection_settings(operator, context)
self.assertFalse(operator.refine_selection)
box = SimpleNamespace(
gesture="BOX",
refine_region=True,
refine_region_settings={"BOX": "image_box_refine_region"},
refine_selection=True,
refine_selection_settings={"BOX": "image_box_refine_selection"},
)
self.image_interaction.apply_region_settings(box, context)
self.assertFalse(box.refine_region)
self.image_interaction.apply_selection_settings(box, context)
self.assertFalse(box.refine_selection)
lasso = SimpleNamespace(
gesture="LASSO",
refine_region=False,
refine_region_settings={"LASSO": "image_lasso_refine_region"},
refine_selection=False,
refine_selection_settings={"LASSO": "image_lasso_refine_selection"},
)
self.image_interaction.apply_region_settings(lasso, context)
self.assertTrue(lasso.refine_region)
self.image_interaction.apply_selection_settings(lasso, context)
self.assertTrue(lasso.refine_selection)
cutout = SimpleNamespace(
gesture="LASSO",
active_tool_id="anyimage.cutout_lasso",
refine_region=False,
refine_region_settings={"LASSO": "cutout_refine_region"},
refine_selection=False,
refine_selection_settings={"LASSO": "cutout_refine_selection"},
)
self.image_interaction.apply_region_settings(cutout, context)
self.assertTrue(cutout.refine_region)
self.image_interaction.apply_selection_settings(cutout, context)
self.assertTrue(cutout.refine_selection)
def test_image_edit_first_click_selects_another_image_without_editing(self):
selected = []
@@ -1981,7 +1987,7 @@ class BlenderAddonTest(unittest.TestCase):
self.assertNotIn("image_perspective_aspect_ratio", settings)
self.assertNotIn("aspect_mode", operator.__annotations__)
self.assertIn("aspect_ratio", operator.__annotations__)
self.assertNotIn("refine_region", operator.__annotations__)
self.assertNotIn("refine_selection", operator.__annotations__)
self.assertNotIn("continuous", operator.__annotations__)
self.assertNotIn("padding", operator.__annotations__)
@@ -1995,14 +2001,14 @@ class BlenderAddonTest(unittest.TestCase):
(),
)
def test_image_box_completion_serializes_the_image_region(self):
def test_image_box_completion_serializes_the_image_selection(self):
source = SimpleNamespace(data=SimpleNamespace(size=(64, 32)))
finished = []
operator = SimpleNamespace(
source_object_name="Source",
source_matrix_data="matrix",
gesture="BOX",
region_path_json="",
selection_path_json="",
invert=False,
preview_fill_rule="EVEN_ODD",
_screen_path=lambda: ((1, 1), (8, 1), (8, 6), (1, 6)),
@@ -2024,16 +2030,16 @@ class BlenderAddonTest(unittest.TestCase):
return_value=polygon,
),
):
result = self.image_interaction.ImageRegionGesture._complete(operator, context)
result = self.image_interaction.ImageSelectionGesture._complete(operator, context)
self.assertEqual(result, {"FINISHED"})
self.assertEqual(
json.loads(operator.region_path_json)["points"],
json.loads(operator.selection_path_json)["points"],
[[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
)
self.assertEqual(finished, [context])
def test_image_region_uses_white_dashed_segments(self):
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
)
@@ -2073,8 +2079,8 @@ class BlenderAddonTest(unittest.TestCase):
patch.object(self.image_interaction, "draw_polygon_fill") as draw_fill,
patch.object(self.image_interaction, "draw_dashed_line") as draw_outline,
):
self.image_interaction.ImageRegionGesture._draw_region(operator)
self.image_interaction.ImageRegionGesture._draw_region(operator)
self.image_interaction.ImageSelectionGesture._draw_selection(operator)
self.image_interaction.ImageSelectionGesture._draw_selection(operator)
build_fill.assert_called_once_with(polygon, "NONZERO")
build_outline.assert_called_once_with((*polygon, polygon[0]))
@@ -2131,11 +2137,11 @@ class BlenderAddonTest(unittest.TestCase):
((0, 5), (10, 5)), (10, 10)
)
self.assertEqual(
self.image_region.region_path_bounds(preview),
self.image_selection.selection_path_bounds(preview),
(0, 5, 10, 10),
)
self.assertEqual(
self.image_region.region_path_bounds(image),
self.image_selection.selection_path_bounds(image),
(0, 0, 10, 5),
)

View File

@@ -453,6 +453,28 @@ class ClipboardImageTest(unittest.TestCase):
self.assertIs(result, expected_reference)
def test_reference_image_uses_drag_and_drop_size(self):
reference = SimpleNamespace(select_set=Mock())
objects = SimpleNamespace(new=Mock(return_value=reference))
context = SimpleNamespace(
mode="OBJECT",
collection=SimpleNamespace(objects=SimpleNamespace(link=Mock())),
selected_objects=(),
view_layer=SimpleNamespace(objects=SimpleNamespace(active=None)),
region_data=None,
)
image = SimpleNamespace(name="Clipboard", size=(1920, 1080))
with patch.object(self.actions.bpy.data, "objects", objects, create=True):
result = self.actions.add_reference_image(
context,
image,
location=(1.0, 2.0, 3.0),
)
self.assertIs(result, reference)
self.assertEqual(reference.empty_display_size, 5.0)
def test_only_import_as_and_shadeless_are_drawn_for_plane_paste(self):
operator = self.addon.clipboard_image.PasteClipboardImage()
drawn_properties = []

View File

@@ -10,12 +10,12 @@ import numpy as np
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
from anyimage.common.image_region import (
RegionMask,
RegionPath,
rasterize_region_path,
from anyimage.common.selection import (
SelectionMask,
SelectionPath,
rasterize_selection_path,
)
from anyimage.common.depth_data import (
from anyimage.common.depth import (
depth_uniform_scale,
load_depth_metadata,
)
@@ -25,11 +25,13 @@ from anyimage.operators.cutout_tool import geometry as cutout_geometry
from anyimage.operators.cutout_tool import object as cutout_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from anyimage.operators.cutout_tool.geometry import (
RegionContours,
extract_region_contours,
SelectionContours,
extract_selection_contours,
poisson_balloon_profile,
)
from anyimage.server.models import moge2 as moge2_model
from anyimage.server.models import da3 as da3_model
from anyimage.server.models.geometry import GeometryFrame
def test_cutout_mesh_detail_uses_the_cropped_analysis_scale():
@@ -193,53 +195,102 @@ def test_depth_surface_projection_uses_one_scale_for_all_camera_axes():
assert np.isclose(reference_depth, 5.8, atol=1e-6)
def test_moge_surface_field_uses_the_model_validity_mask():
prediction = moge2_model.Prediction(
depth=np.ones((1, 2, 2), dtype=np.float32),
normal=np.zeros((1, 2, 2, 3), dtype=np.float32),
mask=np.asarray((((True, False), (True, True)),)),
intrinsics=np.asarray(
(((1.0, 0.0, 0.5), (0.0, 1.0, 0.5), (0.0, 0.0, 1.0)),),
def test_geometry_frame_requires_matching_float32_fields():
with np.testing.assert_raises_regex(ValueError, "validity"):
GeometryFrame(
depth=np.ones((2, 2), dtype=np.float32),
validity=np.ones((1, 2), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
)
with np.testing.assert_raises_regex(ValueError, "normal"):
GeometryFrame(
depth=np.ones((2, 2), dtype=np.float32),
validity=np.ones((2, 2), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
normal=np.ones((2, 2, 2), dtype=np.float32),
)
def test_moge_infer_returns_geometry_frames_with_model_validity():
mask = np.asarray(((True, False), (True, True)))
prediction = {
"depth": np.ones((2, 2), dtype=np.float32),
"normal": np.zeros((2, 2, 3), dtype=np.float32),
"mask": mask,
"intrinsics": np.asarray(
((1.0, 0.0, 0.5), (0.0, 1.0, 0.5), (0.0, 0.0, 1.0)),
dtype=np.float32,
),
points=np.ones((1, 2, 2, 3), dtype=np.float32),
"points": np.ones((2, 2, 3), dtype=np.float32),
}
with patch.object(moge2_model, "infer_one", return_value=prediction):
frames = moge2_model.infer(None, {}, ["image"], include_points=True)
assert len(frames) == 1
frame = frames[0]
assert np.array_equal(frame.validity, mask.astype(np.float32))
assert np.array_equal(frame.normal, prediction["normal"])
assert np.array_equal(frame.points, prediction["points"])
assert np.array_equal(
frame.intrinsics,
np.asarray(((2.0, 0.0, 1.0), (0.0, 2.0, 1.0), (0.0, 0.0, 1.0))),
)
frame = moge2_model.geometry_frame(prediction)
assert np.array_equal(frame.valid_mask, prediction.mask[0])
def test_moge_z_depth_frame_does_not_require_points():
prediction = moge2_model.Prediction(
depth=np.ones((1, 2, 2), dtype=np.float32),
normal=np.zeros((1, 2, 2, 3), dtype=np.float32),
mask=np.ones((1, 2, 2), dtype=bool),
intrinsics=np.eye(3, dtype=np.float32)[None],
points=None,
)
prediction = {
"depth": np.ones((2, 2), dtype=np.float32),
"normal": np.zeros((2, 2, 3), dtype=np.float32),
"mask": np.ones((2, 2), dtype=bool),
"intrinsics": np.eye(3, dtype=np.float32),
"points": np.ones((2, 2, 3), dtype=np.float32),
}
frame = moge2_model.geometry_frame(prediction)
with patch.object(moge2_model, "infer_one", return_value=prediction):
frame = moge2_model.infer(None, {}, ["image"], include_points=False)[0]
assert frame.points is None
assert frame.valid_mask.all()
assert frame.validity.all()
def test_region_contours_group_holes_with_their_connected_component():
def test_da3_geometry_frames_use_confidence_as_validity_and_derive_points():
prediction = SimpleNamespace(
depth=np.ones((2, 2, 3), dtype=np.float32),
conf=np.asarray(
(
((0.25, 0.5, 0.75), (1.0, 0.5, 0.25)),
((0.1, 0.2, 0.3), (0.4, 0.5, 0.6)),
),
dtype=np.float32,
),
intrinsics=np.repeat(np.eye(3, dtype=np.float32)[None], 2, axis=0),
)
frames = da3_model.geometry_frames(prediction)
assert len(frames) == 2
assert frames[0].normal is None
assert np.array_equal(frames[1].validity, prediction.conf[1])
assert frames[0].points.shape == (2, 3, 3)
assert frames[0].points.dtype == np.float32
def test_selection_contours_group_holes_with_their_connected_component():
alpha = np.zeros((120, 200), dtype=np.uint8)
alpha[20:100, 50:160] = 255
alpha[45:75, 80:120] = 0
alpha[5:15, 5:15] = 255
region_contours = extract_region_contours(
RegionMask(alpha, (0, 0, 200, 120)),
selection_contours = extract_selection_contours(
SelectionMask(alpha, (0, 0, 200, 120)),
max_resolution=200,
)
assert region_contours.image_size == (200, 120)
assert sorted(len(component) for component in region_contours.components) == [1, 2]
assert selection_contours.image_size == (200, 120)
assert sorted(len(component) for component in selection_contours.components) == [1, 2]
contours = [
contour for component in region_contours.components for contour in component
contour for component in selection_contours.components for contour in component
]
bounds = [
(*np.min(contour, axis=0), *np.max(contour, axis=0)) for contour in contours
@@ -249,19 +300,19 @@ def test_region_contours_group_holes_with_their_connected_component():
assert (5.0, 5.0, 15.0, 15.0) in bounds
def test_region_contours_accept_blender_float_mask_with_a_hole():
def test_selection_contours_accept_blender_float_mask_with_a_hole():
alpha = np.zeros((120, 200), dtype=np.float32)
alpha[20:100, 50:160] = 1.0
alpha[45:75, 80:120] = 0.0
region_contours = extract_region_contours(
RegionMask(alpha, (0, 0, 200, 120)),
selection_contours = extract_selection_contours(
SelectionMask(alpha, (0, 0, 200, 120)),
max_resolution=200,
)
assert region_contours.image_size == (200, 120)
assert len(region_contours.components) == 1
assert len(region_contours.components[0]) == 2
assert selection_contours.image_size == (200, 120)
assert len(selection_contours.components) == 1
assert len(selection_contours.components[0]) == 2
def test_cutout_crop_bounds_map_to_the_source_plane():
@@ -285,14 +336,14 @@ def test_depth_surface_cutout_uses_half_alpha_boundary():
alpha[2:10, 2:14] = 0.49
alpha[4:8, 5:11] = 0.51
region_contours = cutout_geometry.extract_cutout_contours(
RegionMask(alpha, (0, 0, 16, 12)),
selection_contours = cutout_geometry.extract_cutout_contours(
SelectionMask(alpha, (0, 0, 16, 12)),
64,
threshold=0.5,
)
contour = np.asarray(region_contours.components[0][0])
contour = np.asarray(selection_contours.components[0][0])
assert region_contours.image_size == (16, 12)
assert selection_contours.image_size == (16, 12)
np.testing.assert_allclose(contour.min(axis=0), (5.0, 4.0))
np.testing.assert_allclose(contour.max(axis=0), (11.0, 8.0))
@@ -306,37 +357,37 @@ def test_blender_cutout_alpha_pipeline_does_not_import_pillow():
return original_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=without_pillow):
region_mask = rasterize_region_path(
selection_mask = rasterize_selection_path(
(48, 32),
RegionPath(points=((6, 4), (42, 4), (42, 28), (6, 28))),
SelectionPath(points=((6, 4), (42, 4), (42, 28), (6, 28))),
antialias=True,
)
antialiased = region_mask.full_values((48, 32))
region_contours = extract_region_contours(
RegionMask(antialiased, (0, 0, 48, 32)),
antialiased = selection_mask.full_values((48, 32))
selection_contours = extract_selection_contours(
SelectionMask(antialiased, (0, 0, 48, 32)),
max_resolution=24,
)
assert region_contours.image_size == (48, 32)
assert region_contours.components
assert selection_contours.image_size == (48, 32)
assert selection_contours.components
assert np.any((antialiased > 0.0) & (antialiased < 1.0))
def test_blender_cutout_contour_resolution_tracks_mesh_detail():
expected = RegionContours(
expected = SelectionContours(
components=(((((0, 0), (1, 0), (0, 1))),),),
image_size=(1, 1),
)
region_mask = RegionMask(np.ones((2, 2), dtype=np.float32), (0, 0, 2, 2))
with patch.object(cutout_geometry, "extract_region_contours", return_value=expected) as trace:
assert cutout_geometry.extract_cutout_contours(region_mask, 32) == expected
assert cutout_geometry.extract_cutout_contours(region_mask, 256) == expected
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 trace.call_args_list[0].kwargs["max_resolution"] == 512
assert trace.call_args_list[1].kwargs["max_resolution"] == 1024
def test_cutout_point_spacing_uses_the_complete_source_plane():
def test_cutout_sampling_spacing_uses_the_complete_source_plane():
source = SimpleNamespace(matrix_world=object())
with (
patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
@@ -346,7 +397,7 @@ def test_cutout_point_spacing_uses_the_complete_source_plane():
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
spacing = cutout_geometry.cutout_point_spacing(source, 200)
spacing = cutout_geometry.cutout_sampling_spacing(source, 200)
assert spacing == 0.02
@@ -401,7 +452,7 @@ def test_poisson_balloon_profile_scales_in_world_units():
assert np.isclose(scaled[4], original[4] * 2.0, atol=1e-6)
def test_poisson_balloon_profile_makes_wide_regions_thicker():
def test_poisson_balloon_profile_makes_wide_selections_thicker():
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
boundary = np.asarray((True, True, True, True, False))
wide = np.asarray(
@@ -445,11 +496,11 @@ def test_teddy_balloon_profile_uses_chordal_axis_radius():
assert np.isclose(profile[4], 0.45, atol=1e-6)
def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_region():
regions = ((((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),)
def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_selection():
components = ((((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),)
domain = cutout_geometry.cutout_domain(
RegionContours(regions, (4096, 4096)),
SelectionContours(components, (4096, 4096)),
(-1.0, 1.0, -1.0, 1.0),
0.5,
((1.0, 0.0), (0.0, 1.0)),
@@ -460,10 +511,10 @@ def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_region():
def test_cutout_domain_builds_a_full_image_at_minimum_detail():
regions = ((((0, 0), (4096, 0), (4096, 4096), (0, 4096)),),)
components = ((((0, 0), (4096, 0), (4096, 4096), (0, 4096)),),)
domain = cutout_geometry.cutout_domain(
RegionContours(regions, (4096, 4096)),
SelectionContours(components, (4096, 4096)),
(-1.0, 1.0, -1.0, 1.0),
0.5,
((1.0, 0.0), (0.0, 1.0)),
@@ -474,10 +525,10 @@ def test_cutout_domain_builds_a_full_image_at_minimum_detail():
def test_cutout_domain_centers_vertices_on_crop_bounds():
regions = ((((0, 0), (100, 0), (100, 50), (0, 50)),),)
components = ((((0, 0), (100, 0), (100, 50), (0, 50)),),)
vertices, _faces, uv, _boundary, _planar, _profile = cutout_geometry.cutout_domain(
RegionContours(regions, (100, 50)),
SelectionContours(components, (100, 50)),
(2.0, 6.0, -3.0, 1.0),
0.5,
((1.0, 0.0), (0.0, 1.0)),
@@ -489,8 +540,8 @@ def test_cutout_domain_centers_vertices_on_crop_bounds():
assert np.allclose(uv, expected_uv, atol=1e-6)
def test_cutout_domain_keeps_overlapping_regions_disconnected():
regions = (
def test_cutout_domain_keeps_overlapping_selection_components_disconnected():
components = (
(
((20, 0), (100, 0), (100, 80), (20, 80)),
((50, 20), (50, 60), (70, 60), (70, 20)),
@@ -498,7 +549,7 @@ def test_cutout_domain_keeps_overlapping_regions_disconnected():
(((0, 20), (40, 20), (40, 50), (0, 50)),),
)
domain = cutout_geometry.cutout_domain(
RegionContours(regions, (120, 80)),
SelectionContours(components, (120, 80)),
(0.0, 1.0, 0.0, 1.0),
0.025,
((1.0, 0.0), (0.0, 1.0)),
@@ -511,7 +562,7 @@ def test_cutout_domain_keeps_overlapping_regions_disconnected():
& (centers[:, 1] > 0.35)
& (centers[:, 1] < 0.65)
)
in_left_region = (
in_left_component = (
(centers[:, 0] > 0.02)
& (centers[:, 0] < 0.12)
& (centers[:, 1] > 0.4)
@@ -519,17 +570,17 @@ def test_cutout_domain_keeps_overlapping_regions_disconnected():
)
assert not in_hole.any()
assert in_left_region.any()
assert in_left_component.any()
assert mesh_component_count(domain[1]) == 2
def test_region_calibration_uses_the_final_region_mask():
region_mask = RegionMask(
def test_selection_calibration_uses_the_final_selection_mask():
selection_mask = SelectionMask(
np.asarray(((1.0, 1.0), (1.0, 0.0)), dtype=np.float32),
(0, 0, 2, 2),
)
valid, transparent_fraction = cutout_geometry.region_calibration(region_mask, (2, 2))
valid, transparent_fraction = cutout_geometry.selection_calibration(selection_mask, (2, 2))
assert np.array_equal(valid, ((True, True), (True, False)))
assert transparent_fraction == 0.25

View File

@@ -7,8 +7,8 @@ import bpy
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
from anyimage.common.image_interaction import ImageRegionGesture
from anyimage.common.image_region import RegionPath
from anyimage.common.selection import SelectionPath
from anyimage.common.viewport import ImageSelectionGesture
from anyimage.operators.cutout_tool import interaction
from anyimage.operators.cutout_tool import main as cutout_main
@@ -32,9 +32,9 @@ class WindowManager:
draw(menu, None)
def cutout_context(window_manager, point_spacing=32, generate_normal=False):
def cutout_context(window_manager, mesh_detail="LOW", generate_normal=False):
settings = SimpleNamespace(
cutout_point_spacing=point_spacing,
cutout_mesh_detail=mesh_detail,
cutout_generate_normal=generate_normal,
)
return SimpleNamespace(
@@ -57,8 +57,8 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
context,
object(),
source,
RegionPath(points=((0, 0), (10, 0), (0, 10))),
refine_region=True,
SelectionPath(points=((0, 0), (10, 0), (0, 10))),
refine_selection=True,
)
assert [call[2].shape for call in window_manager.pie.calls] == [
@@ -70,17 +70,17 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
"Balloon",
]
assert all(
call[0] == "anyimage.cutout_region_to_shape"
call[0] == "anyimage.cutout_selection_to_shape"
for call in window_manager.pie.calls
)
assert not any(call[2].refine_region for call in window_manager.pie.calls)
assert not any(call[2].refine_selection for call in window_manager.pie.calls)
assert not any(call[2].generate_normal for call in window_manager.pie.calls)
assert all(call[2].mesh_resolution == 64 for call in window_manager.pie.calls)
def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
window_manager = WindowManager()
context = cutout_context(window_manager, point_spacing=16, generate_normal=True)
context = cutout_context(window_manager, mesh_detail="MEDIUM", generate_normal=True)
source = cutout_source(size=(4096, 2048))
monkeypatch.setattr(interaction, "ai_ready", lambda: True)
@@ -88,11 +88,11 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
context,
object(),
source,
RegionPath(
SelectionPath(
points=((0, 0), (10, 0), (0, 10)),
invert=True,
),
refine_region=True,
refine_selection=True,
)
assert [call[2].shape for call in window_manager.pie.calls] == [
@@ -101,10 +101,10 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
"DEPTH_BALLOON",
"DEPTH_SURFACE",
]
assert all(call[2].refine_region for call in window_manager.pie.calls)
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 all(
RegionPath.from_json(call[2].region_path_json).invert
SelectionPath.from_json(call[2].selection_path_json).invert
for call in window_manager.pie.calls
)
assert all(call[2].mesh_resolution == 256 for call in window_manager.pie.calls)
@@ -124,6 +124,13 @@ def test_cutout_mesh_detail_tracks_the_source_long_edge_and_clamps():
assert interaction.cutout_mesh_resolution((65536, 32768), 4) == 1024
def test_cutout_mesh_detail_maps_to_denser_sampling():
assert [
interaction.cutout_mesh_detail_spacing(detail)
for detail in ("LOW", "MEDIUM", "HIGH", "ULTRA")
] == [32, 16, 8, 4]
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)
@@ -138,23 +145,23 @@ 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, "apply_region_settings", lambda *_args: None)
monkeypatch.setattr(cutout_main, "validate_cutout_region", lambda *_args: None)
monkeypatch.setattr(cutout_main, "apply_selection_settings", lambda *_args: None)
monkeypatch.setattr(cutout_main, "validate_cutout_selection", lambda *_args: None)
operator = SimpleNamespace(
full_image=True,
refine_region=False,
refine_selection=False,
report=lambda *_args: None,
)
operator.execute = MethodType(cutout_main.SelectCutoutRegion.execute, operator)
result = cutout_main.SelectCutoutRegion.invoke(
operator.execute = MethodType(cutout_main.SelectCutoutSelection.execute, operator)
result = cutout_main.SelectCutoutSelection.invoke(
operator, context, SimpleNamespace(value="DOUBLE_CLICK")
)
assert issubclass(cutout_main.SelectCutoutRegion, ImageRegionGesture)
assert issubclass(cutout_main.SelectCutoutSelection, ImageSelectionGesture)
assert result == {"FINISHED"}
region_path = RegionPath.from_json(window_manager.pie.calls[0][2].region_path_json)
assert region_path.points == ((0, 0), (320, 0), (320, 180), (0, 180))
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 not hasattr(cutout_main, "pending_selection")

View File

@@ -10,21 +10,26 @@ from mathutils import Vector
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
from anyimage.common import image_data
from anyimage.common import depth_data
from anyimage.common import depth as depth_data
from anyimage.common import image as image_data
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.image_region import (
RegionMask,
RegionPath,
rasterize_region_path,
from anyimage.common.selection import (
SelectionMask,
SelectionPath,
rasterize_selection_path,
)
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
from anyimage.operators.cutout_tool import object as cutout_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from anyimage.server.geometry.depth_texture import write_vector_depth_texture
from anyimage.server.geometry.depth_texture import (
write_vector_depth_texture,
write_z_depth_texture,
)
from anyimage.server.models.geometry import GeometryFrame
def _inputs(group):
@@ -51,26 +56,26 @@ def _create_cutout_shape(
bounds=None,
depth_image=None,
depth_metadata=None,
region_values=None,
selection_values=None,
):
if bounds is None:
bounds = (0, 0, *tuple(int(value) for value in source.data.size))
left, top, right, bottom = bounds
if region_values is None:
region_values = np.ones((bottom - top, right - left), dtype=np.float32)
region_mask = RegionMask(region_values, bounds)
if selection_values is None:
selection_values = np.ones((bottom - top, right - left), dtype=np.float32)
selection_mask = SelectionMask(selection_values, bounds)
base_shape = cutout_geometry.build_base_shape(
bpy.context,
source,
detail,
region_mask,
selection_mask,
)
return cutout_object.create_shape_object(
bpy.context,
source,
shape,
detail,
region_mask,
selection_mask,
base_shape,
depth_image=depth_image,
depth_metadata=depth_metadata,
@@ -82,7 +87,7 @@ def _depth_image(points, valid, name="Depth"):
valid = np.asarray(valid, dtype=bool)
height, width = valid.shape
pixels = np.zeros((height, width, 4), dtype=np.float32)
pixels[valid, :3] = points[valid]
pixels[..., :3] = points
pixels[..., 3] = valid
image = bpy.data.images.new(
name,
@@ -112,9 +117,15 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path
source = bpy.data.objects.new("Source", None)
source.data = source_image
points = np.arange(12, dtype=np.float32).reshape((2, 2, 3))
valid = np.asarray(((True, False), (True, True)))
validity = np.asarray(((1.0, 0.0), (0.5, 1.0)), dtype=np.float32)
frame = GeometryFrame(
depth=points[..., 2].copy(),
validity=validity,
intrinsics=np.eye(3, dtype=np.float32),
points=points,
)
path = tmp_path / "vector-depth.exr"
write_vector_depth_texture(points, valid, path)
write_vector_depth_texture(frame, path)
metadata = _depth_metadata(np.eye(3), (2, 2), 1.0)
image = depth_data.load_depth_result_image(path, source, metadata)
@@ -122,12 +133,38 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path
packed = np.empty(16, dtype=np.float32)
image.pixels.foreach_get(packed)
packed = np.flipud(packed.reshape((2, 2, 4)))
assert np.array_equal(packed[valid, :3], points[valid])
assert np.array_equal(packed[..., 3] > 0.5, valid)
assert np.array_equal(packed[..., :3], points)
assert np.array_equal(packed[..., 3], validity)
assert image.alpha_mode == "CHANNEL_PACKED"
assert image.name == "Source_depth.exr"
def test_z_depth_texture_keeps_depth_outside_positive_validity(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Source.png", width=2, height=1)
source = bpy.data.objects.new("Source", None)
source.data = source_image
frame = GeometryFrame(
depth=np.asarray(((2.0, 7.0),), dtype=np.float32),
validity=np.asarray(((1.0, 0.0),), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
)
path = tmp_path / "z-depth.exr"
write_z_depth_texture(frame, path)
image = depth_data.load_depth_result_image(
path,
source,
_depth_metadata(np.eye(3), (2, 1), 2.0),
)
packed = np.empty(8, dtype=np.float32)
image.pixels.foreach_get(packed)
packed = packed.reshape((1, 2, 4))
assert np.array_equal(packed[..., :3], np.repeat(frame.depth[..., None], 3, axis=2))
assert np.array_equal(packed[..., 3], frame.validity)
def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -370,8 +407,8 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
def test_image_layer_switches_between_tangent_and_object_space_normal():
bpy.ops.wm.read_factory_settings(use_empty=True)
assert object_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
group = object_data.material_node_group()
assert material_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
group = material_data.material_node_group()
normal = next(
item
for item in group.interface.items_tree
@@ -416,7 +453,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal():
color = bpy.data.images.new("Color", width=2, height=2)
normal_image = bpy.data.images.new("Normal", width=2, height=2)
object_material = object_data.create_image_material(
object_material = material_data.create_image_material(
SimpleNamespace(data=SimpleNamespace(name="Object Normal")),
color,
1.0,
@@ -426,7 +463,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal():
layer = _group_node(group, object_material.node_tree)
assert layer.inputs["Object Space"].default_value is True
tangent_material = object_data.create_image_material(
tangent_material = material_data.create_image_material(
SimpleNamespace(data=SimpleNamespace(name="Tangent Normal")),
color,
1.0,
@@ -434,7 +471,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal():
)
layer = _group_node(group, tangent_material.node_tree)
assert layer.inputs["Object Space"].default_value is False
reused = object_data.material_node_group()
reused = material_data.material_node_group()
assert {
node.space for node in reused.nodes if node.bl_idname == "ShaderNodeNormalMap"
} == {"TANGENT", "OBJECT"}
@@ -503,7 +540,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
)
assert normal_scale_attribute.attribute_type == "GEOMETRY"
assert normal_scale_attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME
image_layer = _group_node(object_data.material_node_group(), material_tree)
image_layer = _group_node(material_data.material_node_group(), material_tree)
normal_scale_link = next(
link
for link in material_tree.links
@@ -572,7 +609,7 @@ def test_cutout_material_uses_flat_tangent_normal():
color = bpy.data.images.new("Color", width=2, height=2)
normal = bpy.data.images.new("Normal", width=1, height=1)
result = object_data.create_image_material(
result = material_data.create_image_material(
source,
color,
0.0,
@@ -580,7 +617,7 @@ def test_cutout_material_uses_flat_tangent_normal():
scene=bpy.context.scene,
)
layer = _group_node(object_data.material_node_group(), result.node_tree)
layer = _group_node(material_data.material_node_group(), result.node_tree)
assert layer.inputs["Object Space"].default_value is False
@@ -624,7 +661,7 @@ def test_depth_surface_protected_smooth_evaluates():
== cutout_shape.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"]
)
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_point_spacing(source, 8),
cutout_geometry.cutout_sampling_spacing(source, 8),
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
@@ -703,6 +740,44 @@ def test_depth_surface_protected_smooth_evaluates():
assert np.isfinite(internal_positions).all()
def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Surface", width=64, height=64)
source = bpy.data.objects.new("Surface", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
size = 8
depth = np.ones((size, size), dtype=np.float32)
x, y = np.meshgrid(np.arange(size), np.arange(size))
intrinsics = np.asarray(
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
points = np.empty((size, size, 3), dtype=np.float32)
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
points[..., 2] = depth
validity = np.ones((size, size), dtype=bool)
validity[[0, -1], :] = False
validity[:, [0, -1]] = False
result = _create_cutout_shape(
source,
"DEPTH_SURFACE",
8,
depth_image=_depth_image(points, validity),
depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0),
)
positions = _evaluated_positions(result)
assert np.isfinite(positions).all()
assert np.ptp(positions[:, 0]) < 1e-4
assert np.ptp(positions[:, 1]) > 1.5
assert np.ptp(positions[:, 2]) > 1.5
def _evaluated_positions(obj):
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
@@ -951,8 +1026,8 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller():
depth_image = _depth_image(z_depth, valid)
depth_metadata = _depth_metadata(intrinsics, (41, 61), 2.0)
region_values = np.ones((60, 40), dtype=np.float32)
region_values[:, :2] = 0.0
selection_values = np.ones((60, 40), dtype=np.float32)
selection_values[:, :2] = 0.0
result = _create_cutout_shape(
source,
"DEPTH_BALLOON",
@@ -960,7 +1035,7 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller():
depth_image=depth_image,
depth_metadata=depth_metadata,
bounds=crop_bounds,
region_values=region_values,
selection_values=selection_values,
)
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
@@ -1135,16 +1210,16 @@ 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
top_down[1, 2, 3] = 26.0 / 255.0
region_mask = rasterize_region_path(
selection_mask = rasterize_selection_path(
(4, 4),
RegionPath(
SelectionPath(
points=((0, 0), (4, 0), (4, 4), (0, 4)),
fill_rule="NONZERO",
),
source_alpha=top_down[:, :, 3],
alpha_threshold=0.1,
)
selection = region_mask.full_values((4, 4))
selection = selection_mask.full_values((4, 4))
assert selection[1, 1] == 0.0
assert selection[1, 2] > 0.1
@@ -1162,7 +1237,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
bpy.context.collection.objects.link(source)
source.select_set(True)
bpy.context.view_layer.objects.active = source
region_path = RegionPath(
selection_path = SelectionPath(
points=((-8, 8), (72, 8), (72, 40), (-8, 40)),
fill_rule="NONZERO",
)
@@ -1174,9 +1249,9 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
wraps=image_data.image_rgba,
) as read_rgba:
rgba = image_data.image_rgba(image)
region_mask = rasterize_region_path(
selection_mask = rasterize_selection_path(
tuple(image.size),
region_path,
selection_path,
source_alpha=rgba[:, :, 3],
alpha_threshold=cutout_geometry.CUTOUT_ALPHA_THRESHOLD,
antialias=True,
@@ -1186,7 +1261,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
source,
"SURFACE",
16,
region_mask,
selection_mask,
source_rgba=rgba,
)
@@ -1203,7 +1278,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
== cutout_shape.CUTOUT_SHAPE_INDICES["SURFACE"]
)
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_point_spacing(source, 16),
cutout_geometry.cutout_sampling_spacing(source, 16),
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
@@ -1245,7 +1320,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
color_texture.image.pixels.foreach_get(copied)
copied = copied.reshape((34, 64, 4))
assert np.allclose(copied[:, :, :3], 1.0)
left, top, right, bottom = region_mask.bounds
left, top, right, bottom = selection_mask.bounds
assert np.allclose(
copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3])
)
@@ -1257,7 +1332,7 @@ def test_balloon_normal_scale_uses_existing_image_layer_input():
{"BALLOON", "DEPTH_BALLOON"}
)
group = bpy.data.node_groups.new(
object_data.IMAGE_MATERIAL_NODE_GROUP_NAME,
material_data.IMAGE_MATERIAL_NODE_GROUP_NAME,
"ShaderNodeTree",
)
group.interface.new_socket(

View File

@@ -59,12 +59,14 @@ class ExtensionPackagingTest(unittest.TestCase):
self.assertNotIn("assets/O_AnyImage.blend1", names)
for path in (
"common/__init__.py",
"common/ai_job.py",
"common/image_data.py",
"common/image_interaction.py",
"common/image_region.py",
"common/ai.py",
"common/depth.py",
"common/image.py",
"common/material.py",
"common/node.py",
"common/object.py",
"common/selection.py",
"common/viewport.py",
"operators/cutout_tool/__init__.py",
"operators/cutout_tool/main.py",
"operators/cutout_tool/interaction.py",
@@ -98,12 +100,12 @@ class ExtensionPackagingTest(unittest.TestCase):
names,
)
self.assertIn("server/media/input.py", names)
self.assertIn("common/depth_data.py", names)
self.assertIn("common/depth.py", names)
self.assertIn("server/geometry/projection.py", names)
self.assertIn("server/models/geometry.py", names)
self.assertIn("server/jobs/remove_background.py", names)
self.assertIn("server/jobs/refine_image_region.py", names)
self.assertIn("server/region/refine_region.py", names)
self.assertIn("server/jobs/refine_image_selection.py", names)
self.assertIn("server/selection/refine_selection.py", names)
self.assertIn("server/jobs/cutout.py", names)
self.assertIn("server/jobs/depth_plane.py", names)
self.assertIn("server/geometry/depth_texture.py", names)

View File

@@ -1,3 +1,5 @@
import ast
import importlib
import importlib.util
import sys
import tempfile
@@ -7,6 +9,47 @@ from types import ModuleType, SimpleNamespace
from unittest.mock import patch
PROJECT_ROOT = Path(__file__).resolve().parents[1]
SOURCE_ROOT = PROJECT_ROOT / "src"
sys.path.insert(0, str(SOURCE_ROOT))
COMMON_ROOT = SOURCE_ROOT / "anyimage" / "common"
COMMON_MODULES = {
"ai",
"depth",
"image",
"material",
"node",
"object",
"selection",
"viewport",
}
def test_common_modules_follow_business_boundaries():
module_names = {
path.stem
for path in COMMON_ROOT.glob("*.py")
if path.name != "__init__.py"
}
assert module_names == COMMON_MODULES
for module_name in COMMON_MODULES:
importlib.import_module(f"anyimage.common.{module_name}")
ai_ui_definitions = {
path.name
for path in COMMON_ROOT.glob("*.py")
if "def draw_ai_setup(" in path.read_text(encoding="utf-8")
or "def draw_ai_property(" in path.read_text(encoding="utf-8")
}
assert ai_ui_definitions == {"ai.py"}
init_tree = ast.parse(
(COMMON_ROOT / "__init__.py").read_text(encoding="utf-8")
)
assert not any(
isinstance(node, (ast.Import, ast.ImportFrom))
for node in init_tree.body
)
def load_image_modules():
@@ -24,12 +67,12 @@ def load_image_modules():
module_paths = (
(
"anyimage.common.image_data",
"anyimage.common.image",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "image_data.py",
/ "image.py",
),
(
"anyimage.common.node",
@@ -39,6 +82,14 @@ def load_image_modules():
/ "common"
/ "node.py",
),
(
"anyimage.common.material",
PROJECT_ROOT
/ "src"
/ "anyimage"
/ "common"
/ "material.py",
),
(
"anyimage.common.object",
PROJECT_ROOT

View File

@@ -15,8 +15,8 @@ class NodeAssetsTest(unittest.TestCase):
self.assertEqual(offenders, [])
def test_runtime_node_group_entries_load_assets(self):
object_source = (
SOURCE_ROOT / "common" / "object.py"
material_source = (
SOURCE_ROOT / "common" / "material.py"
).read_text(encoding="utf-8")
mesh_plane_source = (
SOURCE_ROOT / "operators" / "convert_to_plane" / "mesh_plane.py"
@@ -24,7 +24,7 @@ class NodeAssetsTest(unittest.TestCase):
depth_plane_source = (
SOURCE_ROOT / "operators" / "convert_to_plane" / "object.py"
).read_text(encoding="utf-8")
self.assertIn("return load_node_group(group_name)", object_source)
self.assertIn("return load_node_group(group_name)", material_source)
self.assertIn(
"load_node_group(MESH_PLANE_NODE_GROUP_NAME)",
mesh_plane_source,

View File

@@ -259,7 +259,7 @@ class ServerRuntimeTest(unittest.TestCase):
{
"download-model",
"download-required-models",
"refine-image-region",
"refine-image-selection",
"remove-background",
"upscale-image",
"generate-depth-plane-geometry",
@@ -391,19 +391,27 @@ class ServerRuntimeTest(unittest.TestCase):
import numpy as np
depth = importlib.import_module("server.geometry.depth_texture")
GeometryFrame = importlib.import_module(
"server.models.geometry"
).GeometryFrame
texture_path = self.storage_root / "z-depth.exr"
metadata_path = self.storage_root / "depth.json"
valid = np.asarray(((True, True, False), (True, True, True)))
calibration = np.asarray(((False, True, False), (False, False, True)))
frame = SimpleNamespace(
depth=np.asarray(((1.0, 2.0, 3.0), (4.0, 5.0, 6.0))),
validity = np.asarray(
((1.0, 1.0, 0.0), (1.0, 1.0, 1.0)),
dtype=np.float32,
)
calibration = np.asarray(((False, True, True), (False, False, True)))
frame = GeometryFrame(
depth=np.asarray(
((1.0, 2.0, 3.0), (4.0, 5.0, 6.0)),
dtype=np.float32,
),
validity=validity,
points=np.ones((2, 3, 3), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
valid_mask=valid,
model_mask=None,
)
depth.write_z_depth_texture(frame.depth, frame.valid_mask, texture_path)
depth.write_z_depth_texture(frame, texture_path)
depth.write_depth_metadata(frame, calibration, metadata_path)
self.assertTrue(texture_path.is_file())
@@ -418,6 +426,9 @@ class ServerRuntimeTest(unittest.TestCase):
from PIL import Image
normal_texture = importlib.import_module("server.geometry.normal_texture")
GeometryFrame = importlib.import_module(
"server.models.geometry"
).GeometryFrame
output_path = self.storage_root / "object-normal.png"
camera_normals = np.asarray(
[[
@@ -429,9 +440,15 @@ class ServerRuntimeTest(unittest.TestCase):
dtype=np.float32,
)
valid = np.asarray([[True, True, True, False]], dtype=bool)
frame = GeometryFrame(
depth=np.ones((1, 4), dtype=np.float32),
validity=np.ones((1, 4), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
normal=camera_normals,
)
normal_texture.write_camera_normal_texture(
camera_normals,
frame,
valid,
(0, 0),
(4, 1),
@@ -455,10 +472,153 @@ class ServerRuntimeTest(unittest.TestCase):
),
)
def test_moge_model_input_composites_rgba_over_black(self):
import numpy as np
from PIL import Image
artifacts = sys.modules["server.geometry.prediction_artifacts"]
first_path = self.storage_root / "first.png"
second_path = self.storage_root / "second.png"
first = np.asarray(
[[(240, 120, 60, 0), (200, 100, 50, 128), (9, 8, 7, 255)]],
dtype=np.uint8,
)
second = first.copy()
second[0, 0, :3] = (1, 2, 3)
Image.fromarray(first, mode="RGBA").save(first_path)
Image.fromarray(second, mode="RGBA").save(second_path)
first_output = artifacts._limited_model_input(
first_path,
self.storage_root / "first-model.png",
2048,
)
second_output = artifacts._limited_model_input(
second_path,
self.storage_root / "second-model.png",
2048,
)
with Image.open(first_output) as opened:
first_rgb = np.asarray(opened, dtype=np.uint8)
with Image.open(second_output) as opened:
second_rgb = np.asarray(opened, dtype=np.uint8)
np.testing.assert_array_equal(first_rgb, second_rgb)
np.testing.assert_array_equal(
first_rgb,
np.asarray([[(0, 0, 0), (100, 50, 25), (9, 8, 7)]], dtype=np.uint8),
)
def test_geometry_writers_reject_missing_or_non_finite_fields(self):
import numpy as np
depth = importlib.import_module("server.geometry.depth_texture")
normal = importlib.import_module("server.geometry.normal_texture")
GeometryFrame = importlib.import_module(
"server.models.geometry"
).GeometryFrame
base = {
"depth": np.ones((1, 2), dtype=np.float32),
"validity": np.ones((1, 2), dtype=np.float32),
"intrinsics": np.eye(3, dtype=np.float32),
}
with self.assertRaisesRegex(ValueError, "point field"):
depth.write_vector_depth_texture(
GeometryFrame(**base),
self.storage_root / "missing-points.exr",
)
point_frame = GeometryFrame(
**base,
points=np.asarray([[(0.0, 0.0, 1.0), (np.nan, 0.0, 1.0)]], dtype=np.float32),
)
with self.assertRaisesRegex(ValueError, "Point field"):
depth.write_vector_depth_texture(
point_frame,
self.storage_root / "nan-points.exr",
)
depth_frame = GeometryFrame(
**{**base, "depth": np.asarray(((1.0, np.inf),), dtype=np.float32)}
)
with self.assertRaisesRegex(ValueError, "Depth field"):
depth.write_z_depth_texture(
depth_frame,
self.storage_root / "inf-depth.exr",
)
with self.assertRaisesRegex(ValueError, "normal field"):
normal.write_camera_normal_texture(
GeometryFrame(**base),
np.ones((1, 2), dtype=bool),
(0, 0),
(2, 1),
(2, 1),
self.storage_root / "missing-normal.png",
)
normal_frame = GeometryFrame(
**base,
normal=np.asarray(
[[(0.0, 0.0, 1.0), (0.0, np.nan, 1.0)]],
dtype=np.float32,
),
)
with self.assertRaisesRegex(ValueError, "Normal field"):
normal.write_camera_normal_texture(
normal_frame,
np.ones((1, 2), dtype=bool),
(0, 0),
(2, 1),
(2, 1),
self.storage_root / "nan-normal.png",
)
self.assertFalse(any(self.storage_root.glob("*.exr")))
self.assertFalse(any(self.storage_root.glob("*-normal.png")))
def test_moge_artifact_orchestration_uses_one_geometry_frame(self):
import numpy as np
from PIL import Image
artifacts = sys.modules["server.geometry.prediction_artifacts"]
GeometryFrame = importlib.import_module(
"server.models.geometry"
).GeometryFrame
input_path = self.storage_root / "selection.png"
Image.new("RGBA", (2, 2), (255, 128, 64, 255)).save(input_path)
output_directory = self.storage_root / "job"
output_directory.mkdir()
context = SimpleNamespace(
directory=output_directory,
resource=lambda _name: object(),
progress=lambda *_args: None,
check_cancelled=lambda: None,
)
frame = GeometryFrame(
depth=np.ones((2, 2), dtype=np.float32),
validity=np.ones((2, 2), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
normal=np.zeros((2, 2, 3), dtype=np.float32),
points=np.ones((2, 2, 3), dtype=np.float32),
)
with patch.object(artifacts.moge2, "infer", return_value=[frame]) as infer:
result = artifacts.generate_moge2_artifacts(
context,
{"max_input_size": 2048},
input_path,
depth_mode="VECTOR",
normal_mode="OBJECT",
)
self.assertEqual(
set(result),
{"vector_depth", "depth_metadata", "object_normal"},
)
self.assertFalse((output_directory / "model-input.png").exists())
self.assertTrue(all(path.is_file() for path in result.values()))
self.assertTrue(infer.call_args.kwargs["include_points"])
def test_generate_cutout_artifacts_only_returns_requested_outputs(self):
job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png"
input_path.write_bytes(b"region")
input_path.write_bytes(b"selection")
context = SimpleNamespace(
directory=self.storage_root / "job",
progress=lambda *_args: None,
@@ -505,10 +665,10 @@ class ServerRuntimeTest(unittest.TestCase):
normal_mode="OBJECT",
)
def test_generate_cutout_artifacts_refine_an_already_rasterized_region(self):
def test_generate_cutout_artifacts_refine_an_already_rasterized_selection(self):
job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png"
input_path.write_bytes(b"region")
input_path.write_bytes(b"selection")
directory = self.storage_root / "job"
context = SimpleNamespace(
directory=directory,
@@ -520,12 +680,12 @@ class ServerRuntimeTest(unittest.TestCase):
output.write_bytes(b"refined")
return output, (2, 3, 8, 9)
with patch.object(job, "refine_region", side_effect=generate):
with patch.object(job, "refine_selection", side_effect=generate):
result = job.run(
context,
{
"input": str(input_path),
"refine_region": True,
"refine_selection": True,
"refine_model": "BEN2_BASE",
"device": "cpu",
},
@@ -533,9 +693,12 @@ class ServerRuntimeTest(unittest.TestCase):
self.assertEqual(
result,
{"region_image": "region.png", "region_bounds": "region_bounds.json"},
{
"selection_image": "selection.png",
"selection_bounds": "selection_bounds.json",
},
)
self.assertEqual(
(directory / "region_bounds.json").read_text(encoding="utf-8"),
(directory / "selection_bounds.json").read_text(encoding="utf-8"),
"[2,3,8,9]",
)