spike(ocr): TrOCR → ONNX Export & Machbarkeitsnachweis auf Android #77
Labels
No labels
app/archiv
app/einkaufslisten
app/imap-client
app/wissensbasis
arch-answered
arch-question
area/api
area/auth
area/infra
area/mobile
area/shared
area/ui
area/web
portfolio-status
prio/high
prio/low
prio/medium
roadmap/public
size/l
size/m
size/s
size/xl
size/xs
status/blocked
status/needs-info
type/bug
type/chore
type/docs
type/feature
type/idea
type/refactor
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
admin-mrrm/mrrmlabapp#77
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Ziel
Nachweis dass TrOCR-small-handwritten als ONNX-Modell auf einem Pixel 8 Pro via NNAPI läuft und die Inferenzzeit unter 10 s pro Zeile liegt.
Hintergrund
TrOCR besteht aus zwei Teilen:
Beide müssen separat exportiert und als ONNX-Graph gespeichert werden (
encoder_model.onnx,decoder_model.onnx,decoder_model_merged.onnx).Aufgaben
optimumexportiertonnxruntimelokal testen (Python) – Ergebnis identisch mit transformers?onnxruntime.quantizationanwendenonnxruntime-react-nativeBeispiel-App durchführenErgebnis
Dokumentierter Spike-Bericht: Inferenzzeit, Modellgröße, Erkennungsqualität im Vergleich zum Server.
Akzeptanzkriterien
tools/export-trocr-onnx.pyKick-off — v0.5-OCR-Spike startet jetzt (Risiko-Frontloading)
Per GF-Entscheidung in #412 (KW 2026-W22, Punkt a) wird dieser Spike jetzt gestartet, parallel zu den v0.4-Mail-Resten.
Warum jetzt statt nach v0.4-Finish:
Spike-Scope (zur Erinnerung aus Issue-Body):
Timebox: 1 Woche (Stand 2026-05-27 → Review-Punkt 2026-06-03).
Exit-Kriterien:
Tag 1 — ONNX-Export-Pipeline läuft ✅
Setup: dev-neu (Debian, x86_64, 4 CPU, 8GB RAM, kein GPU).
~/ocr-spike/.venv,requirements-spike.txtfestgehalten.Was funktioniert
optimum-cli export onnx --model microsoft/trocr-small-printed --task image-to-text-with-pastläuft sauber durch nach Stack-Pin (siehe Stolpersteine).generate()auf einem 480×80-Sample — ohne Quantisierung. Bonus.Modell-Footprint (fp32)
encoder_model.onnxdecoder_model_merged.onnxdecoder_model.onnxdecoder_with_past_model.onnxMobile-Payload fp32: ~247 MB (encoder + merged-decoder). Klar zu groß für eine APK — Quantisierung ist Pflicht-Schritt, kein Nice-to-have.
Stack-Pin (Repro)
Python 3.13.5 erforderte zusätzliche Disziplin (Wheel-Verfügbarkeit pinnt Torchvision-Versionen).
Stolpersteine (für nächsten Spike-Tag dokumentiert)
optimum/exporters/onnx/ist gelöscht, CLI hat keine Subcommands mehr. Pin auf 1.24.0.TorchExportError: dim hint conflict. Pin auf 2.6 (letzte mit legacy default).onnxscriptist neue impliziter Dep ab torch 2.6 ONNX-Export, separat installieren.TrOCRProcessorhaben statischesshape[1]=3— Dynamo-Exporter respektiert die nicht.Quality-Beobachtung (NICHT blocking, aber für Tag 3 vormerken)
Off-the-shelf
trocr-small-printedliefert auf PIL-DejaVu-gerenderten Strings Quatsch ('Milch'→'MERIT:'). Das ist kein Export-Bug (PyTorch macht das gleiche), sondern Modell-Domain-Mismatch: das Modell ist auf gedrucktem Text aus Dokumenten/Receipts trainiert, nicht auf synthetisch gerenderten Antialiased-Texten. Echte Einkaufszettel-Crops (ausapps/ocr/finetune/-Trainingsdaten) sind der relevante Quality-Test, kommen Tag 3.Tag-2-Vorhaben
onnxruntime.quantizationStatus: 🟢 grünes Licht für Tag 2.
Tag 2 — Quantisierung: Size-Ziel knapp verfehlt, Quality-Drift dokumentiert 🟡
Setup: dynamic int8 (
QUInt8) viaonnxruntime.quantization.quantize_dynamic. Sample-Set: 15 PIL-DejaVu-gerenderte typische Einkaufszettel-Items.Größen-Ergebnis
encoder_model.onnxdecoder_model.onnxdecoder_with_past_model.onnxTag-1-Ziel war < 80 MB — verfehlt (97.5 MB). Möglich darunter zu kommen via:
decoder_with_past_model.onnxshippen, Empty-Cache für Token 0): ~60 MBFür Spike-Zwecke: 97.5 MB ist mobile-shippable (APKs der App liegen aktuell bei ~141 MB ohne Modell, also +70%). Optimierung in Folge-Spike.
Stolperstein:
decoder_model_merged.onnxquantisiert nichtOptimum exportiert per Default eine „merged"-Variante (
decoder_model_merged.onnx), die beide Modi (mit/ohne KV-Cache) in einemIf-Op vereint.quantize_dynamicläuft NICHT inIf-Subgraphs hinein → Modell blieb bei 152 MB (statt erwartet ~40 MB). Lösung: non-merged-Paar quantisieren (decoder_model.onnx+decoder_with_past_model.onnx),decoder_model_merged.onnxaus dem int8-Artefakt entfernen. Optimum'sORTModelForVision2Seqlädt beide separat und wählt zur Laufzeit.Parität + Latenz (dev-neu CPU, 15 Samples)
Beobachtete Diffs (alle char-swap-level)
Pattern: autoregressiver Decoder amplifiziert int8-Rauschen → kleine per-Step-Fehler propagieren in der Sequenz. Klassisches Verhalten für dynamic-quant ohne Calibration-Set.
Bewertung 🟡
Folge-Spike-Notiz (nicht jetzt blocking)
Für Produktions-Qualität ist mindestens eines davon nötig:
apps/ocr/finetune/-Daten) — bessere int8-TreueTag-3-Vorhaben (Mobile-Integration)
.onnx-Files + Tokenizer-Configs inapps/mobile/assets/ocr-model/OcrServiceanalognli-classifier.ts(Singleton, lazy-load,onnxruntime-react-native)Status: 🟡 grün-gelb für Tag 3. Die offenen Fragen sind weniger „funktioniert es?" sondern „wie gut auf realen Daten?" — und das beantwortet Tag 3+ besser als weiteres synthetisches Benchmarking auf dev-neu.
Tag 3 — Mobile-Scaffold steht, EAS-Build vorbereitet 🟢
Branch:
spike/77-ocr-mobile-scaffold(commita5a4ee6)Deliverables
OcrServiceSingletonapps/mobile/src/services/ocr-service.tsapps/mobile/src/services/ocr-service.spec.tsapps/mobile/app/ocr-spike.tsxService-Design
Spiegelt
nli-classifier.ts-Pattern:ensureReady+initPromise-Race-Guard (lazy-load einmal über mehrererecognize()-Calls)idle→downloading→loading→ready(odererror)OCR_MAX_NEW_TOKENS=128-CapSpec-Coverage (TDD red→green)
pnpm --filter mobile test ocr-service.spec.ts→ 6/6 passed in 36ms. Typecheck (tsc --noEmit) clean.Bewusst deferred (Tag-4-Arbeit)
preprocessImage()ist aktuell Float32-Zeros-Tensor[1, 3, 384, 384]. Real-Impl (expo-image-manipulator → resize → base64 → RGB-extract → TrOCR-mean/std-normalize) ist Tag-4-Arbeit, weil sie nativ-only-deps braucht und auf dem Device gegen echte Sample-Bilder validiert werden muss.microsoft/trocr-small-printed/encoder_model.onnx+decoder_model.onnx— fp32, ~247 MB). Für den ersten EAS-Build OK; Bundling der int8-Variante (97.5 MB total, aus Tag 2) inapps/mobile/assets/ocr-model/kommt in Tag 4, sobald wir wissen ob das ORT-RN-asset-loading-Pattern überhaupt mit demdecoder_with_past-Trick zurechtkommt.ocr-spike.tsxreferenziertasset:///ocr-sample.jpg, die noch nicht im Bundle ist. Bei Tag-4-EAS-Build wird ein echtes Einkaufszettel-Sample mitgebundlet.Tag-4-Plan (EAS-Build + Pixel 8 Pro)
.onnx-Bundling (Metro asset-extensions config)preprocessImage()ersetzeneas build --profile development --platform androideas build:runoder direktes APK-SideloadTag-5-Plan (Real-Data-Qualität)
apps/ocr/finetune/-Datenpool durchschiebenTag-6 (Buffer + Exit-Decision)
Stand 2026-06-03:
Bewertung
🟢 — Scaffold-Code ist produktionsähnlich (kein wegwerf-Hack), Test-Coverage steht TDD-konform, Tag-4-EAS-Build ist startklar. Risikoexposition ab hier ist device-spezifisch (Hermes ORT-RN-Verhalten, Memory-Footprint, Hardware-Latenz auf realer ARM-CPU) — also genau das, was nur ein Device-Run beantworten kann.
Tag 4 — EAS-Build-Prep komplett, Trigger wartet auf GF 🟢
Branch:
spike/77-ocr-mobile-scaffold(HEAD:60d9c42)Was Tag 4 hinzugefügt hat
.onnxals asset registriert (config.resolver.assetExts)ensureModelDownloaded→resolveAssetPathviaAsset.fromModuleocr-model-assets.ts(vitest-mockbar — Metro-Require isoliert)expo-image-manipulator→jpeg-js→ CHW float32 [-1,1] (TrOCR-Norm: mean=std=0.5)Asset.fromModule, ruftocrService.recognize(uri).gitignore+ README —.onnxbleiben lokal (61 MB sind zu groß für direkten Repo-Commit; Bundling-Strategie kommt mit Tag-6-Decision)expo-asset@~12.0.13, +jpeg-js@^0.4.4Test-Status
pnpm --filter mobile test ocr-service.spec.ts→ 6/6 grün (49 ms)pnpm --filter mobile test(volle Suite) → 67/67 grün (3.1 s, kein Regress)pnpm --filter mobile typecheck→ cleanVor dem EAS-Build (User-Schritt, ~30 s)
Modell-Dateien lokal befüllen — sind via
.gitignoreausgeschlossen:EAS-Build-Trigger (User-Schritt, ~15 min)
Nach erfolgreichem Build:
Messung auf Pixel 8 Pro (User-Schritt)
Direkter Route-Aufruf: App-URL-Scheme
mrrmlab://ocr-spike(Screen ist im Router registriert, aber nicht im Drawer-Menü verlinkt).Messungspunkte:
idle→loading→readyperonProgress; Stoppuhrresult.latencyMs(Display im Screen)adb shell dumpsys meminfo de.mrrm.mrrmlab.devBewusst deferred (Tag 5 / Tag 6)
*.onnxadmin-mrrm/trocr-shopping-int8)Entscheidung gehört in Tag-6-Exit-Diskussion zusammen mit Quality-Befund.
AutoTokenizer.from_pretrainedlädt aktuell zur Laufzeit von HF (~5 MB tokenizer.json). Offline-Tokenizer wäre Tag-5-Polish, nicht Tag-4-Blocker.apps/ocr/finetune/-Pool gegen das on-device-Modell durchschieben.decoder_model.onnxohne Past-Keys. Bei Tag-5-Latenz-Werten ≫ 3s solltedecoder_with_past_model.onnxintegriert werden — sollte Inference 2-4× beschleunigen.Tag-5-Plan
apps/ocr/finetune/)Bewertung
🟢 — Code-Seite ist vollständig EAS-build-ready. Die Risikoexposition verschiebt sich ab hier von Engineering-Risiko (geht der Stack technisch?) zu Domain-Risiko (ist die Qualität auf realen Daten gut genug für v0.5?). Das ist exakt das Risiko-Splitting, das wir mit dem Spike-Frontloading erreichen wollten.
Nächster Schritt: GF entscheidet, ob EAS-Build jetzt direkt gefeuert wird (~15 min Build + ~5 min Install/Test) oder ob ein bestimmtes Zeitfenster bevorzugt wird. Sobald Build durch ist, kann ich die Messwerte selbst dokumentieren — du brauchst nur das Pixel-Tap + ggf. Screenshot des Spike-Screens.
Tag-4-Report — Device-Run auf Pixel ✅
Pipeline läuft end-to-end auf Gerät. EAS-Dev-Client-APK (47 MB inkl. int8-Modelle) → Modelle laden cold → Bild-Capture → Preprocess → Encoder+Decoder-Inferenz → Text-Output.
Messungen
ocr-sample.jpg("Milch 1L", DejaVu-Render, 4.8 KB)"Milch I"← echtes OCR-Ergebnis, kein Token-LoopBugfix unterwegs
Erster Device-Run produzierte Token-Loop-Garbage (
.H LLNL...NHLNHL). Ursache: Xenova/trocr-small-printed-Tokenizer hatbos_token_id=null— unsere Greedy-Decode-Schleife seedete den Decoder mit null statt mit demdecoder_start_token_id(=2 lautgeneration_config.json). Fix in820e220— hardcodedOCR_DECODER_START_TOKEN_ID=2+ Regression-Spec.Accuracy-Beobachtung (Input für Tag 5)
"Milch 1L" → "Milch I": klassische Small-Printed-Confusable nach dynamic-int8-Quantisierung. "1L" wird als "I" gelesen (eine Glyphe statt zwei). Hypothesen für Tag-5-Sample-Set:
trocr-base-printedals Fallback wenn-smalldurchfälltOffen für Tag 5
decoder_with_past_model.onnx) für Latenz-ReduktionTag-4-Exit
🟢 Pipeline ist machbar. Latenz akzeptabel für Demo-Use-Case (Async-OCR via BatchTask, nicht interaktiv). Accuracy noch offen — Tag-5-Sample-Set entscheidet 🟢/🟡/🔴 für Exit-Decision am 2026-06-03.
Tag-5-Final-Report — alle Exit-Kriterien 🟢
Branch:
spike/77-ocr-mobile-scaffold(HEAD:171010d)Was Tag 5 hinzugefügt hat
decoder_with_past_model.onnxOcrServicedecoder_with_past(input_ids length 1 + statische encoder-K/V)/root/ocr-spike/samples/Messungen — alle Ziele erfüllt
Quantisierungs-Drift gemessen (Tag-2-Folgefrage geschlossen)
Fp32-vs-int8 auf demselben 20-Crops-Set:
Erkenntnisse:
Sample-Output (Auszug)
Die meisten Fehler sind 1-Charakter-Confusables (1↔I, l↔I, gh↔sh) — bei Listen-Items meistens noch human-readable und für Fuzzy-Match gegen Geschäfts-Sortiment-DB ausreichend.
Tag-5-Exit-Status
🟢 Alle drei Spike-Exit-Kriterien erfüllt:
Tag 6 — Vorbereitung Exit-Decision (2026-06-03)
Technisch ist der Spike geschlossen. Vor Eröffnung der Folge-Stories (#78-#82) eröffne ich eine architekt-Konsultation (
arch-question) zu folgenden Entscheidungen:-base-printed= 12 layers, 4× größer)?Link folgt sobald die arch-question offen ist.
Arch-Konsultation eröffnet: #413 (
arch-question-Label,arch-botassigned). Vier Designentscheidungen warten auf Architekt-Input vor Eröffnung #78-#82. Deadline für Entscheidungen 1+2+3: 2026-06-03 (Exit-Termin).OCR-Spike — Architecture-Reconciliation
Nach Arch-Konsultation (#413) korrigierte Faktenbasis vor Story-Cut:
Bestehender Production-Stack (≠ neu zu bauen)
apps/ocr/app/ocr.pyapps/api/src/modules/lists/ocr.service.tsparseImage,parseItems,saveTraining,trainingStatsapps/web/src/routes/list-image-{preview,analyze,debug}.tsxapps/mobile/app/lists/[listId]/{image-preview,review}.tsx/training_data(OCR-Container)Spike-Outcome (TrOCR-int8 on-device)
kv_smoke.py)Korrigierte Vision
TrOCR-on-device ersetzt nicht den Server-Stack — beide leben parallel:
parseItemsin ocr.service.ts: Natürlicher Insertionspunkt für Fuzzy-Match-Layer gegen Geschäfts-Sortiment-DB (gilt für beide OCR-Pfade).Story-Cut (verfeinert)
/training_data-Crops statt synthetisch)Spike-Exit — Technical-Yes, Business-No
Tag-6-Exit-Decision vorgezogen (eigentlich für 2026-06-03 geplant, durch #416-Real-Data-Eval entschieden).
Spike-Outcome
Technisch erfolgreich:
kv_smoke.py)Business-Outcome: nicht weiterverfolgen.
Real-Data-Eval (#416) auf 77 echten Einkaufszettel-Crops zeigte CER 0.51 vs. 0.04 synthetisch (12× schlechter). TrOCR-small-printed kann keine Handschrift — Großteil der Real-Daten ist handgeschrieben.
Privacy-Klarstellung des Stakeholders kippte das Wert-Modell: Eigener Server = trusted, Einkaufszettel = nicht-sensitive. Damit fehlt der Hauptgrund für On-Device-OCR (Privacy). Was übrig bliebe (Offline + Latency) rechtfertigt 76 MB Bundle nicht.
Was bleibt im Repo
apps/mobile/src/services/ocr-service.ts+ tests (10 grüne Specs für ORT-RN + KV-cache-Decode)apps/mobile/src/services/ocr-model-assets.tsapps/mobile/assets/ocr-model/(gitignored ONNX-Weights, EAS-Asset-Bundle)apps/mobile/app/ocr-spike.tsx(Deep-Link-Demo)Reference-Wert für eine hypothetische künftige Wiederaufnahme (z.B. wenn Bundle-Kosten drastisch sinken oder Modell-Quality-Sprung passiert).
Folge-Stories
parseItemswird neuer v0.5-OCR-Hauptdeliverableapps/ocr/) — nur wenn Fuzzy-Match-Hit-Rate zeigt, dass Roh-OCR-Qualität der eigentliche Bottleneck istSpike-ROI
Spike hat zu Zeit-Investment den richtigen Lerneffekt geliefert: ohne diesen Spike wäre die On-Device-Pipeline (#81/#82) wahrscheinlich als großer v0.5-Block gestartet und erst nach Wochen Engineering an der Real-Daten-Realität gescheitert. Stattdessen: Pivot nach 5 Tagen mit klarer Datenbasis.