Steven
Steven9 min de lectura

De OCR a píxeles sin perder el fallback

GeekBye dejó de leer tus capturas con OCR y empezó a enviar la imagen real a modelos de visión — que es la parte fácil. La parte instructiva es la optimización que se saltó el OCR por completo para los usuarios con visión, rompió sin ruido el fallback a modelos sin visión, y hubo que deshacerla hasta una versión donde la red de seguridad corre en paralelo y no cuesta nada.

Ingeniería
AI
Desktop
Lanzamientos de GeekBye
De OCR a píxeles sin perder el fallback

Hay una auténtica bifurcación en el camino para cualquier app que te deje capturar algo y preguntarle a una IA sobre ello. Puedes correr OCR — extraer el texto en el propio dispositivo, enviar una cadena minúscula y tirar la imagen. O puedes enviar los píxeles — mandar la imagen real y dejar que un modelo capaz de ver la lea. El OCR es barato y pequeño pero ciego a todo lo que no es texto: el layout, el diagrama, la indentación del código, el subrayado rojo bajo el error. Los píxeles preservan todo eso pero cuestan más bytes y exigen un modelo que pueda ver. GeekBye empezó en el lado del OCR de esa bifurcación y, a lo largo de la v1.8.6 y la v1.8.7, se pasó al lado de los píxeles. Ese cambio es la parte fácil de esta historia. La parte interesante es la optimización que lo acompañó, rompió el fallback y hubo que deshacer.

El antes: leer el texto, tirar la imagen

Hasta este arco, una captura se convertía en respuesta así: pulsas el atajo de captura, el cliente corre OCR — el framework Vision de Apple mediante un binario Swift VisionOCR en macOS, un script de PowerShell en Windows, ambos tras una única abstracción IOcrEngine — y el texto extraído se envuelve en una cadena y se manda al backend como screenshotText. La imagen en sí nunca salía de la máquina. Eso es eficiente, y para un muro de texto plano está bien. Pero una captura rara vez es solo texto. Es una oferta de empleo con un layout a dos columnas, un problema de programación con un diagrama, un error con un stack trace cuya indentación es la pista. El OCR aplasta todo eso en una transcripción línea por línea y el modelo responde a una pregunta más pobre que la que hiciste.

El cambio: enviar los píxeles (v1.8.6)

La v1.8.6 añadió el otro camino. Un nuevo imageOptimizer.ts, construido sobre sharp y por tanto multiplataforma, hace exactamente tres cosas con una captura en bruto:

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

Redimensionar a como mucho 1920px de ancho (nunca ampliar una captura más pequeña), convertir PNG a JPEG, quality 80. La salida es base64 en bruto con el media type llevado en un campo aparte, y viaja al backend en dos nuevos campos de la petición junto al antiguo: screenshotImage y screenshotImageMediaType, independientes de screenshotText. El backend ahora puede recibir una imagen, texto, o ambos.

Ese paso de optimización no es cosmético. Un PNG de captura en bruto ocupa 3–8 MB; el JPEG redimensionado ronda los 200–500 KB. Eso es un orden de magnitud menos en la subida, y además recorta la factura del modelo — los modelos de visión trocean una imagen según su resolución y cobran por tesela, así que limitar el ancho a 1920 y recomprimir reduce directamente tanto los bytes en el cable como los tokens de imagen que pagas, sin pérdida apreciable de legibilidad para el texto en pantalla.

¿Cómo sabe la app si el modelo siquiera puede ver? El backend lo anuncia. La respuesta de /api/config ganó un bloque ai.vision indexado por plan, y el cliente cachea un único booleano — aiSupportsVision — en cada carga de config. Cuando hay visión, optimiza la imagen; cuando no, recurre al OCR. Así que la capacidad es un hecho del backend, cacheado en el cliente, y la decisión por captura de correr o saltar el OCR se toma en el cliente a partir de esa flag. El backend sigue siendo el dueño de qué modelo responde de verdad.

La optimización que se pasó de lista

Aquí es donde se vuelve instructivo. La primera versión de la ruta de visión corría el OCR y la optimización de imagen juntos, en paralelo. Luego un commit «perf» lo apretó en un o-lo-uno-o-lo-otro: si el plan soporta visión, optimiza la imagen y sáltate el OCR por completo; si no, corre el OCR y sáltate el trabajo de imagen. Sobre el papel esto es la victoria obvia — ¿para qué extraer texto que el modelo de visión no necesita? El changelog hasta lo anuncia: «OCR is skipped when the AI can read the image directly».

El problema es la palabra fallback. El backend no tiene un solo modelo; tiene uno primario y uno de fallback, y el de fallback podría ser un modelo sin visión. Imagina la secuencia: el modelo primario de tu plan soporta visión, así que el cliente se salta el OCR muy orgulloso y envía solo la imagen. El proveedor primario tiene un tropiezo. El backend recurre a un modelo de solo texto — al que ahora se le entrega una imagen que no puede leer y ningún texto, porque el cliente había optimizado el texto hasta hacerlo desaparecer. La ruta rápida había borrado sin ruido la red de seguridad. Puedes descartar una ruta rápida redundante sin peligro; no puedes descartar aquello que la ruta rápida rodeaba como atajo.

El arreglo: condicionar según el fallback, no el primario

El arreglo es la parte que vale la pena robar. No fue volver a correr siempre el OCR — eso tiraría la victoria para el caso común. Fue hacer el salto condicional a una segunda flag de capacidad: ¿el fallback soporta visión? La config del backend ganó un bloque fallbackVision; el cliente cachea aiFallbackSupportsVision junto a la primera flag. Ahora la decisión tiene cuatro ramas:

  • Primario y fallback ven ambos → optimiza la imagen, salta el OCR. La ruta rápida y común.
  • El primario ve pero el fallback es ciego → optimiza la imagen y corre el OCR, como seguro para el fallback.
  • La optimización de imagen lanza excepción → OCR como fallback duro.
  • El plan no tiene visión en absoluto → solo OCR.

Y la segunda rama — el caso del seguro — es donde importa el último movimiento. Correr el OCR después de la optimización de imagen volvería a sumar su latencia encima. Así que los dos corren juntos bajo un único Promise.all: la optimización de imagen y el OCR se solapan, y el OCR que existe solo por si el backend lo necesita termina dentro del tiempo en que la imagen se estaba optimizando de todas formas. La red de seguridad es gratis en tiempo de reloj siempre que no se use. Ese es todo el truco en una frase: conserva el fallback barato, condiciónalo a si tu proveedor de fallback lo necesita de verdad, y paralelízalo para que no cueste nada hasta el día en que te salva.

Dos trampas menores que disparó el salto

Saltarse agresivamente el trabajo diferido tiene la costumbre de sacar a la luz cada rincón que daba por hecho, en silencio, que el trabajo ocurriría. Dos salieron en la revisión de PR esa misma semana.

Un deadlock de cola. La cola de capturas consideraba un elemento «listo» cuando tenía texto OCR. Las capturas con visión ahora nunca obtenían texto OCR — por diseño — así que la comprobación de completitud hasIncompleteOCR esperaba para siempre y la cola se colgaba. El arreglo redefine «listo» como tiene una imagen optimizada o tiene texto OCR, que es lo que el pipeline ahora garantiza de verdad.

Una flag de capacidad obsoleta. aiSupportsVision está cacheada, lo que significa que puede quedar obsoleta justo en la dirección equivocada: un usuario cierra sesión, o una carga de config falla, y el cliente se queda creyendo que aún puede enviar imágenes a un modelo que ya no las soporta. La flag ahora se resetea al cerrar sesión y al fallar la carga de config — una capacidad cacheada tiene que tener un plan para cuando aquello que describe desaparece.

Unas palabras sobre los números

Porque la honestidad es el sentido de esta serie: los mensajes de commit traen cifras — optimización de imagen «~30ms» frente a OCR «~100–150ms», payloads «~200–500 KB vs ~3–8 MB PNG» — y son plausibles, pero son las propias estimaciones inline del autor, no la salida de un benchmark. No hay ninguna latencia end-to-end medida antes/después en toda la historia. Trata el argumento de velocidad como una intención de diseño sensata, no un resultado probado. La reducción del tamaño de payload es el único número en el que puedes confiar, porque sale directamente de redimensionar y recomprimir.

Tres cosas que nos enseñó esta release

  1. Enviar los píxeles le gana a leer el texto — cuando el modelo puede ver. El OCR descarta todo lo que una captura comunica más allá de los caracteres. Un modelo de visión lee el layout, el diagrama y la indentación, y un JPEG optimizado lo vuelve asequible. Pero es una apuesta de capacidad, y por eso necesita un fallback.
  2. Puedes optimizar y quitar una ruta rápida, nunca la red de seguridad. Saltarse el OCR para los usuarios con visión era correcto para el primario y erróneo para el fallback. El bug no era el salto; era saltar basándose en la capacidad del primario cuando la que importaba era la del fallback.
  3. Una red de seguridad gratis es una red paralelizada. Condiciona el trabajo de seguro a si de verdad hace falta, y luego córrelo junto a la ruta rápida para que se solape. Bien hecho, la degradación elegante no cuesta nada hasta el día en que es la única razón por la que tu funcionalidad sigue funcionando.

Para el capítulo anterior de la historia v1, los dos modos de fallo de un overlay click-through (v1.8.5); y para todo el arco, la anatomía de publicar software hasta la perfección.