ChartPipeline / scripts /run_quality_check.py
Ray1ee01's picture
Upload folder using huggingface_hub
58e6885 verified
Raw
History Blame Contribute Delete
11.3 kB
"""
Per-template quality-check driver.
Reads a plan JSON (produced by pick_smoke_templates.py), runs
infographics_generator.process(input, output, "", "", chart_name) for
each (template, data) pair in a process pool, and incrementally writes:
- <output-dir>/_tasks.csv per-task report (one row per (tpl, data))
- <output-dir>/_summary.csv per-template rollup, worst-first
- <output-dir>/PROGRESS.md live status, ETA, per-tpl table
Resume is supported: if --resume is passed and _tasks.csv already has rows,
those (chart_name, input) pairs are skipped on this run.
Usage:
python scripts/run_quality_check.py \\
--plan scripts/_smoke_templates.json \\
--output-dir output/quality_check \\
--threads 8
"""
import argparse
import json
import os
import sys
import time
import traceback
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from scripts._progress import ProgressTracker
def parse_args():
p = argparse.ArgumentParser()
p.add_argument("--plan", default="scripts/_smoke_templates.json")
p.add_argument("--data-dir", nargs="+", default=None,
help="Override data dir(s) (defaults to plan.data_dirs / data_dir)")
p.add_argument("--output-dir", default="output/quality_check")
p.add_argument("--threads", type=int, default=8)
p.add_argument("--chrome-path", default="/usr/bin/google-chrome",
help="Chrome executable for puppeteer (sets PUPPETEER_EXECUTABLE_PATH)")
p.add_argument("--limit", type=int, default=None,
help="Optional cap on total tasks (for ultra-quick smoke)")
p.add_argument("--resume", action="store_true",
help="Read existing _tasks.csv and skip already-done jobs")
p.add_argument("--match-csv", default="scripts/_template_match.csv",
help="Used to list skipped templates in PROGRESS.md")
p.add_argument("--min-data-for-skipped", type=int, default=5,
help="Show skipped templates whose match count is below this")
return p.parse_args()
def _worker_init(chrome_path: str):
# Each worker process inherits its own env; set puppeteer's chrome here.
os.environ.setdefault("PUPPETEER_EXECUTABLE_PATH", chrome_path)
# Reduce torch / faiss threading noise inside workers.
os.environ.setdefault("OMP_NUM_THREADS", "1")
os.environ.setdefault("MKL_NUM_THREADS", "1")
# FALLBACK_MARKER mirrors what html_to_svg.py writes when puppeteer cannot
# extract a real SVG from the DOM. Same string used by the loader to flag it.
FALLBACK_MARKER = "This is a fallback SVG using a PNG screenshot"
def _is_fallback_svg(svg_path: str) -> bool:
if not os.path.exists(svg_path):
return False
with open(svg_path, "r", encoding="utf-8", errors="ignore") as fh:
head = fh.read(4096)
return FALLBACK_MARKER in head
def _count_svg_elements(svg_path: str):
"""Cheap structural metric: rough count of drawing/text tags.
Doesn't fully parse; just regex-counts common shape and text tags.
Good enough as a sanity signal for "is this SVG basically empty?".
"""
if not os.path.exists(svg_path):
return 0, 0
import re
with open(svg_path, "r", encoding="utf-8", errors="ignore") as fh:
content = fh.read()
shape_tags = ("path", "rect", "circle", "line", "polygon", "polyline", "ellipse", "use")
n_shapes = sum(len(re.findall(rf"<{t}\b", content)) for t in shape_tags)
n_text = len(re.findall(r"<text\b", content))
return n_shapes, n_text
def _run_one(args_tuple):
"""Run one (template, data_file) job. Returns a dict of metrics."""
chart_name, input_path, data_key, template_out_dir, base_url, api_key = args_tuple
# Lazy import inside the worker (after worker_init sets env).
from modules.infographics_generator.infographics_generator import process
input_basename = os.path.basename(input_path)
# process() expects output like "<dir>/<basename>" - it will create
# a "<timestamp>_<chart_name>_<basename_stem>/" subfolder next to it
# AND write a "<timestamp>_<chart_name>_<basename_stem>.svg" SVG.
output_path = os.path.join(template_out_dir, input_basename)
t0 = time.time()
ok = False
err_msg = ""
try:
ok = process(
input=input_path,
output=output_path,
base_url=base_url,
api_key=api_key,
chart_name=chart_name,
)
except BaseException as e:
err_msg = f"{type(e).__name__}: {e}"
tb = traceback.format_exc()
err_msg = (err_msg + " | " + tb.splitlines()[-1])[:300]
elapsed = time.time() - t0
# Find the final SVG that was produced (most-recent matching file).
stem = os.path.splitext(input_basename)[0]
final_svg = None
final_svg_size = 0
chart_svg_path = None
chart_svg_is_fallback = False
n_paths = 0 # actually n_shapes; legacy local name
n_text = 0
if os.path.isdir(template_out_dir):
candidates = [
f for f in os.listdir(template_out_dir)
if f.endswith(f"_{chart_name}_{stem}.svg")
]
if candidates:
candidates.sort()
final_svg = os.path.join(template_out_dir, candidates[-1])
final_svg_size = os.path.getsize(final_svg)
n_paths, n_text = _count_svg_elements(final_svg)
# The per-chart subfolder also contains the raw chart SVG.
sub_candidates = [
d for d in os.listdir(template_out_dir)
if os.path.isdir(os.path.join(template_out_dir, d))
and d.endswith(f"_{chart_name}_{stem}")
]
if sub_candidates:
sub_candidates.sort()
chart_subdir = os.path.join(template_out_dir, sub_candidates[-1])
chart_svg_path = os.path.join(chart_subdir, "chart.svg")
chart_svg_is_fallback = _is_fallback_svg(chart_svg_path)
return {
"chart_name": chart_name,
# Use the plan-level key (may include "<dir>/<filename>") so resume
# matches across multi-pool plans. Falls back to bare basename when
# the caller didn't provide one.
"input": data_key or input_basename,
"ok": bool(ok),
"elapsed_s": round(elapsed, 2),
"final_svg": final_svg or "",
"final_svg_bytes": final_svg_size,
"chart_svg": chart_svg_path or "",
"chart_svg_fallback_png": chart_svg_is_fallback,
"n_shapes": n_paths,
"n_text": n_text,
"err": err_msg,
}
def main():
args = parse_args()
with open(args.plan, "r") as fh:
plan = json.load(fh)
# Resolve data dirs: CLI override > plan.data_dirs > legacy plan.data_dir.
if args.data_dir:
data_dirs = [Path(p) for p in args.data_dir]
elif "data_dirs" in plan:
data_dirs = [Path(p) for p in plan["data_dirs"]]
else:
data_dirs = [Path(plan["data_dir"])]
for d in data_dirs:
if not d.is_dir():
raise SystemExit(f"data dir is not a directory: {d}")
# Map "<dir-name>/<filename>" -> absolute path, falling back to
# bare basenames when an old plan was generated against a single dir.
file_lookup: dict[str, str] = {}
for d in data_dirs:
for f in d.glob("*.json"):
file_lookup.setdefault(f.name, str(f))
file_lookup[f"{d.name}/{f.name}"] = str(f)
def resolve_data(rel: str) -> str:
if rel in file_lookup:
return file_lookup[rel]
bn = os.path.basename(rel)
if bn in file_lookup:
return file_lookup[bn]
raise FileNotFoundError(
f"plan references data file '{rel}' not found in any of: "
+ ", ".join(str(d) for d in data_dirs)
)
output_dir = Path(args.output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
# Ensure parent process also has chrome path set so any in-proc work works.
os.environ.setdefault("PUPPETEER_EXECUTABLE_PATH", args.chrome_path)
tracker = ProgressTracker(
plan=plan,
output_dir=output_dir,
match_csv_path=Path(args.match_csv) if args.match_csv else None,
min_data_for_plan=args.min_data_for_skipped,
)
tracker.open_csv(resume=args.resume)
# Build job list, skipping already-done ones when --resume.
jobs = []
skipped_resume = 0
for tpl in plan["templates"]:
chart_name = tpl["chart_name"]
template_out_dir = output_dir / chart_name
template_out_dir.mkdir(parents=True, exist_ok=True)
for data_basename in tpl["picked_data_files"]:
if tracker.already_done(chart_name, data_basename):
skipped_resume += 1
continue
input_path = resolve_data(data_basename)
jobs.append((chart_name, input_path, data_basename,
str(template_out_dir), "", ""))
if args.limit:
jobs = jobs[: args.limit]
print(
f"Plan: templates={len(plan['templates'])} "
f"total-tasks={tracker.total_tasks} resume-skipped={skipped_resume} "
f"to-run={len(jobs)} threads={args.threads}"
)
# Persist an initial PROGRESS.md so the file exists right away.
tracker.flush()
t_start = time.time()
with ProcessPoolExecutor(
max_workers=args.threads,
initializer=_worker_init,
initargs=(args.chrome_path,),
) as ex:
futures = [ex.submit(_run_one, j) for j in jobs]
done = 0
for fut in as_completed(futures):
res = fut.result()
tracker.add(res)
done += 1
elapsed = time.time() - t_start
rate = done / max(elapsed, 1e-6)
remaining = (len(jobs) - done) / max(rate, 1e-6)
ok_str = "T" if res["ok"] else "F"
print(
f"[{done:4d}/{len(jobs):4d}] "
f"ok={ok_str} "
f"{res['chart_name']:38s} "
f"data={res['input'][:32]:32s} "
f"t={res['elapsed_s']:5.1f}s "
f"size={res['final_svg_bytes']/1024:5.0f}KB "
f"shapes={res['n_shapes']:4d} "
f"| eta {remaining/60:.1f}min",
flush=True,
)
tracker.close()
total_t = time.time() - t_start
print()
print(f"Wrote per-task report: {tracker.tasks_csv}")
print(f"Wrote per-template summary: {tracker.summary_csv}")
print(f"Wrote progress doc: {tracker.progress_md}")
print(f"Total time this session: {total_t/60:.1f} min")
# Always (re)generate the HTML preview at the end of a session so the
# user has a single browsable artifact. Failure to build the preview
# shouldn't fail the whole run.
preview_path = output_dir / "preview.html"
try:
from scripts.build_quality_preview import build_preview
build_preview(output_dir, preview_path)
print(f"Wrote HTML preview: {preview_path}")
except Exception as e:
print(f"WARN: failed to build HTML preview: {e}")
if __name__ == "__main__":
main()