Rework layout engine around exact-glyph collision, add tests and docs sync

Replace the old bbox/heuristic placement (scale search rounds, large-font
capping, stratified sampling, fill-retry ladders) with an area-model font
sizing pass feeding a C++ exact-glyph collision engine (centroid-biased
spiral + random probing, HD clearance refinement, density/hole
optimization). Simplify the frontend advanced-params panel and JobParams
type to match the surviving config surface, add a layout-constraints test
suite and a repeatable benchmark tool, and bring docs/*.md back in sync
with current code (plus new TESTING.md and DEPLOYMENT.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:32:25 +08:00
co-authored by Claude Sonnet 5
parent bf2b138007
commit 1d17b5e20d
24 changed files with 2600 additions and 1283 deletions
+18 -81
View File
@@ -31,8 +31,6 @@ MODE = "IMAGE"
# --- Image Mode ---
MASK_IMAGE_PATH = "7887.png"
IMAGE_CANVAS_MODE = "WIDTH"
EXPAND_FOR_SPIRAL = True # 放大画布使螺旋填充覆盖边角
EXPAND_RATIO = 2.5 # 更大倍率确保覆盖边缘
FILL_CORNERS = False
CORNER_FILL_RATIO = 0.15
@@ -42,11 +40,12 @@ MASK_FONT_PATH = str(PROJECT_DEFAULT_FONT)
MASK_FONT_SIZE = 3000
# --- 自动画幅与清晰度 ---
AUTO_EXPAND_CANVAS = True
BASE_HD_WIDTH = 8000
# 默认 4k 级画布:打印/激光足够清晰,比 8k 渲染快约 4×
BASE_HD_WIDTH = 4000
BASE_HD_HEIGHT = 4000
MIN_READABLE_HEIGHT_PX = 25
WORK_SCALE = 0.25
MIN_READABLE_HEIGHT_PX = 22
# 运算网格缩放:0.18 在速度/质量之间更均衡
WORK_SCALE = 0.18
# --- 阴阳刻 ---
FILL_ON = "BLACK"
@@ -68,53 +67,17 @@ FONT_FALLBACK_PATHS = (
# --- 填充策略 ---
N_REPETITIONS = 1
TARGET_FILL_RATIO = 0.0 # 关闭填充率检测
# 面积模型目标填充率:中文实心笔画像素占比约 0.35–0.55。
# 略偏保守以保证 scale=1.0 首次就能放满,减少多轮重试。
TARGET_FILL_RATIO = 0.45
SIZE_RATIO = 2.0
PACKING_EFFICIENCY = 0.85
# --- 分层采样(边缘覆盖) ---
ENABLE_STRATIFIED_SAMPLING = True
STRATIFIED_BANDS = 3 # Mix Center, Middle, and Edge
# --- 填充率补偿(低填充时略增字号) ---
GROW_FONT_ON_LOW_FILL = False # 关闭
GROW_FONT_STEP = 1.05
# --- 填充率检测 ---
MIN_ACCEPT_FILL_RATIO = 0.75
FILL_RETRY_RELAX_LARGE_CAP = True
FILL_RETRY_MAX_ROUNDS = 3
FILL_RETRY_MAX_SCALE = 1.5
PACKING_EFFICIENCY = 0.9
# --- 智能字号搜索 ---
REQUIRE_ALL_WORDS = True
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
USER_MIN_FONT_SIZE = None
USER_MAX_FONT_SIZE = None
MIN_FONT_FLOOR = 2
FONT_SCALE_MIN = 0.5
FONT_SCALE_MAX = 1.2
SCALE_SEARCH_STEPS = 7
SCALE_SEARCH_ROUNDS = 5
SCALE_DECAY = 0.85
SCALE_FLOOR = 0.25
AUTO_SHRINK_ROUNDS = 4
LOG_WEIGHT_RATIO = 0.72
RANK_WEIGHT_RATIO = 0.28
# --- 大字号智能降级 ---
# 开启后,如果填不满,会自动尝试减少大字号的数量,给小词腾空间
ENABLE_SMART_LARGE_FONT_REDUCTION = True
LIMIT_LARGE_FONTS = True
LARGE_FONT_LIMIT_RATIO = 0.2 # 初始允许 20% 的词是大字
LARGE_FONT_THRESHOLD_RATIO = 0.8 # 超过最大字号 80% 算大字
LARGE_FONT_CAP_RATIO = 0.6 # 被限制时,缩小到阈值的 60%
# --- 点阵补偿 ---
ENABLE_DOT_MATRIX = False
DOT_SPACING = 15
DOT_RADIUS = 0
DOT_SAFETY_BUFFER = 12
# --- 画布重试 ---
CANVAS_RETRY_MAX_ROUNDS = 1
@@ -138,51 +101,36 @@ LIGHT_COLOR_PALETTE = (
FONT_COLOR = "#000000" # 统一字体颜色,None 则使用调色板
# --- 输出 ---
MAX_ATTEMPTS = 5
OUTPUT_DIR = "."
OUTPUT_PREFIX = ""
OUTPUT_PNG = "Efficient_Result_HD_AutoResize.png"
OUTPUT_SVG = "Efficient_Result_HD_AutoResize.svg"
DB_PATH = "wordcloud_hd.db"
METRICS_FILE = "metrics.json"
SAVE_DEBUG_IMAGES = True
SAVE_DEBUG_IMAGES = False
DEBUG_OUTPUT_DIR = "output"
# --- 可复现性 ---
SEED = None
LAYOUT_ORDER_MODE_SORTED = "SORTED"
LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM = "INTERLEAVED_RANDOM"
VALID_LAYOUT_ORDER_MODES = (
LAYOUT_ORDER_MODE_SORTED,
LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM,
)
LAYOUT_ORDER_MODE = LAYOUT_ORDER_MODE_SORTED
LAYOUT_SEED = None
KNOWN_CONFIG_KEYS = {
'MODE', 'MASK_IMAGE_PATH', 'IMAGE_CANVAS_MODE', 'EXPAND_FOR_SPIRAL', 'EXPAND_RATIO', 'FILL_CORNERS',
'CORNER_FILL_RATIO', 'MASK_TEXT', 'MASK_FONT_PATH', 'MASK_FONT_SIZE', 'AUTO_EXPAND_CANVAS',
'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',
'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', 'ENABLE_STRATIFIED_SAMPLING',
'STRATIFIED_BANDS', 'GROW_FONT_ON_LOW_FILL', 'GROW_FONT_STEP', 'MIN_ACCEPT_FILL_RATIO',
'FILL_RETRY_RELAX_LARGE_CAP', 'FILL_RETRY_MAX_ROUNDS', 'FILL_RETRY_MAX_SCALE', 'REQUIRE_ALL_WORDS',
'MIN_FONT_SIZE', 'USER_MIN_FONT_SIZE', 'USER_MAX_FONT_SIZE', 'MIN_FONT_FLOOR', 'FONT_SCALE_MIN',
'FONT_SCALE_MAX', 'SCALE_SEARCH_STEPS', 'SCALE_SEARCH_ROUNDS', 'SCALE_DECAY', 'SCALE_FLOOR',
'LOG_WEIGHT_RATIO', 'RANK_WEIGHT_RATIO',
'AUTO_SHRINK_ROUNDS', 'ENABLE_SMART_LARGE_FONT_REDUCTION', 'LIMIT_LARGE_FONTS',
'LARGE_FONT_LIMIT_RATIO', 'LARGE_FONT_THRESHOLD_RATIO', 'LARGE_FONT_CAP_RATIO', 'ENABLE_DOT_MATRIX',
'DOT_SPACING', 'DOT_RADIUS', 'DOT_SAFETY_BUFFER', 'CANVAS_RETRY_MAX_ROUNDS', 'CANVAS_RETRY_GROWTH',
'DARK_COLOR_PALETTE', 'LIGHT_COLOR_PALETTE', 'FONT_COLOR', 'MAX_ATTEMPTS', 'OUTPUT_DIR', 'OUTPUT_PREFIX',
'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY',
'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',
'OUTPUT_PNG', 'OUTPUT_SVG', 'DB_PATH', 'METRICS_FILE', 'SAVE_DEBUG_IMAGES', 'DEBUG_OUTPUT_DIR', 'SEED',
'LAYOUT_ORDER_MODE', 'LAYOUT_SEED'
'LAYOUT_SEED'
}
CONFIG_ALIASES = {
'seed': 'SEED',
'layout_order_mode': 'LAYOUT_ORDER_MODE',
'layout_seed': 'LAYOUT_SEED',
'excel_path': 'EXCEL_PATH',
'mask_image_path': 'MASK_IMAGE_PATH',
@@ -207,16 +155,12 @@ CRITICAL_TYPE_CHECKS = {
'FONT_FALLBACK_PATHS': (list, tuple),
'USER_MIN_FONT_SIZE': (int, float, type(None)),
'USER_MAX_FONT_SIZE': (int, float, type(None)),
'MAX_ATTEMPTS': int,
'SAVE_DEBUG_IMAGES': bool,
'REMOVE_DUPLICATES': bool,
'ENABLE_STROKE_WEIGHTS': bool,
'CANVAS_RETRY_MAX_ROUNDS': int,
'CANVAS_RETRY_GROWTH': (int, float),
'LOG_WEIGHT_RATIO': (int, float),
'RANK_WEIGHT_RATIO': (int, float),
'SEED': (int, type(None)),
'LAYOUT_ORDER_MODE': str,
'LAYOUT_SEED': (int, type(None)),
}
@@ -227,7 +171,6 @@ def parse_args():
parser = argparse.ArgumentParser(description="Efficient WordCloud generator")
parser.add_argument("--config", type=str, help="JSON 配置文件路径")
parser.add_argument("--seed", type=int, help="随机种子(可复现)")
parser.add_argument("--layout-order-mode", type=lambda s: s.upper(), choices=VALID_LAYOUT_ORDER_MODES, help="布局顺序模式")
parser.add_argument("--layout-seed", type=int, help="布局顺序随机种子")
parser.add_argument("--excel-path", type=str, help="Excel 输入路径")
parser.add_argument("--mask-image-path", type=str, help="掩膜图片路径(IMAGE 模式)")
@@ -298,7 +241,6 @@ def apply_json_config(config_path):
def apply_cli_overrides(args):
mapping = {
'seed': 'SEED',
'layout_order_mode': 'LAYOUT_ORDER_MODE',
'layout_seed': 'LAYOUT_SEED',
'excel_path': 'EXCEL_PATH',
'mask_image_path': 'MASK_IMAGE_PATH',
@@ -342,7 +284,7 @@ def _resolve_font_path(configured_path, fallback_paths, *, role):
def finalize_runtime_config():
global EXCEL_PATH, MASK_IMAGE_PATH, MASK_FONT_PATH, WC_FONT_PATH
global OUTPUT_DIR, OUTPUT_PNG, OUTPUT_SVG, DB_PATH, METRICS_FILE, DEBUG_OUTPUT_DIR, MIN_FONT_SIZE
global LAYOUT_ORDER_MODE, LAYOUT_SEED
global LAYOUT_SEED
# 运行时派生字段
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
@@ -350,11 +292,6 @@ def finalize_runtime_config():
if LAYOUT_SEED is None:
LAYOUT_SEED = SEED
LAYOUT_ORDER_MODE = str(LAYOUT_ORDER_MODE).upper()
if LAYOUT_ORDER_MODE not in VALID_LAYOUT_ORDER_MODES:
print(f"错误: 不支持的 LAYOUT_ORDER_MODE: {LAYOUT_ORDER_MODE}")
sys.exit(1)
EXCEL_PATH = str(_resolve_path(EXCEL_PATH))
MASK_IMAGE_PATH = str(_resolve_path(MASK_IMAGE_PATH))
+330 -209
View File
@@ -1,16 +1,159 @@
import math
import random
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from matplotlib.path import Path as MplPath
from matplotlib.textpath import TextPath
from matplotlib.transforms import Affine2D
from . import config
from .ewc import EfficientWordCloud
from .fonts import get_cached_font, get_font_properties
# ── Fast SVG path cache (fontTools outlines, unscaled per-char) ──────────────
# font_path -> (glyph_set, cmap, units_per_em)
_FT_FONT_CACHE = {}
# (font_path, char) -> (svg_path_d_in_font_units, advance_width)
_FT_CHAR_PATH_CACHE = {}
# (font_path, word, size, orient) -> (path_d_scaled, tx0, ty0)
_SVG_SHAPE_CACHE = {}
def _load_ft_font(font_path):
cached = _FT_FONT_CACHE.get(font_path)
if cached is not None:
return cached
from fontTools.ttLib import TTFont
# .ttc collections: try face 0 first
try:
tt = TTFont(font_path, fontNumber=0)
except TypeError:
tt = TTFont(font_path)
glyph_set = tt.getGlyphSet()
cmap = tt.getBestCmap() or {}
units = tt["head"].unitsPerEm
cached = (tt, glyph_set, cmap, units)
_FT_FONT_CACHE[font_path] = cached
return cached
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.
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.
"""
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
def _build_shape_fonttools(word, 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)
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
path_d = pen.getCommands()
if not path_d:
return "", 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
def _path_bbox(path_d):
import re
nums = [float(n) for n in re.findall(r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)]
# This is approximate (includes arc radii etc.) but good enough for placement offsets
# Better: parse properly. For font outlines, commands are mostly M/L/Q/C/Z with coords.
xs, ys = [], []
tokens = re.findall(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)
i = 0
while i < len(tokens):
t = tokens[i]
if t.isalpha():
cmd = t
i += 1
if cmd in "Zz":
continue
if cmd in "Hh":
while i < len(tokens) and not tokens[i].isalpha():
xs.append(float(tokens[i])); i += 1
elif cmd in "Vv":
while i < len(tokens) and not tokens[i].isalpha():
ys.append(float(tokens[i])); i += 1
elif cmd in "Aa":
while i + 6 < len(tokens) and not tokens[i].isalpha():
xs.append(float(tokens[i + 5])); ys.append(float(tokens[i + 6])); i += 7
else:
while i + 1 < len(tokens) and not tokens[i].isalpha():
xs.append(float(tokens[i])); ys.append(float(tokens[i + 1])); i += 2
else:
i += 1
if not xs or not ys:
return 0.0, 0.0, 0.0, 0.0
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
def normalize_relative_scores(values):
if not values:
@@ -18,7 +161,10 @@ def normalize_relative_scores(values):
v_min = min(values)
v_max = max(values)
if math.isclose(v_min, v_max):
return [1.0 for _ in values]
# Equal weights should produce a neutral, equal hierarchy. Returning
# 1.0 made every word request the maximum size and later words were
# arbitrarily shrunk by placement order.
return [0.5 for _ in values]
scale = v_max - v_min
return [(value - v_min) / scale for value in values]
@@ -35,28 +181,17 @@ def build_log_rank_scores(freq_list, *, per_word=False):
word_weights[word] = f
unique_weights = sorted(set(word_weights.values()), reverse=True)
if len(unique_weights) <= 1:
word_scores = {w: 1.0 for w in word_weights}
word_scores = {w: 0.5 for w in word_weights}
else:
log_vals = [math.log1p(w) for w in unique_weights]
normed = normalize_relative_scores(log_vals)
weight_to_score = dict(zip(unique_weights, normed))
word_scores = {w: weight_to_score[weight] for w, weight in word_weights.items()}
return [word_scores.get(w, 1.0) for w, _ in freq_list]
return [word_scores.get(w, 0.5) for w, _ in freq_list]
safe_freqs = [max(float(freq), 1e-6) for _word, freq in freq_list]
log_scores = normalize_relative_scores([math.log1p(freq) for freq in safe_freqs])
rank_scores = [1.0 - (idx / max(1, len(freq_list) - 1)) for idx in range(len(freq_list))]
total_ratio = config.LOG_WEIGHT_RATIO + config.RANK_WEIGHT_RATIO
if total_ratio <= 0:
return log_scores
log_ratio = config.LOG_WEIGHT_RATIO / total_ratio
rank_ratio = config.RANK_WEIGHT_RATIO / total_ratio
return [
max(0.0, min(1.0, log_score * log_ratio + rank_score * rank_ratio))
for log_score, rank_score in zip(log_scores, rank_scores)
]
return log_scores
def pick_palette_color(relative_score):
@@ -69,76 +204,34 @@ def pick_palette_color(relative_score):
return palette[idx]
def _build_layout_sequence(sorted_freq, max_words, layout_order_mode, layout_seed):
def _build_layout_sequence(sorted_freq, max_words, layout_seed):
if max_words <= 0 or not sorted_freq:
return []
expanded_freq = list(sorted_freq)
if len(expanded_freq) < max_words:
base_words = expanded_freq[:]
if not base_words:
return []
while len(expanded_freq) < max_words:
for item in base_words:
if len(expanded_freq) >= max_words:
break
expanded_freq.append(item)
expanded_freq = expanded_freq[:max_words]
if layout_order_mode == config.LAYOUT_ORDER_MODE_SORTED or len(expanded_freq) <= 1:
return expanded_freq
band_count = min(3, len(expanded_freq))
band_size = math.ceil(len(expanded_freq) / band_count)
bands = []
rng = random.Random(layout_seed)
for band_idx in range(band_count):
start = band_idx * band_size
end = min(len(expanded_freq), start + band_size)
band = expanded_freq[start:end]
rng.shuffle(band)
if band:
bands.append(band)
interleave_pattern = [0, 1, 0, 2]
band_positions = [0] * len(bands)
base_words = list(sorted_freq)
sequence = []
while len(sequence) < len(expanded_freq):
appended = False
for pattern_idx in interleave_pattern:
if pattern_idx >= len(bands):
continue
pos = band_positions[pattern_idx]
if pos >= len(bands[pattern_idx]):
continue
sequence.append(bands[pattern_idx][pos])
band_positions[pattern_idx] += 1
appended = True
if len(sequence) >= len(expanded_freq):
break
if appended:
continue
for band_idx, band in enumerate(bands):
pos = band_positions[band_idx]
if pos < len(band):
sequence.append(band[pos])
band_positions[band_idx] += 1
appended = True
if len(sequence) >= len(expanded_freq):
break
if not appended:
break
while len(sequence) < max_words:
round_items = []
start = 0
while start < len(base_words):
end = start + 1
weight = float(base_words[start][1])
while end < len(base_words) and math.isclose(float(base_words[end][1]), weight):
end += 1
# Start every equal-weight group from a canonical order before
# shuffling. A fixed seed must therefore give the same layout
# regardless of the row order in the uploaded workbook.
group = sorted(base_words[start:end], key=lambda item: str(item[0]))
rng.shuffle(group)
round_items.extend(group)
start = end
remaining = max_words - len(sequence)
sequence.extend(round_items[:remaining])
return sequence
class OptimizedEfficientWordCloud(EfficientWordCloud):
def __init__(self, *args, large_font_ratio=config.LARGE_FONT_LIMIT_RATIO, size_scale=1.0, **kwargs):
super().__init__(*args, **kwargs)
self.large_font_ratio = large_font_ratio
self.size_scale = size_scale
def generate_from_frequencies(self, frequencies):
if isinstance(frequencies, dict):
freq_list = list(frequencies.items())
@@ -147,12 +240,12 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
else:
raise ValueError("frequencies 必须是字典或 (word, freq) 列表")
sorted_freq = sorted(freq_list, key=lambda x: x[1], reverse=True)
sorted_freq = sorted(freq_list, key=lambda item: (-float(item[1]), str(item[0])))
layout_seed = getattr(self, "layout_seed", config.LAYOUT_SEED)
layout_sequence = _build_layout_sequence(
sorted_freq,
self.max_words,
config.LAYOUT_ORDER_MODE,
config.LAYOUT_SEED,
layout_seed,
)
if not layout_sequence:
@@ -164,116 +257,120 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
for (w, _f), s in zip(freq_list, per_word_scores):
if w not in word_to_score or s > word_to_score[w]:
word_to_score[w] = s
score_by_index = [word_to_score.get(w, 1.0) for w, _ in layout_sequence]
effective_max_font = max(self.min_font_size + 1, int(self.max_font_size * self.size_scale))
large_threshold = int(effective_max_font * config.LARGE_FONT_THRESHOLD_RATIO) if config.LIMIT_LARGE_FONTS else None
large_limit = int(self.max_words * self.large_font_ratio) if config.LIMIT_LARGE_FONTS else None
large_count = 0
rotation_flags = [np.random.random() > self.prefer_horizontal for _ in layout_sequence]
score_by_index = [word_to_score.get(w, 0.5) for w, _ in layout_sequence]
seed = layout_seed if layout_seed is not None else config.SEED
rng = np.random.default_rng(seed)
rotation_flags = [bool(rng.random() > self.prefer_horizontal) for _ in layout_sequence]
# Dummy draw for textbbox measurement (no actual PIL image needed during placement)
_measure_img = Image.new("L", (1, 1))
_measure_draw = ImageDraw.Draw(_measure_img)
base_span = max(1, self.max_font_size - self.min_font_size)
# (word, size, rotate) -> exact collision and drawing geometry.
# The C++ canvas stores the same tight glyph bitmap that PIL renders;
# bbox bearings are carried separately so HD output cannot drift away
# from the collision map.
glyph_cache = {}
def measure_and_mask(word, size, rotate):
key = (word, size, rotate)
cached = glyph_cache.get(key)
if cached is not None:
return cached
font = get_cached_font(self.font_path, size)
orientation = Image.ROTATE_90 if rotate else None
transposed = ImageFont.TransposedFont(font, orientation=orientation) if orientation else font
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
glyph_mask = transposed.getmask(word, mode="L")
gw, gh = glyph_mask.size
if gw <= 0 or gh <= 0:
return None
# np.array avoids the intermediate bytes() copy that
# frombuffer(bytes(...)) would incur.
glyph_arr = np.array(glyph_mask, dtype=np.uint8).reshape(gh, gw)
pad = max(0, int(self.margin))
if pad:
padded = np.zeros((gh + 2 * pad, gw + 2 * pad), dtype=np.uint8)
padded[pad:pad + gh, pad:pad + gw] = glyph_arr
# Reserve a true inter-glyph margin while still allowing
# transparent corners and stroke gaps to interlock.
collision_arr = np.asarray(
Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)),
dtype=np.uint8,
)
stamp_arr = padded
else:
collision_arr = glyph_arr
stamp_arr = glyph_arr
result = (
collision_arr.shape[0],
collision_arr.shape[1],
collision_arr,
stamp_arr,
orientation,
int(bbox[0]),
int(bbox[1]),
pad,
)
glyph_cache[key] = result
return result
min_font = max(config.MIN_FONT_FLOOR, int(self.min_font_size))
max_font = max(min_font, int(self.max_font_size))
base_span = max_font - min_font
target_font_sizes = []
for score in score_by_index:
raw_size = self.min_font_size + base_span * score
f_size = max(config.MIN_FONT_FLOOR, int(round(raw_size * self.size_scale)))
raw_size = min_font + base_span * score
f_size = min(max_font, max(min_font, int(round(raw_size))))
target_font_sizes.append(f_size)
gap_fill_list = [] # 收集未成功放置的词,用于第二轮填充
random_large_prefix = max(1, int(math.ceil(len(layout_sequence) * 0.08)))
for idx, (word, _freq) in enumerate(layout_sequence):
font_size = target_font_sizes[idx]
if config.LIMIT_LARGE_FONTS and large_threshold is not None and large_limit is not None:
if font_size >= large_threshold and large_count >= large_limit:
font_size = max(self.min_font_size, int(large_threshold * config.LARGE_FONT_CAP_RATIO))
current_size = font_size
min_attempt_size = max(self.min_font_size, int(current_size * 0.4))
placed = False
rotate = rotation_flags[idx]
for try_rotate in (rotate, not rotate):
measured = measure_and_mask(word, font_size, try_rotate)
if measured is None:
continue
(
query_h,
query_w,
collision_arr,
stamp_arr,
orientation,
bbox_left,
bbox_top,
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
pos = self.grid.place_glyph_exact(
collision_arr,
stamp_arr,
query_h,
query_w,
query_seed,
256,
placement_mode,
)
while current_size >= min_attempt_size:
orientation = None
rotate = rotation_flags[idx]
if rotate:
orientation = Image.ROTATE_90
if pos is None:
continue
y, x = pos
ink_y = y + pad
ink_x = x + pad
draw_y = ink_y - bbox_top
draw_x = ink_x - bbox_left
color = pick_palette_color(score_by_index[idx])
self.layout_.append((word, font_size, (draw_y, draw_x), orientation, color))
placed = True
break
font = get_cached_font(self.font_path, current_size)
if orientation:
transposed = ImageFont.TransposedFont(font, orientation=orientation)
else:
transposed = font
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
w_text = bbox[2] - bbox[0]
h_text = bbox[3] - bbox[1]
query_w = w_text + self.margin
query_h = h_text + self.margin
pos = self.grid.query_direct(query_h, query_w, np.random.randint(0, 2**31))
if pos is not None:
y, x = pos
draw_y = y + self.margin // 2
draw_x = x + self.margin // 2
# Stamp glyph bitmap into C++ canvas for pixel-accurate collision
font = get_cached_font(self.font_path, current_size)
if orientation:
transposed = ImageFont.TransposedFont(font, orientation=orientation)
else:
transposed = font
glyph_mask = transposed.getmask(word, mode="L")
gw, gh = glyph_mask.size
glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw)
self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x)
color = pick_palette_color(score_by_index[idx])
self.layout_.append((word, current_size, (draw_y, draw_x), orientation, color))
if config.LIMIT_LARGE_FONTS and large_threshold is not None and current_size >= large_threshold:
large_count += 1
placed = True
break
current_size -= 2
if not placed:
gap_fill_list.append((word, score_by_index[idx]))
# ── Gap-filling pass: 用更小的字号填充剩余空隙 ──────────────
if gap_fill_list:
gap_font_size = max(config.MIN_FONT_FLOOR, int(self.min_font_size * 0.8))
if gap_font_size >= config.MIN_FONT_FLOOR:
placed_gap = 0
for word, score in gap_fill_list:
font = get_cached_font(self.font_path, gap_font_size)
bbox = _measure_draw.textbbox((0, 0), word, font=font)
w_text = bbox[2] - bbox[0]
h_text = bbox[3] - bbox[1]
query_w = w_text + self.margin
query_h = h_text + self.margin
pos = self.grid.query_direct(query_h, query_w, np.random.randint(0, 2**31))
if pos is not None:
y, x = pos
draw_y = y + self.margin // 2
draw_x = x + self.margin // 2
glyph_mask = font.getmask(word, mode="L")
gw, gh = glyph_mask.size
glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw)
self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x)
color = pick_palette_color(score)
self.layout_.append((word, gap_font_size, (draw_y, draw_x), None, color))
placed_gap += 1
if placed_gap > 0:
config._warn(f"Gap-filling: 用小字号 {gap_font_size} 额外放置了 {placed_gap}/{len(gap_fill_list)} 个词")
# 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.
return self
@@ -287,6 +384,57 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
draw.text((x, y), word, font=font, fill=color)
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 = {}
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
def export_svgs(self, fill_filename, stroke_color="#000000", stroke_width=1.0):
"""Write fill + stroke SVG in one pass (path geometry built once)."""
stroke_filename = str(
Path(fill_filename).with_name(Path(fill_filename).stem + "_stroke" + Path(fill_filename).suffix)
)
background = self.background_color
header = (
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
with open(fill_filename, "w", encoding="utf-8") as ff, open(stroke_filename, "w", encoding="utf-8") as sf:
ff.write(header)
sf.write(header)
ff.write(f'<rect width="100%" height="100%" fill="{background}"/>\n')
sf.write('<rect width="100%" height="100%" fill="none"/>\n')
for path_d, tx, ty, color in self._iter_svg_paths():
transform = f'translate({tx:.3f} {ty:.3f}) scale(1 -1)'
ff.write(f'<path d="{path_d}" transform="{transform}" fill="{color}"/>\n')
sf.write(
f'<path d="{path_d}" transform="{transform}" '
f'fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}" '
f'stroke-linejoin="round" stroke-linecap="round"/>\n'
)
ff.write("</svg>\n")
sf.write("</svg>\n")
return stroke_filename
def to_svg(self, filename):
background = self.background_color
with open(filename, "w", encoding="utf-8") as f:
@@ -295,15 +443,8 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="{background}"/>\n')
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)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
continue
f.write(f'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{color}"/>\n')
for path_d, tx, ty, color in self._iter_svg_paths():
f.write(f'<path d="{path_d}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{color}"/>\n')
f.write("</svg>\n")
def to_svg_stroke(self, filename, stroke_color="#000000", stroke_width=1.0):
@@ -313,20 +454,13 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="none"/>\n')
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)
except Exception as exc:
config._warn(f"SVG stroke path 导出失败,跳过词条: {word}, error={exc}")
continue
f.write('<rect width="100%" height="100%" fill="none"/>\n')
for path_d, tx, ty, _color in self._iter_svg_paths():
f.write(
f'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" '
f'<path d="{path_d}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" '
f'fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}" '
f'stroke-linejoin="round" stroke-linecap="round"/>\n'
)
f.write("</svg>\n")
def to_svg_dotfill(self, filename, dot_spacing=10, dot_radius=2, dot_color="#000000"):
@@ -471,18 +605,6 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
f.write(' </clipPath>\n')
def build_svg_text_path(word, size, x, y, font_path, orient):
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()
tx = x - bbox.xmin
ty = y + bbox.ymax
# 返回变换后的 Path(已定位到画布坐标)以及 SVG 用的偏移量
transformed = path.transformed(Affine2D().scale(1, -1).translate(tx, ty))
return mpl_path_to_svg_d(path), tx, ty, transformed
def mpl_path_to_svg_d(path):
parts = []
for vertices, code in path.iter_segments():
@@ -557,4 +679,3 @@ def render_path_occupancy(layout_data, canvas_shape, font_path):
# 最终蒙版:文字笔画=1,外部和字内空洞=0
return (occ_raw & (~outside).astype(np.uint8)).astype(np.uint8)
+11 -7
View File
@@ -40,15 +40,12 @@ def normalize_mask_for_fill(mask):
def calculate_dynamic_dimensions(base_w, base_h, num_words, avg_len=3, mask_stats=None):
if not config.AUTO_EXPAND_CANVAS:
return base_w, base_h
effective_fill = config.TARGET_FILL_RATIO if config.TARGET_FILL_RATIO > 0 else max(config.MIN_ACCEPT_FILL_RATIO, 0.82)
effective_fill = config.TARGET_FILL_RATIO if config.TARGET_FILL_RATIO > 0 else 0.45
mask_fill_ratio = 0.5
if mask_stats is not None:
mask_fill_ratio = max(0.05, mask_stats["free_ratio"])
area_per_word = (config.MIN_READABLE_HEIGHT_PX ** 2) * max(1.0, avg_len) * 1.2
area_per_word = (config.MIN_READABLE_HEIGHT_PX ** 2) * max(1.0, avg_len) * 1.05
required_fillable_area = (num_words * area_per_word * max(1, config.N_REPETITIONS)) / max(effective_fill, 0.1)
required_canvas_area = required_fillable_area / mask_fill_ratio
current_area = base_w * base_h
@@ -56,8 +53,15 @@ def calculate_dynamic_dimensions(base_w, base_h, num_words, avg_len=3, mask_stat
scale_factor = (required_canvas_area / current_area) ** 0.5
new_w = int(base_w * scale_factor)
new_h = int(base_h * scale_factor)
new_w = ((new_w // 100) + 1) * 100
new_h = ((new_h // 100) + 1) * 100
# Cap HD canvas to keep render/export under the speed budget.
# 6000px 边长对激光/打印足够,再大收益很小但 SVG/PNG 成本陡增。
max_edge = 6000
if max(new_w, new_h) > max_edge:
s = max_edge / max(new_w, new_h)
new_w = int(new_w * s)
new_h = int(new_h * s)
new_w = max(100, ((new_w // 100) + 1) * 100)
new_h = max(100, ((new_h // 100) + 1) * 100)
print(f"[Auto-Size] 扩展画布: {base_w}x{base_h} -> {new_w}x{new_h}")
return new_w, new_h
return base_w, base_h
+463 -213
View File
@@ -1,4 +1,5 @@
import logging
import math
import os
import sqlite3
import sys
@@ -13,13 +14,32 @@ from . import config
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 apply_dot_matrix, compute_fill_ratio_fast
from .weights import calculate_font_by_area_model, extract_weights_from_df, get_stroke_complexity_batch
from .render import (
compute_fill_ratio_fast,
count_layout_overlap_pixels,
largest_empty_square_size,
refine_layout_with_hd_clearance,
scale_layout_for_hd,
)
from .weights import (
calculate_font_by_area_model,
extract_weights_from_df,
get_stroke_complexity_batch,
merge_weight_maps,
)
log = logging.getLogger("core.pipeline")
def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real_hd_w, real_hd_h):
def run_generation_pass(
names,
frequencies_data,
name_weights_map,
mask_hd,
real_hd_w,
real_hd_h,
_collision_margin=0,
):
log.info("[run_generation_pass] 开始")
log.info(" 输入: %d 词 | HD尺寸: %dx%d", len(names), real_hd_w, real_hd_h)
@@ -42,44 +62,73 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real
print(f"最终输出: {real_hd_w}x{real_hd_h} | 运算网格: {w_small}x{h_small}")
current_min_font = max(config.MIN_FONT_FLOOR, int(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE))
readable_min_font = max(
config.MIN_FONT_FLOOR,
int(math.ceil(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE)),
)
total_target = len(names) * config.N_REPETITIONS
current_packing_eff = config.PACKING_EFFICIENCY
grow_step = config.GROW_FONT_STEP if config.GROW_FONT_ON_LOW_FILL else 1.0
final_wc = None
final_scale = 1.0
base_min_font = current_min_font
base_max_font = current_min_font + 1
base_layout_seed = config.LAYOUT_SEED if config.LAYOUT_SEED is not None else config.SEED
final_layout_seed = base_layout_seed
base_min_font, base_max_font = calculate_font_by_area_model(
mask_small,
names,
name_weights_map,
config.TARGET_FILL_RATIO,
config.SIZE_RATIO,
config.PACKING_EFFICIENCY,
config.N_REPETITIONS,
)
def compute_font_bounds(packing_eff):
min_font, max_font = calculate_font_by_area_model(
mask_small, names, name_weights_map, config.TARGET_FILL_RATIO, config.SIZE_RATIO, packing_eff, config.N_REPETITIONS
)
min_font = max(current_min_font, min_font)
explicit_min_font = config.USER_MIN_FONT_SIZE is not None
hard_min_font = readable_min_font
if explicit_min_font:
hard_min_font = max(config.MIN_FONT_FLOOR, int(round(config.USER_MIN_FONT_SIZE)))
if config.USER_MIN_FONT_SIZE is not None:
user_min = int(config.USER_MIN_FONT_SIZE)
if user_min < config.MIN_FONT_FLOOR:
config._warn(f"USER_MIN_FONT_SIZE={config.USER_MIN_FONT_SIZE} 过小,提升到 {config.MIN_FONT_FLOOR}")
user_min = config.MIN_FONT_FLOOR
min_font = user_min
hard_max_font = None
if config.USER_MAX_FONT_SIZE is not None:
hard_max_font = max(config.MIN_FONT_FLOOR, int(round(config.USER_MAX_FONT_SIZE)))
if explicit_min_font and hard_max_font < hard_min_font:
raise ValueError(
f"字号硬约束冲突: USER_MAX_FONT_SIZE={hard_max_font} "
f"小于最小允许字号 {hard_min_font}"
)
if not explicit_min_font:
# A user-specified maximum outranks the automatic readability
# suggestion. It remains an exact ceiling rather than causing an
# artificial conflict with a value the user never requested.
hard_min_font = min(hard_min_font, hard_max_font)
if config.USER_MAX_FONT_SIZE is not None:
user_max = int(config.USER_MAX_FONT_SIZE)
if user_max < config.MIN_FONT_FLOOR:
config._warn(f"USER_MAX_FONT_SIZE={config.USER_MAX_FONT_SIZE} 过小,提升到 {config.MIN_FONT_FLOOR}")
user_max = config.MIN_FONT_FLOOR
max_font = user_max
equal_size_mode = math.isclose(float(config.SIZE_RATIO), 1.0, rel_tol=0.0, abs_tol=1e-9)
base_min_font = max(hard_min_font, int(base_min_font))
base_max_font = max(base_min_font, int(base_max_font))
if hard_max_font is not None:
base_min_font = min(base_min_font, hard_max_font)
base_max_font = min(base_max_font, hard_max_font)
if equal_size_mode:
# Keep one scalar throughout every retry. This is what makes
# SIZE_RATIO=1 exact even after automatic batch scaling.
equal_font = min(base_min_font, base_max_font)
base_min_font = equal_font
base_max_font = equal_font
if max_font <= min_font:
config._warn(f"字号区间无效: min={min_font}, max={max_font},自动修正 max=min+1")
max_font = min_font + 1
def scaled_bounds(scale):
if equal_size_mode:
size = max(hard_min_font, int(round(base_min_font * scale)))
if hard_max_font is not None:
size = min(size, hard_max_font)
return size, size
return min_font, max_font
min_font = max(hard_min_font, int(round(base_min_font * scale)))
max_font = max(min_font, int(round(base_max_font * scale)))
if hard_max_font is not None:
min_font = min(min_font, hard_max_font)
max_font = min(max_font, hard_max_font)
return min_font, max(min_font, max_font)
def try_place(min_font, max_font, large_ratio=config.LARGE_FONT_LIMIT_RATIO, size_scale=1.0):
min_font = max(config.MIN_FONT_FLOOR, int(min_font))
max_font = max(min_font + 1, int(max_font))
def try_place(scale, layout_seed=base_layout_seed):
min_font, max_font = scaled_bounds(scale)
wc = OptimizedEfficientWordCloud(
width=w_small,
height=h_small,
@@ -89,108 +138,77 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real
min_font_size=min_font,
max_font_size=max_font,
background_color=config.get_output_background(),
use_spiral_search=True,
large_font_ratio=large_ratio,
size_scale=size_scale,
prefer_horizontal=0.82,
# 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,
)
if config.ENABLE_STRATIFIED_SAMPLING:
wc.grid.reorder_stratified(config.STRATIFIED_BANDS)
wc.layout_seed = layout_seed
wc.generate_from_frequencies(frequencies_data)
return wc, len(wc.layout_)
return wc, len(wc.layout_), min_font, max_font
print(f"--- 5. 启动生成 (目标: {total_target} 词) ---")
log.info("--- 5. 启动生成 ---")
log.info(" 目标词数: %d (names=%d * N_REPETITIONS=%d)", total_target, len(names), config.N_REPETITIONS)
log.info(" 当前最小字号: %d, 效率: %.2f", current_min_font, current_packing_eff)
for attempt in range(1, config.MAX_ATTEMPTS + 1):
base_min_font, base_max_font = compute_font_bounds(current_packing_eff)
print(f"尝试 #{attempt}: 基准字号 [{base_min_font}, {base_max_font}], 效率: {current_packing_eff:.2f}")
log.info("[尝试 #%d] 字号区间: [%d, %d], 效率: %.2f, 大字率: %.2f",
attempt, base_min_font, base_max_font, current_packing_eff, config.LARGE_FONT_LIMIT_RATIO)
log.info(
" 基准字号: [%d, %d], 硬边界: [%d, %s], 等字号=%s",
base_min_font,
base_max_font,
hard_min_font,
hard_max_font if hard_max_font is not None else "",
equal_size_mode,
)
best_wc = None
best_count = 0
best_scale = config.FONT_SCALE_MIN
best_success_wc = None
best_success_scale = None
current_large_ratio = config.LARGE_FONT_LIMIT_RATIO
low_scale = max(config.SCALE_FLOOR, config.FONT_SCALE_MIN)
high_scale = max(low_scale + 0.01, config.FONT_SCALE_MAX)
for _ in range(max(1, config.SCALE_SEARCH_ROUNDS)):
mid_scale = ((low_scale + high_scale) / 2) * grow_step
wc, placed_count = try_place(base_min_font, base_max_font, current_large_ratio, mid_scale)
print(f" 尺度 {mid_scale:.3f} (字号 {base_min_font}-{base_max_font}) -> 成功: {placed_count}/{total_target}")
log.info(" 尺度 %.3f -> 放置 %d/%d", mid_scale, placed_count, total_target)
if placed_count > best_count:
best_wc = wc
best_count = placed_count
best_scale = mid_scale
if config.REQUIRE_ALL_WORDS:
if placed_count >= total_target:
best_success_wc = wc
best_success_scale = mid_scale
low_scale = max(low_scale, mid_scale / max(grow_step, 1e-6))
else:
high_scale = min(high_scale, mid_scale / max(grow_step, 1e-6))
else:
if placed_count >= best_count:
low_scale = max(low_scale, mid_scale / max(grow_step, 1e-6))
else:
high_scale = min(high_scale, mid_scale / max(grow_step, 1e-6))
if abs(high_scale - low_scale) < 0.02:
break
if best_success_wc is not None:
final_wc = best_success_wc
final_scale = best_success_scale if best_success_scale is not None else best_scale
# Every attempt is a fresh, whole-cloud layout. No word may silently
# receive a smaller fallback size. Usually the area model succeeds on
# attempt one; two adaptive retries cover fragmentation-heavy masks.
best_wc = None
best_count = 0
best_scale = 1.0
scale = 1.0
tried_layouts = set()
failed_scales = []
for attempt in range(1, 4):
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)
print(
f" 整批布局 #{attempt}: scale={scale:.3f}, "
f"字号=[{min_font}, {max_font}] -> {placed_count}/{total_target}"
)
log.info(
" 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d",
attempt,
scale,
min_font,
max_font,
placed_count,
total_target,
)
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
break
if best_wc is not None:
shrink_min = current_min_font
for _ in range(config.AUTO_SHRINK_ROUNDS):
shrink_min = max(config.MIN_FONT_FLOOR, int(shrink_min * 0.8))
if shrink_min >= base_min_font:
continue
failed_scales.append(scale)
print(f" [降级:缩小字号] {shrink_min}...")
wc, placed_count = try_place(shrink_min, base_max_font, current_large_ratio, best_scale)
if placed_count > best_count:
best_wc = wc
best_count = placed_count
best_scale = best_scale
if config.REQUIRE_ALL_WORDS and placed_count >= total_target:
final_wc = wc
final_scale = best_scale
break
if final_wc is not None:
break
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))
scale *= shrink
if config.ENABLE_SMART_LARGE_FONT_REDUCTION:
print(" [降级:牺牲大字] 仍然放不下,尝试减少大字数量...")
strict_large_ratio = 0.05
retry_min = shrink_min if 'shrink_min' in locals() else current_min_font
wc, placed_count = try_place(retry_min, base_max_font, strict_large_ratio, best_scale)
print(f" [严格模式] 大字率 {strict_large_ratio} -> 成功: {placed_count}/{total_target}")
if placed_count > best_count:
best_wc = wc
best_count = placed_count
if config.REQUIRE_ALL_WORDS and placed_count >= total_target:
final_wc = wc
final_scale = best_scale
break
if attempt == config.MAX_ATTEMPTS:
final_wc = best_wc
final_scale = best_scale
break
shrink_ratio = (best_count / total_target) if best_count else 0.5
current_packing_eff *= min(0.95, max(0.5, shrink_ratio))
if final_wc is None:
final_wc = best_wc
final_scale = best_scale
if final_wc is None:
return {
@@ -203,61 +221,263 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real
"base_max_font": base_max_font,
"mask_small": mask_small,
"size_scale": final_scale,
"layout_seed": final_layout_seed,
"collision_margin": _collision_margin,
"hd_overlap_pixels": 0,
"hd_layout": [],
"hd_clearance": None,
}
fill_ratio, occ_fast = compute_fill_ratio_fast(final_wc.layout_, mask_small, config.WC_FONT_PATH)
largest_empty_square = largest_empty_square_size(occ_fast, mask_small)
def has_character_sized_hole(wc, hole_size):
if not equal_size_mode or wc is None or not wc.layout_:
return False
font_size = int(wc.layout_[0][1])
return hole_size >= max(2, int(math.ceil(font_size * 1.25)))
complete_candidates = []
if len(final_wc.layout_) >= total_target:
complete_candidates.append(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
)
print(f"填充率: {fill_ratio:.3f}")
log.info("[填充率] 初始填充率: %.4f (最低要求: %.4f)", fill_ratio, config.MIN_ACCEPT_FILL_RATIO)
log.info("[填充率] 初始填充率: %.4f", fill_ratio)
log.info(" layout_ 词数: %d", len(final_wc.layout_))
if config.SAVE_DEBUG_IMAGES and occ_fast is not None:
debug_dir = Path(config.DEBUG_OUTPUT_DIR)
debug_dir.mkdir(parents=True, exist_ok=True)
Image.fromarray((occ_fast * 255).astype(np.uint8)).save(str(debug_dir / "occ_fast.png"))
if fill_ratio < config.MIN_ACCEPT_FILL_RATIO:
print(f"[填充率不足] {fill_ratio:.3f} < {config.MIN_ACCEPT_FILL_RATIO:.2f},启动二分放大字号重试...")
low_scale = max(final_scale, 1.0)
high_scale = max(low_scale, config.FILL_RETRY_MAX_SCALE)
retry_round = 0
best_wc = final_wc
best_fill = fill_ratio
# 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.
if (
len(final_wc.layout_) >= total_target
and fill_ratio > 0
and (
fill_ratio < config.TARGET_FILL_RATIO * 0.98
or has_character_sized_hole(final_wc, largest_empty_square)
)
):
upper_scale = min(
(failed for failed in failed_scales if failed > final_scale),
default=None,
)
for density_attempt in range(1, 5):
if upper_scale is not None:
grow_scale = (final_scale + upper_scale) / 2.0
elif equal_size_mode:
current_size, _ = scaled_bounds(final_scale)
grow_scale = (current_size + 1) / max(1, base_min_font)
else:
desired_growth = min(
1.12,
math.sqrt(config.TARGET_FILL_RATIO / fill_ratio) * 0.98,
)
if desired_growth <= 1.005:
break
grow_scale = final_scale * desired_growth
while retry_round < config.FILL_RETRY_MAX_ROUNDS:
mid_scale = (low_scale + high_scale) / 2
retry_large_ratio = 1.0 if config.FILL_RETRY_RELAX_LARGE_CAP else config.LARGE_FONT_LIMIT_RATIO
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
wc, _placed_count = try_place(base_min_font, base_max_font, retry_large_ratio, mid_scale)
new_fill, occ_fast = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH)
print(f" [二分重试#{retry_round + 1}] scale={mid_scale:.3f} 填充率={new_fill:.3f}")
if new_fill > best_fill:
best_fill = new_fill
best_wc = wc
if new_fill >= config.MIN_ACCEPT_FILL_RATIO:
final_wc = wc
final_scale = mid_scale
fill_ratio = new_fill
if config.SAVE_DEBUG_IMAGES and occ_fast is not None:
debug_dir = Path(config.DEBUG_OUTPUT_DIR)
debug_dir.mkdir(parents=True, exist_ok=True)
Image.fromarray((occ_fast * 255).astype(np.uint8)).save(
str(debug_dir / f"occ_fast_retry_{retry_round + 1}.png")
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
print(
f" 密度优化 #{density_attempt} 整批重排: "
f"seed={candidate_seed}, 字号=[{grow_min}, {grow_max}] -> "
f"{retry_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
new_fill, new_occ = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH)
if new_fill <= fill_ratio:
break
final_wc = wc
final_scale = grow_scale
final_layout_seed = selected_seed
fill_ratio = new_fill
occ_fast = new_occ
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)
)
if (
fill_ratio >= config.TARGET_FILL_RATIO * 0.98
and not has_character_sized_hole(final_wc, largest_empty_square)
):
break
if new_fill > fill_ratio:
low_scale = mid_scale
else:
high_scale = mid_scale
# At the largest complete equal-size tier, compare a small deterministic
# set of whole-cloud reorderings and retain the one with the smallest
# character-scale void. This never changes an individual font size.
if (
equal_size_mode
and len(final_wc.layout_) >= total_target
and has_character_sized_hole(final_wc, largest_empty_square)
and base_layout_seed is not None
):
if total_target < 100:
hole_attempt_budget = 3
elif total_target <= 300:
hole_attempt_budget = 2
else:
hole_attempt_budget = 1
modulus = 2**31 - 1
for hole_attempt in range(1, hole_attempt_budget + 1):
candidate_seed = (
int(base_layout_seed) ^ ((0x9E3779B9 * hole_attempt) & 0x7FFFFFFF)
) % modulus
bounds = scaled_bounds(final_scale)
layout_key = (bounds, candidate_seed)
if layout_key in tried_layouts:
continue
tried_layouts.add(layout_key)
candidate_wc, placed_count, _, _ = try_place(final_scale, candidate_seed)
if placed_count < total_target:
continue
candidate_fill, candidate_occ = compute_fill_ratio_fast(
candidate_wc.layout_, mask_small, config.WC_FONT_PATH
)
candidate_hole = largest_empty_square_size(candidate_occ, mask_small)
print(
f" 空洞优化 #{hole_attempt}: seed={candidate_seed}, "
f"最大空白={candidate_hole}px -> {placed_count}/{total_target}"
)
if candidate_hole >= largest_empty_square:
continue
final_wc = candidate_wc
final_layout_seed = candidate_seed
fill_ratio = candidate_fill
occ_fast = candidate_occ
largest_empty_square = candidate_hole
complete_candidates.append(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
)
retry_round += 1
hd_layout = None
hd_clearance = None
raw_hd_layout = []
for candidate_index, candidate in enumerate(reversed(complete_candidates), start=1):
candidate_wc, candidate_scale, candidate_seed, candidate_fill, candidate_occ = candidate
raw_hd_layout = scale_layout_for_hd(candidate_wc.layout_, config.WORK_SCALE)
refined_layout = None
clearance_stats = None
clearance_modes = (1, 0)
if total_target >= 100 and candidate_index > 1:
clearance_modes = (0,)
for clearance_px in clearance_modes:
priority_layout = raw_hd_layout
priority_restarts = 0
max_priority_attempts = 2 if clearance_px == 0 or total_target < 100 else 1
for priority_attempt in range(max_priority_attempts):
refined_layout, clearance_stats = refine_layout_with_hd_clearance(
priority_layout,
mask_hd,
config.WC_FONT_PATH,
clearance=clearance_px,
)
if refined_layout is not None:
break
failed_word = clearance_stats["failed_word"]
failed_index = next(
(index for index, item in enumerate(priority_layout) if item[0] == failed_word),
None,
)
if (
failed_index is None
or failed_index == 0
or priority_attempt + 1 >= max_priority_attempts
):
break
failed_item = priority_layout[failed_index]
priority_layout = [failed_item, *priority_layout[:failed_index], *priority_layout[failed_index + 1:]]
priority_restarts += 1
if fill_ratio < config.MIN_ACCEPT_FILL_RATIO:
final_wc = best_wc
fill_ratio = best_fill
if refined_layout is not None:
clearance_stats["clearance_px"] = clearance_px
clearance_stats["priority_restarts"] = priority_restarts
break
clearance_stats["clearance_px"] = clearance_px
clearance_stats["priority_restarts"] = priority_restarts
if refined_layout is None:
log.warning(
"高清候选 #%d 隔离精修失败: word=%s, shifted=%d, max_shift=%d",
candidate_index,
clearance_stats["failed_word"],
clearance_stats["shifted_words"],
clearance_stats["max_shift"],
)
hd_clearance = clearance_stats
continue
print(f"最终填充率: {fill_ratio:.3f}")
final_wc = candidate_wc
final_scale = candidate_scale
final_layout_seed = candidate_seed
fill_ratio = candidate_fill
occ_fast = candidate_occ
largest_empty_square = largest_empty_square_size(occ_fast, mask_small)
hd_layout = refined_layout
hd_clearance = clearance_stats
break
if hd_layout is None:
hd_overlap_pixels = -1
hd_layout = raw_hd_layout
if hd_clearance is None:
hd_clearance = {
"shifted_words": 0,
"max_shift": 0,
"clearance_px": None,
"priority_restarts": 0,
"failed_word": None,
}
else:
hd_overlap_pixels = count_layout_overlap_pixels(
hd_layout,
(real_hd_h, real_hd_w),
config.WC_FONT_PATH,
)
print(
f"最终填充率: {fill_ratio:.3f} | 高清重叠像素: {hd_overlap_pixels} | "
f"精修位移: {hd_clearance['shifted_words']} 词, 最大 {hd_clearance['max_shift']}px | "
f"隔离带: {hd_clearance['clearance_px']}px"
)
return {
"wc": final_wc,
"fill_ratio": fill_ratio,
@@ -268,6 +488,17 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real
"base_max_font": base_max_font,
"mask_small": mask_small,
"size_scale": final_scale,
"layout_seed": final_layout_seed,
"collision_margin": _collision_margin,
"hd_overlap_pixels": hd_overlap_pixels,
"hd_layout": hd_layout,
"hd_clearance": hd_clearance,
"largest_empty_square_work_px": largest_empty_square,
"largest_empty_square_font_ratio": (
largest_empty_square / max(1, int(final_wc.layout_[0][1]))
if final_wc.layout_ and equal_size_mode
else None
),
}
@@ -317,6 +548,7 @@ def main():
log.info(" 平均名字长度: %.2f 字符", avg_len)
log.info(" BASE_HD: %dx%d", config.BASE_HD_WIDTH, config.BASE_HD_HEIGHT)
# 只生成一次掩膜:先 probe 尺寸,再按需扩展后复用(避免二次 LANCZOS)
probe_mask_hd, (probe_w, probe_h), _ = prepare_mask(config.BASE_HD_WIDTH, config.BASE_HD_HEIGHT)
probe_stats = analyze_mask(probe_mask_hd)
log.info(" Probe mask: %dx%d, free_ratio=%.4f, bbox_fill_ratio=%.4f",
@@ -330,8 +562,12 @@ def main():
print("--- 3. 生成掩膜 (High Quality & Edge Fix) ---")
log.info("--- 阶段3: 生成掩膜 ---")
mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(hd_w, hd_h)
mask_stats = analyze_mask(mask_hd)
if (hd_w, hd_h) == (probe_w, probe_h):
mask_hd, real_hd_w, real_hd_h = probe_mask_hd, probe_w, probe_h
mask_stats = probe_stats
else:
mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(hd_w, hd_h)
mask_stats = analyze_mask(mask_hd)
log.info(" mask_hd: %dx%d", real_hd_w, real_hd_h)
log.info(" free_area=%d, free_ratio=%.6f", mask_stats['free_area'], mask_stats['free_ratio'])
log.info(" bbox_fill_ratio=%.6f", mask_stats['bbox_fill_ratio'])
@@ -371,8 +607,11 @@ def main():
print(f"Excel 权重不可用,已回退{fallback}")
log.info(" Excel 权重不可用,已回退%s", fallback)
name_weights_map = dict(stroke_weights_map)
name_weights_map.update(excel_weights_map)
name_weights_map = merge_weight_maps(
names,
stroke_weights_map if config.ENABLE_STROKE_WEIGHTS else {},
excel_weights_map,
)
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
@@ -389,21 +628,35 @@ def main():
real_hd_h,
)
if generation_result["wc"] is None:
if canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS:
print("生成失败:未找到合适布局")
log.error("生成失败:未找到合适布局 (已重试 %d 轮)", canvas_retry_round)
wc = generation_result.get("wc")
placed = len(wc.layout_) if wc is not None else 0
target = len(names) * config.N_REPETITIONS
# 名单完整性是硬约束。填充率优化不得以漏掉姓名为代价。
hd_overlap_pixels = int(generation_result.get("hd_overlap_pixels", 0))
placement_ok = wc is not None and placed >= target and hd_overlap_pixels == 0
if placement_ok or canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS:
if not placement_ok:
print(
f"生成失败:放置 {placed}/{target},高清重叠像素 {hd_overlap_pixels}"
"未满足完整名单与零碰撞约束"
)
log.error(
"生成失败:放置 %d/%d, 高清重叠像素=%d (已重试 %d 轮)",
placed,
target,
hd_overlap_pixels,
canvas_retry_round,
)
sys.exit(1)
log.warning(" 本轮生成失败 (wc=None), 将重试")
elif generation_result["fill_ratio"] >= config.MIN_ACCEPT_FILL_RATIO or canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS:
log.info(" 生成成功! fill_ratio=%.4f (要求>=%.4f), 重试轮次=%d",
generation_result['fill_ratio'], config.MIN_ACCEPT_FILL_RATIO, canvas_retry_round)
log.info(" 生成完成 placed=%d/%d fill=%.4f retry=%d",
placed, target, generation_result["fill_ratio"], canvas_retry_round)
break
canvas_retry_round += 1
next_w = int(real_hd_w * config.CANVAS_RETRY_GROWTH)
next_h = int(real_hd_h * config.CANVAS_RETRY_GROWTH)
print(f"[画布重试#{canvas_retry_round}] {real_hd_w}x{real_hd_h} -> {next_w}x{next_h}")
print(f"[画布重试#{canvas_retry_round}] {real_hd_w}x{real_hd_h} -> {next_w}x{next_h} (放置 {placed}/{target})")
log.info("[画布重试#%d] %dx%d -> %dx%d (growth=%.2f)",
canvas_retry_round, real_hd_w, real_hd_h, next_w, next_h, config.CANVAS_RETRY_GROWTH)
mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(next_w, next_h)
@@ -419,12 +672,7 @@ def main():
print("--- 6. 高清渲染 ---")
log.info("--- 阶段6: 高清渲染 ---")
t_render = time.time()
hd_layout = []
for text, size, (y, x), orient, color in final_wc.layout_:
hd_size = int(size / config.WORK_SCALE)
hd_y = int(y / config.WORK_SCALE)
hd_x = int(x / config.WORK_SCALE)
hd_layout.append((text, hd_size, (hd_y, hd_x), orient, color))
hd_layout = generation_result["hd_layout"]
log.info(" HD layout 词数: %d", len(hd_layout))
log.info(" HD 画布: %dx%d", real_hd_w, real_hd_h)
@@ -439,26 +687,17 @@ def main():
final_wc.height = real_hd_h
base_img = final_wc.to_image().convert("RGB")
if config.ENABLE_DOT_MATRIX:
base_img = apply_dot_matrix(base_img, mask_hd)
base_img.save(config.OUTPUT_PNG)
# 更快的 PNG 写出(压缩等级 1,视觉无损)
base_img.save(config.OUTPUT_PNG, compress_level=1)
print(f"已保存: {config.OUTPUT_PNG}")
log.info(" PNG 已保存: %s (%.2f MB)", config.OUTPUT_PNG,
Path(config.OUTPUT_PNG).stat().st_size / 1024 / 1024 if Path(config.OUTPUT_PNG).exists() else 0)
final_wc.to_svg(config.OUTPUT_SVG)
# 一次构建路径,同时写出 fill / stroke 两份 SVG
stroke_svg = final_wc.export_svgs(config.OUTPUT_SVG)
print(f"已保存: {config.OUTPUT_SVG}")
log.info(" SVG 已保存: %s (%.2f MB)", config.OUTPUT_SVG,
Path(config.OUTPUT_SVG).stat().st_size / 1024 / 1024 if Path(config.OUTPUT_SVG).exists() else 0)
# 描边版 SVG(激光雕刻用)
stroke_svg = str(Path(config.OUTPUT_SVG).with_name(
Path(config.OUTPUT_SVG).stem + "_stroke" + Path(config.OUTPUT_SVG).suffix
))
final_wc.to_svg_stroke(stroke_svg)
print(f"已保存: {stroke_svg}")
log.info(" SVG(stroke) 已保存: %s (%.2f MB)", stroke_svg,
Path(stroke_svg).stat().st_size / 1024 / 1024 if Path(stroke_svg).exists() else 0)
log.info(" SVG 已保存: %s", config.OUTPUT_SVG)
log.info(" SVG(stroke) 已保存: %s", stroke_svg)
log.info(" 渲染耗时: %.2fs", time.time() - t_render)
log.info("--- 阶段7: 写入数据库 ---")
@@ -482,27 +721,35 @@ def main():
box_height INTEGER
)
""")
bbox_canvas = Image.new("L", (1, 1), 0)
bbox_draw = ImageDraw.Draw(bbox_canvas)
# Cache full font bearings as well as dimensions so search highlights
# match the actual PIL-rendered glyph position.
bbox_cache = {}
_measure = ImageDraw.Draw(Image.new("L", (1, 1)))
db_data = []
for name, font_size, (y, x), orient, color in final_wc.layout_:
font = get_cached_font(config.WC_FONT_PATH, max(1, int(font_size)))
orientation = "vertical" if orient else "horizontal"
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bbox = bbox_draw.textbbox((x, y), name, font=font)
key = (name, int(font_size), bool(orient))
box = bbox_cache.get(key)
if box is None:
font = get_cached_font(config.WC_FONT_PATH, max(1, int(font_size)))
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bb = _measure.textbbox((0, 0), name, font=font)
box = (bb[0], bb[1], bb[2] - bb[0], bb[3] - bb[1])
bbox_cache[key] = box
bx, by, bw, bh = box
db_data.append(
(
name,
x,
y,
x + bx,
y + by,
font_size,
color,
orientation,
bbox[0],
bbox[1],
bbox[2] - bbox[0],
bbox[3] - bbox[1],
x,
y,
bw,
bh,
)
)
cursor.executemany(
@@ -525,11 +772,18 @@ def main():
placed_count = len(final_wc.layout_)
metrics = {
"seed": config.SEED,
"layout_order_mode": config.LAYOUT_ORDER_MODE,
"layout_seed": config.LAYOUT_SEED,
"layout_seed": generation_result.get("layout_seed", config.LAYOUT_SEED),
"input_count": input_count,
"placed_count": placed_count,
"fill_ratio": fill_ratio,
"largest_empty_square_work_px": int(
generation_result.get("largest_empty_square_work_px", 0)
),
"largest_empty_square_font_ratio": generation_result.get(
"largest_empty_square_font_ratio"
),
"hd_overlap_pixels": int(generation_result.get("hd_overlap_pixels", -1)),
"hd_clearance": generation_result.get("hd_clearance"),
"elapsed_seconds": round(elapsed, 4),
"font_info": {
"layout_font_path": config.WC_FONT_PATH,
@@ -563,12 +817,8 @@ def main():
"output_dir": config.OUTPUT_DIR,
"output_prefix": config.OUTPUT_PREFIX,
"min_font_size": config.MIN_FONT_SIZE,
"max_attempts": config.MAX_ATTEMPTS,
"fill_on": config.FILL_ON,
"min_accept_fill_ratio": config.MIN_ACCEPT_FILL_RATIO,
"require_all_words": config.REQUIRE_ALL_WORDS,
"layout_order_mode": config.LAYOUT_ORDER_MODE,
"layout_seed": config.LAYOUT_SEED,
"layout_seed": generation_result.get("layout_seed", config.LAYOUT_SEED),
}
}
config.write_metrics(metrics)
+172 -18
View File
@@ -1,10 +1,157 @@
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from . import config
from .fonts import get_cached_font
def scale_layout_for_hd(layout, work_scale):
if work_scale <= 0:
raise ValueError("WORK_SCALE must be greater than zero")
return [
(
text,
max(1, int(size / work_scale)),
(int(y / work_scale), int(x / work_scale)),
orient,
color,
)
for text, size, (y, x), orient, color in layout
]
def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0):
"""Count final rendered pixels occupied by more than one layout entry."""
height, width = mask_shape
occupied = np.zeros((height, width), dtype=bool)
overlaps = np.zeros((height, width), dtype=bool)
measure = ImageDraw.Draw(Image.new("L", (1, 1)))
for word, size, (y, x), orient, _color in layout:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bbox = measure.textbbox((0, 0), word, font=font)
glyph = font.getmask(word, mode="L")
glyph_width, glyph_height = glyph.size
if glyph_width <= 0 or glyph_height <= 0:
continue
ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) > alpha_threshold
ink_x = int(x) + int(bbox[0])
ink_y = int(y) + int(bbox[1])
x0 = max(0, ink_x)
y0 = max(0, ink_y)
x1 = min(width, ink_x + glyph_width)
y1 = min(height, ink_y + glyph_height)
if x0 >= x1 or y0 >= y1:
continue
visible_ink = ink[y0 - ink_y:y1 - ink_y, x0 - ink_x:x1 - ink_x]
occupied_region = occupied[y0:y1, x0:x1]
overlaps[y0:y1, x0:x1] |= occupied_region & visible_ink
occupied_region |= visible_ink
return int(overlaps.sum())
def refine_layout_with_hd_clearance(
layout,
mask,
font_path,
clearance=1,
max_shift=24,
):
"""Validate the whole HD batch and minimally move rasterization collisions."""
height, width = mask.shape
blocked = np.asarray(mask) != 0
occupied = np.zeros((height, width), dtype=bool)
measure = ImageDraw.Draw(Image.new("L", (1, 1)))
refined = []
shifted_words = 0
max_applied_shift = 0
offset_rings = [[(0, 0)]]
for radius in range(1, max_shift + 1):
ring = [
(dy, dx)
for dy in range(-radius, radius + 1)
for dx in range(-radius, radius + 1)
if max(abs(dy), abs(dx)) == radius
]
ring.sort(key=lambda item: (item[0] * item[0] + item[1] * item[1], item[0], item[1]))
offset_rings.append(ring)
for word, size, (draw_y, draw_x), orient, color in layout:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bbox = measure.textbbox((0, 0), word, font=font)
glyph = font.getmask(word, mode="L")
glyph_width, glyph_height = glyph.size
if glyph_width <= 0 or glyph_height <= 0:
refined.append((word, size, (draw_y, draw_x), orient, color))
continue
glyph_ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width)
pad = max(0, int(clearance))
padded = np.zeros((glyph_height + 2 * pad, glyph_width + 2 * pad), dtype=np.uint8)
padded[pad:pad + glyph_height, pad:pad + glyph_width] = glyph_ink
if pad:
collision = np.asarray(
Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)),
dtype=np.uint8,
) > 0
else:
collision = padded > 0
stamp = padded > 0
base_y = int(draw_y) + int(bbox[1]) - pad
base_x = int(draw_x) + int(bbox[0]) - pad
def fits(y0, x0):
y1 = y0 + collision.shape[0]
x1 = x0 + collision.shape[1]
if y0 < 0 or x0 < 0 or y1 > height or x1 > width:
return False
if np.any(blocked[y0:y1, x0:x1] & stamp):
return False
return not np.any(occupied[y0:y1, x0:x1] & collision)
placed_offset = None
for ring in offset_rings:
for dy, dx in ring:
y0 = base_y + dy
x0 = base_x + dx
if not fits(y0, x0):
continue
placed_offset = (dy, dx)
break
if placed_offset is not None:
break
if placed_offset is None:
return None, {
"shifted_words": shifted_words,
"max_shift": max_applied_shift,
"failed_word": word,
}
dy, dx = placed_offset
y0 = base_y + dy
x0 = base_x + dx
occupied[y0:y0 + stamp.shape[0], x0:x0 + stamp.shape[1]] |= stamp
if dy or dx:
shifted_words += 1
max_applied_shift = max(max_applied_shift, abs(dy), abs(dx))
refined.append((word, size, (int(draw_y) + dy, int(draw_x) + dx), orient, color))
return refined, {
"shifted_words": shifted_words,
"max_shift": max_applied_shift,
"failed_word": None,
}
def render_layout_occupancy(layout, mask_shape, font_path):
h, w = mask_shape
canvas = Image.new("L", (w, h), 0)
@@ -28,20 +175,27 @@ def compute_fill_ratio_fast(layout, mask, font_path):
return filled_area / free_area, occ
def apply_dot_matrix(base_img, mask_hd):
text_mask = base_img.convert("L").point(lambda x: 0 if x < 200 else 255)
filter_size = max(3, (config.DOT_SAFETY_BUFFER // 2) * 2 + 1)
safe_zone_mask = text_mask.filter(ImageFilter.MinFilter(size=filter_size))
safe_zone_array = np.array(safe_zone_mask)
unfilled_zone = (mask_hd == 0) & (safe_zone_array > 200)
draw = ImageDraw.Draw(base_img)
h, w = mask_hd.shape
dot_color = "white" if config.FILL_ON == "WHITE" else "black"
for y in range(0, h, config.DOT_SPACING):
for x in range(0, w, config.DOT_SPACING):
if unfilled_zone[y, x]:
if config.DOT_RADIUS > 0:
draw.ellipse([x - config.DOT_RADIUS, y - config.DOT_RADIUS, x + config.DOT_RADIUS, y + config.DOT_RADIUS], fill=dot_color)
else:
draw.point((x, y), fill=dot_color)
return base_img
def largest_empty_square_size(occupancy, mask):
"""Return the largest fully empty square inside the fillable mask."""
blocked = (np.asarray(mask) != 0) | (np.asarray(occupancy) != 0)
if blocked.ndim != 2 or blocked.size == 0:
return 0
integral = np.pad(blocked.astype(np.uint32), ((1, 0), (1, 0)))
integral = integral.cumsum(axis=0).cumsum(axis=1)
low = 0
high = min(blocked.shape)
while low < high:
size = (low + high + 1) // 2
window_sums = (
integral[size:, size:]
- integral[:-size, size:]
- integral[size:, :-size]
+ integral[:-size, :-size]
)
if np.any(window_sums == 0):
low = size
else:
high = size - 1
return int(low)
+53 -5
View File
@@ -73,13 +73,55 @@ def extract_weights_from_df(df, names):
return dict(zip(grouped["name"], grouped["weight"]))
def merge_weight_maps(names, stroke_weights=None, excel_weights=None):
"""Combine optional manual weights with normalized stroke complexity.
Manual Excel weights remain the base signal. When both sources exist,
stroke complexity is normalized around the median and applied as a
multiplicative factor, so an all-ones Excel column still enables visibly
different stroke-driven font sizes while meaningful manual ratios remain.
"""
stroke_weights = stroke_weights or {}
excel_weights = excel_weights or {}
if not stroke_weights:
return dict(excel_weights)
if not excel_weights:
return dict(stroke_weights)
positive_strokes = sorted(
max(float(stroke_weights[name]), 1.0)
for name in names
if name in stroke_weights
)
if not positive_strokes:
return dict(excel_weights)
midpoint = positive_strokes[len(positive_strokes) // 2]
merged = {}
for name in names:
manual = excel_weights.get(name)
stroke = stroke_weights.get(name)
if manual is None:
if stroke is not None:
merged[name] = float(stroke)
continue
if stroke is None:
merged[name] = float(manual)
continue
merged[name] = max(float(manual), 1e-6) * max(float(stroke), 1.0) / midpoint
return merged
def calculate_font_by_area_model(mask, names, weights_map, fill_ratio, size_ratio, packing_efficiency, n_rep):
free_area = int(np.sum(mask == 0))
if free_area <= 0:
free_area = int(mask.size)
effective_fill = fill_ratio if fill_ratio > 0 else max(config.MIN_ACCEPT_FILL_RATIO, 0.82)
target_area = free_area * effective_fill * packing_efficiency
# 中文实心笔画约占字形包围盒 35–55%;目标取 0.42 附近,略保守以便一次放满。
effective_fill = fill_ratio if fill_ratio > 0 else 0.42
# 包围盒面积 vs 实际笔画:面积模型用包围盒估算,需额外 ink_factor 校正
ink_factor = 0.42
target_area = free_area * effective_fill * packing_efficiency / ink_factor
weights = [max(float(weights_map.get(name, 10)), 1.0) for name in names]
if not weights:
@@ -89,13 +131,19 @@ def calculate_font_by_area_model(mask, names, weights_map, fill_ratio, size_rati
char_mass = 0.0
for name, score in zip(names, log_scores):
length = max(1, len(name))
char_mass += length * (0.9 + 0.9 * score)
char_mass += length * (0.85 + 0.7 * score)
char_mass *= max(1, n_rep)
if char_mass <= 0:
return max(config.MIN_FONT_SIZE, 10), max(config.MIN_FONT_SIZE + 4, 20)
nominal_size = math.sqrt(target_area / char_mass)
min_f = max(config.MIN_FONT_SIZE, int(nominal_size * 0.72))
max_f = max(min_f + 1, int(min_f * max(1.4, size_ratio)))
min_f = max(config.MIN_FONT_SIZE, int(round(nominal_size * 0.78)))
ratio = max(1.0, float(size_ratio))
if math.isclose(ratio, 1.0, rel_tol=0.0, abs_tol=1e-9):
# A ratio of exactly one is a hard semantic guarantee: every word
# receives the same target size. Do not inject an artificial span.
max_f = min_f
else:
max_f = max(min_f, int(round(min_f * ratio)))
return min_f, max_f