Download benchmark.py from Sidd2899/indic-ocr: direct link, hf CLI and curl.
- Browser
- Download file 9.64 kB
-
https://huggingface.co/spaces/Sidd2899/indic-ocr/resolve/main/benchmark.py
- Command line
-
hf download hf://spaces/Sidd2899/indic-ocr/benchmark.py
-
curl -L -o benchmark.py https://huggingface.co/spaces/Sidd2899/indic-ocr/resolve/main/benchmark.py
9.64 kB
| """ | |
| 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__) | |