From 2478bc5cb9b8622932293df120acbdcf3bc15a4b Mon Sep 17 00:00:00 2001 From: dschlueter Date: Fri, 9 Oct 2026 22:30:15 +0200 Subject: [PATCH] =?UTF-8?q?feat:=200.4.0=20=E2=80=93=20mehrzeilige=20Norma?= =?UTF-8?q?lform,=20erstes=20ganzes=20PDF-Buch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalform (Göllmann, 400 Formeln: +17 automatisch, weiterhin 0 falsch): - mehrzeilige Formeln gleich bei gleichen Zeilen, egal in welchem Rahmen (aligned/&/\\, cases = \left\{array\right. = \left\{aligned\right.), leere letzte Zeile zählt nicht; Matrizen behalten & als Spaltentrenner - Argumente nach Argumentzahl des Befehls (t^{2}\,{\sqrt{2}} = t^2\sqrt{2}) - \cdot\cdot\cdot = \dots, \root3\of{x} = \sqrt[3]{x}, \left./\right. entfallen, \cfrac = \frac; wörtliche \n aus Modellantworten werden zu Leerraum Gemessen und abgeschaltet bzw. verworfen: - Strukturvergleich mit sympy (structure.py, optional, FORMULA_STRUCTURAL=1): nach der neuen Normalform kein Gewinn mehr - Bild-Schiedsrichter (verify.image_arbiter, arbiter=0): der gemeinsame Fehler \mathcal{E}_r bekam Beleg 7,2 – Abstand zu knapp - Prompt-Stilvorgabe: kein Gewinn, Prompt-Version bleibt 2 Ganzes PDF-Buch (Corral, Elementary Calculus): 429 von 1123 automatisch. - Bilder für die Modelle höchstens 1600 Pixel (Qwen lehnte große ab), Bildvergleich nur bis 4 MP (ganzseitige Abbildung brachte ihn zum Absturz) - fehlgeschlagene Modellanfragen werden wiederholt - pix2text: \cfrac -> \dfrac (Pandoc kennt \cfrac nicht) Werkzeuge: tools/buch_bericht.py (Ergebnisseite eines Buchs), tools/vergleich_bericht.py (Vorher/Nachher), regeltest --refresh/--arbiter, calibrate misst Schiedsrichter und strukturelle Treffer. Co-Authored-By: Claude Opus 5.5 --- BEDIENUNGSANLEITUNG.md | 7 +- CHANGELOG.md | 44 +++++ README.md | 69 +++++-- formula_img2latex/__init__.py | 2 +- formula_img2latex/calibrate.py | 68 ++++++- formula_img2latex/confusables.py | 2 +- formula_img2latex/consensus.py | 119 ++++++++++-- formula_img2latex/decide.py | 23 ++- formula_img2latex/engines/p2t.py | 4 +- formula_img2latex/engines/vlm.py | 8 +- formula_img2latex/pipeline.py | 6 +- formula_img2latex/structure.py | 299 +++++++++++++++++++++++++++++++ formula_img2latex/tex.py | 20 +++ formula_img2latex/verify.py | 20 +++ requirements.txt | 1 + tests/test_pipeline.py | 80 ++++++++- tools/buch_bericht.py | 161 +++++++++++++++++ tools/konsens_bericht.py | 5 + tools/regeltest.py | 42 +++-- tools/vergleich_bericht.py | 205 +++++++++++++++++++++ 20 files changed, 1126 insertions(+), 59 deletions(-) create mode 100644 formula_img2latex/structure.py create mode 100644 tools/buch_bericht.py create mode 100644 tools/vergleich_bericht.py diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 691d3fa..88ad847 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -226,9 +226,10 @@ Nein. Die Entscheidungen hängen am Inhalt der Bilder, nicht an Dateinamen. `sca **Warum landet ein Teil im Review?** Ersetzt wird nur, was pix2text liest und Qwen (oder bei Uneinigkeit Gemma) bestätigt. Sind Qwen und Gemma -gemeinsam anderer Meinung als pix2text, entscheidest du. Häufige Gründe für Review: mehrzeilige Rechnungen -mit Ausrichtung, `\underbrace` oder Zeilenumbrüchen (die Modelle schreiben sie unterschiedlich), sehr -kleine oder unscharfe Bilder, oder ein Modell liest ein Zeichen anders. Im Review stehen die Lesarten +gemeinsam anderer Meinung als pix2text, entscheidest du. Häufige Gründe für Review: ein Modell liest ein +Zeichen anders (oft pix2text: `\nu` statt fettem v, Wörter wie „fir“ statt „für“), sehr kleine oder +unscharfe Bilder, mehrzeilige Rechnungen mit anderen Zeilenumbrüchen oder `\underbrace`. Verschiedene +Rahmen derselben Zeilen (`aligned`, `cases`, `&`) gelten seit 0.4.0 als gleich. Im Review stehen die Lesarten nebeneinander; oft genügt ein Blick und „LaTeX übernehmen“. **Wie sicher ist „automatisch ersetzt“?** diff --git a/CHANGELOG.md b/CHANGELOG.md index e9aa946..190863f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,49 @@ # Changelog +## 0.4.0 – 2026-10-09 + +Bessere Normalform: Lesarten, die sich nur in der Schreibweise unterscheiden, gelten als gleich. +Göllmann (400 Formeln mit bekannter Lösung, gleiche Lesarten): **+17 automatisch, weiterhin 0 falsch**. + +- **Mehrzeilige Formeln:** gleich bei gleichen Zeilen mit gleichen Zeichen, egal in welchem Rahmen + (`aligned`/`gathered`/`split` mit `&` oder nur `\\`; `cases` = `\left\{array\right.` = + `\left\{aligned\right.`); eine leere letzte Zeile zählt nicht. In Matrizen trennt `&` weiter Spalten. +- **Argumente nach Argumentzahl:** Eine Gruppe ist nur Argument, wenn der Befehl noch eines erwartet + (`t^{2}\,{\sqrt{2}}` = `t^2\sqrt{2}`, `\Delta{x_{n}}` = `\Delta x_n`). +- **Weitere Gleichsetzungen:** `\cdot\cdot\cdot` und `...` = `\dots`, `\root3\of{x}` = `\sqrt[3]{x}`, + `\left.`/`\right.` entfallen. +- **Messung genauer:** Der Wahrheitsvergleich nutzt dieselbe Normalform; richtige mehrzeilige Lesarten + zählten bisher als falsch (pix2text allein richtig: Band 2 jetzt 45/50 und 47/50). +- **Ergebnis:** Band 1 89 % / 88 % automatisch (bisher 86 % / 85 %), Band 2 44 und 45 von 50 (bisher 39 + und 43), 0 falsch. +- **Strukturvergleich mit sympy** (`structure.py`, neu, optional, aus): gleicher unausgewerteter + Ausdrucksbaum, nichts umgerechnet; Relationen nie an sympy, Zeichen durch Platzhalter geschützt. + Brachte vor der neuen Normalform +1 Formel, danach nichts – `structural` = 1 bzw. + `FORMULA_STRUCTURAL=1` schaltet ihn ein (`pip install sympy lark`). +- **Bild-Schiedsrichter** (`verify.image_arbiter`, gemessen, aus): bei „Qwen = Gemma gegen pix2text“ + entscheidet der Bildbeleg. +11 Formeln ab Schwelle 8, aber der gemeinsame Fehler `\mathcal{E}_r` + bekam Beleg 7,2 – zu knapp. Einstellungen `arbiter`, `arbiter_margin`. +- **Prompt-Stilvorgabe** getestet und verworfen (Band 2: 44/44 statt 44/45; die Modelle schrieben nach + `\\` ein wörtliches `\n`). Prompt-Version bleibt 2, gespeicherte Lesarten bleiben gültig. +- **Werkzeuge:** `tools/vergleich_bericht.py` (Vorher/Nachher-Seite zweier Läufe), `regeltest.py + --refresh` (Engines trotz `--reuse` neu fragen) und `--arbiter`; `calibrate` misst den + Schiedsrichter (Werte je Fall, Schwellen) und listet nur strukturell gefundene Übereinstimmungen; + `tools/buch_bericht.py` (Ergebnisseite eines ganzen Buchs: jede Formel mit Original, gesetztem + LaTeX, Status, Begründung und allen Lesarten, filterbar). +- **Erstes ganzes PDF-Buch** (Corral, *Elementary Calculus*, 346 Seiten, pdf2md): 1150 Ausschnitte, + **429 automatisch ersetzt (37 %)**, 633 Review, 61 keine Formel. Gefundene und behobene Probleme: + - pix2text schreibt hier Brüche als `\cfrac`: in der Normalform = `\frac`, beim Bereinigen zu + `\dfrac` (Pandoc kennt `\cfrac` nicht und ließ die Formel als TeX-Text stehen). + - Qwen und Gemma schreiben Zeilenumbrüche als wörtliches `\n` (`\begin{aligned}\n…`): wird zu + Leerraum (`tex.fix_literal_newlines`), echte Befehle wie `\nu`, `\neq` bleiben. + - 600-dpi-Ausschnitte bis 3800 × 2700 Pixel: Qwen lehnte sie ab („400 Bad Request“, Kontext voll), + eine ganzseitige Abbildung (33 MP) brachte den Bildvergleich zum Absturz. Jetzt: Bilder für die + Modelle höchstens 1600 Pixel lang, Bildvergleich nur bis 4 MP (größte übernommene Formel: 1,7 MP). + - Fehlgeschlagene Modellanfragen werden beim nächsten Lauf wiederholt (zählten als „schon gefragt“). + - Göllmann danach unverändert: 0 falsch. + - Grenze: pdf2md schneidet in diesem Buch auch Merkkästen mit Text, Tabellen und ganze + Herleitungsblöcke als „Formel“ aus; sie bilden den Großteil des Reviews. + ## 0.3.0 – 2026-10-09 Neue Standardregel `p2t`: **pix2text liest zuerst, Qwen bestätigt, Gemma nur bei Uneinigkeit**; GLM (Cloud) diff --git a/README.md b/README.md index c50691f..77f1310 100644 --- a/README.md +++ b/README.md @@ -34,9 +34,27 @@ Die frühere Regel `vlm3` (Qwen = Gemma, bestätigt von **GLM-4.6V** über OpenR „Dieselbe Formel“ prüft `consensus.py`: gleiche Normalform (Abstände, `\left`/`\right`, überflüssige Klammern, `\vec`/`\overrightarrow`, `\frac`/`\dfrac`, `\iff`/`\Leftrightarrow`, `pmatrix`/`\left(\begin{matrix}`, -TeX-Umlaute, `\atop`/`\substack`, kursiv/aufrecht, fett/normal …) oder – als Rückfallebene – zeichengleiches -Rendering der beiden Lesarten (Render gegen Render, nicht gegen das verrauschte Original). Eine -Gleichungsnummer muss in allen Lesarten übereinstimmen und wird als Text hinter die Formel gesetzt. +TeX-Umlaute, `\atop`/`\substack`, `\cdot\cdot\cdot`/`\cdots`, `\root3\of{x}`/`\sqrt[3]{x}`, kursiv/aufrecht, +fett/normal …) oder – als Rückfallebene – zeichengleiches Rendering der beiden Lesarten (Render gegen +Render, nicht gegen das verrauschte Original). Eine Gleichungsnummer muss in allen Lesarten +übereinstimmen und wird als Text hinter die Formel gesetzt. + +**Mehrzeilige Formeln** (seit 0.4.0): gleich, wenn sie dieselben Zeilen mit denselben Zeichen haben, +egal in welchem Rahmen. `aligned`/`gathered`/`split` und ihr `&` fallen weg (`a\\b` = +`\begin{aligned}a&\\b\end{aligned}`), eine leere letzte Zeile zählt nicht; `cases`, +`\left\{\begin{array}{ll}…\right.` und pix2texts `\left\{\begin{aligned}…\right.` sind dasselbe. +Matrizen bleiben Matrizen: Dort trennt `&` Spalten (`1&2` ≠ `12`). Ob eine Gruppe ein Argument ist, +entscheidet die Argumentzahl des Befehls (`\frac` 2, `\sqrt` 1, `\Delta` 0), nicht ihre Stellung: +`t^{2}\,{\sqrt{2}}` = `t^2\sqrt{2}`, `\Delta{x_{n}}` = `\Delta x_n`. + +**Gleiche Struktur** (`structure.py`, sympy, **optional, standardmäßig aus**): Beide Lesarten ergeben +denselben *unausgewerteten* Ausdrucksbaum (`\frac{a}{b}` = `a/b`, `a\cdot b` = `ab`); nichts wird +umgerechnet oder umgestellt (`a+b` ≠ `b+a`, `2x=4` ≠ `x=2`, `a-b` ≠ `a+(-b)`). Relationen gehen nie an +sympy (es wertet `v=(3,4)ᵀ` und `\nu=(3,4)ᵀ` beide zu `False` aus), jedes Zeichen wird durch einen +Platzhalter geschützt (sympy macht aus `\varepsilon` und `\epsilon` ein Symbol), sichtbare Klammern +zählen, Ungedecktes fällt auf den Render-Vergleich zurück. Gemessen: vor der mehrzeiligen Normalform ++1 Formel bei 400, danach **nichts mehr** – deshalb aus. Einschalten: `structural` = 1 oder +`FORMULA_STRUCTURAL=1`; braucht `pip install sympy lark`. Sind sich die Modelle uneinig oder widerspricht das Veto, entscheidet ein Mensch im Review. @@ -54,6 +72,11 @@ Göllmann Band 1, 150 echte Bilder und 150 Nachbildungen im Gehrke-Stil, Lesarte | + Stichwahl auch Qwen = Gemma gegen pix2text | 92 % / 1 | 91 % / 2 | | Qwen = Gemma = GLM (Regel `vlm3`) | 78 % / 0 | 70 % / 0 | +Ab 0.4.0 mit mehrzeiliger Normalform, gleiche Lesarten (`--reuse`): **89 % / 0** (134) und **88 % / 0** +(132); Band 2 (`tools/regeltest.py`, zwei Stichproben zu 50): **44 und 45** automatisch statt 39 und 43, +**0 falsch**, 6 bzw. 5 Review. Zusammen +17 Formeln bei 400. Die Tabelle und die folgenden Absätze +zeigen den Stand 0.3.0. + Allein richtig: pix2text 90 % / 85 %, Qwen 86 % / 84 %, Gemma 86 % / 80 %, GLM 86 % / 83 %. Zeit je Formel (seriell, 15 Formeln): pix2text 0,10 s (GPU) bzw. 0,51 s (CPU), Qwen 0,72 s, GLM 1,19 s, Gemma 1,77 s. Gegenprobe an Göllmann Band 2 (`tools/regeltest.py`, zwei getrennte Stichproben zu 50 @@ -63,6 +86,17 @@ pix2text ist nicht unfehlbar: In den Reviews standen auch Fälle, in denen nur p (fehlende Wurzelexponenten, zerfallene Vektoren, `\nu` statt fettem v) – deshalb bleibt die Bestätigung durch Qwen Pflicht. +**Bild-Schiedsrichter** (0.4.0, gemessen, **aus**): Bei „Qwen = Gemma gegen pix2text“ misst +`verify.image_arbiter`, wie deutlich das Original Qwens Lesart zeigt (gleiches Kontrastmaß wie der +Variantentest). In 16 solchen Fällen (Band 1 und 2) hatte Qwen 14-mal recht, mit Belegen von 3 bis 50. +Aber: Beim gemeinsamen Fehler `\mathcal{E}_r` statt `\varepsilon_r` sprach das Bild mit 7,2 sogar für +die falsche Lesart. Ab Schwelle 8 wären es +11 Formeln bei 0 falsch – der Abstand ist bei zwei +Gegenbeispielen nicht belastbar. Einschalten: `arbiter` = 1, `arbiter_margin` (5). + +**Prompt-Stilvorgabe** (0.4.0, gemessen, verworfen): feste Schreibweisen für `aligned`, `pmatrix`, +`cases`, `\dots`, `\text`. Band 2: 44 und 44 statt 44 und 45 – die Normalform gleicht die Schreibweisen +schon aus, und mit der Zeilenvorgabe schrieben beide Modelle nach `\\` ein wörtliches `\n`. + ### Regel `vlm3` (Standard bis 0.2.0) An Formelbildern mit bekanntem TeX (Göllmann, Springer: TeX im alt-Text), je 150 echte Bilder und 150 @@ -166,7 +200,8 @@ Sicherheit: Kandidaten kommen von Modellen und gelten als unsicher. Vor pdflatex Die Datensätze hängen am SHA-256 des Bildes: Ein erneuter epub2md-Lauf verliert keine Entscheidungen. Einstellungen in `workspace.json` → `settings.consensus_thresholds`: `require_third` (1), -`bold_relevant` (0), `veto_ncc` (0,65), `veto_scale_dev` (0 = aus), `min_p_mean`/`min_p_min` (0). +`bold_relevant` (0), `structural` (0), `arbiter` (0), `arbiter_margin` (5), `veto_ncc` (0,65), +`veto_scale_dev` (0 = aus), `min_p_mean`/`min_p_min` (0). ## Quellen @@ -184,16 +219,30 @@ sind nicht Teil dieser Version. `vergleich//index.html` mit Original, gerenderten Lesarten, Entscheidung und Veto-Overlay. - `tools/konsens_bericht.py BERICHT.json EPUB` – Übersichtsseite zu `calibrate --consensus --report`. - `tools/zeittest.py EPUB` – Zeit und Trefferquote je Tool, seriell an 15 Formeln mit bekanntem TeX. -- `tools/regeltest.py EPUB [--n 50] [--reuse ergebnis.json]` – die Standardregel an Formeln mit bekanntem - TeX, wie `run`; Seite mit Original, wahrer Formel, allen Lesarten und Entscheidung. +- `tools/regeltest.py EPUB [--n 50] [--reuse ergebnis.json] [--refresh local,local2] [--arbiter 8]` – die + Standardregel an Formeln mit bekanntem TeX, wie `run`; Seite mit Original, wahrer Formel, allen + Lesarten und Entscheidung. `--refresh` fragt trotz `--reuse` diese Engines neu (Prompt-Test). +- `tools/buch_bericht.py AUSGABE` – Ergebnisseite eines ganzen Buchs nach `run`: jede Formel mit + Original, gesetztem LaTeX, Status, Begründung und allen Lesarten, filterbar; liegt in + `/bericht/index.html` (Buchausschnitte, nicht versioniert). +- `tools/vergleich_bericht.py --regeltest VORHER:NACHHER --kalibrierung VORHER.json:NACHHER.json` – + Vorher/Nachher-Seite zweier Läufe auf denselben Lesarten: Zahlen, jede geänderte Entscheidung und + jedes geänderte Urteil gegen die Wahrheit, mit Original und gerenderten Lesarten. - `calibrate --consensus --engines local,local2,cloud,p2t --report X.json` misst alle Regeln; `--reuse X.json` rechnet sie ohne Modellabfragen neu. - `vergleich/` ist nicht versioniert (Ausschnitte aus Büchern). ## Stand -Version 0.3.0 (Regel `p2t`) ist an Göllmann Band 1 und 2 gemessen (siehe [Messung](#messung)), aber noch -**nicht am ganzen Gehrke** gelaufen. Die folgenden Zahlen stammen von Version 0.2.0 (Regel `vlm3`, mit GLM). +Version 0.4.0 (Regel `p2t`, mehrzeilige Normalform) ist an Göllmann Band 1 und 2 gemessen (siehe +[Messung](#messung)), aber noch **nicht am ganzen Gehrke** gelaufen. + +**Erstes ganzes PDF-Buch** (0.4.0): Corral, *Elementary Calculus* (346 Seiten, TeX-PDF, pdf2md mit +600-dpi-Neuausschnitt): 1150 Ausschnitte → **429 automatisch ersetzt (37 %)**, 633 Review, 61 keine +Formel, 27 Abbildungen. EPUB (`pdf2md --from-md`, jetzt mit `--mathml`): 429 Formeln als MathML, kein +TeX-Rest. Laufzeit rund 4 Stunden (pix2text Ø 3,9 s, Qwen Ø 8,1 s, Gemma Ø 15 s je Anfrage – große +Ausschnitte). Das Review besteht überwiegend aus Ausschnitten, die keine einzelne Formel sind +(Merkkästen mit Text, Tabellen, Diagramme, ganze Herleitungen) – das muss pdf2md besser trennen. Die folgenden Zahlen stammen von Version 0.2.0 (Regel `vlm3`, mit GLM). Version 0.2.0 (Konsens), getestet am Gehrke, Brückenkurs Mathematik (EPUB, CM-Formeln als kleine JPGs): @@ -219,8 +268,8 @@ Version 0.2.0 (Konsens), getestet am Gehrke, Brückenkurs Mathematik (EPUB, CM-F Grenzen und offene Punkte: -- Mehrzeilige Rechnungen mit Ausrichtung, `\underbrace` oder Zeilenumbrüchen schreiben die Modelle - unterschiedlich – sie bilden den Großteil des Reviews. +- Mehrzeilige Rechnungen: Rahmen und `&` gleicht die Normalform seit 0.4.0 aus; verschiedene + Zeilenumbrüche, `\underbrace` und fehlende Integrationsgrenzen bleiben im Review. - Formeln auf farbig hinterlegten Kästen verlieren beim Ersetzen die Hinterlegung. - Fettdruck wird nicht geprüft (Vorgabe). - Die Messung umfasst 2 × 150 Formeln eines Buches ohne Textausschnitt; „0 Fehler“ bedeutet eine diff --git a/formula_img2latex/__init__.py b/formula_img2latex/__init__.py index cae0f3a..c3aae35 100644 --- a/formula_img2latex/__init__.py +++ b/formula_img2latex/__init__.py @@ -1,3 +1,3 @@ """formula_img2latex: Formelbilder in Markdown geprüft durch LaTeX ersetzen.""" -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/formula_img2latex/calibrate.py b/formula_img2latex/calibrate.py index 67a6287..ac370e2 100644 --- a/formula_img2latex/calibrate.py +++ b/formula_img2latex/calibrate.py @@ -38,7 +38,7 @@ from .decide import decide, thresholds from .imageio import load_ink from .render import PRESETS, render_many from .tex import strip_delimiters, unsafe_reason -from .verify import verify +from .verify import image_arbiter, verify SYNTH_PX_PER_PT = 1.41 @@ -246,7 +246,8 @@ def _consensus_eval(args: tuple) -> dict[str, Any]: name, data, truth, display, preset, readings = args os.environ.setdefault("OMP_NUM_THREADS", "1") texes = [r.get("tex") or "" for r in readings] - ok = [bool(same_reading(t, truth, display, preset)) for t in texes] + ok_how = [same_reading(t, truth, display, preset) for t in texes] + ok = [bool(h) for h in ok_how] agree: list[tuple[int, int, str]] = [] for i in range(len(texes)): for j in range(i + 1, len(texes)): @@ -263,9 +264,18 @@ def _consensus_eval(args: tuple) -> dict[str, Any]: if (k < 2 or readings[k].get("engine") == "p2t") and any(k in (i, j) for i, j, _ in agree): v = verify(load_ink(data, name), texes[k], display, preset, check_variants=False) vetos[k] = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics} + # rule p2t, Qwen = Gemma against pix2text: image evidence for Qwen's reading (decide_pair_tiebreak arbiter) + arbiter = None + eng = {r.get("engine"): k for k, r in enumerate(readings)} + q, g, p = eng.get("local"), eng.get("local2"), eng.get("p2t") + if None not in (q, g, p) and texes[q] and texes[p]: + pairs = {frozenset((i, j)) for i, j, _ in agree} + if frozenset((q, g)) in pairs and not pairs & {frozenset((q, p)), frozenset((g, p))}: + arbiter = image_arbiter(load_ink(data, name), texes[q], texes[p], display, preset) pair = next((how for i, j, how in agree if (i, j) == (0, 1)), None) # "a", "b", "agree", "veto": the two primary models (read by tools/konsens_bericht.py) - return {"name": name, "truth": truth, "display": display, "readings": readings, "ok": ok, "agree_all": agree, + return {"name": name, "truth": truth, "display": display, "readings": readings, "ok": ok, "ok_how": ok_how, + "agree_all": agree, "arbiter": arbiter, "vetos": vetos, "a": readings[0], "b": readings[1], "a_ok": ok[0], "b_ok": ok[1], "agree": pair, "veto": vetos.get(0) if pair else None} @@ -365,7 +375,9 @@ CONSENSUS_RULES: list[tuple[str, str, list[tuple[str, ...]]]] = [ [("local", "local2", "cloud"), ("local", "local2", "p2t"), ("local", "cloud", "p2t"), ("local2", "cloud", "p2t")]), ("C: Qwen = Gemma = GLM = p2t", "all", [("local", "local2", "cloud"), ("local", "local2", "p2t")]), ("Standard: p2t = Qwen, Stichwahl Gemma = p2t", "tiebreak", [("local", "p2t", "local2")]), + ("Standard + Bild-Schiedsrichter", "arbiter", [("local", "p2t", "local2")]), ] +ARBITER_SWEEP = (1.0, 2.0, 3.0, 5.0, 8.0, 12.0, 20.0) def _rule_outcome(r: dict[str, Any], idx: list[int], th: dict[str, float], decide_fn: Any, @@ -375,11 +387,13 @@ def _rule_outcome(r: dict[str, Any], idx: list[int], th: dict[str, float], decid pos = {k: n for n, k in enumerate(idx)} agree = [(pos[i], pos[j], how) for i, j, how in r["agree_all"] if i in pos and j in pos] t = th if tiebreak else dict(th, require_third=1 if len(idx) > 2 else 0) - _st, _why, use = decide_fn(sub, agree, None, True, None, t) # which reading would be used + extra = {"arbiter": r.get("arbiter")} if tiebreak else {} + _st, _why, use = decide_fn(sub, agree, None, True, None, t, **extra) # which reading would be used if use is None: return None - veto = r["vetos"].get(idx[use]) - st, _why, use = decide_fn(sub, agree, veto, True, None, t) + vetos = {int(k): v for k, v in r["vetos"].items()} # string keys after a JSON round trip + veto = vetos.get(idx[use]) + st, _why, use = decide_fn(sub, agree, veto, True, None, t, **extra) return idx[use] if st == "auto_accepted" else None @@ -395,6 +409,13 @@ def _summarize_consensus(label: str, results: list[dict[str, Any]], names: list[ "rules": [], "sweep": []} out["agree_wrong_examples"] = [{"truth": r["truth"], "a": r["a"].get("tex"), "b": r["b"].get("tex")} for r in results if r["agree"] and not r["a_ok"]][:10] + # every match found only by the structural comparison, for a human check: the truth comparison uses + # the same rule, so a wrong structural match would not show up as "falsch" + out["structural_examples"] = ( + [{"kind": "Wahrheit", "reading": r["readings"][k].get("tex"), "other": r["truth"]} + for r in results for k, h in enumerate(r.get("ok_how", [])) if h == "strukturell"] + + [{"kind": f"{names[i]}={names[j]}", "reading": r["readings"][i].get("tex"), "other": r["readings"][j].get("tex")} + for r in results for i, j, how in r["agree_all"] if how == "strukturell"]) th0 = consensus_thresholds() for rule, mode, groups in CONSENSUS_RULES: if any(e not in names for g in groups for e in g): @@ -402,8 +423,9 @@ def _summarize_consensus(label: str, results: list[dict[str, Any]], names: list[ acc = wrong = 0 examples = [] for r in results: - if mode == "tiebreak": - used = [_rule_outcome(r, [names.index(e) for e in g], th0, decide_pair_tiebreak, True) + if mode in ("tiebreak", "arbiter"): + th = dict(th0, arbiter=1) if mode == "arbiter" else th0 + used = [_rule_outcome(r, [names.index(e) for e in g], th, decide_pair_tiebreak, True) for g in groups] else: used = [_rule_outcome(r, [names.index(e) for e in g], th0, decide_consensus) for g in groups] @@ -417,6 +439,24 @@ def _summarize_consensus(label: str, results: list[dict[str, Any]], names: list[ examples.append({"truth": r["truth"], "used": r["readings"][use].get("tex"), "readings": {e: rd.get("tex") for e, rd in zip(names, r["readings"])}}) out["rules"].append({"rule": rule, "accepted": acc, "wrong": wrong, "wrong_examples": examples[:5]}) + # image arbiter: evidence per case (Qwen = Gemma against pix2text) and accepted/wrong per margin + if all(e in names for e in ("local", "p2t", "local2")): + idx = [names.index(e) for e in ("local", "p2t", "local2")] + out["arbiter_cases"] = [{"value": r["arbiter"], "qwen_ok": r["ok"][idx[0]], "p2t_ok": r["ok"][idx[1]], + "qwen": r["readings"][idx[0]].get("tex"), "p2t": r["readings"][idx[1]].get("tex"), + "truth": r["truth"]} for r in results if r.get("arbiter") is not None] + out["arbiter_sweep"] = [] + for m in ARBITER_SWEEP: + th = dict(th0, arbiter=1, arbiter_margin=m) + acc = wrong = 0 + for r in results: + if r.get("arbiter") is None: + continue + use = _rule_outcome(r, idx, th, decide_pair_tiebreak, True) + if use is not None and _rule_outcome(r, idx, th0, decide_pair_tiebreak, True) is None: + acc += 1 + wrong += not r["ok"][use] + out["arbiter_sweep"].append({"margin": m, "extra_accepted": acc, "wrong": wrong}) for pm in (0.0, 0.8, 0.9, 0.95, 0.98): for pn in (0.0, 0.2, 0.3, 0.5): th = consensus_thresholds({"min_p_mean": pm, "min_p_min": pn, "require_third": 0}) @@ -436,11 +476,23 @@ def _print_consensus(s: dict[str, Any], log: Any) -> None: log(f" {s['label']}: {s['formulas']} Formeln") log(" allein richtig: " + ", ".join(f"{e} {c / n:.0%}" for e, c in s["correct"].items()) + f"; Qwen und Gemma einig {s['agree'] / n:.0%}, davon FALSCH {s['agree_wrong']}") + st = s.get("structural_examples", []) + if st: + log(f" nur strukturell gleich (bitte prüfen): {len(st)}") + for ex in st[:10]: + log(f" {ex['kind']}: {ex['reading']} ≡ {ex['other']}") log(" Regel → automatisch angenommen / davon falsch:") for row in s["rules"]: log(f" {row['rule']:<42} {row['accepted']:4d} ({row['accepted'] / n:.0%}) / {row['wrong']}") for ex in row["wrong_examples"][:3]: log(f" falsch: {ex['used']} (richtig: {ex['truth']})") + if s.get("arbiter_cases"): + log(" Bild-Schiedsrichter (Qwen = Gemma gegen pix2text): Beleg für Qwen, wer richtig ist:") + for ex in sorted(s["arbiter_cases"], key=lambda e: -(e["value"] or 0)): + who = "Qwen" if ex["qwen_ok"] else "pix2text" if ex["p2t_ok"] else "keiner" + log(f" {ex['value']:7.2f} richtig: {who:8s} Qwen: {ex['qwen']} pix2text: {ex['p2t']}") + log(" Schwelle → zusätzlich angenommen / davon falsch: " + ", ".join( + f"{row['margin']:g}: {row['extra_accepted']}/{row['wrong']}" for row in s["arbiter_sweep"])) log(" Qwen = Gemma mit Schwellen (Mittel/Minimum) → automatisch angenommen / davon falsch:") for row in s["sweep"]: log(f" p_mean ≥ {row['min_p_mean']:.2f}, p_min ≥ {row['min_p_min']:.2f}: " diff --git a/formula_img2latex/confusables.py b/formula_img2latex/confusables.py index 6401fa0..c76cd35 100644 --- a/formula_img2latex/confusables.py +++ b/formula_img2latex/confusables.py @@ -77,7 +77,7 @@ GROUPS: list[tuple[str, ...]] = [ # Bold vs. normal and \\mathbb/\\mathcal/\\mathfrak vs. normal stay distinct (vector vs. scalar, ℝ vs. R). EQUIVALENT: list[set[str]] = [ {"\\vec", "\\overrightarrow"}, {"\\bar", "\\overline"}, {"\\hat", "\\widehat"}, {"\\tilde", "\\widetilde"}, - {"\\to", "\\rightarrow", "\\longrightarrow"}, {"\\frac", "\\dfrac", "\\tfrac"}, {"\\le", "\\leq"}, + {"\\to", "\\rightarrow", "\\longrightarrow"}, {"\\frac", "\\dfrac", "\\tfrac", "\\cfrac"}, {"\\le", "\\leq"}, {"\\ge", "\\geq"}, {"\\ne", "\\neq"}, {"\\dots", "\\ldots"}, {"'", "\\prime"}, {"\\boldsymbol", "\\mathbf", "\\bm"}, {"\\mathrm", "\\mathit"}, ] diff --git a/formula_img2latex/consensus.py b/formula_img2latex/consensus.py index b86af31..9b0cf81 100644 --- a/formula_img2latex/consensus.py +++ b/formula_img2latex/consensus.py @@ -5,12 +5,15 @@ others; spacing and lengths may differ. Two readings therefore count as the same 1. their canonical forms are equal (spacing, \\left/\\right, braces, upright vs. italic, length variants such as \\vec/\\overrightarrow removed or unified), or -2. they render to the same picture (render against render: exact, no noisy original involved), +2. they have the same mathematical structure (structure.py: same unevaluated sympy tree, e.g. + \\frac{a}{b} vs. a/b, a\\cdot b vs. ab; nothing computed or reordered), or +3. they render to the same picture (render against render: exact, no noisy original involved), e.g. \\sin x vs. \\operatorname{sin} x. """ from __future__ import annotations +import os import re import numpy as np @@ -19,7 +22,7 @@ from .compare import _register from .confusables import tokenize from .imageio import trim from .render import render_many -from .tex import prepare_for_render, split_tag, strip_delimiters +from .tex import fix_literal_newlines, prepare_for_render, split_tag, strip_delimiters # tokens that carry no symbol _DROP = {"\\,", "\\;", "\\:", "\\!", "\\ ", "~", "\\quad", "\\qquad", "\\left", "\\right", "\\big", "\\Big", "\\bigg", @@ -30,7 +33,7 @@ _UNWRAP = {"\\mathrm", "\\mathit", "\\operatorname", "\\mathop", "\\textrm", "\\ "\\textnormal", "\\textup"} _SYNONYM = { "\\overrightarrow": "\\vec", "\\overline": "\\bar", "\\widehat": "\\hat", "\\widetilde": "\\tilde", - "\\rightarrow": "\\to", "\\longrightarrow": "\\to", "\\dfrac": "\\frac", "\\tfrac": "\\frac", + "\\rightarrow": "\\to", "\\longrightarrow": "\\to", "\\dfrac": "\\frac", "\\tfrac": "\\frac", "\\cfrac": "\\frac", "\\leq": "\\le", "\\geq": "\\ge", "\\neq": "\\ne", "\\ldots": "\\dots", "\\prime": "'", "\\mathbf": "\\boldsymbol", "\\bm": "\\boldsymbol", "\\lbrace": "\\{", "\\rbrace": "\\}", "\\vert": "|", "\\lvert": "|", "\\rvert": "|", "\\Vert": "\\|", "\\lVert": "\\|", "\\rVert": "\\|", "\\gets": "\\leftarrow", @@ -54,9 +57,20 @@ _OPERATORS = {"sin", "cos", "tan", "cot", "sec", "csc", "arcsin", "arccos", "arc # upright text groups; one level of nested braces (\mathrm{f \ddot{u} r}) _TEXT_GROUP = re.compile(r"\\(operatorname\*?|mathrm|text|textrm|mbox|textnormal|textup)\s*\{((?:[^{}]|\{[^{}]*\})*)\}") _BOLD = {"\\boldsymbol", "\\mathbf", "\\bm", "\\pmb"} -# commands that never take an argument: a group right after them is free-standing -_NOARG = {"\\cdot", "\\times", "\\pm", "\\mp", "\\le", "\\ge", "\\ne", "\\in", "\\to", "\\Rightarrow", - "\\Leftrightarrow", "\\approx", "\\equiv", "\\sim", "\\quad", "\\qquad", "\\cup", "\\cap", "\\setminus"} +# number of arguments of a command (after synonyms); every other command takes none, so a group after +# \Delta or after another group is free-standing (\Delta{x_{n}} = \Delta x_n, t^{2}\,{\sqrt{2}} = t^2\sqrt{2}) +_ARITY = {c: 2 for c in ("\\frac", "\\cfrac", "\\binom", "\\dbinom", "\\tbinom", "\\overset", "\\underset", + "\\stackrel", "\\sideset")} +_ARITY.update({c: 1 for c in ( + "\\sqrt", "\\vec", "\\bar", "\\hat", "\\tilde", "\\dot", "\\ddot", "\\dddot", "\\check", "\\breve", "\\acute", + "\\grave", "\\mathring", "\\underline", "\\overleftarrow", "\\overleftrightarrow", "\\overbrace", "\\underbrace", + "\\mathbb", "\\mathcal", "\\mathfrak", "\\mathscr", "\\mathsf", "\\mathtt", "\\boldsymbol", "\\pmb", + "\\begin", "\\end", "\\substack", "\\phantom", "\\hphantom", "\\vphantom", "\\boxed", "\\fbox", + "\\xrightarrow", "\\xleftarrow", "^", "_")}) +# multi-line environments whose & only aligns (dropped); in a matrix & separates columns (kept) +_ALIGN_ENVS = {"aligned", "align", "align*", "gathered", "gather", "gather*", "split", "alignedat", "alignat", + "alignat*", "eqnarray", "eqnarray*", "multline", "multline*", "flalign", "flalign*"} +_CASES_ENVS = {"cases", "dcases", "rcases"} def canonical(tex: str, bold: bool = False) -> str: @@ -64,13 +78,18 @@ def canonical(tex: str, bold: bool = False) -> str: *bold*: whether bold vs. normal counts (False: \\boldsymbol{v} and v are the same reading). """ - body, tag = split_tag(strip_delimiters(tex)) + body, tag = split_tag(strip_delimiters(fix_literal_newlines(tex))) body = prepare_for_render(body) body = _TEXT_GROUP.sub(_text_group, body) body = re.sub(r'\{?\\"\{?([aouAOU])\}?\}?', lambda m: _UMLAUT[m.group(1)], body) # f{\"u}r -> für body = re.sub(r"\^\s*\{\s*((?:\\prime\s*)+)\}", lambda m: "'" * m.group(1).count("\\prime"), body) # f^{\prime} body = re.sub(r"\^\s*\\prime(?![A-Za-z])", "'", body) + body = re.sub(r"\\\\\s*\[[^\]]*\]", r"\\\\", body) # \\[2mm]: row spacing only body = re.sub(r"\\\\\s*(?=\\end\{)", "", body) # a final \\ before \end{...} adds no row + body = re.sub(r"\\(left|right)\s*\.", "", body) # empty delimiter of \left\{ ... \right. + body = re.sub(r"\\cdot\s*\\cdot\s*\\cdot(?![A-Za-z])|(? str: body = re.sub(r"\\not\s*\\in(?![A-Za-z])", r"\\notin ", body) toks = [t for t in tokenize(body) if not t.isspace()] toks = [_SYNONYM.get(t, t) for t in toks if t not in _DROP] + toks = _environments(toks) unwrap = _UNWRAP if bold else _UNWRAP | _BOLD toks = [t for t in toks if t not in unwrap] # keep the argument group, unwrap below toks = _flatten_groups(toks) @@ -97,6 +117,53 @@ def _text_group(m: re.Match[str]) -> str: return f"\\{m.group(1)}{{{inner}}}" +def _environments(toks: list[str]) -> list[str]: + """One spelling for multi-line formulas: the same rows with the same symbols are the same reading. + + Alignment environments (aligned, gathered, ...) are dissolved and their & dropped: a\\\\b equals + \\begin{aligned}a&\\\\b\\end{aligned}. Case distinctions (cases, \\left\\{array or aligned\\right.) become + \\{ followed by their rows. Matrices stay (pmatrix etc. are MATRIX after the string replacements); + an array or matrix counts as a matrix if a bracket precedes it, else as an alignment. + """ + out: list[str] = [] + stack: list[str] = [] + i = 0 + while i < len(toks): + t = toks[i] + if t in ("\\begin", "\\end") and i + 1 < len(toks) and toks[i + 1] == "{" and "}" in toks[i + 2:]: + j = toks.index("}", i + 2) + name = "".join(toks[i + 2:j]) + while t == "\\end" and out and out[-1] == "\\\\": # an empty last row is no row + out.pop() + if t == "\\begin": + prev = out[-1] if out else None + if name in _CASES_ENVS: + kind = "cases" + elif name in _ALIGN_ENVS: + kind = "align" + elif name in ("MATRIX", "matrix"): + kind = "matrix" if prev in ("(", "[", "|", "\\|") else "align" + else: + kind = "other" + stack.append(kind) + if kind == "cases" and prev != "\\{": + out.append("\\{") + else: + kind = stack.pop() if stack else "other" + if kind in ("matrix", "other"): + out.extend(toks[i:j + 1]) + i = j + 1 + continue + if t == "&" and stack and stack[-1] in ("align", "cases"): + i += 1 + continue + out.append(t) + i += 1 + while out and out[-1] == "\\\\": + out.pop() + return out + + def _parse(toks: list[str], i: int = 0) -> tuple[list, int]: """Token list -> nested lists for {...} groups.""" out: list = [] @@ -114,20 +181,32 @@ def _parse(toks: list[str], i: int = 0) -> tuple[list, int]: def _emit(nodes: list) -> list[str]: - """Serialize: argument groups keep braces (if longer than one token), free groups dissolve.""" + """Serialize: argument groups keep braces (if longer than one token), free groups dissolve. + + A group is an argument only while the last command still expects one (_ARITY); a single token + can be an argument too (\\frac12). Tokens of an optional [...] after \\sqrt do not count. + """ out: list[str] = [] + pending = 0 # arguments the last command still expects + optional = False # inside \\sqrt[...] prev: object = None for n in nodes: if isinstance(n, list): inner = _emit(n) - is_arg = isinstance(prev, list) or prev in ("^", "_", "]") or ( - isinstance(prev, str) and prev.startswith("\\") and prev[1:].isalpha() and prev not in _NOARG) - if len(inner) <= 1 or not is_arg: - out.extend(inner) - else: + if len(inner) > 1 and pending > 0 and not optional: out.extend(["{", *inner, "}"]) + else: + out.extend(inner) + if not optional: + pending = max(0, pending - 1) else: out.append(n) + if n == "[" and prev == "\\sqrt": + optional = True + elif n == "]" and optional: + optional, pending = False, 1 + elif not optional: + pending = _ARITY.get(n, max(0, pending - 1)) prev = n return out @@ -154,12 +233,22 @@ def render_equal(a: str, b: str, display: bool, preset: str = "cm", zoom: float return int(diff.sum()) <= max(2, int(0.002 * (ca > 0.5).sum())) -def same_reading(a: str, b: str, display: bool, preset: str = "cm", bold: bool = False) -> str | None: - """'normalform' or 'render' if both readings mean the same, else None.""" +def same_reading(a: str, b: str, display: bool, preset: str = "cm", bold: bool = False, + structural: bool | None = None) -> str | None: + """'normalform', 'strukturell' or 'render' if both readings mean the same, else None. + + *structural*: use the structural comparison (None: environment FORMULA_STRUCTURAL, default off). + """ if not a or not b: return None if canonical(a, bold) == canonical(b, bold): return "normalform" + if structural is None: + structural = os.environ.get("FORMULA_STRUCTURAL", "0") == "1" + if structural: + from .structure import structural_equal + if structural_equal(a, b, bold): + return "strukturell" if render_equal(strip_delimiters(a), strip_delimiters(b), display, preset): return "render" return None diff --git a/formula_img2latex/decide.py b/formula_img2latex/decide.py index 6ff0b51..078e4ae 100644 --- a/formula_img2latex/decide.py +++ b/formula_img2latex/decide.py @@ -75,6 +75,10 @@ CONSENSUS_DEFAULTS: dict[str, float] = { # 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 + "arbiter": 0, # 1: Qwen = Gemma against pix2text is accepted if the image clearly shows their + "arbiter_margin": 5.0, # reading: evidence (compare.contrast) at least this large (rule p2t) + "structural": 0, # 1: readings with the same unevaluated sympy tree agree (structure.py). Off: + # after the multi-line normal form it found nothing more (Göllmann, 0.4.0) } @@ -165,9 +169,14 @@ def _checks(agreeing: list[dict[str, Any]], veto: dict[str, Any] | None, pandoc_ TIEBREAK_MISSING = "Stichwahl fehlt" +def _bold_marks(r: dict[str, Any] | None) -> int: + tex = (r or {}).get("tex") or "" + return sum(tex.count(c) for c in ("\\boldsymbol", "\\mathbf", "\\bm{", "\\pmb")) + + 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]: + th: dict[str, float], arbiter: float | None = None) -> 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*: @@ -177,7 +186,9 @@ def decide_pair_tiebreak(readings: list[dict[str, Any] | None], agree: list[tupl 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}). + invented \\tilde{z}). Exception (th["arbiter"], off by default): *arbiter*, the image evidence for + Qwen's reading against pix2text's (verify.image_arbiter), reaches th["arbiter_margin"]; then the + one of the two agreeing VLM readings with more bold markup is used. """ q, p = readings[0], readings[1] g = readings[2] if len(readings) > 2 else None @@ -195,6 +206,14 @@ def decide_pair_tiebreak(readings: list[dict[str, Any] | None], agree: list[tupl 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: + if th.get("arbiter") and arbiter is not None and arbiter >= th.get("arbiter_margin", 5.0) and q.get("tex"): + use = 2 if _bold_marks(g) > _bold_marks(q) else 0 + who = (f"Qwen und Gemma einig ({pairs[frozenset((0, 2))]}), Bild spricht gegen pix2text " + f"(Beleg {arbiter:.1f})") + problems = _checks([readings[use]], veto, pandoc_ok, book_px_per_pt, th) + if problems: + return "needs_review", f"{who}; " + "; ".join(problems), use + return "auto_accepted", f"{who}, Bild-Veto bestanden", use return ("needs_review", "Qwen und Gemma einig gegen pix2text – Mensch entscheidet: " f"Qwen: {q.get('tex') or '–'}", first) else: diff --git a/formula_img2latex/engines/p2t.py b/formula_img2latex/engines/p2t.py index c553ed7..7ed8e70 100644 --- a/formula_img2latex/engines/p2t.py +++ b/formula_img2latex/engines/p2t.py @@ -30,12 +30,14 @@ def tidy_p2t(tex: str) -> str: The spaces are removed by post_process of pix2tex (LaTeX-OCR), which keeps a space between two letters (`a x`, `\\cdot x`) and the control space `\\ `; TeX ignores the removed spaces in math mode, so the typeset formula does not change. Before that, Pix2Text's bold spellings become - \\boldsymbol / \\mathbf. + \\boldsymbol / \\mathbf, and its \\cfrac becomes \\dfrac (same look for ordinary fractions; Pandoc has + no \\cfrac and would leave the formula as TeX text). """ from pix2tex.utils.utils import post_process # imports torch: only when needed tex = _BOLDMATH.sub(lambda m: "\\boldsymbol{" + re.sub(r"\s+", "", m.group(1)) + "}", tex) tex = _BF.sub(lambda m: "\\mathbf{" + re.sub(r"\s+", "", m.group(2)) + "}", tex) + tex = re.sub(r"\\cfrac(?![A-Za-z])", r"\\dfrac", tex) return post_process(tex).strip() diff --git a/formula_img2latex/engines/vlm.py b/formula_img2latex/engines/vlm.py index 90d0f66..f17e698 100644 --- a/formula_img2latex/engines/vlm.py +++ b/formula_img2latex/engines/vlm.py @@ -15,9 +15,11 @@ from PIL import Image from ..errors import EXIT_TOOL, FormulaError from ..imageio import gray_to_ink, load_gray +from ..tex import fix_literal_newlines MIN_HEIGHT = 96 # small formula images are enlarged to at least this height for the model MAX_UPSCALE = 8 +MAX_SIDE = 1600 # larger crops (600-dpi boxes, figures) are reduced: they overflow the model's context TIMEOUT_S = 120 MAX_TOKENS = 800 # long multi-line formulas need ~400; runaway repetitions are cut off (they are useless anyway) @@ -63,13 +65,16 @@ class Recognition: def prepare_image(data: bytes, name: str = "") -> bytes: - """Flatten transparency onto white, enlarge tiny formulas, add a margin; PNG bytes.""" + """Flatten transparency onto white, enlarge tiny formulas, shrink huge ones, add a margin; PNG bytes.""" ink = gray_to_ink(load_gray(data, name)) gray = (255 - np.clip(ink * 255, 0, 255)).astype(np.uint8) im = Image.fromarray(gray) if im.height < MIN_HEIGHT: f = min(MAX_UPSCALE, max(1, int(np.ceil(MIN_HEIGHT / max(im.height, 1))))) im = im.resize((im.width * f, im.height * f), Image.LANCZOS) + if max(im.size) > MAX_SIDE: + s = MAX_SIDE / max(im.size) + im = im.resize((max(1, round(im.width * s)), max(1, round(im.height * s))), Image.LANCZOS) pad = max(8, im.height // 6) canvas = Image.new("L", (im.width + 2 * pad, im.height + 2 * pad), 255) canvas.paste(im, (pad, pad)) @@ -240,6 +245,7 @@ class VisionEngine: text = content.strip() usage = js.get("usage") or {} tex, isf = parse_answer(text) + tex = fix_literal_newlines(tex) if tex else tex p_min, p_mean = latex_token_probs(content, ((choice.get("logprobs") or {}).get("content") or [])) return Recognition(self.name, js.get("model", self.model), tex, isf, text[:2000], None, time.time() - t0, int(usage.get("prompt_tokens") or 0), diff --git a/formula_img2latex/pipeline.py b/formula_img2latex/pipeline.py index db80c66..1bb9691 100644 --- a/formula_img2latex/pipeline.py +++ b/formula_img2latex/pipeline.py @@ -136,9 +136,9 @@ def recognize(source: Source, ws: Workspace, engine_name: str, model: str | None continue if only_needing_tiebreak and not needs_tiebreak(f): continue - if any(c.get("engine") == engine.name and c.get("model") == engine.model + if any(c.get("engine") == engine.name and c.get("model") == engine.model and not c.get("error") and c.get("prompt") == PROMPT_VERSION and c.get("thinking") == engine.thinking for c in f.candidates): - continue # cached: never ask the same model the same question twice + continue # cached: never ask the same model the same question twice (a failed request is no answer) todo.append(f) if limit: todo = todo[:limit] @@ -420,7 +420,7 @@ def decide_all_consensus(ws: Workspace, quiet: bool, preset: str, only_ids: set[ # pix2text's reading is the one used, so its final punctuation must be confirmed too if rule != "p2t" and j >= 2: a, b = _strip_final_punct(a), _strip_final_punct(b) - how = same_reading(a, b, f.display, preset, bold) + how = same_reading(a, b, f.display, preset, bold, bool(th.get("structural"))) if how: agree.append((i, j, how)) plan.append((f, rs, agree)) diff --git a/formula_img2latex/structure.py b/formula_img2latex/structure.py new file mode 100644 index 0000000..15a75bd --- /dev/null +++ b/formula_img2latex/structure.py @@ -0,0 +1,299 @@ +"""Do two LaTeX readings have the same mathematical structure? (rule 3 of consensus.same_reading) + +Two readings count as structurally equal if they parse to the same *unevaluated* sympy expression +tree. Spelling variants vanish (\\frac{a}{b} vs. a/b, \\cdot vs. juxtaposition, redundant +parentheses), but nothing is computed or reordered: a+b != b+a, 2x=4 != x=2, 1+1 != 2. The reading +that is used must still be faithful to the book, so an equivalence that needs a transformation is +never accepted. + +Safeguards (each one measured to be necessary, see README): + * Relations are never handed to sympy (it evaluates v = (3,4)^T and \\nu = (3,4)^T both to False). + The formula is split at top-level relation and separator symbols; their sequence must match + exactly, only the operands go through sympy. + * Every symbol ("atom": letter, Greek letter, font-styled or accented letter, with index and primes) + is replaced by a placeholder from a table shared by both readings, because sympy merges glyphs + the project keeps apart (\\varepsilon and \\epsilon become one symbol). + * Products are made explicit with \\cdot (sympy's lark grammar rejects a^{b}c and x(y) is ambiguous). + * Visible brackets count: sympy drops them, so a-b and a+(-b) would be equal (a step in a textbook + derivation). The number of parentheses and absolute bars per operand is part of the key; only + the parentheses of a function argument are free (\\sin(x) = \\sin x). + * The tree is serialized in stored argument order (srepr sorts a+b and b+a alike). + * Every placeholder sent to sympy must come back in its tree (nothing silently dropped). + * Anything not covered (matrices, \\pm, \\times, intervals, sets, ...) gives "not comparable", never + a partial match; the caller then falls back to the render comparison. + +Model output is untrusted: it only goes through sympy's LaTeX parser (lark backend, no eval), never +sympify(). +""" + +from __future__ import annotations + +from collections import Counter +from functools import lru_cache +from typing import Any + +from .tex import split_tag, strip_delimiters + +MAX_OPERAND = 300 # characters of one operand handed to the parser (Earley parsing is not linear) + +# top-level symbols that separate operands; their sequence is compared as is +_RELATIONS = {"=", "\\ne", "<", ">", "\\le", "\\ge", "\\ll", "\\gg", "\\approx", "\\equiv", "\\sim", "\\simeq", + "\\cong", "\\propto", "\\in", "\\notin", "\\ni", "\\subset", "\\subseteq", "\\supset", "\\supseteq", + "\\to", "\\mapsto", "\\Rightarrow", "\\Leftarrow", "\\Leftrightarrow", "\\leftarrow", + "\\leftrightarrow", "\\perp", "\\parallel", ":", ",", ";", "."} +_BINOPS = {"+", "-", "\\cdot", "/", "\\div"} +# functions sympy's lark grammar knows; an argument follows without \cdot +_FUNCS = {"\\sin", "\\cos", "\\tan", "\\cot", "\\sec", "\\csc", "\\arcsin", "\\arccos", "\\arctan", "\\sinh", + "\\cosh", "\\tanh", "\\log", "\\ln", "\\exp"} +_BIGOPS = {"\\lim", "\\sum", "\\prod", "\\int"} +_GREEK = {"\\" + g for g in ( + "alpha beta gamma delta epsilon varepsilon zeta eta theta vartheta iota kappa lambda mu nu xi pi varpi rho " + "varrho sigma varsigma tau upsilon phi varphi chi psi omega Gamma Delta Theta Lambda Xi Pi Sigma Upsilon Phi " + "Psi Omega partial nabla hbar ell emptyset dots aleph").split()} +_STYLED = {"\\mathbb", "\\mathcal", "\\mathfrak", "\\mathscr", "\\mathsf", "\\mathtt", "\\boldsymbol", "\\vec", "\\bar", + "\\hat", "\\tilde", "\\dot", "\\ddot", "\\check", "\\breve", "\\underline", "\\overleftarrow"} + + +class _Unsupported(Exception): + pass + + +def structural_equal(a: str, b: str, bold: bool = False) -> bool: + """True if both readings parse to the same unevaluated expression tree (see module docstring). + + *bold*: whether bold vs. normal counts (as in consensus.canonical). + """ + if not a or not b: + return False + table: dict[str, str] = {} + try: + ka, atoms_a = structural_key(a, bold, table) + kb, atoms_b = structural_key(b, bold, table) + except Exception: # not covered, or the parser failed: not comparable + return False + return ka == kb and atoms_a == atoms_b + + +def structural_key(tex: str, bold: bool = False, table: dict[str, str] | None = None) -> tuple[tuple, Counter]: + """(key, atom counts) of one reading; raises if the formula is not covered. + + Placeholders are numbered in order of first appearance in *table*. Keys are only comparable + between readings converted with the same table. + """ + from .consensus import canonical + table = {} if table is None else table + body, tag = split_tag(strip_delimiters(tex)) + toks = [t for t in canonical(body, bold).split(" ") if t] + conv = _Converter(table) + ops, operands = _split_top(toks) + key = (tuple(ops), tuple(conv.operand(o) for o in operands), canonical(tag, bold) if tag else None) + return key, conv.atoms + + +def _split_top(toks: list[str]) -> tuple[list[str], list[list[str]]]: + """Split at relations and separators outside of any group; a dot between digits stays.""" + ops: list[str] = [] + parts: list[list[str]] = [[]] + depth = 0 + for k, t in enumerate(toks): + if t in ("{", "(", "["): + depth += 1 + elif t in ("}", ")", "]"): + depth -= 1 + decimal = t == "." and 0 < k < len(toks) - 1 and toks[k - 1].isdigit() and toks[k + 1].isdigit() + if depth == 0 and t in _RELATIONS and not decimal: + ops.append(t) + parts.append([]) + else: + parts[-1].append(t) + return ops, parts + + +class _Converter: + """Canonical tokens of one operand -> LaTeX for sympy's lark grammar, atoms as placeholders.""" + + def __init__(self, table: dict[str, str]) -> None: + self.table = table + self.atoms: Counter = Counter() + self.sent: set[str] = set() # placeholders of the current operand + self.brackets = 0 # visible ( ) and | | of the current operand + + def operand(self, toks: list[str]) -> tuple: + if not toks: + return ("leer",) + self.sent, self.brackets = set(), 0 + s, i = self.expr(toks, 0, set()) + if i != len(toks): + raise _Unsupported(toks[i]) + if len(s) > MAX_OPERAND: + raise _Unsupported("zu lang") + key, names = _parse(s) + if {n for n in names if n.startswith("z_{")} != self.sent: # Limit also holds a direction symbol + raise _Unsupported("Parser hat Symbole verschluckt") + return key, self.brackets + + def expr(self, t: list[str], i: int, stop: set[str], rel_ok: bool = False) -> tuple[str, int]: + out: list[str] = [] + factor = False # the last emitted item is a complete factor (a following one needs \cdot) + func = False # the last emitted item is a function name: its parentheses are free + while i < len(t) and t[i] not in stop: + x = t[i] + if x in _BINOPS: + out.append(x) + factor, i = False, i + 1 + elif rel_ok and x in ("=", "\\to"): + out.append(x) + factor, i = False, i + 1 + elif x == "^" and factor: + s, i = self.arg(t, i + 1) + out.append(f"^{{{s}}}") + elif x == "!" and factor: + out.append("!") + i += 1 + else: + if x in ("(", "|") and not func: + self.brackets += 1 + func = x in _FUNCS + s, i, is_factor = self.primary(t, i) + if factor: + out.append("\\cdot") + out.append(s) + factor = is_factor + continue + func = False + return " ".join(out), i + + def arg(self, t: list[str], i: int, rel_ok: bool = False) -> tuple[str, int]: + """A macro argument: a {group} or a single token.""" + if i >= len(t): + raise _Unsupported("Argument fehlt") + if t[i] == "{": + s, j = self.expr(t, i + 1, {"}"}, rel_ok) + return s, _expect(t, j, "}") + s, j, _f = self.primary(t[i:i + 1], 0) + if j != 1: + raise _Unsupported(t[i]) + return s, i + 1 + + def primary(self, t: list[str], i: int) -> tuple[str, int, bool]: + x = t[i] + if x.isdigit(): + j = i + while j < len(t) and t[j].isdigit(): + j += 1 + if j + 1 < len(t) and t[j] == "." and t[j + 1].isdigit(): + j += 1 + while j < len(t) and t[j].isdigit(): + j += 1 + return "".join(t[i:j]), j, True + if x in ("(", "|", "{"): + close = {"(": ")", "|": "|", "{": "}"}[x] + s, j = self.expr(t, i + 1, {close}) + j = _expect(t, j, close) + return (f"|{s}|" if x == "|" else f"({s})"), j, True + if x == "\\frac" or x == "\\binom": + a, j = self.arg(t, i + 1) + b, j = self.arg(t, j) + return f"{x}{{{a}}}{{{b}}}", j, True + if x == "\\sqrt": + j, idx = i + 1, "" + if j < len(t) and t[j] == "[": + n, j = self.expr(t, j + 1, {"]"}) + j = _expect(t, j, "]") + idx = f"[{n}]" + a, j = self.arg(t, j) + return f"\\sqrt{idx}{{{a}}}", j, True + if x in _FUNCS or x in _BIGOPS: + out, j = [x], i + 1 + while j < len(t) and t[j] in ("^", "_"): + s, j2 = self.arg(t, j + 1, rel_ok=x in _BIGOPS) + out.append(f"{t[j]}{{{s}}}") + j = j2 + return " ".join(out), j, False + if x == "\\infty": + return x, i + 1, True + return self.atom(t, i) + + def atom(self, t: list[str], i: int) -> tuple[str, int, bool]: + """Letter (with style or accent, index and primes) -> placeholder symbol.""" + x = t[i] + if x in _STYLED: + parts, j = [x, *_raw_arg(t, i + 1)[0]], _raw_arg(t, i + 1)[1] + elif (len(x) == 1 and x.isalpha()) or x in _GREEK: + parts, j = [x], i + 1 + else: + raise _Unsupported(x) + while j < len(t) and t[j] in ("'", "_"): + if t[j] == "'": + parts.append("'") + j += 1 + else: + sub, j = _raw_arg(t, j + 1) + parts += ["_", "{", *sub, "}"] + key = " ".join(parts) + name = self.table.setdefault(key, f"z_{{{len(self.table)}}}") + self.atoms[key] += 1 + self.sent.add(name) + return name, j, True + + +def _expect(t: list[str], j: int, tok: str) -> int: + if j >= len(t) or t[j] != tok: + raise _Unsupported(f"{tok} fehlt") + return j + 1 + + +def _raw_arg(t: list[str], i: int) -> tuple[list[str], int]: + """Tokens of a macro argument without conversion (index of a symbol, argument of \\vec).""" + if i >= len(t): + raise _Unsupported("Argument fehlt") + if t[i] != "{": + return [t[i]], i + 1 + depth, j = 0, i + while j < len(t): + depth += {"{": 1, "}": -1}.get(t[j], 0) + if depth == 0: + return t[i + 1:j], j + 1 + j += 1 + raise _Unsupported("} fehlt") + + +@lru_cache(maxsize=1) +def _sympy() -> tuple[Any, Any]: + import sympy + # sympy's lark transformer imports sympy.physics lazily at the first juxtaposition; inside + # evaluate(False) that import fails and every later implicit product fails with it + import sympy.physics.quantum # noqa: F401 + from sympy.parsing.latex import parse_latex + return sympy, parse_latex + + +@lru_cache(maxsize=8192) +def _parse(s: str) -> tuple[tuple, frozenset[str]]: + """Unevaluated sympy tree of *s*, serialized; plus the names of all symbols in it.""" + sympy, parse_latex = _sympy() + with sympy.evaluate(False): + e = parse_latex(s, backend="lark") + names: set[str] = set() + return _ser(e, names, sympy), frozenset(names) + + +def _ser(e: Any, names: set[str], sympy: Any) -> tuple: + """Tree as nested tuples in stored argument order (srepr would sort commutative arguments).""" + from lark import Token, Tree + if isinstance(e, Tree): # ambiguous parse: deterministic for the same input + return ("Tree", str(e.data), tuple(_ser(c, names, sympy) for c in e.children)) + if isinstance(e, Token): + return ("Token", str(e)) + if isinstance(e, (tuple, list)): + return ("tuple", tuple(_ser(c, names, sympy) for c in e)) + if isinstance(e, (sympy.logic.boolalg.Boolean, sympy.core.relational.Relational)) and not e.is_Symbol: + raise _Unsupported("Wahrheitswert") + if not isinstance(e, sympy.Basic): + raise _Unsupported(type(e).__name__) + if e.is_Symbol: + names.add(str(e.name)) + return ("Symbol", str(e.name)) + if e.is_Number: + return (type(e).__name__, str(e)) + return (type(e).__name__, tuple(_ser(a, names, sympy) for a in e.args)) diff --git a/formula_img2latex/tex.py b/formula_img2latex/tex.py index 505f7f9..9c3f7ff 100644 --- a/formula_img2latex/tex.py +++ b/formula_img2latex/tex.py @@ -92,6 +92,26 @@ def unsafe_reason(tex: str) -> str | None: return None +# commands that start with \n; any other "\n..." in a model reading is a JSON newline escape gone wrong +_N_COMMANDS = frozenset( + "nu nabla ne neq ni neg not notin nexists nmid nleq ngeq nless ngtr nsubseteq nsupseteq nparallel natural " + "nearrow nwarrow newline nleftarrow nrightarrow nLeftarrow nRightarrow nleftrightarrow nLeftrightarrow nprec " + "nsucc ncong nsim nvdash nVdash nolimits normalsize nonumber notag".split()) +_NEWLINE_RE = re.compile(r"\\\\|\\[^A-Za-z]|\\n[A-Za-z]*") + + +def fix_literal_newlines(tex: str) -> str: + """Models write line breaks of multi-line formulas as the two characters \\n (\\begin{aligned}\\n\\lim, + \\\\\\nk_2). TeX reads that as an unknown command \\n or \\nk; turn it into a space. Real commands + (\\nu, \\neq, ...) and a row break \\\\ followed by n stay.""" + def repl(m: re.Match[str]) -> str: + t = m.group(0) + if t.startswith("\\n") and t[1:] not in _N_COMMANDS: + return " " + t[2:] + return t + return _NEWLINE_RE.sub(repl, tex) + + _TAG_RE = re.compile(r"\\tag\*?\s*\{([^{}]*)\}") diff --git a/formula_img2latex/verify.py b/formula_img2latex/verify.py index 758fcf1..455cdb3 100644 --- a/formula_img2latex/verify.py +++ b/formula_img2latex/verify.py @@ -19,6 +19,8 @@ from .render import render_many SWITCH_MARGIN = 2.0 # evidence (see compare.contrast) by which a variant must win to replace the candidate MAX_CLIMB = 3 MIN_NCC_FOR_VARIANTS = 0.6 # hopeless candidates are not worth the variant test +MAX_ORIG_PIXELS = 4_000_000 # larger crops are figures or text boxes (largest accepted formula: 1.7 MP); + # the elastic comparison of a full-page figure killed its worker process @dataclass @@ -73,9 +75,27 @@ def _check_variants(orig: np.ndarray, cand: Comparison, tex: str, display: bool, return min_c, worst, worst_kind, n, eq, rival +def image_arbiter(orig: np.ndarray, a: str, b: str, display: bool, preset: str) -> float | None: + """Evidence that the original shows reading *a* rather than *b* (compare.contrast: positive = a). + + Both renders share *a*'s scale and elastic warp, so only the differing symbols count. 0.0 if the + two render identically, None if *a* cannot be compared at all. + """ + if orig.size > MAX_ORIG_PIXELS: + return None + ra, rb = render_many([(a, display), (b, display)], preset) + ca = compare(orig, ra) + if not ca.ok: + return None + c = contrast(ca, compare(orig, rb, warp_from=ca)) + return 0.0 if c is None else float(c) + + def verify(orig: np.ndarray, tex: str, display: bool, preset: str, max_climb: int = MAX_CLIMB, check_variants: bool = True) -> Verification: """Verify *tex* against the trimmed ink image *orig*.""" + if orig.size > MAX_ORIG_PIXELS: + return Verification(tex, False, f"Ausschnitt zu groß für den Bildvergleich ({orig.size / 1e6:.1f} MP)") history: list[str] = [] current = tex while True: diff --git a/requirements.txt b/requirements.txt index 44365ed..9fbe016 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ scikit-image>=0.22 opencv-python-headless>=4.8 Pillow>=10.0 lxml>=5.0 +# optional, only for the structural comparison (FORMULA_STRUCTURAL=1): sympy>=1.13, lark>=1.1 diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index e988b24..e76371f 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -109,6 +109,20 @@ 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" + big = Image.new("L", (4000, 900), 255) # 600-dpi box: shrunk so it fits the model's context + big.paste(0, (100, 100, 3900, 800)) + buf = io.BytesIO() + big.save(buf, "PNG") + with Image.open(io.BytesIO(prepare_image(buf.getvalue(), "x.png"))) as im: + assert max(im.size) <= 1600 * 1.4 + + +def test_verify_refuses_huge_crops() -> None: + import numpy as np + + from formula_img2latex.verify import verify + v = verify(np.ones((2500, 2000), np.float32), "x", True, "cm") # 5 MP: a figure, not a formula + assert not v.ok and "zu groß" in v.reason # ------------------------------------------------------------------------------- consensus @@ -208,6 +222,14 @@ def test_pair_tiebreak_rule() -> None: 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" + # image arbiter (off by default): Qwen = Gemma against pix2text, accepted only with enough image evidence + assert decide_pair_tiebreak([q, p, g], [(0, 2, "normalform")], veto, True, 1.4, th, 99.0)[0] == "needs_review" + arb = consensus_thresholds({"arbiter": 1, "arbiter_margin": 5.0}) + assert decide_pair_tiebreak([q, p, g], [(0, 2, "normalform")], veto, True, 1.4, arb, 4.0)[0] == "needs_review" + assert decide_pair_tiebreak([q, p, g], [(0, 2, "normalform")], veto, True, 1.4, arb, 6.0)[::2] == ("auto_accepted", 0) + assert decide_pair_tiebreak([q, p, g], [(0, 2, "normalform")], bad, True, 1.4, arb, 6.0)[0] == "needs_review" + gb = _reading("local2", "G", r"\boldsymbol{x}^2") # the agreeing reading with more bold markup is used + assert decide_pair_tiebreak([q, p, gb], [(0, 2, "normalform")], veto, True, 1.4, arb, 6.0)[::2] == ("auto_accepted", 2) 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) @@ -260,8 +282,9 @@ def test_calibration_rules_with_four_engines() -> None: 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)] + # Qwen = Gemma | + GLM | B (+ p2t) | GLM or p2t confirms | A (3 of 4) | C (all four) | standard rule p2t | + # + image arbiter (no arbiter values here: same as the standard rule) + assert got == [(2, 1), (1, 0), (2, 1), (2, 1), (3, 1), (1, 0), (3, 1), (3, 1)] def test_canonical_form() -> None: @@ -279,8 +302,56 @@ def test_canonical_form() -> None: 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}"), (r"\ddot{u}", "ü"), (r"\mathrm{lin}", r"\lim"), (r"f^{\prime 2}", "f'"), (r"f^{\prime}", "f")] - assert all(canonical(a) == canonical(b) for a, b in same) - assert all(canonical(a) != canonical(b) for a, b in differ) + # a group after a group or after \Delta is no argument (only commands in _ARITY take one) + same += [(r"t^{2}\,{\sqrt{2}}", r"t^2\sqrt{2}"), (r"\Delta{x_{n}}", r"\Delta x_n"), + (r"a_1+\cdot\cdot\cdot+a_n", r"a_1+\cdots+a_n"), (r"1,2,...,n", r"1,2,\ldots,n"), + (r"\root3\of{x}", r"\sqrt[3]{x}"), + # multi-line: same rows, any frame + (r"\begin{aligned}a&=b\\[2mm]c&=d\\\end{aligned}", r"a=b\\c=d"), + (r"f(x)=\begin{cases}1,&x>0\\0,&x\le0\end{cases}", r"f(x)=\left\{\begin{array}{ll}1,&x>0\\0,&x\le0\end{array}\right."), + (r"f(x)=\begin{cases}1,&x>0\\0,&x\le0\end{cases}", + r"f(x)=\left\{\begin{aligned}{}&{{}1,}&{x>0}\\{}&{{}0,}&{x\le0}\\\end{aligned}\right."), + (r"\begin{pmatrix}1&2\\3&4\end{pmatrix}", r"\left(\begin{array}{cc}1&2\\3&4\end{array}\right)"), + (r"\begin{aligned}\displaystyle a&\displaystyle=0\,.\\ \displaystyle\end{aligned}", r"a=0."), + # pix2text's \cfrac; model readings with literal \n line breaks (JSON escape gone wrong) + (r"\cfrac{df}{dx}=\cfrac{df}{du}\cdot\cfrac{du}{dx}", r"\frac{df}{dx}=\frac{df}{du}\cdot\frac{du}{dx}"), + (r"\begin{aligned}\n\lim_{x\to0} f &= 1 \\\nk_2 &= 2\n\end{aligned}", r"\lim_{x\to 0}f=1\\k_2=2")] + differ += [(r"\begin{pmatrix}1&2\end{pmatrix}", r"\begin{pmatrix}12\end{pmatrix}"), (r"a\\b\\c", r"a\\bc"), + (r"f(x)=\begin{cases}1\\0\end{cases}", r"f(x)=\begin{aligned}1\\0\end{aligned}"), + (r"x^{2}y", r"x^{2y}"), (r"\frac{a}{b}c", r"\frac{a}{bc}"), (r"\cdot\cdot", r"\dots"), + (r"\nu", "u"), (r"a\neq b", "a b"), (r"a\\\nu", r"a\\u")] # real \n commands stay + assert [p for p in same if canonical(p[0]) != canonical(p[1])] == [] + assert [p for p in differ if canonical(p[0]) == canonical(p[1])] == [] + + +def test_structural_equal() -> None: + pytest.importorskip("sympy") + pytest.importorskip("lark") + from formula_img2latex.consensus import same_reading + from formula_img2latex.structure import structural_equal, structural_key + same = [(r"\frac{a}{b}", "a/b"), (r"a\cdot b", "ab"), (r"a \div b", "a/b"), (r"\frac{1}{2}\cdot x", r"\frac12 x"), + (r"\sin(x)", r"\sin x"), (r"(a+b)c", r"(a+b)\cdot c"), (r"2\pi r", r"2 \cdot \pi \cdot r"), + (r"f'(x)>0", r"f^{\prime}(x) > 0"), + (r"H_0:\mu\le\mu_0\quad A:\mu>\mu_0", r"H_{0} : \mu \leq \mu_{0} \qquad A : \mu > \mu_{0}"), + (r"\lim_{x\to0}\frac{\sin x}{x}=1", r"\lim\limits_{x \to 0} \frac{\sin(x)}{x} = 1"), + # Göllmann: canonical() takes {\sqrt{2}} after t^{2} for a second argument + (r"|{\dot{\boldsymbol{y}}}(t)|=3t^{2}\,{\sqrt{2}}", r"|\dot{\boldsymbol{y}}(t)|=3t^{2}\sqrt{2}")] + differ = [(r"v=\begin{pmatrix}3\\4\end{pmatrix}", r"\nu=\begin{pmatrix}3\\4\end{pmatrix}"), # sympy: both False + ("1+1=2", "2+2=4"), ("2x=4", "x=2"), ("1+1", "2"), ("a+b", "b+a"), ("aa"), + (r"\frac{1}{2}x", r"\frac{x}{2}"), ("a-b", "a+(-b)"), ("(a)+b", "a+b"), # no reordering, brackets count + (r"\varepsilon_r", r"\epsilon_r"), (r"\varepsilon_r", r"\mathcal{E}_r"), (r"\mathbb{R}", "R"), # glyphs + ("X'", "x'"), ("v'''", "v''"), ("y_1", "y_i"), ("x^2", "x_2"), ("a_{n+1}", "a_n+1"), + (r"\sin(x)", "sin(x)"), (r"\exp(x)", "exp(x)"), ("e^x", r"\exp(x)"), (r"\sqrt[3]{x}", r"\sqrt{x}"), + (r"a \times b", r"a \cdot b"), ("0{,}5", "0.5"), ("(1,2)", "(1.2)"), (r"\frac{a}{b+c}", r"\frac{a}{b}+c")] + assert [p for p in same if not structural_equal(*p)] == [] + assert [p for p in differ if structural_equal(*p)] == [] + assert not structural_equal(r"\boldsymbol{v}", "v", bold=True) and structural_equal(r"\boldsymbol{v}", "v") + assert structural_equal("x=1", r"x=1\tag{3}") is False + for tex in (r"\begin{pmatrix}1\\2\end{pmatrix}", r"a \pm b", r"[a,b]", r"\int_0^1 x\,dx", "x" * 400, r"\frac{"): + with pytest.raises(Exception): # not covered: not comparable, the caller falls back to render + structural_key(tex) + assert same_reading(r"\frac{a}{b}", "a/b", False, structural=True) == "strukturell" + assert same_reading(r"\frac{a}{b}", "a/b", False, structural=False) != "strukturell" def test_tidy_p2t() -> None: @@ -294,6 +365,7 @@ def test_tidy_p2t() -> None: 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", + r"\cfrac{d f}{d x}": r"\dfrac{d f}{d x}", # Pandoc has no \cfrac } for raw, tidy in cases.items(): assert tidy_p2t(raw) == tidy, (raw, tidy_p2t(raw)) diff --git a/tools/buch_bericht.py b/tools/buch_bericht.py new file mode 100644 index 0000000..bc3a01c --- /dev/null +++ b/tools/buch_bericht.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""Result page of a whole book: every formula with original, set LaTeX, status and reason. + + python3 tools/buch_bericht.py AUSGABE [--pdf BUCH.pdf] [--out ORDNER] + +Reads the workspace of a finished `run` (nothing is recomputed, no model is asked) and writes +/bericht/index.html with images in bericht/img/ (default). The page lives next to the +book because it shows excerpts of it. Filter buttons: all, automatically replaced, review, image. +""" + +from __future__ import annotations + +import argparse +import html +import io +import re +import statistics +import sys +from pathlib import Path + +import numpy as np +from PIL import Image + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from formula_img2latex.pipeline import _ref_for, latest_by_engine, open_all # noqa: E402 +from formula_img2latex.render import render_many # noqa: E402 +from formula_img2latex.tex import strip_delimiters # noqa: E402 + +LABEL = {"local": "Qwen", "p2t": "pix2text", "local2": "Gemma", "cloud": "GLM"} +STATUS = {"auto_accepted": ("automatisch ersetzt", "good"), "accepted_by_human": ("von Hand übernommen", "good"), + "needs_review": ("Review", "rev"), "not_formula": ("keine Formel", "img"), + "rejected_keep_image": ("bleibt Bild", "img"), "kept_by_human": ("bleibt Bild (Hand)", "img"), + "render_failed": ("nicht setzbar", "img"), "pending": ("nicht geprüft", "img")} + + +def page_key(f) -> tuple[int, int]: + m = re.search(r"Seite (\d+), Box (\d+)", f.source_name or "") + return (int(m.group(1)), int(m.group(2))) if m else (10**6, 0) + + +def save_png(img: Image.Image, path: Path) -> None: + img.save(path, "PNG", optimize=True) + + +def original_png(data: bytes, max_h: int = 110, max_w: int = 900) -> Image.Image: + im = Image.open(io.BytesIO(data)) + if im.mode in ("P", "LA", "RGBA") or "transparency" in im.info: + im = Image.alpha_composite(Image.new("RGBA", im.size, "white"), im.convert("RGBA")) + im = im.convert("L") + f = min(1.0, max_h / max(im.height, 1), max_w / max(im.width, 1)) + if f < 1.0: + im = im.resize((max(1, round(im.width * f)), max(1, round(im.height * f))), Image.LANCZOS) + return im + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("source", type=Path) + ap.add_argument("--pdf", type=Path, default=None) + ap.add_argument("--out", type=Path, default=None) + ap.add_argument("--titel", default=None) + args = ap.parse_args() + source, ws = open_all(args.source, None, pdf=args.pdf) + out = args.out or ws.root / "bericht" + (out / "img").mkdir(parents=True, exist_ok=True) + formulas = sorted(ws.all(), key=page_key) + preset = ws.settings.get("preset") or "cm" + + # the LaTeX shown: the replacement if there is one, else pix2text's reading (rule p2t) + shown = [] + for f in formulas: + tex = f.final_tex + if not tex: + p = latest_by_engine(f).get("p2t") + tex = p.get("tex") if p else None + shown.append(strip_delimiters(tex) if tex else None) + rendered = render_many([(t or "\\text{–}", f.display) for t, f in zip(shown, formulas)], preset) + + counts: dict[str, int] = {} + secs: dict[str, list[float]] = {} + cards = [] + for n, (f, tex, rr) in enumerate(zip(formulas, shown, rendered), 1): + counts[f.status] = counts.get(f.status, 0) + 1 + latest = latest_by_engine(f) + for e, r in latest.items(): + if r.get("seconds"): + secs.setdefault(e, []).append(float(r["seconds"])) + try: + o = out / "img" / f"{f.id}-o.png" + if not o.exists(): # the original never changes; cutting it from the PDF is the slow part + save_png(original_png(source.original_bytes(_ref_for(source, f))), o) + orig = f"" + except Exception: # noqa: BLE001 + orig = "Original nicht lesbar" + if tex and rr.ok: + g = (rr.rasterize(1.6) * 255).astype(np.uint8) + save_png(Image.fromarray(255 - g), out / "img" / f"{f.id}-l.png") + latex = f"" + else: + latex = "–" + label, cls = STATUS.get(f.status, (f.status, "img")) + used = "ersetzt durch" if f.final_tex else "Lesart pix2text" + reads = "".join(f"{LABEL.get(e, e)}{html.escape(r.get('tex') or '–')}" + for e, r in sorted(latest.items(), key=lambda kv: ["p2t", "local", "local2", "cloud"].index(kv[0]) + if kv[0] in ("p2t", "local", "local2", "cloud") else 9)) + occ = f.occurrences[0] if f.occurrences else {} + cards.append( + f"
{n} · {html.escape(f.source_name)} · " + f"Zeile {occ.get('line', '?')} · {label}
" + f"
{html.escape(f.reason or '')}
" + f"
Original (PDF){orig}
{used}{latex}
" + f"{html.escape(tex or '–')}" + f"
alle Lesarten{reads}
") + + total = len(formulas) + good = counts.get("auto_accepted", 0) + counts.get("accepted_by_human", 0) + rev = counts.get("needs_review", 0) + img = total - good - rev + time_rows = "".join(f"Ø {LABEL.get(e, e)}{statistics.mean(v):.2f} s ({len(v)} Formeln)" + for e, v in sorted(secs.items())) + status_rows = "".join(f"{STATUS.get(k, (k,))[0]}{v} ({v / max(total, 1):.0%})" + for k, v in sorted(counts.items(), key=lambda kv: -kv[1])) + titel = args.titel or f"Formeln: {source.path.name}" + page = ( + f"{html.escape(titel)}" + f"

{html.escape(titel)}

" + f"

{total} Formelbilder (abgesetzte Formeln aus der PDF). Regel p2t: pix2text liest, Qwen " + "bestätigt, Gemma nur bei Uneinigkeit, dazu Pandoc und Bild-Veto. Links das Original aus der PDF, rechts das " + "gesetzte LaTeX.

" + f"{status_rows}{time_rows}
" + "
" + f"" + f"
" + + "".join(cards) + + "") + (out / "index.html").write_text(page, encoding="utf-8") + print(out / "index.html") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/konsens_bericht.py b/tools/konsens_bericht.py index f7e10d5..40b47e5 100644 --- a/tools/konsens_bericht.py +++ b/tools/konsens_bericht.py @@ -68,6 +68,11 @@ def main() -> int: parts.append(f"

{html.escape(s['label'])}

{len(res)} Formeln · A richtig {s['a_correct'] / n:.0%} · " f"B richtig {s['b_correct'] / n:.0%} · einig {s['agree'] / n:.0%} · " f"einig, aber falsch: {s['agree_wrong']}

") + st = s.get("structural_examples", []) + if st: # matches found only by the structural comparison: the truth comparison uses it too + parts.append(f"

Nur strukturell gleich ({len(st)}, bitte prüfen):

    " + "".join( + f"
  • {html.escape(ex['kind'])}: {html.escape(ex['reading'] or '–')} ≡ " + f"{html.escape(ex['other'] or '–')}
  • " for ex in st) + "
") order = sorted(res, key=lambda r: (0 if r["agree"] and not r["a_ok"] else 1 if not r["agree"] else 2)) texes = [] for r in order: diff --git a/tools/regeltest.py b/tools/regeltest.py index 7368bbe..79071c1 100644 --- a/tools/regeltest.py +++ b/tools/regeltest.py @@ -39,7 +39,7 @@ from formula_img2latex.engines.p2t import tidy_p2t # noqa: E402 from formula_img2latex.imageio import load_ink # noqa: E402 from formula_img2latex.render import render_many # noqa: E402 from formula_img2latex.tex import strip_delimiters # noqa: E402 -from formula_img2latex.verify import verify # noqa: E402 +from formula_img2latex.verify import image_arbiter, verify # noqa: E402 TOOLS = [("local", "Qwen"), ("p2t", "pix2text"), ("local2", "Gemma")] @@ -63,6 +63,10 @@ def main() -> int: ap.add_argument("--n", type=int, default=50) ap.add_argument("--name", default="regeltest") ap.add_argument("--reuse", type=Path, default=None, help="Lesarten aus einem früheren ergebnis.json") + ap.add_argument("--refresh", default="", + help="diese Engines trotz --reuse neu fragen, z. B. local,local2 (neuer Prompt)") + ap.add_argument("--arbiter", type=float, default=None, + help="Bild-Schiedsrichter einschalten mit dieser Schwelle (Qwen = Gemma gegen pix2text)") ap.add_argument("--exclude", type=Path, action="append", default=[], help="Formeln aus diesem früheren ergebnis.json auslassen (mehrfach möglich)") args = ap.parse_args() @@ -78,9 +82,10 @@ def main() -> int: smp = random.Random(42).sample(items, min(args.n + 1, len(items))) formulas, warm = smp[:args.n], smp[args.n] preset = _best_preset(smp[:12]) - th = consensus_thresholds() + th = consensus_thresholds({"arbiter": 1, "arbiter_margin": args.arbiter} if args.arbiter is not None else None) + refresh = {e for e in args.refresh.split(",") if e} eng = {} - for key, label in ([] if cached else TOOLS): + for key, label in ([t for t in TOOLS if t[0] in refresh] if cached else TOOLS): e = make_engine(key) t0 = time.time() e.check() @@ -94,25 +99,37 @@ def main() -> int: if cached and old is None: raise SystemExit(f"{name} fehlt in {args.reuse} (andere Stichprobe?)") reads: list[dict | None] = [None, None, None] + def read(pos: int) -> dict | None: + key = TOOLS[pos][0] + if old and key not in refresh: + return old[pos] + return eng[key].recognize(data, name, "", disp).to_json() + for pos in (1, 0): # pix2text first, then Qwen - reads[pos] = old[pos] if old else eng[TOOLS[pos][0]].recognize(data, name, "", disp).to_json() + reads[pos] = read(pos) texes = [r and r.get("tex") for r in reads] agree = agreements(texes, disp, preset) if not any((i, j) == (0, 1) for i, j, _ in agree): # disputed: ask Gemma - reads[2] = old[2] if old else eng["local2"].recognize(data, name, "", disp).to_json() + reads[2] = read(2) texes = [r and r.get("tex") for r in reads] agree = agreements(texes, disp, preset) - _st, _why, use = decide_pair_tiebreak(reads, agree, None, True, None, th) + arbiter = None # Qwen = Gemma against pix2text: image evidence for Qwen's reading + pairs = {(i, j) for i, j, _ in agree} + if (0, 2) in pairs and not pairs & {(0, 1), (1, 2)} and texes[0] and texes[1]: + arbiter = image_arbiter(load_ink(data, name), texes[0], texes[1], disp, preset) + _st, _why, use = decide_pair_tiebreak(reads, agree, None, True, None, th, arbiter) veto = None pandoc = True if use is not None and texes[use]: v = verify(load_ink(data, name), texes[use], disp, preset, check_variants=False) veto = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics} pandoc = pandoc_mathml_ok([(texes[use], disp)])[0] - status, reason, use = decide_pair_tiebreak(reads, agree, veto, pandoc, None, th) - ok = [bool(t) and bool(same_reading(t, truth, disp, preset)) for t in texes] + status, reason, use = decide_pair_tiebreak(reads, agree, veto, pandoc, None, th, arbiter) + ok_how = [same_reading(t, truth, disp, preset) if t else None for t in texes] + ok = [bool(h) for h in ok_how] accepted = status == "auto_accepted" - cases.append({"name": name, "truth": truth, "display": disp, "readings": reads, "ok": ok, "agree": agree, + cases.append({"name": name, "truth": truth, "display": disp, "readings": reads, "ok": ok, "ok_how": ok_how, + "agree": agree, "arbiter": arbiter, "status": status, "reason": reason, "use": use, "veto": veto, "right": bool(accepted and ok[use])}) mark = ("✓" if ok[use] else "✗ FALSCH") if accepted else "Review" @@ -130,7 +147,10 @@ def main() -> int: "mean_s_per_formula": statistics.mean(per_formula), "mean_s": {k: statistics.mean(v) if v else None for k, v in secs.items()}, "correct_alone": {key: sum(c["ok"][p] for c in cases if c["readings"][p]) - for p, (key, _l) in enumerate(TOOLS)}} + for p, (key, _l) in enumerate(TOOLS)}, + # matches found only by the structural comparison (check by hand: the truth comparison uses it too) + "structural": {"agree": sum(how == "strukturell" for c in cases for _i, _j, how in c["agree"]), + "truth": sum(h == "strukturell" for c in cases for h in c["ok_how"])}} out = Path(__file__).resolve().parents[1] / "vergleich" / args.name out.mkdir(parents=True, exist_ok=True) @@ -160,6 +180,8 @@ def main() -> int: f"davon FALSCH{len(wrong)}" f"Review (Mensch){len(review)} ({len(review) / n:.0%})" f"Gemma gefragt{gemma} ({gemma / n:.0%})" + f"nur strukturell gleich (Paare / gegen Wahrheit){summary['structural']['agree']} / " + f"{summary['structural']['truth']}" f"Ø Zeit pro Formel (alle Tools nacheinander){summary['mean_s_per_formula']:.2f} s" + "".join(f"Ø {lab[k]}{v:.2f} s" for k, v in summary["mean_s"].items() if v) + "".join(f"{lab[k]} allein richtig{v}/{len(secs[k])}" diff --git a/tools/vergleich_bericht.py b/tools/vergleich_bericht.py new file mode 100644 index 0000000..882a0c9 --- /dev/null +++ b/tools/vergleich_bericht.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Visual before/after report of two runs on the same readings (--reuse), e.g. a rule change. + + python3 tools/vergleich_bericht.py --epub1 BAND1.epub --epub2 BAND2.epub \\ + --regeltest regeltest1-ohne:regeltest1-struktur --regeltest regeltest2-ohne:regeltest2-struktur \\ + --kalibrierung VORHER.json:NACHHER.json [--name struktur] [--titel "…"] + +Each pair is VORHER:NACHHER (regeltest: names under vergleich/, calibration: report files). Writes +vergleich//index.html (not versioned): summary table, every changed decision or truth verdict, +and every match found only by the structural comparison, with original image and rendered readings. +""" + +from __future__ import annotations + +import argparse +import html +import json +import sys +import zipfile +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 _dvipng_jpeg, _rule_outcome # noqa: E402 +from formula_img2latex.decide import consensus_thresholds, decide_pair_tiebreak # noqa: E402 +from formula_img2latex.render import render_many # noqa: E402 +from formula_img2latex.tex import strip_delimiters # noqa: E402 + +ROOT = Path(__file__).resolve().parents[1] / "vergleich" +LABEL = {"local": "Qwen", "p2t": "pix2text", "local2": "Gemma", "cloud": "GLM"} + + +class Page: + """Collects cards; renders all TeX in one pdflatex batch at the end.""" + + def __init__(self) -> None: + self.parts: list[str] = [] + self.texes: list[tuple[str, bool, str]] = [] # (tex, display, preset) + + def img(self, tex: str | None, display: bool, preset: str) -> str: + self.texes.append((strip_delimiters(tex or "") or "\\text{–}", display, preset)) + return f"@@IMG{len(self.texes) - 1}@@" + + def html(self) -> str: + out = "".join(self.parts) + for preset in sorted({p for _t, _d, p in self.texes}): + idx = [k for k, (_t, _d, p) in enumerate(self.texes) if p == preset] + for k, rr in zip(idx, render_many([self.texes[k][:2] for k in idx], preset)): + if rr.ok: + g = (rr.rasterize(2.0) * 255).astype(np.uint8) + tag = f"" + else: + tag = "nicht setzbar" + out = out.replace(f"@@IMG{k}@@", tag) + return out + + +def original(z: zipfile.ZipFile | None, name: str, truth: str, display: bool) -> str: + try: + if name.endswith("#synth.jpg"): + data = _dvipng_jpeg(strip_delimiters(truth), display, 102) or b"" + else: + data = z.read(name) if z else b"" + return f"" + except Exception: # noqa: BLE001 + return "–" + + +def card(page: Page, cls: str, head: str, orig: str, truth: str, display: bool, preset: str, + rows: list[tuple[str, str | None]]) -> None: + trs = [f"Original{orig}", + f"richtig{page.img(truth, display, preset)}{html.escape(truth)}"] + for label, tex in rows: + trs.append(f"{label}{page.img(tex, display, preset) if tex else '–'}" + f"{html.escape(tex or '–')}") + page.parts.append(f"
{head}
{''.join(trs)}
") + + +def mark(ok: bool) -> str: + return "✓" if ok else "✗" + + +def regeltest(page: Page, summary: list[str], pair: str, z: zipfile.ZipFile | None) -> None: + a, b = pair.split(":") + ra, rb = (json.loads((ROOT / n / "ergebnis.json").read_text(encoding="utf-8")) for n in (a, b)) + sa, sb = ra["summary"], rb["summary"] + st = sb.get("structural", {}) + summary.append(f"{html.escape(b)} " + f"(vorher){sa['formulas']}" + f"{sa['accepted']} / {sa['wrong']}{sb['accepted']} / {sb['wrong']}" + f"{sa['review']} → {sb['review']}{st.get('agree', 0)} / {st.get('truth', 0)}") + old = {c["name"]: c for c in ra["cases"]} + preset = rb.get("preset", "cm") + tools = ["local", "p2t", "local2"] + for c in rb["cases"]: + o = old[c["name"]] + changed = c["status"] != o["status"] + struct = [i for i, j, how in c["agree"] if how == "strukturell"] or [ + k for k, h in enumerate(c.get("ok_how", [])) if h == "strukturell"] + verdict = [p for p in range(3) if c["readings"][p] and o["readings"][p] and c["ok"][p] != o["ok"][p]] + if not (changed or struct or verdict): + continue + right = c["status"] == "auto_accepted" and c["right"] + cls = "bad" if c["status"] == "auto_accepted" and not c["right"] else "good" if right else "rev" + head = (f"{html.escape(b)} · {'Entscheidung geändert: ' + o['status'] + ' → ' if changed else ''}" + f"{c['status']} · {html.escape(c['reason'])}" + + (" · Urteil gegen Wahrheit geändert: " + ", ".join(LABEL[tools[p]] for p in verdict) if verdict else "")) + rows = [(f"{mark(c['ok'][p])} {LABEL[t]}" + (" (" + c["ok_how"][p] + ")" if c.get("ok_how") and c["ok_how"][p] else ""), + (c["readings"][p] or {}).get("tex")) for p, t in enumerate(tools) if c["readings"][p]] + card(page, cls, head, original(z, c["name"], c["truth"], c["display"]), c["truth"], c["display"], preset, rows) + + +def kalibrierung(page: Page, summary: list[str], pair: str, z: zipfile.ZipFile | None) -> None: + a, b = pair.split(":") + ka, kb = (json.loads(Path(p).read_text(encoding="utf-8")) for p in (a, b)) + th = consensus_thresholds() + for sa, sb in zip(ka["sets"], kb["sets"]): + names = sb["engines"] + idx = [names.index(e) for e in ("local", "p2t", "local2")] + old = {r["name"]: r for r in sa["results"]} + acc = {"ohne": [0, 0], "mit": [0, 0]} + synth = sb["label"].endswith("Gehrke-Stil]") + preset = "cm" if synth else "txfonts" + for r in sb["results"]: + o = old[r["name"]] + use_o = _rule_outcome(o, idx, th, decide_pair_tiebreak, True) + use_n = _rule_outcome(r, idx, th, decide_pair_tiebreak, True) + for key, rr, u in (("ohne", o, use_o), ("mit", r, use_n)): + if u is not None: + acc[key][0] += 1 + acc[key][1] += not rr["ok"][u] + struct = [(i, j) for i, j, how in r["agree_all"] if how == "strukturell"] + struct_t = [k for k, h in enumerate(r.get("ok_how", [])) if h == "strukturell"] + verdict_ch = [k for k in range(len(names)) if r["ok"][k] != o["ok"][k]] + if (use_o is None) == (use_n is None) and not struct and not struct_t and not verdict_ch: + continue + verdict = ("übernommen" if use_n is not None else "nicht übernommen") + if (use_o is None) != (use_n is None): + verdict = f"Entscheidung geändert: {'übernommen' if use_o is not None else 'nicht übernommen'} → {verdict}" + wrong = use_n is not None and not r["ok"][use_n] + cls = "bad" if wrong else "good" if use_n is not None else "rev" + how = "; ".join([f"{LABEL.get(names[i], names[i])} = {LABEL.get(names[j], names[j])} strukturell" for i, j in struct] + + [f"{LABEL.get(names[k], names[k])} = Wahrheit strukturell" for k in struct_t] + + ["Urteil gegen Wahrheit geändert: " + ", ".join(LABEL.get(names[k], names[k]) for k in verdict_ch)] + * bool(verdict_ch)) + rows = [(f"{mark(r['ok'][k])} {LABEL.get(e, e)}" + (" ← verwendet" if k == use_n else ""), + r["readings"][k].get("tex")) for k, e in enumerate(names)] + card(page, cls, f"Kalibrierung {html.escape(sb['label'])} · {verdict} · {html.escape(how)}", + original(z, r["name"], r["truth"], r["display"]), r["truth"], r["display"], preset, rows) + n = sb["formulas"] + summary.append(f"Kalibrierung Band 1 {html.escape(sb['label'].split('[')[-1].rstrip(']'))}{n}" + f"{acc['ohne'][0]} / {acc['ohne'][1]}{acc['mit'][0]} / {acc['mit'][1]}" + f"–{len(sb.get('structural_examples', []))}") + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--epub1", type=Path, default=None, help="Göllmann Band 1 (Kalibrierung)") + ap.add_argument("--epub2", type=Path, default=None, help="Göllmann Band 2 (Regeltests)") + ap.add_argument("--regeltest", action="append", default=[], help="OHNE:MIT (Namen unter vergleich/)") + ap.add_argument("--kalibrierung", action="append", default=[], help="OHNE.json:MIT.json") + ap.add_argument("--name", default="struktur") + ap.add_argument("--titel", default="Vergleich vorher / nachher") + args = ap.parse_args() + z1 = zipfile.ZipFile(args.epub1) if args.epub1 else None + z2 = zipfile.ZipFile(args.epub2) if args.epub2 else None + page = Page() + summary: list[str] = [] + for pair in args.regeltest: + regeltest(page, summary, pair, z2) + for pair in args.kalibrierung: + kalibrierung(page, summary, pair, z1) + head = (f"

{html.escape(args.titel)}

" + "

Dieselben gespeicherten Lesarten, zweimal entschieden: vorher und nachher. " + "Regel p2t, mit Pandoc und Bild-Veto. ✓/✗ = gleiche Formel wie die Wahrheit.

" + "" + "" + + "".join(summary) + "
MessungFormelnvorher: automatisch / falschnachher: automatisch / falschReviewnur strukturell gleich (Paare / Wahrheit)
" + "

Unten: jede geänderte Entscheidung, jedes geänderte Urteil gegen die Wahrheit und jede " + "Übereinstimmung, die nur die Strukturstufe gefunden hat. Grün = übernommen und richtig, Rot = übernommen, aber falsch, " + "Gelb = Review. Bitte prüfen: Sind die als gleich gewerteten Lesarten wirklich dieselbe Formel?

") + page.parts.insert(0, head) + out = ROOT / args.name + out.mkdir(parents=True, exist_ok=True) + (out / "index.html").write_text( + "Vergleich" + page.html(), + encoding="utf-8") + print(out / "index.html") + return 0 + + +if __name__ == "__main__": + sys.exit(main())