From 388024a17bae646ebe40d7d9393c31bf4ad4f770 Mon Sep 17 00:00:00 2001 From: dschlueter Date: Wed, 7 Oct 2026 07:58:16 +0200 Subject: [PATCH] =?UTF-8?q?feat:=200.2.0=20=E2=80=93=20Konsens=20Qwen=20?= =?UTF-8?q?=3D=20Gemma=20=3D=20GLM=20als=20Standard,=20Gehrke=20komplett?= =?UTF-8?q?=20verarbeitet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- BEDIENUNGSANLEITUNG.md | 92 ++++++++--- CHANGELOG.md | 35 ++++ README.md | 228 +++++++++++++++++--------- formula_img2latex/__init__.py | 2 +- formula_img2latex/cli.py | 9 +- formula_img2latex/decide.py | 21 ++- formula_img2latex/engines/__init__.py | 7 +- formula_img2latex/engines/vlm.py | 14 +- formula_img2latex/pipeline.py | 168 +++++++++++++++---- formula_img2latex/review/server.py | 4 +- formula_img2latex/sources/base.py | 6 + formula_img2latex/sources/epub2md.py | 5 + tests/test_pipeline.py | 63 ++++++- tools/vergleich.py | 185 +++++++++------------ 14 files changed, 574 insertions(+), 265 deletions(-) create mode 100644 CHANGELOG.md diff --git a/BEDIENUNGSANLEITUNG.md b/BEDIENUNGSANLEITUNG.md index 2d36903..c2caaca 100644 --- a/BEDIENUNGSANLEITUNG.md +++ b/BEDIENUNGSANLEITUNG.md @@ -3,38 +3,50 @@ 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. -Es ersetzt eine Formel nur, wenn das gerenderte LaTeX genauso aussieht wie das Bild. Bei Zweifeln fragt -es dich in einer Review-Seite im Browser. Ohne deine Zustimmung bleibt die Formel ein Bild. +Es ersetzt eine Formel nur, wenn **drei unabhängige Bildmodelle dieselbe Formel lesen** (zwei lokale, eines +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) ```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 pip install -r requirements.txt 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 +# Modell A: Qwen3-VL auf GPU 1, Port 8011 M=~/endeavour_home/lm_studio/models/lmstudio-community/Qwen3-VL-30B-A3B-Instruct-GGUF 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 \ --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 -`OPENROUTER_API_KEY` gesetzt sein. Ein Buch mit 2000 Formeln kostet mit dem Standardmodell -`google/gemini-2.5-flash` deutlich unter 1 $. Ohne Cloud: `--no-cloud`. +Die Bestätigung kommt von **GLM-4.6V** über OpenRouter (andere Modellfamilie als Qwen und Gemma). Dafür +muss `OPENROUTER_API_KEY` gesetzt sein. GLM liest nur die Formeln, bei denen Qwen und Gemma schon einig +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 ``` -Am Ende steht eine Übersicht, zum Beispiel: +Das Programm arbeitet in Paketen zu 50 Formeln und meldet nach jedem Paket den Stand: ```text - auto_accepted 412 ← sicher, wird ersetzt - needs_review 380 ← bitte im Browser ansehen - rejected_keep_image 95 ← passt nicht, bleibt Bild - not_formula 117 ← Abbildung/Symbol + 100/1505 verarbeitet: automatisch ersetzt 68 (68%), Review 28, bleibt Bild/keine Formel 4 ``` +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, 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, stimmt etwas nicht (Exponent, Vorzeichen, Klammer). - **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: @@ -118,7 +142,16 @@ cd ~/Python_Programs/epub2md 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 @@ -160,14 +193,27 @@ Nützliche Optionen: **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`. -**Warum landet so viel im Review?** -Das Programm ersetzt nur, was es beweisen kann. Häufige Gründe: Das Bild ist sehr klein (Ziffern unter -8 Pixeln). Das Buch wurde mit einem anderen Satzsystem gesetzt. Oder die Erkennung schreibt eine Formel -typografisch anders (`\vec` statt langem Pfeil). Im Review genügt dann meist ein Blick auf das Overlay. +**Warum landet ein Teil im Review?** +Ersetzt wird nur, worin sich alle drei Modelle einig sind. Häufige Gründe für Review: mehrzeilige Rechnungen +mit Ausrichtung, `\underbrace` oder Zeilenumbrüchen (die Modelle schreiben sie unterschiedlich), sehr +kleine oder unscharfe Bilder, oder ein Modell liest ein Zeichen anders. Im Review stehen die Lesarten +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//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?** Ja. Das Bild hatte die Farbe eingebaut, LaTeX-Formeln haben keinen Hintergrund. **Werden Buchinhalte ins Internet geschickt?** -Nur mit der Cloud-Stufe, und nur die einzelnen Formelbilder samt eines kurzen Textausschnitts davor. Mit -`--no-cloud` bleibt alles lokal. +Nur für die dritte Lesart: die einzelnen Formelbilder, bei denen Qwen und Gemma einig sind, samt eines +kurzen Textausschnitts um die Formel (höchstens 240 Zeichen), gehen an OpenRouter/GLM. Mit `--no-cloud` bleibt alles lokal; dann wird nichts automatisch +ersetzt. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ef87880 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 5844a11..353afb7 100644 --- a/README.md +++ b/README.md @@ -3,27 +3,75 @@ Nachbearbeitungs-Werkzeug für die Markdown-Ausgaben von [epub2md](https://kitux.de/forgejo/dschlueter/epub2md) und [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**. -Zweifelsfälle gehen in eine Review-Galerie, alles andere bleibt Bild. +LaTeX ersetzt – **aber nur, wenn drei unabhängige Bildmodelle dieselbe Formel lesen** und ein grober +Bildabgleich nicht widerspricht. Alles andere geht in eine Review-Galerie oder bleibt Bild. -Schritt-für-Schritt-Anleitung: [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). Hintergrund und -ursprüngliche Planung: `Idee/`. +Schritt-für-Schritt-Anleitung: [BEDIENUNGSANLEITUNG.md](BEDIENUNGSANLEITUNG.md). Änderungen je Version: +[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) -> 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)). +## Annahmeregel + +> 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 ```text 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 -verify Kandidat rendern (pdflatex), mit dem Original vergleichen, Verwechslungsvarianten prüfen, entscheiden +run in Paketen zu 50: Qwen + Gemma lesen (parallel), prüfen, GLM nur für Einige, prüfen; + nach jedem Paket eine Statuszeile 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) revert Markdown-Dateien auf den Stand vor apply zurücksetzen -run scan + recognize lokal + verify + recognize Cloud (nur Ungelöste) + verify -calibrate Messung an Formelbildern mit bekanntem TeX (Springer-EPUBs: TeX im alt-Text) +recognize einzelne Engine (local, local2, cloud) laufen lassen +verify prüfen und entscheiden (ohne neue Modellabfragen) +status Zählung je Status +calibrate Messung an Formelbildern mit bekanntem TeX (--consensus: Konsensregel) ``` ```bash @@ -33,104 +81,121 @@ python3 formula_img2latex.py apply output/buch 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`, `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__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 ```text .formulas/ # neben der Ausgabe, weil epub2md --force die Ausgabe ersetzt -├── workspace.json # Quelle, Schrift, Pixel/Punkt, Schwellen, apply-Zustand -├── formulas/f--.json # je Bild und Darstellung: Kandidaten, Prüfung, Entscheidung -├── review/-{orig,render,diff}.png +├── workspace.json # Quelle, Schrift, Einstellungen, apply-Zustand +├── formulas/f--.json # je Bild und Darstellung: Lesarten, Prüfung, Entscheidung +├── review/-{orig,render,rigid,diff}.png └── 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 -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. +Die Datensätze hängen am SHA-256 des Bildes: Ein erneuter epub2md-Lauf verliert keine Entscheidungen. + +Einstellungen in `workspace.json` → `settings.consensus_thresholds`: `require_third` (1), +`bold_relevant` (0), `veto_ncc` (0,65), `veto_scale_dev` (0 = aus), `min_p_mean`/`min_p_min` (0). ## Quellen | Quelle | Erkennung | Begrenzer | | --- | --- | --- | -| epub2md-Ordner | `conversion-report.json` (`tool: epub2md`); Warnungen `formula-image` markieren sichere Formeln | `--math auto`: `$…$`/`$$…$$`, `--math tex`: `\(…\)`/`\[…\]` | -| pdf2md-Datei `.md` + `_images/` oder `--split`-Ordner | Bildname `--`; 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 | `$…$`/`$$…$$` | +| epub2md-Ordner | `conversion-report.json`; Warnungen `formula-image` markieren sichere Formeln | `--math auto`: `$…$`/`$$…$$`, `--math tex`: `\(…\)`/`\[…\]`; deutsche Bücher: `3{,}6` | +| pdf2md (`.md` + `_images/` oder `--split`-Ordner) | Bildname `--`; 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, 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//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 | -| --- | --- | --- | -| `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` | +## Stand -Getestet: **Qwen3-VL-30B-A3B-Instruct** (Q4_K_M + mmproj) auf einer RTX 3090 mit 0,2–0,5 s je Formel; -Gehrke Brückenkurs (1329 Bilder) in 9 Minuten. +Version 0.2.0 (Konsens), getestet am Gehrke, Brückenkurs Mathematik (EPUB, CM-Formeln als kleine JPGs): -## 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 - 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 - geschickt wird überwiegend wegen **typografischer** Abweichungen der OCR, z. B. - `\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. +Grenzen und offene Punkte: + +- Mehrzeilige Rechnungen mit Ausrichtung, `\underbrace` oder Zeilenumbrüchen schreiben die Modelle + unterschiedlich – sie bilden den Großteil des Reviews. - Formeln auf farbig hinterlegten Kästen verlieren beim Ersetzen die Hinterlegung. - -Nächste Schritte: Schwellen mit `calibrate` an den layouttreuen Varianten neu festlegen; Korrekturrunde -mit Diff-Bild; abschnittsweiser Vergleich (Zeilen/Spalten getrennt ausrichten) für mehrzeilige Formeln -fremder Satzsysteme; Inline-Formeln in PDFs. +- Fettdruck wird nicht geprüft (Vorgabe). +- Die Messung umfasst 2 × 150 Formeln eines Buches ohne Textausschnitt; „0 Fehler“ bedeutet eine + Fehlerquote sehr wahrscheinlich unter ~2 %. +- 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 ```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-dev.txt # pytest ``` +Modelle: siehe BEDIENUNGSANLEITUNG (llama-server starten). + ## Tests ```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, @@ -141,17 +206,18 @@ Rückgabecodes: `0` Erfolg, `1` Bedienfehler, `2` Eingabe ist keine epub2md-/pdf ```text formula_img2latex/ ├── cli.py Unterbefehle, Exit-Codes -├── pipeline.py scan, recognize, verify (Prozess-Pool), Entscheidung, apply/revert -├── sources/ epub2md.py, pdf2md.py, base.py (Markdown-Scan, Begrenzer) -├── pdfcrop.py Formelbild auf der PDF-Seite finden, 600-dpi-Neuausschnitt, Mathe-Schrift-Erkennung -├── classify.py Formel / unklar / Abbildung (Farbe der Tinte, Graustufen, Größe, Kontext) -├── engines/ Vision-Modelle über die OpenAI-API (llama.cpp, OpenRouter) +├── pipeline.py scan, recognize, verify, Entscheidung (Konsens/Pixel), run in Paketen, apply/revert +├── consensus.py Normalform, Render-Gleichheit: sagen zwei Lesarten dasselbe? +├── decide.py Annahmeregeln und Schwellen (Konsens- und Pixel-Modus) +├── engines/ Vision-Modelle über die OpenAI-API (llama.cpp, OpenRouter), Logprobs, Prompt-Version +├── 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 ├── imageio.py Originale laden (PNG/GIF/JPG/SVG, Transparenz, Tönung) -├── compare.py Ausrichtung, Kennzahlen, Kontrasttest, Diff-Bilder -├── confusables.py Verwechslungsvarianten, layouttreue Prüfvarianten -├── verify.py Prüfung + Selbstkorrektur -├── decide.py Schwellen und Status +├── sources/ epub2md.py, pdf2md.py, base.py (Markdown-Scan, Begrenzer, Dezimalkomma) +├── pdfcrop.py Formelbild auf der PDF-Seite finden, 600-dpi-Neuausschnitt +├── classify.py Formel / unklar / Abbildung ├── checks.py Pandoc/MathML-Prüfung ├── calibrate.py Messung an Formelbildern mit bekanntem TeX ├── store.py Arbeitsordner, Datensätze diff --git a/formula_img2latex/__init__.py b/formula_img2latex/__init__.py index 8711171..3c853e7 100644 --- a/formula_img2latex/__init__.py +++ b/formula_img2latex/__init__.py @@ -1,3 +1,3 @@ """formula_img2latex: Formelbilder in Markdown geprüft durch LaTeX ersetzen.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/formula_img2latex/cli.py b/formula_img2latex/cli.py index 548bfc1..a1f07dd 100644 --- a/formula_img2latex/cli.py +++ b/formula_img2latex/cli.py @@ -73,7 +73,7 @@ def build_parser() -> argparse.ArgumentParser: sp = sub.add_parser("recognize", help="LaTeX-Kandidaten mit einem Vision-Modell erzeugen") common(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", 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-cloud", action="store_true", help="Cloud-Stufe überspringen") 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)") common(sp) @@ -185,6 +186,12 @@ def _dispatch(args: argparse.Namespace) -> int: elif args.command == "verify": pipeline.verify_all(source, ws, None if args.preset == "auto" else args.preset, args.include_figures, 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": preset = None if args.preset == "auto" else args.preset pipeline.scan(source, ws, quiet=q) diff --git a/formula_img2latex/decide.py b/formula_img2latex/decide.py index a58e888..f488b53 100644 --- a/formula_img2latex/decide.py +++ b/formula_img2latex/decide.py @@ -67,12 +67,14 @@ def decide(ver: dict[str, Any], pandoc_ok: bool, book_px_per_pt: float | None, # --------------------------------------------------------------------------- consensus mode CONSENSUS_DEFAULTS: dict[str, float] = { - "min_p_mean": 0.90, # geometric mean of the LaTeX token probabilities, each reading - "min_p_min": 0.30, # least certain LaTeX token, each reading - "veto_ncc": 0.75, # rough image check: the agreed reading must resemble the original - "veto_scale_dev": 0.15, # and have the book's font size (px/pt) within 15 % + "min_p_mean": 0.0, # geometric mean of the LaTeX token probabilities (measured: no gain over + "min_p_min": 0.0, # the third reading, only less coverage; kept configurable) + "veto_ncc": 0.65, # rough image check: the agreed reading must resemble the original. Measured: + # 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 - "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: """None if the reading is confident enough, else a German reason.""" 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: return f"{r.get('model', '?')}: keine Token-Wahrscheinlichkeiten" 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) break 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 if 2 in (i, j) or i >= 2 or j >= 2: 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]) return "needs_review", f"zwei Modelle einig ({how}), dritte Lesart weicht ab: {texts}", i 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] if not pandoc_ok: 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", {}) if m.get("ncc", 0.0) < 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") if problems: return "needs_review", f"einig ({how}){tie}; " + "; ".join(problems), use diff --git a/formula_img2latex/engines/__init__.py b/formula_img2latex/engines/__init__.py index 4b04a26..79d65c0 100644 --- a/formula_img2latex/engines/__init__.py +++ b/formula_img2latex/engines/__init__.py @@ -14,7 +14,10 @@ from .vlm import VisionEngine DEFAULT_LOCAL_URL = "http://127.0.0.1:8011/v1" DEFAULT_LOCAL2_URL = "http://127.0.0.1:8012/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: @@ -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), api_key=os.environ.get("OPENROUTER_API_KEY", ""), 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)") diff --git a/formula_img2latex/engines/vlm.py b/formula_img2latex/engines/vlm.py index 8bd986e..90d0f66 100644 --- a/formula_img2latex/engines/vlm.py +++ b/formula_img2latex/engines/vlm.py @@ -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 MAX_UPSCALE = 8 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). @@ -184,6 +187,13 @@ class VisionEngine: "Server starten (siehe BEDIENUNGSANLEITUNG) oder --engine cloud verwenden.", 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]: h = {"Content-Type": "application/json"} if self.api_key: @@ -201,10 +211,10 @@ class VisionEngine: body = { "model": self.model, "temperature": 0, - "max_tokens": 1500, + "max_tokens": MAX_TOKENS, "logprobs": True, "top_logprobs": 1, - **({"chat_template_kwargs": {"enable_thinking": self.thinking}} if self.thinking is not None else {}), + **self._thinking_param(), "messages": [{ "role": "user", "content": [ diff --git a/formula_img2latex/pipeline.py b/formula_img2latex/pipeline.py index 7a9306b..1f4a5a0 100644 --- a/formula_img2latex/pipeline.py +++ b/formula_img2latex/pipeline.py @@ -5,8 +5,10 @@ from __future__ import annotations import hashlib import io import os +import re import shutil import statistics +import threading import sys from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed from pathlib import Path @@ -20,6 +22,7 @@ from .classify import classify from .compare import diff_image from .decide import consensus_thresholds, decide, decide_consensus, thresholds from .engines import make_engine +from .engines.vlm import PROMPT_VERSION from .imageio import load_ink from .render import PRESETS from .sources import open_source @@ -29,6 +32,7 @@ from .tex import normalize_for_compare, split_tag, strip_delimiters from .verify import verify PRESET_SAMPLE = 12 +_SAVE_LOCK = threading.Lock() def log(msg: str, quiet: bool = False) -> None: @@ -104,19 +108,28 @@ def _ref_for(source: Source, f: Formula) -> ImageRef: # --------------------------------------------------------------------------------- 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, 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.check() todo = [] 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 if only_unresolved and f.status in ("auto_accepted", "not_formula"): continue - if any(c.get("engine") == engine.name and c.get("model") == engine.model for c in f.candidates): - continue # cached: never ask the same model twice + if only_needing_third and not needs_third(f): + 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) if 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_out"] += rec.tokens_out 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()} - 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: stats["error"] += 1 elif rec.tex: stats["latex"] += 1 else: stats["not_formula"] += 1 - ws.save(f) if not quiet and (i % 25 == 0 or i == len(todo)): log(f" {i}/{len(todo)}") 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, workers: int = 0, redo: bool = False, quiet: bool = False, - progress: Callable[[int, int], None] | None = None) -> dict[str, int]: - formulas = [f for f in ws.all() if _wanted(f, include_figures) and _distinct_candidates(f)] + 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) + 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: - 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: preset = ws.settings.get("preset") or choose_preset(source, ws, formulas, quiet) ws.settings["preset"] = preset @@ -237,8 +256,8 @@ def verify_all(source: Source, ws: Workspace, preset: str | None = None, include jobs = [] for f in formulas: ref = _ref_for(source, f) - jobs.append((f.id, source.original_bytes(ref), f.asset, _distinct_candidates(f), f.display, preset, - decide_mode(ws) == "pixel")) + jobs.append((f.id, source.original_bytes(ref), f.asset, _verify_texes(f, mode), f.display, preset, + mode == "pixel")) log(f"verify: {len(jobs)} Formeln, Schrift {preset}", quiet) by_id = {f.id: f for f in formulas} 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]): fid, results, pngs = fut.result() 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, 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()} for key, png in pngs.items(): (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)) elif not quiet and (done % 25 == 0 or 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: - return hashlib.sha256("\n".join(sorted(_distinct_candidates(f))).encode()).hexdigest()[:16] +VERIFY_VERSION = 3 # bump when the comparison changes, so stored image checks are recomputed -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 {} - return v.get("candidates_key") == _cands_key(f) + return v.get("candidates_key") == _cands_key(f, mode) 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)) -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": - 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")) 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. @@ -320,23 +357,40 @@ def decide_all(ws: Workspace, quiet: bool = False, preset: str | None = None) -> 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 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 = [] for f in formulas: rs = readings_of(f) bold = bool(th.get("bold_relevant")) - agree = [(i, j, how) for i in range(len(rs)) for j in range(i + 1, len(rs)) - for how in [same_reading(rs[i].get("tex") or "", rs[j].get("tex") or "", f.display, preset, bold)] if how] + agree = [] + 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)) # 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 = [] for f, rs, agree in plan: 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: 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") @@ -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 [] counts: dict[str, int] = {} 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) 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() ws.save(f) 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: - """Re-attach an equation number recognized as \\tag{...} – as visible text, like epub2md does.""" - for c in f.candidates: - if c.get("tex"): - _body, tag = split_tag(strip_delimiters(c["tex"])) - if tag: - return tex + f"\\qquad\\text{{({tag})}}" if f.display else tex - return tex + """Turn an equation number of the *chosen* reading (\\tag{3.7}) into visible text, like epub2md does. + + Only the accepted reading counts – never other or older readings. In consensus mode the number + is part of the compared canonical form, so all agreeing readings carry the same number. + """ + body, tag = split_tag(strip_delimiters(tex)) + if not tag: + return body + return body + f"\\qquad\\text{{({tag})}}" if f.display else body # ------------------------------------------------------------------------------ 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"): source.pdf = Path(ws.meta["source"]["pdf"]) 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 diff --git a/formula_img2latex/review/server.py b/formula_img2latex/review/server.py index eaa5765..9c9c35d 100644 --- a/formula_img2latex/review/server.py +++ b/formula_img2latex/review/server.py @@ -18,7 +18,7 @@ from urllib.parse import unquote, urlparse from ..checks import pandoc_mathml_ok 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 ..store import Formula, Workspace, now_iso from ..tex import strip_delimiters, unsafe_reason @@ -31,7 +31,7 @@ _LOCK = threading.Lock() def _card(f: Formula) -> dict[str, Any]: 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 { "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), diff --git a/formula_img2latex/sources/base.py b/formula_img2latex/sources/base.py index 595cce4..ae795d5 100644 --- a/formula_img2latex/sources/base.py +++ b/formula_img2latex/sources/base.py @@ -103,9 +103,15 @@ class Source: def math_delimiters(self) -> tuple[str, str, str, str]: return "$", "$", "$$", "$$" + def language(self) -> str: + return "" + def format_math(self, tex: str, display: bool) -> str: il, ir, dl, dr = self.math_delimiters() 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: return f"{dl}{body}{dr}" return f"{il}{body}{ir}" diff --git a/formula_img2latex/sources/epub2md.py b/formula_img2latex/sources/epub2md.py index 1e95dda..9e5a11f 100644 --- a/formula_img2latex/sources/epub2md.py +++ b/formula_img2latex/sources/epub2md.py @@ -59,6 +59,11 @@ class Epub2MdSource(Source): def known_formula(self, ref: ImageRef) -> bool: 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: return str((self.report.get("options") or {}).get("math", "auto")) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 1fdd672..2f59a26 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -131,22 +131,33 @@ def test_consensus_mode(epub2md_dir: Path) -> None: ws.save(display) pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True) 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 "uneinig" in st["0002-display.jpg"].reason - # a third reading breaks the tie - d = st["0002-display.jpg"] + # the third reading confirms A = B -> accepted; for A != B a third reading does not decide 2:1 + 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")) + ws.save(i) ws.save(d) pipeline.verify_all(source, ws, preset="cm", workers=2, quiet=True) - d = ws.load(d.id) - assert d.status == "auto_accepted" and "Stichwahl" in d.reason, d.reason + i, d = ws.load(i.id), ws.load(d.id) + 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: 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}") 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" @@ -156,11 +167,18 @@ def test_consensus_rules() -> None: 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 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 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([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: @@ -185,3 +203,34 @@ def test_logprob_confidence() -> None: 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 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 diff --git a/tools/vergleich.py b/tools/vergleich.py index 2eac2dd..2c16477 100644 --- a/tools/vergleich.py +++ b/tools/vergleich.py @@ -1,71 +1,55 @@ #!/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//sample.json and reused on later runs, so -changes to the procedure are compared on the same formulas. Output (not versioned, it shows -excerpts of the book): +changes to the procedure are compared on the same formulas. The formulas are processed exactly +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// - ├── index.html all formulas: image panel, LaTeX, measurements, decision, reason - ├── summary.json counts per status, all numbers - ├── sample.json the formula ids of the sample - └── NN-.png Original / LaTeX rigid / LaTeX elastic / difference + vergleich//index.html per formula: original, rendered readings, decision, veto overlay + vergleich//summary.json counts and all readings """ from __future__ import annotations import argparse +import base64 import html import io import json import random import sys -from concurrent.futures import ProcessPoolExecutor, as_completed 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])) -from formula_img2latex.checks import pandoc_mathml_ok # noqa: E402 -from formula_img2latex.decide import decide, thresholds # noqa: E402 -from formula_img2latex.pipeline import _distinct_candidates, _ref_for, _verify_job, open_all # noqa: E402 +from formula_img2latex.pipeline import _ref_for, _wanted, open_all, readings_of, run_consensus # noqa: E402 +from formula_img2latex.render import render_many # noqa: E402 +from formula_img2latex.tex import strip_delimiters # noqa: E402 -FONT = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" -LABELS = (("orig", "Original"), ("rigid", "LaTeX starr"), ("render", "LaTeX elastisch"), ("diff", "Differenz")) +NAMES = {"local": "Qwen", "local2": "Gemma", "cloud": "GLM"} +COLOR = {"auto_accepted": "#1f7a3a", "needs_review": "#a15c00"} -def panel(pngs: dict[str, bytes], target_h: int = 70) -> Image.Image: - """Stack the four images, enlarged with visible pixels, each with a label.""" - imgs = {k: Image.open(io.BytesIO(v)).convert("RGB") for k, v in pngs.items()} - if not imgs: - return Image.new("RGB", (300, 40), "white") - h0 = next(iter(imgs.values())).height - f = max(1, min(8, round(target_h / max(h0, 1)))) - try: - font = ImageFont.truetype(FONT, 14) - except OSError: - font = ImageFont.load_default() - rows = [] - for key, label in LABELS: - if key not in imgs: - 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 uri(img: Image.Image) -> str: + buf = io.BytesIO() + img.save(buf, "PNG") + return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode() + + +def original(data: bytes, target_h: int = 60) -> Image.Image: + im = Image.open(io.BytesIO(data)) + if im.mode in ("P", "LA", "RGBA") or "transparency" in im.info: + im = im.convert("RGBA") + im = Image.alpha_composite(Image.new("RGBA", im.size, "white"), im) + im = im.convert("RGB") + f = max(1, min(6, round(target_h / max(im.height, 1)))) + return im.resize((im.width * f, im.height * f), Image.NEAREST) def main() -> int: @@ -73,10 +57,9 @@ def main() -> int: ap.add_argument("source", type=Path) ap.add_argument("--n", type=int, default=50) ap.add_argument("--seed", type=int, default=1) - ap.add_argument("--name", default=None, help="Unterordner in vergleich/ (Standard: -)") - ap.add_argument("--preset", default=None) - ap.add_argument("--workers", type=int, default=16) - ap.add_argument("--new-sample", action="store_true", help="neue Stichprobe ziehen") + ap.add_argument("--name", default=None) + ap.add_argument("--new-sample", action="store_true") + ap.add_argument("--no-cloud", action="store_true") args = ap.parse_args() source, ws = open_all(args.source, None) @@ -84,74 +67,68 @@ def main() -> int: out = Path(__file__).resolve().parents[1] / "vergleich" / name out.mkdir(parents=True, exist_ok=True) 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: ids = json.loads(sample_file.read_text()) else: 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)) - 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) - 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]))) + run_consensus(source, ws, chunk=len(ids), ids=ids, use_cloud=not args.no_cloud) - for old in out.glob("*.png"): - old.unlink() + formulas = [ws.load(i) for i in ids] + 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] = {} - rows_html, summary = [], [] + summary = [] for n, f in enumerate(formulas, 1): - best, pngs = results[f.id] - status, reason = decide(best, pandoc[f.id], book_ppt, th) - counts[status] = counts.get(status, 0) + 1 - img = f"{n:02d}-{f.id}.png" - panel(pngs).save(out / img) - m = best.get("metrics", {}) - mc = best.get("min_contrast") - summary.append({"n": n, "id": f.id, "status": status, "reason": reason, "tex": best.get("tex"), - "ocr": [c.get("tex") for c in f.candidates if c.get("tex")], "display": f.display, - "min_contrast": mc, "worst_variant": best.get("worst_variant"), - "worst_kind": best.get("worst_kind"), "climbed_from": best.get("climbed_from"), - "metrics": m}) - climbed = "" - if best.get("climbed_from"): - climbed = "
korrigiert aus: " + html.escape(best["climbed_from"][0]) + "
" - rows_html.append( - f"

{n:02d} · {status} · " - f"{'abgesetzt' if f.display else 'im Text'} · {html.escape(f.occurrences[0]['file'])}:{f.occurrences[0]['line']}

" - f"
{html.escape(best.get('tex') or '')}
{climbed}" - f"
NCC elastisch {m.get('ncc', 0):.3f} · starr {m.get('rigid_ncc', 0):.3f} · " - f"Verzerrung {m.get('warp_share', 0):.2f} · px/pt {m.get('px_per_pt', 0):.3f} · " - f"Kontrast {('–' if mc is None else f'{mc:.3f}')} (knappste Variante: {html.escape(best.get('worst_kind') or '–')}) · " - f"Pandoc {'ok' if pandoc[f.id] else 'FEHLER'}
{html.escape(reason)}
") + counts[f.status] = counts.get(f.status, 0) + 1 + orig = original(source.original_bytes(_ref_for(source, f))) + lines = [f"Original"] + reads = [] + for r in readings_of(f): + rr = next(rendered) + img = "" + if rr.ok: + z = 2.0 * max(1.0, orig.height / 60) + g = (rr.rasterize(min(z, 4.0)) * 255).astype(np.uint8) + img = uri(Image.fromarray(255 - g)) + who = NAMES.get(r.get("engine", ""), r.get("engine", "?")) + reads.append({"engine": who, "model": r.get("model"), "tex": r.get("tex"), "p_mean": r.get("p_mean")}) + p = r.get("p_mean") + lines.append(f"{who}{f'' if img else '(nicht renderbar)'}" + f"{html.escape(r.get('tex') or '–')}" + f"{f' p={p:.2f}' if p is not None else ''}") + diff = ws.review_dir / f"{f.id}-diff.png" + if diff.exists(): + d = Image.open(diff).convert("RGB") + d = d.resize((d.width * 2, d.height * 2), Image.NEAREST) + lines.append(f"Veto-Overlayrot = nur Original, " + "blau = nur LaTeX, schwarz = beide") + color = COLOR.get(f.status, "#a32020") + occ = f.occurrences[0] if f.occurrences else {} + rows.append(f"

{n:02d} · {f.status} · " + f"{'abgesetzt' if f.display else 'im Text'} · {html.escape(occ.get('file', ''))}:{occ.get('line', '')}

" + f"
{html.escape(f.reason or '')}
{''.join(lines)}
") + 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())) (out / "index.html").write_text( "Vergleich " + html.escape(name) + "" - f"

Vergleich {html.escape(name)}

{len(formulas)} Formeln, Schrift {preset} · {head}

" - "

Zeilen je Bild: Original · LaTeX starr ausgerichtet · LaTeX elastisch ausgerichtet · Differenz " - "(schwarz = beide, rot = nur Original, blau = nur LaTeX, gelb = Abweichungsfleck)

" - + "".join(rows_html), encoding="utf-8") - (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}") + "border-left:6px solid #999;border-radius:6px;padding:8px 12px;margin:10px 0}h2{font-size:14px;margin:0 0 4px}" + "th{text-align:left;font-weight:400;color:#666;width:100px;vertical-align:middle}td{padding:2px 8px;vertical-align:middle}" + ".r{font-size:13px;margin-bottom:4px}code{font-size:12px}img{max-width:760px}" + f"

Vergleich {html.escape(name)}

{len(formulas)} Formeln · Regel: Qwen = Gemma = GLM, Fettdruck egal, " + f"Bild-Veto · {head}

" + "".join(rows), encoding="utf-8") + (out / "summary.json").write_text(json.dumps({"counts": counts, "formulas": summary}, ensure_ascii=False, indent=1), + encoding="utf-8") + print(f"{len(formulas)} Formeln – {head}") print(f"Übersicht: {out / 'index.html'}") return 0