formula_img2latex/formula_img2latex/decide.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

210 lines
11 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.

"""Turn a verification into a status. All thresholds live here and can be overridden by `calibrate`.
Accept only if
* the candidate compiles and Pandoc converts it to MathML,
* the absolute comparison is plausible (correlation, proportions, size consistent with the book),
* it beats every distinguishable confusable variant by at least `min_contrast`.
Everything plausible but not certain goes to review; everything else keeps the image.
"""
from __future__ import annotations
from typing import Any
DEFAULTS: dict[str, float] = {
"min_contrast": 2.5, # accept: weakest evidence against any confusable variant, one-line formulas
"min_contrast_multiline": 4.5, # the same for formulas with several lines (aligned, matrices)
"review_contrast": -1.0, # below this a variant explains the image better -> reject
"min_ncc": 0.85, # accept
"review_ncc": 0.15, # below: nothing in common (wrong image/figure) -> keep image
"max_aspect_dev": 0.08, # accept: proportions within 8 %
"max_scale_dev": 0.08, # accept: px/pt within 8 % of the book median (wrong style, \frac vs \tfrac)
}
def thresholds(overrides: dict[str, Any] | None = None) -> dict[str, float]:
t = dict(DEFAULTS)
for k, v in (overrides or {}).items():
if k in t and v is not None:
t[k] = float(v)
return t
def decide(ver: dict[str, Any], pandoc_ok: bool, book_px_per_pt: float | None,
th: dict[str, float]) -> tuple[str, str]:
"""Return (status, German reason)."""
if not ver.get("ok"):
return "render_failed", f"Kandidat nicht vergleichbar: {ver.get('reason') or 'unbekannt'}"
m = ver.get("metrics", {})
ncc = float(m.get("ncc", 0.0))
aspect_dev = float(m.get("aspect_dev", abs(float(m.get("aspect", 1.0)) - 1.0)))
mc = ver.get("min_contrast")
problems: list[str] = []
if not pandoc_ok:
problems.append("Pandoc kann die Formel nicht nach MathML umsetzen")
if ncc < th["min_ncc"]:
problems.append(f"Ähnlichkeit {ncc:.3f} < {th['min_ncc']}")
# With elastic comparison, lengths and spacing may differ by design; only symbols and their
# relative positions count.
if aspect_dev > th["max_aspect_dev"] and not m.get("elastic"):
problems.append(f"Proportionen weichen um {aspect_dev:.0%} ab")
if book_px_per_pt:
dev = abs(float(m.get("px_per_pt", book_px_per_pt)) / book_px_per_pt - 1.0)
if dev > th["max_scale_dev"]:
problems.append(f"Schriftgröße weicht um {dev:.0%} vom Buch ab (Stil?)")
need = th["min_contrast_multiline"] if "\\\\" in (ver.get("tex") or "") else th["min_contrast"]
if mc is not None and mc < need:
problems.append(f"nicht eindeutig gegenüber „{ver.get('worst_variant')}“ (Beleg {mc:.2f} < {need})")
if not problems:
why = "alle Prüfungen bestanden"
if mc is None:
why += " (keine unterscheidbaren Varianten)"
return "auto_accepted", why
hopeless = ncc < th["review_ncc"] or (mc is not None and mc < th["review_contrast"])
return ("rejected_keep_image" if hopeless else "needs_review"), "; ".join(problems)
# --------------------------------------------------------------------------- consensus mode
CONSENSUS_DEFAULTS: dict[str, float] = {
"min_p_mean": 0.0, # geometric mean of the LaTeX token probabilities (measured: no gain over
"min_p_min": 0.0, # the third reading, only less coverage; kept configurable)
"veto_ncc": 0.65, # rough image check: the agreed reading must resemble the original. Measured:
# the only error all three models shared had 0.5-0.6; correct ones >= 0.66
"veto_scale_dev": 0.0, # > 0: font size (px/pt) within this share of the book median (off: false
# alarms on Gehrke, never measured to catch anything)
"bold_relevant": 0, # 1: bold vs. normal counts as a different symbol
"require_third": 1, # a third reading (cloud, other model family) must agree as well
}
def consensus_thresholds(overrides: dict[str, Any] | None = None) -> dict[str, float]:
t = dict(CONSENSUS_DEFAULTS)
for k, v in (overrides or {}).items():
if k in t and v is not None:
t[k] = float(v)
return t
def _sure(r: dict[str, Any], th: dict[str, float]) -> str | None:
"""None if the reading is confident enough, else a German reason."""
pm, pn = r.get("p_mean"), r.get("p_min")
if th["min_p_mean"] <= 0 and th["min_p_min"] <= 0:
return None
if pm is None or pn is None:
return f"{r.get('model', '?')}: keine Token-Wahrscheinlichkeiten"
if pm < th["min_p_mean"] or pn < th["min_p_min"]:
return f"{r.get('model', '?')} unsicher (Mittel {pm:.2f}, Minimum {pn:.2f})"
return None
def decide_consensus(readings: list[dict[str, Any]], agree: list[tuple[int, int, str]], veto: dict[str, Any] | None,
pandoc_ok: bool, book_px_per_pt: float | None, th: dict[str, float]
) -> tuple[str, str, int | None]:
"""Decide from several model readings of one image.
*readings*: latest candidate per model ({model, tex, is_formula, p_min, p_mean}), the two
primary models first. *agree*: pairs (i, j, how) of readings that say the same. *veto*:
verification of the chosen reading (rough image check). Returns (status, reason, index of
the reading to use or None).
"""
if len(readings) < 2:
return "needs_review", "nur eine Lesart (zweites Modell fehlt)", 0 if readings and readings[0].get("tex") else None
flags = [r.get("is_formula") for r in readings[:2]]
if all(f is False for f in flags):
return "not_formula", "beide Modelle: keine Formel", None
pair = None
for i, j, how in agree:
if {i, j} == {0, 1}:
pair = (i, j, how)
break
tie = ""
if pair is None and not th.get("require_third"):
for i, j, how in agree: # tie-break: a third reading agrees with one of the two
if 2 in (i, j) or i >= 2 or j >= 2:
pair, tie = (i, j, how), " (Stichwahl)"
break
if pair is None:
texts = " | ".join(f"{r.get('model', '?')}: {r.get('tex') or '–'}" for r in readings[:3])
return "needs_review", f"Modelle uneinig: {texts}", 0 if readings[0].get("tex") else 1
i, j, how = pair
if th.get("require_third") and not tie:
third = [k for k in range(2, len(readings))]
if not third:
return "needs_review", f"einig ({how}), aber dritte Lesart fehlt", i
if not any({a, b} in ({0, k}, {1, k}) for a, b, _ in agree for k in third):
texts = " | ".join(f"{r.get('model', '?')}: {r.get('tex') or '–'}" for r in readings[2:3])
return "needs_review", f"zwei Modelle einig ({how}), dritte Lesart weicht ab: {texts}", i
tie = " (dritte Lesart bestätigt)"
use = min(i, j) # the primary model's reading (it is the one checked against the image)
problems = _checks([readings[i], readings[j]], veto, pandoc_ok, book_px_per_pt, th)
if problems:
return "needs_review", f"einig ({how}){tie}; " + "; ".join(problems), use
return "auto_accepted", f"zwei Modelle einig ({how}){tie}, sicher, Bild-Veto bestanden", use
def _checks(agreeing: list[dict[str, Any]], veto: dict[str, Any] | None, pandoc_ok: bool,
book_px_per_pt: float | None, th: dict[str, float]) -> list[str]:
"""Problems of an agreed reading: unsure readings, Pandoc, rough image check (German reasons)."""
problems = [p for p in (_sure(r, th) for r in agreeing) if p]
if not pandoc_ok:
problems.append("Pandoc kann die Formel nicht nach MathML umsetzen")
if veto is None or not veto.get("ok"):
problems.append("Bildabgleich nicht möglich" + (f": {veto.get('reason')}" if veto and veto.get("reason") else ""))
else:
m = veto.get("metrics", {})
if m.get("ncc", 0.0) < th["veto_ncc"]:
problems.append(f"Bild-Veto: Ähnlichkeit {m.get('ncc', 0.0):.2f} < {th['veto_ncc']}")
if (th["veto_scale_dev"] > 0 and book_px_per_pt
and abs(m.get("px_per_pt", book_px_per_pt) / book_px_per_pt - 1) > th["veto_scale_dev"]):
problems.append(f"Bild-Veto: Schriftgröße weicht um {abs(m['px_per_pt'] / book_px_per_pt - 1):.0%} ab")
return problems
# Marker in the reason of a formula that waits for the tie-break reading (see pipeline.needs_tiebreak).
TIEBREAK_MISSING = "Stichwahl fehlt"
def decide_pair_tiebreak(readings: list[dict[str, Any] | None], agree: list[tuple[int, int, str]],
veto: dict[str, Any] | None, pandoc_ok: bool, book_px_per_pt: float | None,
th: dict[str, float]) -> tuple[str, str, int | None]:
"""Rule "p2t": pix2text reads first, Qwen confirms, Gemma only where the two disagree.
The reading used is always pix2text's (spaces tidied; it reads bold type best). *readings*:
[Qwen, pix2text, Gemma or None]. *agree*: pairs (i, j, how) of readings that say the same.
*veto*: verification of pix2text's reading. Returns (status, reason, index of the reading to use
or None).
Gemma may only confirm pix2text. Qwen and Gemma together against pix2text go to a human: in the
measurement exactly these were the errors both VLMs share (\\mathcal{E}_r for \\varepsilon_r, an
invented \\tilde{z}).
"""
q, p = readings[0], readings[1]
g = readings[2] if len(readings) > 2 else None
pairs = {frozenset((i, j)): how for i, j, how in agree}
if q is None or p is None:
missing = "Qwen" if q is None else "pix2text"
return "needs_review", f"Lesart von {missing} fehlt", 1 if p is not None and p.get("tex") else None
if q.get("is_formula") is False and (g is None or g.get("is_formula") is False) and (g or not p.get("tex")):
return "not_formula", "Qwen" + (" und Gemma" if g else "") + ": keine Formel", None
first = 1 if p.get("tex") else None # reading shown for review: pix2text's
if frozenset((0, 1)) in pairs:
how, who, confirm = pairs[frozenset((0, 1))], "pix2text und Qwen einig", q
elif g is None:
return "needs_review", f"pix2text und Qwen uneinig, {TIEBREAK_MISSING}", first
elif frozenset((1, 2)) in pairs:
how, who, confirm = pairs[frozenset((1, 2))], "Stichwahl: Gemma bestätigt pix2text", g
elif frozenset((0, 2)) in pairs:
return ("needs_review", "Qwen und Gemma einig gegen pix2text – Mensch entscheidet: "
f"Qwen: {q.get('tex') or '–'}", first)
else:
texts = " | ".join(f"{n}: {r.get('tex') or '–'}" for n, r in (("Qwen", q), ("pix2text", p), ("Gemma", g)))
return "needs_review", f"alle uneinig: {texts}", first
use = 1
if not p.get("tex"):
return "needs_review", f"{who}, aber ohne LaTeX", None
# the confidence check (token probabilities, off by default) covers the confirming VLM reading
problems = _checks([confirm], veto, pandoc_ok, book_px_per_pt, th)
if problems:
return "needs_review", f"{who} ({how}); " + "; ".join(problems), use
return "auto_accepted", f"{who} ({how}), Bild-Veto bestanden", use