feat(selection): separate selection geometry from image content processing
- Introduced a new design for selection rasterization that decouples the selection geometry from the image content, ensuring that the SelectionMask is independent of the source image's alpha and color. - Updated the `rasterize_selection_path` function to only accept selection paths and image dimensions, removing parameters related to source alpha and alpha thresholds. - Implemented a new approach for handling selection paths using non-zero winding fill, ensuring overlapping areas are preserved. - Enhanced performance by utilizing a scanline algorithm for rasterization, significantly reducing processing time for high-resolution images. - Updated internal documentation and added comprehensive tests to validate the new behavior and performance improvements. - Ensured backward compatibility by maintaining existing interfaces where necessary while introducing breaking changes in the selection processing logic.
This commit is contained in:
@@ -5,7 +5,8 @@ from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
import pytest
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
SOURCE_ROOT = Path(__file__).parents[1] / "src"
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
@@ -16,10 +17,12 @@ from anyimage.common import material as material_data
|
||||
from anyimage.common import node as node_asset
|
||||
from anyimage.common import object as object_data
|
||||
from anyimage.common.selection import (
|
||||
ImageEditWarning,
|
||||
SelectionMask,
|
||||
SelectionPath,
|
||||
rasterize_selection_path,
|
||||
)
|
||||
from anyimage.operators.clipboard_image import actions as clipboard_actions
|
||||
from anyimage.operators.convert_to_plane import object as plane_object
|
||||
from anyimage.operators.cutout_tool import geometry as cutout_geometry
|
||||
from anyimage.operators.cutout_tool import main as cutout_main
|
||||
@@ -64,21 +67,30 @@ def _create_cutout_shape(
|
||||
if selection_values is None:
|
||||
selection_values = np.ones((bottom - top, right - left), dtype=np.float32)
|
||||
selection_mask = SelectionMask(selection_values, bounds)
|
||||
source_rgba = image_data.image_rgba(source.data)
|
||||
color_image = cutout_object.create_cropped_color_image(
|
||||
source,
|
||||
selection_mask.bounds,
|
||||
source_rgba,
|
||||
)
|
||||
base_shape = cutout_geometry.build_base_shape(
|
||||
bpy.context,
|
||||
source,
|
||||
detail,
|
||||
selection_mask,
|
||||
selection_mask.values,
|
||||
selection_mask.bounds,
|
||||
)
|
||||
return cutout_object.create_shape_object(
|
||||
bpy.context,
|
||||
source,
|
||||
shape,
|
||||
detail,
|
||||
selection_mask,
|
||||
selection_mask.values,
|
||||
selection_mask.bounds,
|
||||
base_shape,
|
||||
depth_image=depth_image,
|
||||
depth_metadata=depth_metadata,
|
||||
color_image=color_image,
|
||||
)
|
||||
|
||||
|
||||
@@ -177,16 +189,15 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
|
||||
cutout_object.load_cutout_node_group() == cutout
|
||||
for shape in cutout_shape.CUTOUT_SHAPES
|
||||
)
|
||||
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"]
|
||||
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness"]
|
||||
assert _inputs(depth_plane) == [
|
||||
"Geometry",
|
||||
"Subdivide",
|
||||
"Thickness",
|
||||
"Depth Amount",
|
||||
"Reference Depth",
|
||||
"Depth Scale",
|
||||
"Base Plane Depth",
|
||||
"Uniform Scale",
|
||||
"Depth Image",
|
||||
"Material",
|
||||
]
|
||||
assert _inputs(cutout) == [
|
||||
"Geometry",
|
||||
@@ -391,6 +402,12 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
|
||||
if item.item_type == "SOCKET" and item.name == "Thickness"
|
||||
)
|
||||
assert thickness.subtype == "DISTANCE"
|
||||
depth_subdivide = next(
|
||||
item
|
||||
for item in depth_plane.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.name == "Subdivide"
|
||||
)
|
||||
assert depth_subdivide.default_value == 6
|
||||
for group in (
|
||||
child_groups["BALLOON"],
|
||||
child_groups["DEPTH_BALLOON"],
|
||||
@@ -504,6 +521,31 @@ def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(evaluated_mesh.polygons) > 0
|
||||
assert result.data.materials[0] == material
|
||||
assert evaluated_mesh.materials[0].original == material
|
||||
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def test_clipboard_plane_uses_the_mesh_material_slot():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Clipboard Plane", width=128, height=64, alpha=True)
|
||||
|
||||
with patch.object(clipboard_actions, "_place_in_view"):
|
||||
result = clipboard_actions.add_image_plane(bpy.context, image)
|
||||
|
||||
assert _inputs(result.modifiers[0].node_group) == [
|
||||
"Geometry",
|
||||
"Subdivide",
|
||||
"Thickness",
|
||||
]
|
||||
assert len(result.data.materials) == 1
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert evaluated_mesh.materials[0].original == result.data.materials[0]
|
||||
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
@@ -787,6 +829,192 @@ def _evaluated_positions(obj):
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def test_converted_planes_center_the_origin_without_moving_world_corners():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
|
||||
def create_source(name):
|
||||
image = bpy.data.images.new(name, width=200, height=100)
|
||||
source = bpy.data.objects.new(name, None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
source.empty_image_offset = (0.25, -0.2)
|
||||
source.matrix_world = (
|
||||
Matrix.Translation((3.0, -2.0, 5.0))
|
||||
@ Matrix.Rotation(0.4, 4, "Z")
|
||||
@ Matrix.Diagonal((1.5, 0.75, 2.0, 1.0))
|
||||
)
|
||||
bpy.context.collection.objects.link(source)
|
||||
return source
|
||||
|
||||
def assert_centered(result, bounds, source_matrix):
|
||||
x_min, x_max, y_min, y_max = bounds
|
||||
center = Vector(
|
||||
((x_min + x_max) * 0.5, (y_min + y_max) * 0.5, 0.0)
|
||||
)
|
||||
assert np.allclose(
|
||||
tuple(result.matrix_world.translation),
|
||||
tuple(source_matrix @ center),
|
||||
atol=1e-6,
|
||||
)
|
||||
local = np.asarray([tuple(vertex.co) for vertex in result.data.vertices])
|
||||
assert np.allclose(local[:, :2].min(axis=0), (-1.0, -0.5), atol=1e-6)
|
||||
assert np.allclose(local[:, :2].max(axis=0), (1.0, 0.5), atol=1e-6)
|
||||
expected = sorted(
|
||||
tuple(source_matrix @ Vector((x, y, 0.0)))
|
||||
for x in (x_min, x_max)
|
||||
for y in (y_min, y_max)
|
||||
)
|
||||
actual = sorted(
|
||||
tuple(result.matrix_world @ vertex.co)
|
||||
for vertex in result.data.vertices
|
||||
)
|
||||
assert np.allclose(actual, expected, atol=1e-6)
|
||||
|
||||
plane_source = create_source("Centered Plane")
|
||||
plane_bounds = image_data.image_empty_bounds(plane_source)
|
||||
plane_matrix = plane_source.matrix_world.copy()
|
||||
plane_material = bpy.data.materials.new("Centered Plane Material")
|
||||
plane = plane_object.create_plane_object(
|
||||
bpy.context,
|
||||
plane_source,
|
||||
0,
|
||||
plane_material,
|
||||
)
|
||||
assert_centered(plane, plane_bounds, plane_matrix)
|
||||
assert plane["anyimage_mesh_shape"] == "PLANE"
|
||||
|
||||
depth_source = create_source("Centered Depth Plane")
|
||||
depth_bounds = image_data.image_empty_bounds(depth_source)
|
||||
depth_matrix = depth_source.matrix_world.copy()
|
||||
depth_values = np.ones((2, 2, 3), dtype=np.float32)
|
||||
depth_image = _depth_image(depth_values, np.ones((2, 2), dtype=bool))
|
||||
depth_material = bpy.data.materials.new("Centered Depth Material")
|
||||
depth_plane = plane_object.create_depth_plane_object(
|
||||
bpy.context,
|
||||
depth_source,
|
||||
0,
|
||||
depth_material,
|
||||
depth_image,
|
||||
_depth_metadata(
|
||||
((0.5, 0.0, 0.5), (0.0, 0.5, 0.5), (0.0, 0.0, 1.0)),
|
||||
(2, 2),
|
||||
1.0,
|
||||
),
|
||||
)
|
||||
assert_centered(depth_plane, depth_bounds, depth_matrix)
|
||||
assert depth_plane["anyimage_mesh_shape"] == "DEPTH_PLANE"
|
||||
depth_modifier = depth_plane.modifiers[0]
|
||||
uniform_scale = depth_modifier[
|
||||
object_data.modifier_input_identifier(
|
||||
depth_modifier.node_group,
|
||||
"Uniform Scale",
|
||||
)
|
||||
]
|
||||
assert np.isclose(
|
||||
depth_modifier[
|
||||
object_data.modifier_input_identifier(
|
||||
depth_modifier.node_group,
|
||||
"Base Plane Depth",
|
||||
)
|
||||
],
|
||||
uniform_scale,
|
||||
)
|
||||
|
||||
|
||||
def test_depth_plane_builds_a_fixed_base_terrain_block():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Terrain", width=4, height=4)
|
||||
source = bpy.data.objects.new("Terrain", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
material = bpy.data.materials.new("Terrain Material")
|
||||
depth_image = _depth_image(
|
||||
np.ones((4, 4, 3), dtype=np.float32),
|
||||
np.ones((4, 4), dtype=bool),
|
||||
)
|
||||
result = plane_object.create_depth_plane_object(
|
||||
bpy.context,
|
||||
source,
|
||||
2,
|
||||
material,
|
||||
depth_image,
|
||||
_depth_metadata(
|
||||
((1.5, 0.0, 1.5), (0.0, 1.5, 1.5), (0.0, 0.0, 1.0)),
|
||||
(4, 4),
|
||||
1.0,
|
||||
),
|
||||
)
|
||||
modifier = result.modifiers[0]
|
||||
group = modifier.node_group
|
||||
|
||||
def set_input(name, value):
|
||||
object_data.set_modifier_input(
|
||||
modifier,
|
||||
object_data.modifier_input_identifier(group, name),
|
||||
value,
|
||||
)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
set_input("Depth Scale", 0.0)
|
||||
baseline = _evaluated_positions(result)
|
||||
assert np.isclose(baseline[:, 2].min(), 0.0, atol=1e-6)
|
||||
assert np.isclose(baseline[:, 2].max(), 0.1, atol=1e-6)
|
||||
baseline_base = {
|
||||
(round(float(x), 6), round(float(y), 6))
|
||||
for x, y, z in baseline
|
||||
if np.isclose(z, 0.0, atol=1e-6)
|
||||
}
|
||||
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert result.data.materials[0] == material
|
||||
assert evaluated_mesh.materials[0].original == material
|
||||
assert all(polygon.material_index == 0 for polygon in evaluated_mesh.polygons)
|
||||
assert "UVMap" in evaluated_mesh.uv_layers
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
set_input("Depth Scale", 1.0)
|
||||
set_input("Base Plane Depth", 1.2)
|
||||
outward = _evaluated_positions(result)
|
||||
assert np.isclose(outward[:, 2].min(), 0.0, atol=1e-6)
|
||||
assert np.isclose(outward[:, 2].max(), 0.3, atol=1e-5)
|
||||
outward_base = {
|
||||
(round(float(x), 6), round(float(y), 6))
|
||||
for x, y, z in outward
|
||||
if np.isclose(z, 0.0, atol=1e-6)
|
||||
}
|
||||
assert outward_base == baseline_base
|
||||
z_levels = np.unique(np.round(outward[:, 2], 6))
|
||||
assert len(z_levels) >= 4
|
||||
assert np.allclose(np.diff(z_levels), np.diff(z_levels)[0], atol=1e-5)
|
||||
|
||||
set_input("Base Plane Depth", 0.95)
|
||||
inward = _evaluated_positions(result)
|
||||
assert np.isclose(inward[:, 2].min(), 0.0, atol=1e-6)
|
||||
assert np.isclose(inward[:, 2].max(), 0.05, atol=1e-5)
|
||||
|
||||
set_input("Base Plane Depth", 0.0)
|
||||
limited = _evaluated_positions(result)
|
||||
assert np.isclose(limited[:, 2].min(), 0.0, atol=1e-6)
|
||||
assert limited[:, 2].max() > 0.0
|
||||
assert limited[:, 2].max() < 0.001
|
||||
|
||||
set_input("Base Plane Depth", 1.3)
|
||||
outward_beyond_thickness = _evaluated_positions(result)
|
||||
assert np.isclose(outward_beyond_thickness[:, 2].max(), 0.4, atol=1e-5)
|
||||
|
||||
set_input("Thickness", 0.0)
|
||||
set_input("Base Plane Depth", 1.2)
|
||||
sheet = _evaluated_positions(result)
|
||||
assert np.allclose(sheet[:, 2], 0.2, atol=1e-5)
|
||||
|
||||
|
||||
def _add_cutout_modifier(obj, shape):
|
||||
group = cutout_object.load_cutout_node_group()
|
||||
modifier = obj.modifiers.new(group.name, "NODES")
|
||||
@@ -1206,6 +1434,41 @@ def test_converted_color_images_are_new_and_use_blender_duplicate_suffix():
|
||||
assert second.name == "Poster_color.png.001"
|
||||
|
||||
|
||||
def test_bounds_input_preserves_rectangular_rgba_and_stays_packed_after_cleanup():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Bounds Source", width=5, height=4, alpha=True)
|
||||
rgba = np.arange(4 * 5 * 4, dtype=np.float32).reshape((4, 5, 4)) / 100.0
|
||||
rgba[:, :, 3] = np.asarray(
|
||||
(
|
||||
(0.0, 0.1, 0.2, 0.3, 0.4),
|
||||
(0.5, 0.6, 0.7, 0.8, 0.9),
|
||||
(1.0, 0.9, 0.8, 0.7, 0.6),
|
||||
(0.5, 0.4, 0.3, 0.2, 0.1),
|
||||
),
|
||||
dtype=np.float32,
|
||||
)
|
||||
path = image_data.prepare_image_bounds_input(image, rgba, (1, 1, 5, 4))
|
||||
result = None
|
||||
try:
|
||||
result = image_data.load_packed_rgba_image(
|
||||
path,
|
||||
image,
|
||||
"Bounds Result",
|
||||
)
|
||||
loaded = image_data.image_rgba(result)
|
||||
np.testing.assert_allclose(loaded, rgba[1:4, 1:5], atol=1.0 / 255.0)
|
||||
assert result.packed_file is not None
|
||||
|
||||
image_data.cleanup_image_input(path, True)
|
||||
|
||||
assert not path.exists()
|
||||
assert result.packed_file is not None
|
||||
finally:
|
||||
image_data.cleanup_image_input(path, True)
|
||||
if result is not None:
|
||||
bpy.data.images.remove(result, do_unlink=True)
|
||||
|
||||
|
||||
def test_local_cutout_selection_matches_uint8_tenth_alpha_boundary():
|
||||
top_down = np.ones((4, 4, 4), dtype=np.float32)
|
||||
top_down[1, 1, 3] = 25.0 / 255.0
|
||||
@@ -1214,17 +1477,30 @@ def test_local_cutout_selection_matches_uint8_tenth_alpha_boundary():
|
||||
(4, 4),
|
||||
SelectionPath(
|
||||
points=((0, 0), (4, 0), (4, 4), (0, 4)),
|
||||
fill_rule="NONZERO",
|
||||
),
|
||||
source_alpha=top_down[:, :, 3],
|
||||
alpha_threshold=0.1,
|
||||
)
|
||||
selection = selection_mask.full_values((4, 4))
|
||||
selection = cutout_main._cutout_content_values(
|
||||
top_down[:, :, 3],
|
||||
selection_mask.values,
|
||||
)
|
||||
|
||||
assert selection[1, 1] == 0.0
|
||||
assert selection[1, 1] <= cutout_geometry.CUTOUT_ALPHA_THRESHOLD
|
||||
assert selection[1, 2] > 0.1
|
||||
|
||||
|
||||
def test_cutout_content_merge_keeps_the_geometric_selection_unchanged():
|
||||
selection = np.asarray(((0.25, 1.0), (0.5, 0.75)), dtype=np.float32)
|
||||
original = selection.copy()
|
||||
alpha = np.asarray(((1.0, 0.5), (0.0, 0.25)), dtype=np.float32)
|
||||
|
||||
content = cutout_main._cutout_content_values(alpha, selection)
|
||||
|
||||
np.testing.assert_array_equal(selection, original)
|
||||
np.testing.assert_allclose(content, alpha * original)
|
||||
with pytest.raises(ImageEditWarning, match="no visible image pixels"):
|
||||
cutout_main._cutout_content_values(np.zeros((2, 2)), selection)
|
||||
|
||||
|
||||
def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Local Cutout", width=64, height=48, alpha=True)
|
||||
@@ -1239,7 +1515,6 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
||||
bpy.context.view_layer.objects.active = source
|
||||
selection_path = SelectionPath(
|
||||
points=((-8, 8), (72, 8), (72, 40), (-8, 40)),
|
||||
fill_rule="NONZERO",
|
||||
)
|
||||
source_bounds = image_data.image_empty_bounds(source)
|
||||
|
||||
@@ -1252,17 +1527,26 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
||||
selection_mask = rasterize_selection_path(
|
||||
tuple(image.size),
|
||||
selection_path,
|
||||
source_alpha=rgba[:, :, 3],
|
||||
alpha_threshold=cutout_geometry.CUTOUT_ALPHA_THRESHOLD,
|
||||
antialias=True,
|
||||
)
|
||||
left, top, right, bottom = selection_mask.bounds
|
||||
content_values = cutout_main._cutout_content_values(
|
||||
rgba[top:bottom, left:right, 3],
|
||||
selection_mask.values,
|
||||
)
|
||||
color_image = cutout_object.create_cropped_color_image(
|
||||
source,
|
||||
selection_mask.bounds,
|
||||
rgba,
|
||||
)
|
||||
cutout_main.create_cutout_shape(
|
||||
bpy.context,
|
||||
source,
|
||||
"SURFACE",
|
||||
16,
|
||||
selection_mask,
|
||||
source_rgba=rgba,
|
||||
content_values,
|
||||
selection_mask.bounds,
|
||||
color_image=color_image,
|
||||
)
|
||||
|
||||
read_rgba.assert_called_once_with(image)
|
||||
@@ -1315,10 +1599,10 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
|
||||
if node.bl_idname == "ShaderNodeTexImage"
|
||||
)
|
||||
assert color_texture.image is not image
|
||||
assert tuple(color_texture.image.size) == (64, 34)
|
||||
copied = np.empty(34 * 64 * 4, dtype=np.float32)
|
||||
assert tuple(color_texture.image.size) == (64, 36)
|
||||
copied = np.empty(36 * 64 * 4, dtype=np.float32)
|
||||
color_texture.image.pixels.foreach_get(copied)
|
||||
copied = copied.reshape((34, 64, 4))
|
||||
copied = copied.reshape((36, 64, 4))
|
||||
assert np.allclose(copied[:, :, :3], 1.0)
|
||||
left, top, right, bottom = selection_mask.bounds
|
||||
assert np.allclose(
|
||||
|
||||
Reference in New Issue
Block a user