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)