formula_img2latex/tools/zeittest.py
dschlueter 4638a12571 feat: 0.3.0 – pix2text zuerst, Qwen bestätigt, Gemma nur bei Uneinigkeit
Neue Standardregel p2t (GLM/Cloud wird nicht mehr benutzt):
- pix2text liest zuerst; übernommen wird immer seine Lesart, wenn Qwen sie
  bestätigt oder – nur bei Uneinigkeit gefragt – Gemma. Qwen = Gemma gegen
  pix2text geht an einen Menschen. Schlusspunkt muss bestätigt sein; Bild-Veto
  und Pandoc auf pix2texts Lesart. Alte Regel als consensus_rule=vlm3 wählbar.
- pix2text auf der GPU (onnxruntime-gpu, FORMULA_P2T_DEVICE=auto/cpu/cuda:N)
- Ausgabe bereinigt: post_process aus pix2tex, Fettschreibweisen \boldmath/\bf
  -> \boldsymbol/\mathbf; an 400 Lesarten gleiches gesetztes Bild
- Normalform: \mathop, \begin{array}[t]
- calibrate: --engines, Regel-Tabelle, --reuse; wahre Formeln aus dem alt-Text
  bereinigt (aligned, \mathop{}\!, upgreek)
- tools/zeittest.py, tools/regeltest.py (--reuse, --exclude)

Messung: Göllmann Band 1 (2 x 150) 86 % / 85 % automatisch, 0 falsch
(bisher 78 % / 70 %); Band 2, zwei Stichproben zu 50: 78 % und 86 %, 0 falsch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 03:45:18 +02:00

145 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Time and accuracy per recognition tool on formulas whose TeX is known (serial, one at a time).
python3 tools/zeittest.py EPUB [--n 15] [--tools local,local2,cloud,p2t-cpu,p2t-gpu] [--gpu cuda:0]
Same sample as `calibrate --consensus` (seed 42, real images). Every tool reads every formula alone,
one request after the other, after one uncounted warm-up on another formula. Writes
vergleich/zeittest/index.html (not versioned): table per tool, then per formula the original, the
true formula and every reading set with pdflatex, with time and verdict; plus ergebnis.json.
"""
from __future__ import annotations
import argparse
import html
import json
import random
import statistics
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from konsens_bericht import orig_img, png_uri # noqa: E402
from formula_img2latex.calibrate import _best_preset, corpus # noqa: E402
from formula_img2latex.consensus import same_reading # noqa: E402
from formula_img2latex.engines import make_engine # noqa: E402
from formula_img2latex.engines.p2t import Pix2TextEngine # noqa: E402
from formula_img2latex.render import render_many # noqa: E402
from formula_img2latex.tex import strip_delimiters # noqa: E402
LABELS = {"local": "Qwen3-VL-30B (lokal, GPU)", "local2": "Gemma-4-26B (lokal, GPU)", "cloud": "GLM-4.6V (Cloud)",
"p2t-cpu": "pix2text MFR 1.5 (CPU)", "p2t-gpu": "pix2text MFR 1.5 (GPU)"}
def engine_for(tool: str, gpu: str):
if tool == "p2t-cpu":
return Pix2TextEngine(device="cpu")
if tool == "p2t-gpu":
return Pix2TextEngine(device=gpu)
return make_engine(tool)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("epub", type=Path)
ap.add_argument("--n", type=int, default=15)
ap.add_argument("--tools", default="local,local2,cloud,p2t-cpu,p2t-gpu")
ap.add_argument("--gpu", default="cuda", help="Gerät für p2t-gpu (Index relativ zu CUDA_VISIBLE_DEVICES)")
ap.add_argument("--name", default="zeittest")
args = ap.parse_args()
tools = [t.strip() for t in args.tools.split(",") if t.strip()]
items = corpus(args.epub)
smp = random.Random(42).sample(items, min(150, len(items))) # as calibrate_consensus
formulas, warm = smp[:args.n], smp[args.n]
preset = _best_preset(smp[:12])
runs: dict[str, dict] = {}
for tool in tools:
eng = engine_for(tool, args.gpu)
t0 = time.time()
eng.check()
load = time.time() - t0
eng.recognize(warm[1], warm[0], "", warm[3]) # warm-up, not counted
reads = []
for name, data, truth, disp in formulas:
r = eng.recognize(data, name, "", disp)
ok = bool(r.tex) and bool(same_reading(r.tex, truth, disp, preset))
reads.append({"tex": r.tex, "seconds": r.seconds, "ok": ok, "error": r.error,
"tokens_in": r.tokens_in, "tokens_out": r.tokens_out})
print(f"{tool:8s} {r.seconds:6.2f}s {'✓' if ok else '✗'} {r.tex or r.error}", file=sys.stderr, flush=True)
secs = [x["seconds"] for x in reads]
runs[tool] = {"label": LABELS.get(tool, tool), "model": eng.model, "load_s": load, "reads": reads,
"mean_s": statistics.mean(secs), "median_s": statistics.median(secs),
"correct": sum(x["ok"] for x in reads), "errors": sum(bool(x["error"]) for x in reads),
"tokens_in": sum(x["tokens_in"] for x in reads), "tokens_out": sum(x["tokens_out"] for x in reads)}
out = Path(__file__).resolve().parents[1] / "vergleich" / args.name
out.mkdir(parents=True, exist_ok=True)
(out / "ergebnis.json").write_text(json.dumps(
{"epub": args.epub.name, "preset": preset, "truth": [f[2] for f in formulas], "runs": runs},
ensure_ascii=False, indent=1), encoding="utf-8")
n = len(formulas)
rows = "".join(
f"<tr><td>{html.escape(r['label'])}</td><td>{r['mean_s']:.2f}</td><td>{r['median_s']:.2f}</td>"
f"<td>{r['correct']}/{n} ({r['correct'] / n:.0%})</td><td>{r['load_s']:.1f}</td>"
f"<td>{r['tokens_in'] + r['tokens_out'] or '–'}</td></tr>" for r in runs.values())
parts = [f"<h1>Zeittest: {html.escape(args.epub.name[:70])}</h1>",
f"<p>{n} Formeln mit bekanntem TeX (alt-Text), jedes Tool seriell, eine Formel nach der anderen, "
"nach einem nicht gezählten Aufwärmlauf. Alle Formeln mit pdflatex gesetzt. ✓ = gleiche Zeichen an "
"gleicher Stelle wie die wahre Formel (Abstände, kursiv/aufrecht, Fettdruck egal).</p>",
"<table class=sum><tr><th>Tool</th><th>Ø s/Formel</th><th>Median s</th><th>richtig</th>"
f"<th>Laden s</th><th>Tokens</th></tr>{rows}</table>"]
texes = []
for i, (_n, _d, truth, disp) in enumerate(formulas):
texes.append((strip_delimiters(truth), disp))
texes += [(runs[t]["reads"][i]["tex"] or "\\text{–}", disp) for t in tools]
rendered = render_many(texes, preset)
per = 1 + len(tools)
for i, (name, data, truth, _disp) in enumerate(formulas):
imgs = []
for rr in rendered[per * i: per * (i + 1)]:
if rr.ok:
g = (rr.rasterize(2.0) * 255).astype(np.uint8)
imgs.append(f"<img src='{png_uri(Image.fromarray(255 - g))}'>")
else:
imgs.append("<i>nicht setzbar</i>")
body = [f"<tr><th>Original</th><td><img src='{png_uri(orig_img(data, 2))}'></td><td></td></tr>",
f"<tr><th>richtig</th><td>{imgs[0]}</td><td><code>{html.escape(truth)}</code></td></tr>"]
for k, t in enumerate(tools):
r = runs[t]["reads"][i]
mark = "<b class=ok>✓</b>" if r["ok"] else "<b class=no>✗</b>"
body.append(f"<tr><th>{mark} {html.escape(runs[t]['label'])}<br><small>{r['seconds']:.2f} s</small></th>"
f"<td>{imgs[k + 1]}</td><td><code>{html.escape(r['tex'] or r['error'] or '–')}</code></td></tr>")
parts.append(f"<section><div class=v>Formel {i + 1} · {html.escape(name)}</div><table>{''.join(body)}</table></section>")
(out / "index.html").write_text(
"<!doctype html><meta charset=utf-8><title>Zeittest</title><style>"
"body{font:14px system-ui,sans-serif;margin:16px;background:#f6f6f3}section{background:#fff;border:1px solid #ddd;"
"border-radius:6px;padding:6px 10px;margin:10px 0}.v{font-weight:600;font-size:13px;margin-bottom:4px}"
"table.sum{border-collapse:collapse;background:#fff}table.sum td,table.sum th{border:1px solid #ccc;padding:4px 10px}"
"th{text-align:left;font-weight:400;color:#555;width:210px;vertical-align:middle}td{padding:3px 8px;vertical-align:middle}"
".ok{color:#1f7a3a}.no{color:#a32020}code{font-size:12px}img{max-width:640px}</style>" + "".join(parts),
encoding="utf-8")
print(f"\n| Tool | Ø s/Formel | Median s | richtig (n={n}) | Laden s |")
print("|---|---|---|---|---|")
for r in runs.values():
print(f"| {r['label']} | {r['mean_s']:.2f} | {r['median_s']:.2f} | {r['correct']}/{n} | {r['load_s']:.1f} |")
if "p2t-cpu" in runs and "p2t-gpu" in runs:
same = sum(a["tex"] == b["tex"] for a, b in zip(runs["p2t-cpu"]["reads"], runs["p2t-gpu"]["reads"]))
print(f"\npix2text CPU und GPU gleicher Text: {same}/{n}")
print(out / "index.html")
return 0
if __name__ == "__main__":
sys.exit(main())