Add cutout profile and teddy balloon height calculations
- Implemented cotangent Poisson field and Poisson balloon profile in cutout_profile.py. - Added teddy balloon heights calculation and related functions in cutout_teddy.py. - Introduced interaction utilities for image editing in interaction.py. - Developed depth normalization and analysis functions in cutout_depth.py. - Created a validation script for cutout assets in validate_cutout_asset.py.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,12 @@ def test_cutout_pie_uses_four_semantic_shapes_in_cardinal_order():
|
||||
mesh.DEPTH_BALLOON,
|
||||
mesh.DEPTH_SURFACE,
|
||||
]
|
||||
assert [call[1]["text"] for call in window_manager.pie.calls] == [
|
||||
"Surface",
|
||||
"Balloon",
|
||||
"Depth Balloon",
|
||||
"Depth Surface",
|
||||
]
|
||||
assert all(
|
||||
call[0] == "anyimage.convert_cutout_shape" for call in window_manager.pie.calls
|
||||
)
|
||||
|
||||
@@ -11,11 +11,67 @@ from PIL import Image
|
||||
SOURCE_ROOT = Path(__file__).parents[1] / "src"
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
from anyimage.operators import mesh
|
||||
from anyimage.operators import cutout_depth, cutout_teddy, mesh
|
||||
from anyimage.operators.cutout_profile import poisson_balloon_profile
|
||||
from anyimage.operators.normal_map import rasterize_tangent_normal
|
||||
from anyimage.server.geometry.cutout import alpha_regions, image_alpha_regions
|
||||
|
||||
|
||||
def test_base_plane_fit_is_stable_for_a_slender_diagonal_domain():
|
||||
along = np.linspace(-1.0, 1.0, 101)
|
||||
across = np.resize(np.asarray((-0.01, 0.0, 0.01)), len(along))
|
||||
coordinates = np.column_stack(
|
||||
(
|
||||
along - across,
|
||||
along + across,
|
||||
)
|
||||
)
|
||||
values = 0.4 * along + np.sign(across) * 0.05
|
||||
|
||||
plane = cutout_depth._stable_plane(coordinates, values)
|
||||
selected_plane = coordinates @ plane[:2] + plane[2]
|
||||
corners = np.asarray(
|
||||
((-1.0, -1.0), (-1.0, 1.0), (1.0, -1.0), (1.0, 1.0))
|
||||
)
|
||||
corner_plane = corners @ plane[:2] + plane[2]
|
||||
|
||||
assert np.ptp(corner_plane) <= np.ptp(selected_plane) * 1.25
|
||||
assert np.corrcoef(selected_plane, along)[0, 1] > 0.99
|
||||
|
||||
|
||||
def test_depth_balloon_plane_calibration_matches_cutout_axes():
|
||||
vertices = np.asarray(
|
||||
(
|
||||
(-1.0, -1.0, 0.0),
|
||||
(1.0, -1.0, 0.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(-1.0, 1.0, 0.0),
|
||||
),
|
||||
dtype=np.float32,
|
||||
)
|
||||
uv = np.asarray(
|
||||
((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)),
|
||||
dtype=np.float32,
|
||||
)
|
||||
depth_data = {
|
||||
"depth_signal": np.zeros((2, 2), dtype=np.float32),
|
||||
"baseline_vector": np.asarray((0.4, -0.2, 0.1), dtype=np.float32),
|
||||
"valid": np.ones((2, 2), dtype=bool),
|
||||
"crop_size": (2, 2),
|
||||
}
|
||||
|
||||
_depth, local_plane = cutout_depth.depth_balloon_fields(
|
||||
vertices,
|
||||
uv,
|
||||
depth_data,
|
||||
1.0,
|
||||
)
|
||||
oriented = cutout_depth.orient_depth_balloon_plane(local_plane)
|
||||
|
||||
assert np.allclose(local_plane, (0.2, 0.1, 0.05), atol=1e-6)
|
||||
assert np.allclose(oriented, (-0.2, 0.1, 0.05), atol=1e-6)
|
||||
|
||||
|
||||
def mesh_component_count(faces):
|
||||
adjacency = {}
|
||||
for face in faces:
|
||||
@@ -66,63 +122,6 @@ def test_plane_source_topology_is_one_quad_at_every_detail_level():
|
||||
assert low[1].shape == high[1].shape == (1, 4)
|
||||
|
||||
|
||||
def test_cutout_front_back_and_side_faces_point_outward():
|
||||
vertices = np.asarray(
|
||||
((0.0, 0.0, 0.0), (0.0, 1.0, 0.0), (1.0, 0.0, 0.0)),
|
||||
dtype=np.float64,
|
||||
)
|
||||
faces = np.asarray(((0, 1, 2),), dtype=np.int32)
|
||||
uv = vertices[:, :2].astype(np.float32)
|
||||
boundary = np.ones(3, dtype=bool)
|
||||
layered = mesh._layered_geometry(
|
||||
(vertices, faces, uv, boundary, vertices[:, :2]),
|
||||
mesh.CUTOUT,
|
||||
)
|
||||
positioned = layered[0] + layered[3]
|
||||
output_faces = layered[1]
|
||||
|
||||
def face_normal(face):
|
||||
points = positioned[np.asarray(face[:3], dtype=np.int32)]
|
||||
return np.cross(points[1] - points[0], points[2] - points[0])
|
||||
|
||||
assert face_normal(output_faces[0])[2] > 0.0
|
||||
assert face_normal(output_faces[1])[2] < 0.0
|
||||
center = vertices[:, :2].mean(axis=0)
|
||||
for face in output_faces[2:]:
|
||||
normal = face_normal(face)
|
||||
side_center = positioned[np.asarray(face, dtype=np.int32), :2].mean(axis=0)
|
||||
assert np.dot(normal[:2], side_center - center) > 0.0
|
||||
|
||||
|
||||
def test_depth_surface_moves_both_sides_with_camera_projection_and_extrudes_back():
|
||||
domain = mesh._plane_domain((-1.0, 1.0, -1.0, 1.0), 4)
|
||||
field = np.zeros((2, 2, 3), dtype=np.float32)
|
||||
field[..., 2] = 1.0
|
||||
valid = np.ones((2, 2), dtype=bool)
|
||||
camera_depth = np.ones((2, 2), dtype=np.float32)
|
||||
|
||||
depth_surface = mesh._layered_geometry(
|
||||
domain, mesh.DEPTH_SURFACE, (field, valid, camera_depth)
|
||||
)
|
||||
base_count = len(domain[0])
|
||||
|
||||
assert np.allclose(depth_surface[4][:base_count, 2], 1.0)
|
||||
assert np.allclose(depth_surface[4][base_count:, 2], 1.0)
|
||||
assert np.allclose(depth_surface[3][:base_count], 0.0)
|
||||
assert np.allclose(depth_surface[3][base_count:, 2], -1.0)
|
||||
assert all(depth_surface[5][: len(domain[1])])
|
||||
assert not any(depth_surface[5][len(domain[1]) :])
|
||||
|
||||
|
||||
def test_depth_surface_records_depth_discontinuities_for_geometry_nodes():
|
||||
faces = np.asarray(((0, 1, 2), (1, 2, 3)), dtype=np.int32)
|
||||
depth = np.asarray((1.0, 1.01, 1.02, 2.0), dtype=np.float32)
|
||||
|
||||
discontinuity = mesh._depth_discontinuity_values(faces, depth)
|
||||
|
||||
assert discontinuity[0] < mesh.DEPTH_EDGE_THRESHOLD
|
||||
assert discontinuity[1] > mesh.DEPTH_EDGE_THRESHOLD
|
||||
|
||||
|
||||
def test_depth_surface_discontinuity_map_uses_local_three_by_three_range():
|
||||
depth = np.ones((5, 5), dtype=np.float32)
|
||||
@@ -298,6 +297,78 @@ def test_cutout_point_spacing_uses_the_complete_source_plane():
|
||||
assert spacing == 0.02
|
||||
|
||||
|
||||
def test_poisson_balloon_profile_is_zero_on_boundary_and_positive_inside():
|
||||
points = np.asarray(
|
||||
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
|
||||
dtype=float,
|
||||
)
|
||||
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
|
||||
boundary = np.asarray((True, True, True, True, False))
|
||||
|
||||
profile = poisson_balloon_profile(points, faces, boundary)
|
||||
|
||||
assert np.all(profile[:4] == 0.0)
|
||||
assert profile[4] > 0.0
|
||||
|
||||
|
||||
def test_poisson_balloon_profile_scales_in_world_units():
|
||||
points = np.asarray(
|
||||
((0, 0), (2, 0), (2, 2), (0, 2), (1, 1)),
|
||||
dtype=float,
|
||||
)
|
||||
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
|
||||
boundary = np.asarray((True, True, True, True, False))
|
||||
|
||||
original = poisson_balloon_profile(points, faces, boundary)
|
||||
scaled = poisson_balloon_profile(points * 2.0, faces, boundary)
|
||||
|
||||
assert np.isclose(scaled[4], original[4] * 2.0, atol=1e-6)
|
||||
|
||||
|
||||
def test_poisson_balloon_profile_makes_wide_regions_thicker():
|
||||
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
|
||||
boundary = np.asarray((True, True, True, True, False))
|
||||
wide = np.asarray(
|
||||
((0, 0), (4, 0), (4, 2), (0, 2), (2, 1)),
|
||||
dtype=float,
|
||||
)
|
||||
narrow = np.asarray(
|
||||
((0, 0), (4, 0), (4, 0.6), (0, 0.6), (2, 0.3)),
|
||||
dtype=float,
|
||||
)
|
||||
|
||||
wide_profile = poisson_balloon_profile(wide, faces, boundary)
|
||||
narrow_profile = poisson_balloon_profile(narrow, faces, boundary)
|
||||
|
||||
assert wide_profile[4] > narrow_profile[4]
|
||||
def test_teddy_balloon_profile_uses_chordal_axis_radius():
|
||||
points = np.asarray(
|
||||
((0, 0), (4, 0), (4, 2), (0, 2), (2, 1)),
|
||||
dtype=float,
|
||||
)
|
||||
faces = ((0, 1, 4), (1, 2, 4), (2, 3, 4), (3, 0, 4))
|
||||
boundary = np.asarray((True, True, True, True, False))
|
||||
nodes = np.asarray(((1, 1), (3, 1)), dtype=float)
|
||||
|
||||
with patch.object(
|
||||
cutout_teddy,
|
||||
"chordal_axis",
|
||||
return_value=(nodes, [(0, 1)], np.asarray((1.0, 1.0))),
|
||||
):
|
||||
profile = cutout_teddy.teddy_balloon_heights(
|
||||
points,
|
||||
faces,
|
||||
points[:4],
|
||||
boundary,
|
||||
0.45,
|
||||
)
|
||||
|
||||
assert np.all(profile[:4] == 0.0)
|
||||
assert np.isclose(profile[4], 0.45, atol=1e-6)
|
||||
|
||||
|
||||
|
||||
|
||||
def test_cutout_domain_adapts_boundary_spacing_for_a_small_valid_region():
|
||||
regions = (
|
||||
(((2046, 2046), (2050, 2046), (2050, 2050), (2046, 2050)),),
|
||||
@@ -329,6 +400,23 @@ def test_cutout_domain_builds_a_full_image_at_minimum_detail():
|
||||
assert len(domain[0]) >= 4
|
||||
assert len(domain[1]) >= 2
|
||||
|
||||
def test_cutout_domain_centers_vertices_on_crop_bounds():
|
||||
regions = ((((0, 0), (100, 0), (100, 50), (0, 50)),),)
|
||||
|
||||
vertices, _faces, uv, _boundary, _planar, _profile = mesh._cutout_domain(
|
||||
regions,
|
||||
(100, 50),
|
||||
(2.0, 6.0, -3.0, 1.0),
|
||||
0.5,
|
||||
((1.0, 0.0), (0.0, 1.0)),
|
||||
)
|
||||
|
||||
center = (vertices[:, :2].min(axis=0) + vertices[:, :2].max(axis=0)) * 0.5
|
||||
assert np.allclose(center, 0.0, atol=1e-6)
|
||||
expected_uv = vertices[:, :2] / np.asarray((4.0, 4.0)) + 0.5
|
||||
assert np.allclose(uv, expected_uv, atol=1e-6)
|
||||
|
||||
|
||||
def test_cutout_domain_keeps_overlapping_regions_disconnected():
|
||||
regions = (
|
||||
(
|
||||
|
||||
@@ -5,7 +5,7 @@ from unittest.mock import patch
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Matrix
|
||||
from mathutils import Matrix, Vector
|
||||
|
||||
SOURCE_ROOT = Path(__file__).parents[1] / "src"
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
@@ -66,12 +66,31 @@ def test_shape_node_groups_expose_only_shape_parameters():
|
||||
|
||||
plane = mesh._mesh_node_group(mesh.PLANE)
|
||||
depth_plane = mesh._mesh_node_group(mesh.DEPTH_PLANE)
|
||||
legacy_surface = bpy.data.node_groups.new(
|
||||
"O Image Cutout",
|
||||
"GeometryNodeTree",
|
||||
)
|
||||
cutout = mesh._mesh_node_group(mesh.CUTOUT)
|
||||
balloon = mesh._mesh_node_group(mesh.BALLOON)
|
||||
depth_balloon = mesh._mesh_node_group(mesh.DEPTH_BALLOON)
|
||||
depth_surface = mesh._mesh_node_group(mesh.DEPTH_SURFACE)
|
||||
|
||||
assert plane.name == "O Mesh Plane"
|
||||
assert cutout.name == "O Image Surface"
|
||||
assert cutout == legacy_surface
|
||||
assert _inputs(plane) == ["Geometry", "Subdivide", "Thickness", "Material"]
|
||||
assert _inputs(cutout) == ["Geometry", "Thickness"]
|
||||
assert _inputs(cutout) == ["Geometry", "Thickness", "Cleanup"]
|
||||
assert _inputs(balloon) == ["Geometry", "Thickness", "Cleanup"]
|
||||
assert _inputs(depth_balloon) == [
|
||||
"Geometry",
|
||||
"Thickness",
|
||||
"Depth Scale",
|
||||
"Double Sided",
|
||||
"Depth Reference",
|
||||
"Depth Image",
|
||||
"Cleanup",
|
||||
"Base Plane",
|
||||
]
|
||||
assert _inputs(depth_plane) == [
|
||||
"Geometry",
|
||||
"Subdivide",
|
||||
@@ -85,9 +104,46 @@ def test_shape_node_groups_expose_only_shape_parameters():
|
||||
"Thickness",
|
||||
"Depth Scale",
|
||||
"Iterations",
|
||||
"Smooth",
|
||||
"Depth Image",
|
||||
"Cleanup",
|
||||
"Depth Continuity",
|
||||
"Smooth",
|
||||
]
|
||||
assert len({group.as_pointer() for group in (
|
||||
cutout,
|
||||
balloon,
|
||||
depth_balloon,
|
||||
depth_surface,
|
||||
)}) == 4
|
||||
expected_options = {
|
||||
cutout: ["Cleanup"],
|
||||
balloon: ["Cleanup"],
|
||||
depth_balloon: ["Cleanup", "Base Plane"],
|
||||
depth_surface: ["Cleanup", "Depth Continuity", "Smooth"],
|
||||
}
|
||||
for group, expected in expected_options.items():
|
||||
panels = [
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "PANEL"
|
||||
]
|
||||
assert [panel.name for panel in panels] == ["Options"]
|
||||
assert panels[0].default_closed
|
||||
assert [item.name for item in panels[0].interface_items] == expected
|
||||
for group in (cutout, balloon, depth_balloon, depth_surface):
|
||||
extrudes = [
|
||||
node
|
||||
for node in group.nodes
|
||||
if node.bl_idname == "GeometryNodeExtrudeMesh"
|
||||
]
|
||||
assert extrudes
|
||||
assert all(
|
||||
not node.inputs["Individual"].default_value for node in extrudes
|
||||
)
|
||||
assert all(
|
||||
node.inputs["Offset Scale"].default_value == 1.0
|
||||
for node in extrudes
|
||||
)
|
||||
for group in (depth_plane, cutout, depth_surface):
|
||||
thickness = next(
|
||||
item
|
||||
@@ -95,6 +151,14 @@ def test_shape_node_groups_expose_only_shape_parameters():
|
||||
if item.item_type == "SOCKET" and item.name == "Thickness"
|
||||
)
|
||||
assert thickness.subtype == "DISTANCE"
|
||||
for group in (balloon, depth_balloon):
|
||||
thickness = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET" and item.name == "Thickness"
|
||||
)
|
||||
assert thickness.subtype == "NONE"
|
||||
assert thickness.default_value == 1.0
|
||||
for group, name in (
|
||||
(depth_plane, "Depth Amount"),
|
||||
(depth_surface, "Depth Scale"),
|
||||
@@ -145,6 +209,18 @@ def test_shape_node_groups_expose_only_shape_parameters():
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeRepeatOutput" for node in depth_surface.nodes
|
||||
)
|
||||
repeat_counts = {
|
||||
group: sum(
|
||||
node.bl_idname == "GeometryNodeRepeatInput" for node in group.nodes
|
||||
)
|
||||
for group in (cutout, balloon, depth_balloon, depth_surface)
|
||||
}
|
||||
assert repeat_counts == {
|
||||
cutout: 1,
|
||||
balloon: 0,
|
||||
depth_balloon: 0,
|
||||
depth_surface: 2,
|
||||
}
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeBlurAttribute" for node in depth_surface.nodes
|
||||
)
|
||||
@@ -155,7 +231,7 @@ def test_shape_node_groups_expose_only_shape_parameters():
|
||||
assert sum(
|
||||
node.bl_idname == "GeometryNodeInputMeshEdgeNeighbors"
|
||||
for node in depth_surface.nodes
|
||||
) == 2
|
||||
) == 3
|
||||
assert sum(
|
||||
node.bl_idname == "GeometryNodeInputMeshVertexNeighbors"
|
||||
for node in depth_surface.nodes
|
||||
@@ -191,12 +267,51 @@ def test_shape_node_groups_expose_only_shape_parameters():
|
||||
and node.inputs[0].default_value == 1.0
|
||||
for node in depth_surface.nodes
|
||||
)
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeInputNamedAttribute"
|
||||
and node.inputs["Name"].default_value == mesh.DEPTH_DISCONTINUITY_ATTRIBUTE
|
||||
for node in depth_surface.nodes
|
||||
depth_reference = next(
|
||||
item
|
||||
for item in depth_balloon.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Depth Reference"
|
||||
)
|
||||
for group in (depth_plane, cutout, depth_surface):
|
||||
assert depth_reference.hide_in_modifier
|
||||
assert depth_reference.subtype == "DISTANCE"
|
||||
for group in (depth_balloon, depth_surface):
|
||||
depth_image = next(
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
if item.item_type == "SOCKET"
|
||||
and item.in_out == "INPUT"
|
||||
and item.name == "Depth Image"
|
||||
)
|
||||
assert depth_image.hide_in_modifier
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeImageTexture" for node in group.nodes
|
||||
)
|
||||
for group in (cutout, balloon, depth_balloon, depth_surface):
|
||||
assert not any(
|
||||
node.bl_idname == "GeometryNodeInputShortestEdgePaths"
|
||||
for node in group.nodes
|
||||
)
|
||||
assert not any(
|
||||
node.bl_idname == "GeometryNodeInputNamedAttribute"
|
||||
and node.inputs["Name"].default_value == "o_boundary"
|
||||
for node in group.nodes
|
||||
)
|
||||
for group in (balloon, depth_balloon):
|
||||
profile = next(
|
||||
node
|
||||
for node in group.nodes
|
||||
if node.bl_idname == "GeometryNodeInputNamedAttribute"
|
||||
and node.inputs["Name"].default_value == mesh.BALLOON_ATTRIBUTE
|
||||
)
|
||||
assert profile.data_type == "FLOAT"
|
||||
assert any(
|
||||
node.bl_idname == "ShaderNodeMapRange"
|
||||
and node.interpolation_type == "SMOOTHERSTEP"
|
||||
for node in group.nodes
|
||||
)
|
||||
for group in (depth_plane, cutout, balloon, depth_balloon, depth_surface):
|
||||
input_sockets = [
|
||||
item
|
||||
for item in group.interface.items_tree
|
||||
@@ -211,21 +326,21 @@ def test_shape_node_groups_expose_only_shape_parameters():
|
||||
for output in group_input.outputs
|
||||
if output.name
|
||||
)
|
||||
geometry_links = [
|
||||
link
|
||||
for link in group.links
|
||||
if link.from_socket.type == "GEOMETRY"
|
||||
and link.to_socket.type == "GEOMETRY"
|
||||
]
|
||||
assert all(
|
||||
link.from_node.location.x <= link.to_node.location.x
|
||||
for link in geometry_links
|
||||
for link in group.links
|
||||
)
|
||||
assert all(not node.label for node in group.nodes)
|
||||
node_locations = {tuple(node.location) for node in depth_surface.nodes}
|
||||
assert len(node_locations) == len(depth_surface.nodes)
|
||||
depth_plane_locations = {tuple(node.location) for node in depth_plane.nodes}
|
||||
assert len(depth_plane_locations) == len(depth_plane.nodes)
|
||||
node_locations = {tuple(node.location) for node in group.nodes}
|
||||
assert len(node_locations) == len(group.nodes)
|
||||
if group in (cutout, balloon, depth_balloon, depth_surface):
|
||||
group_nodes = list(group.nodes)
|
||||
for index, node in enumerate(group_nodes):
|
||||
for other in group_nodes[index + 1:]:
|
||||
assert not (
|
||||
abs(node.location.x - other.location.x) < 150.0
|
||||
and abs(node.location.y - other.location.y) < 100.0
|
||||
)
|
||||
assert any(
|
||||
node.bl_idname == "GeometryNodeSetShadeSmooth"
|
||||
and node.inputs["Shade Smooth"].default_value
|
||||
@@ -344,9 +459,7 @@ def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
|
||||
assert len(result.data.vertices) == 4
|
||||
assert len(result.data.polygons) == 1
|
||||
assert all(polygon.use_smooth for polygon in result.data.polygons)
|
||||
assert mesh.PROFILE_ATTRIBUTE not in result.data.attributes
|
||||
assert mesh.DEPTH_ATTRIBUTE not in result.data.attributes
|
||||
assert mesh.FRONT_FACE_ATTRIBUTE not in result.data.attributes
|
||||
assert mesh.BALLOON_ATTRIBUTE not in result.data.attributes
|
||||
assert result.modifiers[0].node_group.name == "O Mesh Plane"
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
@@ -355,6 +468,115 @@ def test_build_plane_keeps_one_quad_and_generates_topology_in_geometry_nodes():
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
def test_balloon_build_starts_from_the_unified_single_sheet():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Balloon Sheet", width=64, height=64, alpha=True)
|
||||
source = bpy.data.objects.new("Balloon Sheet", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
balloon_material = bpy.data.materials.new("Balloon Sheet.material")
|
||||
|
||||
result = mesh._build_mesh_object(
|
||||
bpy.context,
|
||||
source,
|
||||
mesh.BALLOON,
|
||||
8,
|
||||
balloon_material,
|
||||
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
|
||||
image_size=(64, 64),
|
||||
keep_original=True,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
||||
assert len(result.data.vertices) > 4
|
||||
assert len(result.data.polygons) > 1
|
||||
assert mesh.BALLOON_ATTRIBUTE in result.data.attributes
|
||||
profile_attribute = result.data.attributes[mesh.BALLOON_ATTRIBUTE]
|
||||
assert profile_attribute.data_type == "FLOAT"
|
||||
assert profile_attribute.domain == "POINT"
|
||||
profile = np.empty(len(result.data.vertices), dtype=np.float32)
|
||||
profile_attribute.data.foreach_get("value", profile)
|
||||
assert profile.min() == 0.0 and profile.max() > 0.0
|
||||
assert result.modifiers[0].node_group.name == "O Image Balloon"
|
||||
inward = result.matrix_world.to_3x3() @ Vector((1.0, 0.0, 0.0))
|
||||
assert np.allclose(tuple(inward), (0.0, 0.0, -1.0), atol=1e-6)
|
||||
|
||||
|
||||
def test_teddy_preference_writes_the_shared_balloon_attribute():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Teddy Sheet", width=64, height=64, alpha=True)
|
||||
source = bpy.data.objects.new("Teddy Sheet", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
teddy_material = bpy.data.materials.new("Teddy Sheet.material")
|
||||
|
||||
with patch.object(
|
||||
mesh,
|
||||
"addon_preferences",
|
||||
return_value=SimpleNamespace(balloon_profile_algorithm="TEDDY"),
|
||||
):
|
||||
result = mesh._build_mesh_object(
|
||||
bpy.context,
|
||||
source,
|
||||
mesh.CUTOUT,
|
||||
8,
|
||||
teddy_material,
|
||||
regions=((((0, 0), (64, 0), (64, 64), (0, 64)),),),
|
||||
image_size=(64, 64),
|
||||
keep_original=True,
|
||||
)
|
||||
|
||||
attribute = result.data.attributes[mesh.BALLOON_ATTRIBUTE]
|
||||
profile = np.empty(len(result.data.vertices), dtype=np.float32)
|
||||
attribute.data.foreach_get("value", profile)
|
||||
assert profile.min() == 0.0 and profile.max() > 0.0
|
||||
|
||||
|
||||
def test_depth_surface_object_normal_is_remapped_to_cutout_axes():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new(
|
||||
"Surface Normal",
|
||||
width=1,
|
||||
height=1,
|
||||
alpha=True,
|
||||
float_buffer=True,
|
||||
)
|
||||
image.pixels.foreach_set((0.5, 0.5, 1.0, 1.0))
|
||||
|
||||
mesh._orient_cutout_object_normal(image)
|
||||
|
||||
pixels = np.empty(4, dtype=np.float32)
|
||||
image.pixels.foreach_get(pixels)
|
||||
assert np.allclose(pixels, (0.0, 0.5, 0.5, 1.0), atol=1e-6)
|
||||
|
||||
|
||||
def test_internal_mesh_depth_images_use_standard_data_name():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
cutout_source = bpy.data.objects.new("Cutout Source", None)
|
||||
cutout_source.data = bpy.data.images.new(
|
||||
"Cutout Source.png", width=2, height=2, alpha=True
|
||||
)
|
||||
cutout_depth = mesh._cutout_depth_image(
|
||||
cutout_source,
|
||||
np.ones((2, 2), dtype=np.float32),
|
||||
)
|
||||
assert cutout_depth.name == "Cutout Source_depth.exr"
|
||||
|
||||
plane_source = bpy.data.objects.new("Plane Source", None)
|
||||
plane_source.data = bpy.data.images.new(
|
||||
"Plane Source.png", width=2, height=2, alpha=True
|
||||
)
|
||||
anchored = np.zeros((2, 2, 3), dtype=np.float32)
|
||||
plane_depth = mesh._depth_field_image(plane_source, anchored)
|
||||
assert plane_depth.name == "Plane Source_depth.exr"
|
||||
|
||||
|
||||
|
||||
def test_depth_surface_protected_smooth_evaluates():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Surface", width=64, height=64)
|
||||
@@ -396,8 +618,8 @@ def test_depth_surface_protected_smooth_evaluates():
|
||||
keep_original=True,
|
||||
)
|
||||
|
||||
assert mesh.BOUNDARY_ATTRIBUTE in result.data.attributes
|
||||
assert mesh.DEPTH_PROTECTION_ATTRIBUTE in result.data.attributes
|
||||
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
||||
assert mesh.BALLOON_ATTRIBUTE in result.data.attributes
|
||||
modifier = result.modifiers[0]
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
@@ -407,9 +629,9 @@ def test_depth_surface_protected_smooth_evaluates():
|
||||
evaluated = result.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
baseline_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
baseline_z = np.asarray([vertex.co.z for vertex in baseline_mesh.vertices])
|
||||
baseline_min = baseline_z.min()
|
||||
baseline_max = baseline_z.max()
|
||||
baseline_x = np.asarray([vertex.co.x for vertex in baseline_mesh.vertices])
|
||||
baseline_min = baseline_x.min()
|
||||
baseline_max = baseline_x.max()
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
@@ -424,9 +646,9 @@ def test_depth_surface_protected_smooth_evaluates():
|
||||
evaluated_mesh = evaluated.to_mesh()
|
||||
try:
|
||||
assert len(evaluated_mesh.polygons) > 0
|
||||
z = np.asarray([vertex.co.z for vertex in evaluated_mesh.vertices])
|
||||
assert np.isclose(z.max(), baseline_max, atol=1e-5)
|
||||
assert np.isclose(z.min(), baseline_min - 1.0, atol=1e-5)
|
||||
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)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
@@ -497,6 +719,361 @@ def test_depth_surface_protected_smooth_evaluates():
|
||||
assert np.allclose(internal_positions, external_positions, atol=1e-6)
|
||||
|
||||
|
||||
|
||||
def _evaluated_positions(obj):
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
return np.asarray([tuple(vertex.co) for vertex in result.vertices])
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
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)
|
||||
]
|
||||
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)))
|
||||
data = bpy.data.meshes.new("Connected Cutout")
|
||||
data.from_pydata(vertices, (), faces)
|
||||
obj = bpy.data.objects.new("Connected Cutout", data)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = obj.modifiers.new("O Image Surface", "NODES")
|
||||
modifier.node_group = mesh._mesh_node_group(mesh.CUTOUT)
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Cleanup"),
|
||||
0.0,
|
||||
)
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
1.0,
|
||||
)
|
||||
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
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)
|
||||
front_faces = [
|
||||
polygon for polygon in result.polygons if polygon.normal.x < -0.9
|
||||
]
|
||||
back_faces = [
|
||||
polygon for polygon in result.polygons if polygon.normal.x > 0.9
|
||||
]
|
||||
side_faces = [
|
||||
polygon for polygon in result.polygons if abs(polygon.normal.x) < 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))
|
||||
for polygon in side_faces:
|
||||
radial = polygon.center - center
|
||||
assert polygon.normal.dot(radial) > 0.0
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
2.4,
|
||||
)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
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
|
||||
]
|
||||
assert len(side_faces) == 24
|
||||
for polygon in side_faces:
|
||||
coordinates = [result.vertices[index].co for index in polygon.vertices]
|
||||
edge_lengths = [
|
||||
(coordinates[(index + 1) % len(coordinates)] - coordinate).length
|
||||
for index, coordinate in enumerate(coordinates)
|
||||
]
|
||||
assert max(edge_lengths) / min(edge_lengths) <= 1.25 + 1e-6
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
|
||||
|
||||
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)
|
||||
]
|
||||
faces = []
|
||||
for z in range(2):
|
||||
for y in range(2):
|
||||
a = z * 3 + y
|
||||
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
||||
data = bpy.data.meshes.new("Balloon Profile")
|
||||
data.from_pydata(vertices, (), faces)
|
||||
mesh._assign_float_point_attribute(
|
||||
data,
|
||||
mesh.BALLOON_ATTRIBUTE,
|
||||
(0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0),
|
||||
)
|
||||
obj = bpy.data.objects.new("Balloon Profile", data)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = obj.modifiers.new("O Image Balloon", "NODES")
|
||||
modifier.node_group = mesh._mesh_node_group(mesh.BALLOON)
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Cleanup"),
|
||||
0.0,
|
||||
)
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
1.0,
|
||||
)
|
||||
|
||||
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[:, 0].min(),
|
||||
-positions[:, 0].max(),
|
||||
atol=1e-5,
|
||||
)
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
result = evaluated.to_mesh()
|
||||
try:
|
||||
baseline_face_count = len(result.polygons)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
2.0,
|
||||
)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
evaluated = obj.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
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)
|
||||
finally:
|
||||
evaluated.to_mesh_clear()
|
||||
mesh.set_modifier_input(
|
||||
modifier,
|
||||
mesh.modifier_input_identifier(modifier.node_group, "Thickness"),
|
||||
0.0,
|
||||
)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
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)
|
||||
|
||||
|
||||
def test_cutout_cleanup_removes_only_small_mesh_island():
|
||||
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.extend(
|
||||
((0.0, 5.0, 5.0), (0.0, 5.01, 5.0), (0.0, 5.0, 5.01))
|
||||
)
|
||||
faces = []
|
||||
for z in range(2):
|
||||
for y in range(2):
|
||||
a = z * 3 + y
|
||||
faces.extend(((a, a + 1, a + 4), (a, a + 4, a + 3)))
|
||||
faces.append((9, 10, 11))
|
||||
data = bpy.data.meshes.new("Cleanup")
|
||||
data.from_pydata(vertices, (), faces)
|
||||
obj = bpy.data.objects.new("Cleanup", data)
|
||||
bpy.context.collection.objects.link(obj)
|
||||
modifier = obj.modifiers.new("O Image Surface", "NODES")
|
||||
modifier.node_group = mesh._mesh_node_group(mesh.CUTOUT)
|
||||
cleanup_id = mesh.modifier_input_identifier(modifier.node_group, "Cleanup")
|
||||
|
||||
mesh.set_modifier_input(modifier, cleanup_id, 0.0)
|
||||
bpy.context.view_layer.update()
|
||||
assert len(_evaluated_positions(obj)) == 12
|
||||
|
||||
mesh.set_modifier_input(modifier, cleanup_id, 1.0)
|
||||
obj.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
assert len(_evaluated_positions(obj)) == 9
|
||||
|
||||
|
||||
def test_depth_balloon_base_plane_uses_crop_center_and_matching_arrow_direction():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
image = bpy.data.images.new("Depth Balloon", width=100, height=100, alpha=True)
|
||||
source = bpy.data.objects.new("Depth Balloon", None)
|
||||
source.empty_display_type = "IMAGE"
|
||||
source.data = image
|
||||
source.empty_display_size = 2.0
|
||||
bpy.context.collection.objects.link(source)
|
||||
balloon_material = bpy.data.materials.new("Depth Balloon.material")
|
||||
crop_bounds = (50, 20, 90, 80)
|
||||
crop = ((0, 0), (40, 0), (40, 60), (0, 60))
|
||||
local_bounds = mesh._crop_plane_bounds(
|
||||
source,
|
||||
crop_bounds,
|
||||
(100, 100),
|
||||
)
|
||||
depth_data = {
|
||||
"depth_signal": np.ones((61, 41), dtype=np.float32),
|
||||
"baseline_vector": np.zeros(3, dtype=np.float32),
|
||||
"valid": np.ones((61, 41), dtype=bool),
|
||||
"origin": (50, 20),
|
||||
"reference_depth": 1.0,
|
||||
"crop_size": (40, 60),
|
||||
"source_size": (100, 100),
|
||||
}
|
||||
|
||||
result = mesh._build_mesh_object(
|
||||
bpy.context,
|
||||
source,
|
||||
mesh.DEPTH_BALLOON,
|
||||
8,
|
||||
balloon_material,
|
||||
depth_data=depth_data,
|
||||
regions=((crop,),),
|
||||
image_size=(40, 60),
|
||||
bounds=local_bounds,
|
||||
keep_original=True,
|
||||
)
|
||||
|
||||
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
||||
assert mesh.BALLOON_ATTRIBUTE in result.data.attributes
|
||||
assert "o_balloon_depth" not in result.data.attributes
|
||||
assert "o_depth_offset" not in result.data.attributes
|
||||
modifier = result.modifiers[0]
|
||||
base_plane_id = mesh.modifier_input_identifier(
|
||||
modifier.node_group,
|
||||
"Base Plane",
|
||||
)
|
||||
depth_image_id = mesh.modifier_input_identifier(
|
||||
modifier.node_group,
|
||||
"Depth Image",
|
||||
)
|
||||
base_plane = modifier[base_plane_id]
|
||||
assert isinstance(modifier[depth_image_id], bpy.types.Image)
|
||||
expected_center = np.zeros(2, dtype=np.float64)
|
||||
assert np.allclose(base_plane.location[1:], expected_center, atol=1e-5)
|
||||
crop_center = Vector(
|
||||
(
|
||||
(local_bounds[0] + local_bounds[1]) * 0.5,
|
||||
(local_bounds[2] + local_bounds[3]) * 0.5,
|
||||
0.0,
|
||||
)
|
||||
)
|
||||
expected_origin = source.matrix_world @ crop_center
|
||||
assert np.allclose(
|
||||
tuple(result.matrix_world.translation),
|
||||
tuple(expected_origin),
|
||||
atol=1e-5,
|
||||
)
|
||||
arrow = base_plane.rotation_euler.to_matrix() @ Vector((0.0, 0.0, 1.0))
|
||||
assert np.allclose(tuple(arrow), (-1.0, 0.0, 0.0), atol=1e-5)
|
||||
|
||||
depth_reference_id = mesh.modifier_input_identifier(
|
||||
modifier.node_group,
|
||||
"Depth Reference",
|
||||
)
|
||||
assert modifier[depth_reference_id] > 0.0
|
||||
thickness_id = mesh.modifier_input_identifier(
|
||||
modifier.node_group,
|
||||
"Thickness",
|
||||
)
|
||||
positions = _evaluated_positions(result)
|
||||
center_distance = np.linalg.norm(positions[:, 1:] - expected_center, axis=1)
|
||||
nearest_center = center_distance <= center_distance.min() + 1e-6
|
||||
depth_at_thickness_one = positions[nearest_center, 0].min()
|
||||
mesh.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)
|
||||
nearest_center = center_distance <= center_distance.min() + 1e-6
|
||||
depth_at_thickness_two = positions[nearest_center, 0].min()
|
||||
assert np.isclose(
|
||||
depth_at_thickness_one,
|
||||
depth_at_thickness_two,
|
||||
atol=1e-5,
|
||||
)
|
||||
mesh.set_modifier_input(modifier, thickness_id, 1.0)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
before_positions = _evaluated_positions(result)
|
||||
base_plane.location += arrow * 0.2
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
after_positions = _evaluated_positions(result)
|
||||
assert np.isclose(
|
||||
(after_positions[:, 0] - before_positions[:, 0]).min(),
|
||||
-0.2,
|
||||
atol=1e-5,
|
||||
)
|
||||
|
||||
depth_scale_id = mesh.modifier_input_identifier(
|
||||
modifier.node_group,
|
||||
"Depth Scale",
|
||||
)
|
||||
mesh.set_modifier_input(modifier, depth_scale_id, 0.0)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
zero_scale_before = _evaluated_positions(result)
|
||||
base_plane.location += arrow * 0.15
|
||||
base_plane.rotation_euler.rotate_axis("Y", np.radians(12.0))
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
zero_scale_after = _evaluated_positions(result)
|
||||
assert np.allclose(zero_scale_after, zero_scale_before, atol=1e-5)
|
||||
mesh.set_modifier_input(modifier, depth_scale_id, 1.0)
|
||||
|
||||
double_sided_id = mesh.modifier_input_identifier(
|
||||
modifier.node_group,
|
||||
"Double Sided",
|
||||
)
|
||||
mesh.set_modifier_input(modifier, double_sided_id, True)
|
||||
result.update_tag(refresh={"DATA"})
|
||||
bpy.context.view_layer.update()
|
||||
mirrored = _evaluated_positions(result)
|
||||
layers = {}
|
||||
for x, y, z in mirrored:
|
||||
layers.setdefault((round(float(y), 6), round(float(z), 6)), []).append(x)
|
||||
assert layers
|
||||
for x_values in layers.values():
|
||||
if len(x_values) == 1:
|
||||
assert np.isclose(x_values[0], 0.0, atol=1e-5)
|
||||
continue
|
||||
assert np.isclose(
|
||||
min(x_values) + max(x_values),
|
||||
0.0,
|
||||
atol=1e-5,
|
||||
)
|
||||
|
||||
|
||||
def test_loaded_normal_image_uses_normal_texture_name(tmp_path):
|
||||
from PIL import Image
|
||||
|
||||
@@ -558,9 +1135,15 @@ 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)
|
||||
vertex_x = [vertex.co.x for vertex in result.data.vertices]
|
||||
assert min(vertex_x) < source_bounds[0]
|
||||
assert max(vertex_x) > source_bounds[1]
|
||||
assert all(abs(vertex.co.x) < 1e-8 for vertex in result.data.vertices)
|
||||
assert mesh.BALLOON_ATTRIBUTE in result.data.attributes
|
||||
source_inverse = source.matrix_world.inverted()
|
||||
source_x = [
|
||||
(source_inverse @ (result.matrix_world @ vertex.co)).x
|
||||
for vertex in result.data.vertices
|
||||
]
|
||||
assert min(source_x) < source_bounds[0]
|
||||
assert max(source_x) > source_bounds[1]
|
||||
assert bpy.data.objects.get(source.name) is source
|
||||
assert result.data.materials[0].node_tree is not None
|
||||
color_texture = next(
|
||||
|
||||
@@ -67,7 +67,13 @@ class PackTest(unittest.TestCase):
|
||||
self.assertIn("operators/depth_plane/operator.py", names)
|
||||
self.assertIn("operators/mesh_plane.py", names)
|
||||
self.assertIn("operators/image.py", names)
|
||||
self.assertIn("operators/balloon/operator.py", names)
|
||||
self.assertIn("operators/cutout_geometry.py", names)
|
||||
self.assertIn("operators/cutout_nodes.py", names)
|
||||
self.assertIn("operators/cutout_depth.py", names)
|
||||
self.assertIn("operators/cutout_profile.py", names)
|
||||
self.assertIn("operators/cutout_teddy.py", names)
|
||||
self.assertNotIn("operators/cutout_poisson.py", names)
|
||||
self.assertNotIn("operators/balloon/operator.py", names)
|
||||
self.assertIn("operators/image_edit/__init__.py", names)
|
||||
self.assertIn("operators/image_edit/common.py", names)
|
||||
self.assertIn("operators/image_edit/mask.py", names)
|
||||
@@ -76,6 +82,7 @@ class PackTest(unittest.TestCase):
|
||||
self.assertIn("operators/image_edit/line.py", names)
|
||||
self.assertIn("operators/image_edit/polyline.py", names)
|
||||
self.assertIn("operators/image_edit/perspective.py", names)
|
||||
self.assertIn("operators/image_edit/interaction.py", names)
|
||||
self.assertIn("operators/surface.py", names)
|
||||
self.assertIn(
|
||||
"assets/O_AnyImage.blend",
|
||||
@@ -87,6 +94,8 @@ class PackTest(unittest.TestCase):
|
||||
self.assertIn("server/geometry/surface.py", names)
|
||||
self.assertIn("server/jobs/background.py", names)
|
||||
self.assertIn("server/jobs/image_mask.py", names)
|
||||
self.assertIn("server/jobs/cutout_depth.py", names)
|
||||
self.assertNotIn("server/jobs/balloon.py", names)
|
||||
self.assertIn("server/models/ben2.py", names)
|
||||
self.assertIn("server/models/da3.py", names)
|
||||
self.assertIn("server/models/moge2.py", names)
|
||||
|
||||
@@ -41,7 +41,7 @@ class ServerTest(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.server_module = load_server_module()
|
||||
importlib.import_module("server.jobs.balloon")
|
||||
importlib.import_module("server.jobs.cutout_depth")
|
||||
importlib.import_module("server.jobs.mesh")
|
||||
from blendjob import JobServer
|
||||
from server.model_manager import ModelManager, ModelSessionCache
|
||||
@@ -415,32 +415,6 @@ class ServerTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual(status["state"], "succeeded")
|
||||
|
||||
def test_balloon_lasso_input_crops_and_clears_outside_rgba(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
balloon = sys.modules["server.jobs.balloon"]
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
source_path = root / "source.png"
|
||||
output_path = root / "lasso.png"
|
||||
pixels = np.full((6, 6, 4), (20, 40, 60, 255), dtype=np.uint8)
|
||||
pixels[1, 1, 3] = 64
|
||||
Image.fromarray(pixels, mode="RGBA").save(source_path)
|
||||
|
||||
_path, _selection, origin, source_size = balloon.create_lasso_input(
|
||||
source_path,
|
||||
((1, 1), (5, 1), (1, 5)),
|
||||
output_path,
|
||||
)
|
||||
result = np.asarray(Image.open(output_path).convert("RGBA"))
|
||||
|
||||
self.assertEqual(origin, (1, 1))
|
||||
self.assertEqual(source_size, (6, 6))
|
||||
self.assertEqual(result.shape, (4, 4, 4))
|
||||
self.assertEqual(tuple(result[0, 0]), (20, 40, 60, 64))
|
||||
self.assertEqual(tuple(result[3, 3]), (0, 0, 0, 0))
|
||||
|
||||
def test_polygon_selection_mask_uses_even_odd_fill_for_self_intersection(self):
|
||||
image_mask = sys.modules["server.jobs.image_mask"]
|
||||
polygon = (
|
||||
@@ -783,7 +757,7 @@ class ServerTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
with patch.object(mesh_job.moge2, "infer", side_effect=infer):
|
||||
depth_path, normal_path = mesh_job._write_balloon_depth(
|
||||
depth_path, normal_path = mesh_job._write_depth_balloon(
|
||||
context,
|
||||
{"device": "cpu"},
|
||||
source_path,
|
||||
@@ -909,53 +883,10 @@ class ServerTest(unittest.TestCase):
|
||||
)
|
||||
alpha = np.asarray(Image.open(output_path).convert("RGBA"))[:, :, 3]
|
||||
|
||||
def test_balloon_job_sends_the_masked_crop_to_ben2(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
balloon = sys.modules["server.jobs.balloon"]
|
||||
source_path = self.storage_root / "source.png"
|
||||
pixels = np.full((6, 6, 4), (20, 40, 60, 255), dtype=np.uint8)
|
||||
Image.fromarray(pixels, mode="RGBA").save(source_path)
|
||||
job_directory = self.storage_root / "job"
|
||||
context = SimpleNamespace(
|
||||
directory=job_directory,
|
||||
progress=lambda *_args: None,
|
||||
check_cancelled=lambda: None,
|
||||
resource=lambda _name: SimpleNamespace(
|
||||
directory=lambda _model: self.storage_root / "model"
|
||||
),
|
||||
)
|
||||
received = []
|
||||
|
||||
def infer(input_path, *_args, **_kwargs):
|
||||
image = Image.open(input_path).convert("RGBA")
|
||||
received.append(np.asarray(image))
|
||||
return image, 0.0, 1.0
|
||||
|
||||
with patch.object(balloon, "infer_foreground", side_effect=infer):
|
||||
result = balloon.run(
|
||||
context,
|
||||
{
|
||||
"input": str(source_path),
|
||||
"polygon": ((1, 1), (5, 1), (1, 5)),
|
||||
"contour_analysis": True,
|
||||
"depth_analysis": False,
|
||||
"contour_model": "BEN2_BASE",
|
||||
"device": "cpu",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(result, {"contour": "contour.json"})
|
||||
self.assertEqual(tuple(received[0][3, 3]), (0, 0, 0, 0))
|
||||
self.assertTrue((job_directory / "lasso-input.png").is_file())
|
||||
self.assertTrue((job_directory / "foreground.png").is_file())
|
||||
self.assertTrue((job_directory / "contour.json").is_file())
|
||||
|
||||
def test_balloon_depth_analysis_normalizes_moge_distance(self):
|
||||
import numpy as np
|
||||
|
||||
balloon = sys.modules["server.jobs.balloon"]
|
||||
balloon = sys.modules["server.jobs.cutout_depth"]
|
||||
y, x = np.indices((7, 9), dtype=np.float32)
|
||||
depth = 2.0 + x * 0.1 + y * 0.2
|
||||
depth[3, 4] -= 0.5
|
||||
@@ -996,10 +927,44 @@ class ServerTest(unittest.TestCase):
|
||||
places=5,
|
||||
)
|
||||
|
||||
def test_balloon_depth_plane_is_stable_for_a_slender_diagonal_mask(self):
|
||||
import numpy as np
|
||||
|
||||
balloon = sys.modules["server.jobs.cutout_depth"]
|
||||
y, x = np.indices((101, 101), dtype=np.float64)
|
||||
normalized_x = x / 50.0 - 1.0
|
||||
normalized_y = y / 50.0 - 1.0
|
||||
distance = y - x
|
||||
selection = np.abs(distance) <= 1.0
|
||||
along = (normalized_x + normalized_y) * 0.5
|
||||
depth = 4.0 + 0.4 * along
|
||||
depth += np.where(distance > 0.0, 0.05, 0.0)
|
||||
depth -= np.where(distance < 0.0, 0.05, 0.0)
|
||||
|
||||
_signal, valid, _reference, baseline = balloon.normalized_moge_depth(
|
||||
depth,
|
||||
np.ones_like(selection),
|
||||
selection,
|
||||
)
|
||||
fitted = (
|
||||
baseline[0] * normalized_x
|
||||
+ baseline[1] * normalized_y
|
||||
+ baseline[2]
|
||||
)
|
||||
|
||||
self.assertLessEqual(
|
||||
float(np.ptp(fitted)),
|
||||
float(np.ptp(fitted[valid])) * 1.25,
|
||||
)
|
||||
self.assertGreater(
|
||||
abs(float(np.corrcoef(fitted[valid], along[valid])[0, 1])),
|
||||
0.99,
|
||||
)
|
||||
|
||||
def test_balloon_relative_depth_ignores_absolute_distance_and_scale(self):
|
||||
import numpy as np
|
||||
|
||||
balloon = sys.modules["server.jobs.balloon"]
|
||||
balloon = sys.modules["server.jobs.cutout_depth"]
|
||||
y, x = np.indices((8, 10), dtype=np.float32)
|
||||
depth = 4.0 + x * 0.04 + y * 0.03
|
||||
depth[2:6, 3:7] -= np.asarray(
|
||||
@@ -1022,100 +987,5 @@ class ServerTest(unittest.TestCase):
|
||||
np.testing.assert_allclose(rescaled[0], original[0], atol=2e-4)
|
||||
np.testing.assert_allclose(rescaled[3], original[3], atol=2e-4)
|
||||
|
||||
def test_balloon_depth_job_sends_masked_crop_only_to_moge(self):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
balloon = sys.modules["server.jobs.balloon"]
|
||||
source_path = self.storage_root / "depth-source.png"
|
||||
pixels = np.full((6, 6, 4), (80, 100, 120, 255), dtype=np.uint8)
|
||||
Image.fromarray(pixels, mode="RGBA").save(source_path)
|
||||
job_directory = self.storage_root / "depth-job"
|
||||
context = SimpleNamespace(
|
||||
directory=job_directory,
|
||||
progress=lambda *_args: None,
|
||||
check_cancelled=lambda: None,
|
||||
resource=lambda _name: SimpleNamespace(),
|
||||
)
|
||||
received = []
|
||||
|
||||
def infer(_manager, _parameters, images, **_kwargs):
|
||||
image = Image.open(images[0]).convert("RGBA")
|
||||
received.append(np.asarray(image))
|
||||
height, width = image.size[1], image.size[0]
|
||||
depth = np.linspace(
|
||||
1.0,
|
||||
2.0,
|
||||
height * width,
|
||||
dtype=np.float32,
|
||||
).reshape(height, width)
|
||||
return SimpleNamespace(
|
||||
depth=depth[None],
|
||||
mask=np.ones((1, height, width), dtype=bool),
|
||||
normal=np.broadcast_to(
|
||||
np.asarray((0.0, 0.0, -1.0), dtype=np.float32),
|
||||
(1, height, width, 3),
|
||||
).copy(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(balloon.moge2, "infer", side_effect=infer),
|
||||
patch.object(
|
||||
balloon,
|
||||
"infer_foreground",
|
||||
side_effect=AssertionError("BEN2 should not run"),
|
||||
),
|
||||
):
|
||||
result = balloon.run(
|
||||
context,
|
||||
{
|
||||
"input": str(source_path),
|
||||
"polygon": ((1, 1), (5, 1), (1, 5)),
|
||||
"contour_analysis": False,
|
||||
"depth_analysis": True,
|
||||
"depth_model": "MOGE2_VITS_NORMAL",
|
||||
"resolution_level": 5,
|
||||
"device": "cpu",
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result,
|
||||
{"depth": "depth.npz", "normal": "normal.png"},
|
||||
)
|
||||
self.assertEqual(tuple(received[0][3, 3]), (0, 0, 0, 0))
|
||||
self.assertFalse((job_directory / "foreground.png").exists())
|
||||
with np.load(job_directory / "depth.npz", allow_pickle=False) as data:
|
||||
self.assertEqual(data["depth_signal"].shape, (4, 4))
|
||||
self.assertEqual(data["baseline_vector"].shape, (3,))
|
||||
self.assertEqual(tuple(data["origin"]), (1, 1))
|
||||
self.assertEqual(tuple(data["source_size"]), (6, 6))
|
||||
self.assertTrue(data["valid"].any())
|
||||
normal = np.asarray(Image.open(job_directory / "normal.png").convert("RGB"))
|
||||
self.assertEqual(normal.shape, (6, 6, 3))
|
||||
self.assertEqual(tuple(normal[0, 0]), (128, 128, 255))
|
||||
|
||||
def test_balloon_contour_uses_alpha_above_half_and_largest_component(self):
|
||||
import numpy as np
|
||||
|
||||
balloon = sys.modules["server.jobs.balloon"]
|
||||
alpha = np.zeros((6, 7), dtype=np.uint8)
|
||||
alpha[0, 0] = 255
|
||||
alpha[1:4, 2:5] = 128
|
||||
alpha[1, 1] = 127
|
||||
selection_mask = np.ones_like(alpha, dtype=bool)
|
||||
|
||||
contour = balloon.contour_from_alpha(
|
||||
alpha,
|
||||
selection_mask,
|
||||
origin=(10, 20),
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
set(map(tuple, contour)),
|
||||
{(12, 21), (15, 21), (15, 24), (12, 24)},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user