#!/usr/bin/env python3 import argparse import csv import json import logging import math import os import sys import time from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) os.chdir(ROOT) from scripts.generate_template_samples import ( # noqa: E402 configure_chart_type_filter, newest_root_png, newest_root_svg, run_sample_job, safe_slug, ) from modules.chart_engine.template.template_registry import scan_templates # noqa: E402 DEFAULT_BASELINE = ( "output/gpt_image_2_polish_bundle_20260528_0242/" "chart_template_samples_20260527_083945" ) 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 read_jsonl(path: Path): if not path.is_file(): return [] with open(path, "r", encoding="utf-8") as f: return [json.loads(line) for line in f if line.strip()] 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 write_json(path: Path, payload): path.parent.mkdir(parents=True, exist_ok=True) with open(path, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2, ensure_ascii=False) def parse_args(): parser = argparse.ArgumentParser() parser.add_argument("--baseline-root", default=DEFAULT_BASELINE) parser.add_argument("--output-root", default=None) parser.add_argument("--samples-per-template", type=int, default=5) parser.add_argument("--workers", type=int, default=2) parser.add_argument("--png-longest-side", type=int, default=1600) parser.add_argument( "--no-output-png", action="store_true", help="Skip PNG generation. PNG output is enabled by default.", ) parser.add_argument("--resume", action="store_true") parser.add_argument("--rerun-failures", action="store_true") parser.add_argument("--compare-only", action="store_true") parser.add_argument("--chart-only", action="store_true") parser.add_argument( "--include-non-d3", action="store_true", help="Include matching non-d3 template records from the baseline manifest.", ) parser.add_argument( "--remap-to-current-template-key", action="store_true", help="Map baseline records to the current registry template key with the same chart name.", ) parser.add_argument( "--slot-polish-after-chart", action="store_true", help="Render each template chart-only, then run full_image_polisher in slot mode.", ) parser.add_argument( "--slot-polish-dry-run", action="store_true", help="Generate slot masks/prompts/manifests without calling the image model.", ) parser.add_argument( "--planned-slot-polish", action="store_true", help=( "Render chart-only, sanitize template title/image artifacts, plan editable slots, " "then run full_image_polisher in slot mode." ), ) parser.add_argument( "--planned-slot-dry-run", action="store_true", help="Generate planned slot canvas/masks/prompts/manifests without calling the image model.", ) parser.add_argument( "--planned-slot-disallow-chart-overlap", action="store_true", help="Do not allow planned slots to overlap the chart bbox. Overlap is allowed by default.", ) 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("--limit", type=int, default=0) parser.add_argument("--template", action="append", default=None) parser.add_argument("--log-file", default=None) return parser.parse_args() def current_template_key_by_chart_name() -> dict[str, str]: priority = {"d3-js": 0, "echarts-js": 1, "echarts_py": 2} candidates: dict[str, tuple[int, str]] = {} templates = scan_templates(force=True) for engine, chart_types in templates.items(): for chart_type, chart_names in chart_types.items(): for chart_name in chart_names: key = f"{engine}/{chart_type}/{chart_name}" rank = priority.get(engine, 99) current = candidates.get(chart_name) if current is None or rank < current[0]: candidates[chart_name] = (rank, key) return {chart_name: key for chart_name, (_rank, key) in candidates.items()} def select_baseline_records( baseline_root: Path, samples_per_template: int, templates: set[str] | None, include_non_d3: bool = False, remap_to_current_template_key: bool = False, ): records = read_jsonl(baseline_root / "manifest.jsonl") current_key_by_name = ( current_template_key_by_chart_name() if remap_to_current_template_key else {} ) grouped = {} for record in records: baseline_template_key = record.get("template_key") or "" chart_name = baseline_template_key.split("/")[-1] template_key = current_key_by_name.get(chart_name, baseline_template_key) if not include_non_d3 and not template_key.startswith("d3-js/"): continue if ( templates and chart_name not in templates and template_key not in templates and baseline_template_key not in templates ): continue if template_key != baseline_template_key: record = { **record, "baseline_template_key": baseline_template_key, "template_key": template_key, } grouped.setdefault(template_key, []).append(record) selected = [] for template_key in sorted(grouped): template_records = sorted( grouped[template_key], key=lambda item: (item.get("sample_index", 10**9), item.get("data_source", "")), ) selected.extend(template_records[:samples_per_template]) return selected def sample_key(record: dict): return (record.get("template_key"), int(record.get("sample_index", -1))) def latest_manifest_records(manifest_path: Path): latest = {} for record in read_jsonl(manifest_path): latest[sample_key(record)] = record return latest def completed_keys(manifest_path: Path, successful_only: bool = False): latest = latest_manifest_records(manifest_path) if successful_only: return {key for key, record in latest.items() if record.get("success")} return set(latest) def resolve_sample_dir(root: Path, record: dict, use_template_key: bool = False): template_key = record.get("template_key") or "" slug_source = template_key if use_template_key else template_key.split("/")[-1] return root / safe_slug(slug_source) / f"sample_{int(record.get('sample_index', 0)):02d}" 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 candidates[-1] if candidates else None def file_metrics(path: Path | None): 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(" 0.01: high_diff["mae_gt_0_01"] += 1 if mae > 0.05: high_diff["mae_gt_0_05"] += 1 if mae > 0.15: high_diff["mae_gt_0_15"] += 1 top_diffs = sorted( [row for row in rows if isinstance(row.get("png_mae"), (int, float))], key=lambda row: row["png_mae"], reverse=True, )[:50] problem_rows = [ row for row in rows if row["status_change"] == "regressed_failure" or row["fallback_change"] == "regressed_fallback" or row["empty_chart_change"] in {"regressed_empty", "same_empty"} or not row["current_success"] ][:100] summary = { "total_samples": len(rows), "total_templates": len({row["template_key"] for row in rows}), "status_counts": by_status, "fallback_counts": by_fallback, "empty_chart_counts": by_empty_chart, "png_diff_counts": high_diff, "top_png_diffs": top_diffs, "problem_rows": problem_rows, "comparison_jsonl": str(jsonl_path), "comparison_csv": str(csv_path), } write_json(output_root / "comparison_summary.json", summary) return summary def redirect_output(log_path: Path): log_path.parent.mkdir(parents=True, exist_ok=True) log_file = open(log_path, "a", encoding="utf-8", buffering=1) sys.stdout = log_file sys.stderr = log_file for handler in logging.getLogger().handlers: if hasattr(handler, "stream"): handler.stream = log_file return log_file def main(): args = parse_args() configure_chart_type_filter(False) os.environ["RENDER_LONGEST_SIDE"] = str(args.png_longest_side) output_png = not args.no_output_png baseline_root = Path(args.baseline_root) if not baseline_root.is_dir(): raise SystemExit(f"baseline root is not a directory: {baseline_root}") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_root = Path(args.output_root or f"output/d3_regression_from_baseline_{timestamp}") output_root.mkdir(parents=True, exist_ok=True) log_handle = None if args.log_file: log_handle = redirect_output(Path(args.log_file)) templates = set(args.template) if args.template else None selected = select_baseline_records( baseline_root, args.samples_per_template, templates, include_non_d3=args.include_non_d3, remap_to_current_template_key=args.remap_to_current_template_key, ) if args.limit: selected = selected[:args.limit] write_json(output_root / "run_config.json", { "baseline_root": str(baseline_root), "output_root": str(output_root), "samples_per_template": args.samples_per_template, "workers": args.workers, "png_longest_side": args.png_longest_side, "output_png": output_png, "rerun_failures": args.rerun_failures, "chart_only": args.chart_only, "include_non_d3": args.include_non_d3, "remap_to_current_template_key": args.remap_to_current_template_key, "slot_polish_after_chart": args.slot_polish_after_chart, "slot_polish_dry_run": args.slot_polish_dry_run, "planned_slot_polish": args.planned_slot_polish, "planned_slot_dry_run": args.planned_slot_dry_run, "planned_slot_allow_chart_overlap": not args.planned_slot_disallow_chart_overlap, "planned_slot_polisher_base_url": args.planned_slot_polisher_base_url, "slot_polisher_backend": args.slot_polisher_backend, "planned_slot_polisher_backend": args.planned_slot_polisher_backend, "pinco_command": args.pinco_command or "", "pinco_url": args.pinco_url or "", "pinco_timeout": args.pinco_timeout, "limit": args.limit, "template": args.template, "selected_samples": len(selected), "selected_templates": len({record.get("template_key") for record in selected}), }) selected_path = output_root / "selected_baseline_records.jsonl" if not selected_path.exists(): for record in selected: append_jsonl(selected_path, record) manifest_path = output_root / "manifest.jsonl" done = ( completed_keys(manifest_path, successful_only=args.rerun_failures) if args.resume or args.compare_only or args.rerun_failures else set() ) jobs = [] if not args.compare_only: for record in selected: key = sample_key(record) if key in done: continue chart_name = (record.get("template_key") or "").split("/")[-1] sample_dir = resolve_sample_dir(output_root, record, use_template_key=True) jobs.append(( record.get("template_key"), int(record.get("sample_index", 0)), Path(record.get("data_source")), sample_dir, output_png, args.chart_only, args.slot_polish_after_chart, args.slot_polish_dry_run, args.planned_slot_polish, args.planned_slot_dry_run, not args.planned_slot_disallow_chart_overlap, args.planned_slot_polisher_base_url, args.slot_polisher_backend, args.planned_slot_polisher_backend, args.pinco_command, args.pinco_url, args.pinco_timeout, )) started = time.time() print( f"selected_templates={len({r.get('template_key') for r in selected})} " f"selected_samples={len(selected)} to_run={len(jobs)} workers={args.workers}", flush=True, ) if jobs and args.workers > 1: with ThreadPoolExecutor(max_workers=args.workers) as executor: futures = [executor.submit(run_sample_job, job) for job in jobs] for index, future in enumerate(as_completed(futures), 1): record = future.result() append_jsonl(manifest_path, record) elapsed = time.time() - started rate = index / elapsed if elapsed else 0 remaining = (len(jobs) - index) / rate if rate else 0 print( f"[{index}/{len(jobs)}] {record['template_key']} " f"sample_{record['sample_index']:02d} success={record['success']} " f"elapsed={elapsed/60:.1f}m eta={remaining/60:.1f}m", flush=True, ) else: for index, job in enumerate(jobs, 1): record = run_sample_job(job) append_jsonl(manifest_path, record) elapsed = time.time() - started rate = index / elapsed if elapsed else 0 remaining = (len(jobs) - index) / rate if rate else 0 print( f"[{index}/{len(jobs)}] {record['template_key']} " f"sample_{record['sample_index']:02d} success={record['success']} " f"elapsed={elapsed/60:.1f}m eta={remaining/60:.1f}m", flush=True, ) rows = compare_records(baseline_root, output_root, selected) summary = write_comparison(output_root, rows) print(json.dumps({ "output_root": str(output_root), "total_samples": summary["total_samples"], "total_templates": summary["total_templates"], "status_counts": summary["status_counts"], "fallback_counts": summary["fallback_counts"], "empty_chart_counts": summary["empty_chart_counts"], "png_diff_counts": summary["png_diff_counts"], }, indent=2, ensure_ascii=False), flush=True) if log_handle: log_handle.close() if __name__ == "__main__": main()