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}")