Add comprehensive tests for image processing and mask functionalities
- Introduced tests for color image handling, ensuring source preservation and premultiplied alpha usage. - Implemented tests for brush stroke operations, validating segment boundaries and brush anti-aliasing. - Added tests for frame anti-aliasing, confirming correct rendering behavior with various alpha values and depth settings. - Created tests for frame projection, ensuring accurate overlap calculations and half-plane clipping. - Developed tests for image premultiplied retention, verifying behavior during loading and packing processes. - Added undo functionality tests for mask operations, ensuring single transaction behavior during edits.
This commit is contained in:
229
tests/test_alpha_presentation.py
Normal file
229
tests/test_alpha_presentation.py
Normal file
@@ -0,0 +1,229 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
|
||||
|
||||
from anyimage.common import image as images
|
||||
from anyimage.operators.convert_to_plane.object import create_color_image
|
||||
from anyimage.operators.cutout_tool.object import create_cropped_color_image
|
||||
|
||||
|
||||
@pytest.mark.parametrize("floating", [False, True])
|
||||
def test_owned_color_images_preserve_source_and_use_premul(floating):
|
||||
original = bpy.data.images.new("Alpha source", width=2, height=2, alpha=True, float_buffer=floating)
|
||||
rgba = np.tile(np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (2, 2, 1))
|
||||
original.pixels.foreach_set(rgba.ravel())
|
||||
source = SimpleNamespace(data=original)
|
||||
expected = images.image_rgba(original)
|
||||
copied = create_color_image(source)
|
||||
cropped = create_cropped_color_image(source, (0, 0, 2, 2), expected)
|
||||
edited = images.create_image_edit_result(original, rgba.ravel(), (2, 2))
|
||||
try:
|
||||
assert original.alpha_mode == "STRAIGHT"
|
||||
np.testing.assert_allclose(images.image_rgba(original), expected)
|
||||
for image in (copied, cropped, edited):
|
||||
assert image.alpha_mode == "PREMUL"
|
||||
np.testing.assert_allclose(images.image_rgba(image), expected, atol=1 / 255)
|
||||
finally:
|
||||
for image in (copied, cropped, edited, original):
|
||||
bpy.data.images.remove(image)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loader", [images.load_image_edit_result, images.load_color_result_image])
|
||||
def test_job_color_loader_defaults_to_premul(tmp_path, loader):
|
||||
rgba = np.array([[[51, 102, 204, 64]]], dtype=np.uint8)
|
||||
Image.fromarray(rgba).save(tmp_path / "image.0001.png")
|
||||
original = bpy.data.images.new("Job source", width=1, height=1, alpha=True)
|
||||
result = loader(SimpleNamespace(data=original), tmp_path)
|
||||
try:
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
assert result.packed_file is not None
|
||||
assert original.alpha_mode == "STRAIGHT"
|
||||
finally:
|
||||
bpy.data.images.remove(result)
|
||||
bpy.data.images.remove(original)
|
||||
|
||||
|
||||
def test_failed_empty_replacement_restores_blending_and_timing():
|
||||
original = SimpleNamespace(name="Original", use_fake_user=False, users=1)
|
||||
timing = SimpleNamespace(frame_start=3, frame_offset=2, frame_duration=7, use_cyclic=True)
|
||||
source = SimpleNamespace(data=original, use_empty_image_alpha=False, image_user=timing)
|
||||
result = SimpleNamespace(source="SEQUENCE")
|
||||
|
||||
def fail_configuration(*args, **kwargs):
|
||||
timing.frame_start = 99
|
||||
raise RuntimeError("Failed configuration")
|
||||
|
||||
with patch.object(images, "video_total_frames", return_value=3), patch.object(
|
||||
images, "configure_image_user", side_effect=fail_configuration
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="Failed configuration"):
|
||||
images.replace_empty_image(source, result)
|
||||
assert source.data is original
|
||||
assert not source.use_empty_image_alpha
|
||||
assert not original.use_fake_user
|
||||
assert images.image_user_settings(source) == (3, 2, 7, True)
|
||||
|
||||
|
||||
def test_business_pixels_ignore_premul_read_interpretation():
|
||||
original = bpy.data.images.new("Premul read source", width=1, height=1, alpha=True)
|
||||
expected = np.array([0.4, 0.25, 0.1, 0.5], dtype=np.float32)
|
||||
result = images.create_image_edit_result(original, expected, (1, 1))
|
||||
try:
|
||||
result.reload()
|
||||
interpreted = np.empty(4, dtype=np.float32)
|
||||
result.pixels.foreach_get(interpreted)
|
||||
assert interpreted[0] > expected[0]
|
||||
np.testing.assert_allclose(images.image_pixels(result), expected, atol=1 / 255)
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
finally:
|
||||
bpy.data.images.remove(result)
|
||||
bpy.data.images.remove(original)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("dirty", [False, True])
|
||||
def test_business_read_preserves_shared_packed_source(dirty):
|
||||
original = bpy.data.images.new("Read integrity", width=1, height=1, alpha=True)
|
||||
result = images.create_image_edit_result(original, np.array([0.2, 0.4, 0.8, 0.25], dtype=np.float32), (1, 1))
|
||||
owner = bpy.data.objects.new("Read owner", None)
|
||||
owner.empty_display_type = "IMAGE"
|
||||
owner.data = result
|
||||
try:
|
||||
result.reload()
|
||||
if dirty:
|
||||
result.pixels.foreach_set([0.9, 0.1, 0.3, 0.5])
|
||||
result.update()
|
||||
before = np.array(result.pixels[:])
|
||||
packed = bytes(result.packed_file.data)
|
||||
state = (result.alpha_mode, result.is_dirty, result.users)
|
||||
count = len(bpy.data.images)
|
||||
for _ in range(3):
|
||||
pixels = images.image_pixels(result)
|
||||
np.testing.assert_allclose(pixels, before if dirty else [0.2, 0.4, 0.8, 0.25], atol=1 / 255)
|
||||
np.testing.assert_array_equal(result.pixels[:], before)
|
||||
assert (result.alpha_mode, result.is_dirty, result.users) == state
|
||||
assert bytes(result.packed_file.data) == packed
|
||||
assert owner.data == result
|
||||
assert len(bpy.data.images) == count
|
||||
finally:
|
||||
bpy.data.objects.remove(owner)
|
||||
bpy.data.images.remove(result)
|
||||
bpy.data.images.remove(original)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("floating", [False, True])
|
||||
def test_generated_premul_reads_current_pixels(floating):
|
||||
source = bpy.data.images.new("Generated read", width=1, height=1, alpha=True, float_buffer=floating)
|
||||
try:
|
||||
source.alpha_mode = "PREMUL"
|
||||
source.pixels.foreach_set([0.2, 0.4, 0.8, 0.25])
|
||||
before = np.array(source.pixels[:])
|
||||
np.testing.assert_array_equal(images.image_pixels(source), before)
|
||||
np.testing.assert_array_equal(source.pixels[:], before)
|
||||
assert source.alpha_mode == "PREMUL"
|
||||
finally:
|
||||
bpy.data.images.remove(source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("packed", [False, True])
|
||||
@pytest.mark.parametrize("floating", [False, True])
|
||||
@pytest.mark.parametrize("dirty", [False, True])
|
||||
def test_file_read_preserves_byte_and_native_float_cache(tmp_path, packed, floating, dirty):
|
||||
path = tmp_path / ("source.exr" if floating else "source.png")
|
||||
if floating:
|
||||
generated = bpy.data.images.new("EXR source", width=1, height=1, alpha=True, float_buffer=True)
|
||||
try:
|
||||
generated.pixels.foreach_set([0.2, 0.4, 0.8, 0.25])
|
||||
generated.file_format = "OPEN_EXR"
|
||||
generated.filepath_raw = str(path)
|
||||
generated.save()
|
||||
finally:
|
||||
bpy.data.images.remove(generated)
|
||||
else:
|
||||
Image.fromarray(np.array([[[51, 102, 204, 64]]], dtype=np.uint8)).save(path)
|
||||
source = bpy.data.images.load(str(path), check_existing=False)
|
||||
try:
|
||||
if packed:
|
||||
source.pack()
|
||||
source.alpha_mode = "PREMUL"
|
||||
if dirty:
|
||||
source.pixels.foreach_set([0.9, 0.1, 0.3, 0.5])
|
||||
source.update()
|
||||
before = np.array(source.pixels[:])
|
||||
assert source.is_dirty == dirty
|
||||
assert source.is_float == floating
|
||||
expected = before if floating or dirty else np.array([51, 102, 204, 64]) / 255
|
||||
count = len(bpy.data.images)
|
||||
np.testing.assert_allclose(images.image_pixels(source), expected, atol=1e-7)
|
||||
np.testing.assert_array_equal(source.pixels[:], before)
|
||||
assert source.is_dirty == dirty and source.alpha_mode == "PREMUL"
|
||||
assert len(bpy.data.images) == count
|
||||
finally:
|
||||
bpy.data.images.remove(source)
|
||||
|
||||
|
||||
def test_isolated_decode_failure_releases_copy():
|
||||
class FailedReader:
|
||||
@property
|
||||
def alpha_mode(self):
|
||||
return "PREMUL"
|
||||
|
||||
@alpha_mode.setter
|
||||
def alpha_mode(self, value):
|
||||
raise RuntimeError("Decode failed")
|
||||
|
||||
reader = FailedReader()
|
||||
source = SimpleNamespace(alpha_mode="PREMUL", is_dirty=False, is_float=False,
|
||||
source="FILE", copy=lambda: reader)
|
||||
with patch.object(images, "bpy", SimpleNamespace(data=SimpleNamespace(images=SimpleNamespace(remove=lambda value: removed.append(value))))):
|
||||
removed = []
|
||||
with pytest.raises(RuntimeError, match="Decode failed"):
|
||||
images.image_pixels(source)
|
||||
assert removed == [reader]
|
||||
assert source.alpha_mode == "PREMUL"
|
||||
|
||||
|
||||
def test_mask_pack_reopen_add_and_repeat_frame_preserve_hidden_color(tmp_path):
|
||||
from anyimage.common.selection import SelectionMask
|
||||
from anyimage.operators.image_edit_tool.mask import apply_alpha_mask
|
||||
from anyimage.operators.image_edit_tool import frame
|
||||
|
||||
source = bpy.data.images.new("Roundtrip source", width=2, height=2, alpha=True)
|
||||
rgba = np.tile(np.array([0.2, 0.4, 0.8, 1], dtype=np.float32), (2, 2, 1))
|
||||
full = SelectionMask(np.ones((2, 2)), (0, 0, 2, 2))
|
||||
result = images.create_image_edit_result(source, apply_alpha_mask(rgba, full, "SUBTRACT"), (2, 2))
|
||||
result.use_fake_user = True
|
||||
name = result.name
|
||||
bpy.data.images.remove(source)
|
||||
path = str(tmp_path / "mask.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=path)
|
||||
bpy.ops.wm.open_mainfile(filepath=path)
|
||||
result = bpy.data.images[name]
|
||||
try:
|
||||
restored = images.image_rgba(result)
|
||||
np.testing.assert_allclose(restored[..., :3], rgba[..., :3], atol=1 / 255)
|
||||
np.testing.assert_array_equal(restored[..., 3], 0)
|
||||
added = images.create_image_edit_result(result, apply_alpha_mask(restored, full, "ADD"), (2, 2))
|
||||
bpy.data.images.remove(result)
|
||||
result = added
|
||||
for _ in range(2):
|
||||
source = frame.frame_source_projection(np.eye(4), np.eye(4), np.eye(4), (-1, 1, -1, 1), (2, 2), perspective=False)
|
||||
rgba_read = images.image_rgba(result)
|
||||
source.update(rgba=rgba_read, pixels=images.premultiplied_rgba(images.image_pixels(result), (2, 2)),
|
||||
image_size=(2, 2), active=True, name=result.name)
|
||||
pixels = frame.composite_frame_pixels((source,), ((0, 2), (2, 2), (2, 0), (0, 0)), (2, 2))
|
||||
next_result = images.create_image_edit_result(result, pixels, (2, 2))
|
||||
bpy.data.images.remove(result)
|
||||
result = next_result
|
||||
result.reload()
|
||||
assert result.alpha_mode == "PREMUL" and result.packed_file is not None
|
||||
np.testing.assert_allclose(images.image_rgba(result), rgba, atol=1 / 255)
|
||||
finally:
|
||||
bpy.data.images.remove(result)
|
||||
File diff suppressed because it is too large
Load Diff
132
tests/test_brush_strokes.py
Normal file
132
tests/test_brush_strokes.py
Normal file
@@ -0,0 +1,132 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
|
||||
|
||||
from anyimage.common import selection, viewport
|
||||
from anyimage.operators.image_edit_tool import mask
|
||||
|
||||
|
||||
def test_simplified_segments_bound_every_original_sample():
|
||||
raw = [(float(x), float(20 * np.sin(x / 15))) for x in np.arange(0, 160, 0.2)]
|
||||
path, samples = [], []
|
||||
for point in raw:
|
||||
viewport.append_lasso_point(path, point, samples)
|
||||
assert len(path) < len(raw) / 5
|
||||
for start, end in zip(path, path[1:]):
|
||||
segment = np.array(end) - start
|
||||
points = np.array([point for point in raw if start[0] <= point[0] <= end[0]])
|
||||
factor = np.clip((points - start) @ segment / (segment @ segment), 0, 1)
|
||||
distances = np.linalg.norm(points - (np.array(start) + factor[:, None] * segment), axis=1)
|
||||
assert distances.max() <= 0.75 + 1e-10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("radius", [2, 5, 25])
|
||||
@pytest.mark.parametrize("path", [
|
||||
((40, 70), (70, 70), (70, 100)),
|
||||
((35, 35), (100, 100), (35, 100), (100, 35)),
|
||||
])
|
||||
def test_brush_antialiases_the_hard_union_once(radius, path):
|
||||
size = (150, 150)
|
||||
hard = np.zeros(size[::-1], dtype=bool)
|
||||
for polygon in viewport.brush_footprint_polygons(path, radius):
|
||||
hard |= selection._rasterize_path((0, 0, *size), polygon)
|
||||
result = mask.rasterize_brush_path(size, path, radius, lambda points: points)
|
||||
np.testing.assert_allclose(result.full_values(size), selection._antialias_mask(hard))
|
||||
assert result.values.size < size[0] * size[1]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("radius", [2, 5, 25])
|
||||
def test_straight_stroke_is_independent_of_segmentation(radius):
|
||||
size = (160, 160)
|
||||
sparse = ((40, 70), (110, 70))
|
||||
dense = tuple((x, 70) for x in range(40, 111, 5))
|
||||
results = [mask.rasterize_brush_path(size, path, radius, lambda p: p).full_values(size)
|
||||
for path in (sparse, dense)]
|
||||
np.testing.assert_array_equal(*results)
|
||||
|
||||
|
||||
def test_incremental_preview_matches_full_union_when_tail_moves_and_crosses():
|
||||
cache = viewport.BrushPreview()
|
||||
path = [(10, 10)]
|
||||
operations = [(False, (100, 100)), (True, (110, 90)), (False, (10, 90)),
|
||||
(False, (110, 10)), (True, (105, 15)), (False, (10, 10))]
|
||||
for replace, point in [(False, None), *operations]:
|
||||
if point is not None:
|
||||
if replace:
|
||||
path[-1] = point
|
||||
else:
|
||||
path.append(point)
|
||||
cache.update(path, 8)
|
||||
reference = {round(bottom / cache.step): intervals
|
||||
for bottom, _, intervals in viewport.scanline_union_bands(
|
||||
viewport.brush_footprint_polygons(path, 8), cache.step)
|
||||
if intervals}
|
||||
assert cache.coverage.keys() == reference.keys()
|
||||
for row in reference:
|
||||
np.testing.assert_allclose(cache.coverage[row], reference[row], atol=1e-10)
|
||||
|
||||
|
||||
def test_preview_reuses_batches_and_limits_updates_to_changed_rows():
|
||||
cache = viewport.BrushPreview()
|
||||
path = [(10, y) for y in range(0, 800, 10)]
|
||||
cache.update(path, 5)
|
||||
gpu = SimpleNamespace(shader=Mock(), state=Mock())
|
||||
build = Mock(side_effect=lambda *args: Mock())
|
||||
with patch.dict(sys.modules, {"gpu": gpu, "gpu_extras.batch": SimpleNamespace(batch_for_shader=build)}):
|
||||
cache.draw((1, 1, 1, 0.16))
|
||||
initial = dict(cache.batches)
|
||||
builds = build.call_count
|
||||
with patch.object(cache, "_polygon_rows", wraps=cache._polygon_rows) as rasterize:
|
||||
cache.update(path, 5)
|
||||
cache.draw((1, 1, 1, 0.16))
|
||||
rasterize.assert_not_called()
|
||||
assert build.call_count == builds
|
||||
path.append((10, 800))
|
||||
cache.update(path, 5)
|
||||
assert rasterize.call_count == 2
|
||||
assert all(len(call.args[0]) <= 2 for call in rasterize.call_args_list)
|
||||
cache.draw((1, 1, 1, 0.16))
|
||||
changed = {chunk for chunk, batches in initial.items() if cache.batches[chunk] is not batches}
|
||||
assert len(changed) <= 2
|
||||
assert len(initial) > 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exit_kind", ["ESC", "SWITCH", "ERROR", "COMPLETE"])
|
||||
def test_gesture_releases_preview_and_samples_on_every_exit(exit_kind):
|
||||
gesture = viewport.ImageGesture()
|
||||
gesture.gesture = "BRUSH"
|
||||
gesture.active_tool_id = "anyimage.mask"
|
||||
gesture._path = [(0, 0), (10, 10)]
|
||||
gesture._path_samples = list(gesture._path)
|
||||
gesture._brush_preview = viewport.BrushPreview()
|
||||
handle = gesture._handle = object()
|
||||
gesture._brush_preview.update(gesture._path, 5)
|
||||
context = SimpleNamespace(workspace=Mock(), area=Mock())
|
||||
event = SimpleNamespace(type="ESC" if exit_kind == "ESC" else "LEFTMOUSE",
|
||||
value="PRESS" if exit_kind == "ESC" else "RELEASE",
|
||||
mouse_region_x=10, mouse_region_y=10)
|
||||
gesture.report = Mock()
|
||||
def complete(context):
|
||||
if exit_kind == "ERROR":
|
||||
raise ValueError("Failed projection")
|
||||
gesture._finish(context)
|
||||
return {"FINISHED"}
|
||||
gesture._complete = complete
|
||||
tool = "builtin.select" if exit_kind == "SWITCH" else gesture.active_tool_id
|
||||
remove = Mock()
|
||||
fake_bpy = SimpleNamespace(types=SimpleNamespace(SpaceView3D=SimpleNamespace(draw_handler_remove=remove)))
|
||||
with patch.object(viewport, "active_view3d_tool_id", return_value=tool), patch.object(viewport, "bpy", fake_bpy):
|
||||
result = gesture.modal(context, event)
|
||||
gesture._finish(context)
|
||||
remove.assert_called_once_with(handle, "WINDOW")
|
||||
assert gesture._handle is None
|
||||
assert result == ({"FINISHED"} if exit_kind == "COMPLETE" else {"CANCELLED"})
|
||||
assert gesture._path is gesture._path_samples is gesture._brush_preview is None
|
||||
assert gesture._fill_triangles == gesture._outline_segments == ()
|
||||
assert viewport.ImageGesture()._brush_preview is None
|
||||
@@ -96,12 +96,12 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes():
|
||||
uv = []
|
||||
camera_depth = np.empty((3, 3), dtype=np.float32)
|
||||
for row in range(3):
|
||||
local_z = 1.0 - row
|
||||
local_y = 1.0 - row
|
||||
for column in range(3):
|
||||
local_y = column - 1.0
|
||||
vertices.append((0.0, local_y, local_z))
|
||||
local_x = column - 1.0
|
||||
vertices.append((local_x, local_y, 0.0))
|
||||
uv.append((column * 0.5, 1.0 - row * 0.5))
|
||||
camera_depth[row, column] = 2.0 + 0.2 * local_y + 0.1 * local_z
|
||||
camera_depth[row, column] = 2.0 + 0.2 * local_x + 0.1 * local_y
|
||||
|
||||
reference, direction = cutout_geometry.depth_balloon_projection_parameters(
|
||||
vertices,
|
||||
@@ -110,7 +110,7 @@ def test_depth_balloon_plane_calibration_matches_cutout_axes():
|
||||
np.ones((3, 3), dtype=bool),
|
||||
0.5,
|
||||
)
|
||||
expected_direction = np.asarray((-0.1, 0.05, 1.0), dtype=np.float64)
|
||||
expected_direction = np.asarray((0.1, 0.05, 1.0), dtype=np.float64)
|
||||
expected_direction /= np.linalg.norm(expected_direction)
|
||||
|
||||
assert np.isclose(
|
||||
@@ -130,7 +130,7 @@ def test_depth_balloon_reference_uses_the_shared_upper_depth_percentile():
|
||||
uv = []
|
||||
for y in range(size):
|
||||
for x in range(size):
|
||||
vertices.append((0.0, float(x), float(size - 1 - y)))
|
||||
vertices.append((float(x), float(size - 1 - y), 0.0))
|
||||
uv.append((x / (size - 1), 1.0 - y / (size - 1)))
|
||||
|
||||
reference, direction = cutout_geometry.depth_balloon_projection_parameters(
|
||||
|
||||
@@ -8,7 +8,7 @@ sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from anyimage.common.selection import SelectionPath
|
||||
from anyimage.common import viewport
|
||||
from anyimage.common.viewport import CropSelectionGesture
|
||||
from anyimage.common.viewport import ImageGesture
|
||||
from anyimage.operators.cutout_tool import interaction
|
||||
from anyimage.operators.cutout_tool import main as cutout_main
|
||||
|
||||
@@ -64,7 +64,6 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
|
||||
object(),
|
||||
source,
|
||||
SelectionPath(points=((0, 0), (10, 0), (0, 10))),
|
||||
refine_selection=True,
|
||||
)
|
||||
|
||||
assert [call[2].shape for call in window_manager.pie.calls] == [
|
||||
@@ -79,7 +78,6 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
|
||||
call[0] == "anyimage.cutout_selection_to_shape"
|
||||
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].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)
|
||||
@@ -111,9 +109,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
|
||||
source,
|
||||
SelectionPath(
|
||||
points=((0, 0), (10, 0), (0, 10)),
|
||||
invert=True,
|
||||
),
|
||||
refine_selection=True,
|
||||
)
|
||||
|
||||
assert [call[2].shape for call in window_manager.pie.calls] == [
|
||||
@@ -122,16 +118,16 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
|
||||
"DEPTH_BALLOON",
|
||||
"DEPTH_SURFACE",
|
||||
]
|
||||
assert all(call[2].refine_selection for call in window_manager.pie.calls)
|
||||
assert all(call[2].generate_normal for call in window_manager.pie.calls)
|
||||
assert len(serialized) == 1
|
||||
assert set(json.loads(serialized[0])) == {"points", "invert"}
|
||||
assert set(json.loads(serialized[0])) == {"points"}
|
||||
assert all(
|
||||
call[2].selection_path_json == serialized[0]
|
||||
for call in window_manager.pie.calls
|
||||
)
|
||||
assert all(
|
||||
SelectionPath.from_json(call[2].selection_path_json).invert
|
||||
SelectionPath.from_json(call[2].selection_path_json).points
|
||||
== ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0))
|
||||
for call in window_manager.pie.calls
|
||||
)
|
||||
assert all(call[2].point_spacing == 16 for call in window_manager.pie.calls)
|
||||
@@ -163,7 +159,6 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
|
||||
)
|
||||
monkeypatch.setattr(cutout_main, "video_total_frames", lambda _image: 1)
|
||||
monkeypatch.setattr(cutout_main, "require_image_empty", lambda _name: source)
|
||||
monkeypatch.setattr(cutout_main, "apply_crop_settings", lambda *_args: None)
|
||||
monkeypatch.setattr(cutout_main, "serialize_matrix", lambda _matrix: "matrix")
|
||||
monkeypatch.setattr(
|
||||
cutout_main,
|
||||
@@ -180,7 +175,6 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
|
||||
|
||||
operator = SimpleNamespace(
|
||||
full_image=True,
|
||||
refine_selection=False,
|
||||
report=lambda *_args: None,
|
||||
)
|
||||
source.matrix_world = object()
|
||||
@@ -192,7 +186,7 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
|
||||
operator, context, SimpleNamespace(value="DOUBLE_CLICK")
|
||||
)
|
||||
|
||||
assert issubclass(cutout_main.SelectCutoutSelection, CropSelectionGesture)
|
||||
assert issubclass(cutout_main.SelectCutoutSelection, ImageGesture)
|
||||
assert result == {"FINISHED"}
|
||||
selection_path = SelectionPath.from_json(window_manager.pie.calls[0][2].selection_path_json)
|
||||
assert selection_path.points == (
|
||||
@@ -205,16 +199,25 @@ def test_cutout_reuses_image_lasso_gesture_and_double_click_selects_all(monkeypa
|
||||
assert not hasattr(cutout_main, "pending_selection")
|
||||
|
||||
|
||||
def test_cutout_tool_prioritizes_double_click_before_lasso_press():
|
||||
def test_cutout_tool_prioritizes_double_click_before_lasso_drag():
|
||||
assert cutout_main.CutoutTool.bl_keymap[0][1]["value"] == "DOUBLE_CLICK"
|
||||
assert cutout_main.CutoutTool.bl_keymap[1][1]["value"] == "PRESS"
|
||||
assert cutout_main.CutoutTool.bl_keymap[1][1]["value"] == "CLICK_DRAG"
|
||||
assert [entry[0] for entry in cutout_main.CutoutTool.bl_keymap[2:]] == [
|
||||
"view3d.select",
|
||||
"view3d.select",
|
||||
]
|
||||
assert cutout_main.CutoutTool.bl_keymap[2][2] == {
|
||||
"properties": [("deselect_all", False)]
|
||||
}
|
||||
assert cutout_main.CutoutTool.bl_keymap[3][2] == {
|
||||
"properties": [("toggle", True)]
|
||||
}
|
||||
|
||||
|
||||
def test_cutout_f_does_not_toggle_invert(monkeypatch):
|
||||
def test_cutout_f_has_no_option_shortcut(monkeypatch):
|
||||
operator = SimpleNamespace(
|
||||
gesture="LASSO",
|
||||
active_tool_id="anyimage.cutout_lasso",
|
||||
invert=False,
|
||||
)
|
||||
monkeypatch.setattr(viewport, "active_view3d_tool_id", lambda _context: None)
|
||||
|
||||
@@ -225,7 +228,6 @@ def test_cutout_f_does_not_toggle_invert(monkeypatch):
|
||||
)
|
||||
|
||||
assert result == {"PASS_THROUGH"}
|
||||
assert operator.invert is False
|
||||
|
||||
|
||||
def test_cutout_status_has_no_letter_shortcut():
|
||||
|
||||
@@ -28,6 +28,8 @@ 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.operators.image_edit_tool import mask as edit_mask
|
||||
from anyimage.operators.image_edit_tool import frame as edit_frame
|
||||
from anyimage.server.geometry.depth_texture import (
|
||||
write_vector_depth_texture,
|
||||
write_z_depth_texture,
|
||||
@@ -60,7 +62,7 @@ def _create_cutout_shape(
|
||||
depth_image=None,
|
||||
depth_metadata=None,
|
||||
selection_values=None,
|
||||
inward_axis=cutout_object.CutoutInwardAxis.POSITIVE_X,
|
||||
inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z,
|
||||
):
|
||||
if bounds is None:
|
||||
bounds = (0, 0, *tuple(int(value) for value in source.data.size))
|
||||
@@ -125,38 +127,6 @@ def _depth_metadata(intrinsics, image_size, reference_depth):
|
||||
}
|
||||
|
||||
|
||||
def test_keep_original_places_and_activates_result_copy():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Crop Source", width=8, height=4, alpha=True)
|
||||
result_image = bpy.data.images.new("Crop Result", width=4, height=2, alpha=True)
|
||||
source = bpy.data.objects.new("Crop Source", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = source_image
|
||||
source.empty_display_size = 3.0
|
||||
source.empty_image_offset = (0.2, -0.1)
|
||||
source.matrix_world = Matrix.Translation((1.0, 2.0, 3.0))
|
||||
bpy.context.collection.objects.link(source)
|
||||
source.select_set(True)
|
||||
bpy.context.view_layer.objects.active = source
|
||||
|
||||
placed = image_data.place_empty_image_result(
|
||||
bpy.context,
|
||||
source,
|
||||
result_image,
|
||||
placement_bounds=(2, 1, 6, 3),
|
||||
keep_original=True,
|
||||
)
|
||||
|
||||
assert placed is not source
|
||||
assert source.data == source_image
|
||||
assert placed.data == result_image
|
||||
assert placed.users_collection[:] == source.users_collection[:]
|
||||
assert np.allclose(placed.matrix_world, source.matrix_world)
|
||||
assert not source.select_get()
|
||||
assert placed.select_get()
|
||||
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)
|
||||
@@ -177,6 +147,9 @@ def test_image_edit_result_uses_complete_shared_source_name():
|
||||
image_data.replace_empty_image(source, result_image)
|
||||
|
||||
assert shared.data == source_image
|
||||
assert source_image.alpha_mode == "STRAIGHT"
|
||||
assert result_image.alpha_mode == "PREMUL"
|
||||
assert source.use_empty_image_alpha
|
||||
assert source.data == result_image
|
||||
assert result_image.name == "Poster.png.001"
|
||||
assert "_color" not in result_image.name
|
||||
@@ -202,6 +175,194 @@ def test_image_edit_result_recovers_unshared_source_name():
|
||||
assert bpy.data.images.get("Poster.png") == result_image
|
||||
|
||||
|
||||
def test_mask_frame_mask_add_preserves_packed_hidden_rgb():
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Frame RGB", width=4, height=4, alpha=True)
|
||||
rgba = np.ones((4, 4, 4), dtype=np.float32)
|
||||
rgba[..., :3] = np.arange(48).reshape((4, 4, 3)) / 47.0
|
||||
source_image.pixels.foreach_set(np.flipud(rgba).ravel())
|
||||
source_image.update()
|
||||
expected = image_data.image_rgba(source_image).copy()
|
||||
source = bpy.data.objects.new("Frame RGB", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.empty_display_size = 2.0
|
||||
source.empty_image_offset = (-0.5, -0.5)
|
||||
source.data = source_image
|
||||
bpy.context.collection.objects.link(source)
|
||||
selection = SelectionMask(np.ones((4, 4), dtype=np.float32), (0, 0, 4, 4))
|
||||
operator = SimpleNamespace(mode="SUBTRACT", report=lambda *_args: None)
|
||||
assert edit_mask.EditImageAlpha._apply_mask(
|
||||
operator, SimpleNamespace(), source, selection
|
||||
) == {"FINISHED"}
|
||||
descriptor = edit_frame._frame_source(
|
||||
source, SimpleNamespace(width=4, height=4),
|
||||
SimpleNamespace(perspective_matrix=Matrix.Identity(4), is_perspective=False),
|
||||
Matrix.Identity(4), active=True,
|
||||
)
|
||||
np.testing.assert_allclose(descriptor["rgba"][..., :3], expected[..., :3])
|
||||
np.testing.assert_array_equal(descriptor["pixels"], 0)
|
||||
framed = edit_frame.composite_frame_pixels(
|
||||
(descriptor,), ((0, 4), (4, 4), (4, 0), (0, 0)), (4, 4)
|
||||
)
|
||||
result = image_data.create_image_edit_result(source.data, framed, (4, 4))
|
||||
image_data.replace_empty_image(source, result)
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
assert source.use_empty_image_alpha
|
||||
assert result.packed_file is not None
|
||||
packed = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255.0
|
||||
np.testing.assert_allclose(packed[..., :3], expected[..., :3], atol=1 / 255)
|
||||
np.testing.assert_array_equal(packed[..., 3], 0)
|
||||
result.reload()
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
operator.mode = "ADD"
|
||||
assert edit_mask.EditImageAlpha._apply_mask(
|
||||
operator, SimpleNamespace(), source, selection
|
||||
) == {"FINISHED"}
|
||||
restored = image_data.image_rgba(source.data)
|
||||
np.testing.assert_allclose(restored[..., :3], expected[..., :3], atol=1 / 255)
|
||||
np.testing.assert_array_equal(restored[..., 3], 1)
|
||||
|
||||
|
||||
def test_frame_restored_alpha_keeps_foreground_blended_into_white():
|
||||
from io import BytesIO
|
||||
from PIL import Image
|
||||
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
sources = []
|
||||
for name, color, depth in (("Background", (1, 1, 1, 0), 2),
|
||||
("Foreground", (0.2, 0.4, 0.6, 1), 1)):
|
||||
image = bpy.data.images.new(name, width=6, height=2, alpha=True)
|
||||
rgba = np.tile(color, (2, 6, 1)).astype(np.float32)
|
||||
if name == "Foreground":
|
||||
rgba[..., 3] = (0, 0.01, 0.1, 0.25, 0.5, 1)
|
||||
rgba[:, 0, :3] = 0
|
||||
image.pixels.foreach_set(np.flipud(rgba).ravel())
|
||||
image.update()
|
||||
source = bpy.data.objects.new(name, None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.empty_display_size = 2
|
||||
source.empty_image_offset = (-0.5, -0.5)
|
||||
source.data = image
|
||||
source.matrix_world = Matrix.Translation((0, 0, -depth))
|
||||
bpy.context.collection.objects.link(source)
|
||||
sources.append(edit_frame._frame_source(
|
||||
source, SimpleNamespace(width=6, height=2),
|
||||
SimpleNamespace(perspective_matrix=Matrix.Identity(4), is_perspective=False),
|
||||
Matrix.Identity(4), active=name == "Foreground",
|
||||
))
|
||||
front = sources[1]
|
||||
source_alpha = front["rgba"][..., 3:4]
|
||||
expected = front["rgba"][..., :3] * source_alpha + 1 - source_alpha
|
||||
# The image aspect makes its orthographic screen projection two thirds of a pixel high.
|
||||
pixels = edit_frame.composite_frame_pixels(
|
||||
sources, ((0, 4 / 3), (6, 4 / 3), (6, 2 / 3), (0, 2 / 3)), (6, 2)
|
||||
)
|
||||
result = image_data.create_image_edit_result(front["object"].data, pixels, (6, 2))
|
||||
image_data.replace_empty_image(front["object"], result)
|
||||
encoded = np.asarray(Image.open(BytesIO(result.packed_file.data)).convert("RGBA")) / 255
|
||||
np.testing.assert_allclose(encoded[..., :3], expected, atol=1 / 255)
|
||||
np.testing.assert_allclose(encoded[..., 3:4], source_alpha, atol=1 / 255)
|
||||
selection = SelectionMask(np.ones((2, 6), dtype=np.float32), (0, 0, 6, 2))
|
||||
operator = SimpleNamespace(mode="ADD", report=lambda *_args: None)
|
||||
assert edit_mask.EditImageAlpha._apply_mask(
|
||||
operator, SimpleNamespace(), front["object"], selection
|
||||
) == {"FINISHED"}
|
||||
restored = image_data.image_rgba(front["object"].data)
|
||||
np.testing.assert_allclose(restored[..., :3], expected, atol=1 / 255)
|
||||
np.testing.assert_array_equal(restored[..., 3], 1)
|
||||
assert np.all(np.diff(restored[0, :, 0]) <= 0)
|
||||
np.testing.assert_array_equal(restored[0, 0, :3], 1)
|
||||
|
||||
|
||||
def test_brush_release_commits_one_packed_image_without_touching_shared_source():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Mask Source", width=8, height=6, alpha=True)
|
||||
source_rgba = np.ones((6, 8, 4), dtype=np.float32)
|
||||
source_rgba[:, :, :3] = (0.2, 0.4, 0.6)
|
||||
source_image.pixels.foreach_set(np.flipud(source_rgba).ravel())
|
||||
source_image.update()
|
||||
source = bpy.data.objects.new("Mask Source", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = source_image
|
||||
source.empty_display_size = 3.0
|
||||
source.empty_image_offset = (-0.25, -0.75)
|
||||
source.matrix_world = Matrix.Translation((1.0, 2.0, 3.0))
|
||||
shared = bpy.data.objects.new("Mask Shared", None)
|
||||
shared.empty_display_type = "IMAGE"
|
||||
shared.data = source_image
|
||||
bpy.context.collection.objects.link(source)
|
||||
bpy.context.collection.objects.link(shared)
|
||||
matrix = source.matrix_world.copy()
|
||||
|
||||
selection_mask = SelectionMask(
|
||||
np.ones((2, 3), dtype=np.float32),
|
||||
(2, 1, 5, 3),
|
||||
)
|
||||
operator = SimpleNamespace(mode="SUBTRACT", report=lambda *_args: None)
|
||||
status = edit_mask.EditImageAlpha._apply_mask(
|
||||
operator,
|
||||
SimpleNamespace(),
|
||||
source,
|
||||
selection_mask,
|
||||
)
|
||||
|
||||
result = source.data
|
||||
assert status == {"FINISHED"}
|
||||
assert source.data == result
|
||||
assert result != source_image
|
||||
assert result.packed_file is not None
|
||||
assert shared.data == source_image
|
||||
np.testing.assert_allclose(image_data.image_rgba(source_image), source_rgba)
|
||||
result_rgba = image_data.image_rgba(result)
|
||||
np.testing.assert_array_equal(result_rgba[1:3, 2:5, 3], 0.0)
|
||||
assert source.matrix_world == matrix
|
||||
assert source.empty_display_size == 3.0
|
||||
assert tuple(source.empty_image_offset) == (-0.25, -0.75)
|
||||
|
||||
|
||||
def test_brush_drag_does_not_create_or_update_an_image():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
source_image = bpy.data.images.new("Mask Cancel", width=4, height=3, alpha=True)
|
||||
source = bpy.data.objects.new("Mask Cancel", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = source_image
|
||||
bpy.context.collection.objects.link(source)
|
||||
image_count = len(bpy.data.images)
|
||||
shared = bpy.data.objects.new("Mask Cancel shared", None)
|
||||
shared.empty_display_type = "IMAGE"
|
||||
shared.data = source_image
|
||||
before = np.array(source_image.pixels[:])
|
||||
operator = edit_mask.ImageGesture()
|
||||
operator.active_tool_id = "anyimage.mask"
|
||||
operator.gesture = "BRUSH"
|
||||
operator._path = [(10.0, 10.0)]
|
||||
operator._path_samples = list(operator._path)
|
||||
operator._cursor = (10.0, 10.0)
|
||||
context = SimpleNamespace(area=SimpleNamespace(tag_redraw=lambda: None),
|
||||
workspace=SimpleNamespace(status_text_set=lambda value: None))
|
||||
event = SimpleNamespace(
|
||||
type="MOUSEMOVE",
|
||||
value="NOTHING",
|
||||
mouse_region_x=20,
|
||||
mouse_region_y=25,
|
||||
)
|
||||
|
||||
result = edit_mask.ImageGesture.modal(operator, context, event)
|
||||
|
||||
assert result == {"RUNNING_MODAL"}
|
||||
assert source.data == source_image
|
||||
assert len(bpy.data.images) == image_count
|
||||
event.type, event.value = "ESC", "PRESS"
|
||||
assert operator.modal(context, event) == {"CANCELLED"}
|
||||
assert source.data == shared.data == source_image
|
||||
np.testing.assert_array_equal(source_image.pixels[:], before)
|
||||
assert len(bpy.data.images) == image_count
|
||||
assert operator._path is operator._path_samples is operator._brush_preview is None
|
||||
|
||||
|
||||
def test_object_normal_image_follows_cutout_inward_axis():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new(
|
||||
@@ -222,7 +383,15 @@ def test_object_normal_image_follows_cutout_inward_axis():
|
||||
|
||||
actual = np.empty(4, dtype=np.float32)
|
||||
image.pixels.foreach_get(actual)
|
||||
expected = np.asarray((0.4, 0.8, -0.2), dtype=np.float32) * 0.5 + 0.5
|
||||
assert np.allclose(actual[:3], encoded, atol=1e-6)
|
||||
assert np.isclose(actual[3], 0.3)
|
||||
|
||||
cutout_object.orient_object_normal_image(
|
||||
image,
|
||||
cutout_object.CutoutInwardAxis.POSITIVE_X,
|
||||
)
|
||||
image.pixels.foreach_get(actual)
|
||||
expected = np.asarray((-0.8, -0.2, -0.4), dtype=np.float32) * 0.5 + 0.5
|
||||
assert np.allclose(actual[:3], expected, atol=1e-6)
|
||||
assert np.isclose(actual[3], 0.3)
|
||||
|
||||
@@ -255,6 +424,13 @@ def test_cutout_axes_produce_the_same_world_geometry(shape):
|
||||
inward_axis=cutout_object.CutoutInwardAxis.NEGATIVE_Z,
|
||||
)
|
||||
|
||||
for result in (positive_x, negative_z):
|
||||
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
|
||||
assert np.allclose(local[:, 2], 0.0, atol=1e-8)
|
||||
assert np.ptp(local[:, 0]) > 0.0
|
||||
assert np.ptp(local[:, 1]) > 0.0
|
||||
assert all(polygon.normal.z > 0.99 for polygon in result.data.polygons)
|
||||
|
||||
def world_positions(obj):
|
||||
local = _evaluated_positions(obj)
|
||||
return np.asarray([tuple(obj.matrix_world @ Vector(point)) for point in local])
|
||||
@@ -393,6 +569,29 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
|
||||
and link.to_socket.name == "Menu"
|
||||
for link in cutout.links
|
||||
)
|
||||
axis_switch = next(
|
||||
node
|
||||
for node in cutout.nodes
|
||||
if node.bl_idname == "GeometryNodeMenuSwitch"
|
||||
and [item.name for item in node.enum_items] == ["-Z", "+X"]
|
||||
)
|
||||
negative_z_link = next(
|
||||
link
|
||||
for link in cutout.links
|
||||
if link.to_node == axis_switch and link.to_socket.name == "-Z"
|
||||
)
|
||||
positive_x_link = next(
|
||||
link
|
||||
for link in cutout.links
|
||||
if link.to_node == axis_switch and link.to_socket.name == "+X"
|
||||
)
|
||||
assert positive_x_link.from_node.bl_idname == "GeometryNodeTransform"
|
||||
assert any(
|
||||
link.from_socket == negative_z_link.from_socket
|
||||
and link.to_node == positive_x_link.from_node
|
||||
and link.to_socket.name == "Geometry"
|
||||
for link in cutout.links
|
||||
)
|
||||
|
||||
child_groups = {
|
||||
shape: bpy.data.node_groups[name]
|
||||
@@ -817,7 +1016,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
|
||||
8,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
||||
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
|
||||
assert len(result.data.vertices) > 4
|
||||
assert len(result.data.polygons) > 1
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
@@ -845,7 +1044,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
|
||||
assert normal_scale_link.from_node == normal_scale_attribute
|
||||
assert normal_scale_link.from_socket == normal_scale_attribute.outputs["Fac"]
|
||||
assert result.modifiers[0].node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME
|
||||
inward = result.matrix_world.to_3x3() @ Vector((1.0, 0.0, 0.0))
|
||||
inward = result.matrix_world.to_3x3() @ Vector((0.0, 0.0, -1.0))
|
||||
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
|
||||
|
||||
|
||||
@@ -991,7 +1190,7 @@ def test_depth_surface_protected_smooth_evaluates():
|
||||
depth_metadata=depth_metadata,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
||||
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
modifier = result.modifiers[0]
|
||||
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAME
|
||||
@@ -1030,9 +1229,9 @@ def test_depth_surface_protected_smooth_evaluates():
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
baseline_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
baseline_x = np.asarray([vertex.co.x for vertex in baseline_mesh.vertices])
|
||||
baseline_min = baseline_x.min()
|
||||
baseline_max = baseline_x.max()
|
||||
baseline_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices])
|
||||
baseline_min = baseline_z.min()
|
||||
baseline_max = baseline_z.max()
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
@@ -1047,9 +1246,9 @@ def test_depth_surface_protected_smooth_evaluates():
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(evaluated_mesh.polygons) > 0
|
||||
x = np.asarray([vertex.co.x for vertex in evaluated_mesh.vertices])
|
||||
assert np.isclose(x.min(), baseline_min, atol=1e-5)
|
||||
assert np.isclose(x.max(), baseline_max + 1.0, atol=1e-5)
|
||||
z = np.asarray([vertex.co.z for vertex in evaluated_mesh.vertices])
|
||||
assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5)
|
||||
assert np.isclose(z.max(), baseline_max, atol=1e-5)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
@@ -1112,9 +1311,9 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
|
||||
positions = _evaluated_positions(result)
|
||||
|
||||
assert np.isfinite(positions).all()
|
||||
assert np.ptp(positions[:, 0]) < 1e-4
|
||||
assert np.ptp(positions[:, 0]) > 1.5
|
||||
assert np.ptp(positions[:, 1]) > 1.5
|
||||
assert np.ptp(positions[:, 2]) > 1.5
|
||||
assert np.ptp(positions[:, 2]) < 1e-4
|
||||
|
||||
|
||||
def _evaluated_positions(obj):
|
||||
@@ -1324,7 +1523,7 @@ def _add_cutout_modifier(obj, shape):
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(group, "Inward Axis"),
|
||||
1,
|
||||
0,
|
||||
)
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
@@ -1345,26 +1544,26 @@ def _add_cutout_modifier(obj, shape):
|
||||
|
||||
|
||||
def _depth_surface_stretch_object():
|
||||
vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)]
|
||||
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
|
||||
faces = []
|
||||
for z in range(2):
|
||||
for y in range(2):
|
||||
a = z * 3 + y
|
||||
faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4)))
|
||||
for y in range(2):
|
||||
for x in range(2):
|
||||
a = y * 3 + x
|
||||
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
||||
data = bpy.data.meshes.new("Depth Surface Stretch")
|
||||
data.from_pydata(vertices, (), faces)
|
||||
uv_layer = data.uv_layers.new(name="UVMap")
|
||||
for loop in data.loops:
|
||||
coordinate = data.vertices[loop.vertex_index].co
|
||||
uv_layer.data[loop.index].uv = (coordinate.y * 0.5, coordinate.z * 0.5)
|
||||
uv_layer.data[loop.index].uv = (coordinate.x * 0.5, coordinate.y * 0.5)
|
||||
obj = bpy.data.objects.new("Depth Surface Stretch", data)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = _add_cutout_modifier(obj, "DEPTH_SURFACE")
|
||||
|
||||
points = np.empty((3, 3, 3), dtype=np.float32)
|
||||
for z in range(3):
|
||||
for y in range(3):
|
||||
points[z, y] = (-float(y), -float(z), 1.0)
|
||||
for y in range(3):
|
||||
for x in range(3):
|
||||
points[y, x] = (float(x), -float(y), 1.0)
|
||||
points[0, 0, 2] = 6.0
|
||||
depth_image = _depth_image(points, np.ones((3, 3), dtype=bool))
|
||||
for name, value in (
|
||||
@@ -1452,12 +1651,12 @@ def test_depth_surface_stretch_limit_tracks_projection_and_cleans_loose_topology
|
||||
|
||||
def test_cutout_thickness_keeps_one_connected_shell():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)]
|
||||
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
|
||||
faces = []
|
||||
for z in range(2):
|
||||
for y in range(2):
|
||||
a = z * 3 + y
|
||||
faces.extend(((a, a + 4, a + 1), (a, a + 3, a + 4)))
|
||||
for y in range(2):
|
||||
for x in range(2):
|
||||
a = y * 3 + x
|
||||
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
||||
data = bpy.data.meshes.new("Connected Cutout")
|
||||
data.from_pydata(vertices, (), faces)
|
||||
obj = bpy.data.objects.new("Connected Cutout", data)
|
||||
@@ -1480,20 +1679,20 @@ def test_cutout_thickness_keeps_one_connected_shell():
|
||||
try:
|
||||
assert len(result.vertices) == 18
|
||||
assert len(result.polygons) == 24
|
||||
x = np.asarray([vertex.co.x for vertex in result.vertices])
|
||||
assert np.isclose(x.min(), -0.5, atol=1e-6)
|
||||
assert np.isclose(x.max(), 0.5, atol=1e-6)
|
||||
z = np.asarray([vertex.co.z for vertex in result.vertices])
|
||||
assert np.isclose(z.min(), -0.5, atol=1e-6)
|
||||
assert np.isclose(z.max(), 0.5, atol=1e-6)
|
||||
front_faces = [
|
||||
polygon for polygon in result.polygons if polygon.normal.x < -0.9
|
||||
polygon for polygon in result.polygons if polygon.normal.z > 0.9
|
||||
]
|
||||
back_faces = [polygon for polygon in result.polygons if polygon.normal.x > 0.9]
|
||||
back_faces = [polygon for polygon in result.polygons if polygon.normal.z < -0.9]
|
||||
side_faces = [
|
||||
polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1
|
||||
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
|
||||
]
|
||||
assert len(front_faces) == 8
|
||||
assert len(back_faces) == 8
|
||||
assert len(side_faces) == 8
|
||||
center = Vector((0.0, 1.0, 1.0))
|
||||
center = Vector((1.0, 1.0, 0.0))
|
||||
for polygon in side_faces:
|
||||
radial = polygon.center - center
|
||||
assert polygon.normal.dot(radial) > 0.0
|
||||
@@ -1513,7 +1712,7 @@ def test_cutout_thickness_keeps_one_connected_shell():
|
||||
assert len(result.vertices) == 34
|
||||
assert len(result.polygons) == 40
|
||||
side_faces = [
|
||||
polygon for polygon in result.polygons if abs(polygon.normal.x) < 0.1
|
||||
polygon for polygon in result.polygons if abs(polygon.normal.z) < 0.1
|
||||
]
|
||||
assert len(side_faces) == 24
|
||||
for polygon in side_faces:
|
||||
@@ -1529,11 +1728,11 @@ def test_cutout_thickness_keeps_one_connected_shell():
|
||||
|
||||
def test_balloon_uses_injected_profile_at_default_height():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)]
|
||||
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
|
||||
faces = []
|
||||
for z in range(2):
|
||||
for y in range(2):
|
||||
a = z * 3 + y
|
||||
for y in range(2):
|
||||
for x in range(2):
|
||||
a = y * 3 + x
|
||||
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
||||
data = bpy.data.meshes.new("Balloon Profile")
|
||||
data.from_pydata(vertices, (), faces)
|
||||
@@ -1559,11 +1758,11 @@ def test_balloon_uses_injected_profile_at_default_height():
|
||||
|
||||
bpy.context.view_layer.update()
|
||||
positions = _evaluated_positions(obj)
|
||||
assert np.isclose(positions[:, 0].min(), -1.0, atol=1e-6)
|
||||
assert np.isclose(positions[:, 0].max(), 1.0, atol=1e-6)
|
||||
assert np.isclose(positions[:, 2].min(), -1.0, atol=1e-6)
|
||||
assert np.isclose(positions[:, 2].max(), 1.0, atol=1e-6)
|
||||
assert np.isclose(
|
||||
positions[:, 0].min(),
|
||||
-positions[:, 0].max(),
|
||||
positions[:, 2].min(),
|
||||
-positions[:, 2].max(),
|
||||
atol=1e-5,
|
||||
)
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
@@ -1583,9 +1782,9 @@ def test_balloon_uses_injected_profile_at_default_height():
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(result.polygons) == baseline_face_count
|
||||
x = np.asarray([vertex.co.x for vertex in result.vertices])
|
||||
assert np.isclose(x.min(), -2.0, atol=1e-6)
|
||||
assert np.isclose(x.max(), 2.0, atol=1e-6)
|
||||
z = np.asarray([vertex.co.z for vertex in result.vertices])
|
||||
assert np.isclose(z.min(), -2.0, atol=1e-6)
|
||||
assert np.isclose(z.max(), 2.0, atol=1e-6)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
object_data.set_modifier_input(
|
||||
@@ -1597,21 +1796,21 @@ def test_balloon_uses_injected_profile_at_default_height():
|
||||
bpy.context.view_layer.update()
|
||||
zero_positions = _evaluated_positions(obj)
|
||||
assert len(zero_positions) == len(vertices)
|
||||
assert np.allclose(zero_positions[:, 0], 0.0, atol=1e-6)
|
||||
assert np.allclose(zero_positions[:, 2], 0.0, atol=1e-6)
|
||||
|
||||
|
||||
def test_cutout_cleanup_uses_absolute_area_with_many_small_islands():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
vertices = [(0.0, float(y), float(z)) for z in range(3) for y in range(3)]
|
||||
vertices = [(float(x), float(y), 0.0) for y in range(3) for x in range(3)]
|
||||
faces = []
|
||||
for z in range(2):
|
||||
for y in range(2):
|
||||
a = z * 3 + y
|
||||
for y in range(2):
|
||||
for x in range(2):
|
||||
a = y * 3 + x
|
||||
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
||||
for island in range(12):
|
||||
start = len(vertices)
|
||||
y = 5.0 + island * 0.1
|
||||
vertices.extend(((0.0, y, 5.0), (0.0, y + 0.01, 5.0), (0.0, y, 5.01)))
|
||||
x = 5.0 + island * 0.1
|
||||
vertices.extend(((x, 5.0, 0.0), (x + 0.01, 5.0, 0.0), (x, 5.01, 0.0)))
|
||||
faces.append((start, start + 1, start + 2))
|
||||
data = bpy.data.meshes.new("Cleanup")
|
||||
data.from_pydata(vertices, (), faces)
|
||||
@@ -1674,7 +1873,7 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller():
|
||||
selection_values=selection_values,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
||||
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
assert "o_balloon_depth" not in result.data.attributes
|
||||
assert "o_depth_offset" not in result.data.attributes
|
||||
@@ -1733,16 +1932,16 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller():
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
positions = _evaluated_positions(result)
|
||||
center_distance = np.linalg.norm(positions[:, 1:] - expected_center, axis=1)
|
||||
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1)
|
||||
nearest_center = center_distance <= center_distance.min() + 1e-6
|
||||
depth_at_thickness_one = positions[nearest_center, 0].min()
|
||||
depth_at_thickness_one = positions[nearest_center, 2].max()
|
||||
object_data.set_modifier_input(modifier, thickness_id, 2.0)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
positions = _evaluated_positions(result)
|
||||
center_distance = np.linalg.norm(positions[:, 1:] - expected_center, axis=1)
|
||||
center_distance = np.linalg.norm(positions[:, :2] - expected_center, axis=1)
|
||||
nearest_center = center_distance <= center_distance.min() + 1e-6
|
||||
depth_at_thickness_two = positions[nearest_center, 0].min()
|
||||
depth_at_thickness_two = positions[nearest_center, 2].max()
|
||||
assert np.isclose(
|
||||
depth_at_thickness_one,
|
||||
depth_at_thickness_two,
|
||||
@@ -1762,8 +1961,8 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller():
|
||||
bpy.context.view_layer.update()
|
||||
after_positions = _evaluated_positions(result)
|
||||
assert np.isclose(
|
||||
(after_positions[:, 0] - before_positions[:, 0]).min(),
|
||||
-0.2,
|
||||
(after_positions[:, 2] - before_positions[:, 2]).max(),
|
||||
0.2,
|
||||
atol=1e-5,
|
||||
)
|
||||
|
||||
@@ -1797,14 +1996,14 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller():
|
||||
mirrored = _evaluated_positions(result)
|
||||
layers = {}
|
||||
for x, y, z in mirrored:
|
||||
layers.setdefault((round(float(y), 6), round(float(z), 6)), []).append(x)
|
||||
layers.setdefault((round(float(x), 6), round(float(y), 6)), []).append(z)
|
||||
assert layers
|
||||
for x_values in layers.values():
|
||||
if len(x_values) == 1:
|
||||
assert np.isclose(x_values[0], 0.0, atol=1e-5)
|
||||
for z_values in layers.values():
|
||||
if len(z_values) == 1:
|
||||
assert np.isclose(z_values[0], 0.0, atol=1e-5)
|
||||
continue
|
||||
assert np.isclose(
|
||||
min(x_values) + max(x_values),
|
||||
min(z_values) + max(z_values),
|
||||
0.0,
|
||||
atol=1e-5,
|
||||
)
|
||||
@@ -1990,7 +2189,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
||||
assert len(result.data.vertices) > 4
|
||||
assert len(result.data.polygons) > 1
|
||||
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
||||
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
||||
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
|
||||
assert cutout_shape.BALLOON_ATTRIBUTE_NAME in result.data.attributes
|
||||
source_inverse = source.matrix_world.inverted()
|
||||
source_x = [
|
||||
|
||||
@@ -77,12 +77,10 @@ class ExtensionPackagingTest(unittest.TestCase):
|
||||
"operators/convert_to_plane/object.py",
|
||||
"operators/convert_to_plane/conversion.py",
|
||||
"operators/convert_to_plane/mesh_plane.py",
|
||||
"operators/crop_tool/__init__.py",
|
||||
"operators/crop_tool/selection.py",
|
||||
"operators/crop_tool/box.py",
|
||||
"operators/crop_tool/lasso.py",
|
||||
"operators/crop_tool/polyline.py",
|
||||
"operators/crop_tool/perspective.py",
|
||||
"operators/image_edit_tool/__init__.py",
|
||||
"operators/image_edit_tool/frame.py",
|
||||
"operators/image_edit_tool/mask.py",
|
||||
"operators/image_edit_tool/rectify.py",
|
||||
"operators/ai_setup.py",
|
||||
"operators/remove_background.py",
|
||||
):
|
||||
@@ -102,8 +100,6 @@ class ExtensionPackagingTest(unittest.TestCase):
|
||||
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_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)
|
||||
|
||||
136
tests/test_frame_antialiasing.py
Normal file
136
tests/test_frame_antialiasing.py
Normal file
@@ -0,0 +1,136 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
|
||||
|
||||
from anyimage.common.image import premultiplied_rgba
|
||||
from anyimage.operators.image_edit_tool import frame
|
||||
|
||||
|
||||
def source(rgba, *, bounds=(-1, 1, -1, 1), depth=1, slope=0, active=True):
|
||||
rgba = np.asarray(rgba, dtype=np.float32)
|
||||
height, width = rgba.shape[:2]
|
||||
world = np.eye(4)
|
||||
world[2, 3] = -depth
|
||||
world[2, 0] = slope
|
||||
result = frame.frame_source_projection(
|
||||
np.eye(4), np.eye(4), world, bounds, (1, 1), perspective=False
|
||||
)
|
||||
result.update(
|
||||
rgba=rgba, pixels=premultiplied_rgba(np.flipud(rgba).ravel(), (width, height)),
|
||||
image_size=(width, height), active=active, name="Active" if active else "Bottom",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def render(sources, size=(1, 1)):
|
||||
return np.flipud(frame.composite_frame_pixels(
|
||||
sources, ((0, 1), (1, 1), (1, 0), (0, 0)), size
|
||||
).reshape(size[1], size[0], 4))
|
||||
|
||||
|
||||
def test_white_half_coverage_does_not_mix_canvas_black():
|
||||
image = source([[[1, 1, 1, 1]]], bounds=(-1, 0, -1, 1))
|
||||
np.testing.assert_allclose(render((image,)), [[[1, 1, 1, 0.5]]])
|
||||
|
||||
|
||||
def test_uncovered_canvas_has_no_rgb_to_reveal_with_mask_add():
|
||||
from anyimage.common.selection import SelectionMask
|
||||
from anyimage.operators.image_edit_tool.mask import apply_alpha_mask
|
||||
|
||||
result = render((source([[[1, 1, 1, 1]]], bounds=(-0.5, 0.5, -0.5, 0.5)),), (8, 8))
|
||||
uncovered = result[..., 3] == 0
|
||||
assert np.any(uncovered)
|
||||
np.testing.assert_array_equal(result[uncovered], 0)
|
||||
restored = np.flipud(apply_alpha_mask(
|
||||
result, SelectionMask(np.ones((8, 8)), (0, 0, 8, 8)), "ADD"
|
||||
).reshape((8, 8, 4)))
|
||||
np.testing.assert_array_equal(restored[..., :3][uncovered], 0)
|
||||
np.testing.assert_array_equal(restored[..., 3], 1)
|
||||
|
||||
|
||||
def test_partial_foreground_over_transparent_blue():
|
||||
bottom = source([[[0, 0, 1, 0]]], depth=2, active=False)
|
||||
top = source([[[1, 0, 0, 0.5]]], bounds=(-1, 0, -1, 1))
|
||||
np.testing.assert_allclose(render((bottom, top)), [[[0.25, 0, 0.75, 0.25]]])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha", [0, 1e-12, 1e-8, 1e-7])
|
||||
def test_near_zero_alpha_keeps_covered_rgb(alpha):
|
||||
result = render((source([[[0.2, 0.4, 0.8, alpha]]]),))
|
||||
np.testing.assert_allclose(result[..., :3], [[[0.2, 0.4, 0.8]]])
|
||||
np.testing.assert_allclose(result[..., 3], 0 if alpha <= 1e-8 else alpha, atol=1e-15)
|
||||
|
||||
|
||||
def test_narrow_source_missed_by_center_is_sampled():
|
||||
image = source([[[1, 1, 1, 1]]], bounds=(-0.8, -0.7, -1, 1))
|
||||
np.testing.assert_allclose(render((image,)), [[[1, 1, 1, 0.25]]])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("alpha", [0, 1])
|
||||
def test_crossing_planes_resolve_per_sample_depth(alpha):
|
||||
first = source([[[1, 0, 0, alpha]]], slope=1)
|
||||
second = source([[[0, 0, 1, alpha]]], slope=-1, active=False)
|
||||
np.testing.assert_allclose(render((first, second)), [[[0.5, 0, 0.5, alpha]]])
|
||||
|
||||
|
||||
def test_aligned_interior_keeps_checkerboard():
|
||||
rgba = np.ones((8, 8, 4), dtype=np.float32)
|
||||
rgba[..., :3] = (np.indices((8, 8)).sum(axis=0) % 2)[..., None]
|
||||
np.testing.assert_allclose(render((source(rgba),), (8, 8)), rgba)
|
||||
|
||||
|
||||
def test_oblique_boundary_matches_reference_and_chunk_sizes():
|
||||
top = source([[[1, 0, 0, 0.5]]], bounds=(-0.7, 0.7, -0.7, 0.7))
|
||||
angle = 0.3
|
||||
world = np.eye(4)
|
||||
world[:2, :2] = ((np.cos(angle), -np.sin(angle)), (np.sin(angle), np.cos(angle)))
|
||||
world[2, 3] = -1
|
||||
top.update(frame.frame_source_projection(
|
||||
np.eye(4), np.eye(4), world, (-0.7, 0.7, -0.7, 0.7), (1, 1), perspective=False
|
||||
))
|
||||
bottom = source([[[0, 0, 1, 0]]], depth=2, active=False)
|
||||
sources = (bottom, top)
|
||||
size = 17
|
||||
reference = np.zeros((size, size, 4), dtype=np.float32)
|
||||
for dy in (0.125, 0.375, 0.625, 0.875):
|
||||
for dx in (0.125, 0.375, 0.625, 0.875):
|
||||
x, y = np.meshgrid((np.arange(size) + dx) / size, 1 - (np.arange(size) + dy) / size)
|
||||
sample, _ = frame._composite_frame_samples(sources, np.stack((x, y), axis=-1))
|
||||
reference += sample / 16
|
||||
for rows in (1, 3, 256):
|
||||
with patch.object(frame, "PROJECTIVE_CHUNK_ROWS", rows):
|
||||
np.testing.assert_allclose(render(sources, (size, size)), reference, atol=1e-6)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("background", [0, 1])
|
||||
def test_packed_frame_mask_add_keeps_boundary_color(background):
|
||||
from io import BytesIO
|
||||
import bpy
|
||||
from PIL import Image
|
||||
from anyimage.common.image import create_image_edit_result, image_rgba
|
||||
from anyimage.common.selection import SelectionMask
|
||||
from anyimage.operators.image_edit_tool.mask import apply_alpha_mask
|
||||
|
||||
bottom = source([[[background, background, background, 0]]], depth=2, active=False)
|
||||
top = source([[[1, 0, 0, 1]]], bounds=(-1, 0, -1, 1))
|
||||
rgba = render((bottom, top))
|
||||
original = bpy.data.images.new("Boundary source", width=1, height=1, alpha=True)
|
||||
result = create_image_edit_result(original, rgba.ravel(), (1, 1))
|
||||
try:
|
||||
packed = np.asarray(Image.open(BytesIO(result.packed_file.data))) / 255
|
||||
expected = (np.array([1, 0, 0]) + background) / 2
|
||||
np.testing.assert_allclose(packed[0, 0, :3], expected, atol=1 / 255)
|
||||
restored = apply_alpha_mask(
|
||||
image_rgba(result), SelectionMask(np.ones((1, 1)), (0, 0, 1, 1)), "ADD"
|
||||
).reshape(1, 1, 4)
|
||||
np.testing.assert_allclose(restored[0, 0, :3], expected, atol=1 / 255)
|
||||
assert restored[0, 0, 3] == 1
|
||||
assert result.alpha_mode == "PREMUL"
|
||||
finally:
|
||||
bpy.data.images.remove(original)
|
||||
bpy.data.images.remove(result)
|
||||
40
tests/test_frame_projection.py
Normal file
40
tests/test_frame_projection.py
Normal file
@@ -0,0 +1,40 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
|
||||
|
||||
from anyimage.common.selection import clip_polygon_halfplanes
|
||||
from anyimage.operators.image_edit_tool import frame
|
||||
|
||||
|
||||
@pytest.mark.parametrize("focal", [1, 1.7, 2, 4])
|
||||
@pytest.mark.parametrize("angle,depth", [(30, 0.2), (0, 1e-14)])
|
||||
def test_frame_overlap_matches_finite_front_samples(focal, angle, depth):
|
||||
projection = np.eye(4)
|
||||
projection[0, 0] = projection[1, 1] = focal
|
||||
projection[3] = (0, 0, -1, 0)
|
||||
c, s = np.cos(np.radians(angle)), np.sin(np.radians(angle))
|
||||
world = np.eye(4)
|
||||
world[:3, :3] = ((c, 0, s), (0, 1, 0), (-s, 0, c))
|
||||
world[2, 3] = -depth
|
||||
source = frame.frame_source_projection(
|
||||
projection, np.eye(4), world, (-1, 1, -1, 1), (100, 100), perspective=True
|
||||
)
|
||||
source["image_size"] = (100, 100)
|
||||
assert frame.frame_source_coordinates(source, np.array(((50, 50),)))[2][0]
|
||||
overlap = frame.frame_source_overlap(source, ((0, 100), (100, 100), (100, 0), (0, 0)))
|
||||
assert len(overlap) >= 3
|
||||
assert np.isfinite(overlap).all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scale", [1e-14, 1, 1e14])
|
||||
def test_halfplane_clipping_is_scale_invariant(scale):
|
||||
square = ((0, 0), (2, 0), (2, 2), (0, 2))
|
||||
result = clip_polygon_halfplanes(square, ((-scale, 0, scale),))
|
||||
assert set(result) == {(0, 0), (1, 0), (1, 2), (0, 2)}
|
||||
assert clip_polygon_halfplanes(square, ((1, 0, -3),)) == ()
|
||||
edge = clip_polygon_halfplanes(square, ((1, 0, -2),))
|
||||
assert all(x == 2 for x, y in edge)
|
||||
50
tests/test_image_premul_retention.py
Normal file
50
tests/test_image_premul_retention.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from io import BytesIO
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
|
||||
@pytest.mark.parametrize("loaded_png", [False, True])
|
||||
@pytest.mark.parametrize("mode_before_pack", [False, True])
|
||||
def test_premul_byte_reload_requires_straight_for_original_rgb(
|
||||
tmp_path, loaded_png, mode_before_pack
|
||||
):
|
||||
"""Characterize the reload mismatch blocking automatic PREMUL defaults."""
|
||||
rgba = np.array(
|
||||
[[51, 102, 204, alpha] for alpha in (0, 64, 128, 255)], dtype=np.uint8
|
||||
).reshape(1, 4, 4)
|
||||
expected = rgba.reshape(4, 4) / 255
|
||||
path = tmp_path / "source.png"
|
||||
Image.fromarray(rgba).save(path)
|
||||
if loaded_png:
|
||||
image = bpy.data.images.load(str(path), check_existing=False)
|
||||
else:
|
||||
image = bpy.data.images.new("Premul probe", width=4, height=1, alpha=True)
|
||||
image.pixels.foreach_set(expected.astype(np.float32).ravel())
|
||||
image.use_fake_user = True
|
||||
image.alpha_mode = "STRAIGHT"
|
||||
if mode_before_pack:
|
||||
image.alpha_mode = "PREMUL"
|
||||
image.pack()
|
||||
image.alpha_mode = "PREMUL"
|
||||
packed = np.asarray(Image.open(BytesIO(image.packed_file.data)))
|
||||
np.testing.assert_array_equal(packed, rgba)
|
||||
name = image.name
|
||||
blend_path = str(tmp_path / "retention.blend")
|
||||
bpy.ops.wm.save_as_mainfile(filepath=blend_path)
|
||||
bpy.ops.wm.open_mainfile(filepath=blend_path)
|
||||
restored = bpy.data.images[name]
|
||||
assert restored.alpha_mode == "PREMUL"
|
||||
interpreted = np.array(restored.pixels[:]).reshape(4, 4)
|
||||
np.testing.assert_allclose(interpreted[0, :3], 1)
|
||||
assert not np.allclose(interpreted, expected, atol=1 / 255)
|
||||
restored.alpha_mode = "STRAIGHT"
|
||||
np.testing.assert_allclose(
|
||||
np.array(restored.pixels[:]).reshape(4, 4), expected, atol=1 / 255
|
||||
)
|
||||
restored.alpha_mode = "PREMUL"
|
||||
np.testing.assert_allclose(
|
||||
np.array(restored.pixels[:]).reshape(4, 4), interpreted, atol=1 / 255
|
||||
)
|
||||
68
tests/test_mask_undo.py
Normal file
68
tests/test_mask_undo.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1] / "src"))
|
||||
|
||||
from anyimage.common import image, viewport
|
||||
from anyimage.operators.image_edit_tool import mask
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gesture", ["LASSO", "BRUSH", "POLYLINE"])
|
||||
def test_mask_commit_is_one_undo_transaction(gesture):
|
||||
source = bpy.data.images.new("Undo source", width=8, height=8, alpha=True)
|
||||
source.pixels.foreach_set(np.ones(8 * 8 * 4, dtype=np.float32))
|
||||
source.pack()
|
||||
owner = bpy.data.objects.new("Mask undo owner", None)
|
||||
owner.empty_display_type = "IMAGE"
|
||||
owner.data = source
|
||||
bpy.context.scene.collection.objects.link(owner)
|
||||
shared = bpy.data.objects.new("Mask undo shared", None)
|
||||
shared.empty_display_type = "IMAGE"
|
||||
shared.data = source
|
||||
bpy.context.scene.collection.objects.link(shared)
|
||||
owner_name, shared_name, source_name = owner.name, shared.name, source.name
|
||||
before = image.image_pixels(source)
|
||||
options = mask.EditImageAlpha.bl_options
|
||||
assert options == {"UNDO"}
|
||||
|
||||
def execute(operator, context):
|
||||
"""Drive gesture completion without a window event in background bpy."""
|
||||
operator.gesture, operator.mode, operator.radius = gesture, "SUBTRACT", 2
|
||||
operator.source_object_name = owner_name
|
||||
operator.source_matrix_data = viewport.serialize_matrix(owner.matrix_world)
|
||||
operator._path = [(2, 2), (6, 2), (6, 6), (2, 6)]
|
||||
operator._path_samples = list(operator._path)
|
||||
return operator._complete(context)
|
||||
|
||||
bpy.context.preferences.edit.use_global_undo = True
|
||||
with patch.object(mask.EditImageAlpha, "execute", execute, create=True), patch.object(
|
||||
mask.EditImageAlpha, "poll", classmethod(lambda cls, context: True)
|
||||
), patch.object(viewport, "screen_path_to_image_pixels", side_effect=lambda path, *args, **kwargs: path), patch.object(
|
||||
mask, "screen_path_to_image_pixels", side_effect=lambda path, *args, **kwargs: path
|
||||
), patch.object(mask, "create_image_edit_result", wraps=mask.create_image_edit_result) as create:
|
||||
bpy.utils.register_class(mask.EditImageAlpha)
|
||||
try:
|
||||
bpy.ops.ed.undo_push(message="Before Mask")
|
||||
assert bpy.ops.anyimage.edit_image_alpha("EXEC_DEFAULT", True) == {"FINISHED"}
|
||||
create.assert_called_once()
|
||||
result = bpy.data.objects[owner_name].data
|
||||
assert result.packed_file is not None and result.alpha_mode == "PREMUL"
|
||||
assert bpy.data.objects[shared_name].data.name == source_name
|
||||
np.testing.assert_array_equal(image.image_pixels(bpy.data.images[source_name]), before)
|
||||
assert bpy.ops.ed.undo() == {"FINISHED"}
|
||||
assert bpy.data.objects[owner_name].data.name == source_name
|
||||
np.testing.assert_array_equal(image.image_pixels(bpy.data.objects[owner_name].data), before)
|
||||
finally:
|
||||
bpy.utils.unregister_class(mask.EditImageAlpha)
|
||||
for name in (owner_name, shared_name):
|
||||
obj = bpy.data.objects.get(name)
|
||||
if obj is not None:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
source = bpy.data.images.get(source_name)
|
||||
if source is not None:
|
||||
bpy.data.images.remove(source)
|
||||
@@ -37,8 +37,6 @@ def reference_selection_mask(
|
||||
if antialias
|
||||
else mask.astype(np.float32)
|
||||
)
|
||||
if selection_path.invert:
|
||||
mask = 1.0 - mask
|
||||
bounds = selection._selection_canvas_bounds(
|
||||
image_size,
|
||||
selection_path,
|
||||
@@ -73,45 +71,25 @@ def assert_matches_reference(image_size, selection_path, **options):
|
||||
np.testing.assert_array_equal(actual.values, expected.values)
|
||||
|
||||
|
||||
def test_selection_path_json_contains_only_points_and_invert():
|
||||
def test_selection_path_json_contains_only_points():
|
||||
selection_path = selection.SelectionPath(
|
||||
points=((1, 2), (8, 2), (4, 9)),
|
||||
invert=True,
|
||||
)
|
||||
value = selection_path.to_json()
|
||||
|
||||
assert set(json.loads(value)) == {"points", "invert"}
|
||||
assert set(json.loads(value)) == {"points"}
|
||||
assert selection.SelectionPath.from_json(value) == selection_path
|
||||
with pytest.raises(ValueError, match="Selection Path data is invalid"):
|
||||
selection.SelectionPath.from_json(
|
||||
json.dumps(
|
||||
{
|
||||
"points": selection_path.points,
|
||||
"invert": selection_path.invert,
|
||||
"fill_rule": "NONZERO",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_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),
|
||||
@@ -144,13 +122,12 @@ def test_scanline_matches_reference_for_edge_cases():
|
||||
((-8, -3), (10, 1), (23, 8), (7, 18), (-4, 10)),
|
||||
)
|
||||
for points in paths:
|
||||
for invert in (False, True):
|
||||
for antialias in (False, True):
|
||||
assert_matches_reference(
|
||||
(18, 14),
|
||||
selection.SelectionPath(points, invert=invert),
|
||||
antialias=antialias,
|
||||
)
|
||||
for antialias in (False, True):
|
||||
assert_matches_reference(
|
||||
(18, 14),
|
||||
selection.SelectionPath(points),
|
||||
antialias=antialias,
|
||||
)
|
||||
|
||||
|
||||
def test_scanline_matches_reference_for_random_paths():
|
||||
@@ -163,13 +140,12 @@ def test_scanline_matches_reference_for_random_paths():
|
||||
random.uniform((-8, -6), (40, 30), size=(point_count, 2)),
|
||||
)
|
||||
)
|
||||
for invert in (False, True):
|
||||
for antialias in (False, True):
|
||||
assert_matches_reference(
|
||||
(32, 24),
|
||||
selection.SelectionPath(points, invert=invert),
|
||||
antialias=antialias,
|
||||
)
|
||||
for antialias in (False, True):
|
||||
assert_matches_reference(
|
||||
(32, 24),
|
||||
selection.SelectionPath(points),
|
||||
antialias=antialias,
|
||||
)
|
||||
|
||||
|
||||
def test_rasterizer_has_no_image_content_parameters():
|
||||
@@ -181,7 +157,7 @@ def test_rasterizer_has_no_image_content_parameters():
|
||||
assert "alpha_threshold" not in parameters
|
||||
|
||||
|
||||
def test_non_inverted_antialias_uses_only_padded_local_canvas(monkeypatch):
|
||||
def test_antialias_uses_only_padded_local_canvas(monkeypatch):
|
||||
selection_path = selection.SelectionPath(
|
||||
((100, 200), (500, 200), (500, 600), (100, 600))
|
||||
)
|
||||
@@ -203,25 +179,6 @@ def test_non_inverted_antialias_uses_only_padded_local_canvas(monkeypatch):
|
||||
assert calls == [(98, 198, 502, 602)]
|
||||
|
||||
|
||||
def test_inverted_selection_uses_full_canvas(monkeypatch):
|
||||
selection_path = selection.SelectionPath(
|
||||
((100, 200), (500, 200), (500, 600), (100, 600)),
|
||||
invert=True,
|
||||
)
|
||||
calls = []
|
||||
rasterize = selection._rasterize_path
|
||||
|
||||
def record(bounds, points):
|
||||
calls.append(bounds)
|
||||
return rasterize(bounds, points)
|
||||
|
||||
monkeypatch.setattr(selection, "_rasterize_path", record)
|
||||
|
||||
selection.rasterize_selection_path((5120, 2880), selection_path)
|
||||
|
||||
assert calls == [(0, 0, 5120, 2880)]
|
||||
|
||||
|
||||
def test_high_resolution_lasso_allocates_one_local_scanline_table(monkeypatch):
|
||||
angles = np.arange(1000, dtype=np.float64) * (2.0 * np.pi / 1000.0)
|
||||
points = tuple(
|
||||
|
||||
@@ -343,7 +343,6 @@ class ServerRuntimeTest(unittest.TestCase):
|
||||
{
|
||||
"download-model",
|
||||
"download-required-models",
|
||||
"refine-image-selection",
|
||||
"remove-background",
|
||||
"upscale-image",
|
||||
"generate-depth-plane-geometry",
|
||||
@@ -547,10 +546,10 @@ class ServerRuntimeTest(unittest.TestCase):
|
||||
encoded,
|
||||
np.asarray(
|
||||
[[
|
||||
(0, 127, 127),
|
||||
(127, 127, 255),
|
||||
(255, 127, 127),
|
||||
(127, 0, 127),
|
||||
(127, 127, 0),
|
||||
(0, 128, 128),
|
||||
(128, 128, 255),
|
||||
]],
|
||||
dtype=np.uint8,
|
||||
),
|
||||
@@ -700,46 +699,6 @@ 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"
|
||||
@@ -789,91 +748,3 @@ class ServerRuntimeTest(unittest.TestCase):
|
||||
depth_mode="VECTOR",
|
||||
normal_mode="OBJECT",
|
||||
)
|
||||
|
||||
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")
|
||||
directory = self.storage_root / "job"
|
||||
context = SimpleNamespace(
|
||||
directory=directory,
|
||||
progress=lambda *_args: None,
|
||||
check_cancelled=lambda: None,
|
||||
)
|
||||
|
||||
def generate(_context, _input, _model, _device, output):
|
||||
output.write_bytes(b"refined")
|
||||
return output
|
||||
|
||||
with patch.object(job, "refine_selection", side_effect=generate):
|
||||
result = job.run(
|
||||
context,
|
||||
{
|
||||
"input": str(input_path),
|
||||
"refine_selection": True,
|
||||
"refine_model": "BEN2_BASE",
|
||||
"device": "cpu",
|
||||
},
|
||||
)
|
||||
|
||||
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,
|
||||
{
|
||||
"input": str(input_path),
|
||||
"refine_selection": True,
|
||||
"refine_model": "BEN2_BASE",
|
||||
"device": "cpu",
|
||||
"depth_mode": "Z",
|
||||
"normal_mode": "TANGENT",
|
||||
},
|
||||
refined_path,
|
||||
depth_mode="Z",
|
||||
normal_mode="TANGENT",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user