Download tinydoc/evaluate.py from harryfrz/tinydoc-5m: direct link, hf CLI and curl.
- Browser
- Download file 19.5 kB
-
https://huggingface.co/harryfrz/tinydoc-5m/resolve/main/tinydoc/evaluate.py
- Command line
-
hf download hf://harryfrz/tinydoc-5m/tinydoc/evaluate.py
-
curl -L -o evaluate.py https://huggingface.co/harryfrz/tinydoc-5m/resolve/main/tinydoc/evaluate.py
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() | |