feat: 0.3.0 – pix2text zuerst, Qwen bestätigt, Gemma nur bei Uneinigkeit

Neue Standardregel p2t (GLM/Cloud wird nicht mehr benutzt):
- pix2text liest zuerst; übernommen wird immer seine Lesart, wenn Qwen sie
  bestätigt oder – nur bei Uneinigkeit gefragt – Gemma. Qwen = Gemma gegen
  pix2text geht an einen Menschen. Schlusspunkt muss bestätigt sein; Bild-Veto
  und Pandoc auf pix2texts Lesart. Alte Regel als consensus_rule=vlm3 wählbar.
- pix2text auf der GPU (onnxruntime-gpu, FORMULA_P2T_DEVICE=auto/cpu/cuda:N)
- Ausgabe bereinigt: post_process aus pix2tex, Fettschreibweisen \boldmath/\bf
  -> \boldsymbol/\mathbf; an 400 Lesarten gleiches gesetztes Bild
- Normalform: \mathop, \begin{array}[t]
- calibrate: --engines, Regel-Tabelle, --reuse; wahre Formeln aus dem alt-Text
  bereinigt (aligned, \mathop{}\!, upgreek)
- tools/zeittest.py, tools/regeltest.py (--reuse, --exclude)

Messung: Göllmann Band 1 (2 x 150) 86 % / 85 % automatisch, 0 falsch
(bisher 78 % / 70 %); Band 2, zwei Stichproben zu 50: 78 % und 86 %, 0 falsch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-09 03:45:18 +02:00
commit 4638a12571
14 changed files with 941 additions and 98 deletions

View file

@ -3,8 +3,10 @@
Dieses Programm ersetzt Formelbilder in den Markdown-Dateien von epub2md und pdf2md durch echte Dieses Programm ersetzt Formelbilder in den Markdown-Dateien von epub2md und pdf2md durch echte
LaTeX-Formeln. Dadurch passen die Formeln beim Neubau als EPUB oder PDF in Größe und Schrift zum Text. LaTeX-Formeln. Dadurch passen die Formeln beim Neubau als EPUB oder PDF in Größe und Schrift zum Text.
Es ersetzt eine Formel nur, wenn **drei unabhängige Bildmodelle dieselbe Formel lesen** (zwei lokale, eines Es liest jede Formel zuerst mit dem Formel-OCR **pix2text** und ersetzt sie nur, wenn das Bildmodell
in der Cloud) und das gerenderte LaTeX dem Bild grob ähnelt. Bei Zweifeln fragt es dich in einer **Qwen** dieselbe Formel liest (bei Uneinigkeit darf das zweite Bildmodell Gemma pix2text bestätigen) und
das gerenderte LaTeX dem Bild grob ähnelt. Eingesetzt wird die Lesart von pix2text, ohne ihre
überflüssigen Leerzeichen. Alles läuft lokal. Bei Zweifeln fragt es dich in einer
Review-Seite im Browser. Ohne deine Zustimmung bleibt die Formel ein Bild. Review-Seite im Browser. Ohne deine Zustimmung bleibt die Formel ein Bild.
„Dieselbe Formel“ heißt: jedes Zeichen stimmt und steht an der richtigen Stelle. Abstände, Pfeillängen, „Dieselbe Formel“ heißt: jedes Zeichen stimmt und steht an der richtigen Stelle. Abstände, Pfeillängen,
@ -41,12 +43,28 @@ CUDA_VISIBLE_DEVICES=2 ~/.local/bin/llama-server \
GPU 2 ist währenddessen für Chatterbox-TTS belegt. Beenden mit Strg+C in beiden Terminals. GPU 2 ist währenddessen für Chatterbox-TTS belegt. Beenden mit Strg+C in beiden Terminals.
### Dritte Lesart aus der Cloud ### pix2text (Formel-OCR)
Die Bestätigung kommt von **GLM-4.6V** über OpenRouter (andere Modellfamilie als Qwen und Gemma). Dafür pix2text läuft ohne Server im Programm selbst, auf der ersten sichtbaren Grafikkarte (sonst auf der CPU,
muss `OPENROUTER_API_KEY` gesetzt sein. GLM liest nur die Formeln, bei denen Qwen und Gemma schon einig dann ~5× langsamer). Einmalig:
sind. Beim Gehrke-Brückenkurs (1505 Formelbilder) waren das 967 Anfragen für zusammen etwa 0,15–0,20 $.
Ohne Cloud (`--no-cloud`) wird nichts automatisch ersetzt, alles Einige landet im Review. ```bash
pip install "pix2text[multilingual]"
pip install --no-deps pix2tex munch # nur für post_process (Leerzeichen)
pip uninstall -y onnxruntime && pip install onnxruntime-gpu # für die GPU
```
Das Modell (~300 MB) wird beim ersten Lauf nach `~/.pix2text` geladen. Gerät wählen mit
`FORMULA_P2T_DEVICE=cpu` oder `cuda:N` (`N` zählt innerhalb von `CUDA_VISIBLE_DEVICES`).
Gemma (Port 8012) wird nur für Formeln gefragt, bei denen pix2text und Qwen uneinig sind. Läuft Gemma
nicht, bleiben diese Formeln im Review.
### Frühere Regel mit Cloud (optional)
Bis Version 0.2.0 kam die Bestätigung von **GLM-4.6V** über OpenRouter (Qwen = Gemma = GLM). Diese Regel
ist weiter wählbar: im Arbeitsordner in `workspace.json` unter `settings` den Eintrag
`"consensus_rule": "vlm3"` setzen; dann muss `OPENROUTER_API_KEY` gesetzt sein.
--- ---
@ -168,9 +186,10 @@ geändert, wird sie übersprungen. `--force` setzt sie trotzdem zurück, dann is
```bash ```bash
python3 formula_img2latex.py scan AUSGABE # Bilder finden python3 formula_img2latex.py scan AUSGABE # Bilder finden
python3 formula_img2latex.py recognize AUSGABE --engine local # lokal erkennen python3 formula_img2latex.py recognize AUSGABE --engine p2t # pix2text (zuerst)
python3 formula_img2latex.py recognize AUSGABE --engine local # Qwen (bestätigt)
python3 formula_img2latex.py verify AUSGABE # prüfen und entscheiden python3 formula_img2latex.py verify AUSGABE # prüfen und entscheiden
python3 formula_img2latex.py recognize AUSGABE --engine cloud --only-unresolved python3 formula_img2latex.py recognize AUSGABE --engine local2 --only-unresolved # Gemma (Stichwahl)
python3 formula_img2latex.py verify AUSGABE python3 formula_img2latex.py verify AUSGABE
python3 formula_img2latex.py status AUSGABE python3 formula_img2latex.py status AUSGABE
``` ```
@ -214,6 +233,6 @@ Entscheidung mit Grund und den Bildabgleich. Der Ordner `vergleich/` wird nicht
Ja. Das Bild hatte die Farbe eingebaut, LaTeX-Formeln haben keinen Hintergrund. Ja. Das Bild hatte die Farbe eingebaut, LaTeX-Formeln haben keinen Hintergrund.
**Werden Buchinhalte ins Internet geschickt?** **Werden Buchinhalte ins Internet geschickt?**
Nur für die dritte Lesart: die einzelnen Formelbilder, bei denen Qwen und Gemma einig sind, samt eines Nein, mit der Standardregel bleibt alles lokal. Nur die frühere Regel `vlm3` schickt die Formelbilder, bei
kurzen Textausschnitts um die Formel (höchstens 240 Zeichen), gehen an OpenRouter/GLM. Mit `--no-cloud` bleibt alles lokal; dann wird nichts automatisch denen Qwen und Gemma einig sind, samt eines kurzen Textausschnitts um die Formel (höchstens 240 Zeichen)
ersetzt. an OpenRouter/GLM.

View file

@ -1,5 +1,31 @@
# Changelog # Changelog
## 0.3.0 – 2026-10-09
Neue Standardregel `p2t`: **pix2text liest zuerst, Qwen bestätigt, Gemma nur bei Uneinigkeit**; GLM (Cloud)
wird nicht mehr benutzt.
- **pix2text** (Formel-OCR MFR 1.5, ONNX) als Engine `p2t`, im Prozess, auf der GPU mit `onnxruntime-gpu`
(`FORMULA_P2T_DEVICE`: `auto`, `cpu`, `cuda:N`). Allein am häufigsten richtig (Göllmann Band 1: 90 % /
85 %) und am schnellsten (0,1 s/Formel auf der GPU, Qwen 0,7 s, Gemma 1,8 s).
- **Regel:** übernommen wird immer pix2texts Lesart (trifft den Fettdruck am besten), wenn Qwen sie
bestätigt oder – nur bei Uneinigkeit gefragt – Gemma. Qwen und Gemma gemeinsam gegen pix2text gehen
an einen Menschen (so kamen an Band 1 die gemeinsamen Fehler der beiden Bildmodelle durch). Der
Schlusspunkt muss bestätigt sein. Bild-Veto und Pandoc wie bisher, geprüft auf pix2texts Lesart.
Die alte Regel bleibt als `"consensus_rule": "vlm3"` in `workspace.json` wählbar.
- **pix2text-Ausgabe bereinigt:** Leerzeichen mit `post_process` aus pix2tex (installiert mit
`--no-deps`), Fettschreibweisen `\mathrm{\boldmath~ x ~}` / `\mathrm{\bf~ r o t}` →
`\boldsymbol{x}` / `\mathbf{rot}`. An 400 Lesarten geprüft: gleiches gesetztes Bild.
- **Normalform:** `\operatorname{l i m}` = `\lim`, `\ddot{u}` in Text = ü, `^{\prime}` = `'`,
abschließendes `\\` vor `\end`, `\begin{array}[t]`, `\mathop{…}`.
- **calibrate:** beliebig viele Engines (`--engines`), Regel-Tabelle, `--reuse` rechnet aus einem
früheren Bericht ohne Modellabfragen; wahre Formeln aus dem alt-Text werden bereinigt
(einzeiliges `aligned`, `\mathop{}\!\mathrm{d}`, upgreek).
- **Werkzeuge:** `tools/zeittest.py` (Zeit und Trefferquote je Tool), `tools/regeltest.py` (Standardregel
an Formeln mit bekanntem TeX, Seite zum Mitbeurteilen; `--reuse`, `--exclude`).
- **Messung:** Göllmann Band 1 (2 × 150) 86 % / 85 % automatisch, 0 falsch (bisher 78 % / 70 %);
Band 2, zwei frische Stichproben zu 50: 78 % und 86 % automatisch, 0 falsch.
## 0.2.0 – 2026-10-07 ## 0.2.0 – 2026-10-07
Entscheider ist jetzt der **Konsens dreier Bildmodelle** statt eines Pixelvergleichs. Entscheider ist jetzt der **Konsens dreier Bildmodelle** statt eines Pixelvergleichs.

View file

@ -3,7 +3,8 @@
Nachbearbeitungs-Werkzeug für die Markdown-Ausgaben von Nachbearbeitungs-Werkzeug für die Markdown-Ausgaben von
[epub2md](https://kitux.de/forgejo/dschlueter/epub2md) und [epub2md](https://kitux.de/forgejo/dschlueter/epub2md) und
[pdf2md](https://kitux.de/forgejo/dschlueter/pdf2md): Formeln, die nur als Bild vorliegen, werden durch [pdf2md](https://kitux.de/forgejo/dschlueter/pdf2md): Formeln, die nur als Bild vorliegen, werden durch
LaTeX ersetzt – **aber nur, wenn drei unabhängige Bildmodelle dieselbe Formel lesen** und ein grober LaTeX ersetzt – **aber nur, wenn zwei unabhängige Erkenner dieselbe Formel lesen** (zuerst das Formel-OCR
pix2text, bestätigt vom Bildmodell Qwen; bei Uneinigkeit darf Gemma pix2text bestätigen) und ein grober
Bildabgleich nicht widerspricht. Alles andere geht in eine Review-Galerie oder bleibt Bild. Bildabgleich nicht widerspricht. Alles andere geht in eine Review-Galerie oder bleibt Bild.
Schritt-für-Schritt-Anleitung: [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). Änderungen je Version: Schritt-für-Schritt-Anleitung: [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). Änderungen je Version:
@ -14,13 +15,22 @@ Schritt-für-Schritt-Anleitung: [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md)
> Jedes Zeichen muss stimmen und an der richtigen Stelle relativ zu den anderen stehen. Abstände, > Jedes Zeichen muss stimmen und an der richtigen Stelle relativ zu den anderen stehen. Abstände,
> Pfeil- und Bruchstrichlängen, Schriftgröße, kursiv/aufrecht und Fettdruck dürfen abweichen. > Pfeil- und Bruchstrichlängen, Schriftgröße, kursiv/aufrecht und Fettdruck dürfen abweichen.
Eine Formel wird automatisch ersetzt, wenn Eine Formel wird automatisch ersetzt (Regel `p2t`, Standard), wenn
1. **Qwen3-VL-30B-A3B** (lokal, llama.cpp) und **Gemma-4-26B-A4B** (lokal) dieselbe Formel lesen, 1. **pix2text** (Formel-OCR MFR 1.5, lokal, GPU) sie zuerst liest und **Qwen3-VL-30B-A3B** (Bildmodell,
2. **GLM-4.6V** (Cloud über OpenRouter, andere Modellfamilie) das bestätigt – ein fehlender Schlusspunkt lokal, llama.cpp) dieselbe Formel liest **oder**, wenn die beiden uneinig sind, **Gemma-4-26B-A4B**
zählt dabei nicht, (lokal) die Lesart von pix2text bestätigt. Übernommen wird immer **pix2texts Lesart** – sie trifft den
3. Pandoc sie nach MathML umsetzen kann (sonst bliebe sie im EPUB/PDF roher TeX-Text) und Fettdruck am besten. Ihre überflüssigen Leerzeichen entfernt vorher `post_process` aus pix2tex (LaTeX-OCR):
4. das gerenderte LaTeX dem Original grob ähnelt (Bild-Veto, elastischer Vergleich, Ähnlichkeit ≥ 0,65). `\frac{\alpha} {3 6 0}` → `\frac{\alpha}{360}`; am gesetzten Bild ändert das nichts, geprüft an
400 Lesarten. Auch der Schlusspunkt muss bestätigt sein,
2. Pandoc sie nach MathML umsetzen kann (sonst bliebe sie im EPUB/PDF roher TeX-Text) und
3. das gerenderte LaTeX dem Original grob ähnelt (Bild-Veto, elastischer Vergleich, Ähnlichkeit ≥ 0,65).
Sind Qwen und Gemma gemeinsam gegen pix2text einig, entscheidet ein Mensch: Genau das sind die Fehler,
die beide Bildmodelle teilen (`\mathcal{E}_r` statt `\varepsilon_r`, erfundenes `\tilde{z}`).
Die frühere Regel `vlm3` (Qwen = Gemma, bestätigt von **GLM-4.6V** über OpenRouter) bleibt wählbar:
`"consensus_rule": "vlm3"` unter `settings` in `workspace.json` des Arbeitsordners.
„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}`,
@ -32,6 +42,25 @@ Sind sich die Modelle uneinig oder widerspricht das Veto, entscheidet ein Mensch
## Messung ## Messung
### Regel `p2t` (Standard)
Göllmann Band 1, 150 echte Bilder und 150 Nachbildungen im Gehrke-Stil, Lesarten aller Modelle:
| Regel | echt: automatisch / falsch | Gehrke-Stil: automatisch / falsch |
| --- | --- | --- |
| pix2text = Qwen | 81 % / 0 | 76 % / 0 |
| + Stichwahl: Gemma bestätigt pix2text, Qwens Lesart (erste Fassung) | 85 % / 0 | 80 % / 0 |
| **pix2text zuerst und übernommen, bereinigt, Stichwahl Gemma** (Standard) | **86 % / 0** | **85 % / 0** |
| + Stichwahl auch Qwen = Gemma gegen pix2text | 92 % / 1 | 91 % / 2 |
| Qwen = Gemma = GLM (Regel `vlm3`) | 78 % / 0 | 70 % / 0 |
Allein richtig: pix2text 90 % / 85 %, Qwen 86 % / 84 %, Gemma 86 % / 80 %, GLM 86 % / 83 %.
Zeit je Formel (seriell, 15 Formeln): pix2text 0,10 s (GPU) bzw. 0,51 s (CPU), Qwen 0,72 s, GLM 1,19 s,
Gemma 1,77 s. Gegenprobe an 50 frischen Formeln aus Göllmann Band 2 (`tools/regeltest.py`):
37 automatisch (74 %), **0 falsch**, 13 Review; Gemma wurde für 16 Formeln gefragt.
### 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
Nachbildungen im Stil von Gehrke (LaTeX + dvipng mit Bitmap-Schriften, ~102 dpi, JPEG): Nachbildungen im Stil von Gehrke (LaTeX + dvipng mit Bitmap-Schriften, ~102 dpi, JPEG):
@ -63,12 +92,12 @@ Gehrke, Brückenkurs Mathematik (EPUB, 1505 Formelbilder): siehe [Stand](#stand)
```text ```text
scan Bildverweise in chapters/*.md (bzw. pdf2md-Markdown) finden, Formel/Abbildung vorklassifizieren scan Bildverweise in chapters/*.md (bzw. pdf2md-Markdown) finden, Formel/Abbildung vorklassifizieren
run in Paketen zu 50: Qwen + Gemma lesen (parallel), prüfen, GLM nur für Einige, prüfen; run in Paketen zu 50: pix2text, dann Qwen lesen, prüfen, Gemma nur für Uneinige, prüfen;
nach jedem Paket eine Statuszeile nach jedem Paket eine Statuszeile (Regel vlm3: Qwen + Gemma, GLM nur für Einige)
review Zweifelsfälle im Browser: Original / Render / Overlay / Diff, LaTeX korrigieren und neu prüfen review Zweifelsfälle im Browser: Original / Render / Overlay / Diff, LaTeX korrigieren und neu prüfen
apply angenommene Formeln ins Markdown schreiben (aus einer Sicherung, idempotent) apply angenommene Formeln ins Markdown schreiben (aus einer Sicherung, idempotent)
revert Markdown-Dateien auf den Stand vor apply zurücksetzen revert Markdown-Dateien auf den Stand vor apply zurücksetzen
recognize einzelne Engine (local, local2, cloud) laufen lassen recognize einzelne Engine (local, local2, cloud, p2t) laufen lassen
verify prüfen und entscheiden (ohne neue Modellabfragen) verify prüfen und entscheiden (ohne neue Modellabfragen)
status Zählung je Status status Zählung je Status
calibrate Messung an Formelbildern mit bekanntem TeX (--consensus: Konsensregel) calibrate Messung an Formelbildern mit bekanntem TeX (--consensus: Konsensregel)
@ -90,14 +119,19 @@ Status je Formel: `auto_accepted`, `needs_review`, `rejected_keep_image`, `rende
| --- | --- | --- | | --- | --- | --- |
| `local` | llama.cpp `http://127.0.0.1:8011/v1` (Qwen3-VL-30B-A3B-Instruct Q4_K_M + mmproj) | `FORMULA_LOCAL_URL`, `FORMULA_LOCAL_MODEL` | | `local` | llama.cpp `http://127.0.0.1:8011/v1` (Qwen3-VL-30B-A3B-Instruct Q4_K_M + mmproj) | `FORMULA_LOCAL_URL`, `FORMULA_LOCAL_MODEL` |
| `local2` | llama.cpp `http://127.0.0.1:8012/v1` (Gemma-4-26B-A4B-it Q4_0 + mmproj) | `FORMULA_LOCAL2_URL`, `FORMULA_LOCAL2_MODEL` | | `local2` | llama.cpp `http://127.0.0.1:8012/v1` (Gemma-4-26B-A4B-it Q4_0 + mmproj) | `FORMULA_LOCAL2_URL`, `FORMULA_LOCAL2_MODEL` |
| `cloud` | OpenRouter, `z-ai/glm-4.6v` | `OPENROUTER_API_KEY`, `FORMULA_CLOUD_MODEL` | | `cloud` | OpenRouter, `z-ai/glm-4.6v` (nur Regel `vlm3`) | `OPENROUTER_API_KEY`, `FORMULA_CLOUD_MODEL` |
| `p2t` | pix2text MFR 1.5 (ONNX) im Prozess, erste sichtbare GPU, sonst CPU | `FORMULA_P2T_DEVICE` (`auto`, `cpu`, `cuda:N`), `FORMULA_P2T_MODEL` |
pix2text auf der GPU braucht `onnxruntime-gpu` statt `onnxruntime` (cuDNN kommt aus den nvidia-Paketen
von torch). Für `post_process` wird `pix2tex` ohne seine Abhängigkeiten installiert
(`pip install --no-deps pix2tex munch`), weil es sonst `timm==0.5.4` erzwingen würde. `cuda:N` zählt innerhalb von `CUDA_VISIBLE_DEVICES`.
`FORMULA_<ENGINE>_THINKING=1` schaltet das Nachdenken wieder ein. Antworten sind auf 800 Tokens begrenzt `FORMULA_<ENGINE>_THINKING=1` schaltet das Nachdenken wieder ein. Antworten sind auf 800 Tokens begrenzt
(lange mehrzeilige Formeln brauchen ~400; einzelne Antworten von Gemma liefen sonst in (lange mehrzeilige Formeln brauchen ~400; einzelne Antworten von Gemma liefen sonst in
Wiederholungsschleifen bis 165 s). Jede Lesart wird mit Modell, Prompt-Version und Nachdenk-Einstellung Wiederholungsschleifen bis 165 s). Jede Lesart wird mit Modell, Prompt-Version und Nachdenk-Einstellung
gespeichert; dieselbe Frage wird nie zweimal gestellt, ein abgebrochener Lauf setzt dort fort. gespeichert; dieselbe Frage wird nie zweimal gestellt, ein abgebrochener Lauf setzt dort fort.
Kosten der Cloud-Stufe: GLM liest nur Formeln, bei denen Qwen und Gemma einig sind. Gehrke: 967 Kosten der Cloud-Stufe (nur Regel `vlm3`): GLM liest nur Formeln, bei denen Qwen und Gemma einig sind. Gehrke: 967
Anfragen, Ø 2,6 s, zusammen rund 0,15–0,20 $. Anfragen, Ø 2,6 s, zusammen rund 0,15–0,20 $.
## Bildvergleich (Veto) ## Bildvergleich (Veto)
@ -105,9 +139,9 @@ Anfragen, Ø 2,6 s, zusammen rund 0,15–0,20 $.
`compare.py` rendert die Lesart mit pdflatex (Schrift je Buch automatisch: `cm`, `times`, `txfonts`, `compare.py` rendert die Lesart mit pdflatex (Schrift je Buch automatisch: `cm`, `times`, `txfonts`,
`palatino`, `stix`, `euler`), richtet sie auf das Original aus und verzerrt sie **elastisch** `palatino`, `stix`, `euler`), richtet sie auf das Original aus und verzerrt sie **elastisch**
(dynamische Zeitverzerrung über Spalten und Zeilen; Dehnen kostet proportional zur Tinte), damit (dynamische Zeitverzerrung über Spalten und Zeilen; Dehnen kostet proportional zur Tinte), damit
Abstände und Längen keine Rolle spielen. Die Ähnlichkeit danach ist das Veto. Geprüft wird nur die Abstände und Längen keine Rolle spielen. Die Ähnlichkeit danach ist das Veto. Geprüft wird nur
Lesart, die übernommen würde (die von Qwen; im Konsens ist sie gleichwertig zu den anderen) – eine die Lesart, die übernommen werden kann: die von pix2text (Regel `vlm3`: die von Qwen). Bestätigende
später eintreffende dritte Lesart löst keine neue Prüfung aus. Die Prüfergebnisse tragen eine Lesarten werden nie übernommen und brauchen keine Prüfung. Die Prüfergebnisse tragen eine
Versionsnummer des Verfahrens und werden nach Änderungen neu berechnet. Der alte Pixel-Entscheider mit Versionsnummer des Verfahrens und werden nach Änderungen neu berechnet. Der alte Pixel-Entscheider mit
Verwechslungsvarianten und Kontrasttest bleibt als Modus `decide=pixel` erhalten. Verwechslungsvarianten und Kontrasttest bleibt als Modus `decide=pixel` erhalten.
@ -145,6 +179,11 @@ sind nicht Teil dieser Version.
- `tools/vergleich.py AUSGABE` – feste Stichprobe (50 Formeln), verarbeitet wie `run`, schreibt - `tools/vergleich.py AUSGABE` – feste Stichprobe (50 Formeln), verarbeitet wie `run`, schreibt
`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/regeltest.py EPUB [--n 50] [--reuse ergebnis.json]` – die Standardregel an Formeln mit bekanntem
TeX, wie `run`; Seite mit Original, wahrer Formel, allen Lesarten und Entscheidung.
- `calibrate --consensus --engines local,local2,cloud,p2t --report X.json` misst alle Regeln;
`--reuse X.json` rechnet sie ohne Modellabfragen neu.
- `vergleich/` ist nicht versioniert (Ausschnitte aus Büchern). - `vergleich/` ist nicht versioniert (Ausschnitte aus Büchern).
## Stand ## Stand
@ -209,7 +248,8 @@ formula_img2latex/
├── pipeline.py scan, recognize, verify, Entscheidung (Konsens/Pixel), run in Paketen, apply/revert ├── pipeline.py scan, recognize, verify, Entscheidung (Konsens/Pixel), run in Paketen, apply/revert
├── consensus.py Normalform, Render-Gleichheit: sagen zwei Lesarten dasselbe? ├── consensus.py Normalform, Render-Gleichheit: sagen zwei Lesarten dasselbe?
├── decide.py Annahmeregeln und Schwellen (Konsens- und Pixel-Modus) ├── decide.py Annahmeregeln und Schwellen (Konsens- und Pixel-Modus)
├── engines/ Vision-Modelle über die OpenAI-API (llama.cpp, OpenRouter), Logprobs, Prompt-Version ├── engines/ Vision-Modelle über die OpenAI-API (llama.cpp, OpenRouter), Logprobs, Prompt-Version;
│ p2t.py: pix2text-Formel-OCR (ONNX, CPU/GPU)
├── compare.py Ausrichtung, elastische Verzerrung, Kennzahlen, Kontrasttest, Diff-Bilder ├── compare.py Ausrichtung, elastische Verzerrung, Kennzahlen, Kontrasttest, Diff-Bilder
├── confusables.py Verwechslungsvarianten (Pixel-Modus) ├── confusables.py Verwechslungsvarianten (Pixel-Modus)
├── verify.py Bildprüfung einer Lesart (Veto bzw. Pixel-Entscheider) ├── verify.py Bildprüfung einer Lesart (Veto bzw. Pixel-Entscheider)

View file

@ -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.2.0" __version__ = "0.3.0"

View file

@ -43,6 +43,22 @@ from .verify import verify
SYNTH_PX_PER_PT = 1.41 SYNTH_PX_PER_PT = 1.41
# upright Greek of the upgreek package (Springer alt texts): pdflatex here has no upgreek, and
# upright vs. italic is the same symbol for the project
_UPGREEK = re.compile(r"\\[Uu]p(alpha|beta|gamma|delta|epsilon|varepsilon|zeta|eta|theta|vartheta|iota|kappa|lambda|mu|nu|"
r"xi|pi|varpi|rho|varrho|sigma|varsigma|tau|upsilon|phi|varphi|chi|psi|omega)(?![A-Za-z])", re.I)
def clean_truth(tex: str) -> str:
"""Alt-text TeX as a plain formula: no one-line aligned wrapper, \\mathop{}\\!\\mathrm{d} -> \\mathrm{d},
upgreek -> standard Greek. Only the spelling changes, never a symbol."""
m = re.fullmatch(r"\s*\\begin\{aligned\}\s*(?:\\displaystyle)?(.*?)\\end\{aligned\}\s*", tex, re.S)
if m and "\\\\" not in m.group(1) and "&" not in m.group(1):
tex = m.group(1).strip()
tex = re.sub(r"\\mathop\{\}\\!", "", tex)
return _UPGREEK.sub(lambda g: "\\" + (g.group(1).capitalize() if g.group(0)[1] == "U" else g.group(1).lower()), tex)
def corpus(epub: Path) -> list[tuple[str, bytes, str, bool]]: def corpus(epub: Path) -> list[tuple[str, bytes, str, bool]]:
"""(name, image bytes, true TeX, display) for every image with TeX in its alt text.""" """(name, image bytes, true TeX, display) for every image with TeX in its alt text."""
out = [] out = []
@ -59,7 +75,7 @@ def corpus(epub: Path) -> list[tuple[str, bytes, str, bool]]:
if not (src and alt and alt.group(1).lstrip().startswith("$")): if not (src and alt and alt.group(1).lstrip().startswith("$")):
continue continue
path = posixpath.normpath(posixpath.join(base, src.group(1))) path = posixpath.normpath(posixpath.join(base, src.group(1)))
tex = strip_delimiters(html.unescape(alt.group(1))) tex = clean_truth(strip_delimiters(html.unescape(alt.group(1))))
if path in names and not unsafe_reason(tex): if path in names and not unsafe_reason(tex):
display = "IEq" not in path and "inline" not in tag.lower() display = "IEq" not in path and "inline" not in tag.lower()
out.append((path, z.read(path), tex, display)) out.append((path, z.read(path), tex, display))
@ -224,26 +240,63 @@ def _print(s: dict[str, Any], log: Any) -> None:
# ------------------------------------------------------------------------- consensus calibration # ------------------------------------------------------------------------- consensus calibration
def _consensus_eval(args: tuple) -> dict[str, Any]: def _consensus_eval(args: tuple) -> dict[str, Any]:
"""Worker: compare both readings with each other and with the truth; rough image check.""" """Worker: compare all readings with each other and with the truth; rough image check."""
from .consensus import same_reading from .consensus import same_reading
name, data, truth, display, preset, ra, rb = args from .pipeline import _strip_final_punct
name, data, truth, display, preset, readings = args
os.environ.setdefault("OMP_NUM_THREADS", "1") os.environ.setdefault("OMP_NUM_THREADS", "1")
ta, tb = ra.get("tex") or "", rb.get("tex") or "" texes = [r.get("tex") or "" for r in readings]
res: dict[str, Any] = {"name": name, "truth": truth, "display": display, "a": ra, "b": rb, ok = [bool(same_reading(t, truth, display, preset)) for t in texes]
"a_ok": bool(same_reading(ta, truth, display, preset)), agree: list[tuple[int, int, str]] = []
"b_ok": bool(same_reading(tb, truth, display, preset)), for i in range(len(texes)):
"agree": same_reading(ta, tb, display, preset)} for j in range(i + 1, len(texes)):
res["veto"] = None a, b = texes[i], texes[j]
if res["agree"]: # as in pipeline.decide_all_consensus: a confirming cloud reading may lack the final period;
v = verify(load_ink(data, name), ta, display, preset, check_variants=False) # pix2text's reading is used under rule p2t, so it is compared strictly
res["veto"] = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics} if j >= 2 and readings[j].get("engine") != "p2t" and readings[i].get("engine") != "p2t":
return res a, b = _strip_final_punct(a), _strip_final_punct(b)
how = same_reading(a, b, display, preset)
if how:
agree.append((i, j, how))
vetos: dict[int, dict[str, Any]] = {}
for k in range(len(texes)): # only readings that may be used: primary pair and pix2text (rule p2t)
if (k < 2 or readings[k].get("engine") == "p2t") and any(k in (i, j) for i, j, _ in agree):
v = verify(load_ink(data, name), texes[k], display, preset, check_variants=False)
vetos[k] = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics}
pair = next((how for i, j, how in agree if (i, j) == (0, 1)), None)
# "a", "b", "agree", "veto": the two primary models (read by tools/konsens_bericht.py)
return {"name": name, "truth": truth, "display": display, "readings": readings, "ok": ok, "agree_all": agree,
"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}
def _reading_cache(report: Path | None) -> dict[tuple[str, str, str], dict[str, Any]]:
"""(set label, image name, engine) -> reading from an earlier --report: models are not asked twice.
pix2text readings are tidied again (reports from before tidy_p2t hold the raw spaced output)."""
from .engines.p2t import tidy_p2t
if not report or not report.exists():
return {}
old = json.loads(report.read_text(encoding="utf-8"))
cache = {}
for s in old.get("sets", []):
for r in s.get("results", []):
for rd in r.get("readings") or [r.get("a") or {}, r.get("b") or {}]:
if rd.get("engine") and not rd.get("error"):
if rd["engine"] == "p2t" and rd.get("tex"):
rd = dict(rd, tex=tidy_p2t(rd.get("raw") or rd["tex"]))
cache[(s["label"], r["name"], rd["engine"])] = rd
return cache
def calibrate_consensus(epubs: list[Path], samples: int = 150, preset: str | None = None, synthetic: bool = True, def calibrate_consensus(epubs: list[Path], samples: int = 150, preset: str | None = None, synthetic: bool = True,
report: Path | None = None, workers: int = 0, quiet: bool = False, report: Path | None = None, workers: int = 0, quiet: bool = False,
engines: tuple[str, str] = ("local", "local2")) -> dict[str, Any]: engines: tuple[str, ...] = ("local", "local2"), reuse: Path | None = None) -> dict[str, Any]:
"""Measure the two-model consensus rule on images whose TeX is known.""" """Measure consensus rules on images whose TeX is known.
*engines*: the first two are the primary pair (local, local2); further engines (cloud, p2t)
give confirming readings. *reuse*: report of an earlier run whose readings are taken over.
"""
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from .decide import consensus_thresholds, decide_consensus from .decide import consensus_thresholds, decide_consensus
@ -253,10 +306,10 @@ def calibrate_consensus(epubs: list[Path], samples: int = 150, preset: str | Non
if not quiet: if not quiet:
print(m, file=sys.stderr, flush=True) print(m, file=sys.stderr, flush=True)
if len(engines) < 2:
raise ValueError("mindestens zwei Engines nötig")
cache = _reading_cache(reuse)
eng = [make_engine(e) for e in engines] eng = [make_engine(e) for e in engines]
for e in eng:
e.check()
log("Modelle: " + ", ".join(f"{e.name}={e.model}" for e in eng))
rng = random.Random(42) rng = random.Random(42)
sets: list[tuple[str, list[tuple[str, bytes, str, bool]], str]] = [] sets: list[tuple[str, list[tuple[str, bytes, str, bool]], str]] = []
for epub in epubs: for epub in epubs:
@ -268,21 +321,28 @@ def calibrate_consensus(epubs: list[Path], samples: int = 150, preset: str | Non
if synthetic: if synthetic:
syn = [(n + "#synth.jpg", img, t, d) for n, _d, t, d in smp for img in [synth_jpeg(t, d, rng)] if img] syn = [(n + "#synth.jpg", img, t, d) for n, _d, t, d in smp for img in [synth_jpeg(t, d, rng)] if img]
sets.append((f"{epub.stem[:40]} [dvipng, Gehrke-Stil]", syn, "cm")) sets.append((f"{epub.stem[:40]} [dvipng, Gehrke-Stil]", syn, "cm"))
todo = [(k, label, i) for label, items, _p in sets for k in range(len(eng)) for i in range(len(items))
if (label, items[i][0], eng[k].name) not in cache]
for k in sorted({k for k, _, _ in todo}):
eng[k].check()
log("Modelle: " + ", ".join(f"{e.name}={e.model}" for e in eng)
+ (f"; {len(cache)} Lesarten aus {reuse}" if cache else ""))
summary: dict[str, Any] = {"engines": [f"{e.name}={e.model}" for e in eng], "sets": []} summary: dict[str, Any] = {"engines": [f"{e.name}={e.model}" for e in eng], "sets": []}
for label, items, p in sets: for label, items, p in sets:
log(f"{label}: {len(items)} Formeln lesen …") log(f"{label}: {len(items)} Formeln lesen …")
readings: list[list[dict[str, Any]]] = [[{}] * len(items) for _ in eng] readings: list[list[dict[str, Any]]] = [
[cache.get((label, n, e.name), {}) for n, _d, _t, _disp in items] for e in eng]
def ask(k: int, i: int) -> None: def ask(k: int, i: int) -> None:
n, d, _t, disp = items[i] n, d, _t, disp = items[i]
readings[k][i] = eng[k].recognize(d, n, "", disp).to_json() readings[k][i] = eng[k].recognize(d, n, "", disp).to_json()
with ThreadPoolExecutor(max_workers=8) as pool: with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(lambda ki: ask(*ki), [(k, i) for k in range(len(eng)) for i in range(len(items))])) list(pool.map(lambda ki: ask(*ki), [(k, i) for k, lb, i in todo if lb == label]))
jobs = [(n, d, t, disp, p, readings[0][i], readings[1][i]) for i, (n, d, t, disp) in enumerate(items)] jobs = [(n, d, t, disp, p, [readings[k][i] for k in range(len(eng))]) for i, (n, d, t, disp) in enumerate(items)]
with ProcessPoolExecutor(max_workers=workers or max(1, (os.cpu_count() or 2) - 2)) as ex: with ProcessPoolExecutor(max_workers=workers or max(1, (os.cpu_count() or 2) - 2)) as ex:
results = list(ex.map(_consensus_eval, jobs)) results = list(ex.map(_consensus_eval, jobs))
s = _summarize_consensus(label, results, consensus_thresholds, decide_consensus) s = _summarize_consensus(label, results, [e.name for e in eng], consensus_thresholds, decide_consensus)
summary["sets"].append(s) summary["sets"].append(s)
s["results"] = results s["results"] = results
_print_consensus(s, log) _print_consensus(s, log)
@ -292,19 +352,74 @@ def calibrate_consensus(epubs: list[Path], samples: int = 150, preset: str | Non
return summary return summary
def _summarize_consensus(label: str, results: list[dict[str, Any]], consensus_thresholds: Any, # Rules to compare: (label, mode, groups). A group lists engines; the first two are the primary pair
# and every further one must confirm it (decide_consensus with require_third). "any": accepted if one
# group accepts (the first accepting group supplies the reading); "all": every group must accept;
# "tiebreak": decide_pair_tiebreak with the group (Qwen, pix2text, Gemma) – the standard rule "p2t".
CONSENSUS_RULES: list[tuple[str, str, list[tuple[str, ...]]]] = [
("Qwen = Gemma", "any", [("local", "local2")]),
("Qwen = Gemma = GLM (bisher)", "any", [("local", "local2", "cloud")]),
("B: Qwen = Gemma = p2t", "any", [("local", "local2", "p2t")]),
("Qwen = Gemma, GLM oder p2t bestätigt", "any", [("local", "local2", "cloud"), ("local", "local2", "p2t")]),
("A: 3 von 4 (p2t entscheidet bei 2:1)", "any",
[("local", "local2", "cloud"), ("local", "local2", "p2t"), ("local", "cloud", "p2t"), ("local2", "cloud", "p2t")]),
("C: Qwen = Gemma = GLM = p2t", "all", [("local", "local2", "cloud"), ("local", "local2", "p2t")]),
("Standard: p2t = Qwen, Stichwahl Gemma = p2t", "tiebreak", [("local", "p2t", "local2")]),
]
def _rule_outcome(r: dict[str, Any], idx: list[int], th: dict[str, float], decide_fn: Any,
tiebreak: bool = False) -> int | None:
"""Index of the accepted reading if the readings *idx* (primary pair first) accept, else None."""
sub = [r["readings"][k] for k in 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]
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
if use is None:
return None
veto = r["vetos"].get(idx[use])
st, _why, use = decide_fn(sub, agree, veto, True, None, t)
return idx[use] if st == "auto_accepted" else None
def _summarize_consensus(label: str, results: list[dict[str, Any]], names: list[str], consensus_thresholds: Any,
decide_consensus: Any) -> dict[str, Any]: decide_consensus: Any) -> dict[str, Any]:
from .decide import decide_pair_tiebreak
n = len(results) n = len(results)
out: dict[str, Any] = {"label": label, "formulas": n, out: dict[str, Any] = {"label": label, "formulas": n, "engines": names,
"correct": {e: sum(r["ok"][k] for r in results) for k, e in enumerate(names)},
"a_correct": sum(r["a_ok"] for r in results), "b_correct": sum(r["b_ok"] for r in results), "a_correct": sum(r["a_ok"] for r in results), "b_correct": sum(r["b_ok"] for r in results),
"agree": sum(bool(r["agree"]) for r in results), "agree": sum(bool(r["agree"]) for r in results),
"agree_wrong": sum(bool(r["agree"]) and not r["a_ok"] for r in results), "agree_wrong": sum(bool(r["agree"]) and not r["a_ok"] for r in results),
"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]
th0 = consensus_thresholds()
for rule, mode, groups in CONSENSUS_RULES:
if any(e not in names for g in groups for e in g):
continue
acc = wrong = 0
examples = []
for r in results:
if mode == "tiebreak":
used = [_rule_outcome(r, [names.index(e) for e in g], th0, decide_pair_tiebreak, True)
for g in groups]
else:
used = [_rule_outcome(r, [names.index(e) for e in g], th0, decide_consensus) for g in groups]
ok_used = [u for u in used if u is not None]
use = (ok_used[0] if ok_used else None) if mode == "any" else (used[0] if None not in used else None)
if use is None:
continue
acc += 1
if not r["ok"][use]:
wrong += 1
examples.append({"truth": r["truth"], "used": r["readings"][use].get("tex"),
"readings": {e: rd.get("tex") for e, rd in zip(names, r["readings"])}})
out["rules"].append({"rule": rule, "accepted": acc, "wrong": wrong, "wrong_examples": examples[:5]})
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}) th = consensus_thresholds({"min_p_mean": pm, "min_p_min": pn, "require_third": 0})
acc = wrong = 0 acc = wrong = 0
for r in results: for r in results:
agree = [(0, 1, r["agree"])] if r["agree"] else [] agree = [(0, 1, r["agree"])] if r["agree"] else []
@ -319,11 +434,14 @@ def _summarize_consensus(label: str, results: list[dict[str, Any]], consensus_th
def _print_consensus(s: dict[str, Any], log: Any) -> None: def _print_consensus(s: dict[str, Any], log: Any) -> None:
n = max(1, s["formulas"]) n = max(1, s["formulas"])
log(f" {s['label']}: {s['formulas']} Formeln") log(f" {s['label']}: {s['formulas']} Formeln")
log(f" Modell A richtig {s['a_correct'] / n:.0%}, Modell B richtig {s['b_correct'] / n:.0%}, " log(" allein richtig: " + ", ".join(f"{e} {c / n:.0%}" for e, c in s["correct"].items())
f"einig {s['agree'] / n:.0%}, davon FALSCH {s['agree_wrong']}") + f"; Qwen und Gemma einig {s['agree'] / n:.0%}, davon FALSCH {s['agree_wrong']}")
for ex in s["agree_wrong_examples"][:5]: log(" Regel → automatisch angenommen / davon falsch:")
log(f" einig, aber falsch: {ex['a']} (richtig: {ex['truth']})") for row in s["rules"]:
log(" Schwellen (Mittel/Minimum) → automatisch angenommen / davon falsch:") log(f" {row['rule']:<42} {row['accepted']:4d} ({row['accepted'] / n:.0%}) / {row['wrong']}")
for ex in row["wrong_examples"][:3]:
log(f" falsch: {ex['used']} (richtig: {ex['truth']})")
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}: "
f"{row['accepted']:4d} ({row['accepted'] / n:.0%}) / {row['wrong']}") f"{row['accepted']:4d} ({row['accepted'] / n:.0%}) / {row['wrong']}")

View file

@ -123,7 +123,12 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("--report", type=Path, default=None, help="Bericht als JSON speichern") sp.add_argument("--report", type=Path, default=None, help="Bericht als JSON speichern")
sp.add_argument("--workers", type=int, default=0) sp.add_argument("--workers", type=int, default=0)
sp.add_argument("--consensus", action="store_true", sp.add_argument("--consensus", action="store_true",
help="Konsensregel messen: beide lokalen Modelle (local, local2) lesen die Formeln") help="Konsensregeln messen: die Engines aus --engines lesen die Formeln")
sp.add_argument("--engines", default="local,local2",
help="Engines für --consensus, die ersten beiden bilden das Hauptpaar "
"(Standard local,local2; z. B. local,local2,cloud,p2t)")
sp.add_argument("--reuse", type=Path, default=None,
help="Lesarten aus einem früheren --report übernehmen (Modelle nicht erneut fragen)")
sp.add_argument("--quiet", "-q", action="store_true") sp.add_argument("--quiet", "-q", action="store_true")
return p return p
@ -166,7 +171,8 @@ def _dispatch(args: argparse.Namespace) -> int:
if args.command == "calibrate" and args.consensus: if args.command == "calibrate" and args.consensus:
from .calibrate import calibrate_consensus from .calibrate import calibrate_consensus
calibrate_consensus(args.epubs, samples=args.samples, preset=None if args.preset == "auto" else args.preset, calibrate_consensus(args.epubs, samples=args.samples, preset=None if args.preset == "auto" else args.preset,
synthetic=args.synthetic, report=args.report, workers=args.workers, quiet=args.quiet) synthetic=args.synthetic, report=args.report, workers=args.workers, quiet=args.quiet,
engines=tuple(e.strip() for e in args.engines.split(",") if e.strip()), reuse=args.reuse)
return EXIT_OK return EXIT_OK
if args.command == "calibrate": if args.command == "calibrate":
from .calibrate import calibrate from .calibrate import calibrate

View file

@ -26,7 +26,7 @@ _DROP = {"\\,", "\\;", "\\:", "\\!", "\\ ", "~", "\\quad", "\\qquad", "\\left",
"\\Bigg", "\\bigl", "\\bigr", "\\Bigl", "\\Bigr", "\\biggl", "\\biggr", "\\displaystyle", "\\textstyle", "\\Bigg", "\\bigl", "\\bigr", "\\Bigl", "\\Bigr", "\\biggl", "\\biggr", "\\displaystyle", "\\textstyle",
"\\scriptstyle", "\\limits", "\\nolimits", "\\middle", "\\hfill", "\\enspace", "\\thinspace", "\\medspace"} "\\scriptstyle", "\\limits", "\\nolimits", "\\middle", "\\hfill", "\\enspace", "\\thinspace", "\\medspace"}
# wrappers whose argument is kept (upright vs. italic is the same symbol) # wrappers whose argument is kept (upright vs. italic is the same symbol)
_UNWRAP = {"\\mathrm", "\\mathit", "\\operatorname", "\\textrm", "\\textit", "\\mathnormal", "\\mbox", "\\text", _UNWRAP = {"\\mathrm", "\\mathit", "\\operatorname", "\\mathop", "\\textrm", "\\textit", "\\mathnormal", "\\mbox", "\\text",
"\\textnormal", "\\textup"} "\\textnormal", "\\textup"}
_SYNONYM = { _SYNONYM = {
"\\overrightarrow": "\\vec", "\\overline": "\\bar", "\\widehat": "\\hat", "\\widetilde": "\\tilde", "\\overrightarrow": "\\vec", "\\overline": "\\bar", "\\widehat": "\\hat", "\\widetilde": "\\tilde",
@ -73,7 +73,8 @@ def canonical(tex: str, bold: bool = False) -> str:
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"\{?\\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\}\{[^{}]*\}", r"\\begin{matrix}", body).replace("\\end{array}", "\\end{matrix}") body = re.sub(r"\\begin\{array\}\s*(?:\[[^\]]*\])?\s*\{[^{}]*\}", r"\\begin{matrix}", body) # [t]: position only
body = body.replace("\\end{array}", "\\end{matrix}")
for env, (lft, rgt) in _ENV_DELIMS.items(): for env, (lft, rgt) in _ENV_DELIMS.items():
body = body.replace(f"\\begin{{{env}}}", f"{lft} \\begin{{MATRIX}}").replace(f"\\end{{{env}}}", f"\\end{{MATRIX}} {rgt}") body = body.replace(f"\\begin{{{env}}}", f"{lft} \\begin{{MATRIX}}").replace(f"\\end{{{env}}}", f"\\end{{MATRIX}} {rgt}")
body = re.sub(r"\\not\s*=", r"\\ne ", body) body = re.sub(r"\\not\s*=", r"\\ne ", body)

View file

@ -137,7 +137,16 @@ def decide_consensus(readings: list[dict[str, Any]], agree: list[tuple[int, int,
return "needs_review", f"zwei Modelle einig ({how}), dritte Lesart weicht ab: {texts}", i return "needs_review", f"zwei Modelle einig ({how}), dritte Lesart weicht ab: {texts}", i
tie = " (dritte Lesart bestätigt)" tie = " (dritte Lesart bestätigt)"
use = min(i, j) # the primary model's reading (it is the one checked against the image) use = min(i, j) # the primary model's reading (it is the one checked against the image)
problems = [p for p in (_sure(readings[i], th), _sure(readings[j], th)) if p] problems = _checks([readings[i], readings[j]], veto, pandoc_ok, book_px_per_pt, th)
if problems:
return "needs_review", f"einig ({how}){tie}; " + "; ".join(problems), use
return "auto_accepted", f"zwei Modelle einig ({how}){tie}, sicher, Bild-Veto bestanden", use
def _checks(agreeing: list[dict[str, Any]], veto: dict[str, Any] | None, pandoc_ok: bool,
book_px_per_pt: float | None, th: dict[str, float]) -> list[str]:
"""Problems of an agreed reading: unsure readings, Pandoc, rough image check (German reasons)."""
problems = [p for p in (_sure(r, th) for r in agreeing) if p]
if not pandoc_ok: if not pandoc_ok:
problems.append("Pandoc kann die Formel nicht nach MathML umsetzen") problems.append("Pandoc kann die Formel nicht nach MathML umsetzen")
if veto is None or not veto.get("ok"): if veto is None or not veto.get("ok"):
@ -149,6 +158,53 @@ def decide_consensus(readings: list[dict[str, Any]], agree: list[tuple[int, int,
if (th["veto_scale_dev"] > 0 and book_px_per_pt if (th["veto_scale_dev"] > 0 and book_px_per_pt
and abs(m.get("px_per_pt", book_px_per_pt) / book_px_per_pt - 1) > th["veto_scale_dev"]): and abs(m.get("px_per_pt", book_px_per_pt) / book_px_per_pt - 1) > th["veto_scale_dev"]):
problems.append(f"Bild-Veto: Schriftgröße weicht um {abs(m['px_per_pt'] / book_px_per_pt - 1):.0%} ab") problems.append(f"Bild-Veto: Schriftgröße weicht um {abs(m['px_per_pt'] / book_px_per_pt - 1):.0%} ab")
return problems
# Marker in the reason of a formula that waits for the tie-break reading (see pipeline.needs_tiebreak).
TIEBREAK_MISSING = "Stichwahl fehlt"
def decide_pair_tiebreak(readings: list[dict[str, Any] | None], agree: list[tuple[int, int, str]],
veto: dict[str, Any] | None, pandoc_ok: bool, book_px_per_pt: float | None,
th: dict[str, float]) -> tuple[str, str, int | None]:
"""Rule "p2t": pix2text reads first, Qwen confirms, Gemma only where the two disagree.
The reading used is always pix2text's (spaces tidied; it reads bold type best). *readings*:
[Qwen, pix2text, Gemma or None]. *agree*: pairs (i, j, how) of readings that say the same.
*veto*: verification of pix2text's reading. Returns (status, reason, index of the reading to use
or None).
Gemma may only confirm pix2text. Qwen and Gemma together against pix2text go to a human: in the
measurement exactly these were the errors both VLMs share (\\mathcal{E}_r for \\varepsilon_r, an
invented \\tilde{z}).
"""
q, p = readings[0], readings[1]
g = readings[2] if len(readings) > 2 else None
pairs = {frozenset((i, j)): how for i, j, how in agree}
if q is None or p is None:
missing = "Qwen" if q is None else "pix2text"
return "needs_review", f"Lesart von {missing} fehlt", 1 if p is not None and p.get("tex") else None
if q.get("is_formula") is False and (g is None or g.get("is_formula") is False) and (g or not p.get("tex")):
return "not_formula", "Qwen" + (" und Gemma" if g else "") + ": keine Formel", None
first = 1 if p.get("tex") else None # reading shown for review: pix2text's
if frozenset((0, 1)) in pairs:
how, who, confirm = pairs[frozenset((0, 1))], "pix2text und Qwen einig", q
elif g is None:
return "needs_review", f"pix2text und Qwen uneinig, {TIEBREAK_MISSING}", first
elif frozenset((1, 2)) in pairs:
how, who, confirm = pairs[frozenset((1, 2))], "Stichwahl: Gemma bestätigt pix2text", g
elif frozenset((0, 2)) in pairs:
return ("needs_review", "Qwen und Gemma einig gegen pix2text – Mensch entscheidet: "
f"Qwen: {q.get('tex') or '–'}", first)
else:
texts = " | ".join(f"{n}: {r.get('tex') or '–'}" for n, r in (("Qwen", q), ("pix2text", p), ("Gemma", g)))
return "needs_review", f"alle uneinig: {texts}", first
use = 1
if not p.get("tex"):
return "needs_review", f"{who}, aber ohne LaTeX", None
# the confidence check (token probabilities, off by default) covers the confirming VLM reading
problems = _checks([confirm], veto, pandoc_ok, book_px_per_pt, th)
if problems: if problems:
return "needs_review", f"einig ({how}){tie}; " + "; ".join(problems), use return "needs_review", f"{who} ({how}); " + "; ".join(problems), use
return "auto_accepted", f"zwei Modelle einig ({how}){tie}, sicher, Bild-Veto bestanden", use return "auto_accepted", f"{who} ({how}), Bild-Veto bestanden", use

View file

@ -47,5 +47,5 @@ def make_engine(name: str, model: str | None = None, url: str | None = None) ->
) )
if name == "p2t": if name == "p2t":
return Pix2TextEngine(model=model or os.environ.get("FORMULA_P2T_MODEL", DEFAULT_P2T_MODEL), return Pix2TextEngine(model=model or os.environ.get("FORMULA_P2T_MODEL", DEFAULT_P2T_MODEL),
device=os.environ.get("FORMULA_P2T_DEVICE", "cpu")) device=os.environ.get("FORMULA_P2T_DEVICE", "auto"))
raise ValueError(f"unbekannte Engine {name!r} (local, local2, cloud, p2t)") raise ValueError(f"unbekannte Engine {name!r} (local, local2, cloud, p2t)")

View file

@ -1,12 +1,14 @@
"""Pix2Text formula OCR (MFR model) as a local candidate generator. """Pix2Text formula OCR (MFR model) as a local candidate generator.
Not a vision-language model but a dedicated image-to-LaTeX transformer: another kind of model than Not a vision-language model but a dedicated image-to-LaTeX transformer: another kind of model than
the VLM engines, so its errors are likely to be different ones. Runs on the CPU (ONNX), no server. the VLM engines, so its errors are likely to be different ones. Runs in-process (ONNX), no server:
on the CPU, or on a GPU with onnxruntime-gpu (device "cuda:N"; N counts within CUDA_VISIBLE_DEVICES).
""" """
from __future__ import annotations from __future__ import annotations
import io import io
import re
import threading import threading
import time import time
@ -17,9 +19,48 @@ from .vlm import Recognition, prepare_image
DEFAULT_MODEL = "mfr-1.5" DEFAULT_MODEL = "mfr-1.5"
# Pix2Text's bold: \mathrm{\boldmath~ x ~} (an error in math mode), \mathrm{{\bf~ r o t}}, \mathrm{\bf~ r o t}
_BOLDMATH = re.compile(r"\\mathrm\s*\{\s*\\boldmath\s*~?\s*([^{}~\\]*?)\s*~?\s*\}")
_BF = re.compile(r"\\mathrm\s*\{\s*(\{)?\s*\\bf\s*~?\s*([^{}~\\]*?)\s*~?\s*(?(1)\})\s*\}")
def tidy_p2t(tex: str) -> str:
"""Pix2Text output without superfluous spaces: `\\frac{\\alpha} {3 6 0}` -> `\\frac{\\alpha}{360}`.
The spaces are removed by post_process of pix2tex (LaTeX-OCR), which keeps a space between two
letters (`a x`, `\\cdot x`) and the control space `\\ `; TeX ignores the removed spaces in math
mode, so the typeset formula does not change. Before that, Pix2Text's bold spellings become
\\boldsymbol / \\mathbf.
"""
from pix2tex.utils.utils import post_process # imports torch: only when needed
tex = _BOLDMATH.sub(lambda m: "\\boldsymbol{" + re.sub(r"\s+", "", m.group(1)) + "}", tex)
tex = _BF.sub(lambda m: "\\mathbf{" + re.sub(r"\s+", "", m.group(2)) + "}", tex)
return post_process(tex).strip()
def resolve_device(device: str) -> str:
"""Device "auto": the first visible GPU if onnxruntime has CUDA, else the CPU."""
if device != "auto":
return device
import onnxruntime
return "cuda" if "CUDAExecutionProvider" in onnxruntime.get_available_providers() else "cpu"
def ort_provider(device: str) -> tuple[str, dict]:
"""ONNX Runtime provider and its options for "cpu", "cuda" or "cuda:N".
Set explicitly: pix2text otherwise takes the first available provider (TensorRT with onnxruntime-gpu).
"""
if device == "cpu":
return "CPUExecutionProvider", {}
if device == "cuda" or device.startswith("cuda:"):
return "CUDAExecutionProvider", {"device_id": int(device.partition(":")[2] or 0)}
raise ValueError(f"unbekanntes Gerät {device!r} (cpu, cuda, cuda:N)")
class Pix2TextEngine: class Pix2TextEngine:
def __init__(self, model: str = DEFAULT_MODEL, device: str = "cpu") -> None: def __init__(self, model: str = DEFAULT_MODEL, device: str = "auto") -> None:
self.name = "p2t" self.name = "p2t"
self.model_name = model self.model_name = model
self.model = f"pix2text-{model}" self.model = f"pix2text-{model}"
@ -38,7 +79,21 @@ class Pix2TextEngine:
raise FormulaError(f"pix2text ist nicht installiert ({e}): pip install 'pix2text[multilingual]'", raise FormulaError(f"pix2text ist nicht installiert ({e}): pip install 'pix2text[multilingual]'",
EXIT_TOOL) from e EXIT_TOOL) from e
try: try:
self._ocr = LatexOCR(model_name=self.model_name, model_backend="onnx", device=self.device) import pix2tex.utils.utils # noqa: F401 (post_process for tidy_p2t)
except ImportError as e:
raise FormulaError(f"pix2tex ist nicht installiert ({e}): pip install --no-deps pix2tex munch",
EXIT_TOOL) from e
self.device = resolve_device(self.device)
provider, options = ort_provider(self.device)
if provider == "CUDAExecutionProvider":
import onnxruntime
if provider not in onnxruntime.get_available_providers():
raise FormulaError(f"Gerät {self.device}: onnxruntime ohne CUDA – pip install onnxruntime-gpu",
EXIT_TOOL)
onnxruntime.preload_dlls() # cuDNN/cuBLAS from the nvidia-* pip packages (installed with torch)
try:
self._ocr = LatexOCR(model_name=self.model_name, model_backend="onnx", device=self.device,
more_model_configs={"provider": provider, "provider_options": options})
except Exception as e: # download or model files broken: any exception type is possible here except Exception as e: # download or model files broken: any exception type is possible here
raise FormulaError(f"Pix2Text-Modell {self.model_name} nicht ladbar ({e})", EXIT_TOOL) from e raise FormulaError(f"Pix2Text-Modell {self.model_name} nicht ladbar ({e})", EXIT_TOOL) from e
@ -51,9 +106,10 @@ class Pix2TextEngine:
with self._lock: with self._lock:
# _one_batch instead of recognize(): the latter draws a tqdm bar for every image # _one_batch instead of recognize(): the latter draws a tqdm bar for every image
out = self._ocr._one_batch([img], None)[0] out = self._ocr._one_batch([img], None)[0]
tex = self._ocr.post_process(out["text"]).strip() raw = self._ocr.post_process(out["text"]).strip()
except Exception as e: # model not loadable or one unreadable image: must not stop the run except Exception as e: # model not loadable or one unreadable image: must not stop the run
return Recognition(self.name, self.model, None, None, "", str(e)[:300], time.time() - t0) return Recognition(self.name, self.model, None, None, "", str(e)[:300], time.time() - t0)
score = float(out.get("score") or 0.0) or None score = float(out.get("score") or 0.0) or None
return Recognition(self.name, self.model, tex or None, None, tex[:2000], None, time.time() - t0, tex = tidy_p2t(raw)
return Recognition(self.name, self.model, tex or None, None, raw[:2000], None, time.time() - t0,
p_mean=score) p_mean=score)

View file

@ -20,8 +20,10 @@ from PIL import Image
from .checks import pandoc_mathml_ok from .checks import pandoc_mathml_ok
from .classify import classify from .classify import classify
from .compare import diff_image from .compare import diff_image
from .decide import consensus_thresholds, decide, decide_consensus, thresholds from .decide import (TIEBREAK_MISSING, consensus_thresholds, decide, decide_consensus, decide_pair_tiebreak,
thresholds)
from .engines import make_engine from .engines import make_engine
from .errors import FormulaError
from .engines.vlm import PROMPT_VERSION from .engines.vlm import PROMPT_VERSION
from .imageio import load_ink from .imageio import load_ink
from .render import PRESETS from .render import PRESETS
@ -113,10 +115,15 @@ def needs_third(f: Formula) -> bool:
return f.status == "needs_review" and "dritte Lesart fehlt" in (f.reason or "") return f.status == "needs_review" and "dritte Lesart fehlt" in (f.reason or "")
def needs_tiebreak(f: Formula) -> bool:
"""Rule "p2t": pix2text and Qwen disagree; Gemma's tie-break reading is missing."""
return f.status == "needs_review" and TIEBREAK_MISSING in (f.reason or "")
def recognize(source: Source, ws: Workspace, engine_name: str, model: str | None = None, url: str | None = None, def recognize(source: Source, ws: Workspace, engine_name: str, model: str | None = None, url: str | None = None,
only_unresolved: bool = False, include_figures: bool = False, limit: int = 0, only_unresolved: bool = False, include_figures: bool = False, limit: int = 0,
workers: int = 4, quiet: bool = False, only_ids: set[str] | None = None, workers: int = 4, quiet: bool = False, only_ids: set[str] | None = None,
only_needing_third: bool = False) -> dict[str, int]: only_needing_third: bool = False, only_needing_tiebreak: bool = False) -> dict[str, int]:
engine = make_engine(engine_name, model, url) engine = make_engine(engine_name, model, url)
engine.check() engine.check()
todo = [] todo = []
@ -127,6 +134,8 @@ def recognize(source: Source, ws: Workspace, engine_name: str, model: str | None
continue continue
if only_needing_third and not needs_third(f): if only_needing_third and not needs_third(f):
continue continue
if only_needing_tiebreak and not needs_tiebreak(f):
continue
if any(c.get("engine") == engine.name and c.get("model") == engine.model if any(c.get("engine") == engine.name and c.get("model") == engine.model
and 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
@ -246,6 +255,8 @@ def verify_all(source: Source, ws: Workspace, preset: str | None = None, include
formulas = [f for f in ws.all() if _wanted(f, include_figures) and _distinct_candidates(f) formulas = [f for f in ws.all() if _wanted(f, include_figures) and _distinct_candidates(f)
and (only_ids is None or f.id in only_ids)] and (only_ids is None or f.id in only_ids)]
mode = decide_mode(ws) mode = decide_mode(ws)
if mode == "consensus" and consensus_rule(ws) == "vlm3":
mode = "consensus-vlm3"
formulas = [f for f in formulas if _verify_texes(f, mode)] formulas = [f for f in formulas if _verify_texes(f, mode)]
if not redo: if not redo:
formulas = [f for f in formulas if not _verified_current(f, mode)] formulas = [f for f in formulas if not _verified_current(f, mode)]
@ -288,10 +299,13 @@ VERIFY_VERSION = 3 # bump when the comparison changes, so stored image checks
def _verify_texes(f: Formula, mode: str) -> list[str]: def _verify_texes(f: Formula, mode: str) -> list[str]:
"""Readings to check against the image. Consensus: only the reading that would be used (the """Readings to check against the image: only the one that may be used. Mode "consensus" (rule
first primary reading) – the veto needs nothing else, and a later third reading changes nothing.""" p2t): pix2text's. "consensus-vlm3": the first primary reading (Qwen's). "pixel": all candidates."""
if mode != "consensus": if mode == "pixel":
return _distinct_candidates(f) return _distinct_candidates(f)
if mode == "consensus":
p = latest_by_engine(f).get("p2t")
return [split_tag(strip_delimiters(p["tex"]))[0]] if p and p.get("tex") else []
for r in readings_of(f): for r in readings_of(f):
if r.get("tex"): if r.get("tex"):
return [split_tag(strip_delimiters(r["tex"]))[0]] return [split_tag(strip_delimiters(r["tex"]))[0]]
@ -312,6 +326,20 @@ def decide_mode(ws: Workspace) -> str:
return ws.settings.get("decide") or "consensus" return ws.settings.get("decide") or "consensus"
def consensus_rule(ws: Workspace) -> str:
"""'p2t' (pix2text = Qwen, Gemma as tie-break; default) or 'vlm3' (Qwen = Gemma = GLM)."""
return ws.settings.get("consensus_rule") or "p2t"
def latest_by_engine(f: Formula) -> dict[str, dict]:
"""Latest error-free reading per engine."""
latest: dict[str, dict] = {}
for c in f.candidates:
if not c.get("error"):
latest[c.get("engine", "")] = c
return latest
def readings_of(f: Formula) -> list[dict]: def readings_of(f: Formula) -> list[dict]:
"""Latest reading per model; local engines first (primary pair), then the rest (tie-break).""" """Latest reading per model; local engines first (primary pair), then the rest (tie-break)."""
latest: dict[str, dict] = {} latest: dict[str, dict] = {}
@ -369,17 +397,28 @@ def _tex_key(tex: str) -> str:
def decide_all_consensus(ws: Workspace, quiet: bool, preset: str, only_ids: set[str] | None = None) -> dict[str, int]: def decide_all_consensus(ws: Workspace, quiet: bool, preset: str, only_ids: set[str] | None = None) -> dict[str, int]:
from .consensus import same_reading from .consensus import same_reading
th = consensus_thresholds(ws.settings.get("consensus_thresholds")) th = consensus_thresholds(ws.settings.get("consensus_thresholds"))
rule = consensus_rule(ws)
decide_fn = decide_pair_tiebreak if rule == "p2t" else decide_consensus
formulas = [f for f in ws.all() if f.candidates and f.status not in HUMAN_STATUSES formulas = [f for f in ws.all() if f.candidates and f.status not in HUMAN_STATUSES
and (only_ids is None or f.id in only_ids)] and (only_ids is None or f.id in only_ids)]
plan = [] plan = []
for f in formulas: for f in formulas:
rs = readings_of(f)
bold = bool(th.get("bold_relevant")) bold = bool(th.get("bold_relevant"))
if rule == "p2t":
latest = latest_by_engine(f)
rs = [latest.get("local"), latest.get("p2t"), latest.get("local2")]
pairs = [(0, 1), (1, 2), (0, 2)]
else:
rs = readings_of(f)
pairs = [(i, j) for i in range(len(rs)) for j in range(i + 1, len(rs))]
agree = [] agree = []
for i in range(len(rs)): for i, j in pairs:
for j in range(i + 1, len(rs)): if rs[i] is None or rs[j] is None:
continue
a, b = rs[i].get("tex") or "", rs[j].get("tex") or "" a, b = rs[i].get("tex") or "", rs[j].get("tex") or ""
if j >= 2: # the confirming reading: a missing final period/comma is no contradiction # rule vlm3: a confirming reading (j >= 2) may lack the final period or comma. Rule p2t: strict,
# pix2text's reading is the one used, so its final punctuation must be confirmed too
if rule != "p2t" and j >= 2:
a, b = _strip_final_punct(a), _strip_final_punct(b) 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)
if how: if how:
@ -397,20 +436,20 @@ def decide_all_consensus(ws: Workspace, quiet: bool, preset: str, only_ids: set[
ws.settings["book_px_per_pt"] = book_ppt ws.settings["book_px_per_pt"] = book_ppt
chosen = [] chosen = []
for f, rs, agree in plan: for f, rs, agree in plan:
status, reason, use = decide_consensus(rs, agree, None, True, book_ppt, th) # preliminary: which reading status, reason, use = decide_fn(rs, agree, None, True, book_ppt, th) # preliminary: which reading
chosen.append(rs[use]["tex"] if use is not None and rs[use].get("tex") else None) chosen.append(rs[use]["tex"] if use is not None and rs[use].get("tex") else None)
pandoc = pandoc_mathml_ok([(t or "x", f.display) for (f, _, _), t in zip(plan, chosen)]) if plan else [] pandoc = pandoc_mathml_ok([(t or "x", f.display) for (f, _, _), t in zip(plan, chosen)]) if plan else []
counts: dict[str, int] = {} counts: dict[str, int] = {}
for (f, rs, agree), tex, pok in zip(plan, chosen, pandoc): for (f, rs, agree), tex, pok in zip(plan, chosen, pandoc):
veto = by_tex[f.id].get(_tex_key(tex)) if tex else None veto = by_tex[f.id].get(_tex_key(tex)) if tex else None
status, reason, use = decide_consensus(rs, agree, veto, pok, book_ppt, th) status, reason, use = decide_fn(rs, agree, veto, pok, book_ppt, th)
f.status, f.reason = status, reason f.status, f.reason = status, reason
f.final_tex = _with_tag(f, tex) if status == "auto_accepted" and tex else None f.final_tex = _with_tag(f, tex) if status == "auto_accepted" and tex else None
f.decided_at = now_iso() f.decided_at = now_iso()
ws.save(f) ws.save(f)
counts[status] = counts.get(status, 0) + 1 counts[status] = counts.get(status, 0) + 1
ws.save_meta() ws.save_meta()
log("Entscheidung (Konsens): " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())), quiet) log(f"Entscheidung (Konsens, Regel {rule}): " + ", ".join(f"{k} {v}" for k, v in sorted(counts.items())), quiet)
return counts return counts
@ -526,25 +565,39 @@ def run_consensus(source: Source, ws: Workspace, chunk: int = 50, preset: str |
include_figures: bool = False, limit: int = 0, cloud_model: str | None = None, include_figures: bool = False, limit: int = 0, cloud_model: str | None = None,
use_cloud: bool = True, ids: list[str] | None = None, quiet: bool = False, use_cloud: bool = True, ids: list[str] | None = None, quiet: bool = False,
report: Callable[[str], None] | None = None) -> dict[str, int]: report: Callable[[str], None] | None = None) -> dict[str, int]:
"""Process formulas in chunks: local, local2, verify; cloud only where both agree; verify again. """Process formulas in chunks and report a status line (processed / accepted / review / image) after each.
After every chunk a status line (processed / accepted / review / image) is reported. Rule "p2t": pix2text, then Qwen, verify; Gemma only where they disagree; decide again.
Rule "vlm3": Qwen and Gemma, verify; cloud only where both agree; verify again.
""" """
say = report or (lambda m: print(m, flush=True)) say = report or (lambda m: print(m, flush=True))
todo = ids or [f.id for f in ws.all() if _wanted(f, include_figures)] todo = ids or [f.id for f in ws.all() if _wanted(f, include_figures)]
if limit: if limit:
todo = todo[:limit] todo = todo[:limit]
rule = consensus_rule(ws)
tiebreak_ok = True
totals: dict[str, int] = {} totals: dict[str, int] = {}
done: list[str] = [] done: list[str] = []
for start in range(0, len(todo), chunk): for start in range(0, len(todo), chunk):
part = set(todo[start:start + chunk]) part = set(todo[start:start + chunk])
# both local models run on their own GPU: ask them at the same time if rule == "p2t": # pix2text first (fast), then Qwen
for eng in ("p2t", "local"):
recognize(source, ws, eng, only_ids=part, include_figures=include_figures, quiet=True)
else: # both VLMs run on their own GPU: ask them at the same time
with ThreadPoolExecutor(max_workers=2) as both: with ThreadPoolExecutor(max_workers=2) as both:
for fut in [both.submit(recognize, source, ws, eng, only_ids=part, include_figures=include_figures, for fut in [both.submit(recognize, source, ws, eng, only_ids=part, include_figures=include_figures,
quiet=True) for eng in ("local", "local2")]: quiet=True) for eng in ("local", "local2")]:
fut.result() fut.result()
verify_all(source, ws, preset, include_figures, quiet=True, only_ids=part) verify_all(source, ws, preset, include_figures, quiet=True, only_ids=part)
if use_cloud: if rule == "p2t" and tiebreak_ok and any(needs_tiebreak(f) for f in map(ws.load, part) if f):
try:
recognize(source, ws, "local2", only_ids=part, only_needing_tiebreak=True,
include_figures=include_figures, quiet=True)
verify_all(source, ws, preset, include_figures, quiet=True, only_ids=part)
except FormulaError as e: # Gemma not running: the disputed formulas stay in review
tiebreak_ok = False
say(f"Stichwahl (Gemma) nicht möglich, strittige Formeln bleiben im Review: {e}")
if rule != "p2t" and use_cloud:
recognize(source, ws, "cloud", cloud_model, only_ids=part, only_needing_third=True, recognize(source, ws, "cloud", cloud_model, only_ids=part, only_needing_third=True,
include_figures=include_figures, workers=8, quiet=True) include_figures=include_figures, workers=8, quiet=True)
verify_all(source, ws, preset, include_figures, quiet=True, only_ids=part) verify_all(source, ws, preset, include_figures, quiet=True, only_ids=part)

View file

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

218
tools/regeltest.py Normal file
View file

@ -0,0 +1,218 @@
#!/usr/bin/env python3
"""Run the standard consensus rule on formulas whose TeX is known and show every case for review.
python3 tools/regeltest.py EPUB [--n 50] [--name regeltest] [--reuse vergleich/regeltest/ergebnis.json]
[--exclude vergleich/regeltest/ergebnis.json ...]
Like `run`: pix2text reads every formula first, then Qwen (local); Gemma (local2) only where they
disagree; then the rough image check on pix2text's reading and decide_pair_tiebreak. Writes
vergleich/<name>/index.html (not versioned): summary, then per formula the original, the true
formula and every reading set with pdflatex, the decision and whether the result is right.
--reuse takes the readings (and their times) of an earlier run instead of asking the models again;
--exclude leaves out the formulas of earlier runs, so a new sample has none of them.
"""
from __future__ import annotations
import argparse
import html
import json
import random
import statistics
import sys
import time
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 _best_preset, corpus # noqa: E402
from formula_img2latex.checks import pandoc_mathml_ok # noqa: E402
from formula_img2latex.consensus import same_reading # noqa: E402
from formula_img2latex.decide import consensus_thresholds, decide_pair_tiebreak # noqa: E402
from formula_img2latex.engines import make_engine # noqa: E402
from formula_img2latex.engines.p2t import tidy_p2t # noqa: E402
from formula_img2latex.imageio import load_ink # noqa: E402
from formula_img2latex.render import render_many # noqa: E402
from formula_img2latex.tex import strip_delimiters # noqa: E402
from formula_img2latex.verify import verify # noqa: E402
TOOLS = [("local", "Qwen"), ("p2t", "pix2text"), ("local2", "Gemma")]
def agreements(texes: list[str | None], display: bool, preset: str) -> list[tuple[int, int, str]]:
"""Pairs (Qwen, pix2text), (pix2text, Gemma), (Qwen, Gemma) as in pipeline.decide_all_consensus (strict)."""
out = []
for i, j in ((0, 1), (1, 2), (0, 2)):
a, b = texes[i], texes[j]
if a is None or b is None:
continue
how = same_reading(a, b, display, preset)
if how:
out.append((i, j, how))
return out
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("epub", type=Path)
ap.add_argument("--n", type=int, default=50)
ap.add_argument("--name", default="regeltest")
ap.add_argument("--reuse", type=Path, default=None, help="Lesarten aus einem früheren ergebnis.json")
ap.add_argument("--exclude", type=Path, action="append", default=[],
help="Formeln aus diesem früheren ergebnis.json auslassen (mehrfach möglich)")
args = ap.parse_args()
cached = {}
if args.reuse:
cached = {c["name"]: c["readings"] for c in json.loads(args.reuse.read_text(encoding="utf-8"))["cases"]}
for rs in cached.values(): # earlier runs stored pix2text's raw spaced output
if rs[1] and rs[1].get("tex"):
rs[1]["tex"] = tidy_p2t(rs[1].get("raw") or rs[1]["tex"])
excluded = {c["name"] for x in args.exclude for c in json.loads(x.read_text(encoding="utf-8"))["cases"]}
items = [it for it in corpus(args.epub) if it[0] not in excluded]
smp = random.Random(42).sample(items, min(args.n + 1, len(items)))
formulas, warm = smp[:args.n], smp[args.n]
preset = _best_preset(smp[:12])
th = consensus_thresholds()
eng = {}
for key, label in ([] if cached else TOOLS):
e = make_engine(key)
t0 = time.time()
e.check()
e.recognize(warm[1], warm[0], "", warm[3]) # warm-up, not counted
eng[key] = e
print(f"{label}: {e.model}, bereit in {time.time() - t0:.1f} s", file=sys.stderr, flush=True)
cases = []
for k, (name, data, truth, disp) in enumerate(formulas, 1):
old = cached.get(name)
if cached and old is None:
raise SystemExit(f"{name} fehlt in {args.reuse} (andere Stichprobe?)")
reads: list[dict | None] = [None, None, None]
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()
texes = [r and r.get("tex") for r in reads]
agree = agreements(texes, disp, preset)
if not any((i, j) == (0, 1) for i, j, _ in agree): # disputed: ask Gemma
reads[2] = old[2] if old else eng["local2"].recognize(data, name, "", disp).to_json()
texes = [r and r.get("tex") for r in reads]
agree = agreements(texes, disp, preset)
_st, _why, use = decide_pair_tiebreak(reads, agree, None, True, None, th)
veto = None
pandoc = True
if use is not None and texes[use]:
v = verify(load_ink(data, name), texes[use], disp, preset, check_variants=False)
veto = {"ok": v.ok, "reason": v.reason, "metrics": v.metrics}
pandoc = pandoc_mathml_ok([(texes[use], disp)])[0]
status, reason, use = decide_pair_tiebreak(reads, agree, veto, pandoc, None, th)
ok = [bool(t) and bool(same_reading(t, truth, disp, preset)) for t in texes]
accepted = status == "auto_accepted"
cases.append({"name": name, "truth": truth, "display": disp, "readings": reads, "ok": ok, "agree": agree,
"status": status, "reason": reason, "use": use, "veto": veto,
"right": bool(accepted and ok[use])})
mark = ("✓" if ok[use] else "✗ FALSCH") if accepted else "Review"
print(f"{k:3d} {status:13s} {mark:8s} {reason[:110]}", file=sys.stderr, flush=True)
n = len(cases)
acc = [c for c in cases if c["status"] == "auto_accepted"]
wrong = [c for c in acc if not c["right"]]
review = [c for c in cases if c["status"] == "needs_review"]
gemma = sum(c["readings"][2] is not None for c in cases)
secs = {key: [c["readings"][p]["seconds"] for c in cases if c["readings"][p]] for p, (key, _l) in enumerate(TOOLS)}
per_formula = [sum(r["seconds"] for r in c["readings"] if r) for c in cases]
summary = {"formulas": n, "accepted": len(acc), "wrong": len(wrong), "review": len(review),
"other": n - len(acc) - len(review), "gemma_asked": gemma,
"mean_s_per_formula": statistics.mean(per_formula),
"mean_s": {k: statistics.mean(v) if v else None for k, v in secs.items()},
"correct_alone": {key: sum(c["ok"][p] for c in cases if c["readings"][p])
for p, (key, _l) in enumerate(TOOLS)}}
out = Path(__file__).resolve().parents[1] / "vergleich" / args.name
out.mkdir(parents=True, exist_ok=True)
(out / "ergebnis.json").write_text(json.dumps({"epub": args.epub.name, "preset": preset, "summary": summary,
"cases": cases}, ensure_ascii=False, indent=1, default=str),
encoding="utf-8")
def order(c: dict) -> int:
return 0 if c["status"] == "auto_accepted" and not c["right"] else 1 if c["status"] != "auto_accepted" else 2
shown = sorted(cases, key=order)
texes = []
for c in shown:
texes.append((strip_delimiters(c["truth"]), c["display"]))
texes += [((r or {}).get("tex") or "\\text{–}", c["display"]) for r in c["readings"]]
rendered = render_many(texes, preset)
by_name = {f[0]: f[1] for f in formulas}
lab = dict(TOOLS)
head = (f"<h1>Regeltest: {html.escape(args.epub.name[:70])}</h1>"
"<p>Regel: pix2text liest zuerst, Qwen bestätigt → annehmen. Sonst fragt Gemma; Gemma = pix2text → annehmen; "
"Qwen = Gemma gegen pix2text → Mensch. Übernommen wird immer pix2texts Lesart (Leerzeichen bereinigt). "
"Annahme nur mit Pandoc und Bild-Veto. "
"✓/✗ = gleiche Zeichen an gleicher Stelle wie die wahre Formel (Abstände, kursiv/aufrecht, Fettdruck egal).</p>"
"<table class=sum>"
f"<tr><th>Formeln</th><td>{n}</td></tr>"
f"<tr><th>automatisch angenommen</th><td>{len(acc)} ({len(acc) / n:.0%})</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>Gemma gefragt</th><td>{gemma} ({gemma / n:.0%})</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]} allein richtig</th><td>{v}/{len(secs[k])}</td></tr>"
for k, v in summary["correct_alone"].items())
+ "</table>")
parts = [head]
per = 4
for i, c in enumerate(shown):
imgs = []
for rr in rendered[per * i: per * (i + 1)]:
if rr.ok:
g = (rr.rasterize(2.0) * 255).astype(np.uint8)
imgs.append(f"<img src='{png_uri(Image.fromarray(255 - g))}'>")
else:
imgs.append("<i>nicht setzbar</i>")
if c["status"] == "auto_accepted":
cls, verdict = ("good", "angenommen, richtig") if c["right"] else ("bad", "angenommen, FALSCH")
elif c["status"] == "needs_review":
cls, verdict = "rev", "Review"
else:
cls, verdict = "rev", c["status"]
ncc = (c["veto"] or {}).get("metrics", {}).get("ncc")
rows = [f"<tr><th>Original</th><td><img src='{png_uri(orig_img(by_name[c['name']], 2))}'></td><td></td></tr>",
f"<tr><th>richtig</th><td>{imgs[0]}</td><td><code>{html.escape(c['truth'])}</code></td></tr>"]
for p, (key, label) in enumerate(TOOLS):
r = c["readings"][p]
if r is None:
rows.append(f"<tr><th>{label}</th><td><i>nicht gefragt</i></td><td></td></tr>")
continue
mark = "<b class=ok>✓</b>" if c["ok"][p] else "<b class=no>✗</b>"
used = " <b>← verwendet</b>" if c["use"] == p and c["status"] == "auto_accepted" else ""
rows.append(f"<tr><th>{mark} {label}{used}<br><small>{r['seconds']:.2f} s</small></th><td>{imgs[p + 1]}</td>"
f"<td><code>{html.escape(r.get('tex') or r.get('error') or '–')}</code>"
+ (f"<br><small>roh: <code>{html.escape(r.get('raw') or '')}</code></small>"
if key == "p2t" and r.get("raw") and r.get("raw") != r.get("tex") else "")
+ "</td></tr>")
parts.append(f"<section class={cls}><div class=v>{verdict} · {html.escape(c['reason'])}"
f"{f' · Bild-Ähnlichkeit {ncc:.2f}' if ncc is not None else ''}</div>"
f"<small>{html.escape(c['name'])}</small><table>{''.join(rows)}</table></section>")
(out / "index.html").write_text(
"<!doctype html><meta charset=utf-8><title>Regeltest</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}"
"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>" + "".join(parts),
encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=1))
print(out / "index.html")
return 0
if __name__ == "__main__":
sys.exit(main())

145
tools/zeittest.py Normal file
View file

@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""Time and accuracy per recognition tool on formulas whose TeX is known (serial, one at a time).
python3 tools/zeittest.py EPUB [--n 15] [--tools local,local2,cloud,p2t-cpu,p2t-gpu] [--gpu cuda:0]
Same sample as `calibrate --consensus` (seed 42, real images). Every tool reads every formula alone,
one request after the other, after one uncounted warm-up on another formula. Writes
vergleich/zeittest/index.html (not versioned): table per tool, then per formula the original, the
true formula and every reading set with pdflatex, with time and verdict; plus ergebnis.json.
"""
from __future__ import annotations
import argparse
import html
import json
import random
import statistics
import sys
import time
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 _best_preset, corpus # noqa: E402
from formula_img2latex.consensus import same_reading # noqa: E402
from formula_img2latex.engines import make_engine # noqa: E402
from formula_img2latex.engines.p2t import Pix2TextEngine # noqa: E402
from formula_img2latex.render import render_many # noqa: E402
from formula_img2latex.tex import strip_delimiters # noqa: E402
LABELS = {"local": "Qwen3-VL-30B (lokal, GPU)", "local2": "Gemma-4-26B (lokal, GPU)", "cloud": "GLM-4.6V (Cloud)",
"p2t-cpu": "pix2text MFR 1.5 (CPU)", "p2t-gpu": "pix2text MFR 1.5 (GPU)"}
def engine_for(tool: str, gpu: str):
if tool == "p2t-cpu":
return Pix2TextEngine(device="cpu")
if tool == "p2t-gpu":
return Pix2TextEngine(device=gpu)
return make_engine(tool)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("epub", type=Path)
ap.add_argument("--n", type=int, default=15)
ap.add_argument("--tools", default="local,local2,cloud,p2t-cpu,p2t-gpu")
ap.add_argument("--gpu", default="cuda", help="Gerät für p2t-gpu (Index relativ zu CUDA_VISIBLE_DEVICES)")
ap.add_argument("--name", default="zeittest")
args = ap.parse_args()
tools = [t.strip() for t in args.tools.split(",") if t.strip()]
items = corpus(args.epub)
smp = random.Random(42).sample(items, min(150, len(items))) # as calibrate_consensus
formulas, warm = smp[:args.n], smp[args.n]
preset = _best_preset(smp[:12])
runs: dict[str, dict] = {}
for tool in tools:
eng = engine_for(tool, args.gpu)
t0 = time.time()
eng.check()
load = time.time() - t0
eng.recognize(warm[1], warm[0], "", warm[3]) # warm-up, not counted
reads = []
for name, data, truth, disp in formulas:
r = eng.recognize(data, name, "", disp)
ok = bool(r.tex) and bool(same_reading(r.tex, truth, disp, preset))
reads.append({"tex": r.tex, "seconds": r.seconds, "ok": ok, "error": r.error,
"tokens_in": r.tokens_in, "tokens_out": r.tokens_out})
print(f"{tool:8s} {r.seconds:6.2f}s {'✓' if ok else '✗'} {r.tex or r.error}", file=sys.stderr, flush=True)
secs = [x["seconds"] for x in reads]
runs[tool] = {"label": LABELS.get(tool, tool), "model": eng.model, "load_s": load, "reads": reads,
"mean_s": statistics.mean(secs), "median_s": statistics.median(secs),
"correct": sum(x["ok"] for x in reads), "errors": sum(bool(x["error"]) for x in reads),
"tokens_in": sum(x["tokens_in"] for x in reads), "tokens_out": sum(x["tokens_out"] for x in reads)}
out = Path(__file__).resolve().parents[1] / "vergleich" / args.name
out.mkdir(parents=True, exist_ok=True)
(out / "ergebnis.json").write_text(json.dumps(
{"epub": args.epub.name, "preset": preset, "truth": [f[2] for f in formulas], "runs": runs},
ensure_ascii=False, indent=1), encoding="utf-8")
n = len(formulas)
rows = "".join(
f"<tr><td>{html.escape(r['label'])}</td><td>{r['mean_s']:.2f}</td><td>{r['median_s']:.2f}</td>"
f"<td>{r['correct']}/{n} ({r['correct'] / n:.0%})</td><td>{r['load_s']:.1f}</td>"
f"<td>{r['tokens_in'] + r['tokens_out'] or '–'}</td></tr>" for r in runs.values())
parts = [f"<h1>Zeittest: {html.escape(args.epub.name[:70])}</h1>",
f"<p>{n} Formeln mit bekanntem TeX (alt-Text), jedes Tool seriell, eine Formel nach der anderen, "
"nach einem nicht gezählten Aufwärmlauf. Alle Formeln mit pdflatex gesetzt. ✓ = gleiche Zeichen an "
"gleicher Stelle wie die wahre Formel (Abstände, kursiv/aufrecht, Fettdruck egal).</p>",
"<table class=sum><tr><th>Tool</th><th>Ø s/Formel</th><th>Median s</th><th>richtig</th>"
f"<th>Laden s</th><th>Tokens</th></tr>{rows}</table>"]
texes = []
for i, (_n, _d, truth, disp) in enumerate(formulas):
texes.append((strip_delimiters(truth), disp))
texes += [(runs[t]["reads"][i]["tex"] or "\\text{–}", disp) for t in tools]
rendered = render_many(texes, preset)
per = 1 + len(tools)
for i, (name, data, truth, _disp) in enumerate(formulas):
imgs = []
for rr in rendered[per * i: per * (i + 1)]:
if rr.ok:
g = (rr.rasterize(2.0) * 255).astype(np.uint8)
imgs.append(f"<img src='{png_uri(Image.fromarray(255 - g))}'>")
else:
imgs.append("<i>nicht setzbar</i>")
body = [f"<tr><th>Original</th><td><img src='{png_uri(orig_img(data, 2))}'></td><td></td></tr>",
f"<tr><th>richtig</th><td>{imgs[0]}</td><td><code>{html.escape(truth)}</code></td></tr>"]
for k, t in enumerate(tools):
r = runs[t]["reads"][i]
mark = "<b class=ok>✓</b>" if r["ok"] else "<b class=no>✗</b>"
body.append(f"<tr><th>{mark} {html.escape(runs[t]['label'])}<br><small>{r['seconds']:.2f} s</small></th>"
f"<td>{imgs[k + 1]}</td><td><code>{html.escape(r['tex'] or r['error'] or '–')}</code></td></tr>")
parts.append(f"<section><div class=v>Formel {i + 1} · {html.escape(name)}</div><table>{''.join(body)}</table></section>")
(out / "index.html").write_text(
"<!doctype html><meta charset=utf-8><title>Zeittest</title><style>"
"body{font:14px system-ui,sans-serif;margin:16px;background:#f6f6f3}section{background:#fff;border:1px solid #ddd;"
"border-radius:6px;padding:6px 10px;margin:10px 0}.v{font-weight:600;font-size:13px;margin-bottom:4px}"
"table.sum{border-collapse:collapse;background:#fff}table.sum td,table.sum th{border:1px solid #ccc;padding:4px 10px}"
"th{text-align:left;font-weight:400;color:#555;width:210px;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>" + "".join(parts),
encoding="utf-8")
print(f"\n| Tool | Ø s/Formel | Median s | richtig (n={n}) | Laden s |")
print("|---|---|---|---|---|")
for r in runs.values():
print(f"| {r['label']} | {r['mean_s']:.2f} | {r['median_s']:.2f} | {r['correct']}/{n} | {r['load_s']:.1f} |")
if "p2t-cpu" in runs and "p2t-gpu" in runs:
same = sum(a["tex"] == b["tex"] for a, b in zip(runs["p2t-cpu"]["reads"], runs["p2t-gpu"]["reads"]))
print(f"\npix2text CPU und GPU gleicher Text: {same}/{n}")
print(out / "index.html")
return 0
if __name__ == "__main__":
sys.exit(main())