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

Binary file not shown.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 David Meeker
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

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

View File

@@ -170,6 +170,8 @@ class AnyImagePreferences(bpy.types.AddonPreferences):
default=1.0,
min=0.001,
max=100.0,
soft_min=0.5,
soft_max=10.0,
subtype="PERCENTAGE",
)
max_frame_resolution: bpy.props.IntProperty(

View File

@@ -237,7 +237,7 @@ def cutout_alpha_threshold_property():
def cutout_fine_outline_property():
return bpy.props.BoolProperty(
name="Fine Outline",
description="Fit detailed outlines and support thin parts; disable for simpler, faster meshes",
description="Preserve detailed outlines and meaningful holes; disable to fill holes and simplify outlines",
default=False,
)
@@ -265,6 +265,8 @@ def cutout_edge_length_property(*, store_meters=False, **options):
description="Target mesh edge length in world space",
default=0.1,
min=1e-6,
soft_min=0.01,
soft_max=1.0,
unit="LENGTH",
subtype="DISTANCE",
**options,