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:
parent
fe95cb572c
commit
388024a17b
14 changed files with 586 additions and 277 deletions
|
|
@ -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
35
CHANGELOG.md
Normal 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
228
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)")
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
f.candidates.append(cand)
|
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)
|
||||||
|
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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue