Refactor Cutout and Image Editing Logic

- Renamed "Base Plane Depth" to "Reference Depth" across validate_cutout.py for consistency.
- Introduced a new specification for Cutout mesh sampling to ensure source pixel spacing is preserved and limits are applied correctly.
- Unified naming conventions for image editing results to maintain consistency across Remove Background, Upscale, and Crop operations.
- Updated the handling of Refine Selection to ensure it retains the original bounds and does not apply unnecessary cropping.
- Enhanced material generation logic to connect image color directly to IOR Level, improving the rendering of dark areas.
- Established clear requirements for geometry depth controls and image result materials to ensure consistent behavior across different components.
- Removed outdated logic and ensured all changes are documented and tested for regression.
This commit is contained in:
2026-09-03 01:02:10 +08:00
parent a6041cbe03
commit bdbde1a10d
47 changed files with 1120 additions and 257 deletions

View File

@@ -1242,7 +1242,7 @@ class BlenderAddonTest(unittest.TestCase):
}
),
shape=shape,
mesh_resolution=64,
point_spacing=32,
generate_normal=False,
refine_selection=False,
report=lambda *_args: None,
@@ -1300,7 +1300,7 @@ class BlenderAddonTest(unittest.TestCase):
context,
source,
shape,
64,
32.0,
selection_mask.values,
selection_mask.bounds,
inward_axis="NEGATIVE_Z",
@@ -1331,7 +1331,7 @@ class BlenderAddonTest(unittest.TestCase):
}
)
operator.shape = "DEPTH_SURFACE"
operator.mesh_resolution = 64
operator.point_spacing = 32
operator.generate_normal = False
operator.refine_selection = False
operator.report = lambda *_args: None
@@ -1413,7 +1413,7 @@ class BlenderAddonTest(unittest.TestCase):
}
),
shape="SURFACE",
mesh_resolution=64,
point_spacing=32,
generate_normal=False,
refine_selection=False,
report=lambda level, message: reports.append((level, message)),
@@ -1721,6 +1721,78 @@ class BlenderAddonTest(unittest.TestCase):
self.assertIs(result, expected)
self.assertNotIn("REGISTER", operator.bl_options)
def test_remove_background_loads_a_plain_image_edit_result(self):
source = SimpleNamespace()
result_image = SimpleNamespace()
result = SimpleNamespace(directory=Path("result"))
ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
)
with (
patch.object(self.image_data, "require_image_empty", return_value=source),
patch.object(
self.image_data,
"load_image_edit_result",
return_value=result_image,
) as load,
patch.object(self.image_data, "replace_empty_image") as replace,
patch.object(self.remove_background.bpy, "ops", ops, create=True),
):
self.remove_background.apply_background_response(result, "Source")
load.assert_called_once_with(source, Path("result"))
replace.assert_called_once_with(source, result_image)
def test_upscale_loads_a_plain_image_edit_result(self):
source_image = SimpleNamespace()
source = SimpleNamespace(data=source_image)
result_image = SimpleNamespace()
result = SimpleNamespace(directory=Path("result"))
operator = self.upscale.UpscaleImage()
operator.source_object_name = "Source"
operator.source_image_name = "Poster.png"
operator.model = "SAFMN_REAL_X4"
images = SimpleNamespace(get=lambda name: source_image if name == "Poster.png" else None)
with (
patch.object(self.upscale, "require_image_empty", return_value=source),
patch.object(
self.upscale,
"load_image_edit_result",
return_value=result_image,
) as load,
patch.object(self.upscale, "replace_empty_image") as replace,
patch.object(
self.upscale,
"upscale_model_label",
return_value="SAFMN Real x4",
),
patch.object(
self.upscale.bpy,
"data",
SimpleNamespace(images=images),
),
):
message = operator.response(None, result)
load.assert_called_once_with(
source,
Path("result"),
frame_prefix="upscale.",
)
replace.assert_called_once_with(source, result_image)
self.assertEqual(message, "Image is upscaled with SAFMN Real x4")
def test_crop_tools_share_the_plain_pixel_edit_result_creator(self):
self.assertIs(
self.edit_mask.create_image_edit_result,
self.image_data.create_image_edit_result,
)
self.assertIs(
self.image_perspective.create_image_edit_result,
self.image_data.create_image_edit_result,
)
self.assertFalse(hasattr(self.image_data, "create_pixel_result_image"))
def test_packed_image_export_does_not_use_scene_render_transform(self):
with tempfile.TemporaryDirectory() as directory:
output_directory = Path(directory)
@@ -2681,7 +2753,7 @@ class BlenderAddonTest(unittest.TestCase):
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):
def test_refined_image_edit_writes_one_local_selection_mask(self):
import numpy as np
source = SimpleNamespace(data=SimpleNamespace(size=(8, 6)))
@@ -2725,6 +2797,11 @@ class BlenderAddonTest(unittest.TestCase):
"prepare_image_bounds_input",
return_value=Path(__file__),
) as prepare_input,
patch.object(
self.edit_mask,
"save_selection_mask",
return_value=Path("selection-mask.npy"),
) as save_mask,
patch.object(self.edit_mask.bpy, "ops", ops, create=True),
):
result = self.edit_mask.CropImageSelection._submit_selection_path(
@@ -2741,29 +2818,55 @@ class BlenderAddonTest(unittest.TestCase):
source_rgba,
selection_mask.bounds,
)
refine.assert_called_once()
save_mask.assert_called_once_with(Path(__file__), selection_mask)
self.assertEqual(
refine.call_args.kwargs["selection_mask_path"],
"selection-mask.npy",
)
def test_refined_image_request_excludes_the_local_selection_mask(self):
operator = self.edit_mask.RefineCropSelection()
operator.input_path = __file__
operator.selection_mask_path = "selection-mask.npy"
with (
patch.object(self.edit_mask, "require_environment"),
patch.object(self.edit_mask, "require_model"),
patch.object(self.edit_mask, "production_device", return_value="CPU"),
):
parameters = operator.request(None)
self.assertEqual(parameters["input"], __file__)
self.assertNotIn("selection_mask_path", parameters)
def test_refined_image_response_applies_the_saved_mask_on_original_bounds(self):
import numpy as np
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__),
}
encoded = SimpleNamespace(users=0)
refined = SimpleNamespace(users=1)
files = {"selection_image": Path(__file__)}
result = SimpleNamespace(file=lambda key: files[key])
operator = self.edit_mask.RefineCropSelection()
operator.source_object_name = "Source"
operator.selection_bounds_json = "[10,20,18,26]"
operator.selection_mask_path = "selection-mask.npy"
operator.keep_original = True
values = np.ones((6, 8), dtype=np.float32)
values[:, 0] = 0.0
selection_mask = self.image_selection.SelectionMask(
values,
(10, 20, 18, 26),
)
rgba = np.ones((6, 8, 4), dtype=np.float32)
ops = SimpleNamespace(
ed=SimpleNamespace(undo_push=Mock(return_value={"FINISHED"}))
)
remove = Mock()
with (
patch.object(
@@ -2774,14 +2877,30 @@ class BlenderAddonTest(unittest.TestCase):
patch.object(
self.edit_mask,
"load_packed_rgba_image",
return_value=refined,
return_value=encoded,
) as load_result,
patch.object(self.edit_mask, "place_empty_image_result") as place,
patch.object(self.edit_mask, "image_rgba") as read_rgba,
patch.object(
self.edit_mask,
"create_pixel_result_image",
"load_selection_mask",
return_value=selection_mask,
) as load_mask,
patch.object(self.edit_mask, "place_empty_image_result") as place,
patch.object(
self.edit_mask,
"image_rgba",
return_value=rgba,
) as read_rgba,
patch.object(
self.edit_mask,
"create_image_edit_result",
return_value=refined,
) as create_pixels,
patch.object(
self.edit_mask.bpy.data,
"images",
SimpleNamespace(remove=remove),
create=True,
),
patch.object(self.edit_mask.bpy, "ops", ops, create=True),
):
message = operator.response(None, result)
@@ -2790,17 +2909,38 @@ class BlenderAddonTest(unittest.TestCase):
load_result.assert_called_once_with(
Path(__file__),
source_image,
"Poster",
"AnyImage Refine Result",
)
load_mask.assert_called_once_with("selection-mask.npy", (10, 20, 18, 26))
read_rgba.assert_called_once_with(encoded)
pixels = create_pixels.call_args.args[1]
masked = np.flipud(pixels.reshape((6, 8, 4)))
np.testing.assert_array_equal(masked[:, 0, 3], 0.0)
np.testing.assert_array_equal(masked[:, 1:, 3], 1.0)
place.assert_called_once_with(
None,
source,
refined,
placement_bounds=(12, 21, 15, 24),
keep_original=False,
placement_bounds=(10, 20, 18, 26),
keep_original=True,
)
read_rgba.assert_not_called()
create_pixels.assert_not_called()
remove.assert_called_once_with(encoded, do_unlink=True)
def test_refined_image_mask_rejects_an_empty_intersection(self):
import numpy as np
selection_mask = self.image_selection.SelectionMask(
np.zeros((2, 3), dtype=np.float32),
(1, 2, 4, 4),
)
with self.assertRaisesRegex(
self.image_selection.ImageEditWarning,
"no visible image pixels",
):
self.edit_mask._apply_local_selection_mask(
np.ones((2, 3, 4), dtype=np.float32),
selection_mask,
)
def test_cutout_ai_response_reuses_color_image_and_keeps_lasso_for_no_refine(self):
import numpy as np
@@ -2818,7 +2958,7 @@ class BlenderAddonTest(unittest.TestCase):
operator.selection_bounds_json = "[10,20,14,23]"
operator.input_path = __file__
operator.shape = "SURFACE"
operator.mesh_resolution = 32
operator.point_spacing = 32
operator.generate_normal = True
operator.refine_selection = False
operator._selection_mask = selection_mask
@@ -2849,7 +2989,7 @@ class BlenderAddonTest(unittest.TestCase):
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):
def test_cutout_refine_keeps_textures_but_masks_the_mesh(self):
import numpy as np
source = SimpleNamespace(data=SimpleNamespace())
@@ -2859,11 +2999,13 @@ class BlenderAddonTest(unittest.TestCase):
operator.selection_bounds_json = "[10,20,16,24]"
operator.input_path = __file__
operator.shape = "SURFACE"
operator.mesh_resolution = 32
operator.point_spacing = 32
operator.generate_normal = False
operator.refine_selection = True
selection_values = np.ones((4, 6), dtype=np.float32)
selection_values[:, 0] = 0.0
operator._selection_mask = self.image_selection.SelectionMask(
np.zeros((4, 6), dtype=np.float32),
selection_values,
(10, 20, 16, 24),
)
result = SimpleNamespace(
@@ -2882,8 +3024,8 @@ class BlenderAddonTest(unittest.TestCase):
"_generated_color_result",
return_value=(
color_image,
np.ones((2, 3), dtype=np.float32),
(12, 21, 15, 23),
np.ones((4, 6), dtype=np.float32),
(10, 20, 16, 24),
),
),
patch.object(self.cutout_main, "create_cutout_shape") as create_shape,
@@ -2893,9 +3035,9 @@ class BlenderAddonTest(unittest.TestCase):
content_values = create_shape.call_args.args[4]
np.testing.assert_array_equal(
content_values,
np.ones((2, 3), dtype=np.float32),
selection_values,
)
self.assertEqual(create_shape.call_args.args[5], (12, 21, 15, 23))
self.assertEqual(create_shape.call_args.args[5], (10, 20, 16, 24))
self.assertIs(create_shape.call_args.kwargs["color_image"], color_image)
def test_image_selection_uses_white_dashed_segments(self):

View File

@@ -34,10 +34,10 @@ 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():
assert cutout_geometry.limit_cutout_mesh_resolution(256, (4096, 1024), 2048) == 128
assert cutout_geometry.limit_cutout_mesh_resolution(256, (1024, 512), 2048) == 256
assert cutout_geometry.limit_cutout_mesh_resolution(8, (8192, 4096), 2048) == 4
def test_cutout_mesh_limit_caps_actual_sampling_resolution():
assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 1024) == 32
assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 64) == 64
assert cutout_geometry.limit_cutout_point_spacing(4, (8192, 4096), 1024) == 8
def test_depth_metadata_loader_uses_one_schema_for_plane_and_cutout(tmp_path):
@@ -378,17 +378,20 @@ def test_blender_cutout_contour_resolution_tracks_mesh_detail():
components=(((((0, 0), (1, 0), (0, 1))),),),
image_size=(1, 1),
)
selection_mask = SelectionMask(np.ones((2, 2), dtype=np.float32), (0, 0, 2, 2))
values = SimpleNamespace(shape=(1024, 1024))
with patch.object(cutout_geometry, "extract_selection_contours", return_value=expected) as trace:
assert cutout_geometry.extract_cutout_contours(selection_mask.values, 32) == expected
assert cutout_geometry.extract_cutout_contours(selection_mask.values, 256) == expected
assert cutout_geometry.extract_cutout_contours(values, 32) == expected
assert cutout_geometry.extract_cutout_contours(values, 4) == expected
assert trace.call_args_list[0].kwargs["max_resolution"] == 512
assert trace.call_args_list[1].kwargs["max_resolution"] == 1024
def test_cutout_sampling_spacing_uses_the_complete_source_plane():
source = SimpleNamespace(matrix_world=object())
source = SimpleNamespace(
data=SimpleNamespace(size=(400, 200)),
matrix_world=object(),
)
with (
patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object(
@@ -397,9 +400,32 @@ def test_cutout_sampling_spacing_uses_the_complete_source_plane():
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
spacing = cutout_geometry.cutout_sampling_spacing(source, 200)
spacing = cutout_geometry.cutout_sampling_spacing(source, 32)
assert spacing == 0.02
assert spacing == 0.32
def test_cutout_sampling_spacing_tracks_upscaled_source_pixels():
original = SimpleNamespace(
data=SimpleNamespace(size=(2048, 1024)),
matrix_world=object(),
)
upscaled = SimpleNamespace(
data=SimpleNamespace(size=(8192, 4096)),
matrix_world=object(),
)
with (
patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object(
cutout_geometry,
"world_plane_metric",
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
original_spacing = cutout_geometry.cutout_sampling_spacing(original, 32)
upscaled_spacing = cutout_geometry.cutout_sampling_spacing(upscaled, 32)
assert upscaled_spacing == original_spacing / 4
def test_cutout_cleanup_value_scales_the_ui_without_changing_node_area():

View File

@@ -81,7 +81,7 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
)
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)
assert all(call[2].point_spacing == 32 for call in window_manager.pie.calls)
assert all(call[2].inward_axis == "NEGATIVE_Z" for call in window_manager.pie.calls)
@@ -134,7 +134,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
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)
assert all(call[2].point_spacing == 16 for call in window_manager.pie.calls)
assert all(call[2].inward_axis == "POSITIVE_X" for call in window_manager.pie.calls)
@@ -146,12 +146,6 @@ def test_cutout_normal_space_is_derived_from_shape():
assert cutout_main.normal_mode_for_shape("DEPTH_SURFACE", True) == "OBJECT"
def test_cutout_mesh_detail_tracks_the_source_long_edge_and_clamps():
assert interaction.cutout_mesh_resolution((4096, 2048), 32) == 128
assert interaction.cutout_mesh_resolution((16, 8), 4) == 4
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)
@@ -207,7 +201,7 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
(65536, 32768),
(0, 32768),
)
assert window_manager.pie.calls[0][2].mesh_resolution == 1024
assert window_manager.pie.calls[0][2].point_spacing == 32
assert not hasattr(cutout_main, "pending_selection")

View File

@@ -157,6 +157,51 @@ def test_keep_original_places_and_activates_result_copy():
assert bpy.context.view_layer.objects.active == placed
def test_image_edit_result_uses_complete_shared_source_name():
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True)
source = bpy.data.objects.new("Poster", None)
source.empty_display_type = "IMAGE"
source.data = source_image
shared = bpy.data.objects.new("Poster Shared", None)
shared.empty_display_type = "IMAGE"
shared.data = source_image
bpy.context.collection.objects.link(source)
bpy.context.collection.objects.link(shared)
result_image = image_data.create_image_edit_result(
source_image,
np.zeros(4 * 4 * 4, dtype=np.float32),
(4, 4),
)
image_data.replace_empty_image(source, result_image)
assert shared.data == source_image
assert source.data == result_image
assert result_image.name == "Poster.png.001"
assert "_color" not in result_image.name
def test_image_edit_result_recovers_unshared_source_name():
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Poster.png", width=2, height=2, alpha=True)
source = bpy.data.objects.new("Poster", None)
source.empty_display_type = "IMAGE"
source.data = source_image
bpy.context.collection.objects.link(source)
result_image = image_data.create_image_edit_result(
source_image,
np.zeros(4 * 4 * 4, dtype=np.float32),
(4, 4),
)
image_data.replace_empty_image(source, result_image)
assert source.data == result_image
assert result_image.name == "Poster.png"
assert bpy.data.images.get("Poster.png") == result_image
def test_object_normal_image_follows_cutout_inward_axis():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new(
@@ -292,7 +337,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Subdivide",
"Thickness",
"Depth Scale",
"Base Plane Depth",
"Reference Depth",
"Uniform Scale",
"Depth Image",
]
@@ -306,7 +351,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Smooth Iterations",
"Cleanup",
"Smooth",
"Base Plane Depth",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
@@ -316,7 +361,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
assert [item.name for item in panels[0].interface_items] == [
"Cleanup",
"Smooth",
"Base Plane Depth",
"Reference Depth",
"Depth Direction",
]
assert [item.name for item in panels[1].interface_items] == [
@@ -375,7 +420,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Thickness",
"Depth Scale",
"Double Sided",
"Base Plane Depth",
"Reference Depth",
"Depth Direction",
"Uniform Scale",
"Depth Image",
@@ -384,7 +429,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Geometry",
"Thickness",
"Depth Scale",
"Base Plane Depth",
"Reference Depth",
"Uniform Scale",
"Depth Image",
]
@@ -405,7 +450,7 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
"Thickness": set(cutout_shape.CUTOUT_SHAPES),
"Depth Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Double Sided": {"DEPTH_BALLOON"},
"Base Plane Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Reference Depth": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Direction": {"DEPTH_BALLOON"},
"Uniform Scale": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
"Depth Image": {"DEPTH_BALLOON", "DEPTH_SURFACE"},
@@ -740,6 +785,50 @@ def test_cutout_material_uses_ior_1_2():
if node.bl_idname == "ShaderNodeBsdfPrincipled"
)
assert np.isclose(shader.inputs["IOR"].default_value, 1.2)
image_layer = _group_node(
material_data.material_node_group(),
result.data.materials[0].node_tree,
)
ior_level_link = next(
link
for link in result.data.materials[0].node_tree.links
if link.to_node == shader
and link.to_socket == shader.inputs["Specular IOR Level"]
)
assert ior_level_link.from_node == image_layer
assert ior_level_link.from_socket == image_layer.outputs["Color"]
def test_plane_material_color_drives_ior_level_but_shadeless_does_not():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Plane", width=2, height=2, alpha=True)
source = SimpleNamespace(data=SimpleNamespace(name="Plane"))
material = material_data.create_image_material(source, image, 0.0)
shader = next(
node
for node in material.node_tree.nodes
if node.bl_idname == "ShaderNodeBsdfPrincipled"
)
image_layer = _group_node(material_data.material_node_group(), material.node_tree)
assert any(
link.from_node == image_layer
and link.from_socket == image_layer.outputs["Color"]
and link.to_node == shader
and link.to_socket == shader.inputs["Specular IOR Level"]
for link in material.node_tree.links
)
shadeless = material_data.create_image_material(
source,
image,
0.0,
shadeless=True,
)
assert not any(
node.bl_idname == "ShaderNodeBsdfPrincipled"
for node in shadeless.node_tree.nodes
)
def test_cutout_material_uses_flat_tangent_normal():
bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -820,7 +909,7 @@ def test_depth_surface_protected_smooth_evaluates():
)
reference_id = object_data.modifier_input_identifier(
modifier.node_group,
"Base Plane Depth",
"Reference Depth",
)
assert modifier[reference_id] == 8.0
object_data.set_modifier_input(
@@ -1013,7 +1102,7 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
depth_modifier[
object_data.modifier_input_identifier(
depth_modifier.node_group,
"Base Plane Depth",
"Reference Depth",
)
],
uniform_scale,
@@ -1078,7 +1167,7 @@ def test_depth_plane_builds_a_fixed_base_terrain_block():
evaluated.to_mesh_clear()
set_input("Depth Scale", 1.0)
set_input("Base Plane Depth", 1.2)
set_input("Reference 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)
@@ -1092,23 +1181,23 @@ def test_depth_plane_builds_a_fixed_base_terrain_block():
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)
set_input("Reference 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)
set_input("Reference 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)
set_input("Reference 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)
set_input("Reference Depth", 1.2)
sheet = _evaluated_positions(result)
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5)
@@ -1401,7 +1490,7 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller():
depth_reference_id = object_data.modifier_input_identifier(
modifier.node_group,
"Base Plane Depth",
"Reference Depth",
)
assert not np.isclose(modifier[depth_reference_id], 8.0)
depth_direction_id = object_data.modifier_input_identifier(

View File

@@ -78,7 +78,6 @@ def test_selection_path_json_contains_only_points_and_invert():
points=((1, 2), (8, 2), (4, 9)),
invert=True,
)
value = selection_path.to_json()
assert set(json.loads(value)) == {"points", "invert"}
@@ -95,6 +94,24 @@ def test_selection_path_json_contains_only_points_and_invert():
)
def test_selection_mask_sidecar_round_trip_and_cleanup(tmp_path):
original = selection.SelectionMask(
np.asarray(((0.0, 0.25), (0.5, 1.0)), dtype=np.float32),
(3, 4, 5, 6),
)
input_path = tmp_path / "input.png"
input_path.write_bytes(b"input")
path = selection.save_selection_mask(input_path, original)
loaded = selection.load_selection_mask(path, original.bounds)
assert path.parent == input_path.parent
np.testing.assert_array_equal(loaded.values, original.values)
assert loaded.bounds == original.bounds
selection.cleanup_selection_mask(path)
assert not path.exists()
def test_nonzero_winding_keeps_same_direction_overlap():
points = (
(1, 1),

View File

@@ -700,6 +700,46 @@ class ServerRuntimeTest(unittest.TestCase):
self.assertTrue(all(path.is_file() for path in result.values()))
self.assertTrue(infer.call_args.kwargs["include_points"])
def test_refine_selection_preserves_the_full_alpha_canvas(self):
import numpy as np
from PIL import Image
refine = importlib.import_module("server.selection.refine_selection")
input_path = self.storage_root / "input.png"
output_path = self.storage_root / "output.png"
source = np.zeros((3, 4, 4), dtype=np.uint8)
source[..., :3] = (32, 64, 96)
source[..., 3] = np.asarray(
((0, 64, 128, 255), (255, 128, 64, 0), (0, 0, 255, 255)),
dtype=np.uint8,
)
Image.fromarray(source, mode="RGBA").save(input_path)
foreground = Image.fromarray(source, mode="RGBA")
context = SimpleNamespace(
resource=lambda _name: SimpleNamespace(directory=lambda _model: "model"),
check_cancelled=lambda: None,
)
with patch.object(
refine,
"infer_foreground",
return_value=(foreground, 0.0, 0.0),
) as infer:
result = refine.refine_selection(
context,
input_path,
"BEN2_BASE",
"cpu",
output_path,
)
self.assertEqual(result, output_path)
infer.assert_called_once()
self.assertTrue(infer.call_args.kwargs["preserve_input_alpha"])
with Image.open(output_path) as output:
self.assertEqual(output.size, (4, 3))
np.testing.assert_array_equal(np.asarray(output.convert("RGBA")), source)
def test_generate_cutout_artifacts_only_returns_requested_outputs(self):
job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png"
@@ -750,7 +790,7 @@ class ServerRuntimeTest(unittest.TestCase):
normal_mode="OBJECT",
)
def test_generate_cutout_artifacts_refines_the_bounds_image(self):
def test_generate_cutout_artifacts_refines_the_full_bounds_image(self):
job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png"
input_path.write_bytes(b"selection")
@@ -763,7 +803,7 @@ class ServerRuntimeTest(unittest.TestCase):
def generate(_context, _input, _model, _device, output):
output.write_bytes(b"refined")
return output, (2, 3, 8, 9)
return output
with patch.object(job, "refine_selection", side_effect=generate):
result = job.run(
@@ -778,12 +818,62 @@ class ServerRuntimeTest(unittest.TestCase):
self.assertEqual(
result,
{"selection_image": "selection.png"},
)
self.assertFalse((directory / "selection_bounds.json").exists())
def test_refined_cutout_artifacts_share_the_full_refine_image(self):
job = sys.modules["server.jobs.cutout"]
input_path = self.storage_root / "input.png"
input_path.write_bytes(b"selection")
directory = self.storage_root / "job"
context = SimpleNamespace(
directory=directory,
progress=lambda *_args: None,
check_cancelled=lambda: None,
)
refined_path = directory / "selection.png"
def refine(_context, _input, _model, _device, output):
output.write_bytes(b"refined")
return output
with (
patch.object(job, "refine_selection", side_effect=refine),
patch.object(
job,
"generate_moge2_artifacts",
return_value={
"z_depth": directory / "z-depth.exr",
"depth_metadata": directory / "depth.json",
"tangent_normal": directory / "tangent-normal.png",
},
) as generate,
):
result = job.run(
context,
{
"input": str(input_path),
"refine_selection": True,
"refine_model": "BEN2_BASE",
"device": "cpu",
"depth_mode": "Z",
"normal_mode": "TANGENT",
},
)
self.assertEqual(result["selection_image"], "selection.png")
generate.assert_called_once_with(
context,
{
"selection_image": "selection.png",
"selection_bounds": "selection_bounds.json",
"input": str(input_path),
"refine_selection": True,
"refine_model": "BEN2_BASE",
"device": "cpu",
"depth_mode": "Z",
"normal_mode": "TANGENT",
},
)
self.assertEqual(
(directory / "selection_bounds.json").read_text(encoding="utf-8"),
"[2,3,8,9]",
refined_path,
depth_mode="Z",
normal_mode="TANGENT",
)