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:
2026-09-01 22:36:20 +08:00
parent 2ff7b04f56
commit 7f6e47432e
54 changed files with 2831 additions and 634 deletions

View File

@@ -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(