Replace the old bbox/heuristic placement (scale search rounds, large-font capping, stratified sampling, fill-retry ladders) with an area-model font sizing pass feeding a C++ exact-glyph collision engine (centroid-biased spiral + random probing, HD clearance refinement, density/hole optimization). Simplify the frontend advanced-params panel and JobParams type to match the surviving config surface, add a layout-constraints test suite and a repeatable benchmark tool, and bring docs/*.md back in sync with current code (plus new TESTING.md and DEPLOYMENT.md). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
202 lines
6.7 KiB
Python
202 lines
6.7 KiB
Python
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
|
|
from .fonts import get_cached_font
|
|
|
|
|
|
def scale_layout_for_hd(layout, work_scale):
|
|
if work_scale <= 0:
|
|
raise ValueError("WORK_SCALE must be greater than zero")
|
|
return [
|
|
(
|
|
text,
|
|
max(1, int(size / work_scale)),
|
|
(int(y / work_scale), int(x / work_scale)),
|
|
orient,
|
|
color,
|
|
)
|
|
for text, size, (y, x), orient, color in layout
|
|
]
|
|
|
|
|
|
def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0):
|
|
"""Count final rendered pixels occupied by more than one layout entry."""
|
|
height, width = mask_shape
|
|
occupied = np.zeros((height, width), dtype=bool)
|
|
overlaps = np.zeros((height, width), dtype=bool)
|
|
measure = ImageDraw.Draw(Image.new("L", (1, 1)))
|
|
|
|
for word, size, (y, x), orient, _color in layout:
|
|
font = get_cached_font(font_path, size)
|
|
if orient:
|
|
font = ImageFont.TransposedFont(font, orientation=orient)
|
|
bbox = measure.textbbox((0, 0), word, font=font)
|
|
glyph = font.getmask(word, mode="L")
|
|
glyph_width, glyph_height = glyph.size
|
|
if glyph_width <= 0 or glyph_height <= 0:
|
|
continue
|
|
ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) > alpha_threshold
|
|
|
|
ink_x = int(x) + int(bbox[0])
|
|
ink_y = int(y) + int(bbox[1])
|
|
x0 = max(0, ink_x)
|
|
y0 = max(0, ink_y)
|
|
x1 = min(width, ink_x + glyph_width)
|
|
y1 = min(height, ink_y + glyph_height)
|
|
if x0 >= x1 or y0 >= y1:
|
|
continue
|
|
|
|
visible_ink = ink[y0 - ink_y:y1 - ink_y, x0 - ink_x:x1 - ink_x]
|
|
occupied_region = occupied[y0:y1, x0:x1]
|
|
overlaps[y0:y1, x0:x1] |= occupied_region & visible_ink
|
|
occupied_region |= visible_ink
|
|
|
|
return int(overlaps.sum())
|
|
|
|
|
|
def refine_layout_with_hd_clearance(
|
|
layout,
|
|
mask,
|
|
font_path,
|
|
clearance=1,
|
|
max_shift=24,
|
|
):
|
|
"""Validate the whole HD batch and minimally move rasterization collisions."""
|
|
height, width = mask.shape
|
|
blocked = np.asarray(mask) != 0
|
|
occupied = np.zeros((height, width), dtype=bool)
|
|
measure = ImageDraw.Draw(Image.new("L", (1, 1)))
|
|
refined = []
|
|
shifted_words = 0
|
|
max_applied_shift = 0
|
|
|
|
offset_rings = [[(0, 0)]]
|
|
for radius in range(1, max_shift + 1):
|
|
ring = [
|
|
(dy, dx)
|
|
for dy in range(-radius, radius + 1)
|
|
for dx in range(-radius, radius + 1)
|
|
if max(abs(dy), abs(dx)) == radius
|
|
]
|
|
ring.sort(key=lambda item: (item[0] * item[0] + item[1] * item[1], item[0], item[1]))
|
|
offset_rings.append(ring)
|
|
|
|
for word, size, (draw_y, draw_x), orient, color in layout:
|
|
font = get_cached_font(font_path, size)
|
|
if orient:
|
|
font = ImageFont.TransposedFont(font, orientation=orient)
|
|
bbox = measure.textbbox((0, 0), word, font=font)
|
|
glyph = font.getmask(word, mode="L")
|
|
glyph_width, glyph_height = glyph.size
|
|
if glyph_width <= 0 or glyph_height <= 0:
|
|
refined.append((word, size, (draw_y, draw_x), orient, color))
|
|
continue
|
|
|
|
glyph_ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width)
|
|
pad = max(0, int(clearance))
|
|
padded = np.zeros((glyph_height + 2 * pad, glyph_width + 2 * pad), dtype=np.uint8)
|
|
padded[pad:pad + glyph_height, pad:pad + glyph_width] = glyph_ink
|
|
if pad:
|
|
collision = np.asarray(
|
|
Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)),
|
|
dtype=np.uint8,
|
|
) > 0
|
|
else:
|
|
collision = padded > 0
|
|
stamp = padded > 0
|
|
|
|
base_y = int(draw_y) + int(bbox[1]) - pad
|
|
base_x = int(draw_x) + int(bbox[0]) - pad
|
|
|
|
def fits(y0, x0):
|
|
y1 = y0 + collision.shape[0]
|
|
x1 = x0 + collision.shape[1]
|
|
if y0 < 0 or x0 < 0 or y1 > height or x1 > width:
|
|
return False
|
|
if np.any(blocked[y0:y1, x0:x1] & stamp):
|
|
return False
|
|
return not np.any(occupied[y0:y1, x0:x1] & collision)
|
|
|
|
placed_offset = None
|
|
for ring in offset_rings:
|
|
for dy, dx in ring:
|
|
y0 = base_y + dy
|
|
x0 = base_x + dx
|
|
if not fits(y0, x0):
|
|
continue
|
|
placed_offset = (dy, dx)
|
|
break
|
|
if placed_offset is not None:
|
|
break
|
|
|
|
if placed_offset is None:
|
|
return None, {
|
|
"shifted_words": shifted_words,
|
|
"max_shift": max_applied_shift,
|
|
"failed_word": word,
|
|
}
|
|
|
|
dy, dx = placed_offset
|
|
y0 = base_y + dy
|
|
x0 = base_x + dx
|
|
occupied[y0:y0 + stamp.shape[0], x0:x0 + stamp.shape[1]] |= stamp
|
|
if dy or dx:
|
|
shifted_words += 1
|
|
max_applied_shift = max(max_applied_shift, abs(dy), abs(dx))
|
|
refined.append((word, size, (int(draw_y) + dy, int(draw_x) + dx), orient, color))
|
|
|
|
return refined, {
|
|
"shifted_words": shifted_words,
|
|
"max_shift": max_applied_shift,
|
|
"failed_word": None,
|
|
}
|
|
|
|
|
|
def render_layout_occupancy(layout, mask_shape, font_path):
|
|
h, w = mask_shape
|
|
canvas = Image.new("L", (w, h), 0)
|
|
draw = ImageDraw.Draw(canvas)
|
|
for word, size, (y, x), orient, _color in layout:
|
|
font = get_cached_font(font_path, size)
|
|
if orient:
|
|
font = ImageFont.TransposedFont(font, orientation=orient)
|
|
draw.text((x, y), word, font=font, fill=255)
|
|
return (np.array(canvas) > 0).astype(np.uint8)
|
|
|
|
|
|
def compute_fill_ratio_fast(layout, mask, font_path):
|
|
if not layout:
|
|
return 0.0, None
|
|
occ = render_layout_occupancy(layout, mask.shape, font_path)
|
|
free_area = np.sum(mask == 0)
|
|
if free_area == 0:
|
|
return 0.0, occ
|
|
filled_area = np.sum((mask == 0) & (occ == 1))
|
|
return filled_area / free_area, occ
|
|
|
|
|
|
def largest_empty_square_size(occupancy, mask):
|
|
"""Return the largest fully empty square inside the fillable mask."""
|
|
blocked = (np.asarray(mask) != 0) | (np.asarray(occupancy) != 0)
|
|
if blocked.ndim != 2 or blocked.size == 0:
|
|
return 0
|
|
|
|
integral = np.pad(blocked.astype(np.uint32), ((1, 0), (1, 0)))
|
|
integral = integral.cumsum(axis=0).cumsum(axis=1)
|
|
|
|
low = 0
|
|
high = min(blocked.shape)
|
|
while low < high:
|
|
size = (low + high + 1) // 2
|
|
window_sums = (
|
|
integral[size:, size:]
|
|
- integral[:-size, size:]
|
|
- integral[size:, :-size]
|
|
+ integral[:-size, :-size]
|
|
)
|
|
if np.any(window_sums == 0):
|
|
low = size
|
|
else:
|
|
high = size - 1
|
|
return int(low)
|