File size: 19,471 Bytes
6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 d28e3c4 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 9d054d4 4dce182 9d054d4 4dce182 e24bf15 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 4dce182 9d054d4 d28e3c4 9d054d4 d28e3c4 9d054d4 d28e3c4 9d054d4 d28e3c4 9d054d4 d28e3c4 9d054d4 d28e3c4 9d054d4 d28e3c4 9d054d4 6a08b03 e24bf15 4dce182 6a08b03 4dce182 6a08b03 4dce182 6a08b03 9d054d4 e24bf15 4dce182 6a08b03 9d054d4 e24bf15 4dce182 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 | """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()
|