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

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

View File

@@ -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),