feat(wordcloud): 收口在途开发(布局/存储/前端)+ R4 WCD 生产任务(jobs wcd_file)与生产订单列表
This commit is contained in:
+20
-2
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user