Rework layout engine around exact-glyph collision, add tests and docs sync
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>
This commit is contained in:
+172
-18
@@ -1,10 +1,157 @@
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||
|
||||
from . import config
|
||||
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)
|
||||
@@ -28,20 +175,27 @@ def compute_fill_ratio_fast(layout, mask, font_path):
|
||||
return filled_area / free_area, occ
|
||||
|
||||
|
||||
def apply_dot_matrix(base_img, mask_hd):
|
||||
text_mask = base_img.convert("L").point(lambda x: 0 if x < 200 else 255)
|
||||
filter_size = max(3, (config.DOT_SAFETY_BUFFER // 2) * 2 + 1)
|
||||
safe_zone_mask = text_mask.filter(ImageFilter.MinFilter(size=filter_size))
|
||||
safe_zone_array = np.array(safe_zone_mask)
|
||||
unfilled_zone = (mask_hd == 0) & (safe_zone_array > 200)
|
||||
draw = ImageDraw.Draw(base_img)
|
||||
h, w = mask_hd.shape
|
||||
dot_color = "white" if config.FILL_ON == "WHITE" else "black"
|
||||
for y in range(0, h, config.DOT_SPACING):
|
||||
for x in range(0, w, config.DOT_SPACING):
|
||||
if unfilled_zone[y, x]:
|
||||
if config.DOT_RADIUS > 0:
|
||||
draw.ellipse([x - config.DOT_RADIUS, y - config.DOT_RADIUS, x + config.DOT_RADIUS, y + config.DOT_RADIUS], fill=dot_color)
|
||||
else:
|
||||
draw.point((x, y), fill=dot_color)
|
||||
return base_img
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user