File size: 9,637 Bytes
9e7ba62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
benchmark.py - word-level OCR benchmark on handwritten word crops (used by notebook CELL 6).

Recreated from how the notebook calls it; the original lived only in Colab's /content.

Data layout (bench_sample.zip unpacks to this):
    bench_sample/<lang>/<image>          lang = hi-IN | kn-IN | ml-IN | ta-IN | te-IN
    bench_sample/<lang>/labels.csv       file,text   (also .tsv / .txt with "file<TAB>text")

    python benchmark.py sample <dataset_root> --per-lang 500     # -> bench_sample.zip (same layout, random subset)
    python benchmark.py summary bench_direct.csv bench_direct_langhint_remap.csv

Document benchmark (full pages / PDFs against a ground-truth .txt with the same name):
    bench_docs/<doc>.<jpg|png|pdf|...>   +   bench_docs/<doc>.txt     (UTF-8, the whole expected text)
    bench_docs/<lang>/<doc>.* + .txt     optional: a language folder sets the language for script checks

In the notebook:
    run(predict_fn, "/content/bench_sample", tag="direct", limit=None)   # -> bench_direct.csv + table
    summary(["bench_direct.csv", "bench_direct_remap.csv"])
    upload_docs("/content/bench_docs")                                    # Colab file picker: docs + .txt
    run_docs(predict_doc, "/content/bench_docs", tag="docs", lang="kn-IN") # -> bench_docs.csv + table
"""
from __future__ import annotations

import csv, io, random, re, sys, time, unicodedata, zipfile
from pathlib import Path

from validate import cer, nfc, script_purity, wer

IMAGE_EXT = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp", ".webp"}
DOC_EXT = IMAGE_EXT | {".pdf"}
LANGS = {"hi-IN", "kn-IN", "ml-IN", "ta-IN", "te-IN"}
FIELDS = ["tag", "lang", "file", "gt", "pred", "cer", "cer_norm", "word_acc", "wa_norm", "wer",
          "empty", "halluc", "wrong_script", "seconds"]
ZERO_WIDTH = dict.fromkeys(map(ord, "​‌‍"))


# ---------------------------------------------------------------- scoring
def normalize(s: str) -> str:
    """Lenient form for CERnorm / WAnorm: NFC, no zero-width joiners, no punctuation or spaces,
    Indic digits -> ASCII. Separates reading errors from formatting differences."""
    s = nfc(s).translate(ZERO_WIDTH)
    s = "".join(str(unicodedata.digit(c)) if unicodedata.category(c) == "Nd" else c for c in s)
    return "".join(c for c in s if unicodedata.category(c)[0] not in ("P", "Z", "S", "C"))


def is_hallucination(gt: str, pred: str) -> bool:
    """Output that is not a reading of the word: a sentence about the image, LaTeX, or far too long."""
    letters = [c for c in pred if unicodedata.category(c)[0] == "L"]
    latin = sum("A" <= c <= "z" for c in letters)
    gt_latin = any("A" <= c <= "z" for c in gt)
    return bool(pred.strip()) and (
        (not gt_latin and letters and latin / len(letters) > 0.5)
        or "$$" in pred or "\\text" in pred
        or len(pred) > 3 * len(gt) + 10)


def score(gt: str, pred: str, lang: str) -> dict:
    gt, pred = nfc(gt).strip(), nfc(pred or "").strip()
    one = lambda s: re.sub(r"\s+", " ", s)
    cap = lambda x: round(min(x, 1.0), 4)    # a word is at most fully wrong; stops one long hallucination skewing the mean
    return {
        "cer": cap(cer(pred, gt)),
        "cer_norm": cap(cer(normalize(pred), normalize(gt))),
        "word_acc": int(one(pred) == one(gt)),
        "wa_norm": int(normalize(pred) == normalize(gt)),
        "wer": cap(wer(pred, gt)),
        "empty": int(not pred),
        "halluc": int(is_hallucination(gt, pred)),
        "wrong_script": int(bool(pred) and script_purity(pred, lang) < 0.5),
    }


# ---------------------------------------------------------------- data
def load_labels(lang_dir: Path) -> dict[str, str]:
    labels = {}
    for f in sorted(lang_dir.glob("*.csv")) + sorted(lang_dir.glob("*.tsv")) + sorted(lang_dir.glob("*.txt")):
        delim = "," if f.suffix == ".csv" else "\t"
        for row in csv.reader(open(f, encoding="utf-8"), delimiter=delim):
            if len(row) >= 2 and row[0] not in ("file", "filename", "image"):
                labels[Path(row[0]).name] = row[1]
    return labels


def items(root, limit=None):
    """(lang, image path, ground truth) for every labelled image, `limit` per language."""
    out = []
    for lang_dir in sorted(p for p in Path(root).iterdir() if p.is_dir()):
        labels = load_labels(lang_dir)
        imgs = sorted(p for p in lang_dir.iterdir() if p.suffix.lower() in IMAGE_EXT and p.name in labels)
        out += [(lang_dir.name, p, labels[p.name]) for p in imgs[:limit]]
    return out


def doc_items(root, lang=None):
    """(lang, document path, ground truth) for every document with a <stem>.txt next to it.
    Language: the parent folder if it is a language code, else `lang`, else "-" (script check skipped)."""
    out, missing = [], []
    for p in sorted(Path(root).rglob("*")):
        if p.suffix.lower() not in DOC_EXT or p.name.startswith("."):
            continue
        gt = p.with_suffix(".txt")
        if not gt.is_file():
            missing.append(p.name)
            continue
        out.append((p.parent.name if p.parent.name in LANGS else lang or "-", p,
                    gt.read_text(encoding="utf-8-sig")))
    if missing:
        print(f"no ground truth .txt for {len(missing)} file(s), skipped: {', '.join(missing)}")
    return out


def upload_docs(dest="/content/bench_docs"):
    """Colab file picker: select documents and their .txt ground truth together (same file names)."""
    from google.colab import files as colab_files
    dest = Path(dest)
    dest.mkdir(parents=True, exist_ok=True)
    for name, data in colab_files.upload().items():
        if name.lower().endswith(".zip"):                # a zip of the whole folder also works
            zipfile.ZipFile(io.BytesIO(data)).extractall(dest)
        else:
            (dest / Path(name).name).write_bytes(data)
    for lang, p, gt in doc_items(dest):
        print(f"{p.relative_to(dest)}  ({lang}, ground truth {len(gt)} chars)")
    return dest


# ---------------------------------------------------------------- run + report
def run(predict, root, tag="direct", limit=None, out=None):
    """predict(path) -> text. Writes bench_<tag>.csv and prints the summary table."""
    return _bench(predict, items(root, limit), tag, out)


def run_docs(predict, root, tag="docs", lang=None, out=None):
    """Same as run(), on whole documents: predict(path) -> the full text of the image or PDF."""
    return _bench(predict, doc_items(root, lang), tag, out)


def _bench(predict, todo, tag, out):
    out = Path(out or f"bench_{tag}.csv")
    with open(out, "w", encoding="utf-8", newline="") as fh:
        wr = csv.DictWriter(fh, fieldnames=FIELDS)
        wr.writeheader()
        for i, (lang, path, gt) in enumerate(todo, 1):
            t0 = time.time()
            try:
                pred = predict(str(path)) or ""
            except Exception as e:                      # one bad crop must not stop a 2,500-image run
                print(f"{path.name}: {e}")
                pred = ""
            row = {"tag": tag, "lang": lang, "file": path.name, "gt": gt, "pred": pred,
                   "seconds": round(time.time() - t0, 3)}
            wr.writerow({**row, **score(gt, pred, lang)})
            if i % 100 == 0 or i == len(todo):
                print(f"{i}/{len(todo)}")
                fh.flush()
    summary([out])
    return out


def summary(paths):
    rows = [r for p in paths for r in csv.DictReader(open(p, encoding="utf-8"))]
    cols = [("CER", "cer"), ("CERnorm", "cer_norm"), ("WordAcc", "word_acc"), ("WAnorm", "wa_norm"),
            ("WER", "wer"), ("empty", "empty"), ("halluc", "halluc"), ("wrongScr", "wrong_script")]
    print(f"{'tag':<22}{'lang':<8}{'n':>6} " + " ".join(f"{h:>8}" for h, _ in cols) + f" {'s/img':>8} {'p95 s':>8}")
    for tag in sorted({r["tag"] for r in rows}):
        tr = [r for r in rows if r["tag"] == tag]
        for lang in ["ALL"] + sorted({r["lang"] for r in tr}):
            g = tr if lang == "ALL" else [r for r in tr if r["lang"] == lang]
            mean = lambda k: sum(float(r[k]) for r in g) / len(g)
            secs = sorted(float(r["seconds"]) for r in g)
            p95 = secs[min(len(secs) - 1, int(0.95 * len(secs)))]
            print(f"{tag:<22}{lang:<8}{len(g):>6} " + " ".join(f"{mean(k):>8.3f}" for _, k in cols)
                  + f" {mean('seconds'):>8.3f} {p95:>8.3f}")


def sample(dataset_root, per_lang=500, out="bench_sample.zip", seed=0):
    """Random `per_lang` crops per language from a dataset in the same layout -> zip for Colab."""
    rnd = random.Random(seed)
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as z:
        for lang_dir in sorted(p for p in Path(dataset_root).iterdir() if p.is_dir()):
            labels = load_labels(lang_dir)
            imgs = sorted(p for p in lang_dir.iterdir() if p.suffix.lower() in IMAGE_EXT and p.name in labels)
            pick = rnd.sample(imgs, min(per_lang, len(imgs)))
            for p in pick:
                z.write(p, f"bench_sample/{lang_dir.name}/{p.name}")
            buf = io.StringIO()
            csv.writer(buf).writerows([("file", "text")] + [(p.name, labels[p.name]) for p in pick])
            z.writestr(f"bench_sample/{lang_dir.name}/labels.csv", buf.getvalue())
            print(f"{lang_dir.name}: {len(pick)}")
    print("wrote", out)


if __name__ == "__main__":
    cmd, *rest = sys.argv[1:] or ["help"]
    if cmd == "summary":
        summary(rest)
    elif cmd == "sample":
        n = int(rest[rest.index("--per-lang") + 1]) if "--per-lang" in rest else 500
        sample(rest[0], n)
    else:
        print(__doc__)