Add tests and implement boundary smoothing and cutout functionality
- Introduced tests for boundary smoothing in `test_boundary_smoothing.py`, verifying that smoothing reduces stair-step artifacts while preserving boundary integrity. - Added tests for cutout boundary behavior in `test_cutout_boundary.py`, ensuring proper handling of boundary triangles and normal blending. - Created `test_temporary_attributes.py` to check the cleanup of temporary attributes across depth modifier branches, ensuring no warnings are raised. - Implemented point culling functionality in `test_validity.py`, confirming that invalid points are removed without affecting adjacent geometry. - Developed boundary smoothing logic in `boundary_smoothing.py`, allowing for depth smoothing while maintaining edge integrity. - Added cutout boundary management in `cutout_boundary.py`, including functions for removing boundary triangles and tapering thickness profiles. - Implemented validity checks in `validity.py` to ensure proper deletion of invalid vertices and associated wire edges.
This commit is contained in:
@@ -50,9 +50,9 @@ class AiSetupTest(BlenderTestCase):
|
||||
with self.assertRaisesRegex(
|
||||
RuntimeError,
|
||||
(
|
||||
"Fast \\| MoGe-2 ViT-S Normal: network unavailable; "
|
||||
"Fast \\| BiRefNet Lite: network unavailable; "
|
||||
"Fast \\| Real-ESRGAN General WDN x4v3: network unavailable"
|
||||
"MoGe-2 ViT-S Normal: network unavailable; "
|
||||
"BiRefNet Lite: network unavailable; "
|
||||
"Real-ESRGAN General WDN x4v3: network unavailable"
|
||||
),
|
||||
):
|
||||
self.runtime.post_install(SimpleNamespace(request=request))
|
||||
|
||||
37
tests/anyimage/operators/test_brush_preview_integration.py
Normal file
37
tests/anyimage/operators/test_brush_preview_integration.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
from anyimage.operators.clipboard_image.actions import _set_brush_preview
|
||||
|
||||
|
||||
def test_packed_brush_preview_preserves_image_and_survives_reopen(tmp_path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Preview Source", width=80, height=40, alpha=True)
|
||||
pixels = np.zeros((40, 80, 4), dtype=np.float32)
|
||||
pixels[:, :40] = (1.0, 0.0, 0.0, 1.0)
|
||||
image.pixels.foreach_set(pixels.ravel())
|
||||
image.pack()
|
||||
original_pixels = tuple(image.pixels)
|
||||
original_format = image.file_format
|
||||
brush = bpy.data.brushes.new("Preview Brush", mode="TEXTURE_PAINT")
|
||||
brush.asset_mark()
|
||||
try:
|
||||
_set_brush_preview(bpy.context, brush, image)
|
||||
assert tuple(brush.preview.image_size) == (80, 40)
|
||||
preview = np.array(brush.preview.image_pixels_float[:]).reshape(40, 80, 4)
|
||||
assert np.allclose(preview[:, :40, 0], 1.0, atol=1 / 255)
|
||||
assert np.all(preview[:, :40, 3] == 1.0)
|
||||
assert np.all(preview[:, 40:, 3] == 0.0)
|
||||
assert tuple(image.pixels) == original_pixels
|
||||
assert tuple(image.size) == (80, 40)
|
||||
assert image.packed_file is not None
|
||||
assert image.file_format == original_format
|
||||
expected_preview = tuple(brush.preview.image_pixels)
|
||||
path = str(tmp_path / "brush-preview.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||
bpy.ops.wm.open_mainfile(filepath=path)
|
||||
restored = bpy.data.brushes["Preview Brush"]
|
||||
assert tuple(restored.preview.image_size) == (80, 40)
|
||||
assert tuple(restored.preview.image_pixels) == expected_preview
|
||||
finally:
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
@@ -631,6 +631,89 @@ class ClipboardImageTest(unittest.TestCase):
|
||||
self.assertEqual(brush.texture_slot.map_mode, "STENCIL")
|
||||
self.assertEqual(brush.stencil_dimension, (256.0, 144.0))
|
||||
|
||||
def test_brush_preview_updates_local_copy_only_in_texture_paint(self):
|
||||
for mode in ("PAINT_TEXTURE", "SCULPT", "PAINT_VERTEX"):
|
||||
with self.subTest(mode=mode):
|
||||
brush = SimpleNamespace(
|
||||
texture=None,
|
||||
texture_slot=SimpleNamespace(texture=None),
|
||||
stencil_dimension=(256.0, 256.0),
|
||||
update_tag=Mock(),
|
||||
)
|
||||
context = SimpleNamespace(mode=mode, brush=object())
|
||||
images = [SimpleNamespace(name=name, size=(80, 40)) for name in ("A", "B")]
|
||||
textures = FakeTextures()
|
||||
with (
|
||||
patch.object(self.actions.bpy, "data", SimpleNamespace(textures=textures)),
|
||||
patch.object(self.actions, "_ensure_editable_brush", return_value=brush),
|
||||
patch.object(self.actions, "_set_brush_preview") as preview,
|
||||
):
|
||||
for image in images:
|
||||
self.actions.set_tool_texture(context, image)
|
||||
self.assertIs(brush.texture.image, images[-1])
|
||||
self.assertEqual(preview.call_count, 2 if mode == "PAINT_TEXTURE" else 0)
|
||||
if mode == "PAINT_TEXTURE":
|
||||
self.assertEqual([call.args[1:] for call in preview.call_args_list],
|
||||
[(brush, image) for image in images])
|
||||
|
||||
def test_brush_preview_cleans_export_on_success_cancel_and_error(self):
|
||||
for outcome in ({"FINISHED"}, {"CANCELLED"}, RuntimeError("preview failed")):
|
||||
with self.subTest(outcome=outcome):
|
||||
brush, image = object(), object()
|
||||
context = SimpleNamespace(scene=object(), temp_override=Mock(return_value=nullcontext()))
|
||||
loader = Mock()
|
||||
if isinstance(outcome, Exception):
|
||||
loader.side_effect = outcome
|
||||
else:
|
||||
loader.return_value = outcome
|
||||
with (
|
||||
patch.object(self.actions, "save_packed_image", return_value="preview.png"),
|
||||
patch.object(self.actions, "cleanup_image_input") as cleanup,
|
||||
patch.object(self.actions.bpy, "ops", SimpleNamespace(
|
||||
ed=SimpleNamespace(lib_id_load_custom_preview=loader)), create=True),
|
||||
):
|
||||
expectation = nullcontext() if outcome == {"FINISHED"} else self.assertRaises(self.actions.BrushPreviewError)
|
||||
with expectation:
|
||||
self.actions._set_brush_preview(context, brush, image)
|
||||
cleanup.assert_called_once_with("preview.png", True)
|
||||
context.temp_override.assert_called_once_with(id=brush)
|
||||
loader.assert_called_once_with(filepath="preview.png")
|
||||
|
||||
def test_preview_failure_preserves_texture_and_redraws(self):
|
||||
textures = FakeTextures()
|
||||
brush = SimpleNamespace(
|
||||
texture=None, texture_slot=SimpleNamespace(texture=None),
|
||||
stencil_dimension=(256.0, 256.0), update_tag=Mock(),
|
||||
)
|
||||
context = SimpleNamespace(mode="PAINT_TEXTURE", brush=brush, area=SimpleNamespace(tag_redraw=Mock()))
|
||||
image = SimpleNamespace(name="Clipboard", size=(80, 40))
|
||||
with (
|
||||
patch.object(self.actions.bpy, "data", SimpleNamespace(textures=textures)),
|
||||
patch.object(self.actions, "_set_brush_preview", side_effect=self.actions.BrushPreviewError("preview failed")),
|
||||
self.assertRaises(self.actions.BrushPreviewError),
|
||||
):
|
||||
self.actions.set_tool_texture(context, image)
|
||||
self.assertEqual(len(textures), 1)
|
||||
self.assertIs(brush.texture.image, image)
|
||||
context.area.tag_redraw.assert_called_once()
|
||||
|
||||
def test_preview_warning_finishes_paste(self):
|
||||
module = self.addon.clipboard_image
|
||||
operator = module.PasteClipboardImage()
|
||||
operator.paste_target = "TOOL_TEXTURE"
|
||||
operator.location_set = False
|
||||
operator.import_as = "REFERENCE"
|
||||
operator.shadeless = False
|
||||
operator.report = Mock()
|
||||
with (
|
||||
patch.object(module, "_should_defer_to_native_paste", return_value=False),
|
||||
patch.object(module, "read_clipboard_image", return_value=(b"image", ".png")),
|
||||
patch.object(module, "acquire_packed_image", return_value=(object(), False)),
|
||||
patch.object(module, "paste_image", side_effect=self.actions.BrushPreviewError("preview failed")),
|
||||
):
|
||||
self.assertEqual(operator.execute(SimpleNamespace()), {"FINISHED"})
|
||||
operator.report.assert_called_once_with({"WARNING"}, "preview failed")
|
||||
|
||||
def test_localizes_read_only_active_brush(self):
|
||||
local_brush = SimpleNamespace(is_editable=True, library=None)
|
||||
linked_brush = SimpleNamespace(
|
||||
|
||||
@@ -26,6 +26,17 @@ def test_new_cutout_does_not_reuse_a_saved_wrapper():
|
||||
assert any(item.name == "Shape" for item in saved.interface.items_tree)
|
||||
|
||||
|
||||
def test_depth_cutout_loads_boundary_blur_revision_and_preserves_existing_group():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
saved = bpy.data.node_groups.new("O Image Depth Cutout", "GeometryNodeTree")
|
||||
saved["anyimage_asset_revision"] = 11
|
||||
current = load_cutout_node_group("DEPTH_SOLID")
|
||||
assert current != saved and current["anyimage_asset_revision"] == 18
|
||||
assert saved in bpy.data.node_groups.values()
|
||||
assert saved["anyimage_asset_revision"] == 11
|
||||
assert load_cutout_node_group("DEPTH_SOLID") == current
|
||||
|
||||
|
||||
def test_refined_boundary_ears_have_interior_balloon_samples():
|
||||
points = np.array([(0, 0), (1, 0), (1, 1), (0, 1)], dtype=float)
|
||||
points, faces, boundary = refine_balloon_triangles(points, [(0, 1, 2), (0, 2, 3)], np.ones(4, bool))
|
||||
|
||||
@@ -46,7 +46,8 @@ def test_cutout_node_groups_are_loaded_from_the_asset():
|
||||
) as load:
|
||||
for shape in cutout_shape.CUTOUT_NODE_GROUP_NAMES:
|
||||
assert cutout_object.load_cutout_node_group(shape) is loaded
|
||||
load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape], asset_revision=11)
|
||||
load.assert_called_with(cutout_shape.CUTOUT_NODE_GROUP_NAMES[shape],
|
||||
asset_revision=18 if shape == "DEPTH_SOLID" else 11)
|
||||
assert load.call_count == 4
|
||||
|
||||
|
||||
|
||||
@@ -187,7 +187,11 @@ def test_cutout_presets_load_three_direct_modifier_assets():
|
||||
assert inputs["Mode"].default_value == "Balloon"
|
||||
assert inputs["Smooth"].default_value == 0
|
||||
assert inputs["Smooth Weight"].default_value == 1
|
||||
assert "Edge Smooth" not in inputs
|
||||
if shape == "DEPTH_SOLID":
|
||||
assert inputs["Boundary Smooth"].default_value == 5
|
||||
assert inputs["Boundary Smooth"].parent.name == "Options"
|
||||
else:
|
||||
assert "Boundary Smooth" not in inputs
|
||||
assert not any(node.bl_idname == "GeometryNodeRepeatInput" and node.inputs["Iterations"].default_value == 8 for node in group.nodes)
|
||||
assert next(item for item in groups["DEPTH_BALLOON"].interface.items_tree if item.name == "Double Sided").default_value
|
||||
|
||||
@@ -424,17 +428,17 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
|
||||
assert np.ptp(positions[:, 2]) < 1e-4
|
||||
|
||||
|
||||
def test_depth_surface_split_removes_isolated_triangle_without_loose_geometry():
|
||||
def test_depth_surface_removes_boundary_ears_without_loose_geometry():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
obj, modifier = _depth_surface_step_object()
|
||||
split_id = object_data.modifier_input_identifier(modifier.node_group, "Split Threshold")
|
||||
# The raised corner becomes an isolated triangle after separation.
|
||||
for value, expected in ((0., (9, 8)), (.5, (8, 7)), (1., (8, 7))):
|
||||
# Both outer ears have three boundary vertices, including the raised corner.
|
||||
for value in (0., .5, 1.):
|
||||
object_data.set_modifier_input(modifier, split_id, value)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
vertices, faces, loose_edges, loose_points = _evaluated_topology(obj)
|
||||
assert (vertices, faces) == expected
|
||||
assert (vertices, faces) == (7, 6)
|
||||
assert not loose_edges and not loose_points
|
||||
|
||||
|
||||
|
||||
@@ -81,12 +81,17 @@ def artifacts(tmp_path, source):
|
||||
|
||||
def test_result_replaces_source_with_zero_rotation_and_shadeless_material(source, artifacts, monkeypatch):
|
||||
result, operator = artifacts
|
||||
saved = bpy.data.node_groups.new("O Image Depth Panorama", "GeometryNodeTree")
|
||||
saved["anyimage_asset_revision"] = 11
|
||||
matrix = Matrix.Translation((2, -3, 4)) @ Matrix.Rotation(0.6, 4, "Y") @ Matrix.Diagonal((2, 3, 4, 1))
|
||||
source.matrix_world = matrix
|
||||
source.empty_image_offset = (0.3, -0.8)
|
||||
undo = Mock(return_value={"FINISHED"})
|
||||
monkeypatch.setattr(conversion, "bpy", SimpleNamespace(data=bpy.data, ops=SimpleNamespace(ed=SimpleNamespace(undo_push=undo))))
|
||||
obj = conversion.create_panorama_from_result(bpy.context, result, operator)
|
||||
assert obj.modifiers[0].node_group != saved
|
||||
assert obj.modifiers[0].node_group["anyimage_asset_revision"] == 14
|
||||
assert saved in bpy.data.node_groups.values() and saved["anyimage_asset_revision"] == 11
|
||||
assert obj.name == operator.source_object_name and obj.type == "MESH"
|
||||
np.testing.assert_allclose(obj.location, matrix.translation, atol=1e-6)
|
||||
np.testing.assert_allclose(obj.scale, matrix.to_scale(), atol=1e-6)
|
||||
|
||||
@@ -11,7 +11,7 @@ def test_conversion_preserves_offset_world_placement_and_initializes_inputs(surf
|
||||
|
||||
obj, _, _, inputs, image = surface
|
||||
group = obj.modifiers[0].node_group
|
||||
monkeypatch.setattr(conversion, "load_node_group", lambda name: group)
|
||||
monkeypatch.setattr(conversion, "load_node_group", lambda name, *, asset_revision: group)
|
||||
source = bpy.data.objects.new("Offset Image", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
@@ -48,7 +48,7 @@ def test_failed_modifier_initialization_removes_partial_object(surface, monkeypa
|
||||
from anyimage.operators.convert_to_plane import object as conversion
|
||||
|
||||
obj, _, _, _, image = surface
|
||||
monkeypatch.setattr(conversion, "load_node_group", lambda name: obj.modifiers[0].node_group)
|
||||
monkeypatch.setattr(conversion, "load_node_group", lambda name, *, asset_revision: obj.modifiers[0].node_group)
|
||||
source = bpy.data.objects.new("Source", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
|
||||
@@ -73,7 +73,8 @@ def test_clipboard_plane_uses_the_mesh_material_slot():
|
||||
|
||||
def test_converted_planes_center_the_origin_without_moving_world_corners():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
build_image_depth_plane_group(build_image_plane_group())
|
||||
saved = build_image_depth_plane_group(build_image_plane_group())
|
||||
saved["anyimage_asset_revision"] = 11
|
||||
|
||||
def create_source(name):
|
||||
image = bpy.data.images.new(name, width=200, height=100)
|
||||
@@ -149,6 +150,9 @@ def test_converted_planes_center_the_origin_without_moving_world_corners():
|
||||
assert_centered(depth_plane, depth_bounds, depth_matrix)
|
||||
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
|
||||
depth_modifier = depth_plane.modifiers[0]
|
||||
assert depth_modifier.node_group != saved
|
||||
assert depth_modifier.node_group["anyimage_asset_revision"] == 14
|
||||
assert saved in bpy.data.node_groups.values() and saved["anyimage_asset_revision"] == 11
|
||||
uniform_scale = depth_modifier[
|
||||
object_data.modifier_input_identifier(
|
||||
depth_modifier.node_group,
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
from server.geometry import prediction_artifacts as artifacts
|
||||
from server.geometry.edge_depth import extend_edge_depth
|
||||
from server.models.geometry import GeometryFrame
|
||||
from server.models.onnx_moge2 import depth_to_points
|
||||
|
||||
|
||||
def make_frame(depth):
|
||||
depth = np.asarray(depth, dtype=np.float32)
|
||||
height, width = depth.shape
|
||||
normalized = np.array([[1.2, 0, 0.4], [0, 1.4, 0.6], [0, 0, 1]], np.float32)
|
||||
intrinsics = normalized.copy()
|
||||
intrinsics[0] *= width
|
||||
intrinsics[1] *= height
|
||||
return GeometryFrame(
|
||||
depth=depth,
|
||||
validity=np.ones_like(depth),
|
||||
intrinsics=intrinsics,
|
||||
points=depth_to_points(depth, normalized),
|
||||
normal=np.zeros((*depth.shape, 3), np.float32),
|
||||
)
|
||||
|
||||
|
||||
def silhouette():
|
||||
alpha = np.zeros((25, 35), np.float32)
|
||||
alpha[4:21, 4:22] = 1
|
||||
alpha[4:21, 22:25] = 0.2
|
||||
depth = np.full(alpha.shape, 20, np.float32)
|
||||
depth[alpha == 1] = 2
|
||||
return make_frame(depth), alpha
|
||||
|
||||
|
||||
def test_hair_extension_preserves_core_alpha_and_projection():
|
||||
frame, alpha = silhouette()
|
||||
original_alpha = alpha.copy()
|
||||
result = extend_edge_depth(frame, alpha)
|
||||
assert np.all(result.depth[alpha >= 0.1] == 2)
|
||||
np.testing.assert_array_equal(result.points[9:16, 9:16], frame.points[9:16, 9:16])
|
||||
np.testing.assert_array_equal(alpha, original_alpha)
|
||||
np.testing.assert_array_equal(frame.depth[alpha == 0.2], 20)
|
||||
np.testing.assert_allclose(
|
||||
result.points[..., :2] / result.points[..., 2:],
|
||||
frame.points[..., :2] / frame.points[..., 2:],
|
||||
atol=1e-6,
|
||||
)
|
||||
assert result.validity is frame.validity
|
||||
assert result.normal is frame.normal
|
||||
assert np.isfinite(result.points).all()
|
||||
|
||||
|
||||
def test_sampling_padding_is_bounded():
|
||||
frame, alpha = silhouette()
|
||||
result = extend_edge_depth(frame, alpha, alpha_threshold=0.1)
|
||||
assert np.all(result.depth[12, 24:29] == 2)
|
||||
assert result.depth[12, 29] == 20
|
||||
assert (result.depth[12, 24] + result.depth[12, 25]) / 2 == 2
|
||||
|
||||
|
||||
def test_edge_padding_tracks_selected_threshold():
|
||||
frame, alpha = silhouette()
|
||||
loose = extend_edge_depth(frame, alpha, alpha_threshold=0.1)
|
||||
strict = extend_edge_depth(frame, alpha)
|
||||
assert loose.depth[12, 28] == 2
|
||||
assert strict.depth[12, 28] == 20
|
||||
|
||||
|
||||
def test_strict_threshold_excludes_lower_alpha_donors():
|
||||
alpha = np.zeros((10, 10), np.float32)
|
||||
alpha[3:7, 3:7] = 0.96
|
||||
alpha[5, 5] = 1
|
||||
depth = np.full(alpha.shape, 10, np.float32)
|
||||
depth[5, 5] = 2
|
||||
result = extend_edge_depth(make_frame(depth), alpha, alpha_threshold=1)
|
||||
assert result.depth[5, 6] == 2
|
||||
|
||||
|
||||
def test_image_rectangle_does_not_erode_subject():
|
||||
alpha = np.ones((15, 20), np.float32)
|
||||
alpha[:, 15:] = 0
|
||||
depth = np.full(alpha.shape, 2, np.float32)
|
||||
depth[0, 0] = 3
|
||||
frame = make_frame(depth)
|
||||
result = extend_edge_depth(frame, alpha)
|
||||
assert result.depth[0, 0] == 3
|
||||
np.testing.assert_array_equal(result.points[0, 0], frame.points[0, 0])
|
||||
|
||||
|
||||
def test_small_components_fall_back_without_borrowing_other_depths():
|
||||
alpha = np.zeros((15, 30), np.float32)
|
||||
alpha[5, 5:7] = [0.95, 0.1]
|
||||
alpha[5, 10:12] = [1, 0.2]
|
||||
alpha[5, 14] = 0.94
|
||||
depth = np.full(alpha.shape, 30, np.float32)
|
||||
depth[5, 5] = 2
|
||||
depth[5, 10] = 8
|
||||
result = extend_edge_depth(make_frame(depth), alpha)
|
||||
assert result.depth[5, 6] == 2
|
||||
assert result.depth[5, 11] == 8
|
||||
assert result.depth[5, 14] == 30
|
||||
assert result.depth[5, 7] == 2
|
||||
assert result.depth[5, 9] == 8
|
||||
|
||||
|
||||
def test_diagonal_pixels_share_a_component():
|
||||
alpha = np.zeros((10, 10), np.float32)
|
||||
alpha[4, 4] = 1
|
||||
alpha[5, 5] = 0.2
|
||||
depth = np.full(alpha.shape, 10, np.float32)
|
||||
depth[4, 4] = 2
|
||||
result = extend_edge_depth(make_frame(depth), alpha)
|
||||
assert result.depth[5, 5] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("validity", [0.0, 0.2, 0.5])
|
||||
def test_invalid_donors_are_excluded(validity):
|
||||
frame, alpha = silhouette()
|
||||
frame.depth[10, 6] = 100
|
||||
frame.validity[10, 6] = validity
|
||||
result = extend_edge_depth(frame, alpha)
|
||||
assert result.depth[10, 4] == 2
|
||||
assert result.depth[10, 6] == 100
|
||||
assert result.validity[10, 6] == np.float32(validity)
|
||||
|
||||
|
||||
def test_internal_mask_hole_does_not_expand_alpha_repair():
|
||||
frame, alpha = silhouette()
|
||||
frame.depth[9:14, 10:15] = 100
|
||||
frame.validity[9:14, 10:15] = 0
|
||||
original = frame.depth.copy()
|
||||
result = extend_edge_depth(frame, alpha)
|
||||
np.testing.assert_array_equal(result.depth[6:19, 6:20], original[6:19, 6:20])
|
||||
assert result.depth[12, 24] == 2
|
||||
|
||||
|
||||
def test_opaque_image_keeps_low_mask_depth():
|
||||
frame = make_frame(np.ones((10, 10)))
|
||||
frame.depth[5, 5] = 100
|
||||
frame.validity[5, 5] = 0
|
||||
assert extend_edge_depth(frame, np.ones((10, 10))) is frame
|
||||
|
||||
|
||||
def test_component_without_valid_donors_keeps_its_depth():
|
||||
frame, alpha = silhouette()
|
||||
frame.validity[alpha > 0] = 0
|
||||
assert extend_edge_depth(frame, alpha) is frame
|
||||
|
||||
|
||||
def test_local_smoothing_reduces_donor_step_and_preserves_core():
|
||||
frame, alpha = silhouette()
|
||||
frame.depth[12:, alpha.shape[1] // 4:22] = 4
|
||||
result = extend_edge_depth(frame, alpha)
|
||||
assert 2 < result.depth[11, 23] < 4
|
||||
assert 2 < result.depth[12, 23] < 4
|
||||
np.testing.assert_array_equal(result.depth[8:17, 8:17], frame.depth[8:17, 8:17])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha_value", [0, 0.3, 1])
|
||||
def test_images_without_repair_return_original_frame(alpha_value):
|
||||
frame = make_frame(np.ones((5, 8)))
|
||||
assert extend_edge_depth(frame, np.full((5, 8), alpha_value)) is frame
|
||||
|
||||
|
||||
def test_alpha_resize_retains_threshold_values(tmp_path):
|
||||
rgba = np.zeros((1, 4, 4), np.uint8)
|
||||
rgba[0, :, 3] = [25, 26, 242, 243]
|
||||
path = tmp_path / "alpha.png"
|
||||
Image.fromarray(rgba).save(path)
|
||||
alpha = artifacts._image_alpha(path, (2, 8))
|
||||
np.testing.assert_array_equal(
|
||||
alpha >= 0.1, [[False] * 2 + [True] * 6] * 2
|
||||
)
|
||||
np.testing.assert_array_equal(
|
||||
alpha >= 0.95, [[False] * 6 + [True] * 2] * 2
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"generate_depth,normal_mode",
|
||||
[
|
||||
(True, "OBJECT"),
|
||||
(True, "TANGENT"),
|
||||
(True, "NONE"),
|
||||
(False, "OBJECT"),
|
||||
],
|
||||
)
|
||||
def test_artifacts_use_corrected_depth_and_original_normals(
|
||||
tmp_path, generate_depth, normal_mode
|
||||
):
|
||||
frame, alpha = silhouette()
|
||||
rgba = np.full((*alpha.shape, 4), 255, np.uint8)
|
||||
rgba[..., 3] = np.round(alpha * 255).astype(np.uint8)
|
||||
path = tmp_path / "source.png"
|
||||
Image.fromarray(rgba).save(path)
|
||||
context = SimpleNamespace(
|
||||
directory=tmp_path,
|
||||
resource=lambda _: None,
|
||||
progress=lambda *_: None,
|
||||
check_cancelled=lambda: None,
|
||||
)
|
||||
with (
|
||||
patch.object(artifacts.moge, "infer", return_value=[frame]),
|
||||
patch.object(
|
||||
artifacts, "write_depth_texture",
|
||||
return_value=tmp_path / "depth.exr",
|
||||
) as depth,
|
||||
patch.object(
|
||||
artifacts, "write_depth_metadata", return_value=tmp_path / "depth.json"
|
||||
) as metadata,
|
||||
patch.object(
|
||||
artifacts, "_write_normal", return_value=tmp_path / "normal.png"
|
||||
) as normal,
|
||||
patch.object(artifacts, "extend_edge_depth", wraps=extend_edge_depth) as repair,
|
||||
):
|
||||
artifacts.generate_moge_artifacts(
|
||||
context, {}, path, generate_depth=generate_depth, normal_mode=normal_mode,
|
||||
)
|
||||
if normal_mode != "NONE":
|
||||
assert normal.call_args.args[0] is frame
|
||||
else:
|
||||
normal.assert_not_called()
|
||||
assert repair.call_count == int(generate_depth)
|
||||
if generate_depth:
|
||||
written_frame = depth.call_args.args[0]
|
||||
np.testing.assert_array_equal(depth.call_args.kwargs["alpha"], alpha)
|
||||
assert metadata.call_args.args[0] is written_frame
|
||||
assert written_frame.depth[12, 24] == 2
|
||||
assert written_frame.validity is frame.validity
|
||||
else:
|
||||
metadata.assert_not_called()
|
||||
depth.assert_not_called()
|
||||
104
tests/anyimage/server/test_prediction_artifacts.py
Normal file
104
tests/anyimage/server/test_prediction_artifacts.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
from server.geometry import prediction_artifacts as artifacts
|
||||
from server.models.geometry import GeometryFrame
|
||||
from server.models.onnx_moge2 import depth_to_points
|
||||
|
||||
|
||||
def make_frame(depth):
|
||||
depth = np.asarray(depth, dtype=np.float32)
|
||||
height, width = depth.shape
|
||||
normalized = np.array([[1.2, 0, 0.4], [0, 1.4, 0.6], [0, 0, 1]], np.float32)
|
||||
intrinsics = normalized.copy()
|
||||
intrinsics[0] *= width
|
||||
intrinsics[1] *= height
|
||||
return GeometryFrame(
|
||||
depth=depth,
|
||||
validity=np.ones_like(depth),
|
||||
intrinsics=intrinsics,
|
||||
points=depth_to_points(depth, normalized),
|
||||
normal=np.zeros((*depth.shape, 3), np.float32),
|
||||
)
|
||||
|
||||
|
||||
def silhouette():
|
||||
alpha = np.zeros((25, 35), np.float32)
|
||||
alpha[4:21, 4:22] = 1
|
||||
alpha[4:21, 22:25] = 0.2
|
||||
depth = np.full(alpha.shape, 20, np.float32)
|
||||
depth[alpha == 1] = 2
|
||||
return make_frame(depth), alpha
|
||||
|
||||
|
||||
def test_alpha_resize_retains_threshold_values(tmp_path):
|
||||
rgba = np.zeros((1, 4, 4), np.uint8)
|
||||
rgba[0, :, 3] = [25, 26, 242, 243]
|
||||
path = tmp_path / "alpha.png"
|
||||
Image.fromarray(rgba).save(path)
|
||||
alpha = artifacts._image_alpha(path, (2, 8))
|
||||
np.testing.assert_array_equal(
|
||||
alpha >= 0.1, [[False] * 2 + [True] * 6] * 2
|
||||
)
|
||||
np.testing.assert_array_equal(
|
||||
alpha >= 0.95, [[False] * 6 + [True] * 2] * 2
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"generate_depth,normal_mode",
|
||||
[
|
||||
(True, "OBJECT"),
|
||||
(True, "TANGENT"),
|
||||
(True, "NONE"),
|
||||
(False, "OBJECT"),
|
||||
],
|
||||
)
|
||||
def test_artifacts_preserve_original_geometry_with_alpha(
|
||||
tmp_path, generate_depth, normal_mode
|
||||
):
|
||||
frame, alpha = silhouette()
|
||||
rgba = np.full((*alpha.shape, 4), 255, np.uint8)
|
||||
rgba[..., 3] = np.round(alpha * 255).astype(np.uint8)
|
||||
path = tmp_path / "source.png"
|
||||
Image.fromarray(rgba).save(path)
|
||||
context = SimpleNamespace(
|
||||
directory=tmp_path,
|
||||
resource=lambda _: None,
|
||||
progress=lambda *_: None,
|
||||
check_cancelled=lambda: None,
|
||||
)
|
||||
with (
|
||||
patch.object(artifacts.moge, "infer", return_value=[frame]),
|
||||
patch.object(
|
||||
artifacts, "write_depth_texture",
|
||||
return_value=tmp_path / "depth.exr",
|
||||
) as depth,
|
||||
patch.object(
|
||||
artifacts, "write_depth_metadata", return_value=tmp_path / "depth.json"
|
||||
) as metadata,
|
||||
patch.object(
|
||||
artifacts, "_write_normal", return_value=tmp_path / "normal.png"
|
||||
) as normal,
|
||||
):
|
||||
artifacts.generate_moge_artifacts(
|
||||
context, {}, path, generate_depth=generate_depth, normal_mode=normal_mode,
|
||||
)
|
||||
if normal_mode != "NONE":
|
||||
assert normal.call_args.args[0] is frame
|
||||
else:
|
||||
normal.assert_not_called()
|
||||
if generate_depth:
|
||||
written_frame = depth.call_args.args[0]
|
||||
np.testing.assert_array_equal(depth.call_args.kwargs["alpha"], alpha)
|
||||
assert metadata.call_args.args[0] is written_frame
|
||||
assert written_frame is frame
|
||||
assert written_frame.depth[12, 24] == 20
|
||||
assert written_frame.validity is frame.validity
|
||||
else:
|
||||
metadata.assert_not_called()
|
||||
depth.assert_not_called()
|
||||
@@ -118,6 +118,21 @@ class PreferencesTest(BlenderTestCase):
|
||||
geometry = properties.geometry_model_enum_items(None, None)
|
||||
self.assertEqual(tuple(item[0] for item in geometry), expected)
|
||||
|
||||
def test_model_tiers_only_appear_in_choices(self):
|
||||
properties = self.anyimage.properties
|
||||
preferences = self.anyimage.preferences
|
||||
catalog = {model["key"]: model for model in properties.model_catalog()}
|
||||
for items in (
|
||||
properties.geometry_model_enum_items(None, None),
|
||||
preferences.BACKGROUND_MODEL_ITEMS,
|
||||
preferences.UPSCALE_MODEL_ITEMS,
|
||||
):
|
||||
for item, tier in zip(items, ("Fast", "Base", "Pro")):
|
||||
label = catalog[item[0]]["label"]
|
||||
self.assertNotIn(" | ", label)
|
||||
self.assertEqual(item[1], f"{tier} | {label}")
|
||||
self.assertEqual(properties.upscale_model_label("HAT_GAN_X4_SHARPER"), "HAT Sharper")
|
||||
|
||||
def test_model_preferences_keep_valid_choices_and_normalize_retired_keys(self):
|
||||
module = self.anyimage.preferences
|
||||
stored = self.fake_bpy.context.preferences.addons["anyimage"].preferences
|
||||
|
||||
Reference in New Issue
Block a user