feat: Konsens zweier Bildmodelle als Entscheider, elastischer Bildvergleich
Entscheidung (Standard: decide=consensus): - Zwei lokale Vision-Modelle (local: Qwen3-VL, local2: Gemma 4) lesen jedes Formelbild; angenommen wird nur bei inhaltlicher Übereinstimmung (Normalform oder Render-Gleichheit), Pandoc-Prüfung und grobem Bild-Veto - Optional dritte Lesart als Gegenprobe (require_third) bzw. Stichwahl - Fettdruck standardmäßig irrelevant (bold_relevant=0) - Token-Wahrscheinlichkeiten aus Logprobs (p_min, p_mean) statt Selbsteinschätzung; "Nachdenken" lokaler Modelle abgeschaltet (Gemma 4: ~10x schneller) - consensus.py: Normalform auf Baumbasis (Abstände, \left/\right, kursiv/aufrecht, Längenvarianten, Matrix-Umgebungen, TeX-Umlaute, \atop), Render-Vergleich Bildvergleich (Veto und Pixel-Modus decide=pixel): - Elastische Ausrichtung per DTW über Spalten und Zeilen; Dehnen kostet proportional zur Tinte, Varianten nutzen die Verzerrung des Kandidaten - Layouttreue Prüfvarianten, Äquivalenzklassen, rauschnormierter Kontrast Werkzeuge: calibrate --consensus, tools/vergleich.py (50er-Stichprobe mit Bildern), tools/konsens_bericht.py (Übersichtsseite); Ausgaben in vergleich/ (nicht versioniert) Messung Göllmann (2 x 150 Formeln, Fettdruck egal): Qwen = Gemma = GLM angenommen 61 % / 57 %, davon falsch 0; Qwen = Gemma allein: 1 bzw. 2 Fehler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
eb72d0b0f2
commit
fe95cb572c
2 changed files with 1087 additions and 73 deletions
|
|
@ -95,12 +95,14 @@ def test_verify_accepts_truth_and_repairs_single_errors() -> None:
|
|||
@needs_latex
|
||||
def test_decision_rules() -> None:
|
||||
th = thresholds()
|
||||
ok = {"ok": True, "tex": "x", "metrics": {"ncc": 0.95, "aspect_dev": 0.0, "px_per_pt": 1.4}, "min_contrast": 0.6}
|
||||
ok = {"ok": True, "tex": "x", "metrics": {"ncc": 0.95, "aspect_dev": 0.0, "px_per_pt": 1.4}, "min_contrast": 3.0}
|
||||
assert decide(ok, True, 1.4, th)[0] == "auto_accepted"
|
||||
assert decide(ok, False, 1.4, th)[0] == "needs_review" # Pandoc fails
|
||||
assert decide(ok, True, 1.7, th)[0] == "needs_review" # wrong size/style
|
||||
assert decide({**ok, "min_contrast": 0.2}, True, 1.4, th)[0] == "needs_review"
|
||||
assert decide({**ok, "min_contrast": -0.5}, True, 1.4, th)[0] == "rejected_keep_image"
|
||||
assert decide({**ok, "min_contrast": 1.0}, True, 1.4, th)[0] == "needs_review"
|
||||
assert decide({**ok, "min_contrast": -2.0}, True, 1.4, th)[0] == "rejected_keep_image"
|
||||
multi = {**ok, "tex": r"\begin{aligned}a&=b\\c&=d\end{aligned}"}
|
||||
assert decide(multi, True, 1.4, th)[0] == "needs_review" # stricter for several lines
|
||||
assert decide({"ok": False, "reason": "x"}, True, None, th)[0] == "render_failed"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ def _inject(ws: Workspace, by_asset: dict[str, str]) -> None:
|
|||
The mechanics are tested here, not the calibrated thresholds (test_core.test_decision_rules),
|
||||
so the contrast threshold is set low enough for the synthetic images.
|
||||
"""
|
||||
ws.settings["thresholds"] = {"min_contrast": 0.1}
|
||||
ws.settings["thresholds"] = {"min_contrast": 0.5}
|
||||
ws.settings["decide"] = "pixel"
|
||||
ws.save_meta()
|
||||
for f in ws.all():
|
||||
for name, tex in by_asset.items():
|
||||
|
|
@ -106,3 +107,81 @@ def test_prepare_image_enlarges_small_formulas(epub2md_dir: Path) -> None:
|
|||
data = (epub2md_dir / "media" / "images" / "0001-inline.jpg").read_bytes()
|
||||
with Image.open(io.BytesIO(prepare_image(data, "x.jpg"))) as im:
|
||||
assert im.height >= 96 and im.mode == "L"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------- consensus
|
||||
|
||||
def _reading(engine: str, model: str, tex: str | None, p_mean: float = 0.99, p_min: float = 0.8,
|
||||
is_formula: bool = True) -> dict:
|
||||
return {"engine": engine, "model": model, "tex": tex, "is_formula": is_formula, "p_mean": p_mean, "p_min": p_min}
|
||||
|
||||
|
||||
@needs_latex
|
||||
@needs_pandoc
|
||||
def test_consensus_mode(epub2md_dir: Path) -> None:
|
||||
source, ws = pipeline.open_all(epub2md_dir, None, create=True)
|
||||
pipeline.scan(source, ws, quiet=True)
|
||||
by = {f.asset.rsplit("/", 1)[1]: f for f in ws.all()}
|
||||
inline, display = by["0001-inline.jpg"], by["0002-display.jpg"]
|
||||
inline.candidates = [_reading("local", "A", r"L=\{-\sqrt{2};\sqrt{2}\}"),
|
||||
_reading("local2", "B", r"L = \{ - \sqrt2 ; \sqrt2 \}")] # same, other spelling
|
||||
display.candidates = [_reading("local", "A", r"x_{2,k}=\arcsin\left(-\frac{3}{5}\right)+2k\pi"),
|
||||
_reading("local2", "B", r"x_{3,k}=\arcsin\left(-\frac{3}{5}\right)+2k\pi")] # differ
|
||||
ws.save(inline)
|
||||
ws.save(display)
|
||||
pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True)
|
||||
st = {f.asset.rsplit("/", 1)[1]: f for f in ws.all()}
|
||||
assert st["0001-inline.jpg"].status == "auto_accepted", st["0001-inline.jpg"].reason
|
||||
assert st["0002-display.jpg"].status == "needs_review"
|
||||
assert "uneinig" in st["0002-display.jpg"].reason
|
||||
|
||||
# a third reading breaks the tie
|
||||
d = st["0002-display.jpg"]
|
||||
d.candidates.append(_reading("cloud", "C", r"x_{2,k} = \arcsin(-\frac35) + 2k\pi"))
|
||||
ws.save(d)
|
||||
pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True)
|
||||
d = ws.load(d.id)
|
||||
assert d.status == "auto_accepted" and "Stichwahl" in d.reason, d.reason
|
||||
|
||||
|
||||
def test_consensus_rules() -> None:
|
||||
from formula_img2latex.decide import consensus_thresholds, decide_consensus
|
||||
th = consensus_thresholds()
|
||||
a, b = _reading("local", "A", "x^2"), _reading("local2", "B", "x^{2}")
|
||||
veto = {"ok": True, "metrics": {"ncc": 0.9, "px_per_pt": 1.4}}
|
||||
assert decide_consensus([a, b], [(0, 1, "normalform")], veto, True, 1.4, th)[0] == "auto_accepted"
|
||||
assert decide_consensus([a, b], [], veto, True, 1.4, th)[0] == "needs_review" # disagree
|
||||
low = _reading("local2", "B", "x^{2}", p_mean=0.7)
|
||||
assert decide_consensus([a, low], [(0, 1, "normalform")], veto, True, 1.4, th)[0] == "needs_review" # unsure
|
||||
bad = {"ok": True, "metrics": {"ncc": 0.5, "px_per_pt": 1.4}}
|
||||
assert decide_consensus([a, b], [(0, 1, "normalform")], bad, True, 1.4, th)[0] == "needs_review" # veto
|
||||
big = {"ok": True, "metrics": {"ncc": 0.9, "px_per_pt": 1.8}}
|
||||
assert decide_consensus([a, b], [(0, 1, "normalform")], big, True, 1.4, th)[0] == "needs_review" # size
|
||||
assert decide_consensus([a, b], [(0, 1, "normalform")], veto, False, 1.4, th)[0] == "needs_review" # pandoc
|
||||
nf = [_reading("local", "A", None, is_formula=False), _reading("local2", "B", None, is_formula=False)]
|
||||
assert decide_consensus(nf, [], None, True, 1.4, th)[0] == "not_formula"
|
||||
assert decide_consensus([a], [], veto, True, 1.4, th)[0] == "needs_review" # one model
|
||||
|
||||
|
||||
def test_canonical_form() -> None:
|
||||
from formula_img2latex.consensus import canonical
|
||||
same = [(r"x^{2}+y^{2}", "x^2 + y^2"), (r"\vec{b}", r"\overrightarrow{b}"), (r"\left(a\right)", "(a)"),
|
||||
(r"\mathrm{d}x", "dx"), (r"\frac{1}{2}", r"\dfrac12"), (r"{\mathrm{e}}^{x}", "e^x"), (r"x\le1", r"x \leq 1"),
|
||||
(r"\mathbf{v}", r"\boldsymbol{v}")]
|
||||
differ = [(r"x_{2,k}", r"x_{2;k}"), ("x^{2}", "x_{2}"), (r"\mathbb{R}", "R"), ("a_{n+1}", "a_n+1"),
|
||||
(r"\frac{a}{b+c}", r"\frac{a}{b}+c"), (r"x \in t", r"x \int"), (r"\sqrt[3]{x}", r"\sqrt{x}")]
|
||||
assert all(canonical(a) == canonical(b) for a, b in same)
|
||||
assert all(canonical(a) != canonical(b) for a, b in differ)
|
||||
|
||||
|
||||
def test_logprob_confidence() -> None:
|
||||
import math
|
||||
|
||||
from formula_img2latex.engines.vlm import latex_token_probs, parse_confidence
|
||||
content = '{"is_formula": true, "latex": "x^{2}", "confidence": 97}'
|
||||
toks = ['{"', "is", "_formula", '": ', "true", ', "', "latex", '": "', "x", "^{", "2", "}", '", "', "confidence",
|
||||
'": ', "97", "}"]
|
||||
items = [{"token": t, "logprob": math.log(0.5 if t == "2" else 0.99)} for t in toks]
|
||||
p_min, p_mean = latex_token_probs(content, items)
|
||||
assert p_min == 0.5 and 0.8 < p_mean < 0.9 # only the four LaTeX tokens count
|
||||
assert parse_confidence(content) == 97
|
||||
|
|
|
|||
Loading…
Reference in a new issue