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