397 lines
13 KiB
Python
397 lines
13 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"
|
|
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}")
|