Add comprehensive tests for cutout and mesh functionalities
- Introduced `test_cutout.py` to validate cutout pie menu behavior, image lasso options, and mesh detail calculations. - Added `test_mesh.py` to ensure correct tangent normal rasterization, plane source topology, and depth surface evaluations. - Created `test_mesh_blender.py` to verify material node groups, tangent baking, and local cutout building from Blender images. - Implemented tests for handling alpha regions and ensuring geometry nodes maintain expected behavior.
This commit is contained in:
211
src/anyimage/server/geometry/cutout.py
Normal file
211
src/anyimage/server/geometry/cutout.py
Normal file
@@ -0,0 +1,211 @@
|
||||
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_mask_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 region 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 region 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 mask_regions(mask):
|
||||
contours = trace_mask_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 region has no outer boundary")
|
||||
regions = [[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 region")
|
||||
owner = min(owners, key=outer_areas.__getitem__)
|
||||
regions[owner].append(hole)
|
||||
return regions
|
||||
|
||||
|
||||
def _resize_alpha(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 alpha_regions(alpha, max_resolution=512, threshold=0.5):
|
||||
import numpy as np
|
||||
|
||||
values = np.asarray(alpha)
|
||||
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_alpha(values, sampled_size)
|
||||
mask = values > round(255.0 * threshold)
|
||||
regions = mask_regions(mask)
|
||||
scale_x = source_size[0] / sampled_size[0]
|
||||
scale_y = source_size[1] / sampled_size[1]
|
||||
return (
|
||||
[
|
||||
[
|
||||
[[float(x) * scale_x, float(y) * scale_y] for x, y in contour]
|
||||
for contour in region
|
||||
]
|
||||
for region in regions
|
||||
],
|
||||
source_size,
|
||||
)
|
||||
|
||||
|
||||
def image_alpha_regions(path, max_resolution=512, threshold=0.5):
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(path) as source:
|
||||
alpha = np.asarray(source.convert("RGBA").getchannel("A"), dtype=np.uint8)
|
||||
return alpha_regions(alpha, max_resolution=max_resolution, threshold=threshold)
|
||||
Reference in New Issue
Block a user