- Added a new cutout tool for selecting regions in images, allowing users to choose mesh shapes based on selected areas. - Introduced depth generation capabilities for rasterized images, including normal data extraction. - Created jobs for generating depth planes and region data, enhancing the AI data generation workflow. - Developed a selection refinement process for cutout regions, improving accuracy in generated outputs. - Established a new region module for handling rasterized image regions and their associated AI generation tasks. - Enhanced node graph builders for asset generation, facilitating smoother integration of new features.
403 lines
13 KiB
Python
403 lines
13 KiB
Python
import math
|
|
|
|
DEFAULT_INFLATION = 0.45
|
|
|
|
def distances_to_contour(points, contour):
|
|
import numpy as np
|
|
|
|
points = np.asarray(points, dtype=np.float64)
|
|
contour = np.asarray(contour, dtype=np.float64)
|
|
squared = np.full(len(points), np.inf, dtype=np.float64)
|
|
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
|
|
factor = np.clip(
|
|
(points - start) @ segment / length_squared,
|
|
0.0,
|
|
1.0,
|
|
)
|
|
nearest = start + factor[:, None] * segment
|
|
squared = np.minimum(
|
|
squared,
|
|
np.sum((points - nearest) ** 2, axis=1),
|
|
)
|
|
return np.sqrt(squared)
|
|
|
|
|
|
def chordal_axis(contour, epsilon=1e-9):
|
|
import numpy as np
|
|
from mathutils import Vector
|
|
from mathutils.geometry import delaunay_2d_cdt
|
|
|
|
contour = np.asarray(contour, dtype=np.float64)
|
|
boundary_count = len(contour)
|
|
boundary_edges = [
|
|
(index, (index + 1) % boundary_count)
|
|
for index in range(boundary_count)
|
|
]
|
|
result = delaunay_2d_cdt(
|
|
[Vector(point) for point in contour],
|
|
boundary_edges,
|
|
[list(range(boundary_count))],
|
|
1,
|
|
epsilon,
|
|
)
|
|
points = np.asarray(
|
|
[tuple(point) for point in result[0]],
|
|
dtype=np.float64,
|
|
)
|
|
triangles = [tuple(face) for face in result[2] if len(face) == 3]
|
|
boundary = {
|
|
tuple(sorted(edge))
|
|
for edge in boundary_edges
|
|
}
|
|
node_indices = {}
|
|
nodes = []
|
|
segments = set()
|
|
|
|
def edge_node(edge):
|
|
key = ("edge", *tuple(sorted(edge)))
|
|
if key not in node_indices:
|
|
node_indices[key] = len(nodes)
|
|
nodes.append((points[edge[0]] + points[edge[1]]) * 0.5)
|
|
return node_indices[key]
|
|
|
|
for face_index, face in enumerate(triangles):
|
|
edges = [
|
|
(face[0], face[1]),
|
|
(face[1], face[2]),
|
|
(face[2], face[0]),
|
|
]
|
|
internal = [
|
|
edge
|
|
for edge in edges
|
|
if tuple(sorted(edge)) not in boundary
|
|
]
|
|
internal_nodes = [edge_node(edge) for edge in internal]
|
|
if len(internal_nodes) == 2:
|
|
segments.add(tuple(sorted(internal_nodes)))
|
|
elif len(internal_nodes) == 3:
|
|
key = ("face", face_index)
|
|
center_index = len(nodes)
|
|
node_indices[key] = center_index
|
|
nodes.append(points[list(face)].mean(axis=0))
|
|
for midpoint_index in internal_nodes:
|
|
segments.add(tuple(sorted((center_index, midpoint_index))))
|
|
|
|
if not nodes:
|
|
center = contour.mean(axis=0)
|
|
return np.asarray((center,), dtype=np.float64), [], np.asarray(
|
|
(distances_to_contour((center,), contour)[0],),
|
|
dtype=np.float64,
|
|
)
|
|
|
|
nodes = np.asarray(nodes, dtype=np.float64)
|
|
radii = distances_to_contour(nodes, contour)
|
|
active = set(segments)
|
|
while len(active) > 1:
|
|
degree = [0] * len(nodes)
|
|
for start, end in active:
|
|
degree[start] += 1
|
|
degree[end] += 1
|
|
removable = []
|
|
for edge in active:
|
|
start, end = edge
|
|
leaf = start if degree[start] == 1 else end if degree[end] == 1 else None
|
|
if leaf is None:
|
|
continue
|
|
length = float(np.linalg.norm(nodes[start] - nodes[end]))
|
|
if length < max(radii[leaf] * 0.5, epsilon):
|
|
removable.append(edge)
|
|
if not removable:
|
|
break
|
|
active.difference_update(removable)
|
|
|
|
if active:
|
|
used = sorted({index for edge in active for index in edge})
|
|
remap = {old: new for new, old in enumerate(used)}
|
|
nodes = nodes[used]
|
|
radii = radii[used]
|
|
active = {
|
|
tuple(sorted((remap[start], remap[end])))
|
|
for start, end in active
|
|
}
|
|
else:
|
|
widest = int(np.argmax(radii))
|
|
nodes = nodes[[widest]]
|
|
radii = radii[[widest]]
|
|
|
|
return nodes, sorted(active), radii
|
|
|
|
|
|
def smooth_interior_heights(
|
|
heights,
|
|
planar_faces,
|
|
boundary_mask,
|
|
iterations=2,
|
|
factor=0.3,
|
|
):
|
|
import numpy as np
|
|
|
|
values = np.asarray(heights, dtype=np.float64).copy()
|
|
boundary_mask = np.asarray(boundary_mask, dtype=bool)
|
|
neighbors = [set() for _index in range(len(values))]
|
|
for face in planar_faces:
|
|
for start, end in zip(face, face[1:] + face[:1]):
|
|
neighbors[start].add(end)
|
|
neighbors[end].add(start)
|
|
for _iteration in range(iterations):
|
|
updated = values.copy()
|
|
for index in np.flatnonzero(~boundary_mask):
|
|
adjacent = [
|
|
neighbor
|
|
for neighbor in neighbors[index]
|
|
if not boundary_mask[neighbor]
|
|
]
|
|
if adjacent:
|
|
average = float(values[adjacent].mean())
|
|
updated[index] = values[index] * (1.0 - factor) + average * factor
|
|
updated[boundary_mask] = 0.0
|
|
values = updated
|
|
return values
|
|
|
|
|
|
def teddy_balloon_heights(
|
|
planar_points,
|
|
planar_faces,
|
|
contour,
|
|
boundary_mask,
|
|
inflation,
|
|
):
|
|
import numpy as np
|
|
|
|
points = np.asarray(planar_points, dtype=np.float64)
|
|
boundary_mask = np.asarray(boundary_mask, dtype=bool)
|
|
nodes, segments, radii = chordal_axis(contour)
|
|
squared_heights = np.zeros(len(points), dtype=np.float64)
|
|
|
|
for start, end in segments:
|
|
axis_start = nodes[start]
|
|
axis_end = nodes[end]
|
|
axis = axis_end - axis_start
|
|
length_squared = float(axis.dot(axis))
|
|
if length_squared <= 1e-16:
|
|
continue
|
|
factors = np.clip(
|
|
(points - axis_start) @ axis / length_squared,
|
|
0.0,
|
|
1.0,
|
|
)
|
|
centers = axis_start + factors[:, None] * axis
|
|
local_radii = radii[start] + factors * (radii[end] - radii[start])
|
|
distance_squared = np.sum((points - centers) ** 2, axis=1)
|
|
squared_heights = np.maximum(
|
|
squared_heights,
|
|
local_radii * local_radii - distance_squared,
|
|
)
|
|
|
|
degree = np.zeros(len(nodes), dtype=np.int64)
|
|
for start, end in segments:
|
|
degree[start] += 1
|
|
degree[end] += 1
|
|
for index in np.flatnonzero(degree != 2):
|
|
distance_squared = np.sum((points - nodes[index]) ** 2, axis=1)
|
|
squared_heights = np.maximum(
|
|
squared_heights,
|
|
radii[index] * radii[index] - distance_squared,
|
|
)
|
|
|
|
missing = (~boundary_mask) & (squared_heights <= 0.0)
|
|
if np.any(missing):
|
|
fallback = distances_to_contour(points[missing], contour)
|
|
squared_heights[missing] = fallback * fallback
|
|
heights = inflation * np.sqrt(np.maximum(squared_heights, 0.0))
|
|
heights[boundary_mask] = 0.0
|
|
return smooth_interior_heights(
|
|
heights,
|
|
planar_faces,
|
|
boundary_mask,
|
|
)
|
|
DEFAULT_ALGORITHM = "POISSON"
|
|
|
|
|
|
def cotangent_poisson_field(
|
|
planar_points,
|
|
planar_faces,
|
|
boundary_mask,
|
|
tolerance=1e-8,
|
|
):
|
|
import numpy as np
|
|
|
|
points = np.asarray(planar_points, dtype=np.float64)
|
|
faces = np.asarray(planar_faces, dtype=np.int64)
|
|
if faces.size == 0:
|
|
faces = np.empty((0, 3), dtype=np.int64)
|
|
if faces.ndim != 2 or faces.shape[1] != 3:
|
|
raise ValueError("Balloon profile faces must be triangles")
|
|
boundary_mask = np.asarray(boundary_mask, dtype=bool)
|
|
triangles = points[faces]
|
|
a, b, c = (triangles[:, index] for index in range(3))
|
|
cross = np.abs(
|
|
(b[:, 0] - a[:, 0]) * (c[:, 1] - a[:, 1])
|
|
- (b[:, 1] - a[:, 1]) * (c[:, 0] - a[:, 0])
|
|
)
|
|
usable = cross > 1e-14
|
|
faces = faces[usable]
|
|
a, b, c = a[usable], b[usable], c[usable]
|
|
cross = cross[usable]
|
|
loads = np.bincount(
|
|
faces.ravel(),
|
|
weights=np.repeat(cross / 6.0, 3),
|
|
minlength=len(points),
|
|
)
|
|
edges = np.concatenate(
|
|
(
|
|
faces[:, (1, 2)],
|
|
faces[:, (0, 2)],
|
|
faces[:, (0, 1)],
|
|
),
|
|
axis=0,
|
|
)
|
|
edge_weights = np.concatenate(
|
|
(
|
|
np.sum((b - a) * (c - a), axis=1),
|
|
np.sum((a - b) * (c - b), axis=1),
|
|
np.sum((a - c) * (b - c), axis=1),
|
|
)
|
|
) / np.tile(cross * 2.0, 3)
|
|
edges.sort(axis=1)
|
|
edges, inverse = np.unique(edges, axis=0, return_inverse=True)
|
|
edge_weights = np.bincount(
|
|
inverse,
|
|
weights=edge_weights,
|
|
minlength=len(edges),
|
|
)
|
|
|
|
interior = np.flatnonzero(~boundary_mask)
|
|
field = np.zeros(len(points), dtype=np.float64)
|
|
if not len(interior):
|
|
return field
|
|
compact = np.full(len(points), -1, dtype=np.int64)
|
|
compact[interior] = np.arange(len(interior), dtype=np.int64)
|
|
# Constrained triangulation can create negative cotangent weights.
|
|
# Clamping them keeps the Dirichlet system positive definite.
|
|
edge_weights = np.maximum(edge_weights, 0.0)
|
|
usable = edge_weights > 1e-14
|
|
compact_edges = compact[edges[usable]]
|
|
edge_weights = edge_weights[usable]
|
|
start_compact = compact_edges[:, 0]
|
|
end_compact = compact_edges[:, 1]
|
|
start_usable = start_compact >= 0
|
|
end_usable = end_compact >= 0
|
|
diagonal = np.zeros(len(interior), dtype=np.float64)
|
|
diagonal += np.bincount(
|
|
start_compact[start_usable],
|
|
weights=edge_weights[start_usable],
|
|
minlength=len(interior),
|
|
)
|
|
diagonal += np.bincount(
|
|
end_compact[end_usable],
|
|
weights=edge_weights[end_usable],
|
|
minlength=len(interior),
|
|
)
|
|
if np.any(diagonal <= 1e-14):
|
|
raise ValueError("Unable to build a stable Balloon profile")
|
|
|
|
interior_edges = start_usable & end_usable
|
|
edge_starts = start_compact[interior_edges]
|
|
edge_ends = end_compact[interior_edges]
|
|
weights = edge_weights[interior_edges]
|
|
directed_starts = np.concatenate((edge_starts, edge_ends))
|
|
directed_ends = np.concatenate((edge_ends, edge_starts))
|
|
directed_weights = np.concatenate((weights, weights))
|
|
|
|
def multiply(values):
|
|
result = diagonal * values
|
|
if len(directed_weights):
|
|
result -= np.bincount(
|
|
directed_starts,
|
|
weights=directed_weights * values[directed_ends],
|
|
minlength=len(values),
|
|
)
|
|
return result
|
|
|
|
right_hand_side = loads[interior]
|
|
solution = np.zeros(len(interior), dtype=np.float64)
|
|
residual = right_hand_side.copy()
|
|
preconditioned = residual / diagonal
|
|
direction = preconditioned.copy()
|
|
residual_product = float(residual.dot(preconditioned))
|
|
target = max(float(np.linalg.norm(right_hand_side)) * tolerance, 1e-12)
|
|
max_iterations = max(64, min(len(interior) * 4, 4000))
|
|
for _iteration in range(max_iterations):
|
|
if float(np.linalg.norm(residual)) <= target:
|
|
break
|
|
multiplied = multiply(direction)
|
|
denominator = float(direction.dot(multiplied))
|
|
if denominator <= 0.0 or not math.isfinite(denominator):
|
|
raise ValueError("Unable to solve the Balloon profile")
|
|
step = residual_product / denominator
|
|
solution += step * direction
|
|
residual -= step * multiplied
|
|
preconditioned = residual / diagonal
|
|
next_product = float(residual.dot(preconditioned))
|
|
if not math.isfinite(next_product):
|
|
raise ValueError("Unable to solve the Balloon profile")
|
|
if abs(residual_product) <= 1e-30:
|
|
break
|
|
direction = preconditioned + next_product / residual_product * direction
|
|
residual_product = next_product
|
|
|
|
field[interior] = np.maximum(solution, 0.0)
|
|
return field
|
|
|
|
|
|
def poisson_balloon_profile(
|
|
planar_points,
|
|
planar_faces,
|
|
boundary_mask,
|
|
inflation=DEFAULT_INFLATION,
|
|
):
|
|
import numpy as np
|
|
|
|
field = cotangent_poisson_field(
|
|
planar_points,
|
|
planar_faces,
|
|
boundary_mask,
|
|
)
|
|
return 2.0 * float(inflation) * np.sqrt(field)
|
|
|
|
|
|
def balloon_profile(
|
|
algorithm,
|
|
planar_points,
|
|
planar_faces,
|
|
boundary_mask,
|
|
components,
|
|
inflation=DEFAULT_INFLATION,
|
|
):
|
|
import numpy as np
|
|
|
|
algorithm = str(algorithm).upper()
|
|
if algorithm == "POISSON":
|
|
return poisson_balloon_profile(
|
|
planar_points,
|
|
planar_faces,
|
|
boundary_mask,
|
|
inflation,
|
|
)
|
|
if algorithm != "TEDDY":
|
|
raise ValueError(f"Unknown Balloon profile algorithm: {algorithm}")
|
|
result = np.zeros(len(planar_points), dtype=np.float64)
|
|
for start, end, component_faces, outer_contour in components:
|
|
result[start:end] = teddy_balloon_heights(
|
|
np.asarray(planar_points)[start:end],
|
|
component_faces,
|
|
outer_contour,
|
|
np.asarray(boundary_mask)[start:end],
|
|
inflation,
|
|
)
|
|
return result
|