Add soft min and max constraints for frame resolution and cutout edge length properties

This commit is contained in:
2026-09-13 11:22:20 +08:00
parent 18799e8828
commit 2a00ed0ee6
21 changed files with 3834 additions and 1293 deletions

View File

@@ -1,636 +0,0 @@
"""Fine Cutout contours, sparse constrained support and interior sampling."""
import heapq
import numpy as np
from mathutils import Vector
from mathutils.geometry import delaunay_2d_cdt
from scipy import ndimage
from scipy.spatial import cKDTree
from .polygon import approximate_polygon
from . import geometry as g
def valid_contours(contours):
"""Reject crossing rings and holes moved outside the outer ring."""
domain = ContourDomain(contours)
count = len(domain.starts)
following = np.concatenate(
[
np.roll(np.arange(len(contour)) + sum(map(len, contours[:index])), -1)
for index, contour in enumerate(contours)
]
)
for first in range(0, count, 32):
ids = np.arange(first, min(first + 32, count))[:, None]
other = np.arange(count)[None, :]
a = domain.starts[first : first + 32, None]
v = domain.delta[first : first + 32, None]
w = domain.delta[None]
d = domain.starts[None] - a
determinant = v[..., 0] * w[..., 1] - v[..., 1] * w[..., 0]
divisor = np.where(np.abs(determinant) > 1e-12, determinant, 1.0)
t = (d[..., 0] * w[..., 1] - d[..., 1] * w[..., 0]) / divisor
u = (d[..., 0] * v[..., 1] - d[..., 1] * v[..., 0]) / divisor
adjacent = (other == following[ids]) | (ids == following[other])
if np.any(
(other > ids)
& ~adjacent
& (np.abs(determinant) > 1e-12)
& (t > 1e-9)
& (t < 1 - 1e-9)
& (u > 1e-9)
& (u < 1 - 1e-9)
):
return False
return all(
g.points_inside_contours(hole[:1], contours[:1])[0] for hole in contours[1:]
)
def support_flat_regions(points, faces):
"""Split interior boundary chords so inflated faces remain connected."""
boundary = g.boundary_vertices_from_faces(len(points), faces)
edges = np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1)
unique, counts = np.unique(edges, axis=0, return_counts=True)
chords = unique[(counts == 2) & boundary[unique].all(axis=1)]
midpoints = {tuple(edge): len(points) + index for index, edge in enumerate(chords)}
vertices = points.tolist() + points[chords].mean(axis=1).tolist()
triangles = []
for face in faces:
ring = []
for index, start in enumerate(face):
end = face[(index + 1) % 3]
ring.append(int(start))
midpoint = midpoints.get(tuple(sorted((int(start), int(end)))))
if midpoint is not None:
ring.append(midpoint)
if len(ring) > 3 or boundary[face].all():
center = len(vertices)
vertices.append(points[face].mean(axis=0).tolist())
triangles.extend(
(center, start, ring[(index + 1) % len(ring)])
for index, start in enumerate(ring)
)
else:
triangles.append(tuple(face))
return np.asarray(vertices), np.asarray(triangles, dtype=np.int32)
def build_fine_mesh(alpha, spacing, threshold, *, pixel_transform=None):
"""Return pixel-space triangles and component ranges for height solving."""
alpha = np.asarray(alpha, dtype=float)
if alpha.ndim != 2 or not alpha.size or not np.isfinite(alpha).all():
raise ValueError("The Cutout Alpha must be a finite two-dimensional array")
if not np.isfinite(spacing) or spacing <= 0:
raise ValueError("Cutout point spacing must be greater than zero")
filtered, _statistics = filter_alpha(alpha, threshold)
mask = (filtered > 0) & (filtered >= threshold)
if not mask.any():
raise ValueError("The Cutout selection contains no visible image pixels")
reference = g.extract_selection_contours(
filtered, max_resolution=max(mask.shape), threshold=threshold
)
# Normalize geometric tolerances and support sampling to the target length.
transform = (
np.eye(2) if pixel_transform is None else np.asarray(pixel_transform)
) * (32 / spacing)
inverse = np.linalg.inv(transform)
width_sizes = WidthSamples(mask, transform=transform)
spacing = 32.0
points, faces, components = [], [], []
offset = 0
for component in reference.components:
contours = contours_for_component([np.asarray(c) @ transform for c in component], spacing)
spine, path_edges = interior_paths(
contours, spacing, width_sizes
)
contours, spine, path_edges = subdivide_constraints(
contours, spine, path_edges, spacing
)
free = relax_points(contours, spine, spacing)
vertices, triangles, constraints = triangulate(
contours, spine, path_edges, free, spacing
)
if not len(triangles):
raise ValueError(
"Fine Outline could not triangulate a foreground component"
)
vertices, triangles = support_flat_regions(vertices, triangles)
boundary = g.boundary_vertices_from_faces(len(vertices), triangles)
if boundary[constraints].any():
raise ValueError(
"Fine Outline placed an interior constraint on the boundary"
)
points.append(vertices @ inverse)
faces.append(triangles + offset)
components.append((offset, offset + len(vertices), triangles, contours[0] @ inverse))
offset += len(vertices)
points, faces = np.concatenate(points), np.concatenate(faces)
return points, faces, g.boundary_vertices_from_faces(len(points), faces), components
def clean_mesh(points, faces):
faces = np.asarray(faces, dtype=np.int32).reshape(-1, 3)
triangles = points[faces]
area = np.abs(
np.cross(triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0])
)
faces = faces[area > 1e-8]
used = np.unique(faces)
remap = np.full(len(points), -1, dtype=np.int32)
remap[used] = np.arange(len(used))
return points[used], remap[faces]
def filter_alpha(alpha, threshold, min_area=16):
mask = (alpha > 0) & (alpha >= threshold)
labels, count = ndimage.label(mask)
areas = np.bincount(labels.ravel(), minlength=count + 1)
keep = areas >= min_area
keep[0] = False
if count:
keep[1 + np.argmax(areas[1:])] = True
removed = mask & ~keep[labels]
result = alpha.copy()
result[removed] = 0
return (
result,
{
"components_before": count,
"components_after": int(keep.sum()),
"removed_pixels": int(removed.sum()),
"foreground_pixels": int(mask.sum()),
},
)
def width_ridge_samples(image):
"""Supply ridge disks for width estimation, not a connected skeleton."""
distance = ndimage.distance_transform_edt(image)
ridge = np.zeros(image.shape, dtype=bool)
dominated = np.zeros(image.shape, dtype=bool)
for dy, dx in ((0, 1), (1, 0), (1, 1), (1, -1)):
before = np.roll(distance, (dy, dx), axis=(0, 1))
after = np.roll(distance, (-dy, -dx), axis=(0, 1))
ridge |= (
(distance >= before)
& (distance >= after)
& ((distance > before) | (distance > after))
)
dominated |= np.maximum(before, after) >= distance + np.hypot(dx, dy) - 1e-9
ridge &= image & ~dominated
return ridge, distance
class WidthSamples:
"""Estimate local width from indexed distance-ridge disks."""
def __init__(self, mask, transform=None):
transform = np.eye(2) if transform is None else transform
self.inverse = np.linalg.inv(transform)
self.size_scale = np.linalg.svd(transform, compute_uv=False).min()
ridge, distance = width_ridge_samples(np.pad(mask, 1))
ridge = ridge[1:-1, 1:-1]
self.distance = np.maximum(distance[1:-1, 1:-1] - 0.5, 0.3)
yy, xx = np.nonzero(ridge)
self.centers = np.column_stack((xx + 0.5, yy + 0.5))
self.radius = self.distance[yy, xx]
self.reach_squared = (self.radius + 0.75) ** 2
buckets = np.floor(np.log2(self.radius)).astype(int)
self.groups = []
for bucket in np.unique(buckets)[::-1]:
ids = np.flatnonzero(buckets == bucket)
self.groups.append(
(ids, cKDTree(self.centers[ids]), float(self.radius[ids].max() + 0.75))
)
def __call__(self, points):
points = points @ self.inverse
radius = np.full(len(points), 0.5)
pending = np.arange(len(points))
for ids, tree, reach in self.groups:
if not len(pending):
break
neighbors = tree.query_ball_point(points[pending], reach)
solved = np.zeros(len(pending), dtype=bool)
for index, local in enumerate(neighbors):
if not len(local):
continue
candidates = ids[np.asarray(local)]
delta = self.centers[candidates] - points[pending[index]]
covered = (
np.sum(delta * delta, axis=1) <= self.reach_squared[candidates]
)
if covered.any():
radius[pending[index]] = max(
0.5, float(self.radius[candidates[covered]].max())
)
solved[index] = True
pending = pending[~solved]
distance = ndimage.map_coordinates(
self.distance,
[points[:, 1] - 0.5, points[:, 0] - 0.5],
order=1,
mode="nearest",
)
return np.maximum(
0.7, np.minimum(32, np.minimum(16, radius * self.size_scale * 0.9) + 0.4 * distance * self.size_scale)
)
class ContourDomain:
"""Reuse boundary segments for exact internal segment validation."""
def __init__(self, contours):
self.contours = contours
self.starts = np.concatenate(contours)
self.ends = np.concatenate([np.roll(c, -1, axis=0) for c in contours])
self.delta = self.ends - self.starts
self.lengths = np.maximum(np.sum(self.delta**2, axis=1), 1e-20)
def clearance(self, points):
result = np.empty(len(points))
for first in range(0, len(points), 64):
d = points[first : first + 64, None, :] - self.starts[None, :, :]
t = np.clip(np.sum(d * self.delta, axis=2) / self.lengths, 0, 1)
result[first : first + 64] = np.sqrt(
np.min(np.sum((d - t[:, :, None] * self.delta) ** 2, axis=2), axis=1)
)
return result
def contains(self, points):
result = np.empty(len(points), dtype=bool)
a = self.starts
b = self.ends
dy = b[:, 1] - a[:, 1]
divisor = np.where(dy != 0, dy, 1.0)
for first in range(0, len(points), 64):
p = points[first : first + 64]
y = p[:, 1, None]
x = p[:, 0, None]
crosses = (a[:, 1] > y) != (b[:, 1] > y)
hit = a[:, 0] + (y - a[:, 1]) * (b[:, 0] - a[:, 0]) / divisor
result[first : first + 64] = (
np.count_nonzero(crosses & (x < hit), axis=1) % 2 == 1
)
return result
def valid_segments(self, starts, ends):
starts = np.asarray(starts)
ends = np.asarray(ends)
valid = self.contains(np.concatenate((starts, ends))).reshape(2, -1).all(axis=0)
valid &= np.minimum(self.clearance(starts), self.clearance(ends)) > 1e-05
for first in range(0, len(starts), 32):
a = starts[first : first + 32, None, :]
v = (ends - starts)[first : first + 32, None, :]
d = self.starts[None, :, :] - a
w = self.delta[None, :, :]
det = v[:, :, 0] * w[:, :, 1] - v[:, :, 1] * w[:, :, 0]
nonparallel = np.abs(det) > 1e-12
divisor = np.where(nonparallel, det, 1.0)
t = (d[:, :, 0] * w[:, :, 1] - d[:, :, 1] * w[:, :, 0]) / divisor
u = (d[:, :, 0] * v[:, :, 1] - d[:, :, 1] * v[:, :, 0]) / divisor
crossing = (nonparallel & (t >= 0) & (t <= 1) & (u >= 0) & (u <= 1)).any(
axis=1
)
factors = np.clip(
np.sum(d * v, axis=2) / np.maximum(np.sum(v * v, axis=2), 1e-20), 0, 1
)
distance = np.min(
np.sum((d - factors[:, :, None] * v) ** 2, axis=2), axis=1
)
valid[first : first + 32] &= ~crossing & (distance > 1e-10)
return valid
def contours_for_component(component, spacing, strength=1.0):
result = []
tolerance = (
2.5 if spacing > 16 else 1.25 if spacing > 8 else 0.625 if spacing > 4 else 0.5
)
for contour in component:
original = np.asarray(contour, dtype=float)
if spacing <= 16 and strength > 0:
original = ndimage.gaussian_filter1d(
g.resample_closed_contour(original, 1.0),
0.8 * strength,
axis=0,
mode="wrap",
)
for error in (tolerance * strength, tolerance * strength * 0.5, 0):
simplified = approximate_polygon(np.vstack((original, original[0])), error)[
:-1
]
if len(simplified) >= 3 and abs(g.contour_area(simplified)) > 0.05:
break
points = []
for a, b in zip(simplified, np.roll(simplified, -1, axis=0)):
count = max(1, int(np.ceil(np.linalg.norm(b - a) / 32)))
points.extend(a + np.arange(count)[:, None] / count * (b - a))
result.append(np.asarray(points))
if strength > 0 and not valid_contours(result):
return contours_for_component(
component, spacing, strength * 0.5 if strength > 0.125 else 0
)
return result
def interior_paths(contours, spacing, width_sizes):
p, f, _ = g._triangulate_contour_component(contours, np.empty((0, 2)), spacing)
p, f = clean_mesh(p, f)
users = {}
for ti, face in enumerate(f):
for i in range(3):
edge = tuple(sorted((int(face[i]), int(face[(i + 1) % 3]))))
users.setdefault(edge, []).append(ti)
nodes = []
connections = []
midpoints = {}
for edge, owners in users.items():
if len(owners) == 2:
midpoints[edge] = len(nodes)
nodes.append(p[list(edge)].mean(axis=0))
for face in f:
mids = [
midpoints[e]
for i in range(3)
if (e := tuple(sorted((int(face[i]), int(face[(i + 1) % 3]))))) in midpoints
]
if len(mids) == 2:
connections.append(tuple(mids))
else:
center = len(nodes)
nodes.append(p[face].mean(axis=0))
connections.extend(((m, center) for m in mids))
if not nodes:
return (np.empty((0, 2)), [])
nodes = np.asarray(nodes)
domain = ContourDomain(contours)
radius = domain.clearance(nodes)
local_sizes = width_sizes(nodes)
connections = [(a, b) for a, b in connections if min(radius[a], radius[b]) > 0.0001]
if connections:
pairs = np.asarray(connections)
valid = domain.valid_segments(nodes[pairs[:, 0]], nodes[pairs[:, 1]])
connections = [edge for edge, ok in zip(connections, valid) if ok]
full_graph = {}
for a, b in connections:
length = float(np.linalg.norm(nodes[a] - nodes[b]))
full_graph.setdefault(a, []).append((b, length))
full_graph.setdefault(b, []).append((a, length))
selected = {
tuple(sorted((a, b)))
for a, b in connections
if min(radius[a], radius[b]) < spacing * 0.4
and min(local_sizes[a], local_sizes[b]) < min(16, spacing * 0.6)
}
if selected:
parents = {i: i for edge in selected for i in edge}
def root(i):
while parents[i] != i:
parents[i] = parents[parents[i]]
i = parents[i]
return i
for a, b in selected:
parents[root(a)] = root(b)
owners = {i: root(i) for i in parents}
distances = {i: 0.0 for i in parents}
previous = {}
queue = [(0.0, i) for i in parents]
heapq.heapify(queue)
while queue:
distance, node = heapq.heappop(queue)
if distance != distances[node]:
continue
for neighbor, length in full_graph.get(node, []):
if max(radius[node], radius[neighbor]) > spacing * 0.75:
continue
value = distance + length
if value < distances.get(neighbor, np.inf):
distances[neighbor] = value
owners[neighbor] = owners[node]
previous[neighbor] = node
heapq.heappush(queue, (value, neighbor))
candidates = []
for a, b in connections:
if (
a in owners
and b in owners
and (owners[a] != owners[b])
and (max(radius[a], radius[b]) <= spacing * 0.75)
):
candidates.append(
(
distances[a]
+ np.linalg.norm(nodes[a] - nodes[b])
+ distances[b],
a,
b,
)
)
for _, a, b in sorted(candidates):
ra, rb = (root(owners[a]), root(owners[b]))
if ra == rb:
continue
parents[ra] = rb
selected.add(tuple(sorted((a, b))))
for cursor in (a, b):
while cursor in previous:
other = previous[cursor]
selected.add(tuple(sorted((cursor, other))))
cursor = other
connections = sorted(selected)
graph = {}
for a, b in connections:
graph.setdefault(a, set()).add(b)
graph.setdefault(b, set()).add(a)
used = set()
paths = []
starts = [i for i in graph if len(graph[i]) != 2] + list(graph)
for start in starts:
for neighbor in graph[start]:
edge = tuple(sorted((start, neighbor)))
if edge in used:
continue
used.add(edge)
ids = [start, neighbor]
while len(graph[ids[-1]]) == 2:
nxt = next((x for x in graph[ids[-1]] if x != ids[-2]))
edge = tuple(sorted((ids[-1], nxt)))
if edge in used:
break
used.add(edge)
ids.append(nxt)
path = nodes[ids]
length = np.linalg.norm(np.diff(path, axis=0), axis=1).sum()
if length < 5 and (len(graph[ids[0]]) == 1 or len(graph[ids[-1]]) == 1):
continue
simplified = path
for tol in (2.0, 1.0, 0.25, 0):
candidate = approximate_polygon(path, tol)
if domain.valid_segments(candidate[:-1], candidate[1:]).all():
simplified = candidate
break
expanded = []
for a, b in zip(simplified, simplified[1:]):
n = max(1, int(np.ceil(np.linalg.norm(b - a) / 24)))
expanded.extend(a + np.arange(n)[:, None] / n * (b - a))
expanded.append(simplified[-1])
paths.append(np.asarray(expanded))
points = []
edges = []
lookup = {}
for path in paths:
ids = []
for p in path:
key = tuple(np.round(p, 7))
if key not in lookup:
lookup[key] = len(points)
points.append(p)
ids.append(lookup[key])
edges.extend(zip(ids, ids[1:]))
return (np.asarray(points).reshape(-1, 2), edges)
def relax_points(contours, spine, spacing):
free = g.uniform_interior_points_for_contours(contours, spacing)
if len(free) and len(spine):
free = free[cKDTree(spine).query(free)[0] > spacing * 0.55]
step = max(1, int(spacing / 4))
phase = step // 2
points = np.concatenate(contours)
minimum = points.min(axis=0)
maximum = points.max(axis=0)
xx = np.arange(minimum[0] + phase, maximum[0], step) + 0.5
yy = np.arange(minimum[1] + phase, maximum[1], step) + 0.5
xx = xx[(xx >= minimum[0]) & (xx <= maximum[0])]
yy = yy[(yy >= minimum[1]) & (yy <= maximum[1])]
xx, yy = np.meshgrid(xx, yy)
samples = np.column_stack((xx.ravel(), yy.ravel()))
samples = samples[g.points_inside_contours(samples, contours)]
if not len(samples):
return free
fixed = np.concatenate((*contours, spine))
for iteration in range(12):
if not len(free):
break
sites = np.concatenate((fixed, free))
labels = cKDTree(sites).query(samples)[1] - len(fixed)
next_free = free.copy()
valid = labels >= 0
owners = labels[valid]
owned = samples[valid]
counts = np.bincount(owners, minlength=len(free))
used = counts > 0
for axis in range(2):
sums = np.bincount(owners, weights=owned[:, axis], minlength=len(free))
next_free[used, axis] = sums[used] / counts[used]
proposed = free * 0.3 + next_free * 0.7
usable = g.points_inside_contours(proposed, contours)
movement = np.linalg.norm(proposed[usable] - free[usable], axis=1)
free[usable] = proposed[usable]
if iteration >= 3 and (
not len(movement) or np.sqrt(np.mean(movement**2)) < spacing * 0.01
):
break
return free
def subdivide_constraints(contours, spine, path_edges, spacing):
dense = []
for c in contours:
points = []
for a, b in zip(c, np.roll(c, -1, axis=0)):
count = max(1, int(np.ceil(np.linalg.norm(b - a) / spacing)))
points.extend(a + np.arange(count)[:, None] / count * (b - a))
dense.append(np.asarray(points))
points = spine.tolist()
edges = []
for a, b in path_edges:
count = max(1, int(np.ceil(np.linalg.norm(spine[b] - spine[a]) / spacing)))
ids = [a]
for i in range(1, count):
ids.append(len(points))
points.append((spine[a] + i / count * (spine[b] - spine[a])).tolist())
ids.append(b)
edges.extend(zip(ids, ids[1:]))
return (dense, np.asarray(points).reshape(-1, 2), edges)
def triangulate(contours, spine, path_edges, free, spacing):
boundary_count = sum(map(len, contours))
p = np.concatenate((*contours, spine, free))
edges = []
offset = 0
for c in contours:
edges.extend(((offset + i, offset + (i + 1) % len(c)) for i in range(len(c))))
offset += len(c)
constraint_start = len(edges)
edges.extend(((boundary_count + a, boundary_count + b) for a, b in path_edges))
result = delaunay_2d_cdt([Vector(v) for v in p], edges, [], 0, 1e-07, True)
points = np.asarray([tuple(v) for v in result[0]])
# Restore input precision before testing area and containment. Float32 CDT
# coordinates can turn collinear outline samples into artificial slivers.
for vertex, originals in enumerate(result[3]):
if originals:
points[vertex] = p[originals].mean(axis=0)
faces = np.asarray(result[2], dtype=np.int32)
faces = faces[g.points_inside_contours(points[faces].mean(axis=1), contours)]
area = np.abs(
np.cross(
points[faces[:, 1]] - points[faces[:, 0]],
points[faces[:, 2]] - points[faces[:, 0]],
)
)
faces = faces[area > 1e-08]
used = np.unique(faces)
remap = np.full(len(points), -1, dtype=np.int32)
remap[used] = np.arange(len(used))
face_edges = {
tuple(sorted((int(face[i]), int(face[(i + 1) % 3]))))
for face in faces
for i in range(3)
}
path_result = []
constraint_graphs = [{} for _ in path_edges]
for edge, originals in zip(result[1], result[4]):
if tuple(sorted(edge)) not in face_edges:
continue
for original in originals:
if constraint_start <= original < len(edges):
graph = constraint_graphs[original - constraint_start]
a, b = edge
graph.setdefault(a, set()).add(b)
graph.setdefault(b, set()).add(a)
path_result.append(tuple(remap[list(edge)]))
# CDT coordinates are float32. Validate endpoint connectivity via provenance,
# so short constraints at large pixel coordinates survive rounding.
original_vertices = {
original: vertex
for vertex, originals in enumerate(result[3])
for original in originals
}
for (a, b), graph in zip(edges[constraint_start:], constraint_graphs):
start = original_vertices.get(a)
end = original_vertices.get(b)
seen = set()
pending = [start] if start in graph else []
while pending:
vertex = pending.pop()
if vertex in seen:
continue
seen.add(vertex)
pending.extend(graph[vertex] - seen)
if end not in seen:
raise ValueError(
"Fine Outline lost an interior constraint during triangulation"
)
return (
points[used],
remap[faces],
np.asarray(path_result, dtype=np.int32).reshape(-1, 2),
)

View File

@@ -380,88 +380,6 @@ def points_inside_contours(points, contours):
return inside
def uniform_interior_points_for_contours(contours, spacing):
import numpy as np
contours = [np.asarray(contour, dtype=np.float64) for contour in contours]
boundary_points = np.concatenate(contours, axis=0)
minimum = boundary_points.min(axis=0)
maximum = boundary_points.max(axis=0)
row_spacing = spacing * math.sqrt(3.0) * 0.5
row_count = int(math.ceil((maximum[1] - minimum[1]) / row_spacing)) + 1
column_count = int(math.ceil((maximum[0] - minimum[0]) / spacing)) + 1
rows = np.arange(row_count, dtype=np.float64)
columns = np.arange(column_count, dtype=np.float64)
y = minimum[1] + rows * row_spacing
x = (
minimum[0]
+ columns[None, :] * spacing
+ (rows.astype(np.int64) % 2)[:, None] * spacing * 0.5
)
x = np.broadcast_to(x, (row_count, column_count))
points = np.column_stack((x.ravel(), np.repeat(y, column_count)))
inside = points_inside_contours(points, contours).reshape(row_count, column_count)
clearance = spacing * 0.4
near_boundary = np.zeros_like(inside)
for contour in contours:
for start, end in zip(contour, np.roll(contour, -1, axis=0)):
segment = end - start
length_squared = float(segment.dot(segment))
if length_squared <= 1e-16:
continue
first_row = max(
0,
int(
math.ceil(
(min(start[1], end[1]) - clearance - minimum[1]) / row_spacing
)
),
)
last_row = min(
row_count - 1,
int(
math.floor(
(max(start[1], end[1]) + clearance - minimum[1]) / row_spacing
)
),
)
for row in range(first_row, last_row + 1):
offset = spacing * 0.5 if row % 2 else 0.0
first_column = max(
0,
int(
math.ceil(
(min(start[0], end[0]) - clearance - minimum[0] - offset)
/ spacing
)
),
)
last_column = min(
column_count - 1,
int(
math.floor(
(max(start[0], end[0]) + clearance - minimum[0] - offset)
/ spacing
)
),
)
if last_column < first_column:
continue
selected = np.arange(first_column, last_column + 1)
row_points = np.column_stack(
(x[row, selected], np.full(len(selected), y[row]))
)
factor = np.clip(
(row_points - start) @ segment / length_squared, 0.0, 1.0
)
nearest = start + factor[:, None] * segment
close = np.sum((row_points - nearest) ** 2, axis=1) < clearance**2
near_boundary[row, selected[close]] = True
usable = inside & ~near_boundary
return np.column_stack((x[usable], np.broadcast_to(y[:, None], x.shape)[usable]))
def metric_transform(world_metric=None):
import numpy as np
@@ -478,82 +396,13 @@ def metric_transform(world_metric=None):
raise ValueError("The Image Empty has a degenerate world transform") from error
def prepare_planar_contours(
contours,
image_size,
local_bounds,
spacing=POINT_SPACING,
world_metric=None,
):
import numpy as np
if spacing <= 0.0:
raise ValueError("Cutout point spacing must be greater than zero")
image_width, image_height = image_size
x_min, x_max, y_min, y_max = local_bounds
local_width = x_max - x_min
local_height = y_max - y_min
transform = metric_transform(world_metric)
prepared = []
for contour in contours:
pixels = np.asarray(contour, dtype=np.float64)
if pixels.ndim != 2 or pixels.shape[0] < 3 or pixels.shape[1] != 2:
continue
local = np.empty_like(pixels)
local[:, 0] = x_min + local_width * pixels[:, 0] / image_width
local[:, 1] = y_max - local_height * pixels[:, 1] / image_height
planar = local @ transform
perimeter = float(
np.linalg.norm(np.roll(planar, -1, axis=0) - planar, axis=1).sum()
)
if perimeter <= 1e-12:
continue
contour_spacing = min(spacing, perimeter / 8.0)
smoothed = smooth_selection_contour(planar, contour_spacing)
smoothed_local = smoothed @ np.linalg.inv(transform)
smoothed_local[:, 0] = np.clip(smoothed_local[:, 0], x_min, x_max)
smoothed_local[:, 1] = np.clip(smoothed_local[:, 1], y_min, y_max)
prepared.append(smoothed_local @ transform)
if not prepared:
raise ValueError("The Cutout has no selection large enough for this resolution")
interior = uniform_interior_points_for_contours(prepared, spacing)
return prepared, interior, spacing
def _prepare_selection_contours(
selection_contours,
local_bounds,
spacing=POINT_SPACING,
world_metric=None,
):
prepared = []
errors = []
for component in selection_contours.components:
try:
contours, interior, _spacing = prepare_planar_contours(
component,
selection_contours.image_size,
local_bounds,
spacing=spacing,
world_metric=world_metric,
)
except ValueError as error:
errors.append(error)
continue
prepared.append((contours, interior))
if not prepared:
if errors:
raise errors[0]
raise ValueError("The Cutout has no usable selection")
return prepared, spacing
def _triangulate_contour_component(contours, interior, spacing):
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, interior), axis=0)
input_points = np.concatenate(contours, axis=0)
boundary_edges = []
offset = 0
for contour in contours:
@@ -583,15 +432,15 @@ def _triangulate_contour_component(contours, interior, spacing):
faces = faces[points_inside_contours(centers, contours)]
if not len(faces):
raise ValueError("Unable to triangulate the Cutout selection components")
used = np.unique(faces)
if len(used) != len(planar_points):
remap = np.full(len(planar_points), -1, dtype=np.int32)
remap[used] = np.arange(len(used), dtype=np.int32)
planar_points = planar_points[used]
faces = remap[faces]
boundary = boundary_vertices_from_faces(len(planar_points), faces)
planar_faces = [tuple(int(value) for value in face) for face in faces]
return planar_points, planar_faces, boundary
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):
@@ -614,54 +463,6 @@ def boundary_vertices_from_faces(vertex_count, faces):
return boundary
def triangulate_selection_contours(
selection_contours,
local_bounds,
spacing=POINT_SPACING,
world_metric=None,
return_components=False,
):
import numpy as np
prepared, spacing = _prepare_selection_contours(
selection_contours,
local_bounds,
spacing=spacing,
world_metric=world_metric,
)
points = []
faces = []
boundaries = []
components = []
offset = 0
for contours, interior in prepared:
component_points, component_faces, component_boundary = _triangulate_contour_component(
contours,
interior,
spacing,
)
components.append(
(
offset,
offset + len(component_points),
component_faces,
contours[0],
)
)
points.append(component_points)
faces.extend(tuple(offset + value for value in face) for face in component_faces)
boundaries.append(component_boundary)
offset += len(component_points)
if not points:
raise ValueError("Unable to triangulate the Cutout selection components")
result = (
np.concatenate(points, axis=0),
faces,
np.concatenate(boundaries),
)
return (*result, components) if return_components else result
CUTOUT_ALPHA_THRESHOLD = 0.9
CUTOUT_CLEANUP_UI_SCALE = 100.0
@@ -674,84 +475,26 @@ class BaseShape:
balloon: object
def refine_balloon_triangles(points, faces, boundary):
"""Add interior samples to boundary ears and interior boundary chords."""
from collections import Counter
import numpy as np
edges = Counter(tuple(sorted((face[i], face[(i + 1) % 3]))) for face in faces for i in range(3))
values, midpoints = list(points), {}
for edge, count in edges.items():
if count == 2 and all(boundary[index] for index in edge):
midpoints[edge] = len(values)
values.append(points[list(edge)].mean(axis=0))
refined = []
for face in faces:
ring = []
for index, vertex in enumerate(face):
ring.append(vertex)
midpoint = midpoints.get(tuple(sorted((vertex, face[(index + 1) % 3]))))
if midpoint is not None:
ring.append(midpoint)
if len(ring) == 3 and not all(boundary[vertex] for vertex in face):
refined.append(tuple(face))
continue
center = len(values)
values.append(points[list(face)].mean(axis=0))
refined.extend((center, ring[i], ring[(i + 1) % len(ring)]) for i in range(len(ring)))
points = np.asarray(values)
return points, refined, boundary_vertices_from_faces(len(points), refined)
def cutout_domain(
selection_contours,
bounds,
spacing,
world_metric,
balloon_algorithm="POISSON",
*,
fine_mesh=None,
):
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)
if fine_mesh is None:
planar, planar_faces, boundary, components = triangulate_selection_contours(
selection_contours,
bounds,
spacing=spacing,
world_metric=world_metric,
return_components=True,
)
else:
pixels, planar_faces, boundary, pixel_components, image_size = fine_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
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)
planar_faces = planar_faces.tolist()
components = [
(start, end, faces.tolist(), pixel_to_planar(contour))
for start, end, faces, contour in pixel_components
]
refined_points, refined_faces, refined_components = [], [], []
offset = 0
for start, end, component_faces, contour in components:
points, faces, _ = refine_balloon_triangles(planar[start:end], component_faces, boundary[start:end])
refined_points.append(points)
refined_faces.extend(tuple(offset + index for index in face) for face in faces)
refined_components.append((offset, offset + len(points), faces, contour))
offset += len(points)
planar = np.concatenate(refined_points)
planar_faces, components = refined_faces, refined_components
boundary = boundary_vertices_from_faces(len(planar), planar_faces)
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
)
@@ -836,7 +579,11 @@ def extract_cutout_contours(
def effective_cutout_edge_length(
context, source_object, edge_length, content_values, content_bounds,
context,
source_object,
edge_length,
content_values,
content_bounds,
threshold=CUTOUT_ALPHA_THRESHOLD,
):
"""Resolve target spacing in Blender world units from visible content bounds."""
@@ -850,16 +597,25 @@ def effective_cutout_edge_length(
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))
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]
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))
return max(
float(edge_length),
longest * configured_min_cutout_relative_edge_length(context),
)
def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD):
def selection_calibration(
content_values, target_shape, threshold=CUTOUT_ALPHA_THRESHOLD
):
"""Return Cutout validity and transparency ratio at geometry resolution."""
import numpy as np
@@ -900,39 +656,27 @@ def build_base_shape(
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)
longest = max(
np.linalg.norm(pixel_transform[0]) * content_values.shape[1],
np.linalg.norm(pixel_transform[1]) * content_values.shape[0],
)
fine_mesh = None
selection_contours = None
if fine_outline:
from .fine_mesh import build_fine_mesh
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
fine_mesh = (
*build_fine_mesh(content_values, edge_length, alpha_threshold, pixel_transform=pixel_transform),
(content_values.shape[1], content_values.shape[0]),
)
else:
selection_contours = extract_cutout_contours(
mesh = (
*build_mesh(
content_values,
longest / edge_length,
threshold=alpha_threshold,
)
edge_length,
alpha_threshold,
pixel_transform=pixel_transform,
fine_outline=fine_outline,
),
(content_values.shape[1], content_values.shape[0]),
)
preferences = addon_preferences(context)
balloon_algorithm = getattr(preferences, "balloon_profile_algorithm", "POISSON")
domain = cutout_domain(
selection_contours,
bounds,
edge_length,
world_metric,
balloon_algorithm,
fine_mesh=fine_mesh,
)
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),

View File

@@ -0,0 +1,426 @@
"""Cutout contours, quality triangulation and local height support."""
import numpy as np
from scipy import ndimage
from scipy.spatial import cKDTree
from .polygon import approximate_polygon
from . import geometry as g
# Keep predicate tolerances independent of scene units and requested density.
NORMALIZED_EDGE_LENGTH = 32.0
def separate_contour_contacts(contours):
"""Open pixel-corner contacts with a subpixel bevel in each incident ring."""
points = np.concatenate(contours)
pairs = cKDTree(points).query_pairs(1e-8)
if not pairs:
return contours
contacts = {i for pair in pairs for i in pair}
result = []
offset = 0
for contour in contours:
ring = contour.copy()
for i in range(len(ring)):
if offset + i in contacts:
before = contour[i-1] - contour[i]
after = contour[(i+1) % len(ring)] - contour[i]
length = min(np.linalg.norm(before), np.linalg.norm(after))
direction = (
before / np.linalg.norm(before) + after / np.linalg.norm(after)
)
ring[i] += direction * length * 0.125
result.append(ring)
offset += len(ring)
return result
def support_mesh(points, faces, *, max_vertices=50000):
"""Split boundary chords once so every face has an interior height sample."""
boundary = g.boundary_vertices_from_faces(len(points), faces)
edges, counts = np.unique(
np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1),
axis=0, return_counts=True,
)
chords = edges[(counts == 2) & boundary[edges].all(axis=1)]
midpoints = {tuple(edge): len(points) + i for i, edge in enumerate(chords)}
isolated = sum(
boundary[face].all() and not any(
tuple(sorted((face[i], face[(i+1) % 3]))) in midpoints for i in range(3)
) for face in faces
)
if len(points) + len(chords) + isolated > max_vertices:
raise ValueError("Cutout support vertex limit exceeded")
if not len(chords) and not isolated:
return points, faces
vertices = points.tolist() + points[chords].mean(axis=1).tolist()
output = []
for face in faces:
pieces = [list(face)]
for i in range(3):
a, b = face[i], face[(i+1) % 3]
midpoint = midpoints.get(tuple(sorted((a, b))))
if midpoint is None:
continue
split = []
for piece in pieces:
if a in piece and b in piece:
k = next(
j for j in range(3)
if piece[j] in (a, b) and piece[(j+1) % 3] in (a, b)
)
x, y, z = piece[k], piece[(k+1) % 3], piece[(k+2) % 3]
split.extend(((x, midpoint, z), (midpoint, y, z)))
else:
split.append(piece)
pieces = split
if len(pieces) == 1 and boundary[face].all():
midpoint = len(vertices)
vertices.append(points[face].mean(axis=0).tolist())
pieces = [(face[i], face[(i+1) % 3], midpoint) for i in range(3)]
output.extend(pieces)
return np.asarray(vertices), np.asarray(output, dtype=np.int32)
def valid_contours(contours):
"""Reject crossing rings and holes moved outside the outer ring."""
starts = np.concatenate(contours)
if cKDTree(starts).query_pairs(1e-8):
return False
ends = np.concatenate([np.roll(c, -1, axis=0) for c in contours])
delta = ends - starts
count = len(starts)
following = np.concatenate(
[
np.roll(np.arange(len(contour)) + sum(map(len, contours[:index])), -1)
for index, contour in enumerate(contours)
]
)
for first in range(0, count, 32):
ids = np.arange(first, min(first + 32, count))[:, None]
other = np.arange(count)[None, :]
a = starts[first : first + 32, None]
v = delta[first : first + 32, None]
w = delta[None]
d = starts[None] - a
determinant = v[..., 0] * w[..., 1] - v[..., 1] * w[..., 0]
divisor = np.where(np.abs(determinant) > 1e-12, determinant, 1.0)
t = (d[..., 0] * w[..., 1] - d[..., 1] * w[..., 0]) / divisor
u = (d[..., 0] * v[..., 1] - d[..., 1] * v[..., 0]) / divisor
adjacent = (other == following[ids]) | (ids == following[other])
if np.any(
(other > ids)
& ~adjacent
& (np.abs(determinant) > 1e-12)
& (t >= -1e-9)
& (t <= 1 + 1e-9)
& (u >= -1e-9)
& (u <= 1 + 1e-9)
):
return False
return all(
g.points_inside_contours(hole[:1], contours[:1])[0] for hole in contours[1:]
)
def prepare_mask(alpha, threshold, transform, fine_outline):
"""Fill holes according to the normalized world-space target length."""
filtered = filter_alpha(alpha, threshold)
mask = (filtered > 0) & (filtered >= threshold)
filled = ndimage.binary_fill_holes(mask)
if not fine_outline:
return filled
holes, _ = ndimage.label(filled & ~mask)
areas = np.bincount(holes.ravel()) * abs(np.linalg.det(transform))
for index, slices in enumerate(ndimage.find_objects(holes), 1):
if slices is None or areas[index] > 0.04 * NORMALIZED_EDGE_LENGTH**2:
continue
y, x = slices
corners = (
np.array(
(
(x.start, y.start),
(x.stop, y.start),
(x.stop, y.stop),
(x.start, y.stop),
)
)
@ transform
)
if np.ptp(corners, axis=0).max() <= 0.25 * NORMALIZED_EDGE_LENGTH:
mask[slices] |= holes[slices] == index
return mask
def build_mesh(alpha, spacing, threshold, *, pixel_transform=None, fine_outline=True):
"""Return pixel-space quality triangles and component ranges."""
alpha = np.asarray(alpha, dtype=float)
if alpha.ndim != 2 or not alpha.size or not np.isfinite(alpha).all():
raise ValueError("The Cutout Alpha must be a finite two-dimensional array")
if not np.isfinite(spacing) or spacing <= 0:
raise ValueError("Cutout point spacing must be greater than zero")
transform = (
np.eye(2) if pixel_transform is None else np.asarray(pixel_transform)
) * (NORMALIZED_EDGE_LENGTH / spacing)
inverse = np.linalg.inv(transform)
mask = prepare_mask(alpha, threshold, transform, fine_outline)
if not mask.any():
raise ValueError("The Cutout selection contains no visible image pixels")
reference = g.extract_selection_contours(
mask.astype(float), max_resolution=max(mask.shape), threshold=0.5
)
prepared = []
for component in reference.components:
raw = separate_contour_contacts([np.asarray(c) @ transform for c in component])
if fine_outline:
contours = contours_for_component(raw, NORMALIZED_EDGE_LENGTH, pixel_transform=transform)
else:
contours = [
g.smooth_selection_contour(
c,
min(
NORMALIZED_EDGE_LENGTH,
np.linalg.norm(np.roll(c, -1, axis=0) - c, axis=1).sum() / 8,
),
)
for c in raw
]
contours = [
np.clip(c @ inverse, (0, 0), mask.shape[::-1]) @ transform
for c in contours
]
if not valid_contours(contours):
raise ValueError("Cutout simplified outline is invalid")
prepared.append(contours)
points, faces, components = [], [], []
offset = 0
for contours in prepared:
vertices, triangles = triangulate(contours, NORMALIZED_EDGE_LENGTH)
vertices, triangles = support_mesh(
vertices, triangles, max_vertices=50000-offset
)
points.append(vertices @ inverse)
faces.append(triangles + offset)
components.append(
(offset, offset + len(vertices), triangles, contours[0] @ inverse)
)
offset += len(vertices)
if offset > 50000:
raise ValueError("Cutout mesh vertex limit exceeded")
points, faces = np.concatenate(points), np.concatenate(faces)
return points, faces, g.boundary_vertices_from_faces(len(points), faces), components
def filter_alpha(alpha, threshold, min_area=16):
mask = (alpha > 0) & (alpha >= threshold)
labels, count = ndimage.label(mask)
areas = np.bincount(labels.ravel(), minlength=count + 1)
keep = areas >= min_area
keep[0] = False
if count:
keep[1 + np.argmax(areas[1:])] = True
removed = mask & ~keep[labels]
result = alpha.copy()
result[removed] = 0
return result
def contours_for_component(component, spacing, strength=1.0, *, pixel_transform=None):
"""Soften pixel steps before simplifying in the target edge-length metric."""
result = []
transform = np.eye(2) if pixel_transform is None else pixel_transform
inverse = np.linalg.inv(transform)
tolerance = spacing * 0.078125
for contour in component:
original = np.asarray(contour, dtype=float)
if strength > 0:
original = ndimage.gaussian_filter1d(
g.resample_closed_contour(original @ inverse, 0.5),
1.6 * strength,
axis=0,
mode="wrap",
) @ transform
for error in (tolerance * strength, tolerance * strength * 0.5, 0):
simplified = approximate_polygon(np.vstack((original, original[0])), error)[
:-1
]
if len(simplified) >= 3 and abs(g.contour_area(simplified)) > 0.05:
break
points = []
for a, b in zip(simplified, np.roll(simplified, -1, axis=0)):
count = max(1, int(np.ceil(np.linalg.norm(b - a) / spacing)))
points.extend(a + np.arange(count)[:, None] / count * (b - a))
result.append(np.asarray(points))
if not valid_contours(result):
if strength == 0:
raise ValueError("Cutout outline is invalid")
return contours_for_component(
component, spacing, strength * 0.5 if strength > 0.125 else 0,
pixel_transform=transform,
)
return result
def normalize_constraints(points, edges):
"""Merge coincident vertices and split constraints at existing vertices."""
tree = cKDTree(points)
parents = np.arange(len(points))
def root(index):
while parents[index] != index:
parents[index] = parents[parents[index]]
index = parents[index]
return index
for a, b in sorted(tree.query_pairs(1e-8)):
parents[root(b)] = root(a)
representatives = np.array([root(i) for i in range(len(points))])
used, inverse = np.unique(representatives, return_inverse=True)
points = points[used]
tree = cKDTree(points)
segments = []
seen = {}
sources = {}
for marker, (a, b) in enumerate(edges, 1):
a, b = inverse[a], inverse[b]
delta = points[b] - points[a]
length = np.linalg.norm(delta)
if length <= 1e-8:
raise ValueError("Cutout constraint collapsed during normalization")
candidates = np.array(
tree.query_ball_point((points[a] + points[b]) / 2, length / 2 + 1e-8),
dtype=int,
)
fraction = (points[candidates] - points[a]) @ delta / length**2
distance = np.linalg.norm(
points[candidates] - points[a] - fraction[:, None] * delta, axis=1
)
valid = (fraction >= -1e-10) & (fraction <= 1 + 1e-10) & (distance < 1e-8)
ids = candidates[valid][np.argsort(fraction[valid])]
for x, y in zip(ids, ids[1:]):
key = tuple(sorted((int(x), int(y))))
if key in seen:
sources[seen[key]].add(marker)
continue
identity = len(segments) + 1
seen[key] = identity
sources[identity] = {marker}
segments.append((int(x), int(y), identity))
return points, segments, sources
def triangulate(contours, spacing):
"""Build and validate a quality mesh with complete constraint chains."""
from . import tangle
from scipy.sparse import coo_matrix
from scipy.sparse.csgraph import connected_components
points = np.concatenate(contours)
if len(points) > 20000:
raise ValueError("Cutout constraint vertex limit exceeded")
edges = []
offset = 0
for contour in contours:
edges.extend(
(offset + i, offset + (i + 1) % len(contour)) for i in range(len(contour))
)
offset += len(contour)
input_points = points
points, segments, sources = normalize_constraints(points, edges)
points = np.vstack((points, g.contour_interior_point(contours, spacing)))
holes = [g.contour_interior_point([contour], spacing).tolist() for contour in contours[1:]]
raw_points, raw_faces, raw_segments = tangle.build_mesh(
points.tolist(), segments, holes, np.sqrt(3) / 4 * spacing**2
)
points = np.asarray(raw_points)
faces = np.asarray(raw_faces, dtype=np.int32).reshape(-1, 3)
if not len(faces) or not np.isfinite(points).all():
raise ValueError("Cutout triangulation is empty or nonfinite")
used = np.unique(faces)
remap = np.full(len(points), -1, dtype=np.int32)
remap[used] = np.arange(len(used))
points, faces = points[used], remap[faces]
triangles = points[faces]
areas = (
abs(
np.cross(
triangles[:, 1] - triangles[:, 0], triangles[:, 2] - triangles[:, 0]
)
)
/ 2
)
expected_area = abs(g.contour_area(contours[0])) - sum(
abs(g.contour_area(c)) for c in contours[1:]
)
if np.any(areas <= 1e-10) or not np.isclose(
areas.sum(), expected_area, rtol=1e-8, atol=1e-7
):
raise ValueError("Cutout triangulation has degenerate faces or incorrect area")
face_edges, counts = np.unique(
np.sort(np.concatenate((faces[:, :2], faces[:, 1:], faces[:, ::2])), axis=1),
axis=0,
return_counts=True,
)
if counts.max() > 2:
raise ValueError("Cutout triangulation has nonmanifold edges")
graph = coo_matrix(
(np.ones(len(face_edges)), (face_edges[:, 0], face_edges[:, 1])),
shape=(len(points), len(points)),
).tocsr()
components, _ = connected_components(graph, directed=False)
if (
components != 1
or components - len(points) + len(face_edges) - len(faces) != len(contours) - 1
):
raise ValueError(
"Cutout triangulation has incorrect component or hole topology"
)
actual_edges = {tuple(e) for e in face_edges}
chains = [[] for _ in edges]
for a, b, marker in raw_segments:
if marker in sources and min(remap[a], remap[b]) >= 0:
edge = tuple(sorted((int(remap[a]), int(remap[b]))))
if edge in actual_edges:
for original in sources[marker]:
chains[original - 1].append(edge)
tree = cKDTree(points)
for (a, b), chain in zip(edges, chains):
error = "Cutout lost a boundary constraint"
if not chain:
raise ValueError(error)
chain = np.asarray(chain)
start, end = input_points[[a, b]]
delta = end - start
length = np.linalg.norm(delta)
vertices = np.unique(chain)
distance = np.linalg.norm(
points[vertices]
- start
- ((points[vertices] - start) @ delta / length**2)[:, None] * delta,
axis=1,
)
covered = np.linalg.norm(
points[chain[:, 1]] - points[chain[:, 0]], axis=1
).sum()
endpoint_distance, endpoints = tree.query([start, end])
adjacency = {}
for x, y in chain:
adjacency.setdefault(x, set()).add(y)
adjacency.setdefault(y, set()).add(x)
visited = set()
pending = [endpoints[0]]
while pending:
vertex = pending.pop()
if vertex not in visited:
visited.add(vertex)
pending.extend(adjacency.get(vertex, ()))
if (
distance.max() > 1e-6
or endpoint_distance.max() > 1e-6
or endpoints[1] not in visited
or not np.isclose(covered, length, rtol=1e-7, atol=1e-6)
):
raise ValueError(error)
return points, faces

File diff suppressed because it is too large Load Diff