feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表

This commit is contained in:
2026-08-13 14:22:48 +08:00
parent 1d17b5e20d
commit e518540235
32 changed files with 3525 additions and 592 deletions
+20 -2
View File
@@ -46,6 +46,8 @@ BASE_HD_HEIGHT = 4000
MIN_READABLE_HEIGHT_PX = 22
# 运算网格缩放:0.18 在速度/质量之间更均衡
WORK_SCALE = 0.18
# 服务端快速预览路径:减少试探次数,但保留真实字形碰撞和零重叠校验。
FAST_MODE = False
# --- 阴阳刻 ---
FILL_ON = "BLACK"
@@ -67,11 +69,19 @@ FONT_FALLBACK_PATHS = (
# --- 填充策略 ---
N_REPETITIONS = 1
# 名单较少、掩膜轮廓填不满时,自动循环追加名字副本增加词数,让费马螺旋
# 能走到掩膜远端(心形尖端、人物四肢),把形状填出来而非退化成圆形。
# AUTO_REPEAT_MAX 是自动重复次数的安全上限,避免无限追加。
AUTO_REPEAT_TO_FILL = True
AUTO_REPEAT_MAX = 20
# 面积模型目标填充率:中文实心笔画像素占比约 0.35–0.55。
# 略偏保守以保证 scale=1.0 首次就能放满,减少多轮重试。
TARGET_FILL_RATIO = 0.45
SIZE_RATIO = 2.0
PACKING_EFFICIENCY = 0.9
# 竖排概率:每个词独立以此概率竖着摆放,其余水平摆放。
# 0.0 = 全部水平,1.0 = 全部竖排。适度混排可打散过于规整的观感。
VERTICAL_RATIO = 0.18
# --- 智能字号搜索 ---
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
@@ -117,11 +127,12 @@ LAYOUT_SEED = None
KNOWN_CONFIG_KEYS = {
'MODE', 'MASK_IMAGE_PATH', 'IMAGE_CANVAS_MODE', 'FILL_CORNERS',
'CORNER_FILL_RATIO', 'MASK_TEXT', 'MASK_FONT_PATH', 'MASK_FONT_SIZE',
'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FILL_ON', 'EXCEL_PATH',
'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FAST_MODE', 'FILL_ON', 'EXCEL_PATH',
'DATA_COL_INDEX', 'WEIGHT_COL_INDEX', 'WEIGHT_COL_NAME', 'REMOVE_DUPLICATES', 'ENABLE_STROKE_WEIGHTS',
'WC_FONT_PATH',
'FONT_FALLBACK_PATHS',
'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY',
'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY', 'VERTICAL_RATIO',
'AUTO_REPEAT_TO_FILL', 'AUTO_REPEAT_MAX',
'USER_MIN_FONT_SIZE', 'USER_MAX_FONT_SIZE',
'CANVAS_RETRY_MAX_ROUNDS', 'CANVAS_RETRY_GROWTH',
'DARK_COLOR_PALETTE', 'LIGHT_COLOR_PALETTE', 'FONT_COLOR', 'OUTPUT_DIR', 'OUTPUT_PREFIX',
@@ -144,11 +155,15 @@ CONFIG_ALIASES = {
'max_font_size': 'USER_MAX_FONT_SIZE',
'font_color': 'FONT_COLOR',
'stroke_weights': 'ENABLE_STROKE_WEIGHTS',
'auto_repeat_to_fill': 'AUTO_REPEAT_TO_FILL',
'auto_repeat_max': 'AUTO_REPEAT_MAX',
'save_debug_images': 'SAVE_DEBUG_IMAGES',
}
CRITICAL_TYPE_CHECKS = {
'MODE': str,
'WORK_SCALE': (int, float),
'FAST_MODE': bool,
'DATA_COL_INDEX': int,
'WEIGHT_COL_INDEX': (int, type(None)),
'WEIGHT_COL_NAME': (str, type(None)),
@@ -160,6 +175,9 @@ CRITICAL_TYPE_CHECKS = {
'ENABLE_STROKE_WEIGHTS': bool,
'CANVAS_RETRY_MAX_ROUNDS': int,
'CANVAS_RETRY_GROWTH': (int, float),
'AUTO_REPEAT_TO_FILL': bool,
'AUTO_REPEAT_MAX': int,
'VERTICAL_RATIO': (int, float),
'SEED': (int, type(None)),
'LAYOUT_SEED': (int, type(None)),
}
+220 -83
View File
@@ -42,65 +42,169 @@ def _load_ft_font(font_path):
def build_svg_text_path_cached(word, size, x, y, font_path, orient):
"""Return (path_d, tx, ty, None). Geometry is cached for identical glyphs.
"""Return (path_d, tx, ty, None) for the whole word as a single path.
Path is in y-up font space (same as matplotlib TextPath). Caller applies
translate(tx, ty) scale(1, -1) to place it on the canvas.
Kept for callers that want one path per word. Internally this concatenates
the per-character parts, which are cached and shared across every word that
reuses the character.
"""
parts, tx0, ty0 = build_svg_word_parts(word, size, font_path, orient)
if not parts:
return "", x, y, None
if len(parts) == 1 and parts[0][1] == 0.0 and parts[0][2] == 0.0:
return parts[0][0], tx0 + x, ty0 + y, None
# Only reached when a caller insists on one path per word; the per-part
# translate has to be baked into the coordinates, so this is the slow path.
merged = " ".join(_translate_path_d(d, dx, dy) for d, dx, dy in parts)
return merged, tx0 + x, ty0 + y, None
def build_svg_word_parts(word, size, font_path, orient):
"""Return ([(path_d, dx, dy), ...], tx0, ty0) for one word.
Each part is a per-character outline cached at cursor 0 and reused verbatim;
dx/dy carry that character's position within the word. A caller places a
part with translate(tx0 + x + dx, ty0 + y + dy) scale(1, -1).
Caching per character rather than per word is what makes export cheap: a
roster of 750 Chinese names contains only a few dozen distinct characters,
so the outline drawing and the path-string formatting run a few dozen times
instead of once per name.
"""
key = (font_path, word, int(size), bool(orient))
cached = _SVG_SHAPE_CACHE.get(key)
if cached is None:
try:
cached = _build_shape_fonttools(word, size, font_path, orient)
except Exception:
cached = _build_shape_matplotlib(word, size, font_path, orient)
_SVG_SHAPE_CACHE[key] = cached
if len(_SVG_SHAPE_CACHE) > 20000:
for i, k in enumerate(list(_SVG_SHAPE_CACHE.keys())):
if i % 2 == 0:
_SVG_SHAPE_CACHE.pop(k, None)
path_d, tx0, ty0 = cached
return path_d, tx0 + x, ty0 + y, None
if cached is not None:
return cached
parts = []
cursor = 0.0
xmin = ymin = float("inf")
xmax = ymax = float("-inf")
for ch in word:
shape = _char_shape(ch, size, font_path, orient)
if shape is None:
continue
path_d, advance, cxmin, cymin, cxmax, cymax = shape
if path_d:
# A character's outline is cached at cursor 0; the cursor becomes a
# translate offset so the cached string is reused byte for byte.
# Horizontal runs advance in +x, rotated runs in +y (the rotated
# glyph transform subtracts the cursor from y, and the outer
# scale(1, -1) flips that back to +y).
dx, dy = (0.0, cursor) if orient else (cursor, 0.0)
parts.append((path_d, dx, dy))
if orient:
xmin = min(xmin, cxmin)
xmax = max(xmax, cxmax)
ymin = min(ymin, cymin - cursor)
ymax = max(ymax, cymax - cursor)
else:
xmin = min(xmin, cxmin + cursor)
xmax = max(xmax, cxmax + cursor)
ymin = min(ymin, cymin)
ymax = max(ymax, cymax)
cursor += advance
if not parts:
result = ([], 0.0, 0.0)
else:
# Offsets placing the run's top-left at (0,0) under
# translate(tx,ty) scale(1,-1).
result = (parts, -xmin, ymax)
_SVG_SHAPE_CACHE[key] = result
if len(_SVG_SHAPE_CACHE) > 20000:
for i, k in enumerate(list(_SVG_SHAPE_CACHE.keys())):
if i % 2 == 0:
_SVG_SHAPE_CACHE.pop(k, None)
return result
def _build_shape_fonttools(word, size, font_path, orient):
def _char_shape(ch, size, font_path, orient):
"""Return (path_d, advance, xmin, ymin, xmax, ymax) for one character.
The outline is drawn at cursor 0 and scaled to `size`, so the same string is
valid at every position the character appears in.
"""
key = (font_path, ch, int(size), bool(orient))
cached = _FT_CHAR_PATH_CACHE.get(key)
if cached is not None:
return cached
try:
cached = _build_char_fonttools(ch, size, font_path, orient)
except Exception:
cached = _build_char_matplotlib(ch, size, font_path, orient)
_FT_CHAR_PATH_CACHE[key] = cached
return cached
def _build_char_fonttools(ch, size, font_path, orient):
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.misc.transform import Transform
_tt, glyph_set, cmap, units = _load_ft_font(font_path)
scale = float(size) / float(units)
gname = cmap.get(ord(ch))
if not gname or gname not in glyph_set:
return None
glyph = glyph_set[gname]
pen = SVGPathPen(glyph_set)
cursor = 0.0
for ch in word:
gname = cmap.get(ord(ch))
if not gname or gname not in glyph_set:
continue
glyph = glyph_set[gname]
if orient:
# Horizontal layout then rotate -90° around origin:
# point (px, py) in string space -> after scale: (s*px, s*py)
# rotate -90: (s*py, -s*px). Compose with glyph origin at cursor:
# glyph local (gx,gy) -> (scale*gx + cursor, scale*gy)
# -> rotate -90: (scale*gy, -(scale*gx + cursor)) = (scale*gy, -scale*gx - cursor)
# matrix: x' = 0*gx + scale*gy + 0; y' = -scale*gx + 0*gy - cursor
# Transform(xx, xy, yx, yy, dx, dy): x' = xx*x + xy*y + dx; y' = yx*x + yy*y + dy
# xx=0, xy=scale, yx=-scale, yy=0, dx=0, dy=-cursor
tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, -cursor))
else:
tp = TransformPen(pen, Transform(scale, 0, 0, scale, cursor, 0))
glyph.draw(tp)
cursor += float(glyph.width) * scale
if orient:
# Horizontal layout then rotate -90° around the origin. The cursor term
# that used to live in dy is applied by the caller as a translate.
tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, 0))
else:
tp = TransformPen(pen, Transform(scale, 0, 0, scale, 0, 0))
glyph.draw(tp)
path_d = pen.getCommands()
advance = float(glyph.width) * scale
if not path_d:
return "", 0.0, 0.0
return "", advance, 0.0, 0.0, 0.0, 0.0
xmin, ymin, xmax, ymax = _path_bbox(path_d)
# Offsets placing glyph top-left at (0,0) under translate(tx,ty) scale(1,-1)
tx0 = -xmin
ty0 = ymax
return path_d, tx0, ty0
return path_d, advance, xmin, ymin, xmax, ymax
def _build_char_matplotlib(ch, size, font_path, orient):
from matplotlib.textpath import TextPath
path = TextPath((0, 0), ch, prop=get_font_properties(font_path, size), size=size)
if orient:
path = path.transformed(Affine2D().rotate_deg(-90))
bbox = path.get_extents()
path_d = mpl_path_to_svg_d(path)
# matplotlib gives no advance width; the ink bbox is the best stand-in.
advance = (bbox.ymax - bbox.ymin) if orient else (bbox.xmax - bbox.xmin)
return path_d, advance, bbox.xmin, bbox.ymin, bbox.xmax, bbox.ymax
def _translate_path_d(path_d, dx, dy):
"""Shift every coordinate pair in an SVG path string by (dx, dy).
Only used by the single-path-per-word compatibility path; the fast export
route carries dx/dy in the element transform instead.
"""
if not dx and not dy:
return path_d
import re
tokens = re.findall(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)
out = []
i = 0
while i < len(tokens):
t = tokens[i]
if not t.isalpha():
i += 1
continue
out.append(t)
i += 1
coords = []
while i < len(tokens) and not tokens[i].isalpha():
coords.append(float(tokens[i]))
i += 1
for j, v in enumerate(coords):
out.append(f"{v + (dx if j % 2 == 0 else dy):g}")
return " ".join(out)
def _path_bbox(path_d):
@@ -137,19 +241,6 @@ def _path_bbox(path_d):
return min(xs), min(ys), max(xs), max(ys)
def _build_shape_matplotlib(word, size, font_path, orient):
from matplotlib.textpath import TextPath
path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size)
if orient:
path = path.transformed(Affine2D().rotate_deg(-90))
bbox = path.get_extents()
tx0 = -bbox.xmin
ty0 = bbox.ymax
path_d = mpl_path_to_svg_d(path)
return path_d, tx0, ty0
def build_svg_text_path(word, size, x, y, font_path, orient):
path_d, tx, ty, _ = build_svg_text_path_cached(word, size, x, y, font_path, orient)
return path_d, tx, ty, None
@@ -324,8 +415,44 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
f_size = min(max_font, max(min_font, int(round(raw_size))))
target_font_sizes.append(f_size)
random_large_prefix = max(1, int(math.ceil(len(layout_sequence) * 0.08)))
for idx, (word, _freq) in enumerate(layout_sequence):
# "Large" words go down first by random probe (mode 2), then everything
# else spirals out from the centre to fill around them (mode 1). Which
# words count as large is decided by their actual font size, not by
# their position in the shuffled sequence: the old rule took the first
# 8% of the sequence, which under equal weights is an arbitrary set of
# same-size words, so "large" placement was applied to words that were
# not large at all. When every word is the same size (the equal-weight
# case) there is no large tier and everything spirals, which is the
# correct degenerate behaviour.
large_font_cutoff = min_font + base_span * 0.80
large_indices = [
idx for idx, size in enumerate(target_font_sizes)
if base_span > 0 and size >= large_font_cutoff
]
# Placing the large words before the small ones matters: they need whole
# empty regions to land in, and once the spiral has packed the canvas
# there are none left. Ordering is by index within each tier so a given
# layout_seed still reproduces exactly.
large_index_set = set(large_indices)
placement_order = large_indices + [
idx for idx in range(len(layout_sequence)) if idx not in large_index_set
]
# A word is only reported unplaced after the spiral, the random probes
# and a full exhaustive scan have all failed, so a single failure proves
# no legal position exists for it at this size -- and since the pipeline
# requires every word, the whole batch is already doomed. `max_failures`
# lets the caller stop right there instead of finishing the batch, which
# is what makes the scale search cheap: an unplaceable word costs about
# ten times a placeable one (it pays the full search before giving up),
# so a doomed batch run to completion is by far the most expensive thing
# the pipeline can do. Left as None, the batch runs to the end and packs
# in as many words as it can.
max_failures = getattr(self, "max_failures", None)
failures = 0
for idx in placement_order:
word, _freq = layout_sequence[idx]
font_size = target_font_sizes[idx]
placed = False
rotate = rotation_flags[idx]
@@ -344,8 +471,7 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
pad,
) = measured
query_seed = int(rng.integers(0, 2**31))
large_word = idx < random_large_prefix or score_by_index[idx] >= 0.80
placement_mode = 2 if large_word else 1
placement_mode = 2 if idx in large_index_set else 1
pos = self.grid.place_glyph_exact(
collision_arr,
stamp_arr,
@@ -371,6 +497,10 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
# Deliberately do not shrink an individual word. The pipeline
# treats a short layout as a failed batch and retries every word
# at one uniformly scaled size range.
if not placed:
failures += 1
if max_failures is not None and failures >= max_failures:
break
return self
@@ -385,28 +515,27 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
return img
def _iter_svg_paths(self):
"""Build SVG path data once per layout entry, with glyph-shape cache."""
# Cache by (word, size, orient): path geometry is identical; only translate differs.
shape_cache = {}
"""Yield (path_d, tx, ty, color), one entry per glyph.
A word contributes one entry per character. Each path string comes
straight from the per-character cache and the character's position
within the word rides along in tx/ty, so no path data is rebuilt or
re-parsed per word.
"""
for word, size, (y, x), orient, color in self.layout_:
key = (word, int(size), bool(orient))
cached = shape_cache.get(key)
if cached is None:
try:
path_d, origin_tx, origin_ty, bbox = build_svg_text_path_cached(
word, size, 0, 0, self.font_path, orient
)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
continue
# origin_tx/ty place the glyph so its top-left is at (0,0)
shape_cache[key] = (path_d, origin_tx, origin_ty)
cached = shape_cache[key]
path_d, origin_tx, origin_ty = cached
# Shift from (0,0) origin to actual layout position
tx = origin_tx + x
ty = origin_ty + y
yield path_d, tx, ty, color
try:
parts, origin_tx, origin_ty = build_svg_word_parts(
word, size, self.font_path, orient
)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
continue
# origin_tx/ty place the run's top-left at (0,0); x/y move it to the
# layout position; dx/dy offset the character within the run.
base_tx = origin_tx + x
base_ty = origin_ty + y
for path_d, dx, dy in parts:
yield path_d, base_tx + dx, base_ty + dy, color
def export_svgs(self, fill_filename, stroke_color="#000000", stroke_width=1.0):
"""Write fill + stroke SVG in one pass (path geometry built once)."""
@@ -500,13 +629,21 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
ring_radius=3, ring_width=1, ring_spacing=8):
"""统一 SVG 导出:fill_mode=fill|dot|line|ring,可叠加描边。"""
# 预先构建所有文字路径(fill / dot 模式共用)
# One entry per glyph rather than per word: each character's outline is
# taken straight from the shared cache, with its position in the run
# carried in tx/ty. Consumers below only ever place these as separate
# <path> elements, so splitting a word costs nothing.
text_paths = []
for word, size, (y, x), orient, _color in self.layout_:
try:
path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient)
text_paths.append((path, tx, ty))
parts, origin_tx, origin_ty = build_svg_word_parts(
word, size, self.font_path, orient
)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过: {word}, error={exc}")
continue
for path, dx, dy in parts:
text_paths.append((path, origin_tx + x + dx, origin_ty + y + dy))
with open(filename, "w", encoding="utf-8") as f:
f.write(
+314 -85
View File
@@ -15,10 +15,13 @@ from .fonts import get_cached_font
from .layout import OptimizedEfficientWordCloud
from .mask import analyze_mask, apply_safe_padding, calculate_dynamic_dimensions, prepare_mask
from .render import (
compute_coverage_score,
compute_fill_ratio_fast,
count_layout_overlap_pixels,
largest_empty_square_size,
append_layout_with_hd_clearance,
refine_layout_with_hd_clearance,
render_layout_occupancy,
scale_layout_for_hd,
)
from .weights import (
@@ -127,7 +130,7 @@ def run_generation_pass(
max_font = min(max_font, hard_max_font)
return min_font, max(min_font, max_font)
def try_place(scale, layout_seed=base_layout_seed):
def try_place(scale, layout_seed=base_layout_seed, probe=False):
min_font, max_font = scaled_bounds(scale)
wc = OptimizedEfficientWordCloud(
width=w_small,
@@ -138,12 +141,23 @@ def run_generation_pass(
min_font_size=min_font,
max_font_size=max_font,
background_color=config.get_output_background(),
prefer_horizontal=0.82,
# Each word independently draws horizontal vs vertical, so the mix
# is scattered rather than banded. VERTICAL_RATIO is the chance of
# a vertical word; mixing orientations is one of the cheapest ways
# to break up an over-regular grid-like look.
prefer_horizontal=1.0 - float(config.VERTICAL_RATIO),
# HD clearance is applied after scaling. Keeping the coarse-grid
# margin at zero avoids turning 1 HD pixel into 5-6 output pixels.
margin=_collision_margin,
)
wc.layout_seed = layout_seed
# A probe only needs to answer "does every word fit at this scale?", so
# it stops at the first word that cannot be placed. The answer is exact
# -- a word is only reported unplaced once an exhaustive scan has ruled
# out every position -- and it avoids paying for a doomed batch's
# remaining failures, each of which is far more expensive than a
# successful placement.
wc.max_failures = 1 if probe else None
wc.generate_from_frequencies(frequencies_data)
return wc, len(wc.layout_), min_font, max_font
@@ -165,55 +179,99 @@ def run_generation_pass(
best_wc = None
best_count = 0
best_scale = 1.0
scale = 1.0
best_coverage = -1.0 # shape-aware coverage of the current best candidate
best_occ = None # occupancy raster of the current best candidate
tried_layouts = set()
failed_scales = []
for attempt in range(1, 4):
attempt = 0
def probe_scale(scale):
"""Lay out every word at `scale`; return (wc, placed_count, complete)."""
nonlocal attempt, best_wc, best_count, best_scale
nonlocal best_coverage, best_occ
attempt += 1
bounds = scaled_bounds(scale)
layout_key = (bounds, base_layout_seed)
if layout_key in tried_layouts:
break
tried_layouts.add(layout_key)
wc, placed_count, min_font, max_font = try_place(scale)
tried_layouts.add((bounds, base_layout_seed))
wc, placed_count, min_font, max_font = try_place(scale, probe=True)
complete = placed_count >= total_target
print(
f" 整批布局 #{attempt}: scale={scale:.3f}, "
f"字号=[{min_font}, {max_font}] -> {placed_count}/{total_target}"
f"字号=[{min_font}, {max_font}] -> "
f"{'完整' if complete else '不足'} ({placed_count}/{total_target})"
)
log.info(
" 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d",
attempt,
scale,
min_font,
max_font,
placed_count,
total_target,
" 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d complete=%s",
attempt, scale, min_font, max_font, placed_count, total_target, complete,
)
if placed_count > best_count:
best_wc = wc
best_count = placed_count
best_scale = scale
if placed_count >= total_target:
final_wc = wc
final_scale = scale
final_layout_seed = base_layout_seed
if complete:
# Among complete layouts prefer the one whose ink reaches furthest
# into the mask shape, not merely the largest font scale. A Fermat
# spiral packs words into a disc around the fillable centroid; with
# few words or large fonts that disc stays small and never reaches
# the mask's protrusions, so the cloud reads as a circle instead of
# the intended silhouette. Coverage rewards layouts that spread into
# those deep regions, letting a slightly smaller scale win when it
# trades font size for a recognisable outline. Scale is the tie-
# breaker so an equal-coverage denser picture is still preferred.
occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH)
coverage = compute_coverage_score(occ, mask_small)
log.info(
" 整批布局 #%d coverage=%.4f (best=%.4f)",
attempt, coverage, best_coverage,
)
if coverage > best_coverage or (
coverage == best_coverage and scale > best_scale
):
best_wc, best_count, best_scale = wc, placed_count, scale
best_coverage, best_occ = coverage, occ
if not complete:
failed_scales.append(scale)
return wc, placed_count, complete
# Find the largest scale at which every word still fits. Bigger is strictly
# better here: the same names drawn larger leave less blank space. A probe
# answers feasibility exactly and stops at the first unplaceable word, so
# searching for the best scale costs little more than accepting the first
# one that happens to work.
lo = None # largest scale known to fit everything
hi = None # smallest scale known to be too big
scale = 1.0
for _ in range(2 if config.FAST_MODE else 4):
wc, placed_count, complete = probe_scale(scale)
if complete:
lo = scale
break
failed_scales.append(scale)
hi = scale
placed_ratio = placed_count / max(1, total_target)
# Required box area is roughly proportional to size². The extra
# safety margin absorbs fragmentation without wasting a binary search.
shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.58, math.sqrt(placed_ratio) * 0.92))
# Area scales with size², so linear size scales with sqrt(ratio). The
# probe stops early, which understates how many words would have fit,
# so this deliberately undershoots and the bisection below climbs back.
shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.55, math.sqrt(placed_ratio) * 0.92))
scale *= shrink
if final_wc is None:
# Close the gap between the largest failing scale and the smallest passing
# one. Each step recovers font size that the shrink above gave away.
if lo is not None and hi is not None:
for _ in range(1 if config.FAST_MODE else 3):
mid = (lo + hi) / 2.0
if hi - lo < 0.02 or scaled_bounds(mid) == scaled_bounds(lo):
break
_wc, _placed, complete = probe_scale(mid)
if complete:
lo = mid
else:
hi = mid
if best_wc is not None:
final_wc = best_wc
final_scale = best_scale
final_layout_seed = base_layout_seed
if final_wc is None:
return {
"wc": None,
"fill_ratio": 0.0,
"coverage": 0.0,
"occ_fast": None,
"w_small": w_small,
"h_small": h_small,
@@ -250,10 +308,16 @@ def run_generation_pass(
debug_dir.mkdir(parents=True, exist_ok=True)
Image.fromarray((occ_fast * 255).astype(np.uint8)).save(str(debug_dir / "occ_fast.png"))
# Probe larger whole-cloud layouts and keep the largest complete one. If
# an earlier batch was too large, search the discrete interval between the
# complete and failed scales instead of accepting an over-aggressive
# shrink. Every probe rebuilds the entire cloud with one shared scale.
# Probe whole-cloud layouts in BOTH font-size directions and keep the one
# whose ink reaches furthest into the mask shape. The original search only
# grew the font (chasing a higher pixel fill ratio), but a Fermat spiral
# packs words into a disc around the centroid: growing the font shrinks that
# disc, so an under-filled silhouette gets *more* circular, not less.
# Shrinking the font lets the spiral walk further out and reach the mask's
# protrusions, which raises shape coverage even when the raw fill ratio
# drops a little. Both directions are probed each round and the higher-
# coverage candidate wins; the loop stops when neither improves coverage.
# Every probe rebuilds the entire cloud with one shared scale.
if (
len(final_wc.layout_) >= total_target
and fill_ratio > 0
@@ -262,11 +326,22 @@ def run_generation_pass(
or has_character_sized_hole(final_wc, largest_empty_square)
)
):
current_coverage = compute_coverage_score(occ_fast, mask_small)
free_px = max(1, int(np.sum(mask_small == 0)))
upper_scale = min(
(failed for failed in failed_scales if failed > final_scale),
default=None,
)
for density_attempt in range(1, 5):
def _fill_and_coverage(wc):
occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH)
new_fill = float(np.sum((mask_small == 0) & (occ == 1))) / free_px
cov = compute_coverage_score(occ, mask_small)
return new_fill, cov, occ
for density_attempt in range(1, 2 if config.FAST_MODE else 5):
# Grow direction (larger font): bisect toward a known-too-big scale,
# or nudge up by the fill-ratio deficit, exactly as before.
if upper_scale is not None:
grow_scale = (final_scale + upper_scale) / 2.0
elif equal_size_mode:
@@ -277,61 +352,79 @@ def run_generation_pass(
1.12,
math.sqrt(config.TARGET_FILL_RATIO / fill_ratio) * 0.98,
)
if desired_growth <= 1.005:
break
grow_scale = final_scale * desired_growth
grow_scale = final_scale * desired_growth if desired_growth > 1.005 else None
grow_bounds = scaled_bounds(grow_scale)
if grow_bounds == scaled_bounds(final_scale):
break
grow_min, grow_max = grow_bounds
layout_key = (grow_bounds, base_layout_seed)
attempted_layout = False
if layout_key not in tried_layouts:
tried_layouts.add(layout_key)
wc, placed_count, _, _ = try_place(grow_scale)
attempted_layout = True
print(
f" 密度优化 #{density_attempt}: scale={grow_scale:.3f}, "
f"字号=[{grow_min}, {grow_max}] -> {placed_count}/{total_target}"
)
else:
wc, placed_count = None, -1
# Shrink direction (smaller font): the inverse nudge. Letting the
# spiral walk further out costs font size but can reach protrusions
# the grow direction abandons. Cap the shrink so one round cannot
# collapse the font to the floor.
shrink_scale = None
if not equal_size_mode and fill_ratio > 0:
shrink_factor = 1.0 / max(1.02, min(1.20, math.sqrt(fill_ratio / max(0.05, config.TARGET_FILL_RATIO)) * 1.02))
cand = final_scale * shrink_factor
if scaled_bounds(cand) != scaled_bounds(final_scale):
shrink_scale = cand
elif equal_size_mode:
current_size, _ = scaled_bounds(final_scale)
if current_size > hard_min_font:
shrink_scale = (current_size - 1) / max(1, base_min_font)
selected_seed = base_layout_seed
if placed_count < total_target and base_layout_seed is not None:
# The reference library samples a fresh legal position order.
# One bounded whole-cloud re-layout recovers dense solutions
# without per-word shrinking or an unbounded random search.
candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1)
retry_key = (grow_bounds, candidate_seed)
if retry_key not in tried_layouts:
tried_layouts.add(retry_key)
retry_wc, retry_count, _, _ = try_place(grow_scale, candidate_seed)
attempted_layout = True
candidates = []
for direction, scale in (("grow", grow_scale), ("shrink", shrink_scale)):
if scale is None or scaled_bounds(scale) == scaled_bounds(final_scale):
continue
bounds = scaled_bounds(scale)
d_min, d_max = bounds
key = (bounds, base_layout_seed)
wc, placed_count, selected_seed = None, -1, base_layout_seed
if key not in tried_layouts:
tried_layouts.add(key)
wc, placed_count, _, _ = try_place(scale)
# Bounded seed retry to recover a complete layout, as before.
if placed_count < total_target and base_layout_seed is not None:
candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1)
retry_key = (bounds, candidate_seed)
if retry_key not in tried_layouts:
tried_layouts.add(retry_key)
retry_wc, retry_count, _, _ = try_place(scale, candidate_seed)
print(
f" 密度优化 #{density_attempt} {direction} 整批重排: "
f"seed={candidate_seed}, 字号=[{d_min}, {d_max}] -> "
f"{retry_count}/{total_target}"
)
if retry_count > placed_count:
wc, placed_count = retry_wc, retry_count
selected_seed = candidate_seed
if placed_count < total_target:
if direction == "grow":
upper_scale = scale
print(
f" 密度优化 #{density_attempt} 整批重排: "
f"seed={candidate_seed}, 字号=[{grow_min}, {grow_max}] -> "
f"{retry_count}/{total_target}"
f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, "
f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} (不完整)"
)
if retry_count > placed_count:
wc = retry_wc
placed_count = retry_count
selected_seed = candidate_seed
if not attempted_layout:
break
if placed_count < total_target:
upper_scale = grow_scale
continue
continue
new_fill, cov, occ = _fill_and_coverage(wc)
print(
f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, "
f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} "
f"fill={new_fill:.3f} coverage={cov:.4f}"
)
candidates.append((direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ))
new_fill, new_occ = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH)
if new_fill <= fill_ratio:
if not candidates:
break
# Pick the higher-coverage candidate; tie-break on fill ratio so a
# genuinely denser picture still wins when coverage is equal.
candidates.sort(key=lambda c: (c[6], c[5]))
direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ = candidates[-1]
if cov <= current_coverage and new_fill <= fill_ratio:
break
final_wc = wc
final_scale = grow_scale
final_scale = scale
final_layout_seed = selected_seed
fill_ratio = new_fill
occ_fast = new_occ
occ_fast = occ
current_coverage = cov
largest_empty_square = largest_empty_square_size(occ_fast, mask_small)
complete_candidates.append(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
@@ -339,6 +432,7 @@ def run_generation_pass(
if (
fill_ratio >= config.TARGET_FILL_RATIO * 0.98
and not has_character_sized_hole(final_wc, largest_empty_square)
and current_coverage >= 0.98
):
break
@@ -350,6 +444,7 @@ def run_generation_pass(
and len(final_wc.layout_) >= total_target
and has_character_sized_hole(final_wc, largest_empty_square)
and base_layout_seed is not None
and not config.FAST_MODE
):
if total_target < 100:
hole_attempt_budget = 3
@@ -389,6 +484,77 @@ def run_generation_pass(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
)
# ── 工作网格增量填充:原名字号不变,用更小字号追加副本填轮廓 ──
# 已填区域标记为阻挡,新词只能进空白间隙。每一轮只生成一份名单,
# 并把新占用合并回工作网格;因此自动填充没有固定的重复数量,
# 只在轮廓仍未覆盖且还有合法位置时继续追加。
fill_work_layout = []
if (
config.AUTO_REPEAT_TO_FILL
and final_wc is not None
and len(final_wc.layout_) >= total_target
and occ_fast is not None
):
current_cov = compute_coverage_score(occ_fast, mask_small)
if current_cov < 0.95 and names:
# 原名字号范围
original_sizes = [size for _, size, *_ in final_wc.layout_]
orig_min_font = int(min(original_sizes))
orig_max_font = int(max(original_sizes))
span = orig_max_font - orig_min_font
# 追加用缩小字号:原最大字号的 50~60%
fill_min_font = max(config.MIN_FONT_FLOOR, int(round(orig_min_font * 0.50)))
fill_max_font = max(fill_min_font, int(round(orig_min_font + span * 0.60)))
fill_seed = (final_layout_seed ^ 0x9E3779B9) & 0x7FFFFFFF
if fill_seed == 0:
fill_seed = 1
# AUTO_REPEAT_MAX 只是防止异常掩膜导致无限循环,不是目标重复次数。
max_fill_rounds = max(1, int(config.AUTO_REPEAT_MAX))
for fill_round in range(max_fill_rounds):
if current_cov >= 0.95:
break
# 融合 mask:原阻挡 + 已填区域都标为 255
fill_mask = mask_small.copy()
fill_mask[(occ_fast == 1)] = 255
fill_wc = OptimizedEfficientWordCloud(
width=w_small, height=h_small,
mask=fill_mask,
font_path=config.WC_FONT_PATH,
# 一轮只追加一份名单;需要更多时由下一轮按需追加。
max_words=len(names),
min_font_size=fill_min_font,
max_font_size=fill_max_font,
background_color=config.get_output_background(),
prefer_horizontal=1.0 - float(config.VERTICAL_RATIO),
margin=_collision_margin,
)
fill_wc.layout_seed = (fill_seed + fill_round) & 0x7FFFFFFF or 1
fill_freq = {name: 0.3 for name in names}
fill_wc.generate_from_frequencies(fill_freq)
fill_placed = len(fill_wc.layout_)
print(
f"[增量填充#{fill_round + 1}] 工作网格追加放置 "
f"{fill_placed}/{len(names)} 词,字号=[{fill_min_font}, {fill_max_font}]"
)
log.info(
"[增量填充#%d] 工作网格追加放置 %d/%d 词, 字号=[%d,%d]",
fill_round + 1, fill_placed, len(names), fill_min_font, fill_max_font,
)
if fill_placed <= 0:
break
fill_work_layout.extend(fill_wc.layout_)
fill_occ = render_layout_occupancy(
fill_wc.layout_, mask_small.shape, config.WC_FONT_PATH
)
occ_fast = np.maximum(occ_fast, fill_occ)
current_cov = compute_coverage_score(occ_fast, mask_small)
print(f"[增量填充#{fill_round + 1}] 工作网格覆盖度={current_cov:.4f}")
hd_layout = None
hd_clearance = None
raw_hd_layout = []
@@ -473,14 +639,73 @@ def run_generation_pass(
config.WC_FONT_PATH,
)
# ── 增量填充(工作网格 → HD) ──
# 工作网格上的合法位置经过放大后可能因取整发生碰撞,因此填充词必须
# 与基础布局一起再次做高清精修。若整批填充无法通过,则二分保留最多
# 的追加词;绝不能让自动填充破坏原本已经成功的基础布局。
if (
fill_work_layout
and hd_layout is not None
):
old_count = len(hd_layout)
fill_hd = scale_layout_for_hd(fill_work_layout, config.WORK_SCALE)
base_hd_layout = list(hd_layout)
accepted_additions, clearance_stats = append_layout_with_hd_clearance(
base_hd_layout,
fill_hd,
mask_hd,
config.WC_FONT_PATH,
clearance=0,
allow_global_search=False,
)
accepted_count = len(accepted_additions)
accepted_clearance = 0
hd_layout = base_hd_layout + accepted_additions
hd_overlap_pixels = count_layout_overlap_pixels(
hd_layout, (real_hd_h, real_hd_w), config.WC_FONT_PATH
)
new_fill, new_occ = compute_fill_ratio_fast(
hd_layout, mask_hd, config.WC_FONT_PATH
)
new_cov = compute_coverage_score(new_occ, mask_hd) if new_occ is not None else 0.0
clearance_stats = dict(clearance_stats or {})
clearance_stats["clearance_px"] = accepted_clearance
hd_clearance = clearance_stats
print(
f"[增量填充] 高清验收: {old_count}+{accepted_count}="
f"{len(hd_layout)} 词, fill={new_fill:.3f}, "
f"coverage={new_cov:.4f}, overlap={hd_overlap_pixels}"
)
print(
f"[增量填充] 工作网格候选 {len(fill_work_layout)} 词,"
f"高清接受 {len(hd_layout) - old_count}"
)
log.info(
"[增量填充] HD 验收: base=%d candidate=%d accepted=%d fill=%.4f coverage=%.4f overlap=%d",
old_count, len(fill_hd), len(hd_layout) - old_count, new_fill, new_cov, hd_overlap_pixels,
)
fill_ratio = new_fill
occ_fast = new_occ
largest_empty_square = largest_empty_square_size(occ_fast, mask_hd)
print(
f"最终填充率: {fill_ratio:.3f} | 高清重叠像素: {hd_overlap_pixels} | "
f"精修位移: {hd_clearance['shifted_words']} 词, 最大 {hd_clearance['max_shift']}px | "
f"隔离带: {hd_clearance['clearance_px']}px"
)
# occ_fast 可能是工作网格或 HD 网格形状(增量填充后),按形状匹配计算覆盖度
if occ_fast is not None and occ_fast.shape == mask_small.shape:
coverage = compute_coverage_score(occ_fast, mask_small)
elif occ_fast is not None and occ_fast.shape == mask_hd.shape:
coverage = compute_coverage_score(occ_fast, mask_hd)
else:
coverage = 0.0
print(f"轮廓覆盖度: {coverage:.4f}")
return {
"wc": final_wc,
"fill_ratio": fill_ratio,
"coverage": coverage,
"occ_fast": occ_fast,
"w_small": w_small,
"h_small": h_small,
@@ -614,6 +839,9 @@ def main():
)
frequencies_data = name_weights_map if config.REMOVE_DUPLICATES else [(name, name_weights_map.get(name, 10)) for name in names]
canvas_retry_round = 0
generation_result = None
t_gen = time.time()
canvas_retry_round = 0
generation_result = None
t_gen = time.time()
@@ -649,8 +877,9 @@ def main():
canvas_retry_round,
)
sys.exit(1)
log.info(" 生成完成 placed=%d/%d fill=%.4f retry=%d",
placed, target, generation_result["fill_ratio"], canvas_retry_round)
log.info(" 生成完成 placed=%d/%d fill=%.4f coverage=%.4f retry=%d",
placed, target, generation_result["fill_ratio"],
generation_result.get("coverage", 0.0), canvas_retry_round)
break
canvas_retry_round += 1
+285 -22
View File
@@ -3,6 +3,37 @@ 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:
@@ -24,21 +55,17 @@ def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0
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:
measured = _word_ink(word, size, orient, font_path)
if measured is None:
continue
ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) > alpha_threshold
ink_arr, bbox_left, bbox_top = measured
glyph_height, glyph_width = ink_arr.shape
ink = ink_arr > alpha_threshold
ink_x = int(x) + int(bbox[0])
ink_y = int(y) + int(bbox[1])
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)
@@ -54,6 +81,56 @@ def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0
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,
@@ -65,7 +142,6 @@ def refine_layout_with_hd_clearance(
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
@@ -82,17 +158,13 @@ def refine_layout_with_hd_clearance(
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:
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 = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width)
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
@@ -105,8 +177,8 @@ def refine_layout_with_hd_clearance(
collision = padded > 0
stamp = padded > 0
base_y = int(draw_y) + int(bbox[1]) - pad
base_x = int(draw_x) + int(bbox[0]) - pad
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]
@@ -129,6 +201,16 @@ def refine_layout_with_hd_clearance(
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,
@@ -152,6 +234,126 @@ def refine_layout_with_hd_clearance(
}
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)
@@ -175,6 +377,67 @@ def compute_fill_ratio_fast(layout, mask, font_path):
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)