Files
AnyImage/src/anyimage/operators/cutout_tool/geometry.py
nan 591e62085a 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.
2026-09-15 00:40:24 +08:00

691 lines
24 KiB
Python

"""Build the planar and relief geometry used by the Cutout mainline."""
import math
from dataclasses import dataclass
from ...preferences import addon_preferences, configured_min_cutout_relative_edge_length
from ...common.image import image_empty_bounds
from . import balloon
@dataclass(frozen=True)
class SelectionContours:
components: tuple
image_size: tuple[int, int]
def __post_init__(self):
components = tuple(
tuple(
tuple((float(x), float(y)) for x, y in contour) for contour in component
)
for component in self.components
)
image_size = tuple(int(value) for value in self.image_size)
if not components or any(not component for component in components):
raise ValueError("The Selection has no usable outline components")
if any(len(contour) < 3 for component in components for contour in component):
raise ValueError("A Selection outline has fewer than three points")
if len(image_size) != 2 or min(image_size) <= 0:
raise ValueError("The Selection outline image size is invalid")
object.__setattr__(self, "components", components)
object.__setattr__(self, "image_size", image_size)
def _choose_boundary_edge(start, direction, candidates):
right = (-direction[1], direction[0])
left = (direction[1], -direction[0])
back = (-direction[0], -direction[1])
preference = {right: 0, direction: 1, left: 2, back: 3}
return min(
candidates,
key=lambda end: preference.get(
(end[0] - start[0], end[1] - start[1]),
4,
),
)
def _simplify_axis_aligned_contour(points):
simplified = []
for index, current in enumerate(points):
previous = points[index - 1]
following = points[(index + 1) % len(points)]
first = (current[0] - previous[0], current[1] - previous[1])
second = (following[0] - current[0], following[1] - current[1])
if first[0] * second[1] - first[1] * second[0] != 0:
simplified.append(current)
return simplified
def _trace_selection_contours(mask):
import numpy as np
mask = np.asarray(mask, dtype=bool)
if mask.ndim != 2:
raise ValueError("Cutout Alpha must be two-dimensional")
height, width = mask.shape
edges = {}
def add_edge(start, end):
edges.setdefault(start, []).append(end)
top = mask.copy()
top[1:] &= ~mask[:-1]
for y, x in np.argwhere(top):
add_edge((int(x), int(y)), (int(x) + 1, int(y)))
right = mask.copy()
right[:, :-1] &= ~mask[:, 1:]
for y, x in np.argwhere(right):
add_edge((int(x) + 1, int(y)), (int(x) + 1, int(y) + 1))
bottom = mask.copy()
bottom[:-1] &= ~mask[1:]
for y, x in np.argwhere(bottom):
add_edge((int(x) + 1, int(y) + 1), (int(x), int(y) + 1))
left = mask.copy()
left[:, 1:] &= ~mask[:, :-1]
for y, x in np.argwhere(left):
add_edge((int(x), int(y) + 1), (int(x), int(y)))
if not edges:
raise ValueError("The Cutout selection has no usable boundary")
contours = []
edge_count = sum(len(values) for values in edges.values())
while edges:
start = min(edges, key=lambda point: (point[1], point[0]))
current = start
direction = (1, 0)
contour = []
for _index in range(edge_count + 1):
contour.append(current)
candidates = edges.get(current)
if not candidates:
raise ValueError("The Cutout boundary is open")
following = _choose_boundary_edge(current, direction, candidates)
candidates.remove(following)
if not candidates:
del edges[current]
direction = (following[0] - current[0], following[1] - current[1])
current = following
if current == start:
break
else:
raise ValueError("The Cutout boundary could not be closed")
contour = _simplify_axis_aligned_contour(contour)
if len(contour) >= 3:
contours.append(contour)
if not contours:
raise ValueError("The Cutout selection is too small")
return contours
def _signed_contour_area(contour):
return 0.5 * sum(
start[0] * end[1] - end[0] * start[1]
for start, end in zip(contour, contour[1:] + contour[:1])
)
def _point_inside_contour(point, contour):
inside = False
previous = contour[-1]
for current in contour:
if (current[1] > point[1]) != (previous[1] > point[1]):
crossing_x = current[0] + (
(point[1] - current[1])
* (previous[0] - current[0])
/ (previous[1] - current[1])
)
inside ^= point[0] < crossing_x
previous = current
return inside
def _group_selection_contours(mask):
contours = _trace_selection_contours(mask)
outer = [contour for contour in contours if _signed_contour_area(contour) > 0.0]
holes = [contour for contour in contours if _signed_contour_area(contour) < 0.0]
if not outer:
raise ValueError("The Cutout selection has no outer boundary")
components = [[contour] for contour in outer]
outer_areas = [abs(_signed_contour_area(contour)) for contour in outer]
for hole in holes:
start = hole[0]
end = hole[1]
edge_x = end[0] - start[0]
edge_y = end[1] - start[1]
length = max((edge_x * edge_x + edge_y * edge_y) ** 0.5, 1e-12)
sample = (
(start[0] + end[0]) * 0.5 - edge_y * 0.25 / length,
(start[1] + end[1]) * 0.5 + edge_x * 0.25 / length,
)
owners = [
index
for index, contour in enumerate(outer)
if _point_inside_contour(sample, contour)
]
if not owners:
raise ValueError("The Cutout hole has no enclosing selection component")
owner = min(owners, key=outer_areas.__getitem__)
components[owner].append(hole)
return components
def _resize_values(values, size):
import numpy as np
target_width, target_height = (int(value) for value in size)
source_height, source_width = values.shape
if (target_width, target_height) == (source_width, source_height):
return values
x = (np.arange(target_width, dtype=np.float32) + 0.5) * (
source_width / target_width
) - 0.5
y = (np.arange(target_height, dtype=np.float32) + 0.5) * (
source_height / target_height
) - 0.5
x0_unclipped = np.floor(x).astype(np.int32)
y0_unclipped = np.floor(y).astype(np.int32)
x_weight = x - x0_unclipped
y_weight = y - y0_unclipped
x0 = np.clip(x0_unclipped, 0, source_width - 1)
y0 = np.clip(y0_unclipped, 0, source_height - 1)
x1 = np.clip(x0_unclipped + 1, 0, source_width - 1)
y1 = np.clip(y0_unclipped + 1, 0, source_height - 1)
horizontal = (
values[:, x0] * (1.0 - x_weight)[None, :] + values[:, x1] * x_weight[None, :]
)
return (
horizontal[y0] * (1.0 - y_weight)[:, None] + horizontal[y1] * y_weight[:, None]
)
def extract_selection_contours(selection_values, max_resolution=512, threshold=0.5):
import numpy as np
values = np.asarray(selection_values)
if values.ndim != 2:
raise ValueError("Cutout Alpha must be two-dimensional")
if np.issubdtype(values.dtype, np.floating):
values = np.clip(values, 0.0, 1.0) * 255.0
else:
values = np.clip(values, 0, 255).astype(np.float32)
source_size = (values.shape[1], values.shape[0])
scale = min(1.0, float(max_resolution) / max(source_size))
sampled_size = tuple(max(1, round(value * scale)) for value in source_size)
if sampled_size != source_size:
values = _resize_values(values, sampled_size)
mask = (values > 0.0) & (values >= 255.0 * threshold)
components = _group_selection_contours(mask)
scale_x = source_size[0] / sampled_size[0]
scale_y = source_size[1] / sampled_size[1]
return SelectionContours(
components=tuple(
tuple(
tuple(
(
min(max(float(x) * scale_x, 0.0), source_size[0]),
min(max(float(y) * scale_y, 0.0), source_size[1]),
)
for x, y in contour
)
for contour in component
)
for component in components
),
image_size=source_size,
)
POINT_SPACING = 0.1
def contour_area(points):
import numpy as np
points = np.asarray(points, dtype=np.float64)
following = np.roll(points, -1, axis=0)
return 0.5 * float(
np.sum(points[:, 0] * following[:, 1] - following[:, 0] * points[:, 1])
)
def resample_closed_contour(contour, spacing):
import numpy as np
points = np.asarray(contour, dtype=np.float64)
if len(points) < 3:
raise ValueError("The lasso does not define a valid image selection")
segments = np.roll(points, -1, axis=0) - points
lengths = np.linalg.norm(segments, axis=1)
points = points[lengths > 1e-8]
if len(points) < 3:
raise ValueError("The selection is too small to create a Cutout")
segments = np.roll(points, -1, axis=0) - points
lengths = np.linalg.norm(segments, axis=1)
perimeter = float(lengths.sum())
if perimeter <= spacing * 3.0:
raise ValueError("The selection is too small to create a Cutout")
count = max(3, int(round(perimeter / spacing)))
distances = np.arange(count, dtype=np.float64) * perimeter / count
cumulative = np.concatenate(([0.0], np.cumsum(lengths)))
indices = np.searchsorted(cumulative[1:], distances, side="right")
factors = (distances - cumulative[indices]) / lengths[indices]
return points[indices] + segments[indices] * factors[:, None]
def smooth_selection_contour(contour, spacing, iterations=16):
contour = resample_closed_contour(contour, spacing * 0.25)
original_area = abs(contour_area(contour))
for _index in range(iterations):
import numpy as np
contour = (
np.roll(contour, 1, axis=0) * 0.125
+ contour * 0.75
+ np.roll(contour, -1, axis=0) * 0.125
)
smooth_area = abs(contour_area(contour))
if original_area > 0.0 and smooth_area > 0.0:
center = contour.mean(axis=0)
contour = center + (contour - center) * math.sqrt(original_area / smooth_area)
contour = resample_closed_contour(contour, spacing)
if contour_area(contour) < 0.0:
contour = contour[::-1].copy()
return contour
def _contour_edges(contours):
import numpy as np
starts = []
ends = []
for contour in contours:
contour = np.asarray(contour, dtype=np.float64)
following = np.roll(contour, -1, axis=0)
usable = np.abs(following[:, 1] - contour[:, 1]) > 1e-12
starts.append(contour[usable])
ends.append(following[usable])
if not starts:
empty = np.empty((0, 2), dtype=np.float64)
return empty, empty
return np.concatenate(starts), np.concatenate(ends)
def _edge_bucket_indices(starts, ends, minimum_y, bucket_height, bucket_count):
import numpy as np
lower = np.minimum(starts[:, 1], ends[:, 1])
upper = np.maximum(starts[:, 1], ends[:, 1])
first = np.floor((lower - minimum_y) / bucket_height).astype(np.int64)
last = np.floor((np.nextafter(upper, lower) - minimum_y) / bucket_height).astype(
np.int64
)
first = np.clip(first, 0, bucket_count - 1)
last = np.clip(last, 0, bucket_count - 1)
buckets = [[] for _index in range(bucket_count)]
for edge_index, (start, stop) in enumerate(zip(first, last)):
for bucket_index in range(int(start), int(stop) + 1):
buckets[bucket_index].append(edge_index)
return buckets
def points_inside_contours(points, contours):
import numpy as np
points = np.asarray(points, dtype=np.float64)
inside = np.zeros(len(points), dtype=bool)
if not len(points):
return inside
starts, ends = _contour_edges(contours)
if not len(starts):
return inside
minimum_y = min(float(points[:, 1].min()), float(starts[:, 1].min()))
maximum_y = max(float(points[:, 1].max()), float(starts[:, 1].max()))
typical_span = float(np.median(np.abs(ends[:, 1] - starts[:, 1])))
bucket_height = max(typical_span, (maximum_y - minimum_y) / 4096.0, 1e-9)
bucket_count = max(1, int(math.ceil((maximum_y - minimum_y) / bucket_height)))
edge_buckets = _edge_bucket_indices(
starts, ends, minimum_y, bucket_height, bucket_count
)
point_buckets = np.floor((points[:, 1] - minimum_y) / bucket_height).astype(
np.int64
)
point_buckets = np.clip(point_buckets, 0, bucket_count - 1)
order = np.argsort(point_buckets, kind="stable")
ordered_buckets = point_buckets[order]
split = np.flatnonzero(np.diff(ordered_buckets)) + 1
for indices in np.split(order, split):
edge_indices = edge_buckets[int(point_buckets[indices[0]])]
if not edge_indices:
continue
selected_starts = starts[edge_indices]
selected_ends = ends[edge_indices]
sample_y = points[indices, 1, None]
crosses = (selected_starts[None, :, 1] > sample_y) != (
selected_ends[None, :, 1] > sample_y
)
crossing_x = selected_starts[None, :, 0] + (
(sample_y - selected_starts[None, :, 1])
* (selected_ends[None, :, 0] - selected_starts[None, :, 0])
/ (selected_ends[None, :, 1] - selected_starts[None, :, 1])
)
inside[indices] = (
np.count_nonzero(crosses & (points[indices, 0, None] < crossing_x), axis=1)
% 2
== 1
)
return inside
def metric_transform(world_metric=None):
import numpy as np
metric = (
np.eye(2, dtype=np.float64)
if world_metric is None
else np.asarray(world_metric, dtype=np.float64)
)
if metric.shape != (2, 2):
raise ValueError("The Image Empty has an invalid world transform")
try:
return np.linalg.cholesky(metric)
except np.linalg.LinAlgError as error:
raise ValueError("The Image Empty has a degenerate world transform") from error
def contour_interior_point(contours, spacing):
"""Choose an interior seed from the largest auxiliary triangle."""
import numpy as np
from mathutils import Vector
from mathutils.geometry import delaunay_2d_cdt
input_points = np.concatenate(contours, axis=0)
boundary_edges = []
offset = 0
for contour in contours:
count = len(contour)
boundary_edges.extend(
(offset + index, offset + (index + 1) % count) for index in range(count)
)
offset += count
result = delaunay_2d_cdt(
[Vector(point) for point in input_points],
boundary_edges,
[],
0,
max(spacing * 1e-6, 1e-9),
False,
)
planar_points = np.asarray(
[tuple(point) for point in result[0]],
dtype=np.float64,
)
faces = np.asarray(
[tuple(face) for face in result[2] if len(face) == 3],
dtype=np.int32,
)
if len(faces):
centers = planar_points[faces].mean(axis=1)
faces = faces[points_inside_contours(centers, contours)]
if not len(faces):
raise ValueError("Unable to triangulate the Cutout selection components")
triangles = planar_points[faces]
first = triangles[:, 1] - triangles[:, 0]
second = triangles[:, 2] - triangles[:, 0]
areas = abs(first[:, 0] * second[:, 1] - first[:, 1] * second[:, 0])
seed = triangles[np.argmax(areas)].mean(axis=0)
if not points_inside_contours(seed[None], contours)[0]:
raise ValueError("Cutout seed is outside its contour")
return seed
def boundary_vertices_from_faces(vertex_count, faces):
import numpy as np
faces = np.asarray(faces, dtype=np.int32)
boundary = np.zeros(vertex_count, dtype=bool)
if not len(faces):
return boundary
edges = np.concatenate(
[
faces[:, (index, (index + 1) % faces.shape[1])]
for index in range(faces.shape[1])
],
axis=0,
)
edges.sort(axis=1)
unique_edges, users = np.unique(edges, axis=0, return_counts=True)
boundary[unique_edges[users == 1].ravel()] = True
return boundary
CUTOUT_ALPHA_THRESHOLD = 0.9
CUTOUT_CLEANUP_UI_SCALE = 100.0
@dataclass(frozen=True)
class BaseShape:
vertices: object
faces: object
uv: object
balloon: object
mask: object
def cutout_domain(mesh, bounds, world_metric, balloon_algorithm="POISSON"):
"""Map the quality mesh into local coordinates and solve its height."""
import numpy as np
x_min, x_max, y_min, y_max = bounds
pixels, planar_faces, boundary, pixel_components, image_size = mesh
image_width, image_height = image_size
transform = metric_transform(world_metric)
def pixel_to_planar(points):
local = np.asarray(points, dtype=np.float64).copy()
local[:, 0] = x_min + local[:, 0] * (x_max - x_min) / image_width
local[:, 1] = y_max - local[:, 1] * (y_max - y_min) / image_height
return local @ transform
planar = pixel_to_planar(pixels)
components = [
(start, end, faces.tolist(), pixel_to_planar(contour))
for start, end, faces, contour in pixel_components
]
profile = balloon.balloon_profile(
balloon_algorithm, planar, planar_faces, boundary, components
)
faces = np.asarray(planar_faces, dtype=np.int32)
local = planar @ np.linalg.inv(metric_transform(world_metric))
uv = np.column_stack(
(
(local[:, 0] - x_min) / (x_max - x_min),
(local[:, 1] - y_min) / (y_max - y_min),
)
).astype(np.float32)
crop_center = np.asarray(
((x_min + x_max) * 0.5, (y_min + y_max) * 0.5),
dtype=np.float64,
)
local -= crop_center
vertices = np.column_stack((local, np.zeros(len(local), dtype=np.float64)))
return vertices, faces, uv, boundary, planar, profile
def world_plane_metric(matrix_world):
from mathutils import Vector
linear = matrix_world.to_3x3()
x = linear @ Vector((1.0, 0.0, 0.0))
y = linear @ Vector((0.0, 1.0, 0.0))
z = linear @ Vector((0.0, 0.0, 1.0))
return ((x.dot(x), x.dot(y)), (x.dot(y), y.dot(y))), z.length
def cutout_cleanup_value(spacing, world_metric):
import numpy as np
if spacing <= 0.0:
raise ValueError("Cutout point spacing must be greater than zero")
area_scale = abs(float(np.linalg.det(metric_transform(world_metric))))
if area_scale <= 1e-12:
raise ValueError("The Image Empty has a degenerate world transform")
return CUTOUT_CLEANUP_UI_SCALE * float(spacing) ** 2 / area_scale
def counter_clockwise_faces(points, faces):
import numpy as np
points = np.asarray(points, dtype=np.float64)
result = np.asarray(faces, dtype=np.int32).copy()
polygons = points[result]
following = np.roll(polygons, -1, axis=1)
signed_area = np.sum(
polygons[..., 0] * following[..., 1] - polygons[..., 1] * following[..., 0],
axis=1,
)
result[signed_area < 0.0] = result[signed_area < 0.0, ::-1]
return result
def crop_plane_bounds(source_object, crop_bounds, source_size):
source_width, source_height = (float(value) for value in source_size)
left, top, right, bottom = (float(value) for value in crop_bounds)
x_min, x_max, y_min, y_max = image_empty_bounds(source_object)
width = x_max - x_min
height = y_max - y_min
return (
x_min + left * width / source_width,
x_min + right * width / source_width,
y_max - bottom * height / source_height,
y_max - top * height / source_height,
)
def extract_cutout_contours(
content_values,
sampling_resolution,
threshold=CUTOUT_ALPHA_THRESHOLD,
):
contour_resolution = max(512, min(round(sampling_resolution * 8), 1024))
return extract_selection_contours(
content_values,
max_resolution=contour_resolution,
threshold=threshold,
)
def effective_cutout_edge_length(
context,
source_object,
edge_length,
content_values,
content_bounds,
threshold=CUTOUT_ALPHA_THRESHOLD,
):
"""Resolve target spacing in Blender world units from visible content bounds."""
import numpy as np
if not math.isfinite(edge_length) or edge_length <= 0:
raise ValueError("Cutout edge length must be greater than zero")
values = np.asarray(content_values)
visible = (values > 0) & (values >= threshold)
rows = np.flatnonzero(visible.any(axis=1))
columns = np.flatnonzero(visible.any(axis=0))
if not len(rows):
raise ValueError("The Cutout selection contains no visible image pixels")
bounds = crop_plane_bounds(
source_object, content_bounds, tuple(source_object.data.size)
)
metric, _ = world_plane_metric(source_object.matrix_world)
metric_transform(metric)
width = (
(bounds[1] - bounds[0]) * (columns.max() + 1 - columns.min()) / values.shape[1]
)
height = (bounds[3] - bounds[2]) * (rows.max() + 1 - rows.min()) / values.shape[0]
longest = max(width * math.sqrt(metric[0][0]), height * math.sqrt(metric[1][1]))
return max(
float(edge_length),
longest * configured_min_cutout_relative_edge_length(context),
)
def selection_calibration(
content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD
):
"""Return Cutout validity and transparency ratio at geometry resolution."""
import numpy as np
values = np.asarray(content_values, dtype=np.float32)
target_height, target_width = (int(value) for value in target_shape)
source_height, source_width = values.shape
y = np.minimum(
(np.arange(target_height) * source_height / target_height).astype(np.int64),
source_height - 1,
)
x = np.minimum(
(np.arange(target_width) * source_width / target_width).astype(np.int64),
source_width - 1,
)
resized = values[y[:, None], x[None, :]]
valid = (resized > 0.0) & (resized >= float(threshold))
transparent_fraction = float(np.count_nonzero(~valid) / valid.size)
return valid, transparent_fraction
def build_base_shape(
context,
source_object,
edge_length,
content_values,
content_bounds,
*,
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
fine_outline=False,
):
import numpy as np
bounds = crop_plane_bounds(
source_object,
content_bounds,
tuple(source_object.data.size),
)
world_metric, world_z_scale = world_plane_metric(source_object.matrix_world)
if world_z_scale <= 1e-10:
raise ValueError("The Image Empty has a degenerate world transform")
pixel_transform = np.diag(
(
(bounds[1] - bounds[0]) / content_values.shape[1],
-(bounds[3] - bounds[2]) / content_values.shape[0],
)
) @ metric_transform(world_metric)
from .mesh import build_mesh
mesh_result = build_mesh(
content_values,
edge_length,
alpha_threshold,
pixel_transform=pixel_transform,
fine_outline=fine_outline,
)
*mesh_geometry, mask = mesh_result
mesh = (
*mesh_geometry,
(content_values.shape[1], content_values.shape[0]),
)
preferences = addon_preferences(context)
balloon_algorithm = getattr(preferences, "balloon_profile_algorithm", "POISSON")
domain = cutout_domain(mesh, bounds, world_metric, balloon_algorithm)
source_vertices, faces, uv, _boundary, planar, balloon = domain
return BaseShape(
vertices=np.asarray(source_vertices, dtype=np.float64),
faces=counter_clockwise_faces(planar, faces),
uv=np.asarray(uv, dtype=np.float32),
balloon=np.asarray(balloon, dtype=np.float64) / world_z_scale,
mask=np.asarray(mask, dtype=bool),
)