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>
This commit is contained in:
parent
41bfe9fc26
commit
4638a12571
1 changed files with 941 additions and 98 deletions
|
|
@ -4,6 +4,8 @@ from __future__ import annotations
|
|||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from conftest import needs_latex, needs_pandoc
|
||||
from formula_img2latex import pipeline
|
||||
from formula_img2latex.cli import main
|
||||
|
|
@ -120,6 +122,7 @@ def _reading(engine: str, model: str, tex: str | None, p_mean: float = 0.99, p_m
|
|||
@needs_pandoc
|
||||
def test_consensus_mode(epub2md_dir: Path) -> None:
|
||||
source, ws = pipeline.open_all(epub2md_dir, None, create=True)
|
||||
ws.settings["consensus_rule"] = "vlm3" # the earlier rule Qwen = Gemma = GLM
|
||||
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"]
|
||||
|
|
@ -155,6 +158,63 @@ def test_consensus_mode(epub2md_dir: Path) -> None:
|
|||
assert i.status == "needs_review" and "weicht ab" in i.reason, i.reason
|
||||
|
||||
|
||||
@needs_latex
|
||||
@needs_pandoc
|
||||
def test_consensus_rule_p2t(epub2md_dir: Path) -> None:
|
||||
"""Standard rule: pix2text first, Qwen confirms; where they differ Gemma may confirm pix2text.
|
||||
The reading used is always pix2text's."""
|
||||
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", "Q", r"L=\{-\sqrt{2};\sqrt{2}\}"),
|
||||
_reading("p2t", "P", r"L=\{-\sqrt{2};\sqrt{2}\}")]
|
||||
display.candidates = [_reading("local", "Q", r"x_{3,k}=\arcsin\left(-\frac{3}{5}\right)+2k\pi"),
|
||||
_reading("p2t", "P", r"x_{2,k}=\operatorname{arcsin}(-\frac{3}{5})+2k\pi")]
|
||||
ws.save(inline)
|
||||
ws.save(display)
|
||||
pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True)
|
||||
i, d = ws.load(inline.id), ws.load(display.id)
|
||||
assert i.status == "auto_accepted" and "pix2text und Qwen einig" in i.reason, i.reason
|
||||
assert i.final_tex == r"L=\{-\sqrt{2};\sqrt{2}\}" # pix2text's reading
|
||||
assert pipeline.needs_tiebreak(d), d.reason
|
||||
|
||||
# Gemma confirms pix2text -> accepted with pix2text's reading
|
||||
d.candidates.append(_reading("local2", "G", r"x_{2,k} = \arcsin(-\frac{3}{5}) + 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 "Gemma bestätigt pix2text" in d.reason, d.reason
|
||||
assert d.final_tex == r"x_{2,k}=\operatorname{arcsin}(-\frac{3}{5})+2k\pi"
|
||||
|
||||
# Qwen and Gemma together against pix2text -> a human decides
|
||||
d.candidates[-1] = _reading("local2", "G", r"x_{3,k}=\arcsin(-\frac{3}{5})+2k\pi")
|
||||
d.status = "pending"
|
||||
ws.save(d)
|
||||
pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True)
|
||||
d = ws.load(d.id)
|
||||
assert d.status == "needs_review" and "Mensch entscheidet" in d.reason, d.reason
|
||||
|
||||
|
||||
def test_pair_tiebreak_rule() -> None:
|
||||
from formula_img2latex.decide import consensus_thresholds, decide_pair_tiebreak
|
||||
th = consensus_thresholds()
|
||||
q, p, g = _reading("local", "Q", "x^2"), _reading("p2t", "P", "x^{2}"), _reading("local2", "G", "x^2")
|
||||
veto = {"ok": True, "metrics": {"ncc": 0.9, "px_per_pt": 1.4}}
|
||||
assert decide_pair_tiebreak([q, p, None], [(0, 1, "normalform")], veto, True, 1.4, th)[::2] == ("auto_accepted", 1)
|
||||
bad = {"ok": True, "metrics": {"ncc": 0.5, "px_per_pt": 1.4}}
|
||||
assert decide_pair_tiebreak([q, p, None], [(0, 1, "normalform")], bad, True, 1.4, th)[0] == "needs_review" # veto
|
||||
assert decide_pair_tiebreak([q, p, None], [(0, 1, "normalform")], veto, False, 1.4, th)[0] == "needs_review" # pandoc
|
||||
assert "Stichwahl fehlt" in decide_pair_tiebreak([q, p, None], [], veto, True, 1.4, th)[1]
|
||||
assert decide_pair_tiebreak([q, p, g], [(1, 2, "normalform")], veto, True, 1.4, th)[::2] == ("auto_accepted", 1)
|
||||
assert decide_pair_tiebreak([q, p, g], [(0, 2, "normalform")], veto, True, 1.4, th)[0] == "needs_review"
|
||||
assert decide_pair_tiebreak([q, p, g], [], veto, True, 1.4, th)[0] == "needs_review"
|
||||
assert "fehlt" in decide_pair_tiebreak([q, None, None], [], veto, True, 1.4, th)[1]
|
||||
nq, ng = _reading("local", "Q", None, is_formula=False), _reading("local2", "G", None, is_formula=False)
|
||||
assert decide_pair_tiebreak([nq, p, ng], [], None, True, 1.4, th)[0] == "not_formula"
|
||||
assert decide_pair_tiebreak([nq, p, None], [], None, True, 1.4, th)[0] == "needs_review" # ask Gemma first
|
||||
|
||||
|
||||
def test_consensus_rules() -> None:
|
||||
from formula_img2latex.decide import consensus_thresholds, decide_consensus
|
||||
th = consensus_thresholds({"min_p_mean": 0.9, "min_p_min": 0.3, "require_third": 0})
|
||||
|
|
@ -181,6 +241,29 @@ def test_consensus_rules() -> None:
|
|||
assert decide_consensus([a, b, c], [(0, 2, "normalform")], veto, True, 1.4, strict)[0] == "needs_review" # no 2:1
|
||||
|
||||
|
||||
def test_calibration_rules_with_four_engines() -> None:
|
||||
from formula_img2latex.calibrate import _summarize_consensus
|
||||
from formula_img2latex.decide import consensus_thresholds, decide_consensus
|
||||
names = ["local", "local2", "cloud", "p2t"]
|
||||
veto = {"ok": True, "metrics": {"ncc": 0.9}}
|
||||
|
||||
def res(agree: list[tuple[int, int]], ok: list[bool]) -> dict:
|
||||
rs = [_reading(e, e, "x") for e in names]
|
||||
pair = "normalform" if (0, 1) in agree else None
|
||||
return {"truth": "x", "readings": rs, "ok": ok, "agree_all": [(i, j, "normalform") for i, j in agree],
|
||||
"vetos": {0: veto, 1: veto, 3: veto}, "a": rs[0], "b": rs[1], "a_ok": ok[0], "b_ok": ok[1],
|
||||
"agree": pair, "veto": veto if pair else None}
|
||||
|
||||
all4 = [(0, 1), (0, 2), (0, 3), (1, 2), (1, 3), (2, 3)]
|
||||
results = [res(all4, [True] * 4), # everyone agrees
|
||||
res([(0, 1), (0, 3), (1, 3)], [False, False, True, False]), # Q = G = p2t, all wrong, GLM right
|
||||
res([(0, 2), (0, 3), (2, 3)], [True, False, True, True])] # Gemma differs: 2:1 decided by p2t
|
||||
s = _summarize_consensus("t", results, names, consensus_thresholds, decide_consensus)
|
||||
got = [(r["accepted"], r["wrong"]) for r in s["rules"]]
|
||||
# Qwen = Gemma | + GLM | B (+ p2t) | GLM or p2t confirms | A (3 of 4) | C (all four) | standard rule p2t
|
||||
assert got == [(2, 1), (1, 0), (2, 1), (2, 1), (3, 1), (1, 0), (3, 1)]
|
||||
|
||||
|
||||
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)"),
|
||||
|
|
@ -190,6 +273,8 @@ def test_canonical_form() -> None:
|
|||
(r"\operatorname{l i m}_{x \to0} \frac{\operatorname{s i n} x} {x}", r"\lim_{x\to 0}\frac{\sin x}{x}"),
|
||||
(r"\mathrm{f \ddot{u} r ~ a l l e ~} x", r"\text{für alle } x"), (r"f^{\prime\prime}(x)", "f''(x)"),
|
||||
(r"\left( \begin{matrix} {1} & {2} \\ \end{matrix} \right)", r"\begin{pmatrix}1&2\end{pmatrix}"),
|
||||
(r"\begin{array}[t]{cc}a&b\end{array}", r"\begin{array}{cc}a&b\end{array}"),
|
||||
(r"\mathop{\mathbf{rot}}\boldsymbol{v}", r"\mathbf{rot}\,\boldsymbol{v}"),
|
||||
(r"{\int}_{0}^{\infty} \! e^{-x^{2}} \, d x \!=\! \frac{\sqrt\pi} 2", r"\int_0^\infty e^{-x^2}\,dx=\frac{\sqrt{\pi}}{2}")]
|
||||
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}"),
|
||||
|
|
@ -198,10 +283,30 @@ def test_canonical_form() -> None:
|
|||
assert all(canonical(a) != canonical(b) for a, b in differ)
|
||||
|
||||
|
||||
def test_tidy_p2t() -> None:
|
||||
pytest.importorskip("pix2tex")
|
||||
from formula_img2latex.engines.p2t import tidy_p2t
|
||||
cases = {
|
||||
r"{\frac{\alpha} {3 6 0}}={\frac{x} {2 \pi}}": r"{\frac{\alpha}{360}}={\frac{x}{2\pi}}",
|
||||
r"\frac{\mathrm{d}} {\mathrm{d} x} | x |=\frac{1} {x} .": r"\frac{\mathrm{d}}{\mathrm{d}x}|x|=\frac{1}{x}.",
|
||||
r"\left( \begin{array} {c} {-y} \\ {0} \end{array} \right)": r"\left(\begin{array}{c}{-y}\\ {0}\end{array}\right)",
|
||||
r"a \cdot x \in t": r"a\cdot x\in t", # space between letters stays
|
||||
r"A \cdot\mathrm{\boldmath~ v ~}=0": r"A\cdot\boldsymbol{v}=0", # Pix2Text's bold
|
||||
r"{\mathrm{{\bf~ r o t}}} \, v": r"{\mathbf{rot}}\,v",
|
||||
r"\mathrm{\bf~ r o t} \, E": r"\mathbf{rot}\,E",
|
||||
}
|
||||
for raw, tidy in cases.items():
|
||||
assert tidy_p2t(raw) == tidy, (raw, tidy_p2t(raw))
|
||||
|
||||
|
||||
def test_p2t_engine_is_lazy() -> None:
|
||||
from formula_img2latex.engines import make_engine
|
||||
e = make_engine("p2t")
|
||||
assert (e.name, e.model, e._ocr) == ("p2t", "pix2text-mfr-1.5", None) # model loads only in check()
|
||||
from formula_img2latex.engines.p2t import ort_provider
|
||||
assert ort_provider("cpu") == ("CPUExecutionProvider", {})
|
||||
assert ort_provider("cuda") == ("CUDAExecutionProvider", {"device_id": 0})
|
||||
assert ort_provider("cuda:2") == ("CUDAExecutionProvider", {"device_id": 2})
|
||||
|
||||
|
||||
def test_logprob_confidence() -> None:
|
||||
|
|
|
|||
Loading…
Reference in a new issue