feat: 0.2.0 – Konsens Qwen = Gemma = GLM als Standard, Gehrke komplett verarbeitet

- Dritte Lesart (GLM-4.6V über OpenRouter, ohne Nachdenken) muss zustimmen;
  keine 2:1-Annahme; fehlender Schlusspunkt der dritten Lesart zählt nicht
- run in Paketen zu 50 mit Statuszeile, beide lokalen Modelle parallel,
  GLM nur für Einige; nur die übernommene Lesart wird gegen das Bild geprüft
- Bild-Veto: Ähnlichkeit >= 0,65, Schriftgrößen-Prüfung abgeschaltet
- Antworten auf 800 Tokens begrenzt; Prompt-Version und Nachdenk-Einstellung
  im Cache-Schlüssel; Verfahrensversion für Prüfergebnisse
- Behoben: Gleichungsnummern aus alten Lesarten ("(3.7)"), gleichzeitiges
  Speichern zweier Engines, Absturz bei Formeln ohne aktuelle Lesart
- apply: deutsches Dezimalkomma 3{,}6; Review-Galerie zeigt aktuelle Lesarten
- tools/vergleich.py auf Konsens-Ablauf umgestellt
- Doku: README, BEDIENUNGSANLEITUNG, neues CHANGELOG

Gehrke-Brückenkurs: 942 von 1505 Formelbildern automatisch ersetzt (63 %),
62 von 62 von Hand geprüften korrekt; Pandoc/MathML 945 von 945.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dieter Schlüter 2026-10-07 07:58:16 +02:00
commit 388024a17b
14 changed files with 586 additions and 277 deletions

View file

@ -3,38 +3,50 @@
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 das gerenderte LaTeX genauso aussieht wie das Bild. Bei Zweifeln fragt Es ersetzt eine Formel nur, wenn **drei unabhängige Bildmodelle dieselbe Formel lesen** (zwei lokale, eines
es dich in einer Review-Seite im Browser. Ohne deine Zustimmung bleibt die Formel ein Bild. in der Cloud) und das gerenderte LaTeX dem Bild grob ähnelt. Bei Zweifeln fragt es dich in einer
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,
Schriftgröße, kursiv/aufrecht und Fettdruck dürfen abweichen.
--- ---
## 1. Einrichtung (einmalig) ## 1. Einrichtung (einmalig)
```bash ```bash
sudo apt install texlive-latex-extra texlive-fonts-extra texlive-science pandoc sudo apt install texlive-latex-extra texlive-fonts-extra texlive-science pandoc dvipng
cd ~/Python_Programs/formula_img2latex cd ~/Python_Programs/formula_img2latex
pip install -r requirements.txt pip install -r requirements.txt
python3 formula_img2latex.py --version python3 formula_img2latex.py --version
``` ```
### Lokales Bildmodell starten ### Die beiden lokalen Bildmodelle starten
Die Formelerkennung macht ein Vision-Sprachmodell. Lokal läuft es mit llama.cpp auf einer Grafikkarte: Zwei Vision-Sprachmodelle lesen jede Formel, je eines pro Grafikkarte (je ~18–21 GB). In zwei Terminals:
```bash ```bash
# Modell A: Qwen3-VL auf GPU 1, Port 8011
M=~/endeavour_home/lm_studio/models/lmstudio-community/Qwen3-VL-30B-A3B-Instruct-GGUF M=~/endeavour_home/lm_studio/models/lmstudio-community/Qwen3-VL-30B-A3B-Instruct-GGUF
CUDA_VISIBLE_DEVICES=1 ~/.local/bin/llama-server \ CUDA_VISIBLE_DEVICES=1 ~/.local/bin/llama-server \
-m $M/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --mmproj $M/mmproj-Qwen3-VL-30B-A3B-Instruct-F16.gguf \ -m $M/Qwen3-VL-30B-A3B-Instruct-Q4_K_M.gguf --mmproj $M/mmproj-Qwen3-VL-30B-A3B-Instruct-F16.gguf \
--host 127.0.0.1 --port 8011 -ngl 99 -c 16384 --parallel 4 --host 127.0.0.1 --port 8011 -ngl 99 -c 16384 --parallel 4
# Modell B: Gemma 4 auf GPU 2, Port 8012
M=~/nvme2n1p7_home/huggingface/models/gemma4
CUDA_VISIBLE_DEVICES=2 ~/.local/bin/llama-server \
-m $M/gemma-4-26B-A4B-it-Q4_0.gguf --mmproj $M/mmproj-gemma-4-26B-A4B-it-BF16.gguf \
--host 127.0.0.1 --port 8012 -ngl 99 -c 16384 --parallel 4
``` ```
Der Server belegt etwa 20 GB auf GPU 1. Beenden mit Strg+C. GPU 2 ist währenddessen für Chatterbox-TTS belegt. Beenden mit Strg+C in beiden Terminals.
### Cloud-Stufe (optional) ### Dritte Lesart aus der Cloud
Formeln, die lokal nicht sicher erkannt werden, kann ein Cloud-Modell über OpenRouter versuchen. Dafür muss Die Bestätigung kommt von **GLM-4.6V** über OpenRouter (andere Modellfamilie als Qwen und Gemma). Dafür
`OPENROUTER_API_KEY` gesetzt sein. Ein Buch mit 2000 Formeln kostet mit dem Standardmodell muss `OPENROUTER_API_KEY` gesetzt sein. GLM liest nur die Formeln, bei denen Qwen und Gemma schon einig
`google/gemini-2.5-flash` deutlich unter 1 $. Ohne Cloud: `--no-cloud`. 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.
--- ---
@ -62,15 +74,26 @@ Bei pdf2md zusätzlich die Original-PDF angeben. Dann schneidet das Programm jed
python3 formula_img2latex.py run Output/buch.md --pdf buch.pdf python3 formula_img2latex.py run Output/buch.md --pdf buch.pdf
``` ```
Am Ende steht eine Übersicht, zum Beispiel: Das Programm arbeitet in Paketen zu 50 Formeln und meldet nach jedem Paket den Stand:
```text ```text
auto_accepted 412 ← sicher, wird ersetzt 100/1505 verarbeitet: automatisch ersetzt 68 (68%), Review 28, bleibt Bild/keine Formel 4
needs_review 380 ← bitte im Browser ansehen
rejected_keep_image 95 ← passt nicht, bleibt Bild
not_formula 117 ← Abbildung/Symbol
``` ```
Am Ende steht eine Übersicht je Status, beim Gehrke-Brückenkurs zum Beispiel:
```text
auto_accepted 942 ← drei Modelle einig, wird ersetzt
needs_review 478 ← bitte im Browser ansehen
not_formula 93 ← laut Modellen keine Formel
pending 539 ← Abbildungen/Symbole, nicht geprüft
```
Dauer: etwa 5 Minuten je 50 Formeln, ein Buch mit 1500 Formelbildern also rund 2,5 Stunden.
Abbrechen (Strg+C) und erneut starten ist kein Problem: Bereits gelesene Formeln werden nicht noch einmal
gefragt, der Lauf setzt fort. `--chunk 100` ändert die Paketgröße, `--limit 200` verarbeitet nur die ersten
200 Bilder.
Neben dem Buchordner entsteht ein Arbeitsordner `buch.formulas/`. Er enthält alle Ergebnisse. Lösch ihn nicht, Neben dem Buchordner entsteht ein Arbeitsordner `buch.formulas/`. Er enthält alle Ergebnisse. Lösch ihn nicht,
solange du am Buch arbeitest. solange du am Buch arbeitest.
@ -86,7 +109,8 @@ Der Browser öffnet `http://127.0.0.1:8765/`. Für jede Formel siehst du:
- **Overlay:** Original und LaTeX übereinander. Mit dem Regler blendest du zwischen beiden. Springt etwas, - **Overlay:** Original und LaTeX übereinander. Mit dem Regler blendest du zwischen beiden. Springt etwas,
stimmt etwas nicht (Exponent, Vorzeichen, Klammer). stimmt etwas nicht (Exponent, Vorzeichen, Klammer).
- **Diff:** schwarz = beide gleich, rot = nur im Original, blau = nur im LaTeX, gelb = deutliche Abweichung. - **Diff:** schwarz = beide gleich, rot = nur im Original, blau = nur im LaTeX, gelb = deutliche Abweichung.
- **LaTeX:** Text im Eingabefeld, darunter eventuell weitere Kandidaten zum Anklicken. - **LaTeX:** Text im Eingabefeld (die Lesart von Qwen), darunter die Lesarten der anderen Modelle zum
Anklicken. Oben steht der Grund für das Review, z. B. „Modelle uneinig“ mit den abweichenden Lesarten.
Schaltflächen: Schaltflächen:
@ -118,7 +142,16 @@ cd ~/Python_Programs/epub2md
python3 epub2md_merge.py output/buch --force python3 epub2md_merge.py output/buch --force
``` ```
Für EPUB und PDF mit Formeln gelten die Pandoc-Befehle aus der epub2md-Anleitung mit `--mathml`. Für EPUB und PDF mit Formeln gelten die Pandoc-Befehle aus der epub2md-Anleitung mit `--mathml`, z. B.:
```bash
cd ~/Python_Programs/epub2md/output/buch
P=~/Python_Programs/epub2md/pandoc
pandoc buch.md --toc --mathml --lua-filter $P/callouts.lua -o buch.epub
```
In deutschen Büchern schreibt `apply` Dezimalzahlen als `3{,}6`, sonst setzt LaTeX „3, 6“. Eine
Gleichungsnummer steht als Text hinter der Formel, wie bei epub2md.
### Rückgängig machen ### Rückgängig machen
@ -160,14 +193,27 @@ Nützliche Optionen:
**Das Buch wurde mit epub2md `--force` neu umgewandelt. Ist meine Arbeit weg?** **Das Buch wurde mit epub2md `--force` neu umgewandelt. Ist meine Arbeit weg?**
Nein. Die Entscheidungen hängen am Inhalt der Bilder, nicht an Dateinamen. `scan`, dann `apply`. Nein. Die Entscheidungen hängen am Inhalt der Bilder, nicht an Dateinamen. `scan`, dann `apply`.
**Warum landet so viel im Review?** **Warum landet ein Teil im Review?**
Das Programm ersetzt nur, was es beweisen kann. Häufige Gründe: Das Bild ist sehr klein (Ziffern unter Ersetzt wird nur, worin sich alle drei Modelle einig sind. Häufige Gründe für Review: mehrzeilige Rechnungen
8 Pixeln). Das Buch wurde mit einem anderen Satzsystem gesetzt. Oder die Erkennung schreibt eine Formel mit Ausrichtung, `\underbrace` oder Zeilenumbrüchen (die Modelle schreiben sie unterschiedlich), sehr
typografisch anders (`\vec` statt langem Pfeil). Im Review genügt dann meist ein Blick auf das Overlay. kleine oder unscharfe Bilder, oder ein Modell liest ein Zeichen anders. Im Review stehen die Lesarten
nebeneinander; oft genügt ein Blick und „LaTeX übernehmen“.
**Wie sicher ist „automatisch ersetzt“?**
Gemessen an 300 Formeln mit bekannter Lösung: keine einzige falsche Formel wurde angenommen (Fehlerquote
sehr wahrscheinlich unter 2 %). Am Gehrke-Brückenkurs wurden 62 automatisch ersetzte Formeln von Hand
geprüft, alle korrekt. Fettdruck wird bewusst nicht geprüft: Ein fetter Vektor **v** kann als normales v
übernommen werden.
**Ich will erst ein paar Formeln ansehen, bevor das ganze Buch läuft.**
`python3 tools/vergleich.py AUSGABE` verarbeitet eine feste Stichprobe von 50 Formeln genau wie `run`
und schreibt `vergleich/<name>/index.html`: Originalbild, die gerenderten Lesarten aller Modelle, die
Entscheidung mit Grund und den Bildabgleich. Der Ordner `vergleich/` wird nicht ins Git übernommen.
**Geht die blaue Hinterlegung mancher Formeln verloren?** **Geht die blaue Hinterlegung mancher Formeln verloren?**
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 mit der Cloud-Stufe, und nur die einzelnen Formelbilder samt eines kurzen Textausschnitts davor. Mit Nur für die dritte Lesart: die einzelnen Formelbilder, bei denen Qwen und Gemma einig sind, samt eines
`--no-cloud` bleibt alles lokal. kurzen Textausschnitts um die Formel (höchstens 240 Zeichen), gehen an OpenRouter/GLM. Mit `--no-cloud` bleibt alles lokal; dann wird nichts automatisch
ersetzt.

35
CHANGELOG.md Normal file
View file

@ -0,0 +1,35 @@
# Changelog
## 0.2.0 – 2026-10-07
Entscheider ist jetzt der **Konsens dreier Bildmodelle** statt eines Pixelvergleichs.
- **Annahmeregel:** jedes Zeichen richtig und an der richtigen Stelle; Abstände, Längen, Schriftgröße,
kursiv/aufrecht und Fettdruck egal.
- **Konsens:** Qwen3-VL-30B-A3B (lokal) = Gemma-4-26B-A4B (lokal) = GLM-4.6V (OpenRouter, andere
Modellfamilie); Pandoc muss die Formel umsetzen; grobes Bild-Veto (elastischer Vergleich,
Ähnlichkeit ≥ 0,65). Bei 2:1 entscheidet ein Mensch.
- **`consensus.py`:** Normalform auf Baumbasis (Abstände, `\left`/`\right`, freistehende Klammern,
`\vec`/`\overrightarrow`, `\frac`/`\dfrac`, `\iff`/`\Leftrightarrow`, Matrix-Umgebungen, TeX-Umlaute,
`\atop`/`\substack`, kursiv/aufrecht, fett/normal) und Render-Gleichheit als Rückfallebene.
- **Engines:** zweite lokale Engine `local2`; Token-Wahrscheinlichkeiten aus Logprobs; Nachdenken
abgeschaltet (Gemma ~10× schneller, GLM besser und ~25× billiger); Antworten auf 800 Tokens begrenzt;
Prompt fragt ausdrücklich nach Fettdruck; Prompt-Version und Nachdenk-Einstellung im Cache-Schlüssel.
- **`run`:** in Paketen zu 50 mit Statuszeile; beide lokalen Modelle parallel; GLM nur für Einige;
geprüft wird nur die übernommene Lesart; Prüfergebnisse mit Verfahrensversion.
- **Bildvergleich:** elastische Ausrichtung (DTW über Spalten und Zeilen, Dehnen kostet proportional zur
Tinte); layouttreue Prüfvarianten und rauschnormierter Kontrast im Pixel-Modus (`decide=pixel`).
- **apply:** deutsches Dezimalkomma `3{,}6`.
- **Behoben:** Gleichungsnummern wurden aus alten, verworfenen Lesarten übernommen (erfundene „(3.7)“);
sie kommen jetzt nur aus der übernommenen Lesart. Gleichzeitige Speicherungen zweier Engines sind
abgesichert. Verify stürzte bei Formeln ohne aktuelle Lesart ab.
- **Werkzeuge:** `calibrate --consensus`, `tools/vergleich.py` (50er-Stichprobe mit Bildern),
`tools/konsens_bericht.py`; Ausgaben in `vergleich/` (nicht versioniert).
- **Messung** (2 × 150 Formeln mit bekanntem TeX): 63–68 % automatisch, 0 falsch.
Gehrke-Brückenkurs: 942 von 1505 automatisch (63 %), 62 von 62 geprüften korrekt.
## 0.1.0 – 2026-10-07
Erste Fassung: scan, recognize (ein Vision-Modell), verify mit Pixelvergleich und Kontrasttest gegen
Verwechslungsvarianten, Review-Galerie, apply/revert, calibrate. Sicher (0 Falsch-Annahmen), aber an
echten Büchern nur 1–2 % automatisch.

228
README.md
View file

@ -3,27 +3,75 @@
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 das gerenderte LaTeX nachweislich so aussieht wie das Original**. LaTeX ersetzt – **aber nur, wenn drei unabhängige Bildmodelle dieselbe Formel lesen** und ein grober
Zweifelsfälle gehen in eine Review-Galerie, alles andere bleibt Bild. Bildabgleich nicht widerspricht. Alles andere geht in eine Review-Galerie oder bleibt Bild.
Schritt-für-Schritt-Anleitung: [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). Hintergrund und Schritt-für-Schritt-Anleitung: [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). Änderungen je Version:
ursprüngliche Planung: `Idee/`. [CHANGELOG.md](CHANGELOG.md). Hintergrund und ursprüngliche Planung: `Idee/`.
> **Stand 0.1.0 (Prototyp).** Der Ablauf ist vollständig (scan → erkennen → prüfen → Review → apply/revert) ## Annahmeregel
> und getestet. Die Annahmeschwellen sind noch nicht endgültig kalibriert; an echten Büchern landet
> derzeit der größte Teil im Review (siehe [Stand und Grenzen](#stand-und-grenzen)). > 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.
Eine Formel wird automatisch ersetzt, wenn
1. **Qwen3-VL-30B-A3B** (lokal, llama.cpp) und **Gemma-4-26B-A4B** (lokal) dieselbe Formel lesen,
2. **GLM-4.6V** (Cloud über OpenRouter, andere Modellfamilie) das bestätigt – ein fehlender Schlusspunkt
zählt dabei nicht,
3. Pandoc sie nach MathML umsetzen kann (sonst bliebe sie im EPUB/PDF roher TeX-Text) und
4. das gerenderte LaTeX dem Original grob ähnelt (Bild-Veto, elastischer Vergleich, Ähnlichkeit ≥ 0,65).
„Dieselbe Formel“ prüft `consensus.py`: gleiche Normalform (Abstände, `\left`/`\right`, überflüssige
Klammern, `\vec`/`\overrightarrow`, `\frac`/`\dfrac`, `\iff`/`\Leftrightarrow`, `pmatrix`/`\left(\begin{matrix}`,
TeX-Umlaute, `\atop`/`\substack`, kursiv/aufrecht, fett/normal …) oder – als Rückfallebene – zeichengleiches
Rendering der beiden Lesarten (Render gegen Render, nicht gegen das verrauschte Original). Eine
Gleichungsnummer muss in allen Lesarten übereinstimmen und wird als Text hinter die Formel gesetzt.
Sind sich die Modelle uneinig oder widerspricht das Veto, entscheidet ein Mensch im Review.
## Messung
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):
| Regel | echt: automatisch / davon falsch | Gehrke-Stil: automatisch / davon falsch |
| --- | --- | --- |
| Qwen = Gemma | 73 % / 1 | 69 % / 2 |
| Qwen = Gemma = Gemini 2.5 Flash | 67 % / 0 | 57 % / 1 |
| **Qwen = Gemma = GLM-4.6V** ohne Nachdenken (Standard) | **68 % / 0** | **63 % / 0** |
- Fehler, bei denen zwei Modelle gemeinsam irren, gibt es: `\mathcal{E}_r` statt `\varepsilon_r`, ein
erfundenes `\tilde{z}`, `x^{f}` statt `x'`. Die dritte Lesart aus einer **anderen Modellfamilie** fängt
sie ab; Gemini (mit Gemma verwandt) machte einen davon mit.
- Selbstauskünfte der Modelle („95 % sicher“) sind wertlos. Token-Wahrscheinlichkeiten (Logprobs) werden
gespeichert, brachten aber neben der dritten Lesart keinen Gewinn (Schwellen bleiben einstellbar).
- „Nachdenken“ (Reasoning) ist für alle Modelle abgeschaltet: Gemma 4 wird ~10× schneller, GLM-4.6V liest
ohne Nachdenken sogar besser (85 % statt 79 % allein richtig) und braucht ~25× weniger Tokens.
- Fettdruck lesen alle Modelle schlecht (fette Vektoren werden zu normalen Buchstaben); nach Vorgabe zählt
er nicht. Mit `bold_relevant=1` würden diese Fälle ins Review gehen.
- Das Bild-Veto fing den einzigen Fall ab, in dem alle drei Modelle gemeinsam irrten (Ähnlichkeit
0,5–0,6); richtige Lesarten lagen bei ≥ 0,66. Daher die Schwelle 0,65. Eine Prüfung der Schriftgröße
(Pixel pro Punkt gegen das Buchmittel) erzeugte nur Fehlalarme und ist abgeschaltet.
- Die Messung lief **ohne** Textausschnitt um die Formel; `run` gibt den Modellen einen kurzen
Textausschnitt (≤ 240 Zeichen) mit. Die Stichproben am Buch (62 von 62 korrekt) sprechen nicht gegen
den Kontext, gemessen ist er aber nicht.
Gehrke, Brückenkurs Mathematik (EPUB, 1505 Formelbilder): siehe [Stand](#stand).
## Ablauf ## Ablauf
```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
recognize LaTeX-Kandidaten von einem Vision-Modell holen: lokal (llama.cpp) zuerst, Cloud (OpenRouter) nur für Ungelöste run in Paketen zu 50: Qwen + Gemma lesen (parallel), prüfen, GLM nur für Einige, prüfen;
verify Kandidat rendern (pdflatex), mit dem Original vergleichen, Verwechslungsvarianten prüfen, entscheiden nach jedem Paket eine Statuszeile
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
run scan + recognize lokal + verify + recognize Cloud (nur Ungelöste) + verify recognize einzelne Engine (local, local2, cloud) laufen lassen
calibrate Messung an Formelbildern mit bekanntem TeX (Springer-EPUBs: TeX im alt-Text) verify prüfen und entscheiden (ohne neue Modellabfragen)
status Zählung je Status
calibrate Messung an Formelbildern mit bekanntem TeX (--consensus: Konsensregel)
``` ```
```bash ```bash
@ -33,104 +81,121 @@ python3 formula_img2latex.py apply output/buch
python3 epub2md_merge.py output/buch --force # Gesamtdatei danach neu bauen python3 epub2md_merge.py output/buch --force # Gesamtdatei danach neu bauen
``` ```
## Wie geprüft wird
Die OCR liefert nur einen **Kandidaten**. Entschieden wird über das Bild:
1. **Rendern** mit pdflatex (Paket `preview`, viele Formeln je Lauf, `-no-shell-escape`,
`openout_any=p`). Unsichere Befehle (`\input`, `\write`, `\def`, `\catcode`, …) werden vorher abgelehnt.
Die Schrift wird je Buch automatisch gewählt: `cm`, `times`, `txfonts`, `palatino`, `stix`, `euler`.
2. **Vergleichen** in der Auflösung des Originals: Tinte normalisieren (Transparenz, JPEG-Rauschen,
getönter Hintergrund), gleichmäßig skalieren (nie strecken), auf ¼ Pixel ausrichten.
Kennzahlen: Korrelation (NCC), SSIM, Abweichungsflecken, Größenabweichung, **Pixel pro Punkt**.
Pixel pro Punkt ist innerhalb eines Buches konstant; Abweichungen verraten falsche Stile
(`\frac` statt `\tfrac`, `\displaystyle`).
3. **Kontrasttest gegen Verwechslungsvarianten** (`confusables.py`). Erzeugt werden ~50–160 Varianten,
die sich durch je einen typischen Erkennungsfehler unterscheiden: andere Ziffer, hoch statt tief,
`,`/`;`, `-`/`+`/`=`, ähnliche Buchstaben, Groß/klein, `\mathcal`/`\mathbb`/…, fehlendes Vorzeichen, Strich
oder Akzent. Varianten werden **layouttreu** gerendert (`\mathrlap{neu}\mathbin{\phantom{alt}}`), damit
sich nur das eine Zeichen ändert. Verglichen wird nur dort, wo sich Kandidat und Variante unterscheiden
(Likelihood-Test). Der Kandidat muss **jede** unterscheidbare Variante mit Abstand schlagen.
4. **Selbstkorrektur:** Erklärt eine Variante das Bild deutlich besser, wird sie neuer Kandidat
(höchstens 3 Schritte). So werden einzelne OCR-Fehler ohne erneute Modellanfrage repariert.
5. **Pandoc-Prüfung:** Jede angenommene Formel muss mit `pandoc --mathml` umsetzbar sein, sonst
bliebe sie im späteren EPUB/PDF roher TeX-Text.
Status je Formel: `auto_accepted`, `needs_review`, `rejected_keep_image`, `render_failed`, `no_candidate`, Status je Formel: `auto_accepted`, `needs_review`, `rejected_keep_image`, `render_failed`, `no_candidate`,
`not_formula`, nach Review `accepted_by_human`, `corrected_by_human`, `kept_by_human`. `not_formula`, nach Review `accepted_by_human`, `corrected_by_human`, `kept_by_human`.
## Modelle und Server
| Engine | Standard | Konfiguration |
| --- | --- | --- |
| `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` |
| `cloud` | OpenRouter, `z-ai/glm-4.6v` | `OPENROUTER_API_KEY`, `FORMULA_CLOUD_MODEL` |
`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
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.
Kosten der Cloud-Stufe: GLM liest nur Formeln, bei denen Qwen und Gemma einig sind. Gehrke: 967
Anfragen, Ø 2,6 s, zusammen rund 0,15–0,20 $.
## Bildvergleich (Veto)
`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**
(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
Lesart, die übernommen würde (die von Qwen; im Konsens ist sie gleichwertig zu den anderen) – eine
später eintreffende dritte Lesart löst keine neue Prüfung aus. Die Prüfergebnisse tragen eine
Versionsnummer des Verfahrens und werden nach Änderungen neu berechnet. Der alte Pixel-Entscheider mit
Verwechslungsvarianten und Kontrasttest bleibt als Modus `decide=pixel` erhalten.
Sicherheit: Kandidaten kommen von Modellen und gelten als unsicher. Vor pdflatex werden Befehle wie
`\input`, `\write`, `\def`, `\catcode` abgelehnt; pdflatex läuft mit `-no-shell-escape`,
`openout_any=p`, Zeitlimit.
## Arbeitsordner ## Arbeitsordner
```text ```text
<ausgabe>.formulas/ # neben der Ausgabe, weil epub2md --force die Ausgabe ersetzt <ausgabe>.formulas/ # neben der Ausgabe, weil epub2md --force die Ausgabe ersetzt
├── workspace.json # Quelle, Schrift, Pixel/Punkt, Schwellen, apply-Zustand ├── workspace.json # Quelle, Schrift, Einstellungen, apply-Zustand
├── formulas/f-<sha12>-<d|i>.json # je Bild und Darstellung: Kandidaten, Prüfung, Entscheidung ├── formulas/f-<sha12>-<d|i>.json # je Bild und Darstellung: Lesarten, Prüfung, Entscheidung
├── review/<id>-{orig,render,diff}.png ├── review/<id>-{orig,render,rigid,diff}.png
└── backup/ # Markdown-Dateien vor dem ersten apply └── backup/ # Markdown-Dateien vor dem ersten apply
``` ```
Die Datensätze sind über den SHA-256 des Bildes verknüpft. Ein erneuter epub2md-Lauf mit `--force` verliert Die Datensätze hängen am SHA-256 des Bildes: Ein erneuter epub2md-Lauf verliert keine Entscheidungen.
deshalb keine Entscheidungen. `scan` und `apply` spielen sie ohne neue OCR wieder ein. Modellantworten
werden je Modell gespeichert; dasselbe Modell wird nie zweimal nach demselben Bild gefragt. Einstellungen in `workspace.json` → `settings.consensus_thresholds`: `require_third` (1),
`bold_relevant` (0), `veto_ncc` (0,65), `veto_scale_dev` (0 = aus), `min_p_mean`/`min_p_min` (0).
## Quellen ## Quellen
| Quelle | Erkennung | Begrenzer | | Quelle | Erkennung | Begrenzer |
| --- | --- | --- | | --- | --- | --- |
| epub2md-Ordner | `conversion-report.json` (`tool: epub2md`); Warnungen `formula-image` markieren sichere Formeln | `--math auto`: `$…$`/`$$…$$`, `--math tex`: `\(…\)`/`\[…\]` | | epub2md-Ordner | `conversion-report.json`; Warnungen `formula-image` markieren sichere Formeln | `--math auto`: `$…$`/`$$…$$`, `--math tex`: `\(…\)`/`\[…\]`; deutsche Bücher: `3{,}6` |
| pdf2md-Datei `<name>.md` + `<name>_images/` oder `--split`-Ordner | Bildname `<pdf>-<seite>-<box>`; mit `--pdf` wird jede Formel per Template-Matching auf der Seite gefunden, mit 600 dpi aus dem Vektor-PDF neu ausgeschnitten und an Mathe-Schriften (CMMI, EURM, …) als Formel erkannt | `$…$`/`$$…$$` | | pdf2md (`<name>.md` + `<name>_images/` oder `--split`-Ordner) | Bildname `<pdf>-<seite>-<box>`; mit `--pdf` per Template-Matching auf der Seite gefunden und mit 600 dpi aus dem Vektor-PDF neu ausgeschnitten | `$…$`/`$$…$$` |
Abgesetzte Bildformeln gelten als Display-Mathematik, Bilder im Fließtext als Inline-Mathematik.
Codeblöcke werden nie verändert. Inline-Formeln, die pdf2md als Glyphensalat im Fließtext ausgibt, Codeblöcke werden nie verändert. Inline-Formeln, die pdf2md als Glyphensalat im Fließtext ausgibt,
sind nicht Teil dieser Version. sind nicht Teil dieser Version.
## Engines ## Werkzeuge zum Einstellen
Jede Engine spricht die OpenAI-Chat-API mit Bildteil: - `tools/vergleich.py AUSGABE` – feste Stichprobe (50 Formeln), verarbeitet wie `run`, schreibt
`vergleich/<name>/index.html` mit Original, gerenderten Lesarten, Entscheidung und Veto-Overlay.
- `tools/konsens_bericht.py BERICHT.json EPUB` – Übersichtsseite zu `calibrate --consensus --report`.
- `vergleich/` ist nicht versioniert (Ausschnitte aus Büchern).
| `--engine` | Standard | Konfiguration | ## Stand
| --- | --- | --- |
| `local` | llama.cpp-Server `http://127.0.0.1:8011/v1`, Modell vom Server | `FORMULA_LOCAL_URL`, `FORMULA_LOCAL_MODEL`, `FORMULA_LOCAL_API_KEY` |
| `cloud` | OpenRouter, `google/gemini-2.5-flash` | `OPENROUTER_API_KEY`, `FORMULA_CLOUD_MODEL`, `FORMULA_CLOUD_URL` |
Getestet: **Qwen3-VL-30B-A3B-Instruct** (Q4_K_M + mmproj) auf einer RTX 3090 mit 0,2–0,5 s je Formel; Version 0.2.0 (Konsens), getestet am Gehrke, Brückenkurs Mathematik (EPUB, CM-Formeln als kleine JPGs):
Gehrke Brückenkurs (1329 Bilder) in 9 Minuten.
## Stand und Grenzen | | Anzahl |
| --- | --- |
| Formelbilder verarbeitet (sicher oder unklar klassifiziert) | 1505 |
| **automatisch ersetzt** | **942 (63 %)** |
| Review: Qwen und Gemma uneinig | 410 |
| Review: GLM weicht ab | 65 |
| Review: Bild-Veto / Pandoc | 2 / 1 |
| keine Formel (laut Modellen) | 85 |
| nicht geprüft (Abbildungen, Symbole) | 539 |
Messungen vom 07.10.2026 (Details: `calibrate`): - **Von Hand geprüft:** die 42 automatisch ersetzten Formeln der festen 50er-Stichprobe und 20 zufällig
gezogene weitere – **62 von 62 Zeichen für Zeichen korrekt** (Brüche, Wurzeln mit Index, `cases`,
Grenzwerte, Integrale, Vektorpfeile, Überstriche über mehreren Buchstaben).
- **apply** an einer Kopie: 946 Ersetzungen in 258 Kapiteldateien (manche Formeln kommen mehrfach vor).
`epub2md_merge.py` → 945 Formeln in der Gesamtdatei (eine stand in einer doppelten Fußnote, die das
Zusammenfügen entfernt). Pandoc `--mathml`: **945 von 945** als MathML, keine „Could not convert TeX
math“-Meldung. EPUB daraus in 10 s gebaut.
- **Laufzeit** mit beiden lokalen Modellen parallel (zwei RTX 3090) und GLM: ~5 Minuten je 50 Formeln,
das Buch also rund 2,5 Stunden.
- **Synthetisch** (Computer Modern, ~1,4 px/pt, JPEG – wie Gehrke): Die korrekte Formel schlägt in Grenzen und offene Punkte:
98 % der Fälle alle Verwechslungsvarianten; ein einzelner falscher Startkandidat wird meist repariert.
- **Gehrke Brückenkurs** (EPUB, ~1400 Formel-JPGs, CM): Die OCR ist gut. Abgelehnt oder ins Review - Mehrzeilige Rechnungen mit Ausrichtung, `\underbrace` oder Zeilenumbrüchen schreiben die Modelle
geschickt wird überwiegend wegen **typografischer** Abweichungen der OCR, z. B. unterschiedlich – sie bilden den Großteil des Reviews.
`\xrightarrow{…}` statt `\stackrel{…}{\longrightarrow}`, `\vec` statt `\overrightarrow`, Text neben statt
über `=`. Bei einer Pfeillänge verrutscht alles dahinter. Abhilfe in Arbeit: Korrekturrunde, in der das
Modell Original und eigenes Render nebeneinander sieht.
- **Springer-Bücher** (Liesen, Göllmann): Was im alt-Text kein TeX hat, sind meist komplexe mehrzeilige
Formeln eines fremden Satzsystems. Abstände und Klammergrößen weichen ab, der starre Vergleich bestätigt
sie nicht → Review.
- **PDF** (Hefferon, Euler-Schrift): Einfache Formeln passen mit `euler` sehr gut (NCC 0,96). Wo Ziffern
oder Operatornamen in anderen Schnitten gesetzt sind (serifenloses `sgn`), wird der Kontrasttest
unzuverlässig → Review.
- Formeln auf farbig hinterlegten Kästen verlieren beim Ersetzen die Hinterlegung. - Formeln auf farbig hinterlegten Kästen verlieren beim Ersetzen die Hinterlegung.
- Fettdruck wird nicht geprüft (Vorgabe).
Nächste Schritte: Schwellen mit `calibrate` an den layouttreuen Varianten neu festlegen; Korrekturrunde - Die Messung umfasst 2 × 150 Formeln eines Buches ohne Textausschnitt; „0 Fehler“ bedeutet eine
mit Diff-Bild; abschnittsweiser Vergleich (Zeilen/Spalten getrennt ausrichten) für mehrzeilige Formeln Fehlerquote sehr wahrscheinlich unter ~2 %.
fremder Satzsysteme; Inline-Formeln in PDFs. - pdf2md-Bücher: der Ablauf ist umgesetzt (600-dpi-Neuausschnitt, Euler-Schrift für Hefferon), aber im
Konsens-Modus noch nicht an einem ganzen Buch getestet.
## Installation ## Installation
```bash ```bash
sudo apt install texlive-latex-extra texlive-fonts-extra texlive-science pandoc # pdflatex, Schriften, Pandoc sudo apt install texlive-latex-extra texlive-fonts-extra texlive-science pandoc dvipng
pip install -r requirements.txt # pymupdf, numpy, scipy, scikit-image, opencv, Pillow, lxml, requests pip install -r requirements.txt # pymupdf, numpy, scipy, scikit-image, opencv, Pillow, lxml, requests
pip install -r requirements-dev.txt # pytest pip install -r requirements-dev.txt # pytest
``` ```
Modelle: siehe BEDIENUNGSANLEITUNG (llama-server starten).
## Tests ## Tests
```bash ```bash
python3 -m pytest -q # ca. 30 s; synthetische Bilder, kein Modellserver, keine Buchinhalte python3 -m pytest -q # ca. 45 s; synthetische Bilder, kein Modellserver, keine Buchinhalte
``` ```
Rückgabecodes: `0` Erfolg, `1` Bedienfehler, `2` Eingabe ist keine epub2md-/pdf2md-Ausgabe, Rückgabecodes: `0` Erfolg, `1` Bedienfehler, `2` Eingabe ist keine epub2md-/pdf2md-Ausgabe,
@ -141,17 +206,18 @@ Rückgabecodes: `0` Erfolg, `1` Bedienfehler, `2` Eingabe ist keine epub2md-/pdf
```text ```text
formula_img2latex/ formula_img2latex/
├── cli.py Unterbefehle, Exit-Codes ├── cli.py Unterbefehle, Exit-Codes
├── pipeline.py scan, recognize, verify (Prozess-Pool), Entscheidung, apply/revert ├── pipeline.py scan, recognize, verify, Entscheidung (Konsens/Pixel), run in Paketen, apply/revert
├── sources/ epub2md.py, pdf2md.py, base.py (Markdown-Scan, Begrenzer) ├── consensus.py Normalform, Render-Gleichheit: sagen zwei Lesarten dasselbe?
├── pdfcrop.py Formelbild auf der PDF-Seite finden, 600-dpi-Neuausschnitt, Mathe-Schrift-Erkennung ├── decide.py Annahmeregeln und Schwellen (Konsens- und Pixel-Modus)
├── classify.py Formel / unklar / Abbildung (Farbe der Tinte, Graustufen, Größe, Kontext) ├── engines/ Vision-Modelle über die OpenAI-API (llama.cpp, OpenRouter), Logprobs, Prompt-Version
├── engines/ Vision-Modelle über die OpenAI-API (llama.cpp, OpenRouter) ├── compare.py Ausrichtung, elastische Verzerrung, Kennzahlen, Kontrasttest, Diff-Bilder
├── confusables.py Verwechslungsvarianten (Pixel-Modus)
├── verify.py Bildprüfung einer Lesart (Veto bzw. Pixel-Entscheider)
├── render.py pdflatex-Batch, Schrift-Presets, Rasterung ├── render.py pdflatex-Batch, Schrift-Presets, Rasterung
├── imageio.py Originale laden (PNG/GIF/JPG/SVG, Transparenz, Tönung) ├── imageio.py Originale laden (PNG/GIF/JPG/SVG, Transparenz, Tönung)
├── compare.py Ausrichtung, Kennzahlen, Kontrasttest, Diff-Bilder ├── sources/ epub2md.py, pdf2md.py, base.py (Markdown-Scan, Begrenzer, Dezimalkomma)
├── confusables.py Verwechslungsvarianten, layouttreue Prüfvarianten ├── pdfcrop.py Formelbild auf der PDF-Seite finden, 600-dpi-Neuausschnitt
├── verify.py Prüfung + Selbstkorrektur ├── classify.py Formel / unklar / Abbildung
├── decide.py Schwellen und Status
├── checks.py Pandoc/MathML-Prüfung ├── checks.py Pandoc/MathML-Prüfung
├── calibrate.py Messung an Formelbildern mit bekanntem TeX ├── calibrate.py Messung an Formelbildern mit bekanntem TeX
├── store.py Arbeitsordner, Datensätze ├── store.py Arbeitsordner, Datensätze

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.1.0" __version__ = "0.2.0"

View file

@ -73,7 +73,7 @@ def build_parser() -> argparse.ArgumentParser:
sp = sub.add_parser("recognize", help="LaTeX-Kandidaten mit einem Vision-Modell erzeugen") sp = sub.add_parser("recognize", help="LaTeX-Kandidaten mit einem Vision-Modell erzeugen")
common(sp) common(sp)
recog_opts(sp) recog_opts(sp)
sp.add_argument("--engine", choices=["local", "cloud"], default="local") sp.add_argument("--engine", choices=["local", "local2", "cloud"], default="local")
sp.add_argument("--only-unresolved", action="store_true", sp.add_argument("--only-unresolved", action="store_true",
help="nur Formeln, die noch nicht automatisch angenommen sind") help="nur Formeln, die noch nicht automatisch angenommen sind")
@ -93,6 +93,7 @@ def build_parser() -> argparse.ArgumentParser:
sp.add_argument("--no-local", action="store_true", help="lokale Stufe überspringen") sp.add_argument("--no-local", action="store_true", help="lokale Stufe überspringen")
sp.add_argument("--no-cloud", action="store_true", help="Cloud-Stufe überspringen") sp.add_argument("--no-cloud", action="store_true", help="Cloud-Stufe überspringen")
sp.add_argument("--preset", choices=["auto", *PRESETS], default="auto") sp.add_argument("--preset", choices=["auto", *PRESETS], default="auto")
sp.add_argument("--chunk", type=int, default=50, help="Paketgröße; nach jedem Paket eine Statuszeile (Standard 50)")
sp = sub.add_parser("review", help="Review-Galerie im Browser (localhost)") sp = sub.add_parser("review", help="Review-Galerie im Browser (localhost)")
common(sp) common(sp)
@ -185,6 +186,12 @@ def _dispatch(args: argparse.Namespace) -> int:
elif args.command == "verify": elif args.command == "verify":
pipeline.verify_all(source, ws, None if args.preset == "auto" else args.preset, args.include_figures, pipeline.verify_all(source, ws, None if args.preset == "auto" else args.preset, args.include_figures,
args.workers, args.redo, q) args.workers, args.redo, q)
elif args.command == "run" and pipeline.decide_mode(ws) == "consensus":
pipeline.scan(source, ws, quiet=q)
pipeline.run_consensus(source, ws, chunk=args.chunk, preset=None if args.preset == "auto" else args.preset,
include_figures=args.include_figures, limit=args.limit, cloud_model=args.cloud_model,
use_cloud=not args.no_cloud, quiet=q)
_status(ws, q, False)
elif args.command == "run": elif args.command == "run":
preset = None if args.preset == "auto" else args.preset preset = None if args.preset == "auto" else args.preset
pipeline.scan(source, ws, quiet=q) pipeline.scan(source, ws, quiet=q)

View file

@ -67,12 +67,14 @@ def decide(ver: dict[str, Any], pandoc_ok: bool, book_px_per_pt: float | None,
# --------------------------------------------------------------------------- consensus mode # --------------------------------------------------------------------------- consensus mode
CONSENSUS_DEFAULTS: dict[str, float] = { CONSENSUS_DEFAULTS: dict[str, float] = {
"min_p_mean": 0.90, # geometric mean of the LaTeX token probabilities, each reading "min_p_mean": 0.0, # geometric mean of the LaTeX token probabilities (measured: no gain over
"min_p_min": 0.30, # least certain LaTeX token, each reading "min_p_min": 0.0, # the third reading, only less coverage; kept configurable)
"veto_ncc": 0.75, # rough image check: the agreed reading must resemble the original "veto_ncc": 0.65, # rough image check: the agreed reading must resemble the original. Measured:
"veto_scale_dev": 0.15, # and have the book's font size (px/pt) within 15 % # the only error all three models shared had 0.5-0.6; correct ones >= 0.66
"veto_scale_dev": 0.0, # > 0: font size (px/pt) within this share of the book median (off: false
# alarms on Gehrke, never measured to catch anything)
"bold_relevant": 0, # 1: bold vs. normal counts as a different symbol "bold_relevant": 0, # 1: bold vs. normal counts as a different symbol
"require_third": 0, # 1: a third reading (e.g. cloud) must agree as well "require_third": 1, # a third reading (cloud, other model family) must agree as well
} }
@ -87,6 +89,8 @@ def consensus_thresholds(overrides: dict[str, Any] | None = None) -> dict[str, f
def _sure(r: dict[str, Any], th: dict[str, float]) -> str | None: def _sure(r: dict[str, Any], th: dict[str, float]) -> str | None:
"""None if the reading is confident enough, else a German reason.""" """None if the reading is confident enough, else a German reason."""
pm, pn = r.get("p_mean"), r.get("p_min") pm, pn = r.get("p_mean"), r.get("p_min")
if th["min_p_mean"] <= 0 and th["min_p_min"] <= 0:
return None
if pm is None or pn is None: if pm is None or pn is None:
return f"{r.get('model', '?')}: keine Token-Wahrscheinlichkeiten" return f"{r.get('model', '?')}: keine Token-Wahrscheinlichkeiten"
if pm < th["min_p_mean"] or pn < th["min_p_min"]: if pm < th["min_p_mean"] or pn < th["min_p_min"]:
@ -115,7 +119,7 @@ def decide_consensus(readings: list[dict[str, Any]], agree: list[tuple[int, int,
pair = (i, j, how) pair = (i, j, how)
break break
tie = "" tie = ""
if pair is None: if pair is None and not th.get("require_third"):
for i, j, how in agree: # tie-break: a third reading agrees with one of the two for i, j, how in agree: # tie-break: a third reading agrees with one of the two
if 2 in (i, j) or i >= 2 or j >= 2: if 2 in (i, j) or i >= 2 or j >= 2:
pair, tie = (i, j, how), " (Stichwahl)" pair, tie = (i, j, how), " (Stichwahl)"
@ -132,7 +136,7 @@ def decide_consensus(readings: list[dict[str, Any]], agree: list[tuple[int, int,
texts = " | ".join(f"{r.get('model', '?')}: {r.get('tex') or '–'}" for r in readings[2:3]) texts = " | ".join(f"{r.get('model', '?')}: {r.get('tex') or '–'}" for r in readings[2:3])
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 = i if (readings[i].get("p_mean") or 0) >= (readings[j].get("p_mean") or 0) else j 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 = [p for p in (_sure(readings[i], th), _sure(readings[j], th)) 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")
@ -142,7 +146,8 @@ def decide_consensus(readings: list[dict[str, Any]], agree: list[tuple[int, int,
m = veto.get("metrics", {}) m = veto.get("metrics", {})
if m.get("ncc", 0.0) < th["veto_ncc"]: if m.get("ncc", 0.0) < th["veto_ncc"]:
problems.append(f"Bild-Veto: Ähnlichkeit {m.get('ncc', 0.0):.2f} < {th['veto_ncc']}") problems.append(f"Bild-Veto: Ähnlichkeit {m.get('ncc', 0.0):.2f} < {th['veto_ncc']}")
if book_px_per_pt and abs(m.get("px_per_pt", book_px_per_pt) / book_px_per_pt - 1) > th["veto_scale_dev"]: 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"]):
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")
if problems: if problems:
return "needs_review", f"einig ({how}){tie}; " + "; ".join(problems), use return "needs_review", f"einig ({how}){tie}; " + "; ".join(problems), use

View file

@ -14,7 +14,10 @@ from .vlm import VisionEngine
DEFAULT_LOCAL_URL = "http://127.0.0.1:8011/v1" DEFAULT_LOCAL_URL = "http://127.0.0.1:8011/v1"
DEFAULT_LOCAL2_URL = "http://127.0.0.1:8012/v1" DEFAULT_LOCAL2_URL = "http://127.0.0.1:8012/v1"
DEFAULT_CLOUD_URL = "https://openrouter.ai/api/v1" DEFAULT_CLOUD_URL = "https://openrouter.ai/api/v1"
DEFAULT_CLOUD_MODEL = "google/gemini-2.5-flash" # Third reading: another model family than the two local ones (Qwen, Gemma). Measured on 2 x 150
# formulas with known TeX: Qwen = Gemma = GLM accepted 57-61 % with 0 errors; Gemini (related to
# Gemma) let a shared error through.
DEFAULT_CLOUD_MODEL = "z-ai/glm-4.6v"
def make_engine(name: str, model: str | None = None, url: str | None = None) -> VisionEngine: def make_engine(name: str, model: str | None = None, url: str | None = None) -> VisionEngine:
@ -38,5 +41,7 @@ def make_engine(name: str, model: str | None = None, url: str | None = None) ->
model=model or os.environ.get("FORMULA_CLOUD_MODEL", DEFAULT_CLOUD_MODEL), model=model or os.environ.get("FORMULA_CLOUD_MODEL", DEFAULT_CLOUD_MODEL),
api_key=os.environ.get("OPENROUTER_API_KEY", ""), api_key=os.environ.get("OPENROUTER_API_KEY", ""),
needs_key=True, needs_key=True,
# GLM-4.6V with reasoning garbled formulas it reads correctly without (and took 25x the tokens)
thinking=os.environ.get("FORMULA_CLOUD_THINKING", "0") == "1",
) )
raise ValueError(f"unbekannte Engine {name!r} (local, local2, cloud)") raise ValueError(f"unbekannte Engine {name!r} (local, local2, cloud)")

View file

@ -19,6 +19,9 @@ from ..imageio import gray_to_ink, load_gray
MIN_HEIGHT = 96 # small formula images are enlarged to at least this height for the model MIN_HEIGHT = 96 # small formula images are enlarged to at least this height for the model
MAX_UPSCALE = 8 MAX_UPSCALE = 8
TIMEOUT_S = 120 TIMEOUT_S = 120
MAX_TOKENS = 800 # long multi-line formulas need ~400; runaway repetitions are cut off (they are useless anyway)
PROMPT_VERSION = 2 # bump when PROMPT changes: cached readings of older prompts are asked again
PROMPT = """Transcribe the mathematical formula in the image into LaTeX (amsmath, amssymb). PROMPT = """Transcribe the mathematical formula in the image into LaTeX (amsmath, amssymb).
@ -184,6 +187,13 @@ class VisionEngine:
"Server starten (siehe BEDIENUNGSANLEITUNG) oder --engine cloud verwenden.", "Server starten (siehe BEDIENUNGSANLEITUNG) oder --engine cloud verwenden.",
EXIT_TOOL) from e EXIT_TOOL) from e
def _thinking_param(self) -> dict:
if self.thinking is None:
return {}
if "openrouter.ai" in self.base_url:
return {"reasoning": {"enabled": self.thinking}}
return {"chat_template_kwargs": {"enable_thinking": self.thinking}}
def _headers(self) -> dict[str, str]: def _headers(self) -> dict[str, str]:
h = {"Content-Type": "application/json"} h = {"Content-Type": "application/json"}
if self.api_key: if self.api_key:
@ -201,10 +211,10 @@ class VisionEngine:
body = { body = {
"model": self.model, "model": self.model,
"temperature": 0, "temperature": 0,
"max_tokens": 1500, "max_tokens": MAX_TOKENS,
"logprobs": True, "logprobs": True,
"top_logprobs": 1, "top_logprobs": 1,
**({"chat_template_kwargs": {"enable_thinking": self.thinking}} if self.thinking is not None else {}), **self._thinking_param(),
"messages": [{ "messages": [{
"role": "user", "role": "user",
"content": [ "content": [

View file

@ -5,8 +5,10 @@ from __future__ import annotations
import hashlib import hashlib
import io import io
import os import os
import re
import shutil import shutil
import statistics import statistics
import threading
import sys import sys
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
from pathlib import Path from pathlib import Path
@ -20,6 +22,7 @@ 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 consensus_thresholds, decide, decide_consensus, thresholds
from .engines import make_engine from .engines import make_engine
from .engines.vlm import PROMPT_VERSION
from .imageio import load_ink from .imageio import load_ink
from .render import PRESETS from .render import PRESETS
from .sources import open_source from .sources import open_source
@ -29,6 +32,7 @@ from .tex import normalize_for_compare, split_tag, strip_delimiters
from .verify import verify from .verify import verify
PRESET_SAMPLE = 12 PRESET_SAMPLE = 12
_SAVE_LOCK = threading.Lock()
def log(msg: str, quiet: bool = False) -> None: def log(msg: str, quiet: bool = False) -> None:
@ -104,19 +108,28 @@ def _ref_for(source: Source, f: Formula) -> ImageRef:
# --------------------------------------------------------------------------------- recognize # --------------------------------------------------------------------------------- recognize
def needs_third(f: Formula) -> bool:
"""The two primary readings agree; only the confirming third reading is missing."""
return f.status == "needs_review" and "dritte Lesart fehlt" 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) -> dict[str, int]: workers: int = 4, quiet: bool = False, only_ids: set[str] | None = None,
only_needing_third: 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 = []
for f in ws.all(): for f in ws.all():
if not _wanted(f, include_figures): if not _wanted(f, include_figures) or (only_ids is not None and f.id not in only_ids):
continue continue
if only_unresolved and f.status in ("auto_accepted", "not_formula"): if only_unresolved and f.status in ("auto_accepted", "not_formula"):
continue continue
if any(c.get("engine") == engine.name and c.get("model") == engine.model for c in f.candidates): if only_needing_third and not needs_third(f):
continue # cached: never ask the same model twice continue
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):
continue # cached: never ask the same model the same question twice
todo.append(f) todo.append(f)
if limit: if limit:
todo = todo[:limit] todo = todo[:limit]
@ -136,16 +149,19 @@ def recognize(source: Source, ws: Workspace, engine_name: str, model: str | None
stats["tokens_in"] += rec.tokens_in stats["tokens_in"] += rec.tokens_in
stats["tokens_out"] += rec.tokens_out stats["tokens_out"] += rec.tokens_out
cand = {"engine": engine.name, "model": engine.model, "tex": rec.tex, "is_formula": rec.is_formula, cand = {"engine": engine.name, "model": engine.model, "tex": rec.tex, "is_formula": rec.is_formula,
"p_min": rec.p_min, "p_mean": rec.p_mean, "self_conf": rec.self_conf, "p_min": rec.p_min, "p_mean": rec.p_mean, "self_conf": rec.self_conf, "prompt": PROMPT_VERSION,
"thinking": engine.thinking,
"error": rec.error, "seconds": round(rec.seconds, 2), "created_at": now_iso()} "error": rec.error, "seconds": round(rec.seconds, 2), "created_at": now_iso()}
with _SAVE_LOCK: # another engine may run in parallel: append to the current record
f = ws.load(f.id) or f
f.candidates.append(cand) f.candidates.append(cand)
ws.save(f)
if rec.error: if rec.error:
stats["error"] += 1 stats["error"] += 1
elif rec.tex: elif rec.tex:
stats["latex"] += 1 stats["latex"] += 1
else: else:
stats["not_formula"] += 1 stats["not_formula"] += 1
ws.save(f)
if not quiet and (i % 25 == 0 or i == len(todo)): if not quiet and (i % 25 == 0 or i == len(todo)):
log(f" {i}/{len(todo)}") log(f" {i}/{len(todo)}")
log(f"recognize: {stats['latex']} LaTeX-Kandidaten, {stats['not_formula']} „keine Formel“, " log(f"recognize: {stats['latex']} LaTeX-Kandidaten, {stats['not_formula']} „keine Formel“, "
@ -226,10 +242,13 @@ def choose_preset(source: Source, ws: Workspace, formulas: list[Formula], quiet:
def verify_all(source: Source, ws: Workspace, preset: str | None = None, include_figures: bool = False, def verify_all(source: Source, ws: Workspace, preset: str | None = None, include_figures: bool = False,
workers: int = 0, redo: bool = False, quiet: bool = False, workers: int = 0, redo: bool = False, quiet: bool = False,
progress: Callable[[int, int], None] | None = None) -> dict[str, int]: progress: Callable[[int, int], None] | None = None, only_ids: set[str] | None = None) -> dict[str, int]:
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)]
mode = decide_mode(ws)
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)] formulas = [f for f in formulas if not _verified_current(f, mode)]
if preset is None: if preset is None:
preset = ws.settings.get("preset") or choose_preset(source, ws, formulas, quiet) preset = ws.settings.get("preset") or choose_preset(source, ws, formulas, quiet)
ws.settings["preset"] = preset ws.settings["preset"] = preset
@ -237,8 +256,8 @@ def verify_all(source: Source, ws: Workspace, preset: str | None = None, include
jobs = [] jobs = []
for f in formulas: for f in formulas:
ref = _ref_for(source, f) ref = _ref_for(source, f)
jobs.append((f.id, source.original_bytes(ref), f.asset, _distinct_candidates(f), f.display, preset, jobs.append((f.id, source.original_bytes(ref), f.asset, _verify_texes(f, mode), f.display, preset,
decide_mode(ws) == "pixel")) mode == "pixel"))
log(f"verify: {len(jobs)} Formeln, Schrift {preset}", quiet) log(f"verify: {len(jobs)} Formeln, Schrift {preset}", quiet)
by_id = {f.id: f for f in formulas} by_id = {f.id: f for f in formulas}
ws.review_dir.mkdir(parents=True, exist_ok=True) ws.review_dir.mkdir(parents=True, exist_ok=True)
@ -248,9 +267,11 @@ def verify_all(source: Source, ws: Workspace, preset: str | None = None, include
for fut in as_completed([pool.submit(_verify_job, j) for j in jobs]): for fut in as_completed([pool.submit(_verify_job, j) for j in jobs]):
fid, results, pngs = fut.result() fid, results, pngs = fut.result()
f = by_id[fid] f = by_id[fid]
if not results:
continue
best = max(results, key=lambda r: (r["ok"], r["min_contrast"] if r["min_contrast"] is not None else 1.0, best = max(results, key=lambda r: (r["ok"], r["min_contrast"] if r["min_contrast"] is not None else 1.0,
r["metrics"].get("ncc", 0.0))) r["metrics"].get("ncc", 0.0)))
f.verification = {"preset": preset, "candidates_key": _cands_key(f), "best": best, "all": results, f.verification = {"preset": preset, "candidates_key": _cands_key(f, mode), "best": best, "all": results,
"at": now_iso()} "at": now_iso()}
for key, png in pngs.items(): for key, png in pngs.items():
(ws.review_dir / f"{fid}-{key}.png").write_bytes(png) (ws.review_dir / f"{fid}-{key}.png").write_bytes(png)
@ -260,16 +281,31 @@ def verify_all(source: Source, ws: Workspace, preset: str | None = None, include
progress(done, len(jobs)) progress(done, len(jobs))
elif not quiet and (done % 25 == 0 or done == len(jobs)): elif not quiet and (done % 25 == 0 or done == len(jobs)):
log(f" {done}/{len(jobs)}") log(f" {done}/{len(jobs)}")
return decide_all(ws, quiet=quiet, preset=preset) return decide_all(ws, quiet=quiet, preset=preset, only_ids=only_ids)
def _cands_key(f: Formula) -> str: VERIFY_VERSION = 3 # bump when the comparison changes, so stored image checks are recomputed
return hashlib.sha256("\n".join(sorted(_distinct_candidates(f))).encode()).hexdigest()[:16]
def _verified_current(f: Formula) -> bool: def _verify_texes(f: Formula, mode: str) -> list[str]:
"""Readings to check against the image. Consensus: only the reading that would be used (the
first primary reading) – the veto needs nothing else, and a later third reading changes nothing."""
if mode != "consensus":
return _distinct_candidates(f)
for r in readings_of(f):
if r.get("tex"):
return [split_tag(strip_delimiters(r["tex"]))[0]]
return []
def _cands_key(f: Formula, mode: str = "pixel") -> str:
text = f"v{VERIFY_VERSION}\n" + "\n".join(sorted(_verify_texes(f, mode)))
return hashlib.sha256(text.encode()).hexdigest()[:16]
def _verified_current(f: Formula, mode: str = "pixel") -> bool:
v = f.verification or {} v = f.verification or {}
return v.get("candidates_key") == _cands_key(f) return v.get("candidates_key") == _cands_key(f, mode)
def decide_mode(ws: Workspace) -> str: def decide_mode(ws: Workspace) -> str:
@ -287,9 +323,10 @@ def readings_of(f: Formula) -> list[dict]:
return sorted(latest.values(), key=lambda c: order.get(c.get("engine", ""), 3)) return sorted(latest.values(), key=lambda c: order.get(c.get("engine", ""), 3))
def decide_all(ws: Workspace, quiet: bool = False, preset: str | None = None) -> dict[str, int]: def decide_all(ws: Workspace, quiet: bool = False, preset: str | None = None,
only_ids: set[str] | None = None) -> dict[str, int]:
if decide_mode(ws) == "consensus": if decide_mode(ws) == "consensus":
return decide_all_consensus(ws, quiet, preset or ws.settings.get("preset") or "cm") return decide_all_consensus(ws, quiet, preset or ws.settings.get("preset") or "cm", only_ids)
th = thresholds(ws.settings.get("thresholds")) th = thresholds(ws.settings.get("thresholds"))
formulas = [f for f in ws.all() if f.verification and f.status not in HUMAN_STATUSES] formulas = [f for f in ws.all() if f.verification and f.status not in HUMAN_STATUSES]
# Book-wide scale: all formula images of a book share one resolution, so px/pt is constant. # Book-wide scale: all formula images of a book share one resolution, so px/pt is constant.
@ -320,23 +357,40 @@ def decide_all(ws: Workspace, quiet: bool = False, preset: str | None = None) ->
return counts return counts
def decide_all_consensus(ws: Workspace, quiet: bool, preset: str) -> dict[str, int]: def _strip_final_punct(tex: str) -> str:
return re.sub(r"(\\[,;!]|\\quad|\\qquad|\s)*[.,;]\s*$", "", strip_delimiters(tex or "")).strip()
def _tex_key(tex: str) -> str:
"""Key under which verify stores a candidate (see _distinct_candidates)."""
return normalize_for_compare(split_tag(strip_delimiters(tex))[0])
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"))
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)]
plan = [] plan = []
for f in formulas: for f in formulas:
rs = readings_of(f) rs = readings_of(f)
bold = bool(th.get("bold_relevant")) bold = bool(th.get("bold_relevant"))
agree = [(i, j, how) for i in range(len(rs)) for j in range(i + 1, len(rs)) agree = []
for how in [same_reading(rs[i].get("tex") or "", rs[j].get("tex") or "", f.display, preset, bold)] if how] for i in range(len(rs)):
for j in range(i + 1, len(rs)):
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
a, b = _strip_final_punct(a), _strip_final_punct(b)
how = same_reading(a, b, f.display, preset, bold)
if how:
agree.append((i, j, how))
plan.append((f, rs, agree)) plan.append((f, rs, agree))
# Book-wide scale from agreed readings that resemble their original well. # Book-wide scale from agreed readings that resemble their original well.
by_tex = {f.id: {r["tex"]: r for r in (f.verification or {}).get("all", [])} for f, _, _ in plan} by_tex = {f.id: {_tex_key(r["tex"]): r for r in (f.verification or {}).get("all", [])} for f, _, _ in plan}
good = [] good = []
for f, rs, agree in plan: for f, rs, agree in plan:
for i, j, _ in agree[:1]: for i, j, _ in agree[:1]:
v = by_tex[f.id].get(rs[i].get("tex")) v = by_tex[f.id].get(_tex_key(rs[i].get("tex") or ""))
if v and v.get("ok") and v["metrics"].get("ncc", 0) >= 0.85: if v and v.get("ok") and v["metrics"].get("ncc", 0) >= 0.85:
good.append(v["metrics"]["px_per_pt"]) good.append(v["metrics"]["px_per_pt"])
book_ppt = float(statistics.median(good)) if len(good) >= 5 else ws.settings.get("book_px_per_pt") book_ppt = float(statistics.median(good)) if len(good) >= 5 else ws.settings.get("book_px_per_pt")
@ -348,10 +402,10 @@ def decide_all_consensus(ws: Workspace, quiet: bool, preset: str) -> dict[str, i
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) 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_consensus(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, strip_delimiters(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
@ -361,13 +415,15 @@ def decide_all_consensus(ws: Workspace, quiet: bool, preset: str) -> dict[str, i
def _with_tag(f: Formula, tex: str) -> str: def _with_tag(f: Formula, tex: str) -> str:
"""Re-attach an equation number recognized as \\tag{...} – as visible text, like epub2md does.""" """Turn an equation number of the *chosen* reading (\\tag{3.7}) into visible text, like epub2md does.
for c in f.candidates:
if c.get("tex"): Only the accepted reading counts – never other or older readings. In consensus mode the number
_body, tag = split_tag(strip_delimiters(c["tex"])) is part of the compared canonical form, so all agreeing readings carry the same number.
if tag: """
return tex + f"\\qquad\\text{{({tag})}}" if f.display else tex body, tag = split_tag(strip_delimiters(tex))
return tex if not tag:
return body
return body + f"\\qquad\\text{{({tag})}}" if f.display else body
# ------------------------------------------------------------------------------ apply/revert # ------------------------------------------------------------------------------ apply/revert
@ -462,3 +518,45 @@ def open_all(path: Path, workspace: Path | None, create: bool = False, pdf: Path
if getattr(source, "pdf", "unset") is None and ws.meta.get("source", {}).get("pdf"): if getattr(source, "pdf", "unset") is None and ws.meta.get("source", {}).get("pdf"):
source.pdf = Path(ws.meta["source"]["pdf"]) source.pdf = Path(ws.meta["source"]["pdf"])
return source, ws return source, ws
# ------------------------------------------------------------------------- run in chunks
def run_consensus(source: Source, ws: Workspace, chunk: int = 50, preset: 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,
report: Callable[[str], None] | None = None) -> dict[str, int]:
"""Process formulas in chunks: local, local2, verify; cloud only where both agree; verify again.
After every chunk a status line (processed / accepted / review / image) is reported.
"""
say = report or (lambda m: print(m, flush=True))
todo = ids or [f.id for f in ws.all() if _wanted(f, include_figures)]
if limit:
todo = todo[:limit]
totals: dict[str, int] = {}
done: list[str] = []
for start in range(0, len(todo), chunk):
part = set(todo[start:start + chunk])
# both local models run on their own GPU: ask them at the same time
with ThreadPoolExecutor(max_workers=2) as both:
for fut in [both.submit(recognize, source, ws, eng, only_ids=part, include_figures=include_figures,
quiet=True) for eng in ("local", "local2")]:
fut.result()
verify_all(source, ws, preset, include_figures, quiet=True, only_ids=part)
if use_cloud:
recognize(source, ws, "cloud", cloud_model, only_ids=part, only_needing_third=True,
include_figures=include_figures, workers=8, quiet=True)
verify_all(source, ws, preset, include_figures, quiet=True, only_ids=part)
done.extend(sorted(part))
totals = {}
for fid in done:
f = ws.load(fid)
if f is not None:
totals[f.status] = totals.get(f.status, 0) + 1
auto = totals.get("auto_accepted", 0)
image = sum(totals.get(k, 0) for k in ("rejected_keep_image", "not_formula", "no_candidate", "render_failed"))
review = totals.get("needs_review", 0)
say(f"{len(done):5d}/{len(todo)} verarbeitet: automatisch ersetzt {auto} ({auto / len(done):.0%}), "
f"Review {review}, bleibt Bild/keine Formel {image}")
return totals

View file

@ -18,7 +18,7 @@ from urllib.parse import unquote, urlparse
from ..checks import pandoc_mathml_ok from ..checks import pandoc_mathml_ok
from ..imageio import load_ink from ..imageio import load_ink
from ..pipeline import _ref_for, comparison_pngs from ..pipeline import _ref_for, comparison_pngs, readings_of
from ..sources.base import Source from ..sources.base import Source
from ..store import Formula, Workspace, now_iso from ..store import Formula, Workspace, now_iso
from ..tex import strip_delimiters, unsafe_reason from ..tex import strip_delimiters, unsafe_reason
@ -31,7 +31,7 @@ _LOCK = threading.Lock()
def _card(f: Formula) -> dict[str, Any]: def _card(f: Formula) -> dict[str, Any]:
best = (f.verification or {}).get("best") or {} best = (f.verification or {}).get("best") or {}
cands = [c.get("tex") for c in f.candidates if c.get("tex")] cands = [r.get("tex") for r in readings_of(f) if r.get("tex")] or [c.get("tex") for c in f.candidates if c.get("tex")]
return { return {
"id": f.id, "status": f.status, "reason": f.reason, "display": f.display, "asset": f.asset, "id": f.id, "status": f.status, "reason": f.reason, "display": f.display, "asset": f.asset,
"source_name": f.source_name, "occurrences": f.occurrences[:3], "n_occ": len(f.occurrences), "source_name": f.source_name, "occurrences": f.occurrences[:3], "n_occ": len(f.occurrences),

View file

@ -103,9 +103,15 @@ class Source:
def math_delimiters(self) -> tuple[str, str, str, str]: def math_delimiters(self) -> tuple[str, str, str, str]:
return "$", "$", "$$", "$$" return "$", "$", "$$", "$$"
def language(self) -> str:
return ""
def format_math(self, tex: str, display: bool) -> str: def format_math(self, tex: str, display: bool) -> str:
il, ir, dl, dr = self.math_delimiters() il, ir, dl, dr = self.math_delimiters()
body = tex.strip() body = tex.strip()
if self.language().lower().startswith("de"):
# German decimal comma: 3,6 -> 3{,}6, otherwise LaTeX sets "3, 6"
body = re.sub(r"(?<=\d),(?=\d)", "{,}", body)
if display: if display:
return f"{dl}{body}{dr}" return f"{dl}{body}{dr}"
return f"{il}{body}{ir}" return f"{il}{body}{ir}"

View file

@ -59,6 +59,11 @@ class Epub2MdSource(Source):
def known_formula(self, ref: ImageRef) -> bool: def known_formula(self, ref: ImageRef) -> bool:
return posixpath.basename(self.source_name(ref)) in self.formula_names return posixpath.basename(self.source_name(ref)) in self.formula_names
def language(self) -> str:
meta = self.report.get("metadata") or {}
lang = meta.get("language") or meta.get("lang") or ""
return lang[0] if isinstance(lang, list) and lang else str(lang)
def math_mode(self) -> str: def math_mode(self) -> str:
return str((self.report.get("options") or {}).get("math", "auto")) return str((self.report.get("options") or {}).get("math", "auto"))

View file

@ -131,22 +131,33 @@ def test_consensus_mode(epub2md_dir: Path) -> None:
ws.save(display) ws.save(display)
pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True) pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True)
st = {f.asset.rsplit("/", 1)[1]: f for f in ws.all()} st = {f.asset.rsplit("/", 1)[1]: f for f in ws.all()}
assert st["0001-inline.jpg"].status == "auto_accepted", st["0001-inline.jpg"].reason assert pipeline.needs_third(st["0001-inline.jpg"]), st["0001-inline.jpg"].reason # A = B, C missing
assert st["0002-display.jpg"].status == "needs_review" assert st["0002-display.jpg"].status == "needs_review"
assert "uneinig" in st["0002-display.jpg"].reason assert "uneinig" in st["0002-display.jpg"].reason
# a third reading breaks the tie # the third reading confirms A = B -> accepted; for A != B a third reading does not decide 2:1
d = st["0002-display.jpg"] i, d = st["0001-inline.jpg"], st["0002-display.jpg"]
i.candidates.append(_reading("cloud", "C", r"L=\left\{-\sqrt{2};\sqrt{2}\right\}"))
d.candidates.append(_reading("cloud", "C", r"x_{2,k} = \arcsin(-\frac35) + 2k\pi")) d.candidates.append(_reading("cloud", "C", r"x_{2,k} = \arcsin(-\frac35) + 2k\pi"))
ws.save(i)
ws.save(d) ws.save(d)
pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True) pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True)
d = ws.load(d.id) i, d = ws.load(i.id), ws.load(d.id)
assert d.status == "auto_accepted" and "Stichwahl" in d.reason, d.reason assert i.status == "auto_accepted" and "dritte Lesart bestätigt" in i.reason, i.reason
assert d.status == "needs_review"
# a disagreeing third reading blocks the acceptance
i.candidates[-1] = _reading("cloud", "C", r"L=\{-\sqrt{2};\sqrt{3}\}")
i.status = "pending"
ws.save(i)
pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True)
i = ws.load(i.id)
assert i.status == "needs_review" and "weicht ab" in i.reason, i.reason
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() th = consensus_thresholds({"min_p_mean": 0.9, "min_p_min": 0.3, "require_third": 0})
a, b = _reading("local", "A", "x^2"), _reading("local2", "B", "x^{2}") a, b = _reading("local", "A", "x^2"), _reading("local2", "B", "x^{2}")
veto = {"ok": True, "metrics": {"ncc": 0.9, "px_per_pt": 1.4}} veto = {"ok": True, "metrics": {"ncc": 0.9, "px_per_pt": 1.4}}
assert decide_consensus([a, b], [(0, 1, "normalform")], veto, True, 1.4, th)[0] == "auto_accepted" assert decide_consensus([a, b], [(0, 1, "normalform")], veto, True, 1.4, th)[0] == "auto_accepted"
@ -156,11 +167,18 @@ def test_consensus_rules() -> None:
bad = {"ok": True, "metrics": {"ncc": 0.5, "px_per_pt": 1.4}} bad = {"ok": True, "metrics": {"ncc": 0.5, "px_per_pt": 1.4}}
assert decide_consensus([a, b], [(0, 1, "normalform")], bad, True, 1.4, th)[0] == "needs_review" # veto assert decide_consensus([a, b], [(0, 1, "normalform")], bad, True, 1.4, th)[0] == "needs_review" # veto
big = {"ok": True, "metrics": {"ncc": 0.9, "px_per_pt": 1.8}} big = {"ok": True, "metrics": {"ncc": 0.9, "px_per_pt": 1.8}}
assert decide_consensus([a, b], [(0, 1, "normalform")], big, True, 1.4, th)[0] == "needs_review" # size sized = {**th, "veto_scale_dev": 0.15}
assert decide_consensus([a, b], [(0, 1, "normalform")], big, True, 1.4, sized)[0] == "needs_review" # size check on
assert decide_consensus([a, b], [(0, 1, "normalform")], veto, False, 1.4, th)[0] == "needs_review" # pandoc assert decide_consensus([a, b], [(0, 1, "normalform")], veto, False, 1.4, th)[0] == "needs_review" # pandoc
nf = [_reading("local", "A", None, is_formula=False), _reading("local2", "B", None, is_formula=False)] nf = [_reading("local", "A", None, is_formula=False), _reading("local2", "B", None, is_formula=False)]
assert decide_consensus(nf, [], None, True, 1.4, th)[0] == "not_formula" assert decide_consensus(nf, [], None, True, 1.4, th)[0] == "not_formula"
assert decide_consensus([a], [], veto, True, 1.4, th)[0] == "needs_review" # one model assert decide_consensus([a], [], veto, True, 1.4, th)[0] == "needs_review" # one model
strict = consensus_thresholds() # default: third must agree
c = _reading("cloud", "C", "x^2")
assert decide_consensus([a, b], [(0, 1, "normalform")], veto, True, 1.4, strict)[0] == "needs_review"
assert decide_consensus([a, b, c], [(0, 1, "normalform"), (0, 2, "normalform"), (1, 2, "normalform")],
veto, True, 1.4, strict)[0] == "auto_accepted"
assert decide_consensus([a, b, c], [(0, 2, "normalform")], veto, True, 1.4, strict)[0] == "needs_review" # no 2:1
def test_canonical_form() -> None: def test_canonical_form() -> None:
@ -185,3 +203,34 @@ def test_logprob_confidence() -> None:
p_min, p_mean = latex_token_probs(content, items) p_min, p_mean = latex_token_probs(content, items)
assert p_min == 0.5 and 0.8 < p_mean < 0.9 # only the four LaTeX tokens count assert p_min == 0.5 and 0.8 < p_mean < 0.9 # only the four LaTeX tokens count
assert parse_confidence(content) == 97 assert parse_confidence(content) == 97
def test_equation_number_only_from_chosen_reading() -> None:
from formula_img2latex.store import Formula
f = Formula(id="f-x-d", sha256="x", asset="a.png", display=True)
f.candidates = [{"engine": "local", "model": "old", "tex": r"a=b \tag{3.7}"}] # an old, hallucinated number
assert pipeline._with_tag(f, "a=b") == "a=b"
assert pipeline._with_tag(f, r"a=b \tag{2.1}") == r"a=b\qquad\text{(2.1)}"
f.display = False
assert pipeline._with_tag(f, r"a=b \tag{2.1}") == "a=b"
def test_german_decimal_comma(epub2md_dir: Path) -> None:
import json
source, _ws = pipeline.open_all(epub2md_dir, None, create=True)
assert source.format_math("3,6 + (1,2)", False) == "$3,6 + (1,2)$" # no language: unchanged
rep = json.loads((epub2md_dir / "conversion-report.json").read_text())
rep["metadata"] = {"language": "de"}
(epub2md_dir / "conversion-report.json").write_text(json.dumps(rep))
source, _ws = pipeline.open_all(epub2md_dir, None)
assert source.format_math(r"\frac{72}{3,6} = 20, x", True) == r"$$\frac{72}{3{,}6} = 20, x$$"
def test_verify_skips_formulas_without_current_reading(epub2md_dir: Path) -> None:
source, ws = pipeline.open_all(epub2md_dir, None, create=True)
pipeline.scan(source, ws, quiet=True)
f = next(f for f in ws.all() if f.asset.endswith("0001-inline.jpg"))
f.candidates = [{"engine": "local", "model": "A", "tex": "x", "is_formula": True, "prompt": 1}, # old reading
{"engine": "local", "model": "A", "tex": None, "is_formula": False, "prompt": 2}] # current: none
ws.save(f)
pipeline.verify_all(source, ws, preset="cm", workers=1, quiet=True) # must not crash

View file

@ -1,71 +1,55 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Tuning aid: verify a fixed sample of formulas and write comparison images + an HTML overview. """Tuning aid: process a fixed sample of formulas and write an HTML overview with images.
python3 tools/vergleich.py AUSGABE [--n 50] [--seed 1] [--name gehrke-50] python3 tools/vergleich.py AUSGABE [--n 50] [--seed 1] [--name gehrke-50] [--new-sample]
The sample (formula ids) is stored in vergleich/<name>/sample.json and reused on later runs, so The sample (formula ids) is stored in vergleich/<name>/sample.json and reused on later runs, so
changes to the procedure are compared on the same formulas. Output (not versioned, it shows changes to the procedure are compared on the same formulas. The formulas are processed exactly
excerpts of the book): like `run` does (consensus mode: Qwen, Gemma, GLM for agreed readings, image veto). Output is
not versioned because it shows excerpts of the book:
vergleich/<name>/ vergleich/<name>/index.html per formula: original, rendered readings, decision, veto overlay
├── index.html all formulas: image panel, LaTeX, measurements, decision, reason vergleich/<name>/summary.json counts and all readings
├── summary.json counts per status, all numbers
├── sample.json the formula ids of the sample
└── NN-<id>.png Original / LaTeX rigid / LaTeX elastic / difference
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import html import html
import io import io
import json import json
import random import random
import sys import sys
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont import numpy as np
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from formula_img2latex.checks import pandoc_mathml_ok # noqa: E402 from formula_img2latex.pipeline import _ref_for, _wanted, open_all, readings_of, run_consensus # noqa: E402
from formula_img2latex.decide import decide, thresholds # noqa: E402 from formula_img2latex.render import render_many # noqa: E402
from formula_img2latex.pipeline import _distinct_candidates, _ref_for, _verify_job, open_all # noqa: E402 from formula_img2latex.tex import strip_delimiters # noqa: E402
FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" NAMES = {"local": "Qwen", "local2": "Gemma", "cloud": "GLM"}
LABELS = (("orig", "Original"), ("rigid", "LaTeX starr"), ("render", "LaTeX elastisch"), ("diff", "Differenz")) COLOR = {"auto_accepted": "#1f7a3a", "needs_review": "#a15c00"}
def panel(pngs: dict[str, bytes], target_h: int = 70) -> Image.Image: def uri(img: Image.Image) -> str:
"""Stack the four images, enlarged with visible pixels, each with a label.""" buf = io.BytesIO()
imgs = {k: Image.open(io.BytesIO(v)).convert("RGB") for k, v in pngs.items()} img.save(buf, "PNG")
if not imgs: return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
return Image.new("RGB", (300, 40), "white")
h0 = next(iter(imgs.values())).height
f = max(1, min(8, round(target_h / max(h0, 1)))) def original(data: bytes, target_h: int = 60) -> Image.Image:
try: im = Image.open(io.BytesIO(data))
font = ImageFont.truetype(FONT, 14) if im.mode in ("P", "LA", "RGBA") or "transparency" in im.info:
except OSError: im = im.convert("RGBA")
font = ImageFont.load_default() im = Image.alpha_composite(Image.new("RGBA", im.size, "white"), im)
rows = [] im = im.convert("RGB")
for key, label in LABELS: f = max(1, min(6, round(target_h / max(im.height, 1))))
if key not in imgs: return im.resize((im.width * f, im.height * f), Image.NEAREST)
continue
im = imgs[key]
im = im.resize((im.width * f, im.height * f), Image.NEAREST)
row = Image.new("RGB", (im.width + 140, im.height + 8), "white")
ImageDraw.Draw(row).text((4, 4), label, fill=(80, 80, 80), font=font)
row.paste(im, (136, 4))
rows.append(row)
w = max(r.width for r in rows)
out = Image.new("RGB", (w, sum(r.height for r in rows) + 4), "white")
y = 0
for r in rows:
out.paste(r, (0, y))
y += r.height
ImageDraw.Draw(out).rectangle([0, 0, out.width - 1, out.height - 1], outline=(200, 200, 200))
return out
def main() -> int: def main() -> int:
@ -73,10 +57,9 @@ def main() -> int:
ap.add_argument("source", type=Path) ap.add_argument("source", type=Path)
ap.add_argument("--n", type=int, default=50) ap.add_argument("--n", type=int, default=50)
ap.add_argument("--seed", type=int, default=1) ap.add_argument("--seed", type=int, default=1)
ap.add_argument("--name", default=None, help="Unterordner in vergleich/ (Standard: <buch>-<n>)") ap.add_argument("--name", default=None)
ap.add_argument("--preset", default=None) ap.add_argument("--new-sample", action="store_true")
ap.add_argument("--workers", type=int, default=16) ap.add_argument("--no-cloud", action="store_true")
ap.add_argument("--new-sample", action="store_true", help="neue Stichprobe ziehen")
args = ap.parse_args() args = ap.parse_args()
source, ws = open_all(args.source, None) source, ws = open_all(args.source, None)
@ -84,74 +67,68 @@ def main() -> int:
out = Path(__file__).resolve().parents[1] / "vergleich" / name out = Path(__file__).resolve().parents[1] / "vergleich" / name
out.mkdir(parents=True, exist_ok=True) out.mkdir(parents=True, exist_ok=True)
sample_file = out / "sample.json" sample_file = out / "sample.json"
pool = [f for f in ws.all() if _distinct_candidates(f) and (f.classify or {}).get("label") != "figure"] pool = [f for f in ws.all() if _wanted(f, False)]
if sample_file.exists() and not args.new_sample: if sample_file.exists() and not args.new_sample:
ids = json.loads(sample_file.read_text()) ids = json.loads(sample_file.read_text())
else: else:
ids = sorted(f.id for f in random.Random(args.seed).sample(pool, min(args.n, len(pool)))) ids = sorted(f.id for f in random.Random(args.seed).sample(pool, min(args.n, len(pool))))
sample_file.write_text(json.dumps(ids, indent=1)) sample_file.write_text(json.dumps(ids, indent=1))
by_id = {f.id: f for f in pool}
formulas = [by_id[i] for i in ids if i in by_id]
preset = args.preset or ws.settings.get("preset") or "cm"
book_ppt = ws.settings.get("book_px_per_pt")
th = thresholds(ws.settings.get("thresholds"))
jobs = [(f.id, source.original_bytes(_ref_for(source, f)), f.asset, _distinct_candidates(f), f.display, preset) run_consensus(source, ws, chunk=len(ids), ids=ids, use_cloud=not args.no_cloud)
for f in formulas]
results: dict[str, tuple[dict, dict[str, bytes]]] = {}
with ProcessPoolExecutor(max_workers=args.workers) as ex:
for fut in as_completed([ex.submit(_verify_job, j) for j in jobs]):
fid, res, pngs = fut.result()
best = max(res, key=lambda r: (r["ok"], r["min_contrast"] if r["min_contrast"] is not None else 1.0,
r["metrics"].get("ncc", 0.0)))
results[fid] = (best, pngs)
print(f" {len(results)}/{len(jobs)}", file=sys.stderr, end="\r")
print(file=sys.stderr)
pandoc = dict(zip([f.id for f in formulas], pandoc_mathml_ok([(results[f.id][0]["tex"], f.display) for f in formulas])))
for old in out.glob("*.png"): formulas = [ws.load(i) for i in ids]
old.unlink() formulas = [f for f in formulas if f is not None]
preset = ws.settings.get("preset") or "cm"
items, rows = [], []
for f in formulas:
for r in readings_of(f):
items.append((strip_delimiters(r.get("tex") or "") or "\\text{–}", f.display))
rendered = iter(render_many(items, preset))
counts: dict[str, int] = {} counts: dict[str, int] = {}
rows_html, summary = [], [] summary = []
for n, f in enumerate(formulas, 1): for n, f in enumerate(formulas, 1):
best, pngs = results[f.id] counts[f.status] = counts.get(f.status, 0) + 1
status, reason = decide(best, pandoc[f.id], book_ppt, th) orig = original(source.original_bytes(_ref_for(source, f)))
counts[status] = counts.get(status, 0) + 1 lines = [f"<tr><th>Original</th><td><img src='{uri(orig)}'></td><td></td></tr>"]
img = f"{n:02d}-{f.id}.png" reads = []
panel(pngs).save(out / img) for r in readings_of(f):
m = best.get("metrics", {}) rr = next(rendered)
mc = best.get("min_contrast") img = ""
summary.append({"n": n, "id": f.id, "status": status, "reason": reason, "tex": best.get("tex"), if rr.ok:
"ocr": [c.get("tex") for c in f.candidates if c.get("tex")], "display": f.display, z = 2.0 * max(1.0, orig.height / 60)
"min_contrast": mc, "worst_variant": best.get("worst_variant"), g = (rr.rasterize(min(z, 4.0)) * 255).astype(np.uint8)
"worst_kind": best.get("worst_kind"), "climbed_from": best.get("climbed_from"), img = uri(Image.fromarray(255 - g))
"metrics": m}) who = NAMES.get(r.get("engine", ""), r.get("engine", "?"))
climbed = "" reads.append({"engine": who, "model": r.get("model"), "tex": r.get("tex"), "p_mean": r.get("p_mean")})
if best.get("climbed_from"): p = r.get("p_mean")
climbed = "<div class=fix>korrigiert aus: <code>" + html.escape(best["climbed_from"][0]) + "</code></div>" lines.append(f"<tr><th>{who}</th><td>{f'<img src={chr(39)}{img}{chr(39)}>' if img else '(nicht renderbar)'}</td>"
rows_html.append( f"<td><code>{html.escape(r.get('tex') or '–')}</code>"
f"<section class='{status}'><h2>{n:02d} · <span class=st>{status}</span> · " f"{f' <small>p={p:.2f}</small>' if p is not None else ''}</td></tr>")
f"{'abgesetzt' if f.display else 'im Text'} · {html.escape(f.occurrences[0]['file'])}:{f.occurrences[0]['line']}</h2>" diff = ws.review_dir / f"{f.id}-diff.png"
f"<img src='{img}'><div><code>{html.escape(best.get('tex') or '')}</code></div>{climbed}" if diff.exists():
f"<div class=m>NCC elastisch {m.get('ncc', 0):.3f} · starr {m.get('rigid_ncc', 0):.3f} · " d = Image.open(diff).convert("RGB")
f"Verzerrung {m.get('warp_share', 0):.2f} · px/pt {m.get('px_per_pt', 0):.3f} · " d = d.resize((d.width * 2, d.height * 2), Image.NEAREST)
f"Kontrast {('–' if mc is None else f'{mc:.3f}')} (knappste Variante: {html.escape(best.get('worst_kind') or '–')}) · " lines.append(f"<tr><th>Veto-Overlay</th><td><img src='{uri(d)}'></td><td><small>rot = nur Original, "
f"Pandoc {'ok' if pandoc[f.id] else 'FEHLER'}</div><div class=r>{html.escape(reason)}</div></section>") "blau = nur LaTeX, schwarz = beide</small></td></tr>")
color = COLOR.get(f.status, "#a32020")
occ = f.occurrences[0] if f.occurrences else {}
rows.append(f"<section style='border-left-color:{color}'><h2>{n:02d} · <span style='color:{color}'>{f.status}</span> · "
f"{'abgesetzt' if f.display else 'im Text'} · {html.escape(occ.get('file', ''))}:{occ.get('line', '')}</h2>"
f"<div class=r>{html.escape(f.reason or '')}</div><table>{''.join(lines)}</table></section>")
summary.append({"n": n, "id": f.id, "status": f.status, "reason": f.reason, "final_tex": f.final_tex,
"readings": reads})
head = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items())) head = " · ".join(f"{k}: {v}" for k, v in sorted(counts.items()))
(out / "index.html").write_text( (out / "index.html").write_text(
"<!doctype html><meta charset=utf-8><title>Vergleich " + html.escape(name) + "</title><style>" "<!doctype html><meta charset=utf-8><title>Vergleich " + html.escape(name) + "</title><style>"
"body{font:14px system-ui,sans-serif;margin:16px;background:#f6f6f3}section{background:#fff;border:1px solid #ddd;" "body{font:14px system-ui,sans-serif;margin:16px;background:#f6f6f3}section{background:#fff;border:1px solid #ddd;"
"border-radius:6px;padding:8px 12px;margin:10px 0}h2{font-size:14px;margin:0 0 6px}img{max-width:100%}" "border-left:6px solid #999;border-radius:6px;padding:8px 12px;margin:10px 0}h2{font-size:14px;margin:0 0 4px}"
".auto_accepted .st{color:#1f7a3a}.needs_review .st{color:#a15c00}.rejected_keep_image .st,.render_failed .st{color:#a32020}" "th{text-align:left;font-weight:400;color:#666;width:100px;vertical-align:middle}td{padding:2px 8px;vertical-align:middle}"
".m{font:12px monospace;color:#555;margin-top:4px}.r{font-size:13px}.fix{font-size:12px;color:#2a5db0}" ".r{font-size:13px;margin-bottom:4px}code{font-size:12px}img{max-width:760px}</style>"
"code{font-size:12px}</style>" f"<h1>Vergleich {html.escape(name)}</h1><p>{len(formulas)} Formeln · Regel: Qwen = Gemma = GLM, Fettdruck egal, "
f"<h1>Vergleich {html.escape(name)}</h1><p>{len(formulas)} Formeln, Schrift {preset} · {head}</p>" f"Bild-Veto · {head}</p>" + "".join(rows), encoding="utf-8")
"<p>Zeilen je Bild: Original · LaTeX starr ausgerichtet · LaTeX elastisch ausgerichtet · Differenz " (out / "summary.json").write_text(json.dumps({"counts": counts, "formulas": summary}, ensure_ascii=False, indent=1),
"(schwarz = beide, rot = nur Original, blau = nur LaTeX, gelb = Abweichungsfleck)</p>" encoding="utf-8")
+ "".join(rows_html), encoding="utf-8") print(f"{len(formulas)} Formeln – {head}")
(out / "summary.json").write_text(json.dumps({"preset": preset, "counts": counts, "formulas": summary},
ensure_ascii=False, indent=1), encoding="utf-8")
print(f"{len(formulas)} Formeln geprüft – {head}")
print(f"Übersicht: {out / 'index.html'}") print(f"Übersicht: {out / 'index.html'}")
return 0 return 0