grinpress-v3 / eval_ocr.py
skyuu72's picture
grinpress v3: student weights, ONNX fp32/int8, CRNN, eval assets, card
0d47286 verified
Raw History Blame Contribute Delete
11.3 kB
"""grinpress v3 OCR evaluation.
python eval_ocr.py refs # Phase 2: tesseract v2 / tesseract v3 / CRNN on the 48 reference renders
python eval_ocr.py models --unet onnx/unet_distilled_int8.onnx --text onnx/text_encoder_int8.onnx [--teacher ckpt]
# Phase 5: 48-grid at 2 and 4 steps (+14-grid teacher vs student) -> assets/eval_ocr_v3.json, eval_compare_v3.json,
# samples_4step_v3.png, samples_2step_v3.png, teacher_vs_student_v3.png
Scores (per string): exact = case-sensitive exact match after whitespace normalisation; exact_ci = v2's metric
(upper-cased, stray face-edge glyph stripped); char_acc = 1 - Levenshtein/len on the exact_ci-normalised strings
(v2 definition), char_acc_cs = same on case-sensitive strings. Primary v3 metric: CRNN exact (case-sensitive).
"""
import argparse, json, os, random, re, sys, time
import numpy as np, torch
from PIL import Image, ImageDraw, ImageFont
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT)
def lev(a, b):
d = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
p, d[0] = d[0], i
for j, cb in enumerate(b, 1):
p, d[j] = d[j], min(d[j] + 1, d[j - 1] + 1, p + (ca != cb))
return d[len(b)]
def norm_ci(s):
s = re.sub(r"\s+", " ", s.strip().upper())
return re.sub(r"^[)|}\].,'\u2018\u201c-] ", "", s)
def norm_cs(s):
return re.sub(r"\s+", " ", s.strip())
def score_text(pred, gt):
p, g = norm_ci(pred), norm_ci(gt)
pc, gc = norm_cs(pred), norm_cs(gt)
return dict(exact=pc == gc, exact_ci=p == g, char_acc=max(0.0, 1 - lev(p, g) / max(1, len(g))),
char_acc_cs=max(0.0, 1 - lev(pc, gc) / max(1, len(gc))))
def summarize(rows, key):
P = [r[key] for r in rows]
return dict(n=len(P), exact=sum(p["exact"] for p in P) / len(P), exact_ci=sum(p["exact_ci"] for p in P) / len(P),
char_acc=float(np.mean([p["char_acc"] for p in P])), char_acc_cs=float(np.mean([p["char_acc_cs"] for p in P])))
def fonts():
from generate_data import ensure_fonts
return sorted(ensure_fonts())
def reference_images(grid):
from generate_data_v2 import render_v2
F = fonts()
return [render_v2(128, random.Random(seed), F, **{k: sp[k] for k in ("color", "expr", "text", "layout", "paper")})[0]
for sp, _, seed in grid]
def ocr_all(imgs, layouts, crnn=None, tess=("v3",)):
from crnn import recognize
from ocr_tess import tess_v2, tess_v3
out = {}
if crnn is not None:
out["crnn"] = recognize(crnn, imgs, layouts)
if "v2" in tess:
out["tess_v2"] = [tess_v2(im, l) for im, l in zip(imgs, layouts)]
if "v3" in tess:
out["tess_v3"] = [tess_v3(im, l) for im, l in zip(imgs, layouts)]
if "v3s" in tess:
out["tess_v3_sauvola"] = [tess_v3(im, l, "sauvola") for im, l in zip(imgs, layouts)]
return out
FONT = ImageFont.load_default(size=13)
def grid_png(imgs, labels, cols, path, cell=192, title=None):
rows = (len(imgs) + cols - 1) // cols; lh = 34; th = 30 if title else 0
G = Image.new("RGB", (cols * cell, th + rows * (cell + lh)), "white"); d = ImageDraw.Draw(G)
if title: d.text((6, 6), title, fill="black", font=ImageFont.load_default(size=16))
for k, (im, lab) in enumerate(zip(imgs, labels)):
r, c = divmod(k, cols); x0, y0 = c * cell, th + r * (cell + lh)
G.paste(im.resize((cell, cell), Image.NEAREST), (x0, y0))
for li, line in enumerate(lab.split("\n")[:2]):
d.text((x0 + 3, y0 + cell + 2 + li * 15), line, fill="black", font=FONT)
G.save(path); return path
# ------------------------------------------------------------------ Phase 2: references
def cmd_refs(a):
from eval_grid import grid48
from crnn import load_crnn
g = grid48(); imgs = reference_images(g); lays = [sp["layout"] for sp, _, _ in g]; texts = [sp["text"] for sp, _, _ in g]
crnn = load_crnn(a.crnn) if a.crnn and os.path.exists(a.crnn) else None
t0 = time.time()
preds = ocr_all(imgs, lays, crnn, tess=("v2", "v3", "v3s"))
res = {"what": "OCR on the 48 procedural reference renders (generate_data_v2.render_v2, seeds 1000+i, byte-identical to v2)",
"crnn": a.crnn if crnn is not None else None, "per_sample": [], "summary": {}}
for i, (sp, cap, seed) in enumerate(g):
row = dict(text=sp["text"], layout=sp["layout"], paper=sp["paper"], expr=sp["expr"], seed=seed)
for k, v in preds.items(): row[k] = dict(ocr=v[i], **score_text(v[i], sp["text"]))
res["per_sample"].append(row)
for k in preds: res["summary"][k] = summarize(res["per_sample"], k)
res["seconds"] = round(time.time() - t0, 1)
os.makedirs(os.path.dirname(a.out), exist_ok=True)
json.dump(res, open(a.out, "w"), indent=1)
print(json.dumps(res["summary"], indent=1))
for k in preds:
miss = [(r["text"], r[k]["ocr"]) for r in res["per_sample"] if not r[k]["exact"]]
print(k, "misses (case-sensitive):", miss[:60])
# ------------------------------------------------------------------ Phase 5: models
def ort_sess(p, threads=os.cpu_count()):
import onnxruntime as ort
so = ort.SessionOptions(); so.intra_op_num_threads = threads; so.inter_op_num_threads = 1
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
return ort.InferenceSession(p, so, providers=["CPUExecutionProvider"])
def noise(seed):
return torch.randn(1, 3, 128, 128, generator=torch.Generator().manual_seed(seed)).numpy()
def sched(n):
return [1.0 - i / n for i in range(n + 1)]
def student_onnx(te, un, caps, seeds, steps, bs=8):
from conditioning import tokenize
out = []
for b in range(0, len(caps), bs):
c, sd = caps[b:b + bs], seeds[b:b + bs]
ids, mk = tokenize(c)
ctx, cm = te.run(None, {"input_ids": ids.numpy(), "attention_mask": mk.long().numpy()})
x = np.concatenate([noise(s) for s in sd]); ts = sched(steps)
for i in range(steps):
v = un.run(None, {"x_t": x, "t": np.full((len(c),), ts[i], np.float32), "context": ctx, "context_mask": cm})[0]
x = x - (ts[i] - ts[i + 1]) * v
out.append(np.clip(x, -1, 1))
return np.concatenate(out)
@torch.no_grad()
def teacher_images(teacher_path, caps, seeds, steps=50, cfg=5.0, bs=8, device="cpu"):
from train_teacher_v3 import load_flow, sample_cfg
T = load_flow(teacher_path, device)
out = []
for b in range(0, len(caps), bs):
z = torch.from_numpy(np.concatenate([noise(s) for s in seeds[b:b + bs]])).to(device)
out.append(sample_cfg(T, caps[b:b + bs], z, steps, cfg).cpu().numpy())
return np.concatenate(out)
def to_img(x):
return Image.fromarray(((x.transpose(1, 2, 0) + 1) * 127.5).round().clip(0, 255).astype(np.uint8))
def cmd_models(a):
from eval_grid import grid48, grid14
from crnn import load_crnn
crnn = load_crnn(a.crnn)
tess = tuple(t for t in a.tess.split(",") if t)
os.makedirs(a.assets, exist_ok=True)
te, un = ort_sess(a.text), ort_sess(a.unet)
g = grid48(); caps = [c for _, c, _ in g]; seeds = [s for *_, s in g]; lays = [sp["layout"] for sp, _, _ in g]
imgs, res = {}, {"unet": os.path.basename(a.unet), "text_encoder": os.path.basename(a.text), "n": len(g),
"captions_truncated": 0, "primary_metric": "crnn exact (case-sensitive)", "per_sample": [], "summary": {}}
from conditioning import MAX_LEN
res["captions_truncated"] = sum(len(c) > MAX_LEN for c in caps)
t0 = time.time()
for steps in a.steps:
imgs[f"s{steps}"] = [to_img(z) for z in student_onnx(te, un, caps, seeds, steps)]
imgs["ref"] = reference_images(g)
preds = {k: ocr_all(v, lays, crnn, tess) for k, v in imgs.items()}
for i, (sp, cap, seed) in enumerate(g):
row = dict(sp, caption=cap, seed=seed)
for k in imgs:
row[k] = {m: dict(ocr=p[i], **score_text(p[i], sp["text"])) for m, p in preds[k].items()}
res["per_sample"].append(row)
for k in imgs:
res["summary"][k] = {m: summarize([{"x": r[k][m]} for r in res["per_sample"]], "x") for m in preds[k]}
res["seconds"] = round(time.time() - t0, 1)
json.dump(res, open(os.path.join(a.assets, "eval_ocr_v3.json"), "w"), indent=1)
for steps in a.steps:
lab = [f"'{sp['text']}' -> '{r[f's{steps}']['crnn']['ocr']}'\n{sp['expr']}/{sp['layout']}/{sp['paper']}"
for (sp, _, _), r in zip(g, res["per_sample"])]
grid_png(imgs[f"s{steps}"], lab, 8, os.path.join(a.assets, f"samples_{steps}step_v3.png"),
title=f"grinpress v3 student ({os.path.basename(a.unet)}), {steps} steps, seeds 1000+i (label: text -> CRNN read)")
print(json.dumps(res["summary"], indent=1))
# ---- 14-string teacher vs student comparison
g14 = grid14(); c14 = [c for _, c, _ in g14]; s14 = [s for *_, s in g14]; l14 = [sp["layout"] for sp, _, _ in g14]
rows = {"ref": reference_images(g14)}
if a.teacher:
rows["teacher"] = [to_img(z) for z in teacher_images(a.teacher, c14, s14, a.teacher_steps, a.cfg, device=a.device)]
for steps in a.steps:
rows[f"s{steps}"] = [to_img(z) for z in student_onnx(te, un, c14, s14, steps)]
cmp = {}
for k, ims in rows.items():
p = ocr_all(ims, l14, crnn, tess)
cmp[k] = {m: dict(summarize([{"x": score_text(o, sp["text"])} for o, (sp, _, _) in zip(v, g14)], "x"), ocr=v) for m, v in p.items()}
json.dump(cmp, open(os.path.join(a.assets, "eval_compare_v3.json"), "w"), indent=1)
all_imgs, labels = [], []
for k, ims in rows.items():
for i, im in enumerate(ims):
all_imgs.append(im); labels.append(f"'{g14[i][0]['text']}' {k}\n-> '{cmp[k]['crnn']['ocr'][i]}'")
grid_png(all_imgs, labels, len(g14), os.path.join(a.assets, "teacher_vs_student_v3.png"), cell=160,
title="rows: " + " | ".join(rows) + " (same noise per column; label: CRNN read)")
print(json.dumps({k: {m: {x: y for x, y in v.items() if x != "ocr"} for m, v in d.items()} for k, d in cmp.items()}, indent=1))
def main():
ap = argparse.ArgumentParser()
sub = ap.add_subparsers(dest="cmd", required=True)
r = sub.add_parser("refs")
r.add_argument("--crnn", default=os.path.join(ROOT, "artifacts", "crnn.safetensors"))
r.add_argument("--out", default=os.path.join(ROOT, "assets", "eval_ocr_refs_phase2.json"))
m = sub.add_parser("models")
m.add_argument("--unet", required=True)
m.add_argument("--text", required=True)
m.add_argument("--teacher", default="")
m.add_argument("--teacher_steps", type=int, default=50)
m.add_argument("--cfg", type=float, default=5.0)
m.add_argument("--steps", type=int, nargs="+", default=[4, 2])
m.add_argument("--crnn", default=os.path.join(ROOT, "artifacts", "crnn.safetensors"))
m.add_argument("--tess", default="v3", help="comma list of tesseract variants to also run (v2,v3,v3s) or ''")
m.add_argument("--assets", default=os.path.join(ROOT, "assets"))
m.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
a = ap.parse_args()
torch.set_num_threads(os.cpu_count())
{"refs": cmd_refs, "models": cmd_models}[a.cmd](a)
if __name__ == "__main__":
main()