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