tinydoc-5m / tinydoc /evaluate.py
harryfrz's picture
tinydoc-5m v4: OmniDocBench EN text edit 0.117 (v3 0.127), inline maths as text, invoice summary tables
e24bf15 verified
Raw History Blame Contribute Delete
19.5 kB
"""Evaluate the full system on held-out *real* documents, plus robustness and unseen-font synthetic checks.
python -m tinydoc.evaluate --weights models # all held-out real test sets
python -m tinydoc.evaluate --pages my_docs/pages.jsonl # your own annotated documents
python -m tinydoc.evaluate --robustness 100 --synthetic 150 # + rotation/skew and unseen-font docs
Metrics: detection P/R/F1 (IoU 0.5), recognition CER on ground-truth boxes, per-field accuracy, exact numeric
accuracy, how often a field is wrong vs. left empty, how often a wrong value was NOT flagged for review,
orientation accuracy under rotation, page latency, and parameter count.
Annotation format: see tinydoc/data/convert.py (one JSON page record per line).
"""
import argparse
import json
import re
import tempfile
import time
from collections import defaultdict
from pathlib import Path
import cv2
import numpy as np
from .geometry import quad_size
from .pipeline.assemble import (assemble_page_structure, block_groups, block_order, groups_from_block_map, number_style,
parse_amount, parse_date, structure_from_groups, xy_cut)
from .pipeline.detect import match_quads
from .train import cer
NUMERIC = ("subtotal", "tax", "total", "amount_paid", "change")
TEXTUAL = ("invoice_number", "date", "merchant_name")
def _norm(s):
return re.sub(r"[\s:#.,'-]", "", (s or "")).lower()
def field_scores(result, gt_fields, scores):
"""Compare one document's output with its ground truth; accumulate into scores[field][...]."""
pred_fields = result.get("fields") or {}
doc_flagged = bool((result.get("validation") or {}).get("needs_review"))
gt_money = [gt_fields.get(k) for k in NUMERIC if gt_fields.get(k)]
dec = number_style(gt_money + [i.get("amount") for i in gt_fields.get("items", []) if i.get("amount")])
for k in NUMERIC + TEXTUAL:
g = gt_fields.get(k)
if not g:
continue
p = pred_fields.get(k)
pv = p.get("value") if isinstance(p, dict) else None
if k in NUMERIC:
gv = parse_amount(g, dec)
if gv is None:
continue
ok = pv is not None and abs(abs(pv) - abs(gv)) < 0.005
elif k == "date":
giso, gcands = parse_date(g)
if giso is None and len(gcands) > 1:
# genuinely ambiguous date (e.g. 07/05/22): the right answer is null plus both candidates
ok = pv is None and isinstance(p, dict) and set(p.get("candidates") or []) == set(gcands)
else:
ok = pv is not None and (pv == giso if giso else _norm(p.get("raw")) == _norm(g))
if pv is None and isinstance(p, dict) and p.get("candidates") and giso in p["candidates"]:
scores[k]["flagged_ambiguous"] += 1
else:
ok = pv is not None and _norm(pv) == _norm(g)
wrong = pv is not None and not ok
s = scores[k]
s["n"] += 1
s["correct"] += ok
s["empty"] += pv is None
s["wrong"] += wrong
# dangerous case: a wrong value that neither the field nor the document flags for review
s["wrong_unflagged"] += wrong and not (p.get("needs_review") or doc_flagged)
gi = [i for i in gt_fields.get("items", []) if i.get("amount")]
if gi:
pi = pred_fields.get("items") or []
scores["item_count"]["n"] += 1
scores["item_count"]["correct"] += len(pi) == len(gi)
g_amts = sorted(parse_amount(i["amount"], dec) or 0 for i in gi)
p_amts = sorted((i.get("amount") or {}).get("value") or 0 for i in pi)
scores["item_amounts"]["n"] += 1
scores["item_amounts"]["correct"] += len(g_amts) == len(p_amts) and all(abs(a - b) < 0.005 for a, b in zip(g_amts, p_amts))
def summarise_fields(fscores):
out = {}
for k, v in fscores.items():
row = {"accuracy": round(v["correct"] / v["n"], 4), "n": v["n"]}
if k not in ("item_count", "item_amounts"):
row.update(wrong_rate=round(v["wrong"] / v["n"], 4), empty_rate=round(v["empty"] / v["n"], 4),
wrong_unflagged_rate=round(v["wrong_unflagged"] / v["n"], 4))
if v.get("flagged_ambiguous"):
row["flagged_ambiguous"] = v["flagged_ambiguous"]
out[k] = row
nums = [fscores[k] for k in NUMERIC if fscores[k]["n"]]
return out, (round(sum(v["correct"] for v in nums) / sum(v["n"] for v in nums), 4) if nums else None)
def run_doc(eng, img_rgb):
with tempfile.NamedTemporaryFile(suffix=".png") as tmp:
cv2.imwrite(tmp.name, np.ascontiguousarray(img_rgb[:, :, ::-1]))
t0 = time.time()
out = eng.process(tmp.name)
return out, time.time() - t0
def eval_real(eng, args):
report = {}
sources = args.pages or [str(p) for p in sorted(Path(args.real_root).glob(f"*/{args.split}/pages.jsonl"))]
for pages_path in sources:
base = Path(pages_path).parent
name = base.parent.name if base.name in ("train", "val", "test") else base.name
recs = [json.loads(l) for l in open(pages_path)]
if args.limit:
recs = recs[: args.limit]
if not recs:
continue
det = {"tp": 0, "fp": 0, "fn": 0}
rec_err = rec_chars = 0
fscores = defaultdict(lambda: defaultdict(int))
latency, stage_ms = [], defaultdict(list)
for r in recs:
img = cv2.imread(str(base / r["image"]), cv2.IMREAD_COLOR)[:, :, ::-1].copy()
segs = r["segments"]
scored = [not s["ignore"] and s.get("label") != "formula" for s in segs] # maths is skipped by design
gt_q = [np.asarray(s["quad"], np.float32) for s, ok in zip(segs, scored) if ok]
if r["use"]["detector"] and gt_q:
pred = [q for q, _ in eng.detect(img)]
ign = [np.asarray(s["quad"], np.float32) for s, ok in zip(segs, scored) if not ok] + [np.asarray(q, np.float32) for q in r["ignore_regions"]]
m, up, ug = match_quads(pred, gt_q)
if ign and up:
_, up, _ = match_quads([pred[i] for i in up], ign, 0.1)
det["tp"] += len(m)
det["fp"] += len(up)
det["fn"] += len(ug)
if r["use"]["recognizer"]:
ok = [s for s, sc in zip(segs, scored) if sc and s["text"] and not s.get("rec_skip")
and quad_size(s["quad"])[1] >= 9][: args.lines_per_page]
if ok:
texts = eng.recognize(img, [np.asarray(s["quad"], np.float32) for s in ok])
for s, (t, _) in zip(ok, texts):
rec_err += cer(t, s["text"]) * len(s["text"])
rec_chars += len(s["text"])
if r.get("fields"):
out, dt = run_doc(eng, img)
latency.append(dt)
for k, v in out["timing_ms"].items():
stage_ms[k].append(v)
field_scores(out, r["fields"], fscores)
res = {"documents": len(recs)}
if det["tp"] + det["fn"]:
p = det["tp"] / max(det["tp"] + det["fp"], 1)
rc = det["tp"] / max(det["tp"] + det["fn"], 1)
res["detection"] = {"precision": round(p, 4), "recall": round(rc, 4), "f1": round(2 * p * rc / max(p + rc, 1e-9), 4)}
if rec_chars:
res["recognition_cer_on_gt_boxes"] = round(rec_err / rec_chars, 4)
if fscores:
res["fields"], res["exact_numeric_accuracy"] = summarise_fields(fscores)
if latency:
res["latency_ms_per_page"] = {"mean": round(1000 * np.mean(latency), 1), "p50": round(1000 * np.median(latency), 1),
"p90": round(1000 * np.percentile(latency, 90), 1),
"stages_mean": {k: round(float(np.mean(v)), 1) for k, v in stage_ms.items() if k != "total"}}
report[name] = res
print(f"{name}: {json.dumps(res)}", flush=True)
return report
def _pair_f1(pred_blocks, gt_blocks):
"""Pairwise F1 of 'these two segments are in the same block' (pred and gt: lists of index lists)."""
def pairs(blocks):
return {(a, b) for g in blocks for i, a in enumerate(sorted(g)) for b in sorted(g)[i + 1:]}
p, g = pairs(pred_blocks), pairs(gt_blocks)
tp = len(p & g)
return tp, len(p) - tp, len(g) - tp
def eval_blocks(eng, args):
"""Paragraph/block grouping on DocLayNet pages from ground-truth lines, three ways: geometric rules, the
extractor's same_block relation, and the detector's block regions. Picks the one the engine should use
(returned as "blocks"; the caller writes it to config.json). A learned method must beat the rules by 0.01."""
from .schema import FIELD_LABELS, REL_TYPES
base = Path(args.real_root) / "doclaynet" / args.split
if not (base / "pages.jsonl").exists():
return None
recs = [json.loads(l) for l in open(base / "pages.jsonl")][: args.blocks]
tot = {"geometric": [0, 0, 0], "model": [0, 0, 0], "detector": [0, 0, 0]}
seen = set()
def add(mode, blocks_or_groups, gt):
seen.add(mode)
tot[mode] = [a + b for a, b in zip(tot[mode], _pair_f1(blocks_or_groups, list(gt.values())))]
for r in recs:
keep = [s for s in r["segments"] if not s["ignore"] and s.get("block") is not None]
if len(keep) < 2:
continue
segs = [{"id": i, "text": s["text"], "bbox": [min(x for x, _ in s["quad"]), min(y for _, y in s["quad"]),
max(x for x, _ in s["quad"]), max(y for _, y in s["quad"])], "conf": 1.0}
for i, s in enumerate(keep)]
gt = {}
for i, s in enumerate(keep):
gt.setdefault(s["block"], []).append(i)
labels, rel, _ = eng.extract(segs, r["width"], r["height"])
names = [FIELD_LABELS[int(x.argmax())] for x in labels]
boxes = [s["bbox"] for s in segs]
blocks, _ = assemble_page_structure(segs, xy_cut(boxes), names, rel[0])
add("geometric", [b["source"] for b in blocks], gt)
if rel.shape[0] > REL_TYPES.index("same_block"):
groups = block_order(boxes, block_groups(boxes, names, rel[REL_TYPES.index("same_block")]))
add("model", [b["source"] for b in structure_from_groups(segs, groups, names, rel[0])[0]], gt)
img = cv2.imread(str(base / r["image"]), cv2.IMREAD_COLOR)
if img is not None:
_, block_map, scale = eng.detect_full(img[:, :, ::-1].copy())
if block_map is not None:
groups = block_order(boxes, groups_from_block_map(boxes, block_map, scale))
add("detector", [b["source"] for b in structure_from_groups(segs, groups, names, rel[0])[0]], gt)
out = {"pages": len(recs)}
for k, (tp, fp, fn) in tot.items():
if k in seen and tp + fp + fn:
out[f"{k}_pair_f1"] = round(2 * tp / max(2 * tp + fp + fn, 1), 4)
best = "geometric"
for k in ("model", "detector"):
if out.get(f"{k}_pair_f1", 0) > out.get(f"{best}_pair_f1", 0) + (0.01 if best == "geometric" else 0):
best = k
out["blocks"] = best
out["use_model_blocks"] = best == "model" # older readers
print(f"block grouping: {json.dumps(out)}", flush=True)
return out
def eval_robustness(eng, args):
"""CORD test receipts rotated by 90/180/270 degrees and skewed: orientation accuracy and field survival."""
base = Path(args.real_root) / "cord-v2" / args.split
recs = [json.loads(l) for l in open(base / "pages.jsonl")][: args.robustness]
out = {}
for cond in ("upright", "rot90", "rot180", "rot270", "skew+6", "skew-6"):
fscores = defaultdict(lambda: defaultdict(int))
orient_ok = 0
for r in recs:
img = cv2.imread(str(base / r["image"]), cv2.IMREAD_COLOR)[:, :, ::-1].copy()
expect = 0
if cond.startswith("rot"):
k = int(cond[3:]) // 90
img = np.ascontiguousarray(np.rot90(img, -k)) # page rotated k*90 degrees clockwise
expect = 90 * k
elif cond.startswith("skew"):
ang = float(cond[4:])
h, w = img.shape[:2]
m = cv2.getRotationMatrix2D((w / 2, h / 2), ang, 1.0)
img = cv2.warpAffine(img, m, (w, h), borderMode=cv2.BORDER_REPLICATE)
res, _ = run_doc(eng, img)
orient_ok += res["pages"][0].get("rotation_corrected_deg", 0) == expect
field_scores(res, r["fields"], fscores)
fs, num = summarise_fields(fscores)
out[cond] = {"orientation_accuracy": round(orient_ok / len(recs), 4), "exact_numeric_accuracy": num,
"total_accuracy": fs.get("total", {}).get("accuracy")}
print(f"robustness {cond}: {json.dumps(out[cond])}", flush=True)
return out
def eval_synthetic(eng, args):
"""Receipts and invoices rendered with font families held out from training (all fields known)."""
from .synth.augment import augment_page
from .synth.documents import build, render
from .synth.fonts import FontBank
bank = FontBank(args.fonts, "val")
out = {}
for kind in ("receipt", "invoice"):
fscores = defaultdict(lambda: defaultdict(int))
doc_ok = 0
for i in range(args.synthetic):
rng = np.random.default_rng([31337, i, kind == "invoice"])
L = build(rng, bank, kind)
img = render(L, rng)
img, _, _ = augment_page(img, [], [], rng, strength=0.7, geo_mode=str(rng.choice(["none", "rotate"])))
res, _ = run_doc(eng, img)
doc_ok += (res["document_type"] or {}).get("value") == kind
field_scores(res, L.fields, fscores)
fs, num = summarise_fields(fscores)
out[kind] = {"documents": args.synthetic, "doc_type_accuracy": round(doc_ok / args.synthetic, 4),
"fields": fs, "exact_numeric_accuracy": num}
print(f"synthetic {kind} (unseen fonts): {json.dumps(out[kind])}", flush=True)
return out
def _math_counts(pred, gt):
"""Maths characters (sub/superscripts, Greek, operators) in gt that pred has, in gt, and extra ones in pred."""
from collections import Counter
from .charset import MATH_CHARS
g = Counter(c for c in gt if c in MATH_CHARS)
p = Counter(c for c in pred if c in MATH_CHARS)
return sum(min(n, p[c]) for c, n in g.items()), sum(g.values()), sum(max(0, n - g[c]) for c, n in p.items())
def _broken_encoding(text):
"""Ground truth from a PDF with a broken font encoding ("> Ì > Ê μ Õ i Ì"): mostly single characters, many of
them accented capitals. One DocLayNet test document is like this; its lines say nothing about OCR quality."""
chars = [c for c in text if not c.isspace()]
words = text.split()
return (len(chars) >= 4 and sum(c in "ÀÁÂÄÃÅÇÈÉÊËÌÍÎÏÑÒÓÔÖÕØÙÚÛÜÝ" for c in chars) / len(chars) > 0.15
and sum(len(w) == 1 for w in words) / len(words) > 0.5)
def eval_math(eng, args):
"""Inline maths read as text: synthetic lines in held-out font families, and real DocLayNet test lines that
contain sub/superscripts, Greek or maths symbols (read at their ground-truth boxes)."""
from .charset import MATH_CHARS
from .data.datasets import synth_math_line
from .synth.fonts import FontBank
def summary(pairs):
hit = tot = extra = 0
for p, g in pairs:
h, t, e = _math_counts(p, g)
hit, tot, extra = hit + h, tot + t, extra + e
chars = sum(len(g) for _, g in pairs)
return {"lines": len(pairs), "cer": round(sum(cer(p, g) * len(g) for p, g in pairs) / max(chars, 1), 4),
"exact": round(sum(p == g for p, g in pairs) / max(len(pairs), 1), 4),
"math_char_recall": round(hit / max(tot, 1), 4), "math_chars": tot, "invented_math_chars": extra}
bank, rng, pairs = FontBank(args.fonts, "val"), np.random.default_rng(4242), []
for _ in range(args.math):
gray, (x0, y0, x1, y1), text = synth_math_line(rng, bank)
img = np.repeat(gray[:, :, None], 3, axis=2)
quad = np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], np.float32)
pairs.append((eng.recognize(img, [quad])[0][0], text))
out = {"synthetic_unseen_fonts": summary(pairs)}
print(f"maths lines, synthetic (unseen fonts): {json.dumps(out['synthetic_unseen_fonts'])}", flush=True)
pairs, base = [], Path(args.real_root) / "doclaynet" / args.split
for line in open(base / "pages.jsonl") if (base / "pages.jsonl").exists() else []:
r = json.loads(line)
segs = [s for s in r["segments"] if any(c in MATH_CHARS for c in s["text"]) and not s["ignore"]
and not s.get("rec_skip") and s.get("label") != "formula" and quad_size(s["quad"])[1] >= 9
and not _broken_encoding(s["text"])]
if not segs:
continue
img = cv2.imread(str(base / r["image"]), cv2.IMREAD_COLOR)[:, :, ::-1].copy()
texts = eng.recognize(img, [np.asarray(s["quad"], np.float32) for s in segs])
pairs += [(t, s["text"]) for s, (t, _) in zip(segs, texts)]
if len(pairs) >= args.math:
break
if pairs:
out["doclaynet"] = summary(pairs)
print(f"maths lines, DocLayNet {args.split}: {json.dumps(out['doclaynet'])}", flush=True)
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--backend", choices=["onnx", "torch"], default="onnx")
ap.add_argument("--weights", default="models")
ap.add_argument("--device", default="cpu")
ap.add_argument("--real-root", default="data/real")
ap.add_argument("--fonts", default="fonts")
ap.add_argument("--split", default="test")
ap.add_argument("--pages", nargs="*", help="explicit pages.jsonl files (e.g. your own annotated set)")
ap.add_argument("--limit", type=int, default=0, help="documents per source (0 = all)")
ap.add_argument("--lines-per-page", type=int, default=40)
ap.add_argument("--robustness", type=int, default=0, help="CORD test receipts for rotation/skew checks")
ap.add_argument("--synthetic", type=int, default=0, help="unseen-font synthetic docs per kind")
ap.add_argument("--blocks", type=int, default=0, help="DocLayNet pages for the block-grouping comparison")
ap.add_argument("--math", type=int, default=0, help="lines for the inline-maths check (synthetic and DocLayNet)")
ap.add_argument("--out", default="eval_report.json")
args = ap.parse_args()
from .pipeline.engine import Engine
eng = Engine(args.weights, backend=args.backend, device=args.device)
report = {"parameters": eng.backend.params, "total_parameters": int(sum(eng.backend.params.values())),
"backend": args.backend, "sources": eval_real(eng, args)}
if args.robustness:
report["robustness"] = eval_robustness(eng, args)
if args.synthetic:
report["synthetic_unseen_fonts"] = eval_synthetic(eng, args)
if args.blocks:
report["block_grouping"] = eval_blocks(eng, args)
if args.math:
report["inline_maths"] = eval_math(eng, args)
Path(args.out).write_text(json.dumps(report, indent=2))
print(f"wrote {args.out}")
if __name__ == "__main__":
main()