ERP-DocIQ / backend /app /ocr /quality.py
kenmandal's picture
Deploy latest: ERP DocIQ NLQ chatbot + reasoning models (MiniCPM3-4B/Command R7B) + ERP fine-tuning + extreme OCR docs
082d661 verified
Raw
History Blame Contribute Delete
8.53 kB
"""OCR output-quality + document-analysis benchmark.
For each available OCR backend, over a set of scanned samples with ground truth, we
measure two quality dimensions and capture logs + metrics:
1. OCR text quality — Character Error Rate (CER) and Word Error Rate (WER) of the
transcribed text vs a reference (the `.txt` sidecar that ships with each scan).
2. Document-analysis quality — field exact-match and F1 of the FULL pipeline
(OCR → classify → extract → validate) vs the document's ground-truth JSON.
Plus latency, cost, model name/size. Results are published (file + endpoint + optional
HF upload). Pure-Python edit distance, no extra deps.
"""
from __future__ import annotations
import json
import re
import time
from pathlib import Path
from ..observability import log_event
# scanned samples that have BOTH a .txt sidecar (reference text) and a gt.json, AND
# that genuinely exercise each OCR engine independently (different CER per backend).
DEFAULT_SAMPLES = [
"invoice_scanned_basic", "receipt_scanned", "po_scanned",
"contract_scanned", "subscription_memo_scanned",
]
# field-accuracy-only (no sidecar reference text). The extreme tier
# (scripts/generate_extreme_docs.py — perspective photo, image collage, degraded fax) is a
# VISION-extraction stress set: on those images the OCR engines fall back to a shared text
# source (identical CER across backends), so they are excluded from the per-backend CER
# benchmark and scored on field accuracy only. complex_invoice_messy requires a real VLM.
FIELD_ONLY_SAMPLES = ["complex_invoice_messy"]
def _norm(s: str) -> str:
return re.sub(r"\s+", " ", (s or "").strip().lower())
def _lev(a, b) -> int:
"""Levenshtein distance over any sequences (str or list)."""
if a == b:
return 0
la, lb = len(a), len(b)
if la == 0:
return lb
if lb == 0:
return la
prev = list(range(lb + 1))
for i in range(1, la + 1):
cur = [i]
ca = a[i - 1]
for j in range(1, lb + 1):
cur.append(min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != b[j - 1])))
prev = cur
return prev[lb]
def cer(hyp: str, ref: str):
ref, hyp = _norm(ref), _norm(hyp)
if not ref:
return None
return round(min(1.0, _lev(hyp, ref) / len(ref)), 4)
def wer(hyp: str, ref: str):
rw, hw = _norm(ref).split(), _norm(hyp).split()
if not rw:
return None
return round(min(1.0, _lev(hw, rw) / len(rw)), 4)
def _model_size(settings, backend_name: str):
"""Map an OCR backend to its model name + size from the model registry."""
from ..models_registry import model_catalog
cat = model_catalog(settings)
flat = [{**m, "lab": lab["lab"]} for lab in cat["labs"] for m in lab["models"]]
if backend_name == "minicpm":
m = next((x for x in flat if x["name"].startswith("MiniCPM-V")), None)
elif backend_name == "cohere":
m = next((x for x in flat if x["name"].startswith("Aya-Vision-8")), None)
else:
m = None
if m:
return {"model": m["name"], "params_b": m["params_b"], "size_gb": m["size_gb_int4"], "lab": m["lab"]}
return {"model": backend_name, "params_b": None, "size_gb": None, "lab": "classic"}
def run_ocr_quality(settings, ocr_registry, router, metrics, db=None, rag_store=None,
samples=None) -> dict:
from ..pipeline import process_document
from evals import scorers
samples = samples or DEFAULT_SAMPLES
ds = settings.evals_dataset_dir
available = [n for n in ocr_registry.available_names() if n != "sidecar"] + ["sidecar"]
log_event("info", "OCR quality benchmark started",
backends=available, samples=samples + FIELD_ONLY_SAMPLES)
backend_rows = []
for bname in available:
backend = ocr_registry.get(bname)
if not backend or not backend.available():
continue
cers, wers, exacts, f1s, lats, costs = [], [], [], [], [], []
per_sample = []
for sid in samples + FIELD_ONLY_SAMPLES:
doc = _find(ds, sid)
if not doc:
continue
gt = _load_gt(ds, sid)
t0 = time.perf_counter()
run = process_document(doc, router=router, settings=settings, metrics=metrics,
ocr_registry=ocr_registry, ocr_backend=bname,
db=db, rag_store=rag_store, doc_id=f"q-{bname}-{sid}",
mode="quality")
st = run["_state"]
ocr_text = (st.get("ocr") or {}).get("text", "")
ref = _ref_text(ds, sid)
c = cer(ocr_text, ref) if ref else None
w = wer(ocr_text, ref) if ref else None
score = scorers.score_document(st.get("extracted") or {},
{k: v for k, v in gt.items() if not k.startswith("_")}) if gt else {}
case = {"sample": sid, "cer": c, "wer": w,
"field_exact": score.get("exact_match"), "field_f1": score.get("field_f1"),
"latency_ms": round((time.perf_counter() - t0) * 1000, 1),
"cost_usd": run.get("total_cost_usd", 0.0),
"confidence": st.get("confidence")}
per_sample.append(case)
if c is not None:
cers.append(c); wers.append(w)
if score.get("exact_match") is not None:
exacts.append(score["exact_match"]); f1s.append(score["field_f1"] or 0)
lats.append(case["latency_ms"]); costs.append(case["cost_usd"])
log_event("info", f"OCR quality: {bname} on {sid}",
cer=c, wer=w, field_exact=score.get("exact_match"))
size = _model_size(settings, bname)
row = {
"backend": bname, **size,
"is_reference": bname == "sidecar",
"cer": _avg(cers), "wer": _avg(wers),
"field_exact_match": _avg(exacts), "field_f1": _avg(f1s),
"avg_latency_ms": _avg(lats), "avg_cost_usd": round(_avg(costs) or 0, 6),
"samples_scored": len(per_sample), "per_sample": per_sample,
}
backend_rows.append(row)
# rank: best OCR text quality (lowest CER among real engines), best analysis (highest exact)
real = [r for r in backend_rows if not r["is_reference"] and r["cer"] is not None]
best_ocr = min(real, key=lambda r: r["cer"])["backend"] if real else None
# rank only real engines for "best analysis" — sidecar is the reference text, not a
# competing OCR backend, so it must never be crowned best.
scored = [r for r in backend_rows
if not r["is_reference"] and r["field_exact_match"] is not None]
best_analysis = max(scored, key=lambda r: r["field_exact_match"])["backend"] if scored else None
report = {
"generated_at": time.time(),
"note": "CER/WER vs .txt sidecar reference; field accuracy vs gt.json. "
"sidecar = reference text source (CER≈0 by construction).",
"models": _model_size_table(settings),
"backends": backend_rows,
"best_ocr_quality": best_ocr,
"best_document_analysis": best_analysis,
}
if db is not None:
try:
db.audit("ocr_quality_benchmark",
detail={"best_ocr": best_ocr, "best_analysis": best_analysis,
"backends": [r["backend"] for r in backend_rows]})
except Exception:
pass
log_event("info", "OCR quality benchmark complete",
best_ocr=best_ocr, best_analysis=best_analysis)
return report
def _model_size_table(settings):
from ..models_registry import model_catalog
return [{"lab": lab["lab"], **{k: m[k] for k in ("name", "params_b", "size_gb_int4", "modality", "role", "available")}}
for lab in model_catalog(settings)["labs"] for m in lab["models"]]
def _avg(xs):
xs = [x for x in xs if x is not None]
return round(sum(xs) / len(xs), 4) if xs else None
def _find(ds: Path, sid: str):
for ext in (".png", ".jpg", ".jpeg", ".pdf"):
p = ds / f"{sid}{ext}"
if p.exists():
return p
return None
def _ref_text(ds: Path, sid: str):
p = ds / f"{sid}.txt"
return p.read_text(encoding="utf-8", errors="ignore") if p.exists() else None
def _load_gt(ds: Path, sid: str):
p = ds / f"{sid}.gt.json"
return json.loads(p.read_text()) if p.exists() else None