feat(cutout): implement boundary padding for cutout textures

- Introduced a new `Boundary Padding` preference in the Cutout Tool with a default of 2 px and a configurable range of 1-4 px.
- Updated the Cutout client to establish inward mapping from the final Mask, sampling in a fixed direction and processing independent material RGBA and Depth.
- Enhanced the handling of RGBA and Depth during cutout operations, ensuring proper mapping and preservation of source data.
- Added tests to validate the new boundary padding functionality and its integration with existing cutout processes.

feat(geometry): remove overall mesh smoothing from geometry nodes

- Removed the `Smooth` input from several geometry node groups to eliminate unnecessary overall mesh smoothing.
- Retained `Smooth Weight` for depth nodes while removing it from Cutout and Relief Plane nodes.
- Ensured that boundary, normal, and fill smoothing behaviors remain intact and consistent.
- Updated tests and documentation to reflect the changes in node interfaces and behaviors.

feat(normals): scale tangent normals with depth

- Reused the existing `o_normal_reduction` attribute to adjust Tangent Normal strength based on the geometry's `Depth Scale`.
- Ensured that Tangent Normal strength is consistent with the geometry depth, preserving full strength for materials that do not provide the attribute.
- Updated tests to verify the correct behavior of normal strength adjustments in both Tangent and Object Space.
This commit is contained in:
2026-09-15 00:40:24 +08:00
parent ab282e4fdf
commit 591e62085a
93 changed files with 1915 additions and 2851 deletions

View File

@@ -0,0 +1,130 @@
import numpy as np
from anyimage.operators.cutout_tool.boundary_padding import (
apply_boundary_mapping,
apply_depth_boundary_mapping,
build_boundary_mapping,
image_rgba_buffer,
pad_cutout_images,
)
def square_with_hole():
values = np.zeros((17, 17), dtype=np.float32)
values[3:14, 3:14] = 1
values[7:10, 7:10] = 0
return values
def test_outer_and_hole_boundaries_follow_the_local_interior():
mapping = build_boundary_mapping(square_with_hole(), 0.9, 2)
assert mapping.target[2, 8]
assert mapping.source_y[2, 8] > 3
assert abs(mapping.source_x[2, 8] - 8) < 0.25
assert mapping.target[7, 8]
assert mapping.source_y[7, 8] < 7
assert abs(mapping.source_x[7, 8] - 8) < 0.5
def test_mapping_moves_along_one_direction_instead_of_nearest_content():
values = square_with_hole()
yy, xx = np.mgrid[:17, :17]
rgba = np.stack((xx, yy, xx + yy, np.ones_like(xx)), axis=-1).astype(np.float32)
mapping = build_boundary_mapping(values, 0.9, 3)
result = apply_boundary_mapping(rgba, mapping)
assert result[1, 8, 1] > 3
assert abs(result[1, 8, 0] - 8) < 0.5
def test_thin_structure_stops_at_the_last_point_on_the_same_ray():
values = np.zeros((11, 15), dtype=np.float32)
values[5:7, 2:13] = 1
mapping = build_boundary_mapping(values, 0.9, 4)
sampled_inside = values[
np.rint(mapping.source_y[mapping.target]).astype(int),
np.rint(mapping.source_x[mapping.target]).astype(int),
]
assert sampled_inside.size
assert np.all(sampled_inside == 1)
assert np.isfinite(mapping.source_y).all()
assert np.isfinite(mapping.source_x).all()
def test_scaled_mapping_uses_proportional_padding_and_zero_is_identity():
values = square_with_hole()
original = build_boundary_mapping(values, 0.9, 2)
scaled = build_boundary_mapping(values, 0.9, 2, (34, 34))
assert scaled.target.sum() > original.target.sum() * 3
disabled = build_boundary_mapping(values, 0.9, 0)
assert not disabled.target.any()
def test_depth_moves_z_and_validity_but_reprojects_xy_on_target_rays():
values = np.zeros((9, 9), dtype=np.float32)
values[2:7, 2:7] = 1
mapping = build_boundary_mapping(values, 0.9, 2)
yy, xx = np.mgrid[:9, :9]
rgba = np.zeros((9, 9, 4), dtype=np.float32)
rgba[..., 2] = yy + 10
rgba[..., 3] = yy / 10
intrinsics = np.array(((4, 0, 4), (0, 4, 4), (0, 0, 1)), dtype=np.float32)
result = apply_depth_boundary_mapping(rgba, mapping, intrinsics)
y, x = 1, 4
assert mapping.target[y, x]
assert result[y, x, 2] > rgba[2, x, 2]
assert result[y, x, 0] == 0
assert result[y, x, 1] == (y - 4) / 4 * result[y, x, 2]
assert result[y, x, 3] > rgba[2, x, 3]
class PixelBuffer:
def __init__(self, values):
self.values = np.asarray(values, dtype=np.float32).copy()
def foreach_get(self, target):
target[:] = self.values
def foreach_set(self, values):
self.values[:] = values
class Image:
def __init__(self, top_down):
height, width = top_down.shape[:2]
self.size = (width, height)
self.pixels = PixelBuffer(np.flipud(top_down).ravel())
self.updated = 0
self.packed = 0
def update(self):
self.updated += 1
def pack(self):
self.packed += 1
def test_client_processing_updates_and_packs_color_and_depth_together():
content = square_with_hole()
yy, xx = np.mgrid[:17, :17]
color_values = np.stack((xx, yy, xx + yy, content), axis=-1).astype(np.float32)
original = color_values.copy()
color = Image(color_values)
depth_values = np.zeros((17, 17, 4), dtype=np.float32)
depth_values[..., 2] = yy + 10
depth_values[..., 3] = content
depth = Image(depth_values)
metadata = {
"intrinsics": ((8.0, 0.0, 8.0), (0.0, 8.0, 8.0), (0.0, 0.0, 1.0)),
"reference_depth": 12.0,
}
pad_cutout_images(color, depth, metadata, content, 0.9, 2)
padded_color = image_rgba_buffer(color)
padded_depth = image_rgba_buffer(depth)
np.testing.assert_array_equal(padded_color[5, 5], original[5, 5])
assert padded_color[2, 8, 3] == 1
assert padded_depth[2, 8, 2] > depth_values[3, 8, 2]
assert padded_depth[2, 8, 0] == 0
assert metadata["reference_depth"] == 12
assert (color.updated, color.packed, depth.updated, depth.packed) == (1, 1, 1, 1)