Initial project baseline

This commit is contained in:
2026-07-04 02:40:45 +08:00
commit d5d8caef2f
86 changed files with 15590 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Core pipeline modules for the wordcloud generator."""
+396
View File
@@ -0,0 +1,396 @@
import argparse
import json
import logging
import os
import random
import sys
from pathlib import Path
import numpy as np
from PIL import ImageFont
from . import paths
BASE_DIR = paths.BASE_DIR
RUNTIME_DIR = paths.RUNTIME_DIR
ASSETS_DIR = paths.ASSETS_DIR
FONTS_DIR = paths.FONTS_DIR
PROJECT_DEFAULT_FONT = paths.PROJECT_DEFAULT_FONT
# 配置日志:只写入文件,不干扰控制台输出
logging.basicConfig(
filename=str(RUNTIME_DIR / "ewc_concurrency.log"),
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s',
filemode='w'
)
# ==================== 0. 配置区(默认值) ====================
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
# --- Text Mode ---
MASK_TEXT = "A"
MASK_FONT_PATH = str(PROJECT_DEFAULT_FONT)
MASK_FONT_SIZE = 3000
# --- 自动画幅与清晰度 ---
AUTO_EXPAND_CANVAS = True
BASE_HD_WIDTH = 8000
BASE_HD_HEIGHT = 4000
MIN_READABLE_HEIGHT_PX = 25
WORK_SCALE = 0.25
# --- 阴阳刻 ---
FILL_ON = "BLACK"
# --- 数据与字体 ---
EXCEL_PATH = "四个方向汇总录取名单.xlsx"
DATA_COL_INDEX = 1
WEIGHT_COL_INDEX = None
WEIGHT_COL_NAME = None
REMOVE_DUPLICATES = False
ENABLE_STROKE_WEIGHTS = True
WC_FONT_PATH = str(PROJECT_DEFAULT_FONT)
FONT_FALLBACK_PATHS = (
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/Library/Fonts/Arial Unicode.ttf",
)
# --- 填充策略 ---
N_REPETITIONS = 1
TARGET_FILL_RATIO = 0.0 # 关闭填充率检测
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
# --- 智能字号搜索 ---
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
CANVAS_RETRY_GROWTH = 1.12
# --- 配色 ---
DARK_COLOR_PALETTE = (
"#102A43",
"#1F4E5F",
"#206A5D",
"#7B341E",
"#5D1F45",
)
LIGHT_COLOR_PALETTE = (
"#EAF2FF",
"#CDECF6",
"#CFF7E6",
"#FFD8C2",
"#F6D1EB",
)
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
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',
'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',
'OUTPUT_PNG', 'OUTPUT_SVG', 'DB_PATH', 'METRICS_FILE', 'SAVE_DEBUG_IMAGES', 'DEBUG_OUTPUT_DIR', 'SEED',
'LAYOUT_ORDER_MODE', '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',
'output_dir': 'OUTPUT_DIR',
'output_prefix': 'OUTPUT_PREFIX',
'mode': 'MODE',
'work_scale': 'WORK_SCALE',
'weight_col_index': 'WEIGHT_COL_INDEX',
'weight_col_name': 'WEIGHT_COL_NAME',
'min_font_size': 'USER_MIN_FONT_SIZE',
'max_font_size': 'USER_MAX_FONT_SIZE',
'font_color': 'FONT_COLOR',
'stroke_weights': 'ENABLE_STROKE_WEIGHTS',
}
CRITICAL_TYPE_CHECKS = {
'MODE': str,
'WORK_SCALE': (int, float),
'DATA_COL_INDEX': int,
'WEIGHT_COL_INDEX': (int, type(None)),
'WEIGHT_COL_NAME': (str, type(None)),
'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)),
}
DEFAULT_CONFIG = {k: v for k, v in globals().items() if k in KNOWN_CONFIG_KEYS}
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 模式)")
parser.add_argument("--output-dir", type=str, help="输出目录")
parser.add_argument("--output-prefix", type=str, help="输出文件前缀")
parser.add_argument("--mode", type=str, choices=["TEXT", "IMAGE"], help="掩膜模式")
parser.add_argument("--work-scale", type=float, help="运算缩放比例")
parser.add_argument("--weight-col-index", type=int, help="Excel 权重列索引")
parser.add_argument("--weight-col-name", type=str, help="Excel 权重列名(优先于索引)")
parser.add_argument("--min-font-size", type=float, help="覆盖最小字号")
parser.add_argument("--max-font-size", type=float, help="覆盖最大字号")
return parser.parse_args()
def _warn(msg):
print(f"[WARN] {msg}")
def _resolve_path(path_str):
p = Path(path_str)
if p.is_absolute():
return p
return BASE_DIR / p
def _with_prefix(filename, prefix):
if not prefix:
return filename
return f"{prefix}_{filename}"
def apply_json_config(config_path):
cfg_path = _resolve_path(config_path)
if not cfg_path.exists():
print(f"错误: 配置文件不存在: {cfg_path}")
sys.exit(1)
try:
with cfg_path.open("r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"错误: 读取配置文件失败: {e}")
sys.exit(1)
if not isinstance(data, dict):
print("错误: 配置文件顶层必须是 JSON 对象")
sys.exit(1)
normalized_data = {}
for key, value in data.items():
key_upper = CONFIG_ALIASES.get(key, key)
normalized_data[key_upper] = value
for key in normalized_data.keys():
if key not in KNOWN_CONFIG_KEYS:
_warn(f"未知配置键: {key}")
for key, expected in CRITICAL_TYPE_CHECKS.items():
if key in normalized_data and not isinstance(normalized_data[key], expected):
print(f"错误: 配置键 {key} 类型错误,期望 {expected},实际 {type(normalized_data[key])}")
sys.exit(1)
for key, value in normalized_data.items():
if key in KNOWN_CONFIG_KEYS:
globals()[key] = value
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',
'output_dir': 'OUTPUT_DIR',
'output_prefix': 'OUTPUT_PREFIX',
'mode': 'MODE',
'work_scale': 'WORK_SCALE',
'weight_col_index': 'WEIGHT_COL_INDEX',
'weight_col_name': 'WEIGHT_COL_NAME',
'min_font_size': 'USER_MIN_FONT_SIZE',
'max_font_size': 'USER_MAX_FONT_SIZE',
}
for arg_key, cfg_key in mapping.items():
value = getattr(args, arg_key)
if value is not None:
globals()[cfg_key] = value
def _resolve_font_path(configured_path, fallback_paths, *, role):
candidates = [_resolve_path(configured_path), *[Path(path) for path in fallback_paths]]
errors = []
for index, candidate in enumerate(candidates):
if not candidate.exists():
errors.append(f"{candidate}: missing")
continue
try:
ImageFont.truetype(str(candidate), 32)
if index == 0:
print(f"[Font] {role} 使用项目字体: {candidate}")
else:
_warn(f"{role} 字体未命中项目内资源,回退到系统字体: {candidate}")
return str(candidate)
except OSError as exc:
errors.append(f"{candidate}: {exc}")
print(f"错误: {role} 字体初始化失败。候选路径: {errors}")
sys.exit(1)
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
# 运行时派生字段
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
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))
MASK_FONT_PATH = _resolve_font_path(MASK_FONT_PATH, FONT_FALLBACK_PATHS, role="mask")
WC_FONT_PATH = _resolve_font_path(WC_FONT_PATH, FONT_FALLBACK_PATHS, role="layout")
OUTPUT_DIR = str(_resolve_path(OUTPUT_DIR))
Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
OUTPUT_PNG = str(Path(OUTPUT_DIR) / _with_prefix(Path(OUTPUT_PNG).name, OUTPUT_PREFIX))
OUTPUT_SVG = str(Path(OUTPUT_DIR) / _with_prefix(Path(OUTPUT_SVG).name, OUTPUT_PREFIX))
DB_PATH = str(Path(OUTPUT_DIR) / _with_prefix(Path(DB_PATH).name, OUTPUT_PREFIX))
METRICS_FILE = str(Path(OUTPUT_DIR) / _with_prefix(Path(METRICS_FILE).name, OUTPUT_PREFIX))
DEBUG_OUTPUT_DIR = str(Path(OUTPUT_DIR) / Path(DEBUG_OUTPUT_DIR).name)
def set_random_seed():
if SEED is None:
return
np.random.seed(SEED)
random.seed(SEED)
print(f"[Seed] 使用固定随机种子: {SEED}")
def get_output_background():
return "black" if FILL_ON == "WHITE" else "white"
def get_output_palette():
return LIGHT_COLOR_PALETTE if FILL_ON == "WHITE" else DARK_COLOR_PALETTE
def write_metrics(metrics):
try:
with Path(METRICS_FILE).open("w", encoding="utf-8") as f:
json.dump(metrics, f, ensure_ascii=False, indent=2)
print(f"已保存: {METRICS_FILE}")
except Exception as e:
_warn(f"写入 metrics 失败(不影响主产物): {e}")
+15
View File
@@ -0,0 +1,15 @@
import sys
from .paths import BASE_DIR
lib_path = str(BASE_DIR / "EfficientWordCloud")
if lib_path not in sys.path:
sys.path.insert(0, lib_path)
try:
from efficient_wordcloud import EfficientWordCloud
except ImportError:
print("错误: 找不到 EfficientWordCloud 库。请确保已编译并安装该库。")
sys.exit(1)
__all__ = ["EfficientWordCloud"]
+25
View File
@@ -0,0 +1,25 @@
from matplotlib.font_manager import FontProperties
from PIL import ImageFont
from . import config
_global_font_cache = {}
_font_properties_cache = {}
def get_cached_font(font_path, size):
key = (font_path, size)
if key not in _global_font_cache:
try:
_global_font_cache[key] = ImageFont.truetype(font_path, size)
except IOError as e:
config._warn(f"字体加载失败,回退默认字体: path={font_path}, size={size}, error={e}")
_global_font_cache[key] = ImageFont.load_default()
return _global_font_cache[key]
def get_font_properties(font_path, size):
key = (font_path, size)
if key not in _font_properties_cache:
_font_properties_cache[key] = FontProperties(fname=font_path, size=size)
return _font_properties_cache[key]
+560
View File
@@ -0,0 +1,560 @@
import math
import random
import numpy as np
from PIL import Image, ImageDraw, 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
def normalize_relative_scores(values):
if not values:
return []
v_min = min(values)
v_max = max(values)
if math.isclose(v_min, v_max):
return [1.0 for _ in values]
scale = v_max - v_min
return [(value - v_min) / scale for value in values]
def build_log_rank_scores(freq_list, *, per_word=False):
if not freq_list:
return []
if per_word:
word_weights = {}
for word, freq in freq_list:
f = max(float(freq), 1e-6)
if word not in word_weights or f > word_weights[word]:
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}
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]
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)
]
def pick_palette_color(relative_score):
if config.FONT_COLOR:
return config.FONT_COLOR
palette = config.LIGHT_COLOR_PALETTE if config.FILL_ON == "WHITE" else config.DARK_COLOR_PALETTE
if not palette:
return "#111111"
idx = min(len(palette) - 1, max(0, int(round((1.0 - relative_score) * (len(palette) - 1)))))
return palette[idx]
def _build_layout_sequence(sorted_freq, max_words, layout_order_mode, 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)
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
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())
elif isinstance(frequencies, list):
freq_list = frequencies
else:
raise ValueError("frequencies 必须是字典或 (word, freq) 列表")
sorted_freq = sorted(freq_list, key=lambda x: x[1], reverse=True)
layout_sequence = _build_layout_sequence(
sorted_freq,
self.max_words,
config.LAYOUT_ORDER_MODE,
config.LAYOUT_SEED,
)
if not layout_sequence:
return self
self.layout_ = []
per_word_scores = build_log_rank_scores(freq_list, per_word=True)
word_to_score = {}
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]
# 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)
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)))
target_font_sizes.append(f_size)
gap_fill_list = [] # 收集未成功放置的词,用于第二轮填充
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
while current_size >= min_attempt_size:
orientation = None
rotate = rotation_flags[idx]
if rotate:
orientation = Image.ROTATE_90
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)} 个词")
return self
def to_image(self):
img = Image.new(self.mode, (self.width, self.height), self.background_color)
draw = ImageDraw.Draw(img)
for word, size, (y, x), orient, color in self.layout_:
font = get_cached_font(self.font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=color)
return img
def to_svg(self, filename):
background = self.background_color
with open(filename, "w", encoding="utf-8") as f:
f.write(
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="{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')
f.write("</svg>\n")
def to_svg_stroke(self, filename, stroke_color="#000000", stroke_width=1.0):
"""生成描边版 SVG,适合激光雕刻机使用(描边路径,无填充)。"""
with open(filename, "w", encoding="utf-8") as f:
f.write(
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(
f'<path d="{path}" 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"):
"""生成点阵填充 SVG:文字区域用密排小圆点填充,适合激光雕刻逐点打标。"""
from .render import render_layout_occupancy
occ = render_layout_occupancy(self.layout_, (self.height, self.width), self.font_path)
occ_arr = np.array(occ)
with open(filename, "w", encoding="utf-8") as f:
f.write(
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')
half = dot_spacing / 2
dot_count = 0
h, w = occ_arr.shape
for gy in range(0, h, dot_spacing):
for gx in range(0, w, dot_spacing):
cy = min(gy + int(half), h - 1)
cx = min(gx + int(half), w - 1)
if occ_arr[cy, cx]:
f.write(
f'<circle cx="{cx}" cy="{cy}" r="{dot_radius}" '
f'fill="{dot_color}" stroke="none"/>\n'
)
dot_count += 1
f.write("</svg>\n")
return dot_count
def to_svg_custom(self, filename, fill_mode="fill", do_stroke=False,
dot_spacing=10, dot_radius=2, color="#000000",
line_spacing=6, line_width=1, line_angle=0,
ring_radius=3, ring_width=1, ring_spacing=8):
"""统一 SVG 导出:fill_mode=fill|dot|line|ring,可叠加描边。"""
# 预先构建所有文字路径(fill / dot 模式共用)
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))
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过: {word}, error={exc}")
with open(filename, "w", encoding="utf-8") as f:
f.write(
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')
if fill_mode == "dot":
# 点阵模式:用 SVG pattern 平铺圆点 + clipPath 裁剪到文字形状
f.write('<defs>\n')
f.write(f' <pattern id="dot-pat" x="0" y="0" width="{dot_spacing}" height="{dot_spacing}" patternUnits="userSpaceOnUse">\n')
half = dot_spacing / 2
f.write(f' <circle cx="{half}" cy="{half}" r="{dot_radius}" fill="{color}"/>\n')
f.write(' </pattern>\n')
self._write_text_clip(f, text_paths)
f.write('</defs>\n')
f.write(f'<rect width="{self.width}" height="{self.height}" fill="url(#dot-pat)" clip-path="url(#text-clip)"/>\n')
elif fill_mode == "line":
# 线条填充:用 matplotlib Path 渲染占用蒙版(与 SVG 完全对齐)
import math as _m
occ = render_path_occupancy(self.layout_, (self.height, self.width), self.font_path)
angle = line_angle % 360
rad = _m.radians(angle)
cos_a, sin_a = _m.cos(rad), _m.sin(rad)
h, w = occ.shape
step = 1 # 逐像素采样,保证线段连续
# 垂直方向的总范围(确保覆盖整个画布)
perp_max = abs(h * cos_a) + abs(w * sin_a)
n_lines = max(1, int(perp_max / line_spacing) + 1)
sw = f'{line_width:g}'
path_parts = []
for i in range(n_lines):
d0 = (i - n_lines // 2) * line_spacing
sx = -d0 * sin_a
sy = d0 * cos_a
n_steps = int(perp_max) + 1
run_start = None
for s in range(n_steps + 1):
px = sx + s * step * cos_a
py = sy + s * step * sin_a
ix, iy = int(round(px)), int(round(py))
inside = (0 <= iy < h and 0 <= ix < w and occ[iy, ix])
if inside:
if run_start is None:
run_start = (px, py)
else:
if run_start is not None:
ex = px - step * cos_a
ey = py - step * sin_a
path_parts.append(f'M{run_start[0]:.1f} {run_start[1]:.1f}L{ex:.1f} {ey:.1f}')
run_start = None
if run_start is not None:
ex = sx + n_steps * step * cos_a
ey = sy + n_steps * step * sin_a
path_parts.append(f'M{run_start[0]:.1f} {run_start[1]:.1f}L{ex:.1f} {ey:.1f}')
if path_parts:
f.write(f'<path d="{" ".join(path_parts)}" fill="none" stroke="{color}" stroke-width="{sw}" stroke-linecap="round"/>\n')
elif fill_mode == "ring":
# 空心圆点填充:闭合路径,激光机可描一圈
occ = render_path_occupancy(self.layout_, (self.height, self.width), self.font_path)
h, w = occ.shape
r = ring_radius
sw = f'{ring_width:g}'
circle_parts = []
for gy in range(r, h - r, ring_spacing):
for gx in range(r, w - r, ring_spacing):
if not occ[gy, gx]:
continue
lx = gx - r
rx = gx + r
circle_parts.append(
f'M{lx} {gy}A{r} {r} 0 1 0 {rx} {gy}A{r} {r} 0 1 0 {lx} {gy}Z'
)
if circle_parts:
f.write(f'<path d="{" ".join(circle_parts)}" fill="none" stroke="{color}" stroke-width="{sw}"/>\n')
# 只有 fill 模式和显式描边时才输出 matplotlib 文字路径
# ring/line 模式用 PIL occupancy mask 生成填充,不需要文字轮廓
if fill_mode == "fill" or do_stroke:
for path, tx, ty in text_paths:
fill_attr = color if fill_mode == "fill" else "none"
stroke_attr = f'stroke="{color}" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"' if do_stroke else ""
f.write(f'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{fill_attr}" {stroke_attr}/>\n')
f.write("</svg>\n")
@staticmethod
def _write_text_clip(f, text_paths):
"""将文字路径写入 <clipPath id="text-clip">(调用方负责 <defs> 开闭)。"""
f.write(' <clipPath id="text-clip">\n')
for path, tx, ty in text_paths:
f.write(f' <path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)"/>\n')
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():
if code == MplPath.MOVETO:
x, y = vertices
parts.append(f"M{x:.3f} {y:.3f}")
elif code == MplPath.LINETO:
x, y = vertices
parts.append(f"L{x:.3f} {y:.3f}")
elif code == MplPath.CURVE3:
x1, y1, x2, y2 = vertices
parts.append(f"Q{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f}")
elif code == MplPath.CURVE4:
x1, y1, x2, y2, x3, y3 = vertices
parts.append(
f"C{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f} {x3:.3f} {y3:.3f}"
)
elif code == MplPath.CLOSEPOLY:
parts.append("Z")
return " ".join(parts)
def render_path_occupancy(layout_data, canvas_shape, font_path):
"""渲染文字占用蒙版:字形笔画=1,字内空洞(如口)=0,外部=0。
使用 PIL 渲染文字蒙版(与画布坐标完全对齐)+ 边界泛洪填充来区分外部区域与字内空洞。
layout_data: [(word, size, (y, x), orient, color), ...] 同 self.layout_
"""
from collections import deque
h, w = canvas_shape
if not layout_data:
return np.zeros((h, w), dtype=np.uint8)
# 用 PIL 渲染文字蒙版(坐标系与 to_image() 完全一致)
mask = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(mask)
for word, size, (y, x), orient, _color in layout_data:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=255)
occ_raw = (np.array(mask) > 127).astype(np.uint8)
# 泛洪填充:从边框出发标记所有与外部连通的白色区域
# 口 等闭合字符的内部空洞不会与边框连通,因此正确保留为空
outside = np.zeros_like(occ_raw, dtype=np.uint8)
q = deque()
for x in range(w):
if occ_raw[0, x]:
q.append((0, x))
outside[0, x] = 1
if occ_raw[h - 1, x]:
q.append((h - 1, x))
outside[h - 1, x] = 1
for y in range(1, h - 1):
if occ_raw[y, 0]:
q.append((y, 0))
outside[y, 0] = 1
if occ_raw[y, w - 1]:
q.append((y, w - 1))
outside[y, w - 1] = 1
while q:
cy, cx = q.popleft()
for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):
ny, nx = cy + dy, cx + dx
if 0 <= ny < h and 0 <= nx < w and occ_raw[ny, nx] and not outside[ny, nx]:
outside[ny, nx] = 1
q.append((ny, nx))
# 最终蒙版:文字笔画=1,外部和字内空洞=0
return (occ_raw & (~outside).astype(np.uint8)).astype(np.uint8)
+145
View File
@@ -0,0 +1,145 @@
import os
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from . import config
from .fonts import get_cached_font
def analyze_mask(mask):
free = mask == 0
free_area = int(np.sum(free))
total_area = int(mask.size)
free_ratio = (free_area / total_area) if total_area else 0.0
rows = np.where(np.any(free, axis=1))[0]
cols = np.where(np.any(free, axis=0))[0]
bbox_fill_ratio = free_ratio
bbox = None
if rows.size and cols.size:
y0, y1 = int(rows[0]), int(rows[-1])
x0, x1 = int(cols[0]), int(cols[-1])
bbox = (x0, y0, x1, y1)
bbox_area = max(1, (x1 - x0 + 1) * (y1 - y0 + 1))
bbox_fill_ratio = free_area / bbox_area
return {
"free_area": free_area,
"free_ratio": free_ratio,
"bbox": bbox,
"bbox_fill_ratio": bbox_fill_ratio,
}
def normalize_mask_for_fill(mask):
if config.FILL_ON == "WHITE":
return np.where(mask > 128, 0, 255).astype(np.uint8)
return np.where(mask > 128, 255, 0).astype(np.uint8)
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)
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
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
if required_canvas_area > current_area:
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
print(f"[Auto-Size] 扩展画布: {base_w}x{base_h} -> {new_w}x{new_h}")
return new_w, new_h
return base_w, base_h
def prepare_mask(target_w, target_h):
if config.MODE == "TEXT":
img_mask_gen = Image.new("L", (target_w, target_h), 255)
draw_mask = ImageDraw.Draw(img_mask_gen)
font_size = min(config.MASK_FONT_SIZE, int(target_h * 0.75))
font_mask = get_cached_font(config.MASK_FONT_PATH, font_size)
bbox = draw_mask.textbbox((0, 0), config.MASK_TEXT, font=font_mask)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
x_pos = (target_w - text_w) // 2
y_pos = (target_h - text_h) // 2
draw_mask.text((x_pos, y_pos), config.MASK_TEXT, fill=0, font=font_mask)
mask_hd = np.array(img_mask_gen)
mask_hd = normalize_mask_for_fill(mask_hd)
return mask_hd, (target_w, target_h), None
if config.MODE == "IMAGE":
if not os.path.exists(config.MASK_IMAGE_PATH):
raise FileNotFoundError(f"找不到掩膜文件 {config.MASK_IMAGE_PATH}")
img_raw = Image.open(config.MASK_IMAGE_PATH)
if img_raw.mode in ('RGBA', 'LA') or (img_raw.mode == 'P' and 'transparency' in img_raw.info):
img_bg = Image.new('RGB', img_raw.size, (255, 255, 255))
if img_raw.mode == 'P':
img_raw = img_raw.convert('RGBA')
img_bg.paste(img_raw, mask=img_raw.split()[-1])
img_src = img_bg.convert('L')
else:
img_src = img_raw.convert("L")
src_w, src_h = img_src.size
if config.IMAGE_CANVAS_MODE == "AUTO" or (target_w is None and target_h is None):
final_w, final_h = src_w, src_h
elif config.IMAGE_CANVAS_MODE == "WIDTH":
final_w = target_w
final_h = int(round(final_w * src_h / src_w))
elif config.IMAGE_CANVAS_MODE == "HEIGHT":
final_h = target_h
final_w = int(round(final_h * src_w / src_h))
else:
final_w, final_h = target_w, target_h
if (final_w, final_h) != (src_w, src_h):
print(f"正在重采样掩膜: {src_w}x{src_h} -> {final_w}x{final_h} (LANCZOS)")
img_src = img_src.resize((final_w, final_h), Image.Resampling.LANCZOS)
threshold = 200
img_src = img_src.point(lambda p: 255 if p > threshold else 0)
# 自动填充边角区域为可填充(黑色)
if config.FILL_CORNERS:
arr = np.array(img_src)
corner_h = int(final_h * config.CORNER_FILL_RATIO)
corner_w = int(final_w * config.CORNER_FILL_RATIO)
# 四个角落区域设为黑色(可填充)
arr[:corner_h, :corner_w] = 0 # 左上
arr[:corner_h, -corner_w:] = 0 # 右上
arr[-corner_h:, :corner_w] = 0 # 左下
arr[-corner_h:, -corner_w:] = 0 # 右下
img_src = Image.fromarray(arr)
print(f"[边角填充] 四角区域 {corner_w}x{corner_h} 已设为可填充")
if config.SAVE_DEBUG_IMAGES:
debug_dir = config.DEBUG_OUTPUT_DIR
os.makedirs(debug_dir, exist_ok=True)
img_src.save(str(Path(debug_dir) / "mask_src.png"))
mask_hd = np.array(img_src)
mask_hd = normalize_mask_for_fill(mask_hd)
return mask_hd, (final_w, final_h), None
raise ValueError(f"未知 MODE: {config.MODE}")
def apply_safe_padding(mask, padding_px=4, padding_ratio=0.003, max_padding=20):
h, w = mask.shape
padding = max(padding_px, int(min(h, w) * padding_ratio))
padding = min(padding, max_padding)
if padding <= 0:
return mask
mask[:padding, :] = 255
mask[-padding:, :] = 255
mask[:, :padding] = 255
mask[:, -padding:] = 255
return mask
+14
View File
@@ -0,0 +1,14 @@
from pathlib import Path
import os
BASE_DIR = Path(__file__).resolve().parents[1]
RUNTIME_DIR = BASE_DIR / ".runtime"
MPL_CONFIG_DIR = RUNTIME_DIR / "matplotlib"
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
MPL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("MPLCONFIGDIR", str(MPL_CONFIG_DIR))
ASSETS_DIR = BASE_DIR / "assets"
FONTS_DIR = ASSETS_DIR / "fonts"
PROJECT_DEFAULT_FONT = Path("assets/fonts/STHeiti Medium.ttc")
+586
View File
@@ -0,0 +1,586 @@
import logging
import os
import sqlite3
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import pandas as pd
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
log = logging.getLogger("core.pipeline")
def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real_hd_w, real_hd_h):
log.info("[run_generation_pass] 开始")
log.info(" 输入: %d 词 | HD尺寸: %dx%d", len(names), real_hd_w, real_hd_h)
w_small = max(1, int(real_hd_w * config.WORK_SCALE))
h_small = max(1, int(real_hd_h * config.WORK_SCALE))
img_small = Image.fromarray(mask_hd).resize((w_small, h_small), Image.NEAREST)
mask_small = np.array(img_small)
apply_safe_padding(mask_small)
log.info(" 运算网格: %dx%d (WORK_SCALE=%.4f)", w_small, h_small, config.WORK_SCALE)
log.info(" mask_small 统计: 总像素=%d, 空闲像素=%d, 空闲率=%.4f",
mask_small.size, int(np.sum(mask_small == 0)),
int(np.sum(mask_small == 0)) / mask_small.size if mask_small.size else 0)
if config.SAVE_DEBUG_IMAGES:
debug_dir = Path(config.DEBUG_OUTPUT_DIR)
debug_dir.mkdir(parents=True, exist_ok=True)
Image.fromarray(mask_hd).save(str(debug_dir / "mask_hd.png"))
Image.fromarray(mask_small).save(str(debug_dir / "mask_small.png"))
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))
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
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)
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
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
if max_font <= min_font:
config._warn(f"字号区间无效: min={min_font}, max={max_font},自动修正 max=min+1")
max_font = min_font + 1
return 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))
wc = OptimizedEfficientWordCloud(
width=w_small,
height=h_small,
mask=mask_small,
font_path=config.WC_FONT_PATH,
max_words=total_target,
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,
)
if config.ENABLE_STRATIFIED_SAMPLING:
wc.grid.reorder_stratified(config.STRATIFIED_BANDS)
wc.generate_from_frequencies(frequencies_data)
return wc, len(wc.layout_)
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)
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
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
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
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:
return {
"wc": None,
"fill_ratio": 0.0,
"occ_fast": None,
"w_small": w_small,
"h_small": h_small,
"base_min_font": base_min_font,
"base_max_font": base_max_font,
"mask_small": mask_small,
"size_scale": final_scale,
}
fill_ratio, occ_fast = compute_fill_ratio_fast(final_wc.layout_, mask_small, config.WC_FONT_PATH)
print(f"填充率: {fill_ratio:.3f}")
log.info("[填充率] 初始填充率: %.4f (最低要求: %.4f)", fill_ratio, config.MIN_ACCEPT_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
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
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")
)
break
if new_fill > fill_ratio:
low_scale = mid_scale
else:
high_scale = mid_scale
retry_round += 1
if fill_ratio < config.MIN_ACCEPT_FILL_RATIO:
final_wc = best_wc
fill_ratio = best_fill
print(f"最终填充率: {fill_ratio:.3f}")
return {
"wc": final_wc,
"fill_ratio": fill_ratio,
"occ_fast": occ_fast,
"w_small": w_small,
"h_small": h_small,
"base_min_font": base_min_font,
"base_max_font": base_max_font,
"mask_small": mask_small,
"size_scale": final_scale,
}
def main():
t_start = time.time()
print("--- 1. 读取数据 ---")
log.info("=" * 60)
log.info("[Pipeline] main() 开始")
log.info(" EXCEL_PATH = %s", config.EXCEL_PATH)
log.info(" DATA_COL = %d", config.DATA_COL_INDEX)
log.info(" MODE = %s", config.MODE)
log.info(" FILL_ON = %s", config.FILL_ON)
log.info(" WORK_SCALE = %.4f", config.WORK_SCALE)
log.info(" SEED = %s", config.SEED)
names = []
df = None
if os.path.exists(config.EXCEL_PATH):
try:
df = pd.read_excel(config.EXCEL_PATH)
raw_names = df.iloc[:, config.DATA_COL_INDEX].dropna().astype(str)
if config.REMOVE_DUPLICATES:
names = raw_names.unique().tolist()
print(f"模式: 去重 | 数量: {len(names)}")
else:
names = raw_names.tolist()
print(f"模式: 保留重复 | 数量: {len(names)}")
except Exception as e:
print(f"读取 Excel 失败: {e}")
log.error("读取 Excel 失败: %s", e)
sys.exit(1)
else:
count = 12000
print(f"未找到Excel,使用测试数据: {count}")
log.info("未找到 Excel,使用测试数据: %d", count)
names = [f"测试_{i % 100}" for i in range(count)]
input_count = len(names)
log.info("[阶段1] 读取完成: input_count=%d, 去重=%s", input_count, config.REMOVE_DUPLICATES)
if names:
sample = names[:min(10, len(names))]
log.info(" 前10个名字: %s", sample)
print("--- 2. 智能画幅计算 ---")
log.info("--- 阶段2: 智能画幅计算 ---")
avg_len = sum(len(n) for n in names) / len(names) if names else 3
log.info(" 平均名字长度: %.2f 字符", avg_len)
log.info(" BASE_HD: %dx%d", config.BASE_HD_WIDTH, config.BASE_HD_HEIGHT)
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",
probe_w, probe_h, probe_stats['free_ratio'], probe_stats['bbox_fill_ratio'])
if probe_stats.get('bbox'):
log.info(" Probe bbox: %s", probe_stats['bbox'])
print(f"[Mask Probe] 可填充比例={probe_stats['free_ratio']:.3f}")
hd_w, hd_h = calculate_dynamic_dimensions(probe_w, probe_h, len(names), avg_len, probe_stats)
log.info(" 动态画幅计算结果: %dx%d", hd_w, hd_h)
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)
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'])
if mask_stats.get('bbox'):
log.info(" bbox=%s", mask_stats['bbox'])
print(f"[Mask Final] 可填充比例={mask_stats['free_ratio']:.3f}")
print("--- 4. 计算权重 ---")
log.info("--- 阶段4: 计算权重 ---")
t_weights = time.time()
if config.ENABLE_STROKE_WEIGHTS:
stroke_weights_map = get_stroke_complexity_batch(names, config.WC_FONT_PATH)
log.info(" 笔画权重计算完成: %d 个词, 耗时=%.3fs", len(stroke_weights_map), time.time() - t_weights)
else:
stroke_weights_map = {}
print("笔画权重已关闭")
log.info(" 笔画权重已关闭")
# 打印权重分布统计
if stroke_weights_map:
w_vals = list(stroke_weights_map.values())
log.info(" 笔画权重分布: min=%.1f, max=%.1f, avg=%.1f, median=%.1f",
min(w_vals), max(w_vals), sum(w_vals)/len(w_vals),
sorted(w_vals)[len(w_vals)//2])
sample_items = list(stroke_weights_map.items())[:5]
log.info(" 笔画权重样本: %s", sample_items)
excel_weights_map = extract_weights_from_df(df, names) if df is not None else {}
if excel_weights_map:
print(f"Excel 权重生效: {len(excel_weights_map)} 个词")
log.info(" Excel 权重生效: %d 个词", len(excel_weights_map))
ew_vals = list(excel_weights_map.values())
log.info(" Excel 权重分布: min=%.1f, max=%.1f, avg=%.1f",
min(ew_vals), max(ew_vals), sum(ew_vals)/len(ew_vals))
elif config.WEIGHT_COL_NAME is not None or config.WEIGHT_COL_INDEX is not None:
fallback = "笔画权重" if config.ENABLE_STROKE_WEIGHTS else "均等权重"
print(f"Excel 权重不可用,已回退{fallback}")
log.info(" Excel 权重不可用,已回退%s", fallback)
name_weights_map = dict(stroke_weights_map)
name_weights_map.update(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
generation_result = None
t_gen = time.time()
while True:
log.info("[画布] 第%d轮生成 pass, 当前画布: %dx%d", canvas_retry_round + 1, real_hd_w, real_hd_h)
generation_result = run_generation_pass(
names,
frequencies_data,
name_weights_map,
mask_hd,
real_hd_w,
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)
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)
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}")
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)
mask_stats = analyze_mask(mask_hd)
final_wc = generation_result["wc"]
fill_ratio = generation_result["fill_ratio"]
w_small = generation_result["w_small"]
h_small = generation_result["h_small"]
log.info("[阶段5完成] 生成耗时=%.2fs, fill_ratio=%.4f, size_scale=%.4f",
time.time() - t_gen, fill_ratio, generation_result["size_scale"])
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))
log.info(" HD layout 词数: %d", len(hd_layout))
log.info(" HD 画布: %dx%d", real_hd_w, real_hd_h)
if hd_layout:
sample = hd_layout[:3]
for s in sample:
log.info(" 样本: text='%s', size=%d, pos=(%d,%d), orient=%s, color=%s",
s[0], s[1], s[2][1], s[2][0], s[3], s[4])
final_wc.layout_ = hd_layout
final_wc.width = real_hd_w
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)
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)
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(" 渲染耗时: %.2fs", time.time() - t_render)
log.info("--- 阶段7: 写入数据库 ---")
t_db = time.time()
try:
conn = sqlite3.connect(config.DB_PATH)
cursor = conn.cursor()
cursor.execute("DROP TABLE IF EXISTS word_locations")
cursor.execute("""
CREATE TABLE word_locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
x INTEGER,
y INTEGER,
font_size INTEGER,
color TEXT,
orientation TEXT,
box_x INTEGER,
box_y INTEGER,
box_width INTEGER,
box_height INTEGER
)
""")
bbox_canvas = Image.new("L", (1, 1), 0)
bbox_draw = ImageDraw.Draw(bbox_canvas)
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)
db_data.append(
(
name,
x,
y,
font_size,
color,
orientation,
bbox[0],
bbox[1],
bbox[2] - bbox[0],
bbox[3] - bbox[1],
)
)
cursor.executemany(
"""
INSERT INTO word_locations
(name, x, y, font_size, color, orientation, box_x, box_y, box_width, box_height)
VALUES (?,?,?,?,?,?,?,?,?,?)
""",
db_data,
)
conn.commit()
conn.close()
log.info(" DB 写入完成: %s, %d 行, 耗时=%.3fs", config.DB_PATH, len(db_data), time.time() - t_db)
except sqlite3.Error as e:
print(f"DB Error: {e}")
log.error(" DB 写入失败: %s", e)
sys.exit(1)
elapsed = time.time() - t_start
placed_count = len(final_wc.layout_)
metrics = {
"seed": config.SEED,
"layout_order_mode": config.LAYOUT_ORDER_MODE,
"layout_seed": config.LAYOUT_SEED,
"input_count": input_count,
"placed_count": placed_count,
"fill_ratio": fill_ratio,
"elapsed_seconds": round(elapsed, 4),
"font_info": {
"layout_font_path": config.WC_FONT_PATH,
"mask_font_path": config.MASK_FONT_PATH,
"palette": list(config.get_output_palette()),
"background": config.get_output_background(),
},
"mask_info": {
"free_ratio": round(mask_stats["free_ratio"], 6),
"bbox_fill_ratio": round(mask_stats["bbox_fill_ratio"], 6),
"canvas_retry_rounds": canvas_retry_round,
},
"canvas_info": {
"hd_width": real_hd_w,
"hd_height": real_hd_h,
"work_width": w_small,
"work_height": h_small,
"work_scale": config.WORK_SCALE,
},
"output_paths": {
"png": config.OUTPUT_PNG,
"svg": config.OUTPUT_SVG,
"db": config.DB_PATH,
"metrics": config.METRICS_FILE,
"debug_dir": config.DEBUG_OUTPUT_DIR,
},
"config_snapshot": {
"mode": config.MODE,
"excel_path": config.EXCEL_PATH,
"mask_image_path": config.MASK_IMAGE_PATH,
"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,
}
}
config.write_metrics(metrics)
print(f"\n✅ 完成! 总耗时: {elapsed:.2f}s")
log.info("=" * 60)
log.info("[Pipeline] 全流程完成!")
log.info(" 总耗时: %.2fs", elapsed)
log.info(" 输入: %d 词 -> 放置: %d", input_count, placed_count)
log.info(" 填充率: %.4f", fill_ratio)
log.info(" 画布: %dx%d (运算: %dx%d)", real_hd_w, real_hd_h, w_small, h_small)
log.info(" 输出: PNG=%s", config.OUTPUT_PNG)
log.info(" 输出: SVG=%s", config.OUTPUT_SVG)
log.info(" 输出: DB=%s", config.DB_PATH)
log.info("=" * 60)
+47
View File
@@ -0,0 +1,47 @@
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from . import config
from .fonts import get_cached_font
def render_layout_occupancy(layout, mask_shape, font_path):
h, w = mask_shape
canvas = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(canvas)
for word, size, (y, x), orient, _color in layout:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=255)
return (np.array(canvas) > 0).astype(np.uint8)
def compute_fill_ratio_fast(layout, mask, font_path):
if not layout:
return 0.0, None
occ = render_layout_occupancy(layout, mask.shape, font_path)
free_area = np.sum(mask == 0)
if free_area == 0:
return 0.0, occ
filled_area = np.sum((mask == 0) & (occ == 1))
return filled_area / free_area, occ
def 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
+101
View File
@@ -0,0 +1,101 @@
import math
import numpy as np
import pandas as pd
from PIL import Image, ImageDraw
from . import config
from .fonts import get_cached_font
from .layout import normalize_relative_scores
def get_stroke_complexity_batch(names, font_p, test_size=64):
font = get_cached_font(font_p, test_size)
img = Image.new("L", (test_size, test_size), 255)
draw = ImageDraw.Draw(img)
char_complexity_cache = {}
weights = {}
all_chars = set("".join(names))
for char in all_chars:
draw.rectangle([0, 0, test_size, test_size], fill=255)
draw.text((0, 0), char, font=font, fill=0)
char_complexity_cache[char] = np.sum(np.array(img) < 200)
unique_names = set(names)
for name in unique_names:
if not name:
weights[name] = 10
continue
complexities = [char_complexity_cache.get(c, 10) for c in name]
weights[name] = max(complexities)
return weights
def extract_weights_from_df(df, names):
series = None
if config.WEIGHT_COL_NAME is not None:
if config.WEIGHT_COL_NAME in df.columns:
series = df[config.WEIGHT_COL_NAME]
else:
config._warn(f"权重列名不存在: {config.WEIGHT_COL_NAME},尝试使用权重列索引")
if series is None and config.WEIGHT_COL_INDEX is not None:
if 0 <= config.WEIGHT_COL_INDEX < len(df.columns):
series = df.iloc[:, config.WEIGHT_COL_INDEX]
else:
config._warn(f"权重列索引越界: {config.WEIGHT_COL_INDEX},将回退到笔画权重")
if series is None:
return {}
name_series = df.iloc[:, config.DATA_COL_INDEX]
numeric = pd.to_numeric(series, errors='coerce')
pairs = pd.DataFrame({"name": name_series, "weight": numeric})
pairs = pairs[pairs["name"].notna()]
pairs["name"] = pairs["name"].astype(str)
pairs = pairs[pairs["weight"].notna() & (pairs["weight"] > 0)]
if pairs.empty:
config._warn("Excel 权重列没有可用正数,全部回退到笔画权重")
return {}
if config.REMOVE_DUPLICATES:
grouped = pairs.groupby("name", as_index=False)["weight"].max()
return dict(zip(grouped["name"], grouped["weight"]))
valid_name_set = set(names)
pairs = pairs[pairs["name"].isin(valid_name_set)]
if pairs.empty:
config._warn("Excel 权重与名称列未形成有效映射,全部回退到笔画权重")
return {}
grouped = pairs.groupby("name", as_index=False)["weight"].max()
return dict(zip(grouped["name"], grouped["weight"]))
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
weights = [max(float(weights_map.get(name, 10)), 1.0) for name in names]
if not weights:
return max(config.MIN_FONT_SIZE, 10), max(config.MIN_FONT_SIZE + 4, 20)
log_scores = normalize_relative_scores([math.log1p(weight) for weight in weights])
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 *= 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)))
return min_f, max_f