465 lines
17 KiB
Python
465 lines
17 KiB
Python
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
|
|
|
from .fonts import get_cached_font
|
|
|
|
# (font_path, word, size, orient) -> (ink[h,w] uint8, bbox_left, bbox_top).
|
|
# The HD passes -- clearance refinement and the independent overlap audit --
|
|
# rasterize the same words at the same sizes, and rasterizing is the single
|
|
# most expensive thing either of them does, so they share one cache.
|
|
_HD_INK_CACHE = {}
|
|
|
|
|
|
def _word_ink(word, size, orient, font_path):
|
|
"""Return (ink array, bbox_left, bbox_top) for a word, or None if empty."""
|
|
key = (font_path, word, int(size), orient)
|
|
cached = _HD_INK_CACHE.get(key)
|
|
if cached is not None or key in _HD_INK_CACHE:
|
|
return cached
|
|
font = get_cached_font(font_path, size)
|
|
if orient:
|
|
font = ImageFont.TransposedFont(font, orientation=orient)
|
|
bbox = ImageDraw.Draw(Image.new("L", (1, 1))).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:
|
|
result = None
|
|
else:
|
|
ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width)
|
|
result = (ink, int(bbox[0]), int(bbox[1]))
|
|
_HD_INK_CACHE[key] = result
|
|
if len(_HD_INK_CACHE) > 20000:
|
|
for i, k in enumerate(list(_HD_INK_CACHE.keys())):
|
|
if i % 2 == 0:
|
|
_HD_INK_CACHE.pop(k, None)
|
|
return result
|
|
|
|
|
|
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)
|
|
|
|
for word, size, (y, x), orient, _color in layout:
|
|
measured = _word_ink(word, size, orient, font_path)
|
|
if measured is None:
|
|
continue
|
|
ink_arr, bbox_left, bbox_top = measured
|
|
glyph_height, glyph_width = ink_arr.shape
|
|
ink = ink_arr > alpha_threshold
|
|
|
|
ink_x = int(x) + bbox_left
|
|
ink_y = int(y) + bbox_top
|
|
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 _find_free_placement(blocked, occupied, collision, stamp, base_y, base_x):
|
|
"""Find any canvas position where `collision` hits nothing already taken.
|
|
|
|
Returns the (dy, dx) offset from (base_y, base_x), or None. Candidates are
|
|
ranked by distance from the original spot so a relocated word stays as close
|
|
to its intended position as possible.
|
|
|
|
A position whose whole footprint is empty is guaranteed to fit, so the
|
|
search first looks for those using an integral image, which rejects the vast
|
|
majority of positions with two additions instead of a per-pixel test.
|
|
"""
|
|
height, width = blocked.shape
|
|
gh, gw = collision.shape
|
|
if height - gh < 0 or width - gw < 0:
|
|
return None
|
|
|
|
# Search expanding windows around the intended spot instead of the whole
|
|
# canvas: a relocated word almost always finds room nearby, and the integral
|
|
# image costs time proportional to the area examined. The last radius covers
|
|
# the full canvas, so nothing is missed if the neighbourhood really is full.
|
|
for radius in (256, 1024, max(height, width)):
|
|
y_lo = max(0, base_y - radius)
|
|
x_lo = max(0, base_x - radius)
|
|
y_hi = min(height, base_y + radius + gh)
|
|
x_hi = min(width, base_x + radius + gw)
|
|
if y_hi - y_lo < gh or x_hi - x_lo < gw:
|
|
continue
|
|
|
|
taken = blocked[y_lo:y_hi, x_lo:x_hi] | occupied[y_lo:y_hi, x_lo:x_hi]
|
|
integral = np.pad(taken.astype(np.int32), ((1, 0), (1, 0))).cumsum(0).cumsum(1)
|
|
# Footprint sum for every candidate top-left corner in the window. A
|
|
# position whose whole footprint is empty is guaranteed to fit, so no
|
|
# per-pixel mask test is needed.
|
|
counts = (
|
|
integral[gh:, gw:]
|
|
- integral[:-gh, gw:]
|
|
- integral[gh:, :-gw]
|
|
+ integral[:-gh, :-gw]
|
|
)
|
|
ys, xs = np.nonzero(counts == 0)
|
|
if ys.size == 0:
|
|
continue
|
|
dy = ys.astype(np.int64) + y_lo - base_y
|
|
dx = xs.astype(np.int64) + x_lo - base_x
|
|
best = int(np.argmin(dy * dy + dx * dx))
|
|
return int(dy[best]), int(dx[best])
|
|
|
|
return None
|
|
|
|
|
|
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)
|
|
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:
|
|
measured = _word_ink(word, size, orient, font_path)
|
|
if measured is None:
|
|
refined.append((word, size, (draw_y, draw_x), orient, color))
|
|
continue
|
|
|
|
glyph_ink, bbox_left, bbox_top = measured
|
|
glyph_height, glyph_width = glyph_ink.shape
|
|
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) + bbox_top - pad
|
|
base_x = int(draw_x) + bbox_left - 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:
|
|
# Nothing within max_shift. Rather than fail the batch -- which
|
|
# makes the caller rebuild the entire cloud on a larger canvas, by
|
|
# far the most expensive thing that can happen -- look for any free
|
|
# spot on the whole canvas. This only runs for the occasional word
|
|
# whose work-grid position does not survive the scale-up to HD.
|
|
placed_offset = _find_free_placement(
|
|
blocked, occupied, collision, stamp, base_y, base_x
|
|
)
|
|
|
|
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 append_layout_with_hd_clearance(
|
|
base_layout,
|
|
additions,
|
|
mask,
|
|
font_path,
|
|
clearance=1,
|
|
max_shift=24,
|
|
allow_global_search=False,
|
|
):
|
|
"""Place only *additions* against an already validated HD layout.
|
|
|
|
The normal refinement pass must rebuild occupancy for every word because it
|
|
is allowed to move the whole batch. Auto-repeat words are appended after the
|
|
base batch has already passed refinement, so rescanning that batch is wasted
|
|
work. This helper seeds occupancy from the base once, then processes only
|
|
the new words and returns the largest collision-free prefix.
|
|
"""
|
|
height, width = mask.shape
|
|
blocked = np.asarray(mask) != 0
|
|
occupied = np.zeros((height, width), dtype=bool)
|
|
|
|
def stamp_existing(item):
|
|
word, size, (draw_y, draw_x), orient, _color = item
|
|
measured = _word_ink(word, size, orient, font_path)
|
|
if measured is None:
|
|
return
|
|
ink, bbox_left, bbox_top = measured
|
|
ink_y = int(draw_y) + bbox_top
|
|
ink_x = int(draw_x) + bbox_left
|
|
y0 = max(0, ink_y)
|
|
x0 = max(0, ink_x)
|
|
y1 = min(height, ink_y + ink.shape[0])
|
|
x1 = min(width, ink_x + ink.shape[1])
|
|
if y0 < y1 and x0 < x1:
|
|
occupied[y0:y1, x0:x1] |= ink[y0 - ink_y:y1 - ink_y, x0 - ink_x:x1 - ink_x] > 0
|
|
|
|
for item in base_layout:
|
|
stamp_existing(item)
|
|
|
|
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)
|
|
|
|
accepted = []
|
|
shifted_words = 0
|
|
max_applied_shift = 0
|
|
failed_word = None
|
|
for word, size, (draw_y, draw_x), orient, color in additions:
|
|
measured = _word_ink(word, size, orient, font_path)
|
|
if measured is None:
|
|
accepted.append((word, size, (draw_y, draw_x), orient, color))
|
|
continue
|
|
|
|
glyph_ink, bbox_left, bbox_top = measured
|
|
glyph_height, glyph_width = glyph_ink.shape
|
|
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) + bbox_top - pad
|
|
base_x = int(draw_x) + bbox_left - 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:
|
|
if fits(base_y + dy, base_x + dx):
|
|
placed_offset = (dy, dx)
|
|
break
|
|
if placed_offset is not None:
|
|
break
|
|
if placed_offset is None and allow_global_search:
|
|
placed_offset = _find_free_placement(
|
|
blocked, occupied, collision, stamp, base_y, base_x
|
|
)
|
|
if placed_offset is None:
|
|
# A local-only append is deliberately best-effort: skipping one
|
|
# extra word is much cheaper than scanning the full HD canvas and
|
|
# keeps the latency predictable for large auto-repeat batches.
|
|
failed_word = word
|
|
continue
|
|
|
|
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))
|
|
accepted.append((word, size, (int(draw_y) + dy, int(draw_x) + dx), orient, color))
|
|
|
|
return accepted, {
|
|
"shifted_words": shifted_words,
|
|
"max_shift": max_applied_shift,
|
|
"failed_word": failed_word,
|
|
}
|
|
|
|
|
|
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 compute_coverage_score(occupancy, mask, block_size=8):
|
|
"""Shape-aware fill quality: how broadly the ink reaches across the mask.
|
|
|
|
Unlike :func:`compute_fill_ratio_fast` (filled pixels / free pixels), this
|
|
rewards reaching *every part* of the mask silhouette -- protrusions, limb
|
|
tips, heart-shaped cusps -- that a centre-out Fermat spiral abandons first
|
|
when words are scarce or font sizes are large.
|
|
|
|
The fillable region is tiled into ``block_size`` cells. A cell counts as a
|
|
"region block" when at least 30% of its pixels are free; it is "covered"
|
|
when at least one of those free pixels is inked. The score is the share of
|
|
region blocks that are covered:
|
|
|
|
coverage = covered_blocks / region_blocks
|
|
|
|
A compact disc packed around the centroid touches only the central blocks,
|
|
so it scores low even at a high pixel fill ratio; a layout that spreads
|
|
into every arm of the mask touches blocks in each arm and scores high.
|
|
Block granularity (not per-pixel weighting) is what makes this robust to
|
|
the mask's geometry: a thin tip is one block whether it is 3px or 30px
|
|
wide, so reaching it is rewarded consistently. ``occupancy`` may be None
|
|
(treated as empty).
|
|
"""
|
|
mask_arr = np.asarray(mask)
|
|
if mask_arr.ndim != 2 or mask_arr.size == 0:
|
|
return 0.0
|
|
free = mask_arr == 0
|
|
if not free.any():
|
|
return 0.0
|
|
|
|
h, w = mask_arr.shape
|
|
occ = np.asarray(occupancy) if occupancy is not None else None
|
|
if occ is None or occ.shape != mask_arr.shape:
|
|
return 0.0
|
|
inked = (occ == 1) & free
|
|
|
|
# Block-aligned tile counts. Trailing partial blocks are merged into the
|
|
# last full block by clamping the end index, so no free pixels are dropped.
|
|
region_blocks = 0
|
|
covered_blocks = 0
|
|
for by in range(0, h, block_size):
|
|
y1 = min(h, by + block_size)
|
|
for bx in range(0, w, block_size):
|
|
x1 = min(w, bx + block_size)
|
|
block_free = free[by:y1, bx:x1]
|
|
free_count = int(block_free.sum())
|
|
if free_count == 0:
|
|
continue
|
|
# A block is a region if a meaningful share of it is fillable;
|
|
# this ignores blocks that only clip a mask corner.
|
|
if free_count < 0.30 * block_free.size:
|
|
continue
|
|
region_blocks += 1
|
|
if np.any(inked[by:y1, bx:x1] & block_free):
|
|
covered_blocks += 1
|
|
|
|
if region_blocks == 0:
|
|
return 0.0
|
|
return covered_blocks / region_blocks
|
|
|
|
|
|
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)
|