Add image selection refinement functionality with BEN2 integration

- Implemented viewport.py for handling 3D view projections, gestures, and overlay drawing.
- Created refine_image_selection.py to manage the refinement of gesture masks and output image bounds.
- Added selection module with refine_selection.py to process and crop images based on alpha channel visibility.
- Introduced __init__.py for selection module to expose refine_selection function.
This commit is contained in:
2026-08-31 22:10:05 +08:00
parent 14d63001d2
commit c97b4a3e41
76 changed files with 2127 additions and 1137 deletions

View File

@@ -10,21 +10,26 @@ from mathutils import Vector
SOURCE_ROOT = Path(__file__).parents[1] / "src"
sys.path.insert(0, str(SOURCE_ROOT))
from anyimage.common import image_data
from anyimage.common import depth_data
from anyimage.common import depth as depth_data
from anyimage.common import image as image_data
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.image_region import (
RegionMask,
RegionPath,
rasterize_region_path,
from anyimage.common.selection import (
SelectionMask,
SelectionPath,
rasterize_selection_path,
)
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
from anyimage.operators.cutout_tool import object as cutout_object
from anyimage.operators.cutout_tool import shape as cutout_shape
from anyimage.server.geometry.depth_texture import write_vector_depth_texture
from anyimage.server.geometry.depth_texture import (
write_vector_depth_texture,
write_z_depth_texture,
)
from anyimage.server.models.geometry import GeometryFrame
def _inputs(group):
@@ -51,26 +56,26 @@ def _create_cutout_shape(
bounds=None,
depth_image=None,
depth_metadata=None,
region_values=None,
selection_values=None,
):
if bounds is None:
bounds = (0, 0, *tuple(int(value) for value in source.data.size))
left, top, right, bottom = bounds
if region_values is None:
region_values = np.ones((bottom - top, right - left), dtype=np.float32)
region_mask = RegionMask(region_values, bounds)
if selection_values is None:
selection_values = np.ones((bottom - top, right - left), dtype=np.float32)
selection_mask = SelectionMask(selection_values, bounds)
base_shape = cutout_geometry.build_base_shape(
bpy.context,
source,
detail,
region_mask,
selection_mask,
)
return cutout_object.create_shape_object(
bpy.context,
source,
shape,
detail,
region_mask,
selection_mask,
base_shape,
depth_image=depth_image,
depth_metadata=depth_metadata,
@@ -82,7 +87,7 @@ def _depth_image(points, valid, name="Depth"):
valid = np.asarray(valid, dtype=bool)
height, width = valid.shape
pixels = np.zeros((height, width, 4), dtype=np.float32)
pixels[valid, :3] = points[valid]
pixels[..., :3] = points
pixels[..., 3] = valid
image = bpy.data.images.new(
name,
@@ -112,9 +117,15 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path
source = bpy.data.objects.new("Source", None)
source.data = source_image
points = np.arange(12, dtype=np.float32).reshape((2, 2, 3))
valid = np.asarray(((True, False), (True, True)))
validity = np.asarray(((1.0, 0.0), (0.5, 1.0)), dtype=np.float32)
frame = GeometryFrame(
depth=points[..., 2].copy(),
validity=validity,
intrinsics=np.eye(3, dtype=np.float32),
points=points,
)
path = tmp_path / "vector-depth.exr"
write_vector_depth_texture(points, valid, path)
write_vector_depth_texture(frame, path)
metadata = _depth_metadata(np.eye(3), (2, 2), 1.0)
image = depth_data.load_depth_result_image(path, source, metadata)
@@ -122,12 +133,38 @@ def test_depth_result_image_loads_server_vector_exr_and_keeps_data_name(tmp_path
packed = np.empty(16, dtype=np.float32)
image.pixels.foreach_get(packed)
packed = np.flipud(packed.reshape((2, 2, 4)))
assert np.array_equal(packed[valid, :3], points[valid])
assert np.array_equal(packed[..., 3] > 0.5, valid)
assert np.array_equal(packed[..., :3], points)
assert np.array_equal(packed[..., 3], validity)
assert image.alpha_mode == "CHANNEL_PACKED"
assert image.name == "Source_depth.exr"
def test_z_depth_texture_keeps_depth_outside_positive_validity(tmp_path):
bpy.ops.wm.read_factory_settings(use_empty=True)
source_image = bpy.data.images.new("Source.png", width=2, height=1)
source = bpy.data.objects.new("Source", None)
source.data = source_image
frame = GeometryFrame(
depth=np.asarray(((2.0, 7.0),), dtype=np.float32),
validity=np.asarray(((1.0, 0.0),), dtype=np.float32),
intrinsics=np.eye(3, dtype=np.float32),
)
path = tmp_path / "z-depth.exr"
write_z_depth_texture(frame, path)
image = depth_data.load_depth_result_image(
path,
source,
_depth_metadata(np.eye(3), (2, 1), 2.0),
)
packed = np.empty(8, dtype=np.float32)
image.pixels.foreach_get(packed)
packed = packed.reshape((1, 2, 4))
assert np.array_equal(packed[..., :3], np.repeat(frame.depth[..., None], 3, axis=2))
assert np.array_equal(packed[..., 3], frame.validity)
def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
bpy.ops.wm.read_factory_settings(use_empty=True)
@@ -370,8 +407,8 @@ def test_cutout_modifier_wraps_four_menu_controlled_shape_groups():
def test_image_layer_switches_between_tangent_and_object_space_normal():
bpy.ops.wm.read_factory_settings(use_empty=True)
assert object_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
group = object_data.material_node_group()
assert material_data.IMAGE_MATERIAL_NODE_GROUP_NAME == "O Image Layer"
group = material_data.material_node_group()
normal = next(
item
for item in group.interface.items_tree
@@ -416,7 +453,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal():
color = bpy.data.images.new("Color", width=2, height=2)
normal_image = bpy.data.images.new("Normal", width=2, height=2)
object_material = object_data.create_image_material(
object_material = material_data.create_image_material(
SimpleNamespace(data=SimpleNamespace(name="Object Normal")),
color,
1.0,
@@ -426,7 +463,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal():
layer = _group_node(group, object_material.node_tree)
assert layer.inputs["Object Space"].default_value is True
tangent_material = object_data.create_image_material(
tangent_material = material_data.create_image_material(
SimpleNamespace(data=SimpleNamespace(name="Tangent Normal")),
color,
1.0,
@@ -434,7 +471,7 @@ def test_image_layer_switches_between_tangent_and_object_space_normal():
)
layer = _group_node(group, tangent_material.node_tree)
assert layer.inputs["Object Space"].default_value is False
reused = object_data.material_node_group()
reused = material_data.material_node_group()
assert {
node.space for node in reused.nodes if node.bl_idname == "ShaderNodeNormalMap"
} == {"TANGENT", "OBJECT"}
@@ -503,7 +540,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
)
assert normal_scale_attribute.attribute_type == "GEOMETRY"
assert normal_scale_attribute.attribute_name == cutout_shape.BALLOON_ATTRIBUTE_NAME
image_layer = _group_node(object_data.material_node_group(), material_tree)
image_layer = _group_node(material_data.material_node_group(), material_tree)
normal_scale_link = next(
link
for link in material_tree.links
@@ -572,7 +609,7 @@ def test_cutout_material_uses_flat_tangent_normal():
color = bpy.data.images.new("Color", width=2, height=2)
normal = bpy.data.images.new("Normal", width=1, height=1)
result = object_data.create_image_material(
result = material_data.create_image_material(
source,
color,
0.0,
@@ -580,7 +617,7 @@ def test_cutout_material_uses_flat_tangent_normal():
scene=bpy.context.scene,
)
layer = _group_node(object_data.material_node_group(), result.node_tree)
layer = _group_node(material_data.material_node_group(), result.node_tree)
assert layer.inputs["Object Space"].default_value is False
@@ -624,7 +661,7 @@ def test_depth_surface_protected_smooth_evaluates():
== cutout_shape.CUTOUT_SHAPE_INDICES["DEPTH_SURFACE"]
)
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_point_spacing(source, 8),
cutout_geometry.cutout_sampling_spacing(source, 8),
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
@@ -703,6 +740,44 @@ def test_depth_surface_protected_smooth_evaluates():
assert np.isfinite(internal_positions).all()
def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
bpy.ops.wm.read_factory_settings(use_empty=True)
image = bpy.data.images.new("Surface", width=64, height=64)
source = bpy.data.objects.new("Surface", None)
source.empty_display_type = "IMAGE"
source.data = image
source.empty_display_size = 2.0
bpy.context.collection.objects.link(source)
size = 8
depth = np.ones((size, size), dtype=np.float32)
x, y = np.meshgrid(np.arange(size), np.arange(size))
intrinsics = np.asarray(
((8.0, 0.0, 3.5), (0.0, 8.0, 3.5), (0.0, 0.0, 1.0)),
dtype=np.float32,
)
points = np.empty((size, size, 3), dtype=np.float32)
points[..., 0] = (x - intrinsics[0, 2]) / intrinsics[0, 0] * depth
points[..., 1] = (y - intrinsics[1, 2]) / intrinsics[1, 1] * depth
points[..., 2] = depth
validity = np.ones((size, size), dtype=bool)
validity[[0, -1], :] = False
validity[:, [0, -1]] = False
result = _create_cutout_shape(
source,
"DEPTH_SURFACE",
8,
depth_image=_depth_image(points, validity),
depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0),
)
positions = _evaluated_positions(result)
assert np.isfinite(positions).all()
assert np.ptp(positions[:, 0]) < 1e-4
assert np.ptp(positions[:, 1]) > 1.5
assert np.ptp(positions[:, 2]) > 1.5
def _evaluated_positions(obj):
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
result = evaluated.to_mesh()
@@ -951,8 +1026,8 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller():
depth_image = _depth_image(z_depth, valid)
depth_metadata = _depth_metadata(intrinsics, (41, 61), 2.0)
region_values = np.ones((60, 40), dtype=np.float32)
region_values[:, :2] = 0.0
selection_values = np.ones((60, 40), dtype=np.float32)
selection_values[:, :2] = 0.0
result = _create_cutout_shape(
source,
"DEPTH_BALLOON",
@@ -960,7 +1035,7 @@ def test_depth_balloon_uses_metric_plane_inputs_without_controller():
depth_image=depth_image,
depth_metadata=depth_metadata,
bounds=crop_bounds,
region_values=region_values,
selection_values=selection_values,
)
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
@@ -1135,16 +1210,16 @@ 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
top_down[1, 2, 3] = 26.0 / 255.0
region_mask = rasterize_region_path(
selection_mask = rasterize_selection_path(
(4, 4),
RegionPath(
SelectionPath(
points=((0, 0), (4, 0), (4, 4), (0, 4)),
fill_rule="NONZERO",
),
source_alpha=top_down[:, :, 3],
alpha_threshold=0.1,
)
selection = region_mask.full_values((4, 4))
selection = selection_mask.full_values((4, 4))
assert selection[1, 1] == 0.0
assert selection[1, 2] > 0.1
@@ -1162,7 +1237,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
bpy.context.collection.objects.link(source)
source.select_set(True)
bpy.context.view_layer.objects.active = source
region_path = RegionPath(
selection_path = SelectionPath(
points=((-8, 8), (72, 8), (72, 40), (-8, 40)),
fill_rule="NONZERO",
)
@@ -1174,9 +1249,9 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
wraps=image_data.image_rgba,
) as read_rgba:
rgba = image_data.image_rgba(image)
region_mask = rasterize_region_path(
selection_mask = rasterize_selection_path(
tuple(image.size),
region_path,
selection_path,
source_alpha=rgba[:, :, 3],
alpha_threshold=cutout_geometry.CUTOUT_ALPHA_THRESHOLD,
antialias=True,
@@ -1186,7 +1261,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
source,
"SURFACE",
16,
region_mask,
selection_mask,
source_rgba=rgba,
)
@@ -1203,7 +1278,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
== cutout_shape.CUTOUT_SHAPE_INDICES["SURFACE"]
)
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_point_spacing(source, 16),
cutout_geometry.cutout_sampling_spacing(source, 16),
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
@@ -1245,7 +1320,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
color_texture.image.pixels.foreach_get(copied)
copied = copied.reshape((34, 64, 4))
assert np.allclose(copied[:, :, :3], 1.0)
left, top, right, bottom = region_mask.bounds
left, top, right, bottom = selection_mask.bounds
assert np.allclose(
copied[:, :, 3], np.flipud(rgba[top:bottom, left:right, 3])
)
@@ -1257,7 +1332,7 @@ def test_balloon_normal_scale_uses_existing_image_layer_input():
{"BALLOON", "DEPTH_BALLOON"}
)
group = bpy.data.node_groups.new(
object_data.IMAGE_MATERIAL_NODE_GROUP_NAME,
material_data.IMAGE_MATERIAL_NODE_GROUP_NAME,
"ShaderNodeTree",
)
group.interface.new_socket(