Files

352 lines
12 KiB
Python

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"
FILL_CORNERS = False
CORNER_FILL_RATIO = 0.15
# --- Text Mode ---
MASK_TEXT = "A"
MASK_FONT_PATH = str(PROJECT_DEFAULT_FONT)
MASK_FONT_SIZE = 3000
# --- 自动画幅与清晰度 ---
# 默认 4k 级画布:打印/激光足够清晰,比 8k 渲染快约 4×
BASE_HD_WIDTH = 4000
BASE_HD_HEIGHT = 4000
MIN_READABLE_HEIGHT_PX = 22
# 运算网格缩放:0.18 在速度/质量之间更均衡
WORK_SCALE = 0.18
# 服务端快速预览路径:减少试探次数,但保留真实字形碰撞和零重叠校验。
FAST_MODE = False
# --- 阴阳刻 ---
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
# 名单较少、掩膜轮廓填不满时,自动循环追加名字副本增加词数,让费马螺旋
# 能走到掩膜远端(心形尖端、人物四肢),把形状填出来而非退化成圆形。
# AUTO_REPEAT_MAX 是自动重复次数的安全上限,避免无限追加。
AUTO_REPEAT_TO_FILL = True
AUTO_REPEAT_MAX = 20
# 面积模型目标填充率:中文实心笔画像素占比约 0.35–0.55。
# 略偏保守以保证 scale=1.0 首次就能放满,减少多轮重试。
TARGET_FILL_RATIO = 0.45
SIZE_RATIO = 2.0
PACKING_EFFICIENCY = 0.9
# 竖排概率:每个词独立以此概率竖着摆放,其余水平摆放。
# 0.0 = 全部水平,1.0 = 全部竖排。适度混排可打散过于规整的观感。
VERTICAL_RATIO = 0.18
# --- 智能字号搜索 ---
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
USER_MIN_FONT_SIZE = None
USER_MAX_FONT_SIZE = None
MIN_FONT_FLOOR = 2
# --- 画布重试 ---
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 则使用调色板
# --- 输出 ---
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 = False
DEBUG_OUTPUT_DIR = "output"
# --- 可复现性 ---
SEED = None
LAYOUT_SEED = None
KNOWN_CONFIG_KEYS = {
'MODE', 'MASK_IMAGE_PATH', 'IMAGE_CANVAS_MODE', 'FILL_CORNERS',
'CORNER_FILL_RATIO', 'MASK_TEXT', 'MASK_FONT_PATH', 'MASK_FONT_SIZE',
'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FAST_MODE', 'FILL_ON', 'EXCEL_PATH',
'DATA_COL_INDEX', 'WEIGHT_COL_INDEX', 'WEIGHT_COL_NAME', 'REMOVE_DUPLICATES', 'ENABLE_STROKE_WEIGHTS',
'WC_FONT_PATH',
'FONT_FALLBACK_PATHS',
'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY', 'VERTICAL_RATIO',
'AUTO_REPEAT_TO_FILL', 'AUTO_REPEAT_MAX',
'USER_MIN_FONT_SIZE', 'USER_MAX_FONT_SIZE',
'CANVAS_RETRY_MAX_ROUNDS', 'CANVAS_RETRY_GROWTH',
'DARK_COLOR_PALETTE', 'LIGHT_COLOR_PALETTE', 'FONT_COLOR', 'OUTPUT_DIR', 'OUTPUT_PREFIX',
'OUTPUT_PNG', 'OUTPUT_SVG', 'DB_PATH', 'METRICS_FILE', 'SAVE_DEBUG_IMAGES', 'DEBUG_OUTPUT_DIR', 'SEED',
'LAYOUT_SEED'
}
CONFIG_ALIASES = {
'seed': 'SEED',
'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',
'auto_repeat_to_fill': 'AUTO_REPEAT_TO_FILL',
'auto_repeat_max': 'AUTO_REPEAT_MAX',
'save_debug_images': 'SAVE_DEBUG_IMAGES',
}
CRITICAL_TYPE_CHECKS = {
'MODE': str,
'WORK_SCALE': (int, float),
'FAST_MODE': bool,
'DATA_COL_INDEX': int,
'WEIGHT_COL_INDEX': (int, type(None)),
'WEIGHT_COL_NAME': (str, type(None)),
'FONT_FALLBACK_PATHS': (list, tuple),
'USER_MIN_FONT_SIZE': (int, float, type(None)),
'USER_MAX_FONT_SIZE': (int, float, type(None)),
'SAVE_DEBUG_IMAGES': bool,
'REMOVE_DUPLICATES': bool,
'ENABLE_STROKE_WEIGHTS': bool,
'CANVAS_RETRY_MAX_ROUNDS': int,
'CANVAS_RETRY_GROWTH': (int, float),
'AUTO_REPEAT_TO_FILL': bool,
'AUTO_REPEAT_MAX': int,
'VERTICAL_RATIO': (int, float),
'SEED': (int, type(None)),
'LAYOUT_SEED': (int, type(None)),
}
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-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_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_SEED
# 运行时派生字段
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
if LAYOUT_SEED is None:
LAYOUT_SEED = SEED
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}")