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>
210 lines
11 KiB
Python
210 lines
11 KiB
Python
"""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
|