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:
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user