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()