""" 为每个 chart template 随机生成若干完整 infographic 结果。 默认会跑完整流程: preprocess -> datafact_generator -> title_generator -> color_recommender -> image_recommender -> infographics_generator 示例: PYTHONPATH=. python scripts/generate_template_samples.py --samples-per-template 10 --output-png 输出结构: output/chart_template_samples// / sample_00/ sample_01/ ... 为了覆盖所有 chart template,默认忽略 allowed_chart_types.json 白名单。 如果只想跑当前白名单里的 chart_type,加 --respect-allowed-chart-types。 """ import argparse import csv import json import os import random import re import shutil import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path from collections import defaultdict ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) os.chdir(ROOT) from config import api_key as CFG_API_KEY from config import base_url as CFG_BASE_URL from config import data_resource_dirs as CFG_DATA_DIRS from modules.chart_engine.template.template_registry import scan_templates from modules.infographics_generator.template_utils import ( analyze_templates, check_template_compatibility, ) from pipeline import run_single_file DEFAULT_ENGINES = ["d3-js", "echarts-js", "echarts_py"] PIPELINE_MODULES = ["all", "infographics_generator"] COMPAT_CACHE_VERSION = 1 SHAPE_TAGS = ("path", "rect", "circle", "line", "polygon", "polyline", "ellipse", "use") IMAGE_TAGS = ("image",) FALLBACK_MARKER = "This is a fallback SVG using a PNG screenshot" def parse_args(): parser = argparse.ArgumentParser() parser.add_argument( "--data", nargs="+", default=None, help="输入 JSON 数据目录;默认使用 config.data_resource_dirs", ) parser.add_argument( "--output", default=None, help="任务输出父目录;脚本会在下面创建 /,默认 output/chart_template_samples", ) parser.add_argument( "--samples-per-template", type=int, default=10, help="每个 chart template 生成多少个结果", ) parser.add_argument("--seed", type=int, default=42) parser.add_argument( "--engines", nargs="+", default=DEFAULT_ENGINES, help="要覆盖的 chart engine", ) parser.add_argument( "--scan-limit", type=int, default=0, help="候选数据扫描上限;0 表示扫描全部数据", ) parser.add_argument( "--limit-templates", type=int, default=0, help="仅跑前 N 个模板,调试用;0 表示不限制", ) parser.add_argument( "--output-png", action="store_true", help="同时生成 PNG,便于人工检查", ) parser.add_argument( "--chart-only", action="store_true", help="只生成 chart SVG/PNG", ) parser.add_argument( "--slot-polish-after-chart", action="store_true", help="先以 chart-only 渲染 template,再直接调用 full_image_polisher 的 slot mode", ) parser.add_argument( "--slot-polish-dry-run", action="store_true", help="只生成 full_image_polisher slot mask/prompt/manifest,不调用图像模型", ) parser.add_argument( "--planned-slot-polish", action="store_true", help="先清理 template 自带 title/image,再规划 editable slots,最后调用 full_image_polisher slot mode", ) parser.add_argument( "--planned-slot-dry-run", action="store_true", help="只生成 planned slot canvas/mask/prompt/manifest,不调用图像模型", ) parser.add_argument( "--planned-slot-disallow-chart-overlap", action="store_true", help="规划 slots 时不允许和 chart bbox 相交;默认允许相交", ) parser.add_argument( "--planned-slot-polisher-base-url", default=None, help=( "Override base_url for planned-slot full_image_polisher. " "Use 'openai_default' to ignore config.base_url and call the official OpenAI endpoint." ), ) parser.add_argument("--slot-polisher-backend", choices=("auto", "openai", "pinco"), default="auto") parser.add_argument("--planned-slot-polisher-backend", choices=("auto", "openai", "pinco"), default="auto") parser.add_argument( "--pinco-command", default=None, help=( "Command template for local Pinco inference. Placeholders: {input}, {mask}, " "{foreground}, {prompt_file}, {output}, {model}, {width}, {height}." ), ) parser.add_argument("--pinco-url", default=None, help="HTTP endpoint for a Pinco inpainting service.") parser.add_argument("--pinco-timeout", type=int, default=600) parser.add_argument( "--png-longest-side", type=int, default=1600, help="PNG 最长边像素;批量审查默认 1600,线上默认配置目前是 3860", ) parser.add_argument( "--workers", type=int, default=1, help="并行生成 worker 数;1 表示串行", ) parser.add_argument( "--scan-workers", type=int, default=1, help="并行兼容性扫描 worker 数;1 表示串行", ) parser.add_argument( "--compat-cache", default="output/chart_template_samples/template_compat_cache.json", help="template -> compatible data 扫描缓存路径", ) parser.add_argument( "--cache-candidates-per-template", type=int, default=50, help="每个 template 最多缓存多少个兼容数据路径", ) parser.add_argument( "--rebuild-compat-cache", action="store_true", help="忽略已有兼容数据缓存,重新扫描", ) parser.add_argument( "--respect-allowed-chart-types", action="store_true", help="尊重 allowed_chart_types.json;默认忽略白名单以覆盖所有模板", ) parser.add_argument( "--dry-run", action="store_true", help="只生成候选匹配报告,不执行 pipeline", ) parser.add_argument( "--clean", action="store_true", help="如果本次 timestamp 任务目录已存在,先删除再生成", ) parser.add_argument( "--resume-root", default=None, help="继续已有任务目录,跳过 manifest 中已成功的样本", ) return parser.parse_args() def configure_chart_type_filter(respect_allowed_chart_types: bool): if respect_allowed_chart_types: return os.environ["ALLOWED_CHART_TYPES_FILE"] = str( ROOT / "tmp" / "__ignore_allowed_chart_types_for_template_samples__.json" ) def safe_slug(value: str, max_len: int = 120) -> str: slug = re.sub(r"[^a-zA-Z0-9._-]+", "_", value).strip("_") return slug[:max_len] or "template" def read_json(path: Path): with open(path, "r", encoding="utf-8") as f: return json.load(f) def write_json(path: Path, data): path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) def append_jsonl(path: Path, record: dict): path.parent.mkdir(parents=True, exist_ok=True) with open(path, "a", encoding="utf-8") as f: f.write(json.dumps(record, ensure_ascii=False) + "\n") def cache_meta(data_dirs, engines, respect_allowed_chart_types, scan_limit, template_keys): return { "version": COMPAT_CACHE_VERSION, "data_dirs": [str(Path(p)) for p in data_dirs], "engines": list(engines), "respect_allowed_chart_types": bool(respect_allowed_chart_types), "scan_limit": int(scan_limit), "template_keys": list(template_keys), } def load_compatibility_cache(path: Path, expected_meta: dict): if not path.is_file(): return None cached = read_json(path) cached_meta = cached.get("meta") or {} same_scan_scope = all( cached_meta.get(key) == expected_meta.get(key) for key in ("version", "data_dirs", "respect_allowed_chart_types", "scan_limit") ) cached_template_keys = set(cached_meta.get("template_keys") or []) expected_template_keys = set(expected_meta.get("template_keys") or []) if not same_scan_scope or not expected_template_keys.issubset(cached_template_keys): return None candidates = { key: [Path(p) for p in paths] for key, paths in cached.get("candidates", {}).items() if key in expected_template_keys } return candidates def save_compatibility_cache(path: Path, meta: dict, candidates_by_template): payload = { "meta": meta, "candidates": { key: [str(p) for p in paths] for key, paths in candidates_by_template.items() }, } write_json(path, payload) def make_output_root(output_parent, timestamp: str) -> Path: parent = Path(output_parent or "output/chart_template_samples") return parent / timestamp def collect_input_files(data_dirs, rng, scan_limit: int): files = [] for data_dir in data_dirs: path = Path(data_dir) if not path.is_dir(): raise SystemExit(f"数据目录不存在: {path}") files.extend(sorted(path.glob("*.json"))) rng.shuffle(files) if scan_limit and scan_limit < len(files): files = files[:scan_limit] if not files: raise SystemExit("没有找到可用 JSON 数据") return files def enumerate_template_keys(templates, engines): keys = [] for engine, chart_types in templates.items(): if engine not in engines: continue for chart_type, chart_names in chart_types.items(): for chart_name in chart_names: if "base" in chart_name: continue if engine == "vegalite_py": continue keys.append(f"{engine}/{chart_type}/{chart_name}") return sorted(keys) def scan_file_compatibility(path, templates, target_keys, target_chart_names, use_targeted_check): data = read_json(path) data["name"] = str(path) matched_keys = [] if use_targeted_check: for chart_name in target_chart_names: compatible = check_template_compatibility(data, templates, chart_name) matched_keys.extend( template_key for template_key, _ordered_fields in compatible if template_key in target_keys ) else: compatible = check_template_compatibility(data, templates, None) matched_keys.extend( template_key for template_key, _ordered_fields in compatible if template_key in target_keys ) return path, matched_keys def add_compatibility_result(by_template, path, matched_keys, min_candidates: int): for template_key in matched_keys: if len(by_template[template_key]) < min_candidates: by_template[template_key].append(path) def compatibility_scan_complete(by_template, template_keys, min_candidates: int): return all(len(by_template[key]) >= min_candidates for key in template_keys) def build_compatibility_index_serial( input_files, templates, template_keys, min_candidates: int, target_keys, target_chart_names, use_targeted_check: bool, ): by_template = defaultdict(list) for index, path in enumerate(input_files, 1): path, matched_keys = scan_file_compatibility( path, templates, target_keys, target_chart_names, use_targeted_check, ) add_compatibility_result(by_template, path, matched_keys, min_candidates) if index % 500 == 0: print(f" scanned {index}/{len(input_files)} data files", flush=True) if compatibility_scan_complete(by_template, template_keys, min_candidates): print(f" found {min_candidates} candidates for every selected template", flush=True) break return by_template def build_compatibility_index_parallel( input_files, templates, template_keys, min_candidates: int, target_keys, target_chart_names, use_targeted_check: bool, scan_workers: int, ): by_template = defaultdict(list) next_submit_index = 0 next_result_index = 0 results = {} pending = {} max_pending = max(scan_workers * 4, scan_workers) with ThreadPoolExecutor(max_workers=scan_workers) as executor: while next_submit_index < len(input_files) and len(pending) < max_pending: path = input_files[next_submit_index] pending[ executor.submit( scan_file_compatibility, path, templates, target_keys, target_chart_names, use_targeted_check, ) ] = next_submit_index next_submit_index += 1 while pending: for future in as_completed(pending): result_index = pending.pop(future) results[result_index] = future.result() break while next_result_index in results: path, matched_keys = results.pop(next_result_index) add_compatibility_result(by_template, path, matched_keys, min_candidates) scanned = next_result_index + 1 if scanned % 500 == 0: print(f" scanned {scanned}/{len(input_files)} data files", flush=True) next_result_index += 1 if compatibility_scan_complete(by_template, template_keys, min_candidates): print( f" found {min_candidates} candidates for every selected template", flush=True, ) for future in pending: future.cancel() return by_template while next_submit_index < len(input_files) and len(pending) < max_pending: path = input_files[next_submit_index] pending[ executor.submit( scan_file_compatibility, path, templates, target_keys, target_chart_names, use_targeted_check, ) ] = next_submit_index next_submit_index += 1 return by_template def build_compatibility_index(input_files, templates, template_keys, min_candidates: int, scan_workers: int): target_keys = set(template_keys) target_chart_names = sorted({key.split("/")[-1] for key in template_keys}) use_targeted_check = len(template_keys) <= 20 if scan_workers <= 1: return build_compatibility_index_serial( input_files, templates, template_keys, min_candidates, target_keys, target_chart_names, use_targeted_check, ) return build_compatibility_index_parallel( input_files, templates, template_keys, min_candidates, target_keys, target_chart_names, use_targeted_check, scan_workers, ) def choose_samples(candidates, count: int, rng): if len(candidates) >= count: return rng.sample(candidates, count) return [rng.choice(candidates) for _ in range(count)] def write_candidate_report(path: Path, template_keys, candidates_by_template): path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["template_key", "num_compatible_data"]) for template_key in template_keys: writer.writerow([template_key, len(candidates_by_template.get(template_key, []))]) def newest_root_svg(sample_dir: Path): svgs = sorted( [p for p in sample_dir.glob("*.svg") if p.is_file()], key=lambda p: p.stat().st_mtime, ) return str(svgs[-1]) if svgs else None def newest_root_png(sample_dir: Path): pngs = sorted( [p for p in sample_dir.glob("*.png") if p.is_file()], key=lambda p: p.stat().st_mtime, ) return str(pngs[-1]) if pngs else None def nested_chart_svg(sample_dir: Path): candidates = sorted( [p for p in sample_dir.glob("*/chart.svg") if p.is_file()], key=lambda p: p.stat().st_mtime, ) return str(candidates[-1]) if candidates else None def file_metrics(path): if path is not None: path = Path(path) if path is None or not path.is_file(): return { "exists": False, "bytes": 0, "fallback": False, "n_shapes": 0, "n_text": 0, "n_images": 0, "n_visible": 0, "n_elements": 0, "empty": True, } text = path.read_text(encoding="utf-8", errors="ignore") n_shapes = sum(text.count(f"<{tag}") for tag in SHAPE_TAGS) n_text = text.count(" 1: with ThreadPoolExecutor(max_workers=args.workers) as executor: futures = [executor.submit(run_sample_job, job) for job in jobs] for done_index, future in enumerate(as_completed(futures), 1): record = future.result() append_jsonl(manifest_path, record) if record["success"]: total_success += 1 else: total_failed += 1 print( f" completed {done_index}/{len(jobs)}: " f"{record['template_key']} sample_{record['sample_index']:02d} " f"success={record['success']}", flush=True, ) else: for done_index, job in enumerate(jobs, 1): record = run_sample_job(job) append_jsonl(manifest_path, record) if record["success"]: total_success += 1 else: total_failed += 1 print( f" completed {done_index}/{len(jobs)}: " f"{record['template_key']} sample_{record['sample_index']:02d} " f"success={record['success']}", flush=True, ) summary = { "total_templates": len(template_keys), "skipped_templates": total_skipped_templates, "samples_requested": total_requested, "samples_success": total_success, "samples_failed": total_failed, "output_root": str(output_root), } write_json(output_root / "summary.json", summary) print(json.dumps(summary, indent=2, ensure_ascii=False), flush=True) if __name__ == "__main__": main()