Model Test 1 — Document Parse: figure/table, two tracks¶

Separate detection (localization) from understanding. Dataset: 3 paper pages (table_page · chart_page · diagram_page).

  • Note: notes/01-figure-table.md · scores: results/scores_exp1b.json
In [ ]:
import sys, os, json, glob, re, copy
from pathlib import Path
# Run from the repo root (move up if the notebook was opened inside notebooks/)
if Path.cwd().name == "notebooks": os.chdir("..")
sys.path.insert(0, "code")
import upstage_eval as ue
import metrics as M
from bs4 import BeautifulSoup
assert os.environ.get("UPSTAGE_API_KEY"), "export UPSTAGE_API_KEY first"
RES, FIG = Path("results"), Path("figures")
for d in [RES, RES/"parse", RES/"ie", RES/"report", FIG]: d.mkdir(parents=True, exist_ok=True)
FORCE = False  # set True to re-call the API (ignore cache)
def cached_parse(path, name, cats=("table",)):
    out = RES/"parse"/f"{name}.json"
    if out.exists() and not FORCE: return json.loads(out.read_text())
    resp = ue.document_parse(path, base64_categories=cats)
    out.write_text(json.dumps(resp, ensure_ascii=False)); return resp

1. Parse + crops + bbox overlay (Track A input)¶

In [ ]:
EXP1B = {"table_page":"data/exp1b_paper/table_page.pdf",
         "chart_page":"data/exp1b_paper/chart_page.pdf",
         "diagram_page":"data/exp1b_paper/diagram_page.pdf"}
for name, path in EXP1B.items():
    resp = cached_parse(path, f"1b_{name}", cats=("table","figure","chart","equation"))
    ue.save_crops(resp, FIG/f"1b_{name}_crops")
    ue.draw_bboxes(path, resp, FIG/f"1b_{name}_overlay.png")
    print(f"{name:14s} {ue.categories(resp)} -> saved overlay + crops")

2. Scoring — detection recall + chart series/type (+ table TEDS for reference)¶

In [ ]:
"""Quantitative scoring for the figure/table experiment:
 - Track A localization recall + Track B chart series/type stats
 - table TEDS (parse table HTML vs gt/exp1_tables/*.html)
Run: ./.venv/bin/python code/score_exp1.py
"""
import sys, json, re
from pathlib import Path
from bs4 import BeautifulSoup
sys.path.insert(0, "code")
import upstage_eval as ue
import metrics as M

RES = Path("results"); out = {}

# ---------- Track A: localization recall ----------
loc = json.load(open("gt/exp1b_localization.json"))
cats = ["table","figure","chart","caption","equation"]
agg = {c: [0,0] for c in cats}   # [detected_correct, true]
per_page = {}
for pg, v in loc.items():
    if not isinstance(v, dict) or "true_counts" not in v: continue
    det, tru = v["detected"], v["true_counts"]
    row = {}
    for c in cats:
        t = tru.get(c,0); d = det.get(c,0)
        if t==0 and d==0: continue
        hit = min(d,t)                       # recall: does detection cover the true count?
        agg[c][0]+=hit; agg[c][1]+=t
        row[c] = f"{hit}/{t}" + (f" (+{d-t} over-detected)" if d>t else "")
    per_page[pg]=row
prim = ["table","figure","chart"]
prim_hit = sum(agg[c][0] for c in prim); prim_tot = sum(agg[c][1] for c in prim)
out["localization"] = {
  "per_page": per_page,
  "primary_recall": f"{prim_hit}/{prim_tot}",
  "caption_recall": f"{agg['caption'][0]}/{agg['caption'][1]}",
  "note": "primary=table+chart+figure. caption: missed on diagram_page (detected as paragraph)."
}

# ---------- Track B: chart series/type ----------
cg = json.load(open("gt/exp1b_chart_values.json")).get("charts_VERIFY",{})
els = ue.elements(json.loads((RES/"parse"/"1b_chart_page.json").read_text()))
charts = [e for e in els if e.get("category")=="chart"]
rows=[]; series_lost=0; type_wrong=0
for e in charts:
    h=e["content"]["html"]
    pred_series = len(BeautifulSoup(h,"lxml").select("tbody tr"))
    m=re.search(r"Chart Type:\s*([a-zA-Z]+)", h); pred_type=m.group(1) if m else "?"
    g=cg.get(f"el{e['id']}",{}); true_series=g.get("true_series"); true_type=g.get("true_type")
    lost = (true_series or 0) > pred_series
    twrong = true_type and pred_type.lower() not in true_type.lower()
    series_lost+=lost; type_wrong+=twrong
    rows.append({"id":e["id"],"pred_type":pred_type,"true_type":true_type,
                 "pred_series":pred_series,"true_series":true_series,
                 "series_lost":lost,"type_wrong":bool(twrong)})
out["chart"] = {"charts":rows,
  "series_lost": f"{series_lost}/{len(charts)}",
  "type_wrong":  f"{type_wrong}/{len(charts)}",
  "note": "all 4 charts are 2-series dot plots → collapsed to a single item_01 series, type misclassified as line/bar."}

# ---------- table TEDS ----------
def parse_tables(doc):
    els=ue.elements(json.loads((RES/"parse"/f"{doc}.json").read_text()))
    return [e["content"]["html"] for e in els if e.get("category")=="table"]
def gt_tables(doc):
    html=Path(f"gt/exp1_tables/{doc}.html").read_text()
    return [str(t) for t in BeautifulSoup(html,"lxml").find_all("table")]  # split multiple <table>s

teds_res=[]
for doc in ["complex_table_1","complex_table_2","korean_table"]:
    P, G = parse_tables(doc), gt_tables(doc)
    n=min(len(P),len(G))
    scores=[M.teds(P[i], G[i]) for i in range(n)]
    circular=all(P[i].strip()==G[i].strip() for i in range(n))  # is GT verbatim the parse output?
    teds_res.append({"doc":doc,"n_tables":n,
                     "teds_per_table":[round(s,3) for s in scores],
                     "teds_mean":round(sum(scores)/n,3) if n else None,
                     "gt_is_parse_output":circular})
out["teds"]={"by_doc":teds_res,
  "VALID": not any(t["gt_is_parse_output"] for t in teds_res),
  "note": "GT HTML is identical to the Document Parse output → TEDS=1.0 is circular (meaningless). "
          "A valid TEDS needs an independent GT built by hand from the source PDF."}

json.dump(out, open(RES/"scores_exp1b.json","w"), ensure_ascii=False, indent=2)
print("=== Localization ===")
print(" primary(table+chart+figure):", out["localization"]["primary_recall"],
      "| caption:", out["localization"]["caption_recall"])
for pg,r in per_page.items(): print("  ",pg,r)
print("=== Chart === series_lost:", out["chart"]["series_lost"], "| type_wrong:", out["chart"]["type_wrong"])
print("=== TEDS ===")
for t in teds_res: print("  ",t["doc"], "mean", t["teds_mean"], t["teds_per_table"])
print("saved -> results/scores_exp1b.json")