Steven
Steven8 Min. Lesezeit

Von OCR zu Pixeln, ohne den Fallback zu verlieren

GeekBye hörte auf, deine Screenshots per OCR zu lesen, und begann, das echte Bild an Vision-Modelle zu schicken — was der leichte Teil ist. Der lehrreiche Teil ist die Optimierung, die OCR für Vision-Nutzer komplett übersprang, den Fallback zu Nicht-Vision-Modellen still zerbrach und bis zu einer Version zurückgenommen werden musste, in der das Sicherheitsnetz parallel läuft und nichts kostet.

Technik
AI
Desktop
GeekBye-Releases
Von OCR zu Pixeln, ohne den Fallback zu verlieren

Es gibt eine echte Weggabelung für jede App, die dich etwas per Screenshot festhalten und eine KI dazu befragen lässt. Du kannst OCR laufen lassen — den Text auf dem Gerät extrahieren, einen winzigen String senden und das Bild wegwerfen. Oder du kannst die Pixel senden — das echte Bild verschicken und ein vision-fähiges Modell es lesen lassen. OCR ist billig und klein, aber blind für alles, was kein Text ist: das Layout, das Diagramm, die Einrückung des Codes, die rote Schlängellinie unter dem Fehler. Pixel bewahren all das, kosten aber mehr Bytes und verlangen ein Modell, das sehen kann. GeekBye begann auf der OCR-Seite dieser Gabelung und ging über v1.8.6 und v1.8.7 hinüber auf die Pixel-Seite. Dieser Wechsel ist der leichte Teil dieser Geschichte. Der interessante Teil ist die Optimierung, die mitkam, den Fallback zerbrach und rückgängig gemacht werden musste.

Das Davor: den Text lesen, das Bild fallenlassen

Bis zu diesem Bogen wurde aus einem Screenshot so eine Antwort: du drückst das Aufnahme-Kürzel, der Client führt OCR aus — Apples Vision-Framework über ein Swift-VisionOCR-Binary auf macOS, ein PowerShell-Skript auf Windows, beide hinter einer einzigen IOcrEngine-Abstraktion — und der extrahierte Text wird in einen String verpackt und als screenshotText an das Backend geschickt. Das Bild selbst verließ die Maschine nie. Das ist effizient, und für eine Wand aus reinem Text ist es in Ordnung. Aber ein Screenshot ist selten nur Text. Er ist eine Stellenanzeige mit einem zweispaltigen Layout, eine Coding-Aufgabe mit einem Diagramm, ein Fehler mit einer Stack-Trace, deren Einrückung der Hinweis ist. OCR plättet all das zu einer Zeile-für-Zeile-Transkription, und das Modell beantwortet eine dünnere Frage als die, die du gestellt hast.

Der Wechsel: die Pixel senden (v1.8.6)

v1.8.6 fügte den anderen Pfad hinzu. Ein neues imageOptimizer.ts, auf sharp gebaut und daher plattformübergreifend, tut mit einem rohen Screenshot genau drei Dinge:

sharp(pngFilePath).resize({ width: 1920, withoutEnlargement: true }).jpeg({ quality: 80 })

Auf höchstens 1920px Breite skalieren (eine kleinere Aufnahme nie hochskalieren), PNG zu JPEG konvertieren, quality 80. Die Ausgabe ist rohes base64, wobei der Media-Type in einem separaten Feld getragen wird, und sie reist in zwei neuen Request-Feldern neben dem alten zum Backend: screenshotImage und screenshotImageMediaType, unabhängig von screenshotText. Das Backend kann nun ein Bild, Text oder beides empfangen.

Dieser Optimierungsschritt ist nicht kosmetisch. Ein rohes Screenshot-PNG ist 3–8 MB; das skalierte JPEG ist grob 200–500 KB. Das ist eine Größenordnung weniger beim Upload, und es senkt auch die Rechnung des Modells — Vision-Modelle kacheln ein Bild nach seiner Auflösung und rechnen pro Kachel ab, also reduziert das Deckeln der Breite auf 1920 und das Neukomprimieren direkt sowohl die Bytes auf der Leitung als auch die Bild-Tokens, die du bezahlst, ohne nennenswerten Verlust an Lesbarkeit für Text am Bildschirm.

Woher weiß die App überhaupt, ob das Modell sehen kann? Das Backend gibt es bekannt. Die /api/config-Antwort bekam einen ai.vision-Block, nach Plan geschlüsselt, und der Client cached einen einzelnen Boolean — aiSupportsVision — bei jedem Config-Fetch. Wenn Vision unterstützt wird, optimiere das Bild; wenn nicht, falle auf OCR zurück. Also ist die Capability eine Backend-Tatsache, clientseitig gecached, und die Pro-Screenshot-Entscheidung, OCR laufen zu lassen oder zu überspringen, wird auf dem Client aus dieser Flag getroffen. Das Backend besitzt weiterhin, welches Modell tatsächlich antwortet.

Die Optimierung, die zu clever war

Hier wird es lehrreich. Der erste Wurf des Vision-Pfads ließ OCR und Bildoptimierung zusammen laufen, parallel. Dann straffte ein „perf"-Commit es zu einem Entweder-oder: Wenn der Plan Vision unterstützt, optimiere das Bild und überspring OCR komplett; wenn nicht, lass OCR laufen und überspring die Bildarbeit. Auf dem Papier ist das der offensichtliche Gewinn — wozu Text extrahieren, den das Vision-Modell nicht braucht? Das Changelog bewirbt es sogar: „OCR is skipped when the AI can read the image directly".

Das Problem ist das Wort Fallback. Das Backend hat nicht ein Modell; es hat ein primäres und einen Fallback, und der Fallback könnte ein **Nicht-Vision-**Modell sein. Stell dir die Abfolge vor: das primäre Modell deines Plans unterstützt Vision, also überspringt der Client stolz OCR und schickt nur das Bild. Der primäre Anbieter hat einen Schluckauf. Das Backend fällt auf ein reines Textmodell zurück — dem nun ein Bild gereicht wird, das es nicht lesen kann, und kein Text, weil der Client den Text wegoptimiert hat. Der schnelle Pfad hatte still das Sicherheitsnetz gelöscht. Man kann einen redundanten schnellen Pfad gefahrlos fallenlassen; man kann nicht das fallenlassen, worum herum der schnelle Pfad eine Abkürzung war.

Der Fix: auf den Fallback abstellen, nicht auf das primäre

Der Fix ist der Teil, den zu stehlen sich lohnt. Er war nicht, wieder immer OCR laufen zu lassen — das würde den Gewinn für den häufigen Fall wegwerfen. Er war, das Überspringen von einer zweiten Capability-Flag abhängig zu machen: unterstützt der Fallback Vision? Die Config des Backends bekam einen fallbackVision-Block; der Client cached aiFallbackSupportsVision neben der ersten Flag. Nun hat die Entscheidung vier Zweige:

  • Primäres und Fallback sehen beide → optimiere das Bild, überspring OCR. Der schnelle, häufige Pfad.
  • Primäres sieht, aber Fallback ist blind → optimiere das Bild und lass OCR laufen, als Versicherung für den Fallback.
  • Bildoptimierung wirft eine Ausnahme → OCR als harter Fallback.
  • Plan hat gar keine Vision → nur OCR.

Und der zweite Zweig — der Versicherungsfall — ist da, wo der letzte Zug zählt. OCR nach der Bildoptimierung laufen zu lassen würde dessen Latenz obendrauf packen. Also laufen die beiden zusammen unter einem einzigen Promise.all: Bildoptimierung und OCR überlappen sich, und das OCR, das nur für den Fall existiert, dass das Backend es braucht, ist innerhalb der Zeit fertig, in der das Bild ohnehin optimiert wurde. Das Sicherheitsnetz ist in Uhrzeit gratis, wann immer es nicht benutzt wird. Das ist der ganze Trick in einem Satz: behalte den billigen Fallback, mach ihn davon abhängig, ob dein Fallback-Anbieter ihn wirklich braucht, und parallelisiere ihn, sodass er nichts kostet, bis zu dem Tag, an dem er dich rettet.

Zwei kleinere Fallen, die das Überspringen auslöste

Aufgeschobene Arbeit aggressiv zu überspringen hat die Eigenart, jede Stelle ans Licht zu holen, die still annahm, die Arbeit würde geschehen. Zwei kamen in derselben Woche im PR-Review heraus.

Ein Queue-Deadlock. Die Screenshot-Queue hielt einen Eintrag für „bereit", wenn er OCR-Text hatte. Vision-Screenshots bekamen nun nie OCR-Text — beabsichtigt — also wartete der Vollständigkeitscheck hasIncompleteOCR für immer und die Queue hing. Der Fix definiert „bereit" neu als hat ein optimiertes Bild oder hat OCR-Text, was die Pipeline nun tatsächlich garantiert.

Eine veraltete Capability-Flag. aiSupportsVision ist gecached, was heißt, sie kann genau in die falsche Richtung veralten: ein Nutzer loggt sich aus, oder ein Config-Fetch schlägt fehl, und der Client bleibt im Glauben, er dürfe noch Bilder an ein Modell schicken, das sie nicht mehr unterstützt. Die Flag setzt sich nun beim Logout und bei fehlgeschlagenem Config-Fetch zurück — eine gecachte Capability muss eine Geschichte dafür haben, wenn das, was sie beschreibt, verschwindet.

Ein Wort zu den Zahlen

Weil Ehrlichkeit der Sinn dieser Serie ist: die Commit-Messages tragen Zahlen — Bildoptimierung „~30ms" gegen OCR „~100–150ms", Payloads „~200–500 KB vs ~3–8 MB PNG" — und sie sind plausibel, aber es sind die eigenen Inline-Schätzungen des Autors, nicht die Ausgabe eines Benchmarks. Es gibt nirgends in der Historie eine gemessene End-to-End-Latenz davor/danach. Behandle das Geschwindigkeitsargument als vernünftige Design-Absicht, nicht als bewiesenes Ergebnis. Die Payload-Größen-Reduktion ist die eine Zahl, der du trauen kannst, weil sie direkt aus dem Skalieren und Neukomprimieren fällt.

Drei Dinge, die uns dieses Release lehrte

  1. Die Pixel zu senden schlägt den Text zu lesen — wenn das Modell sehen kann. OCR verwirft alles, was ein Screenshot außer Zeichen kommuniziert. Ein Vision-Modell liest das Layout, das Diagramm und die Einrückung, und ein optimiertes JPEG macht das bezahlbar. Aber es ist eine Wette auf die Capability, und darum braucht es einen Fallback.
  2. Du kannst einen schnellen Pfad wegoptimieren, nie das Sicherheitsnetz. OCR für Vision-Nutzer zu überspringen war für das primäre richtig und für den Fallback falsch. Der Bug war nicht das Überspringen; es war, auf Basis der Capability des primären zu überspringen, wenn die des Fallbacks die war, die zählte.
  3. Ein gratis Sicherheitsnetz ist ein parallelisiertes. Mach die Versicherungsarbeit davon abhängig, ob sie wirklich gebraucht wird, und lass sie dann neben dem schnellen Pfad laufen, sodass sie sich überlappt. Richtig gemacht kostet Graceful Degradation nichts, bis zu dem Tag, an dem sie der einzige Grund ist, dass dein Feature noch funktioniert.

Für das vorige Kapitel der v1-Geschichte, die zwei Fehlermodi eines Click-Through-Overlays (v1.8.5); und für den ganzen Bogen, die Anatomie, Software bis zur Perfektion auszuliefern.