feat: 0.4.0 – mehrzeilige Normalform, erstes ganzes PDF-Buch
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 <noreply@anthropic.com>
This commit is contained in:
parent
b06c9cbb4b
commit
2478bc5cb9
20 changed files with 1126 additions and 59 deletions
|
|
@ -226,9 +226,10 @@ Nein. Die Entscheidungen hängen am Inhalt der Bilder, nicht an Dateinamen. `sca
|
||||||
|
|
||||||
**Warum landet ein Teil im Review?**
|
**Warum landet ein Teil im Review?**
|
||||||
Ersetzt wird nur, was pix2text liest und Qwen (oder bei Uneinigkeit Gemma) bestätigt. Sind Qwen und Gemma
|
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
|
gemeinsam anderer Meinung als pix2text, entscheidest du. Häufige Gründe für Review: ein Modell liest ein
|
||||||
mit Ausrichtung, `\underbrace` oder Zeilenumbrüchen (die Modelle schreiben sie unterschiedlich), sehr
|
Zeichen anders (oft pix2text: `\nu` statt fettem v, Wörter wie „fir“ statt „für“), sehr kleine oder
|
||||||
kleine oder unscharfe Bilder, oder ein Modell liest ein Zeichen anders. Im Review stehen die Lesarten
|
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“.
|
nebeneinander; oft genügt ein Blick und „LaTeX übernehmen“.
|
||||||
|
|
||||||
**Wie sicher ist „automatisch ersetzt“?**
|
**Wie sicher ist „automatisch ersetzt“?**
|
||||||
|
|
|
||||||
44
CHANGELOG.md
44
CHANGELOG.md
|
|
@ -1,5 +1,49 @@
|
||||||
# Changelog
|
# 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
|
## 0.3.0 – 2026-10-09
|
||||||
|
|
||||||
Neue Standardregel `p2t`: **pix2text liest zuerst, Qwen bestätigt, Gemma nur bei Uneinigkeit**; GLM (Cloud)
|
Neue Standardregel `p2t`: **pix2text liest zuerst, Qwen bestätigt, Gemma nur bei Uneinigkeit**; GLM (Cloud)
|
||||||
|
|
|
||||||
69
README.md
69
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
|
„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}`,
|
Klammern, `\vec`/`\overrightarrow`, `\frac`/`\dfrac`, `\iff`/`\Leftrightarrow`, `pmatrix`/`\left(\begin{matrix}`,
|
||||||
TeX-Umlaute, `\atop`/`\substack`, kursiv/aufrecht, fett/normal …) oder – als Rückfallebene – zeichengleiches
|
TeX-Umlaute, `\atop`/`\substack`, `\cdot\cdot\cdot`/`\cdots`, `\root3\of{x}`/`\sqrt[3]{x}`, kursiv/aufrecht,
|
||||||
Rendering der beiden Lesarten (Render gegen Render, nicht gegen das verrauschte Original). Eine
|
fett/normal …) oder – als Rückfallebene – zeichengleiches Rendering der beiden Lesarten (Render gegen
|
||||||
Gleichungsnummer muss in allen Lesarten übereinstimmen und wird als Text hinter die Formel gesetzt.
|
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.
|
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 |
|
| + Stichwahl auch Qwen = Gemma gegen pix2text | 92 % / 1 | 91 % / 2 |
|
||||||
| Qwen = Gemma = GLM (Regel `vlm3`) | 78 % / 0 | 70 % / 0 |
|
| 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 %.
|
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,
|
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
|
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
|
(fehlende Wurzelexponenten, zerfallene Vektoren, `\nu` statt fettem v) – deshalb bleibt die Bestätigung
|
||||||
durch Qwen Pflicht.
|
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)
|
### 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
|
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.
|
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),
|
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
|
## Quellen
|
||||||
|
|
||||||
|
|
@ -184,16 +219,30 @@ sind nicht Teil dieser Version.
|
||||||
`vergleich/<name>/index.html` mit Original, gerenderten Lesarten, Entscheidung und Veto-Overlay.
|
`vergleich/<name>/index.html` mit Original, gerenderten Lesarten, Entscheidung und Veto-Overlay.
|
||||||
- `tools/konsens_bericht.py BERICHT.json EPUB` – Übersichtsseite zu `calibrate --consensus --report`.
|
- `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/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
|
- `tools/regeltest.py EPUB [--n 50] [--reuse ergebnis.json] [--refresh local,local2] [--arbiter 8]` – die
|
||||||
TeX, wie `run`; Seite mit Original, wahrer Formel, allen Lesarten und Entscheidung.
|
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
|
||||||
|
`<arbeitsordner>/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;
|
- `calibrate --consensus --engines local,local2,cloud,p2t --report X.json` misst alle Regeln;
|
||||||
`--reuse X.json` rechnet sie ohne Modellabfragen neu.
|
`--reuse X.json` rechnet sie ohne Modellabfragen neu.
|
||||||
- `vergleich/` ist nicht versioniert (Ausschnitte aus Büchern).
|
- `vergleich/` ist nicht versioniert (Ausschnitte aus Büchern).
|
||||||
|
|
||||||
## Stand
|
## Stand
|
||||||
|
|
||||||
Version 0.3.0 (Regel `p2t`) ist an Göllmann Band 1 und 2 gemessen (siehe [Messung](#messung)), aber noch
|
Version 0.4.0 (Regel `p2t`, mehrzeilige Normalform) ist an Göllmann Band 1 und 2 gemessen (siehe
|
||||||
**nicht am ganzen Gehrke** gelaufen. Die folgenden Zahlen stammen von Version 0.2.0 (Regel `vlm3`, mit GLM).
|
[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):
|
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:
|
Grenzen und offene Punkte:
|
||||||
|
|
||||||
- Mehrzeilige Rechnungen mit Ausrichtung, `\underbrace` oder Zeilenumbrüchen schreiben die Modelle
|
- Mehrzeilige Rechnungen: Rahmen und `&` gleicht die Normalform seit 0.4.0 aus; verschiedene
|
||||||
unterschiedlich – sie bilden den Großteil des Reviews.
|
Zeilenumbrüche, `\underbrace` und fehlende Integrationsgrenzen bleiben im Review.
|
||||||
- Formeln auf farbig hinterlegten Kästen verlieren beim Ersetzen die Hinterlegung.
|
- Formeln auf farbig hinterlegten Kästen verlieren beim Ersetzen die Hinterlegung.
|
||||||
- Fettdruck wird nicht geprüft (Vorgabe).
|
- Fettdruck wird nicht geprüft (Vorgabe).
|
||||||
- Die Messung umfasst 2 × 150 Formeln eines Buches ohne Textausschnitt; „0 Fehler“ bedeutet eine
|
- Die Messung umfasst 2 × 150 Formeln eines Buches ohne Textausschnitt; „0 Fehler“ bedeutet eine
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
"""formula_img2latex: Formelbilder in Markdown geprüft durch LaTeX ersetzen."""
|
"""formula_img2latex: Formelbilder in Markdown geprüft durch LaTeX ersetzen."""
|
||||||
|
|
||||||
__version__ = "0.3.0"
|
__version__ = "0.4.0"
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ from .decide import decide, thresholds
|
||||||
from .imageio import load_ink
|
from .imageio import load_ink
|
||||||
from .render import PRESETS, render_many
|
from .render import PRESETS, render_many
|
||||||
from .tex import strip_delimiters, unsafe_reason
|
from .tex import strip_delimiters, unsafe_reason
|
||||||
from .verify import verify
|
from .verify import image_arbiter, verify
|
||||||
|
|
||||||
SYNTH_PX_PER_PT = 1.41
|
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
|
name, data, truth, display, preset, readings = args
|
||||||
os.environ.setdefault("OMP_NUM_THREADS", "1")
|
os.environ.setdefault("OMP_NUM_THREADS", "1")
|
||||||
texes = [r.get("tex") or "" for r in readings]
|
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]] = []
|
agree: list[tuple[int, int, str]] = []
|
||||||
for i in range(len(texes)):
|
for i in range(len(texes)):
|
||||||
for j in range(i + 1, 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):
|
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)
|
v = verify(load_ink(data, name), texes[k], display, preset, check_variants=False)
|
||||||
vetos[k] = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics}
|
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)
|
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)
|
# "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,
|
"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}
|
"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")]),
|
[("local", "local2", "cloud"), ("local", "local2", "p2t"), ("local", "cloud", "p2t"), ("local2", "cloud", "p2t")]),
|
||||||
("C: Qwen = Gemma = GLM = p2t", "all", [("local", "local2", "cloud"), ("local", "local2", "p2t")]),
|
("C: Qwen = Gemma = GLM = p2t", "all", [("local", "local2", "cloud"), ("local", "local2", "p2t")]),
|
||||||
("Standard: p2t = Qwen, Stichwahl Gemma = p2t", "tiebreak", [("local", "p2t", "local2")]),
|
("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,
|
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)}
|
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]
|
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)
|
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:
|
if use is None:
|
||||||
return None
|
return None
|
||||||
veto = r["vetos"].get(idx[use])
|
vetos = {int(k): v for k, v in r["vetos"].items()} # string keys after a JSON round trip
|
||||||
st, _why, use = decide_fn(sub, agree, veto, True, None, t)
|
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
|
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": []}
|
"rules": [], "sweep": []}
|
||||||
out["agree_wrong_examples"] = [{"truth": r["truth"], "a": r["a"].get("tex"), "b": r["b"].get("tex")}
|
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]
|
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()
|
th0 = consensus_thresholds()
|
||||||
for rule, mode, groups in CONSENSUS_RULES:
|
for rule, mode, groups in CONSENSUS_RULES:
|
||||||
if any(e not in names for g in groups for e in g):
|
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
|
acc = wrong = 0
|
||||||
examples = []
|
examples = []
|
||||||
for r in results:
|
for r in results:
|
||||||
if mode == "tiebreak":
|
if mode in ("tiebreak", "arbiter"):
|
||||||
used = [_rule_outcome(r, [names.index(e) for e in g], th0, decide_pair_tiebreak, True)
|
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]
|
for g in groups]
|
||||||
else:
|
else:
|
||||||
used = [_rule_outcome(r, [names.index(e) for e in g], th0, decide_consensus) for g in groups]
|
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"),
|
examples.append({"truth": r["truth"], "used": r["readings"][use].get("tex"),
|
||||||
"readings": {e: rd.get("tex") for e, rd in zip(names, r["readings"])}})
|
"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]})
|
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 pm in (0.0, 0.8, 0.9, 0.95, 0.98):
|
||||||
for pn in (0.0, 0.2, 0.3, 0.5):
|
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})
|
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(f" {s['label']}: {s['formulas']} Formeln")
|
||||||
log(" allein richtig: " + ", ".join(f"{e} {c / n:.0%}" for e, c in s["correct"].items())
|
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']}")
|
+ 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:")
|
log(" Regel → automatisch angenommen / davon falsch:")
|
||||||
for row in s["rules"]:
|
for row in s["rules"]:
|
||||||
log(f" {row['rule']:<42} {row['accepted']:4d} ({row['accepted'] / n:.0%}) / {row['wrong']}")
|
log(f" {row['rule']:<42} {row['accepted']:4d} ({row['accepted'] / n:.0%}) / {row['wrong']}")
|
||||||
for ex in row["wrong_examples"][:3]:
|
for ex in row["wrong_examples"][:3]:
|
||||||
log(f" falsch: {ex['used']} (richtig: {ex['truth']})")
|
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:")
|
log(" Qwen = Gemma mit Schwellen (Mittel/Minimum) → automatisch angenommen / davon falsch:")
|
||||||
for row in s["sweep"]:
|
for row in s["sweep"]:
|
||||||
log(f" p_mean ≥ {row['min_p_mean']:.2f}, p_min ≥ {row['min_p_min']:.2f}: "
|
log(f" p_mean ≥ {row['min_p_mean']:.2f}, p_min ≥ {row['min_p_min']:.2f}: "
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ GROUPS: list[tuple[str, ...]] = [
|
||||||
# Bold vs. normal and \\mathbb/\\mathcal/\\mathfrak vs. normal stay distinct (vector vs. scalar, ℝ vs. R).
|
# Bold vs. normal and \\mathbb/\\mathcal/\\mathfrak vs. normal stay distinct (vector vs. scalar, ℝ vs. R).
|
||||||
EQUIVALENT: list[set[str]] = [
|
EQUIVALENT: list[set[str]] = [
|
||||||
{"\\vec", "\\overrightarrow"}, {"\\bar", "\\overline"}, {"\\hat", "\\widehat"}, {"\\tilde", "\\widetilde"},
|
{"\\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"},
|
{"\\ge", "\\geq"}, {"\\ne", "\\neq"}, {"\\dots", "\\ldots"}, {"'", "\\prime"},
|
||||||
{"\\boldsymbol", "\\mathbf", "\\bm"}, {"\\mathrm", "\\mathit"},
|
{"\\boldsymbol", "\\mathbf", "\\bm"}, {"\\mathrm", "\\mathit"},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
1. their canonical forms are equal (spacing, \\left/\\right, braces, upright vs. italic, length
|
||||||
variants such as \\vec/\\overrightarrow removed or unified), or
|
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.
|
e.g. \\sin x vs. \\operatorname{sin} x.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
@ -19,7 +22,7 @@ from .compare import _register
|
||||||
from .confusables import tokenize
|
from .confusables import tokenize
|
||||||
from .imageio import trim
|
from .imageio import trim
|
||||||
from .render import render_many
|
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
|
# tokens that carry no symbol
|
||||||
_DROP = {"\\,", "\\;", "\\:", "\\!", "\\ ", "~", "\\quad", "\\qquad", "\\left", "\\right", "\\big", "\\Big", "\\bigg",
|
_DROP = {"\\,", "\\;", "\\:", "\\!", "\\ ", "~", "\\quad", "\\qquad", "\\left", "\\right", "\\big", "\\Big", "\\bigg",
|
||||||
|
|
@ -30,7 +33,7 @@ _UNWRAP = {"\\mathrm", "\\mathit", "\\operatorname", "\\mathop", "\\textrm", "\\
|
||||||
"\\textnormal", "\\textup"}
|
"\\textnormal", "\\textup"}
|
||||||
_SYNONYM = {
|
_SYNONYM = {
|
||||||
"\\overrightarrow": "\\vec", "\\overline": "\\bar", "\\widehat": "\\hat", "\\widetilde": "\\tilde",
|
"\\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": "'",
|
"\\leq": "\\le", "\\geq": "\\ge", "\\neq": "\\ne", "\\ldots": "\\dots", "\\prime": "'",
|
||||||
"\\mathbf": "\\boldsymbol", "\\bm": "\\boldsymbol", "\\lbrace": "\\{", "\\rbrace": "\\}", "\\vert": "|",
|
"\\mathbf": "\\boldsymbol", "\\bm": "\\boldsymbol", "\\lbrace": "\\{", "\\rbrace": "\\}", "\\vert": "|",
|
||||||
"\\lvert": "|", "\\rvert": "|", "\\Vert": "\\|", "\\lVert": "\\|", "\\rVert": "\\|", "\\gets": "\\leftarrow",
|
"\\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})
|
# 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*\{((?:[^{}]|\{[^{}]*\})*)\}")
|
_TEXT_GROUP = re.compile(r"\\(operatorname\*?|mathrm|text|textrm|mbox|textnormal|textup)\s*\{((?:[^{}]|\{[^{}]*\})*)\}")
|
||||||
_BOLD = {"\\boldsymbol", "\\mathbf", "\\bm", "\\pmb"}
|
_BOLD = {"\\boldsymbol", "\\mathbf", "\\bm", "\\pmb"}
|
||||||
# commands that never take an argument: a group right after them is free-standing
|
# number of arguments of a command (after synonyms); every other command takes none, so a group after
|
||||||
_NOARG = {"\\cdot", "\\times", "\\pm", "\\mp", "\\le", "\\ge", "\\ne", "\\in", "\\to", "\\Rightarrow",
|
# \Delta or after another group is free-standing (\Delta{x_{n}} = \Delta x_n, t^{2}\,{\sqrt{2}} = t^2\sqrt{2})
|
||||||
"\\Leftrightarrow", "\\approx", "\\equiv", "\\sim", "\\quad", "\\qquad", "\\cup", "\\cap", "\\setminus"}
|
_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:
|
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).
|
*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 = prepare_for_render(body)
|
||||||
body = _TEXT_GROUP.sub(_text_group, 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'\{?\\"\{?([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*\{\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*\\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"\\\\\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])|(?<!\\)\.\s*\.\s*\.", r"\\dots ", body)
|
||||||
|
body = re.sub(r"\\root\s*(\{[^{}]*\}|[^\s{}\\])\s*\\of(?![A-Za-z])",
|
||||||
|
lambda m: f"\\sqrt[{m.group(1).strip('{}')}]", body) # plain TeX \root3\of{x}
|
||||||
body = re.sub(r"\{?\\ss\{?\}?\}?", "ß", body)
|
body = re.sub(r"\{?\\ss\{?\}?\}?", "ß", body)
|
||||||
body = re.sub(r"\{([^{}]*)\}\s*\\atop\s*\{([^{}]*)\}", r"\\substack{\1\\\\\2}", body)
|
body = re.sub(r"\{([^{}]*)\}\s*\\atop\s*\{([^{}]*)\}", r"\\substack{\1\\\\\2}", body)
|
||||||
body = re.sub(r"\\begin\{array\}\s*(?:\[[^\]]*\])?\s*\{[^{}]*\}", r"\\begin{matrix}", body) # [t]: position only
|
body = re.sub(r"\\begin\{array\}\s*(?:\[[^\]]*\])?\s*\{[^{}]*\}", r"\\begin{matrix}", body) # [t]: position only
|
||||||
|
|
@ -81,6 +100,7 @@ def canonical(tex: str, bold: bool = False) -> str:
|
||||||
body = re.sub(r"\\not\s*\\in(?![A-Za-z])", r"\\notin ", body)
|
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 = [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 = [_SYNONYM.get(t, t) for t in toks if t not in _DROP]
|
||||||
|
toks = _environments(toks)
|
||||||
unwrap = _UNWRAP if bold else _UNWRAP | _BOLD
|
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 = [t for t in toks if t not in unwrap] # keep the argument group, unwrap below
|
||||||
toks = _flatten_groups(toks)
|
toks = _flatten_groups(toks)
|
||||||
|
|
@ -97,6 +117,53 @@ def _text_group(m: re.Match[str]) -> str:
|
||||||
return f"\\{m.group(1)}{{{inner}}}"
|
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]:
|
def _parse(toks: list[str], i: int = 0) -> tuple[list, int]:
|
||||||
"""Token list -> nested lists for {...} groups."""
|
"""Token list -> nested lists for {...} groups."""
|
||||||
out: list = []
|
out: list = []
|
||||||
|
|
@ -114,20 +181,32 @@ def _parse(toks: list[str], i: int = 0) -> tuple[list, int]:
|
||||||
|
|
||||||
|
|
||||||
def _emit(nodes: list) -> list[str]:
|
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] = []
|
out: list[str] = []
|
||||||
|
pending = 0 # arguments the last command still expects
|
||||||
|
optional = False # inside \\sqrt[...]
|
||||||
prev: object = None
|
prev: object = None
|
||||||
for n in nodes:
|
for n in nodes:
|
||||||
if isinstance(n, list):
|
if isinstance(n, list):
|
||||||
inner = _emit(n)
|
inner = _emit(n)
|
||||||
is_arg = isinstance(prev, list) or prev in ("^", "_", "]") or (
|
if len(inner) > 1 and pending > 0 and not optional:
|
||||||
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:
|
|
||||||
out.extend(["{", *inner, "}"])
|
out.extend(["{", *inner, "}"])
|
||||||
|
else:
|
||||||
|
out.extend(inner)
|
||||||
|
if not optional:
|
||||||
|
pending = max(0, pending - 1)
|
||||||
else:
|
else:
|
||||||
out.append(n)
|
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
|
prev = n
|
||||||
return out
|
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()))
|
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:
|
def same_reading(a: str, b: str, display: bool, preset: str = "cm", bold: bool = False,
|
||||||
"""'normalform' or 'render' if both readings mean the same, else None."""
|
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:
|
if not a or not b:
|
||||||
return None
|
return None
|
||||||
if canonical(a, bold) == canonical(b, bold):
|
if canonical(a, bold) == canonical(b, bold):
|
||||||
return "normalform"
|
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):
|
if render_equal(strip_delimiters(a), strip_delimiters(b), display, preset):
|
||||||
return "render"
|
return "render"
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,10 @@ CONSENSUS_DEFAULTS: dict[str, float] = {
|
||||||
# alarms on Gehrke, never measured to catch anything)
|
# alarms on Gehrke, never measured to catch anything)
|
||||||
"bold_relevant": 0, # 1: bold vs. normal counts as a different symbol
|
"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
|
"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"
|
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]],
|
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,
|
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.
|
"""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*:
|
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
|
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
|
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]
|
q, p = readings[0], readings[1]
|
||||||
g = readings[2] if len(readings) > 2 else None
|
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:
|
elif frozenset((1, 2)) in pairs:
|
||||||
how, who, confirm = pairs[frozenset((1, 2))], "Stichwahl: Gemma bestätigt pix2text", g
|
how, who, confirm = pairs[frozenset((1, 2))], "Stichwahl: Gemma bestätigt pix2text", g
|
||||||
elif frozenset((0, 2)) in pairs:
|
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: "
|
return ("needs_review", "Qwen und Gemma einig gegen pix2text – Mensch entscheidet: "
|
||||||
f"Qwen: {q.get('tex') or '–'}", first)
|
f"Qwen: {q.get('tex') or '–'}", first)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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
|
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
|
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 = _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 = _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()
|
return post_process(tex).strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,11 @@ from PIL import Image
|
||||||
|
|
||||||
from ..errors import EXIT_TOOL, FormulaError
|
from ..errors import EXIT_TOOL, FormulaError
|
||||||
from ..imageio import gray_to_ink, load_gray
|
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
|
MIN_HEIGHT = 96 # small formula images are enlarged to at least this height for the model
|
||||||
MAX_UPSCALE = 8
|
MAX_UPSCALE = 8
|
||||||
|
MAX_SIDE = 1600 # larger crops (600-dpi boxes, figures) are reduced: they overflow the model's context
|
||||||
TIMEOUT_S = 120
|
TIMEOUT_S = 120
|
||||||
MAX_TOKENS = 800 # long multi-line formulas need ~400; runaway repetitions are cut off (they are useless anyway)
|
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:
|
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))
|
ink = gray_to_ink(load_gray(data, name))
|
||||||
gray = (255 - np.clip(ink * 255, 0, 255)).astype(np.uint8)
|
gray = (255 - np.clip(ink * 255, 0, 255)).astype(np.uint8)
|
||||||
im = Image.fromarray(gray)
|
im = Image.fromarray(gray)
|
||||||
if im.height < MIN_HEIGHT:
|
if im.height < MIN_HEIGHT:
|
||||||
f = min(MAX_UPSCALE, max(1, int(np.ceil(MIN_HEIGHT / max(im.height, 1)))))
|
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)
|
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)
|
pad = max(8, im.height // 6)
|
||||||
canvas = Image.new("L", (im.width + 2 * pad, im.height + 2 * pad), 255)
|
canvas = Image.new("L", (im.width + 2 * pad, im.height + 2 * pad), 255)
|
||||||
canvas.paste(im, (pad, pad))
|
canvas.paste(im, (pad, pad))
|
||||||
|
|
@ -240,6 +245,7 @@ class VisionEngine:
|
||||||
text = content.strip()
|
text = content.strip()
|
||||||
usage = js.get("usage") or {}
|
usage = js.get("usage") or {}
|
||||||
tex, isf = parse_answer(text)
|
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 []))
|
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,
|
return Recognition(self.name, js.get("model", self.model), tex, isf, text[:2000], None,
|
||||||
time.time() - t0, int(usage.get("prompt_tokens") or 0),
|
time.time() - t0, int(usage.get("prompt_tokens") or 0),
|
||||||
|
|
|
||||||
|
|
@ -136,9 +136,9 @@ def recognize(source: Source, ws: Workspace, engine_name: str, model: str | None
|
||||||
continue
|
continue
|
||||||
if only_needing_tiebreak and not needs_tiebreak(f):
|
if only_needing_tiebreak and not needs_tiebreak(f):
|
||||||
continue
|
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):
|
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)
|
todo.append(f)
|
||||||
if limit:
|
if limit:
|
||||||
todo = todo[: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
|
# pix2text's reading is the one used, so its final punctuation must be confirmed too
|
||||||
if rule != "p2t" and j >= 2:
|
if rule != "p2t" and j >= 2:
|
||||||
a, b = _strip_final_punct(a), _strip_final_punct(b)
|
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:
|
if how:
|
||||||
agree.append((i, j, how))
|
agree.append((i, j, how))
|
||||||
plan.append((f, rs, agree))
|
plan.append((f, rs, agree))
|
||||||
|
|
|
||||||
299
formula_img2latex/structure.py
Normal file
299
formula_img2latex/structure.py
Normal file
|
|
@ -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))
|
||||||
|
|
@ -92,6 +92,26 @@ def unsafe_reason(tex: str) -> str | None:
|
||||||
return 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*\{([^{}]*)\}")
|
_TAG_RE = re.compile(r"\\tag\*?\s*\{([^{}]*)\}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
SWITCH_MARGIN = 2.0 # evidence (see compare.contrast) by which a variant must win to replace the candidate
|
||||||
MAX_CLIMB = 3
|
MAX_CLIMB = 3
|
||||||
MIN_NCC_FOR_VARIANTS = 0.6 # hopeless candidates are not worth the variant test
|
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
|
@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
|
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,
|
def verify(orig: np.ndarray, tex: str, display: bool, preset: str, max_climb: int = MAX_CLIMB,
|
||||||
check_variants: bool = True) -> Verification:
|
check_variants: bool = True) -> Verification:
|
||||||
"""Verify *tex* against the trimmed ink image *orig*."""
|
"""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] = []
|
history: list[str] = []
|
||||||
current = tex
|
current = tex
|
||||||
while True:
|
while True:
|
||||||
|
|
|
||||||
|
|
@ -5,3 +5,4 @@ scikit-image>=0.22
|
||||||
opencv-python-headless>=4.8
|
opencv-python-headless>=4.8
|
||||||
Pillow>=10.0
|
Pillow>=10.0
|
||||||
lxml>=5.0
|
lxml>=5.0
|
||||||
|
# optional, only for the structural comparison (FORMULA_STRUCTURAL=1): sympy>=1.13, lark>=1.1
|
||||||
|
|
|
||||||
|
|
@ -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()
|
data = (epub2md_dir / "media" / "images" / "0001-inline.jpg").read_bytes()
|
||||||
with Image.open(io.BytesIO(prepare_image(data, "x.jpg"))) as im:
|
with Image.open(io.BytesIO(prepare_image(data, "x.jpg"))) as im:
|
||||||
assert im.height >= 96 and im.mode == "L"
|
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
|
# ------------------------------------------------------------------------------- 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 "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], [(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], [(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 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]
|
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)
|
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
|
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)
|
s = _summarize_consensus("t", results, names, consensus_thresholds, decide_consensus)
|
||||||
got = [(r["accepted"], r["wrong"]) for r in s["rules"]]
|
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
|
# 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)]
|
# + 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:
|
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"),
|
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"\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")]
|
(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)
|
# a group after a group or after \Delta is no argument (only commands in _ARITY take one)
|
||||||
assert all(canonical(a) != canonical(b) for a, b in differ)
|
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"), ("a<b", "b>a"),
|
||||||
|
(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:
|
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"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}}} \, v": r"{\mathbf{rot}}\,v",
|
||||||
r"\mathrm{\bf~ r o t} \, E": r"\mathbf{rot}\,E",
|
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():
|
for raw, tidy in cases.items():
|
||||||
assert tidy_p2t(raw) == tidy, (raw, tidy_p2t(raw))
|
assert tidy_p2t(raw) == tidy, (raw, tidy_p2t(raw))
|
||||||
|
|
|
||||||
161
tools/buch_bericht.py
Normal file
161
tools/buch_bericht.py
Normal file
|
|
@ -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
|
||||||
|
<workspace>/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"<img loading=lazy src='img/{f.id}-o.png'>"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
orig = "<i>Original nicht lesbar</i>"
|
||||||
|
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"<img loading=lazy src='img/{f.id}-l.png'>"
|
||||||
|
else:
|
||||||
|
latex = "<i>–</i>"
|
||||||
|
label, cls = STATUS.get(f.status, (f.status, "img"))
|
||||||
|
used = "ersetzt durch" if f.final_tex else "Lesart pix2text"
|
||||||
|
reads = "".join(f"<tr><th>{LABEL.get(e, e)}</th><td><code>{html.escape(r.get('tex') or '–')}</code></td></tr>"
|
||||||
|
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"<section class='{cls}' data-s='{cls}'><div class=h><b>{n}</b> · {html.escape(f.source_name)} · "
|
||||||
|
f"Zeile {occ.get('line', '?')} · <span class=b>{label}</span></div>"
|
||||||
|
f"<div class=why>{html.escape(f.reason or '')}</div>"
|
||||||
|
f"<div class=row><div><small>Original (PDF)</small>{orig}</div><div><small>{used}</small>{latex}</div></div>"
|
||||||
|
f"<code class=tex>{html.escape(tex or '–')}</code>"
|
||||||
|
f"<details><summary>alle Lesarten</summary><table>{reads}</table></details></section>")
|
||||||
|
|
||||||
|
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"<tr><th>Ø {LABEL.get(e, e)}</th><td>{statistics.mean(v):.2f} s ({len(v)} Formeln)</td></tr>"
|
||||||
|
for e, v in sorted(secs.items()))
|
||||||
|
status_rows = "".join(f"<tr><th>{STATUS.get(k, (k,))[0]}</th><td>{v} ({v / max(total, 1):.0%})</td></tr>"
|
||||||
|
for k, v in sorted(counts.items(), key=lambda kv: -kv[1]))
|
||||||
|
titel = args.titel or f"Formeln: {source.path.name}"
|
||||||
|
page = (
|
||||||
|
f"<!doctype html><meta charset=utf-8><title>{html.escape(titel)}</title><style>"
|
||||||
|
":root{--bg:#f6f6f3;--card:#fff;--ink:#222;--mut:#666;--line:#ddd}"
|
||||||
|
"body{font:14px system-ui,sans-serif;margin:0;padding:16px;background:var(--bg);color:var(--ink)}"
|
||||||
|
"h1{font-size:20px}table.sum{border-collapse:collapse;background:var(--card);margin:8px 0}"
|
||||||
|
"table.sum th,table.sum td{border:1px solid var(--line);padding:4px 10px;text-align:left}"
|
||||||
|
".bar{position:sticky;top:0;background:var(--bg);padding:8px 0;z-index:1}"
|
||||||
|
"button{font:inherit;padding:4px 10px;margin-right:6px;border:1px solid #bbb;border-radius:4px;background:#fff;cursor:pointer}"
|
||||||
|
"button.on{background:#222;color:#fff}"
|
||||||
|
"section{background:var(--card);border:1px solid var(--line);border-left:6px solid #999;border-radius:6px;"
|
||||||
|
"padding:6px 10px;margin:8px 0}section.good{border-left-color:#1f7a3a}section.rev{border-left-color:#c88a00}"
|
||||||
|
"section.img{border-left-color:#888}.h{font-size:13px}.b{font-weight:600}.good .b{color:#1f7a3a}"
|
||||||
|
".rev .b{color:#a15c00}.why{font-size:12px;color:var(--mut);margin:2px 0 4px}"
|
||||||
|
".row{display:flex;flex-wrap:wrap;gap:16px;align-items:flex-start}.row>div{display:flex;flex-direction:column;gap:2px}"
|
||||||
|
".row img{max-width:min(900px,100%);height:auto}code{font-size:12px}code.tex{display:block;margin:4px 0;"
|
||||||
|
"overflow-x:auto;white-space:pre-wrap;word-break:break-all}details{font-size:12px}"
|
||||||
|
"details th{text-align:left;font-weight:400;color:var(--mut);padding-right:8px;vertical-align:top}</style>"
|
||||||
|
f"<h1>{html.escape(titel)}</h1>"
|
||||||
|
f"<p>{total} Formelbilder (abgesetzte Formeln aus der PDF). Regel <code>p2t</code>: pix2text liest, Qwen "
|
||||||
|
"bestätigt, Gemma nur bei Uneinigkeit, dazu Pandoc und Bild-Veto. Links das Original aus der PDF, rechts das "
|
||||||
|
"gesetzte LaTeX.</p>"
|
||||||
|
f"<table class=sum>{status_rows}{time_rows}</table>"
|
||||||
|
"<div class=bar><button class=on data-f=all>alle</button>"
|
||||||
|
f"<button data-f=good>ersetzt ({good})</button><button data-f=rev>Review ({rev})</button>"
|
||||||
|
f"<button data-f=img>Bild/keine Formel ({img})</button></div>"
|
||||||
|
+ "".join(cards)
|
||||||
|
+ "<script>document.querySelectorAll('button[data-f]').forEach(function(b){b.onclick=function(){"
|
||||||
|
"document.querySelectorAll('button[data-f]').forEach(function(x){x.classList.toggle('on',x===b);});"
|
||||||
|
"var f=b.dataset.f;document.querySelectorAll('section').forEach(function(s){"
|
||||||
|
"s.hidden=!(f==='all'||s.dataset.s===f);});};});</script>")
|
||||||
|
(out / "index.html").write_text(page, encoding="utf-8")
|
||||||
|
print(out / "index.html")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -68,6 +68,11 @@ def main() -> int:
|
||||||
parts.append(f"<h2>{html.escape(s['label'])}</h2><p>{len(res)} Formeln · A richtig {s['a_correct'] / n:.0%} · "
|
parts.append(f"<h2>{html.escape(s['label'])}</h2><p>{len(res)} Formeln · A richtig {s['a_correct'] / n:.0%} · "
|
||||||
f"B richtig {s['b_correct'] / n:.0%} · einig {s['agree'] / n:.0%} · "
|
f"B richtig {s['b_correct'] / n:.0%} · einig {s['agree'] / n:.0%} · "
|
||||||
f"<b>einig, aber falsch: {s['agree_wrong']}</b></p>")
|
f"<b>einig, aber falsch: {s['agree_wrong']}</b></p>")
|
||||||
|
st = s.get("structural_examples", [])
|
||||||
|
if st: # matches found only by the structural comparison: the truth comparison uses it too
|
||||||
|
parts.append(f"<p><b>Nur strukturell gleich ({len(st)}, bitte prüfen):</b></p><ul>" + "".join(
|
||||||
|
f"<li>{html.escape(ex['kind'])}: <code>{html.escape(ex['reading'] or '–')}</code> ≡ "
|
||||||
|
f"<code>{html.escape(ex['other'] or '–')}</code></li>" for ex in st) + "</ul>")
|
||||||
order = sorted(res, key=lambda r: (0 if r["agree"] and not r["a_ok"] else 1 if not r["agree"] else 2))
|
order = sorted(res, key=lambda r: (0 if r["agree"] and not r["a_ok"] else 1 if not r["agree"] else 2))
|
||||||
texes = []
|
texes = []
|
||||||
for r in order:
|
for r in order:
|
||||||
|
|
|
||||||
|
|
@ -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.imageio import load_ink # noqa: E402
|
||||||
from formula_img2latex.render import render_many # noqa: E402
|
from formula_img2latex.render import render_many # noqa: E402
|
||||||
from formula_img2latex.tex import strip_delimiters # 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")]
|
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("--n", type=int, default=50)
|
||||||
ap.add_argument("--name", default="regeltest")
|
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("--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=[],
|
ap.add_argument("--exclude", type=Path, action="append", default=[],
|
||||||
help="Formeln aus diesem früheren ergebnis.json auslassen (mehrfach möglich)")
|
help="Formeln aus diesem früheren ergebnis.json auslassen (mehrfach möglich)")
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
@ -78,9 +82,10 @@ def main() -> int:
|
||||||
smp = random.Random(42).sample(items, min(args.n + 1, len(items)))
|
smp = random.Random(42).sample(items, min(args.n + 1, len(items)))
|
||||||
formulas, warm = smp[:args.n], smp[args.n]
|
formulas, warm = smp[:args.n], smp[args.n]
|
||||||
preset = _best_preset(smp[:12])
|
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 = {}
|
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)
|
e = make_engine(key)
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
e.check()
|
e.check()
|
||||||
|
|
@ -94,25 +99,37 @@ def main() -> int:
|
||||||
if cached and old is None:
|
if cached and old is None:
|
||||||
raise SystemExit(f"{name} fehlt in {args.reuse} (andere Stichprobe?)")
|
raise SystemExit(f"{name} fehlt in {args.reuse} (andere Stichprobe?)")
|
||||||
reads: list[dict | None] = [None, None, None]
|
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
|
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]
|
texes = [r and r.get("tex") for r in reads]
|
||||||
agree = agreements(texes, disp, preset)
|
agree = agreements(texes, disp, preset)
|
||||||
if not any((i, j) == (0, 1) for i, j, _ in agree): # disputed: ask Gemma
|
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]
|
texes = [r and r.get("tex") for r in reads]
|
||||||
agree = agreements(texes, disp, preset)
|
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
|
veto = None
|
||||||
pandoc = True
|
pandoc = True
|
||||||
if use is not None and texes[use]:
|
if use is not None and texes[use]:
|
||||||
v = verify(load_ink(data, name), texes[use], disp, preset, check_variants=False)
|
v = verify(load_ink(data, name), texes[use], disp, preset, check_variants=False)
|
||||||
veto = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics}
|
veto = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics}
|
||||||
pandoc = pandoc_mathml_ok([(texes[use], disp)])[0]
|
pandoc = pandoc_mathml_ok([(texes[use], disp)])[0]
|
||||||
status, reason, use = decide_pair_tiebreak(reads, agree, veto, pandoc, None, th)
|
status, reason, use = decide_pair_tiebreak(reads, agree, veto, pandoc, None, th, arbiter)
|
||||||
ok = [bool(t) and bool(same_reading(t, truth, disp, preset)) for t in texes]
|
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"
|
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,
|
"status": status, "reason": reason, "use": use, "veto": veto,
|
||||||
"right": bool(accepted and ok[use])})
|
"right": bool(accepted and ok[use])})
|
||||||
mark = ("✓" if ok[use] else "✗ FALSCH") if accepted else "Review"
|
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_per_formula": statistics.mean(per_formula),
|
||||||
"mean_s": {k: statistics.mean(v) if v else None for k, v in secs.items()},
|
"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])
|
"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 = Path(__file__).resolve().parents[1] / "vergleich" / args.name
|
||||||
out.mkdir(parents=True, exist_ok=True)
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -160,6 +180,8 @@ def main() -> int:
|
||||||
f"<tr><th>davon FALSCH</th><td><b>{len(wrong)}</b></td></tr>"
|
f"<tr><th>davon FALSCH</th><td><b>{len(wrong)}</b></td></tr>"
|
||||||
f"<tr><th>Review (Mensch)</th><td>{len(review)} ({len(review) / n:.0%})</td></tr>"
|
f"<tr><th>Review (Mensch)</th><td>{len(review)} ({len(review) / n:.0%})</td></tr>"
|
||||||
f"<tr><th>Gemma gefragt</th><td>{gemma} ({gemma / n:.0%})</td></tr>"
|
f"<tr><th>Gemma gefragt</th><td>{gemma} ({gemma / n:.0%})</td></tr>"
|
||||||
|
f"<tr><th>nur strukturell gleich (Paare / gegen Wahrheit)</th><td>{summary['structural']['agree']} / "
|
||||||
|
f"{summary['structural']['truth']}</td></tr>"
|
||||||
f"<tr><th>Ø Zeit pro Formel (alle Tools nacheinander)</th><td>{summary['mean_s_per_formula']:.2f} s</td></tr>"
|
f"<tr><th>Ø Zeit pro Formel (alle Tools nacheinander)</th><td>{summary['mean_s_per_formula']:.2f} s</td></tr>"
|
||||||
+ "".join(f"<tr><th>Ø {lab[k]}</th><td>{v:.2f} s</td></tr>" for k, v in summary["mean_s"].items() if v)
|
+ "".join(f"<tr><th>Ø {lab[k]}</th><td>{v:.2f} s</td></tr>" for k, v in summary["mean_s"].items() if v)
|
||||||
+ "".join(f"<tr><th>{lab[k]} allein richtig</th><td>{v}/{len(secs[k])}</td></tr>"
|
+ "".join(f"<tr><th>{lab[k]} allein richtig</th><td>{v}/{len(secs[k])}</td></tr>"
|
||||||
|
|
|
||||||
205
tools/vergleich_bericht.py
Normal file
205
tools/vergleich_bericht.py
Normal file
|
|
@ -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/<name>/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"<img src='{png_uri(Image.fromarray(255 - g))}'>"
|
||||||
|
else:
|
||||||
|
tag = "<i>nicht setzbar</i>"
|
||||||
|
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"<img src='{png_uri(orig_img(data, 2))}'>"
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return "<i>–</i>"
|
||||||
|
|
||||||
|
|
||||||
|
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"<tr><th>Original</th><td>{orig}</td><td></td></tr>",
|
||||||
|
f"<tr><th>richtig</th><td>{page.img(truth, display, preset)}</td><td><code>{html.escape(truth)}</code></td></tr>"]
|
||||||
|
for label, tex in rows:
|
||||||
|
trs.append(f"<tr><th>{label}</th><td>{page.img(tex, display, preset) if tex else '<i>–</i>'}</td>"
|
||||||
|
f"<td><code>{html.escape(tex or '–')}</code></td></tr>")
|
||||||
|
page.parts.append(f"<section class={cls}><div class=v>{head}</div><table>{''.join(trs)}</table></section>")
|
||||||
|
|
||||||
|
|
||||||
|
def mark(ok: bool) -> str:
|
||||||
|
return "<b class=ok>✓</b>" if ok else "<b class=no>✗</b>"
|
||||||
|
|
||||||
|
|
||||||
|
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"<tr><td><a href='../{b}/index.html'>{html.escape(b)}</a> "
|
||||||
|
f"<small>(<a href='../{a}/index.html'>vorher</a>)</small></td><td>{sa['formulas']}</td>"
|
||||||
|
f"<td>{sa['accepted']} / {sa['wrong']}</td><td><b>{sb['accepted']} / {sb['wrong']}</b></td>"
|
||||||
|
f"<td>{sa['review']} → {sb['review']}</td><td>{st.get('agree', 0)} / {st.get('truth', 0)}</td></tr>")
|
||||||
|
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"<tr><td>Kalibrierung Band 1 {html.escape(sb['label'].split('[')[-1].rstrip(']'))}</td><td>{n}</td>"
|
||||||
|
f"<td>{acc['ohne'][0]} / {acc['ohne'][1]}</td><td><b>{acc['mit'][0]} / {acc['mit'][1]}</b></td>"
|
||||||
|
f"<td>–</td><td>{len(sb.get('structural_examples', []))}</td></tr>")
|
||||||
|
|
||||||
|
|
||||||
|
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"<h1>{html.escape(args.titel)}</h1>"
|
||||||
|
"<p>Dieselben gespeicherten Lesarten, zweimal entschieden: vorher und nachher. "
|
||||||
|
"Regel <code>p2t</code>, mit Pandoc und Bild-Veto. ✓/✗ = gleiche Formel wie die Wahrheit.</p>"
|
||||||
|
"<table class=sum><tr><th>Messung</th><th>Formeln</th><th>vorher: automatisch / falsch</th>"
|
||||||
|
"<th>nachher: automatisch / falsch</th><th>Review</th><th>nur strukturell gleich (Paare / Wahrheit)</th></tr>"
|
||||||
|
+ "".join(summary) + "</table>"
|
||||||
|
"<p>Unten: jede geänderte Entscheidung, jedes geänderte Urteil gegen die Wahrheit und jede "
|
||||||
|
"Übereinstimmung, die <b>nur</b> die Strukturstufe gefunden hat. Grün = übernommen und richtig, <b style='color:#a32020'>Rot = übernommen, aber falsch</b>, "
|
||||||
|
"Gelb = Review. Bitte prüfen: Sind die als gleich gewerteten Lesarten wirklich dieselbe Formel?</p>")
|
||||||
|
page.parts.insert(0, head)
|
||||||
|
out = ROOT / args.name
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
(out / "index.html").write_text(
|
||||||
|
"<!doctype html><meta charset=utf-8><title>Vergleich</title><style>"
|
||||||
|
"body{font:14px system-ui,sans-serif;margin:16px;background:#f6f6f3}section{background:#fff;border:1px solid #ddd;"
|
||||||
|
"border-left:6px solid #999;border-radius:6px;padding:6px 10px;margin:10px 0}section.good{border-left-color:#1f7a3a}"
|
||||||
|
"section.bad{border-left-color:#a32020;background:#fff5f5}section.rev{border-left-color:#c88a00}"
|
||||||
|
".v{font-weight:600;font-size:13px}table.sum{border-collapse:collapse;background:#fff}"
|
||||||
|
"table.sum td,table.sum th{border:1px solid #ccc;padding:4px 10px;width:auto}"
|
||||||
|
"th{text-align:left;font-weight:400;color:#555;width:190px;vertical-align:middle}td{padding:3px 8px;vertical-align:middle}"
|
||||||
|
".ok{color:#1f7a3a}.no{color:#a32020}code{font-size:12px}img{max-width:640px}</style>" + page.html(),
|
||||||
|
encoding="utf-8")
|
||||||
|
print(out / "index.html")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in a new issue