Add comprehensive tests for edge depth extension, empty image edits, and material view adaptation
- Introduced `test_edge_depth.py` to validate the functionality of edge depth extension, ensuring core alpha preservation, proper depth handling, and edge padding behavior. - Created `test_empty_image_edits.py` to verify the behavior of image edits in Blender, focusing on shared edits, preservation of image properties, and handling of failed operations. - Developed `test_material_view_adaptation.py` to test material adaptation preferences, ensuring color space and alpha settings are correctly applied and preserved across various scenarios.
This commit is contained in:
@@ -4,6 +4,7 @@ import tempfile
|
||||
|
||||
import bpy
|
||||
|
||||
from ...common.material import configure_material_color_image
|
||||
|
||||
CLIPBOARD_HASH_PROPERTY = "anyimage_clipboard_sha256"
|
||||
CLIPBOARD_TEXTURE_PROPERTY = "anyimage_clipboard_texture"
|
||||
@@ -149,7 +150,7 @@ def add_image_plane(
|
||||
from ..convert_to_plane.mesh_plane import create_mesh_plane_object
|
||||
|
||||
half_width, half_height = _plane_half_dimensions(width, height)
|
||||
material = _create_image_material(image, shadeless)
|
||||
material = _create_image_material(image, shadeless, scene=context.scene)
|
||||
plane = create_mesh_plane_object(
|
||||
context,
|
||||
image.name,
|
||||
@@ -197,7 +198,8 @@ def _plane_half_dimensions(width, height):
|
||||
return width / height, 1.0
|
||||
|
||||
|
||||
def _create_image_material(image, shadeless=False):
|
||||
def _create_image_material(image, shadeless=False, *, scene=None):
|
||||
material_image = configure_material_color_image(image, scene)
|
||||
material = bpy.data.materials.new(name=f"{image.name} Material")
|
||||
material.use_nodes = True
|
||||
if hasattr(material, "surface_render_method"):
|
||||
@@ -206,7 +208,7 @@ def _create_image_material(image, shadeless=False):
|
||||
nodes = material.node_tree.nodes
|
||||
nodes.clear()
|
||||
image_node = nodes.new("ShaderNodeTexImage")
|
||||
image_node.image = image
|
||||
image_node.image = material_image
|
||||
image_node.location = (-620.0, 80.0)
|
||||
|
||||
output_node = nodes.new("ShaderNodeOutputMaterial")
|
||||
|
||||
621
src/anyimage/operators/cutout_tool/fine_mesh.py
Normal file
621
src/anyimage/operators/cutout_tool/fine_mesh.py
Normal file
@@ -0,0 +1,621 @@
|
||||
"""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):
|
||||
"""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
|
||||
)
|
||||
width_sizes = WidthSamples(mask)
|
||||
points, faces, components = [], [], []
|
||||
offset = 0
|
||||
for component in reference.components:
|
||||
contours = contours_for_component(component, spacing)
|
||||
spine, path_edges = interior_paths(
|
||||
contours, min(32, max(mask.shape) / 6), width_sizes
|
||||
)
|
||||
contours, spine, path_edges = subdivide_constraints(
|
||||
contours, spine, path_edges, spacing
|
||||
)
|
||||
free = relax_points(contours, spine, mask.shape, 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)
|
||||
faces.append(triangles + offset)
|
||||
components.append((offset, offset + len(vertices), triangles, contours[0]))
|
||||
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):
|
||||
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):
|
||||
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 * 0.9) + 0.4 * distance)
|
||||
)
|
||||
|
||||
|
||||
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, shape, 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(phase, shape[1], step) + 0.5
|
||||
yy = np.arange(phase, shape[0], 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]])
|
||||
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),
|
||||
)
|
||||
@@ -216,7 +216,7 @@ def extract_selection_contours(selection_values, max_resolution=512, threshold=0
|
||||
sampled_size = tuple(max(1, round(value * scale)) for value in source_size)
|
||||
if sampled_size != source_size:
|
||||
values = _resize_values(values, sampled_size)
|
||||
mask = values >= 255.0 * threshold
|
||||
mask = (values > 0.0) & (values >= 255.0 * threshold)
|
||||
components = _group_selection_contours(mask)
|
||||
scale_x = source_size[0] / sampled_size[0]
|
||||
scale_y = source_size[1] / sampled_size[1]
|
||||
@@ -1226,7 +1226,7 @@ def depth_balloon_projection_parameters(
|
||||
return reference, tuple(float(value) for value in direction)
|
||||
|
||||
|
||||
CUTOUT_ALPHA_THRESHOLD = 0.1
|
||||
CUTOUT_ALPHA_THRESHOLD = 0.9
|
||||
CUTOUT_CLEANUP_UI_SCALE = 100.0
|
||||
|
||||
|
||||
@@ -1244,18 +1244,38 @@ def cutout_domain(
|
||||
spacing,
|
||||
world_metric,
|
||||
balloon_algorithm="POISSON",
|
||||
*,
|
||||
fine_mesh=None,
|
||||
):
|
||||
import numpy as np
|
||||
|
||||
x_min, x_max, y_min, y_max = bounds
|
||||
|
||||
planar, planar_faces, boundary, components = triangulate_selection_contours(
|
||||
selection_contours,
|
||||
bounds,
|
||||
spacing=spacing,
|
||||
world_metric=world_metric,
|
||||
return_components=True,
|
||||
)
|
||||
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
|
||||
|
||||
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
|
||||
]
|
||||
profile = balloon_profile(
|
||||
balloon_algorithm, planar, planar_faces, boundary, components
|
||||
)
|
||||
@@ -1381,7 +1401,7 @@ def selection_calibration(content_values, target_shape, threshold=CUTOUT_ALPHA_T
|
||||
source_width - 1,
|
||||
)
|
||||
resized = values[y[:, None], x[None, :]]
|
||||
valid = resized > float(threshold)
|
||||
valid = (resized > 0.0) & (resized >= float(threshold))
|
||||
transparent_fraction = float(np.count_nonzero(~valid) / valid.size)
|
||||
return valid, transparent_fraction
|
||||
|
||||
@@ -1392,6 +1412,9 @@ def build_base_shape(
|
||||
point_spacing,
|
||||
content_values,
|
||||
content_bounds,
|
||||
*,
|
||||
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
|
||||
fine_outline=False,
|
||||
):
|
||||
import numpy as np
|
||||
|
||||
@@ -1400,11 +1423,21 @@ def build_base_shape(
|
||||
content_bounds,
|
||||
tuple(source_object.data.size),
|
||||
)
|
||||
selection_contours = extract_cutout_contours(
|
||||
content_values,
|
||||
point_spacing,
|
||||
threshold=CUTOUT_ALPHA_THRESHOLD,
|
||||
)
|
||||
fine_mesh = None
|
||||
selection_contours = None
|
||||
if fine_outline:
|
||||
from .fine_mesh import build_fine_mesh
|
||||
|
||||
fine_mesh = (
|
||||
*build_fine_mesh(content_values, point_spacing, alpha_threshold),
|
||||
(content_values.shape[1], content_values.shape[0]),
|
||||
)
|
||||
else:
|
||||
selection_contours = extract_cutout_contours(
|
||||
content_values,
|
||||
point_spacing,
|
||||
threshold=alpha_threshold,
|
||||
)
|
||||
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")
|
||||
@@ -1417,6 +1450,7 @@ def build_base_shape(
|
||||
point_spacing,
|
||||
world_metric,
|
||||
balloon_algorithm,
|
||||
fine_mesh=fine_mesh,
|
||||
)
|
||||
source_vertices, faces, uv, _boundary, planar, balloon = domain
|
||||
return BaseShape(
|
||||
|
||||
@@ -32,6 +32,8 @@ def _pie_button(
|
||||
generate_normal,
|
||||
point_spacing,
|
||||
inward_axis,
|
||||
alpha_threshold,
|
||||
fine_outline,
|
||||
):
|
||||
operator = pie.operator(
|
||||
"anyimage.cutout_selection_to_shape",
|
||||
@@ -44,6 +46,8 @@ def _pie_button(
|
||||
operator.generate_normal = generate_normal
|
||||
operator.point_spacing = point_spacing
|
||||
operator.inward_axis = inward_axis
|
||||
operator.alpha_threshold = alpha_threshold
|
||||
operator.fine_outline = fine_outline
|
||||
|
||||
|
||||
def open_shape_pie(
|
||||
@@ -61,6 +65,8 @@ def open_shape_pie(
|
||||
getattr(settings, "cutout_generate_normal", False)
|
||||
)
|
||||
inward_axis = str(getattr(settings, "cutout_inward_axis", "NEGATIVE_Z"))
|
||||
alpha_threshold = settings.cutout_alpha_threshold
|
||||
fine_outline = settings.cutout_fine_outline
|
||||
|
||||
def draw(menu, _context):
|
||||
pie = menu.layout.menu_pie()
|
||||
@@ -76,6 +82,8 @@ def open_shape_pie(
|
||||
generate_normal,
|
||||
point_spacing,
|
||||
inward_axis,
|
||||
alpha_threshold,
|
||||
fine_outline,
|
||||
)
|
||||
|
||||
context.window_manager.popup_menu_pie(
|
||||
|
||||
@@ -9,7 +9,11 @@ from ...preferences import (
|
||||
configured_max_ai_input_size,
|
||||
configured_max_cutout_mesh_resolution,
|
||||
)
|
||||
from ...properties import DEFAULT_MOGE2_MODEL_KEY
|
||||
from ...properties import (
|
||||
DEFAULT_MOGE2_MODEL_KEY,
|
||||
cutout_alpha_threshold_property,
|
||||
cutout_fine_outline_property,
|
||||
)
|
||||
from ...runtime import JobOperatorBase
|
||||
from ...server.media.resolution import (
|
||||
DEFAULT_MAX_AI_INPUT_SIZE,
|
||||
@@ -92,6 +96,8 @@ def create_cutout_shape(
|
||||
content_values,
|
||||
content_bounds,
|
||||
*,
|
||||
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
|
||||
fine_outline=False,
|
||||
depth_image=None,
|
||||
depth_metadata=None,
|
||||
normal_image=None,
|
||||
@@ -106,6 +112,8 @@ def create_cutout_shape(
|
||||
point_spacing,
|
||||
content_values,
|
||||
content_bounds,
|
||||
alpha_threshold=alpha_threshold,
|
||||
fine_outline=fine_outline,
|
||||
)
|
||||
result = create_shape_object(
|
||||
context,
|
||||
@@ -115,6 +123,7 @@ def create_cutout_shape(
|
||||
content_values,
|
||||
content_bounds,
|
||||
base_shape,
|
||||
alpha_threshold=alpha_threshold,
|
||||
depth_image=depth_image,
|
||||
depth_metadata=depth_metadata,
|
||||
normal_image=normal_image,
|
||||
@@ -132,7 +141,9 @@ def create_cutout_shape(
|
||||
return result
|
||||
|
||||
|
||||
def _cutout_content_values(source_alpha, selection_values=None):
|
||||
def _cutout_content_values(
|
||||
source_alpha, selection_values=None, *, alpha_threshold=CUTOUT_ALPHA_THRESHOLD
|
||||
):
|
||||
"""Combine local image Alpha with optional pure Selection geometry."""
|
||||
import numpy as np
|
||||
|
||||
@@ -146,7 +157,7 @@ def _cutout_content_values(source_alpha, selection_values=None):
|
||||
if alpha.shape != selection.shape:
|
||||
raise ValueError("The Cutout source Alpha does not match the Selection")
|
||||
values = alpha * selection
|
||||
if not np.any(values > CUTOUT_ALPHA_THRESHOLD):
|
||||
if not np.any((values > 0.0) & (values >= alpha_threshold)):
|
||||
raise ImageEditWarning(
|
||||
"The Cutout selection contains no visible image pixels"
|
||||
)
|
||||
@@ -212,6 +223,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
options=_HIDDEN,
|
||||
)
|
||||
generate_normal: bpy.props.BoolProperty(default=False, options=_HIDDEN)
|
||||
alpha_threshold: cutout_alpha_threshold_property()
|
||||
fine_outline: cutout_fine_outline_property()
|
||||
inward_axis: bpy.props.StringProperty(
|
||||
default=CutoutInwardAxis.NEGATIVE_Z.value,
|
||||
options=_HIDDEN,
|
||||
@@ -254,6 +267,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
content_values = _cutout_content_values(
|
||||
source_rgba[top:bottom, left:right, 3],
|
||||
selection_mask.values,
|
||||
alpha_threshold=self.alpha_threshold,
|
||||
)
|
||||
color_image = create_cropped_color_image(
|
||||
source_object,
|
||||
@@ -268,6 +282,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
content_values,
|
||||
selection_mask.bounds,
|
||||
inward_axis=self.inward_axis,
|
||||
alpha_threshold=self.alpha_threshold,
|
||||
fine_outline=self.fine_outline,
|
||||
color_image=color_image,
|
||||
)
|
||||
return {"FINISHED"}
|
||||
@@ -314,6 +330,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
"depth_mode": depth_mode,
|
||||
"normal_mode": normal_mode.value,
|
||||
"max_input_size": int(self.max_ai_input_size),
|
||||
"alpha_threshold": float(self.alpha_threshold),
|
||||
}
|
||||
if depth_mode != "NONE" or normal_mode != NormalMode.NONE:
|
||||
start_frame = source_start_frame(self.source_object_name)
|
||||
@@ -356,6 +373,7 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
content_values = _cutout_content_values(
|
||||
source_alpha,
|
||||
selection_mask.values,
|
||||
alpha_threshold=self.alpha_threshold,
|
||||
)
|
||||
content_bounds = color_bounds
|
||||
normal_mode = normal_mode_for_shape(self.shape, self.generate_normal)
|
||||
@@ -389,6 +407,8 @@ class CutoutSelectionToShape(JobOperatorBase, bpy.types.Operator):
|
||||
normal_image=normal_image,
|
||||
normal_mode=normal_mode,
|
||||
inward_axis=inward_axis,
|
||||
alpha_threshold=self.alpha_threshold,
|
||||
fine_outline=self.fine_outline,
|
||||
color_image=color_image,
|
||||
)
|
||||
except Exception:
|
||||
@@ -490,4 +510,6 @@ class CutoutTool(bpy.types.WorkSpaceTool):
|
||||
status = draw_ai_setup(layout)
|
||||
draw_ai_property(layout, settings, "cutout_generate_normal", status)
|
||||
layout.prop(settings, "cutout_mesh_detail")
|
||||
layout.prop(settings, "cutout_fine_outline")
|
||||
layout.prop(settings, "cutout_alpha_threshold")
|
||||
layout.prop(settings, "cutout_inward_axis")
|
||||
|
||||
@@ -17,6 +17,7 @@ from ...common.object import (
|
||||
)
|
||||
from ...common.node import load_node_group
|
||||
from .geometry import (
|
||||
CUTOUT_ALPHA_THRESHOLD,
|
||||
DEFAULT_DEPTH_BASELINE,
|
||||
DEFAULT_DEPTH_DIRECTION,
|
||||
crop_plane_bounds,
|
||||
@@ -161,6 +162,7 @@ def create_shape_object(
|
||||
content_bounds,
|
||||
base_shape,
|
||||
*,
|
||||
alpha_threshold=CUTOUT_ALPHA_THRESHOLD,
|
||||
depth_image=None,
|
||||
depth_metadata=None,
|
||||
normal_image=None,
|
||||
@@ -213,7 +215,7 @@ def create_shape_object(
|
||||
raise ValueError("Depth Shape requires a Depth texture and metadata")
|
||||
camera_depth, valid = camera_depth_values(depth_image)
|
||||
calibration_valid, transparent_fraction = selection_calibration(
|
||||
content_values, valid.shape
|
||||
content_values, valid.shape, threshold=alpha_threshold
|
||||
)
|
||||
calibration_valid &= valid
|
||||
uniform_scale = depth_uniform_scale(
|
||||
|
||||
94
src/anyimage/operators/cutout_tool/polygon.py
Normal file
94
src/anyimage/operators/cutout_tool/polygon.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Polygonal chain simplification using NumPy."""
|
||||
|
||||
import numpy as np
|
||||
|
||||
|
||||
# approximate_polygon: scikit-image 0.25.2, copyright 2009-2022 the scikit-image team.
|
||||
# BSD-3-Clause; see ../../licenses/scikit-image.txt.
|
||||
def approximate_polygon(coords, tolerance):
|
||||
"""Approximate a polygonal chain with the specified tolerance.
|
||||
|
||||
It is based on the Douglas-Peucker algorithm.
|
||||
|
||||
Note that the approximated polygon is always within the convex hull of the
|
||||
original polygon.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
coords : (K, 2) array
|
||||
Coordinate array.
|
||||
tolerance : float
|
||||
Maximum distance from original points of polygon to approximated
|
||||
polygonal chain. If tolerance is 0, the original coordinate array
|
||||
is returned.
|
||||
|
||||
Returns
|
||||
-------
|
||||
coords : (L, 2) array
|
||||
Approximated polygonal chain where L <= K.
|
||||
|
||||
References
|
||||
----------
|
||||
.. [1] https://en.wikipedia.org/wiki/Ramer-Douglas-Peucker_algorithm
|
||||
"""
|
||||
if tolerance <= 0:
|
||||
return coords
|
||||
|
||||
chain = np.zeros(coords.shape[0], "bool")
|
||||
# pre-allocate distance array for all points
|
||||
dists = np.zeros(coords.shape[0])
|
||||
chain[0] = True
|
||||
chain[-1] = True
|
||||
pos_stack = [(0, chain.shape[0] - 1)]
|
||||
end_of_chain = False
|
||||
|
||||
while not end_of_chain:
|
||||
start, end = pos_stack.pop()
|
||||
# determine properties of current line segment
|
||||
r0, c0 = coords[start, :]
|
||||
r1, c1 = coords[end, :]
|
||||
dr = r1 - r0
|
||||
dc = c1 - c0
|
||||
segment_angle = -np.arctan2(dr, dc)
|
||||
segment_dist = c0 * np.sin(segment_angle) + r0 * np.cos(segment_angle)
|
||||
|
||||
# select points in-between line segment
|
||||
segment_coords = coords[start + 1 : end, :]
|
||||
segment_dists = dists[start + 1 : end]
|
||||
|
||||
# check whether to take perpendicular or euclidean distance with
|
||||
# inner product of vectors
|
||||
|
||||
# vectors from points -> start and end
|
||||
dr0 = segment_coords[:, 0] - r0
|
||||
dc0 = segment_coords[:, 1] - c0
|
||||
dr1 = segment_coords[:, 0] - r1
|
||||
dc1 = segment_coords[:, 1] - c1
|
||||
# vectors points -> start and end projected on start -> end vector
|
||||
projected_lengths0 = dr0 * dr + dc0 * dc
|
||||
projected_lengths1 = -dr1 * dr - dc1 * dc
|
||||
perp = np.logical_and(projected_lengths0 > 0, projected_lengths1 > 0)
|
||||
eucl = np.logical_not(perp)
|
||||
segment_dists[perp] = np.abs(
|
||||
segment_coords[perp, 0] * np.cos(segment_angle)
|
||||
+ segment_coords[perp, 1] * np.sin(segment_angle)
|
||||
- segment_dist
|
||||
)
|
||||
segment_dists[eucl] = np.minimum(
|
||||
# distance to start point
|
||||
np.sqrt(dc0[eucl] ** 2 + dr0[eucl] ** 2),
|
||||
# distance to end point
|
||||
np.sqrt(dc1[eucl] ** 2 + dr1[eucl] ** 2),
|
||||
)
|
||||
|
||||
if np.any(segment_dists > tolerance):
|
||||
# select point with maximum distance to line
|
||||
new_end = start + np.argmax(segment_dists) + 1
|
||||
pos_stack.append((new_end, end))
|
||||
pos_stack.append((start, new_end))
|
||||
chain[new_end] = True
|
||||
|
||||
if len(pos_stack) == 0:
|
||||
end_of_chain = True
|
||||
|
||||
return coords[chain, :]
|
||||
@@ -498,8 +498,6 @@ def apply_frame(context, source_objects, frame_quad):
|
||||
output_size,
|
||||
)
|
||||
result_image = create_image_edit_result(active.data, pixels, output_size)
|
||||
active_source_image = active.data
|
||||
active_source_name = active_source_image.name
|
||||
old_matrix = active.matrix_world.copy()
|
||||
old_display_size = active.empty_display_size
|
||||
old_offset = tuple(active.empty_image_offset)
|
||||
@@ -511,13 +509,14 @@ def apply_frame(context, source_objects, frame_quad):
|
||||
active.matrix_world = matrix
|
||||
active.empty_display_size = display_size
|
||||
active.empty_image_offset = (-0.5, -0.5)
|
||||
replace_empty_image(active, result_image)
|
||||
replace_empty_image(
|
||||
active, result_image,
|
||||
removed_objects=tuple(source for source in source_objects if source != active),
|
||||
)
|
||||
except Exception:
|
||||
active.matrix_world = old_matrix
|
||||
active.empty_display_size = old_display_size
|
||||
active.empty_image_offset = old_offset
|
||||
if result_image.users == 0:
|
||||
bpy.data.images.remove(result_image, do_unlink=True)
|
||||
raise
|
||||
for source in source_objects:
|
||||
if source is not active:
|
||||
@@ -526,8 +525,6 @@ def apply_frame(context, source_objects, frame_quad):
|
||||
try:
|
||||
if image.users == 0 and not image.use_fake_user:
|
||||
bpy.data.images.remove(image, do_unlink=True)
|
||||
if image is active_source_image:
|
||||
result_image.name = active_source_name
|
||||
except (ReferenceError, RuntimeError):
|
||||
pass
|
||||
return active
|
||||
|
||||
@@ -145,12 +145,7 @@ class EditImageAlpha(ImageGesture, bpy.types.Operator):
|
||||
pixels,
|
||||
tuple(source_image.size),
|
||||
)
|
||||
try:
|
||||
replace_empty_image(source_object, result_image)
|
||||
except Exception:
|
||||
if result_image.users == 0:
|
||||
bpy.data.images.remove(result_image, do_unlink=True)
|
||||
raise
|
||||
replace_empty_image(source_object, result_image)
|
||||
except (RuntimeError, TypeError, ValueError) as error:
|
||||
report_image_edit_exception(self, error)
|
||||
return {"CANCELLED"}
|
||||
|
||||
Reference in New Issue
Block a user