import logging import math 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 ( compute_coverage_score, compute_fill_ratio_fast, count_layout_overlap_pixels, largest_empty_square_size, append_layout_with_hd_clearance, refine_layout_with_hd_clearance, render_layout_occupancy, scale_layout_for_hd, ) from .weights import ( calculate_font_by_area_model, extract_weights_from_df, get_stroke_complexity_batch, merge_weight_maps, ) log = logging.getLogger("core.pipeline") def run_generation_pass( names, frequencies_data, name_weights_map, mask_hd, real_hd_w, real_hd_h, _collision_margin=0, ): 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}") readable_min_font = max( config.MIN_FONT_FLOOR, int(math.ceil(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE)), ) total_target = len(names) * config.N_REPETITIONS final_wc = None final_scale = 1.0 base_layout_seed = config.LAYOUT_SEED if config.LAYOUT_SEED is not None else config.SEED final_layout_seed = base_layout_seed base_min_font, base_max_font = calculate_font_by_area_model( mask_small, names, name_weights_map, config.TARGET_FILL_RATIO, config.SIZE_RATIO, config.PACKING_EFFICIENCY, config.N_REPETITIONS, ) explicit_min_font = config.USER_MIN_FONT_SIZE is not None hard_min_font = readable_min_font if explicit_min_font: hard_min_font = max(config.MIN_FONT_FLOOR, int(round(config.USER_MIN_FONT_SIZE))) hard_max_font = None if config.USER_MAX_FONT_SIZE is not None: hard_max_font = max(config.MIN_FONT_FLOOR, int(round(config.USER_MAX_FONT_SIZE))) if explicit_min_font and hard_max_font < hard_min_font: raise ValueError( f"字号硬约束冲突: USER_MAX_FONT_SIZE={hard_max_font} " f"小于最小允许字号 {hard_min_font}" ) if not explicit_min_font: # A user-specified maximum outranks the automatic readability # suggestion. It remains an exact ceiling rather than causing an # artificial conflict with a value the user never requested. hard_min_font = min(hard_min_font, hard_max_font) equal_size_mode = math.isclose(float(config.SIZE_RATIO), 1.0, rel_tol=0.0, abs_tol=1e-9) base_min_font = max(hard_min_font, int(base_min_font)) base_max_font = max(base_min_font, int(base_max_font)) if hard_max_font is not None: base_min_font = min(base_min_font, hard_max_font) base_max_font = min(base_max_font, hard_max_font) if equal_size_mode: # Keep one scalar throughout every retry. This is what makes # SIZE_RATIO=1 exact even after automatic batch scaling. equal_font = min(base_min_font, base_max_font) base_min_font = equal_font base_max_font = equal_font def scaled_bounds(scale): if equal_size_mode: size = max(hard_min_font, int(round(base_min_font * scale))) if hard_max_font is not None: size = min(size, hard_max_font) return size, size min_font = max(hard_min_font, int(round(base_min_font * scale))) max_font = max(min_font, int(round(base_max_font * scale))) if hard_max_font is not None: min_font = min(min_font, hard_max_font) max_font = min(max_font, hard_max_font) return min_font, max(min_font, max_font) def try_place(scale, layout_seed=base_layout_seed, probe=False): min_font, max_font = scaled_bounds(scale) 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(), # Each word independently draws horizontal vs vertical, so the mix # is scattered rather than banded. VERTICAL_RATIO is the chance of # a vertical word; mixing orientations is one of the cheapest ways # to break up an over-regular grid-like look. prefer_horizontal=1.0 - float(config.VERTICAL_RATIO), # HD clearance is applied after scaling. Keeping the coarse-grid # margin at zero avoids turning 1 HD pixel into 5-6 output pixels. margin=_collision_margin, ) wc.layout_seed = layout_seed # A probe only needs to answer "does every word fit at this scale?", so # it stops at the first word that cannot be placed. The answer is exact # -- a word is only reported unplaced once an exhaustive scan has ruled # out every position -- and it avoids paying for a doomed batch's # remaining failures, each of which is far more expensive than a # successful placement. wc.max_failures = 1 if probe else None wc.generate_from_frequencies(frequencies_data) return wc, len(wc.layout_), min_font, max_font 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, %d], 硬边界: [%d, %s], 等字号=%s", base_min_font, base_max_font, hard_min_font, hard_max_font if hard_max_font is not None else "∞", equal_size_mode, ) # Every attempt is a fresh, whole-cloud layout. No word may silently # receive a smaller fallback size. Usually the area model succeeds on # attempt one; two adaptive retries cover fragmentation-heavy masks. best_wc = None best_count = 0 best_scale = 1.0 best_coverage = -1.0 # shape-aware coverage of the current best candidate best_occ = None # occupancy raster of the current best candidate tried_layouts = set() failed_scales = [] attempt = 0 def probe_scale(scale): """Lay out every word at `scale`; return (wc, placed_count, complete).""" nonlocal attempt, best_wc, best_count, best_scale nonlocal best_coverage, best_occ attempt += 1 bounds = scaled_bounds(scale) tried_layouts.add((bounds, base_layout_seed)) wc, placed_count, min_font, max_font = try_place(scale, probe=True) complete = placed_count >= total_target print( f" 整批布局 #{attempt}: scale={scale:.3f}, " f"字号=[{min_font}, {max_font}] -> " f"{'完整' if complete else '不足'} ({placed_count}/{total_target})" ) log.info( " 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d complete=%s", attempt, scale, min_font, max_font, placed_count, total_target, complete, ) if complete: # Among complete layouts prefer the one whose ink reaches furthest # into the mask shape, not merely the largest font scale. A Fermat # spiral packs words into a disc around the fillable centroid; with # few words or large fonts that disc stays small and never reaches # the mask's protrusions, so the cloud reads as a circle instead of # the intended silhouette. Coverage rewards layouts that spread into # those deep regions, letting a slightly smaller scale win when it # trades font size for a recognisable outline. Scale is the tie- # breaker so an equal-coverage denser picture is still preferred. occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH) coverage = compute_coverage_score(occ, mask_small) log.info( " 整批布局 #%d coverage=%.4f (best=%.4f)", attempt, coverage, best_coverage, ) if coverage > best_coverage or ( coverage == best_coverage and scale > best_scale ): best_wc, best_count, best_scale = wc, placed_count, scale best_coverage, best_occ = coverage, occ if not complete: failed_scales.append(scale) return wc, placed_count, complete # Find the largest scale at which every word still fits. Bigger is strictly # better here: the same names drawn larger leave less blank space. A probe # answers feasibility exactly and stops at the first unplaceable word, so # searching for the best scale costs little more than accepting the first # one that happens to work. lo = None # largest scale known to fit everything hi = None # smallest scale known to be too big scale = 1.0 for _ in range(2 if config.FAST_MODE else 4): wc, placed_count, complete = probe_scale(scale) if complete: lo = scale break hi = scale placed_ratio = placed_count / max(1, total_target) # Area scales with size², so linear size scales with sqrt(ratio). The # probe stops early, which understates how many words would have fit, # so this deliberately undershoots and the bisection below climbs back. shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.55, math.sqrt(placed_ratio) * 0.92)) scale *= shrink # Close the gap between the largest failing scale and the smallest passing # one. Each step recovers font size that the shrink above gave away. if lo is not None and hi is not None: for _ in range(1 if config.FAST_MODE else 3): mid = (lo + hi) / 2.0 if hi - lo < 0.02 or scaled_bounds(mid) == scaled_bounds(lo): break _wc, _placed, complete = probe_scale(mid) if complete: lo = mid else: hi = mid if best_wc is not None: final_wc = best_wc final_scale = best_scale final_layout_seed = base_layout_seed if final_wc is None: return { "wc": None, "fill_ratio": 0.0, "coverage": 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, "layout_seed": final_layout_seed, "collision_margin": _collision_margin, "hd_overlap_pixels": 0, "hd_layout": [], "hd_clearance": None, } fill_ratio, occ_fast = compute_fill_ratio_fast(final_wc.layout_, mask_small, config.WC_FONT_PATH) largest_empty_square = largest_empty_square_size(occ_fast, mask_small) def has_character_sized_hole(wc, hole_size): if not equal_size_mode or wc is None or not wc.layout_: return False font_size = int(wc.layout_[0][1]) return hole_size >= max(2, int(math.ceil(font_size * 1.25))) complete_candidates = [] if len(final_wc.layout_) >= total_target: complete_candidates.append( (final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast) ) print(f"填充率: {fill_ratio:.3f}") log.info("[填充率] 初始填充率: %.4f", 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")) # Probe whole-cloud layouts in BOTH font-size directions and keep the one # whose ink reaches furthest into the mask shape. The original search only # grew the font (chasing a higher pixel fill ratio), but a Fermat spiral # packs words into a disc around the centroid: growing the font shrinks that # disc, so an under-filled silhouette gets *more* circular, not less. # Shrinking the font lets the spiral walk further out and reach the mask's # protrusions, which raises shape coverage even when the raw fill ratio # drops a little. Both directions are probed each round and the higher- # coverage candidate wins; the loop stops when neither improves coverage. # Every probe rebuilds the entire cloud with one shared scale. if ( len(final_wc.layout_) >= total_target and fill_ratio > 0 and ( fill_ratio < config.TARGET_FILL_RATIO * 0.98 or has_character_sized_hole(final_wc, largest_empty_square) ) ): current_coverage = compute_coverage_score(occ_fast, mask_small) free_px = max(1, int(np.sum(mask_small == 0))) upper_scale = min( (failed for failed in failed_scales if failed > final_scale), default=None, ) def _fill_and_coverage(wc): occ = render_layout_occupancy(wc.layout_, mask_small.shape, config.WC_FONT_PATH) new_fill = float(np.sum((mask_small == 0) & (occ == 1))) / free_px cov = compute_coverage_score(occ, mask_small) return new_fill, cov, occ for density_attempt in range(1, 2 if config.FAST_MODE else 5): # Grow direction (larger font): bisect toward a known-too-big scale, # or nudge up by the fill-ratio deficit, exactly as before. if upper_scale is not None: grow_scale = (final_scale + upper_scale) / 2.0 elif equal_size_mode: current_size, _ = scaled_bounds(final_scale) grow_scale = (current_size + 1) / max(1, base_min_font) else: desired_growth = min( 1.12, math.sqrt(config.TARGET_FILL_RATIO / fill_ratio) * 0.98, ) grow_scale = final_scale * desired_growth if desired_growth > 1.005 else None # Shrink direction (smaller font): the inverse nudge. Letting the # spiral walk further out costs font size but can reach protrusions # the grow direction abandons. Cap the shrink so one round cannot # collapse the font to the floor. shrink_scale = None if not equal_size_mode and fill_ratio > 0: shrink_factor = 1.0 / max(1.02, min(1.20, math.sqrt(fill_ratio / max(0.05, config.TARGET_FILL_RATIO)) * 1.02)) cand = final_scale * shrink_factor if scaled_bounds(cand) != scaled_bounds(final_scale): shrink_scale = cand elif equal_size_mode: current_size, _ = scaled_bounds(final_scale) if current_size > hard_min_font: shrink_scale = (current_size - 1) / max(1, base_min_font) candidates = [] for direction, scale in (("grow", grow_scale), ("shrink", shrink_scale)): if scale is None or scaled_bounds(scale) == scaled_bounds(final_scale): continue bounds = scaled_bounds(scale) d_min, d_max = bounds key = (bounds, base_layout_seed) wc, placed_count, selected_seed = None, -1, base_layout_seed if key not in tried_layouts: tried_layouts.add(key) wc, placed_count, _, _ = try_place(scale) # Bounded seed retry to recover a complete layout, as before. if placed_count < total_target and base_layout_seed is not None: candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1) retry_key = (bounds, candidate_seed) if retry_key not in tried_layouts: tried_layouts.add(retry_key) retry_wc, retry_count, _, _ = try_place(scale, candidate_seed) print( f" 密度优化 #{density_attempt} {direction} 整批重排: " f"seed={candidate_seed}, 字号=[{d_min}, {d_max}] -> " f"{retry_count}/{total_target}" ) if retry_count > placed_count: wc, placed_count = retry_wc, retry_count selected_seed = candidate_seed if placed_count < total_target: if direction == "grow": upper_scale = scale print( f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, " f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} (不完整)" ) continue new_fill, cov, occ = _fill_and_coverage(wc) print( f" 密度优化 #{density_attempt} {direction}: scale={scale:.3f}, " f"字号=[{d_min}, {d_max}] -> {placed_count}/{total_target} " f"fill={new_fill:.3f} coverage={cov:.4f}" ) candidates.append((direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ)) if not candidates: break # Pick the higher-coverage candidate; tie-break on fill ratio so a # genuinely denser picture still wins when coverage is equal. candidates.sort(key=lambda c: (c[6], c[5])) direction, scale, selected_seed, wc, placed_count, new_fill, cov, occ = candidates[-1] if cov <= current_coverage and new_fill <= fill_ratio: break final_wc = wc final_scale = scale final_layout_seed = selected_seed fill_ratio = new_fill occ_fast = occ current_coverage = cov largest_empty_square = largest_empty_square_size(occ_fast, mask_small) complete_candidates.append( (final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast) ) if ( fill_ratio >= config.TARGET_FILL_RATIO * 0.98 and not has_character_sized_hole(final_wc, largest_empty_square) and current_coverage >= 0.98 ): break # At the largest complete equal-size tier, compare a small deterministic # set of whole-cloud reorderings and retain the one with the smallest # character-scale void. This never changes an individual font size. if ( equal_size_mode and len(final_wc.layout_) >= total_target and has_character_sized_hole(final_wc, largest_empty_square) and base_layout_seed is not None and not config.FAST_MODE ): if total_target < 100: hole_attempt_budget = 3 elif total_target <= 300: hole_attempt_budget = 2 else: hole_attempt_budget = 1 modulus = 2**31 - 1 for hole_attempt in range(1, hole_attempt_budget + 1): candidate_seed = ( int(base_layout_seed) ^ ((0x9E3779B9 * hole_attempt) & 0x7FFFFFFF) ) % modulus bounds = scaled_bounds(final_scale) layout_key = (bounds, candidate_seed) if layout_key in tried_layouts: continue tried_layouts.add(layout_key) candidate_wc, placed_count, _, _ = try_place(final_scale, candidate_seed) if placed_count < total_target: continue candidate_fill, candidate_occ = compute_fill_ratio_fast( candidate_wc.layout_, mask_small, config.WC_FONT_PATH ) candidate_hole = largest_empty_square_size(candidate_occ, mask_small) print( f" 空洞优化 #{hole_attempt}: seed={candidate_seed}, " f"最大空白={candidate_hole}px -> {placed_count}/{total_target}" ) if candidate_hole >= largest_empty_square: continue final_wc = candidate_wc final_layout_seed = candidate_seed fill_ratio = candidate_fill occ_fast = candidate_occ largest_empty_square = candidate_hole complete_candidates.append( (final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast) ) # ── 工作网格增量填充:原名字号不变,用更小字号追加副本填轮廓 ── # 已填区域标记为阻挡,新词只能进空白间隙。每一轮只生成一份名单, # 并把新占用合并回工作网格;因此自动填充没有固定的重复数量, # 只在轮廓仍未覆盖且还有合法位置时继续追加。 fill_work_layout = [] if ( config.AUTO_REPEAT_TO_FILL and final_wc is not None and len(final_wc.layout_) >= total_target and occ_fast is not None ): current_cov = compute_coverage_score(occ_fast, mask_small) if current_cov < 0.95 and names: # 原名字号范围 original_sizes = [size for _, size, *_ in final_wc.layout_] orig_min_font = int(min(original_sizes)) orig_max_font = int(max(original_sizes)) span = orig_max_font - orig_min_font # 追加用缩小字号:原最大字号的 50~60% fill_min_font = max(config.MIN_FONT_FLOOR, int(round(orig_min_font * 0.50))) fill_max_font = max(fill_min_font, int(round(orig_min_font + span * 0.60))) fill_seed = (final_layout_seed ^ 0x9E3779B9) & 0x7FFFFFFF if fill_seed == 0: fill_seed = 1 # AUTO_REPEAT_MAX 只是防止异常掩膜导致无限循环,不是目标重复次数。 max_fill_rounds = max(1, int(config.AUTO_REPEAT_MAX)) for fill_round in range(max_fill_rounds): if current_cov >= 0.95: break # 融合 mask:原阻挡 + 已填区域都标为 255 fill_mask = mask_small.copy() fill_mask[(occ_fast == 1)] = 255 fill_wc = OptimizedEfficientWordCloud( width=w_small, height=h_small, mask=fill_mask, font_path=config.WC_FONT_PATH, # 一轮只追加一份名单;需要更多时由下一轮按需追加。 max_words=len(names), min_font_size=fill_min_font, max_font_size=fill_max_font, background_color=config.get_output_background(), prefer_horizontal=1.0 - float(config.VERTICAL_RATIO), margin=_collision_margin, ) fill_wc.layout_seed = (fill_seed + fill_round) & 0x7FFFFFFF or 1 fill_freq = {name: 0.3 for name in names} fill_wc.generate_from_frequencies(fill_freq) fill_placed = len(fill_wc.layout_) print( f"[增量填充#{fill_round + 1}] 工作网格追加放置 " f"{fill_placed}/{len(names)} 词,字号=[{fill_min_font}, {fill_max_font}]" ) log.info( "[增量填充#%d] 工作网格追加放置 %d/%d 词, 字号=[%d,%d]", fill_round + 1, fill_placed, len(names), fill_min_font, fill_max_font, ) if fill_placed <= 0: break fill_work_layout.extend(fill_wc.layout_) fill_occ = render_layout_occupancy( fill_wc.layout_, mask_small.shape, config.WC_FONT_PATH ) occ_fast = np.maximum(occ_fast, fill_occ) current_cov = compute_coverage_score(occ_fast, mask_small) print(f"[增量填充#{fill_round + 1}] 工作网格覆盖度={current_cov:.4f}") hd_layout = None hd_clearance = None raw_hd_layout = [] for candidate_index, candidate in enumerate(reversed(complete_candidates), start=1): candidate_wc, candidate_scale, candidate_seed, candidate_fill, candidate_occ = candidate raw_hd_layout = scale_layout_for_hd(candidate_wc.layout_, config.WORK_SCALE) refined_layout = None clearance_stats = None clearance_modes = (1, 0) if total_target >= 100 and candidate_index > 1: clearance_modes = (0,) for clearance_px in clearance_modes: priority_layout = raw_hd_layout priority_restarts = 0 max_priority_attempts = 2 if clearance_px == 0 or total_target < 100 else 1 for priority_attempt in range(max_priority_attempts): refined_layout, clearance_stats = refine_layout_with_hd_clearance( priority_layout, mask_hd, config.WC_FONT_PATH, clearance=clearance_px, ) if refined_layout is not None: break failed_word = clearance_stats["failed_word"] failed_index = next( (index for index, item in enumerate(priority_layout) if item[0] == failed_word), None, ) if ( failed_index is None or failed_index == 0 or priority_attempt + 1 >= max_priority_attempts ): break failed_item = priority_layout[failed_index] priority_layout = [failed_item, *priority_layout[:failed_index], *priority_layout[failed_index + 1:]] priority_restarts += 1 if refined_layout is not None: clearance_stats["clearance_px"] = clearance_px clearance_stats["priority_restarts"] = priority_restarts break clearance_stats["clearance_px"] = clearance_px clearance_stats["priority_restarts"] = priority_restarts if refined_layout is None: log.warning( "高清候选 #%d 隔离精修失败: word=%s, shifted=%d, max_shift=%d", candidate_index, clearance_stats["failed_word"], clearance_stats["shifted_words"], clearance_stats["max_shift"], ) hd_clearance = clearance_stats continue final_wc = candidate_wc final_scale = candidate_scale final_layout_seed = candidate_seed fill_ratio = candidate_fill occ_fast = candidate_occ largest_empty_square = largest_empty_square_size(occ_fast, mask_small) hd_layout = refined_layout hd_clearance = clearance_stats break if hd_layout is None: hd_overlap_pixels = -1 hd_layout = raw_hd_layout if hd_clearance is None: hd_clearance = { "shifted_words": 0, "max_shift": 0, "clearance_px": None, "priority_restarts": 0, "failed_word": None, } else: hd_overlap_pixels = count_layout_overlap_pixels( hd_layout, (real_hd_h, real_hd_w), config.WC_FONT_PATH, ) # ── 增量填充(工作网格 → HD) ── # 工作网格上的合法位置经过放大后可能因取整发生碰撞,因此填充词必须 # 与基础布局一起再次做高清精修。若整批填充无法通过,则二分保留最多 # 的追加词;绝不能让自动填充破坏原本已经成功的基础布局。 if ( fill_work_layout and hd_layout is not None ): old_count = len(hd_layout) fill_hd = scale_layout_for_hd(fill_work_layout, config.WORK_SCALE) base_hd_layout = list(hd_layout) accepted_additions, clearance_stats = append_layout_with_hd_clearance( base_hd_layout, fill_hd, mask_hd, config.WC_FONT_PATH, clearance=0, allow_global_search=False, ) accepted_count = len(accepted_additions) accepted_clearance = 0 hd_layout = base_hd_layout + accepted_additions hd_overlap_pixels = count_layout_overlap_pixels( hd_layout, (real_hd_h, real_hd_w), config.WC_FONT_PATH ) new_fill, new_occ = compute_fill_ratio_fast( hd_layout, mask_hd, config.WC_FONT_PATH ) new_cov = compute_coverage_score(new_occ, mask_hd) if new_occ is not None else 0.0 clearance_stats = dict(clearance_stats or {}) clearance_stats["clearance_px"] = accepted_clearance hd_clearance = clearance_stats print( f"[增量填充] 高清验收: {old_count}+{accepted_count}=" f"{len(hd_layout)} 词, fill={new_fill:.3f}, " f"coverage={new_cov:.4f}, overlap={hd_overlap_pixels}" ) print( f"[增量填充] 工作网格候选 {len(fill_work_layout)} 词," f"高清接受 {len(hd_layout) - old_count} 词" ) log.info( "[增量填充] HD 验收: base=%d candidate=%d accepted=%d fill=%.4f coverage=%.4f overlap=%d", old_count, len(fill_hd), len(hd_layout) - old_count, new_fill, new_cov, hd_overlap_pixels, ) fill_ratio = new_fill occ_fast = new_occ largest_empty_square = largest_empty_square_size(occ_fast, mask_hd) print( f"最终填充率: {fill_ratio:.3f} | 高清重叠像素: {hd_overlap_pixels} | " f"精修位移: {hd_clearance['shifted_words']} 词, 最大 {hd_clearance['max_shift']}px | " f"隔离带: {hd_clearance['clearance_px']}px" ) # occ_fast 可能是工作网格或 HD 网格形状(增量填充后),按形状匹配计算覆盖度 if occ_fast is not None and occ_fast.shape == mask_small.shape: coverage = compute_coverage_score(occ_fast, mask_small) elif occ_fast is not None and occ_fast.shape == mask_hd.shape: coverage = compute_coverage_score(occ_fast, mask_hd) else: coverage = 0.0 print(f"轮廓覆盖度: {coverage:.4f}") return { "wc": final_wc, "fill_ratio": fill_ratio, "coverage": coverage, "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, "layout_seed": final_layout_seed, "collision_margin": _collision_margin, "hd_overlap_pixels": hd_overlap_pixels, "hd_layout": hd_layout, "hd_clearance": hd_clearance, "largest_empty_square_work_px": largest_empty_square, "largest_empty_square_font_ratio": ( largest_empty_square / max(1, int(final_wc.layout_[0][1])) if final_wc.layout_ and equal_size_mode else None ), } 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 尺寸,再按需扩展后复用(避免二次 LANCZOS) 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: 生成掩膜 ---") if (hd_w, hd_h) == (probe_w, probe_h): mask_hd, real_hd_w, real_hd_h = probe_mask_hd, probe_w, probe_h mask_stats = probe_stats else: 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 = merge_weight_maps( names, stroke_weights_map if config.ENABLE_STROKE_WEIGHTS else {}, 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() 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, ) wc = generation_result.get("wc") placed = len(wc.layout_) if wc is not None else 0 target = len(names) * config.N_REPETITIONS # 名单完整性是硬约束。填充率优化不得以漏掉姓名为代价。 hd_overlap_pixels = int(generation_result.get("hd_overlap_pixels", 0)) placement_ok = wc is not None and placed >= target and hd_overlap_pixels == 0 if placement_ok or canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS: if not placement_ok: print( f"生成失败:放置 {placed}/{target},高清重叠像素 {hd_overlap_pixels}," "未满足完整名单与零碰撞约束" ) log.error( "生成失败:放置 %d/%d, 高清重叠像素=%d (已重试 %d 轮)", placed, target, hd_overlap_pixels, canvas_retry_round, ) sys.exit(1) log.info(" 生成完成 placed=%d/%d fill=%.4f coverage=%.4f retry=%d", placed, target, generation_result["fill_ratio"], generation_result.get("coverage", 0.0), 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} (放置 {placed}/{target})") 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 = generation_result["hd_layout"] 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") # 更快的 PNG 写出(压缩等级 1,视觉无损) base_img.save(config.OUTPUT_PNG, compress_level=1) 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) # 一次构建路径,同时写出 fill / stroke 两份 SVG stroke_svg = final_wc.export_svgs(config.OUTPUT_SVG) print(f"已保存: {config.OUTPUT_SVG}") print(f"已保存: {stroke_svg}") log.info(" SVG 已保存: %s", config.OUTPUT_SVG) log.info(" SVG(stroke) 已保存: %s", stroke_svg) 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 ) """) # Cache full font bearings as well as dimensions so search highlights # match the actual PIL-rendered glyph position. bbox_cache = {} _measure = ImageDraw.Draw(Image.new("L", (1, 1))) db_data = [] for name, font_size, (y, x), orient, color in final_wc.layout_: orientation = "vertical" if orient else "horizontal" key = (name, int(font_size), bool(orient)) box = bbox_cache.get(key) if box is None: font = get_cached_font(config.WC_FONT_PATH, max(1, int(font_size))) if orient: font = ImageFont.TransposedFont(font, orientation=orient) bb = _measure.textbbox((0, 0), name, font=font) box = (bb[0], bb[1], bb[2] - bb[0], bb[3] - bb[1]) bbox_cache[key] = box bx, by, bw, bh = box db_data.append( ( name, x + bx, y + by, font_size, color, orientation, x, y, bw, bh, ) ) 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_seed": generation_result.get("layout_seed", config.LAYOUT_SEED), "input_count": input_count, "placed_count": placed_count, "fill_ratio": fill_ratio, "largest_empty_square_work_px": int( generation_result.get("largest_empty_square_work_px", 0) ), "largest_empty_square_font_ratio": generation_result.get( "largest_empty_square_font_ratio" ), "hd_overlap_pixels": int(generation_result.get("hd_overlap_pixels", -1)), "hd_clearance": generation_result.get("hd_clearance"), "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, "fill_on": config.FILL_ON, "layout_seed": generation_result.get("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)