wandres.dev
CÁMARA Y MULTIMEDIA · CameraX y Media3

Análisis en tiempo real

Un flujo de cuadros a treinta por segundo es una manguera abierta, y todo lo que hagas con cada cuadro compite con el hilo que dibuja la vista previa. Esta lección explica cómo funciona `ImageAnalysis`, qué significa realmente la contrapresión, cómo se conecta ML Kit para escanear códigos o detectar caras, por qué el cierre del cuadro es la primera causa de visores congelados, y cómo traducir coordenadas del análisis a coordenadas de pantalla sin que los recuadros bailen.

⏱ 25 min

Analizar imágenes en directo es el punto donde una aplicación de cámara deja de ser un visor y empieza a entender lo que ve: un código de barras en una estantería, una matrícula, una cara que hay que encuadrar, un texto que hay que traducir sobre la marcha. La tentación es tratarlo como un bucle sencillo —recibo un cuadro, lo proceso, dibujo el resultado— y esa lectura ingenua se rompe en cuanto se mide. Un cuadro de mil doscientos ochenta por setecientos veinte en formato de luminancia y croma ocupa más de un megabyte; a treinta cuadros por segundo, el sistema está moviendo decenas de megabytes por segundo a través de un conjunto finito de buffers reutilizados. Si tu análisis tarda más de lo que dura un cuadro, y casi cualquier análisis interesante tarda más, alguien tiene que decidir qué pasa con los cuadros que llegan mientras tanto. Esa decisión es la contrapresión, y entenderla es la diferencia entre un escáner que responde al instante y uno que acumula retardo hasta que la vista previa se congela y el usuario cierra la aplicación.

🎯 Al terminar esta lección sabrás
  • Configurar ImageAnalysis eligiendo la estrategia de contrapresión y el formato de salida adecuados al problema.
  • Conectar ML Kit para escanear códigos o detectar caras sin bloquear el hilo que alimenta la vista previa.
  • Gestionar correctamente el cierre de cada cuadro y diagnosticar los síntomas de no hacerlo.
  • Traducir coordenadas del espacio de análisis al espacio del visor y estabilizar el resultado en pantalla.

La manguera y la contrapresión

ImageAnalysis entrega cuadros a un analizador que tú registras sobre un ejecutor que tú eliges. La primera decisión es la estrategia de contrapresión, y solo hay dos. La estrategia de conservar únicamente el último descarta los cuadros que llegan mientras tu analizador trabaja y entrega el más reciente al terminar: la aplicación siempre analiza el presente, aunque salte instantes. La estrategia de bloquear mantiene una cola pequeña y detiene la producción cuando se llena: no se pierde ningún cuadro, pero el análisis se va retrasando respecto a la realidad.

Para reconocimiento en vivo, la primera es casi siempre la correcta, porque analizar un código de barras de hace medio segundo no sirve de nada. La segunda solo tiene sentido cuando el conjunto completo de cuadros importa, como en una medición de frecuencia cardiaca o en un cálculo de movimiento que necesita continuidad temporal.

val analisis = ImageAnalysis.Builder()
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888)
    .build()

analisis.setAnalyzer(Executors.newSingleThreadExecutor()) { proxy ->
    procesar(proxy)
}

El formato de salida es la otra decisión estructural. El formato nativo del sensor separa la luminancia de la información de color en planos distintos, lo que resulta incómodo si tu algoritmo espera píxeles empaquetados, pero es enormemente eficiente cuando solo necesitas el brillo: la detección de códigos, el seguimiento de bordes y buena parte de la visión clásica trabajan exclusivamente sobre el primer plano, que ya viene aislado y no requiere ninguna conversión. Pedir salida en formato de color empaquetado es cómodo y cuesta una conversión por cuadro que se paga en cada uno de los treinta cuadros de cada segundo.

De ahí sale una regla práctica que ordena el diseño del analizador: reduce antes de procesar. Baja la resolución solicitada hasta el mínimo en que el detector siga funcionando, trabaja sobre el plano de luminancia si te basta, y limita la región de interés cuando la funcionalidad lo permita. Un escáner que analiza un recorte central a resolución modesta puede ser un orden de magnitud más barato que uno que procesa el cuadro completo, con idéntica tasa de aciertos.

El ejecutor debe ser propio y no el del hilo principal. Parece obvio y sin embargo es un error frecuente, porque el resultado del análisis suele necesitar actualizar la interfaz y resulta cómodo hacerlo todo en el mismo sitio. La regla correcta es analizar fuera y publicar dentro: el trabajo pesado en un hilo dedicado, y solo el resultado ya reducido cruzando al hilo principal.

⚠️
Cerrar el cuadro no es opcional

Cada cuadro que recibes es una vista sobre un buffer de un conjunto finito, y hasta que lo cierras ese buffer no vuelve a la circulación. Olvidar el cierre en cualquier rama del código —incluida la rama de error, incluida la salida temprana cuando el resultado no interesa— agota el conjunto en unos pocos cuadros y detiene la entrega para siempre. El síntoma es característico y engaña: la vista previa sigue viéndose porque va por otro flujo, pero el análisis deja de recibir nada y no aparece ningún error. Ante un analizador que se calla a los dos segundos, la primera hipótesis siempre es un cierre perdido.

ML Kit sobre el flujo de análisis

ML Kit consume imágenes a través de una envoltura que acepta directamente el cuadro entregado por la biblioteca de cámara junto con los grados de rotación, lo que evita conversiones manuales de formato. A partir de ahí, cada detector es una tarea asíncrona que se completa con la lista de resultados. El patrón importante es que el cierre del cuadro debe ocurrir cuando la tarea termina, no cuando el analizador retorna, porque el detector sigue leyendo el buffer.

private val escaner = BarcodeScanning.getClient(
    BarcodeScannerOptions.Builder()
        .setBarcodeFormats(Barcode.FORMAT_QR_CODE, Barcode.FORMAT_EAN_13)
        .build(),
)

@OptIn(ExperimentalGetImage::class)
private fun procesar(proxy: ImageProxy) {
    val medio = proxy.image ?: run { proxy.close(); return }
    val entrada = InputImage.fromMediaImage(medio, proxy.imageInfo.rotationDegrees)

    escaner.process(entrada)
        .addOnSuccessListener { codigos -> codigos.firstOrNull()?.let { publicar(it) } }
        .addOnFailureListener { error -> registrar(error) }
        .addOnCompleteListener { proxy.close() }
}

Conviene además no analizar todos los cuadros cuando el problema no lo exige. Muchos reconocimientos toleran perfectamente diez cuadros por segundo, y saltarse dos de cada tres reduce el consumo a un tercio sin que el usuario perciba diferencia alguna. Un contador sencillo que descarta cuadros según la marca de tiempo, cerrándolos igualmente, es una de las optimizaciones más rentables de todo este terreno.

Restringir los formatos que el escáner busca no es una optimización menor. Un detector configurado para todos los formatos posibles ejecuta muchos más pasos por cuadro que uno limitado a los dos que tu aplicación acepta, y la diferencia se nota en el consumo y en la temperatura del dispositivo antes que en la latencia.

La detección de caras plantea el mismo compromiso en otra dimensión: el modo rápido frente al preciso, la detección o no de puntos de referencia, la clasificación de sonrisa y ojos abiertos, y el seguimiento de identidad entre cuadros. Cada opción activada añade coste por cuadro. La disciplina consiste en habilitar exclusivamente lo que la funcionalidad usa, y revisarlo cuando la funcionalidad cambie.

Hay además una decisión de empaquetado que afecta al primer arranque y que conviene tomar conscientemente. Los modelos pueden viajar dentro del paquete de la aplicación, disponibles desde el primer segundo a cambio de varios megabytes, o descargarse bajo demanda a través de los servicios del dispositivo, lo que aligera la instalación pero introduce un estado en el que el detector todavía no existe. Ese estado hay que representarlo en la interfaz: un escáner que parece funcionar pero nunca reconoce nada, porque el modelo aún se está descargando, es peor que uno que dice explícitamente que se está preparando.

💡
Un detector es un recurso, no una función

El cliente del detector mantiene modelos cargados en memoria y a menudo recursos del acelerador del dispositivo. Crearlo dentro del analizador significa construirlo y destruirlo treinta veces por segundo, lo que en el mejor caso desperdicia trabajo y en el peor agota memoria nativa. Constrúyelo una vez junto al ciclo de vida de la pantalla, ciérralo explícitamente al terminar, y trátalo con la misma disciplina que aplicarías a una conexión abierta.

🔍

Modelos empaquetados

Disponibles desde la primera ejecución, sin espera ni red, a cambio de tamaño en el paquete de la aplicación.

☁️

Modelos por servicios

Descargados bajo demanda a través de los servicios del dispositivo. Paquete más ligero, pero hay que contemplar el estado en el que el modelo todavía no está listo.

De las coordenadas del sensor a los píxeles de la pantalla

Todo detector devuelve rectángulos y puntos en el espacio de la imagen analizada, que casi nunca coincide con el espacio del visor: distinta resolución, distinta relación de aspecto, distinta rotación y, si el visor recorta para llenar la pantalla, distinta región visible. Dibujar esas coordenadas tal cual produce recuadros desplazados que parecen un error del detector cuando en realidad es un error de transformación.

La biblioteca de cámara resuelve el problema con un objeto de transformación que relaciona el espacio de salida de un caso de uso con el del visor. Con él se construye una matriz que se aplica a los rectángulos antes de dibujarlos, y esa matriz debe recalcularse cuando cambia la rotación o el tamaño de la superficie.

val transformacion = visor.outputTransform ?: return
val puente = CoordinateTransform(
    OutputTransform(matrizDeAnalisis, tamanoDeAnalisis),
    transformacion,
)

val destino = RectF(recuadroDetectado)
puente.mapRect(destino)
dibujar(destino)

Hay un caso particular que conviene tener presente porque produce errores difíciles de ver: la cámara frontal invierte la vista previa horizontalmente, pero el análisis recibe el cuadro sin invertir. Un recuadro dibujado sin contemplar ese espejo aparecerá reflejado respecto al objeto real, y como la escena suele ser aproximadamente simétrica cuando hay una cara delante, el fallo pasa desapercibido en las pruebas y se hace evidente en cuanto el objeto detectado está a un lado.

private fun matrizDeSalida(proxy: ImageProxy, frontal: Boolean): Matrix =
    Matrix().apply {
        postRotate(proxy.imageInfo.rotationDegrees.toFloat())
        if (frontal) postScale(-1f, 1f)
    }

Queda un segundo problema, que es perceptual y no geométrico: los detectores producen resultados ligeramente distintos en cada cuadro, de modo que un recuadro correcto tiembla. La solución habitual es un suavizado temporal, promediando la posición sobre una ventana corta, junto con una histéresis que evita que el recuadro desaparezca por un único cuadro sin detección. Un decaimiento de unos pocos cuadros elimina el parpadeo sin introducir un retardo perceptible.

flowchart LR
A[Sensor entrega cuadro] --> B[Analizador en hilo propio]
B --> C[Detector de ML Kit]
C --> D[Resultados en coordenadas de analisis]
D --> E[Transformacion al espacio del visor]
E --> F[Suavizado temporal]
F --> G[Dibujo sobre la vista previa]
C --> H[Cierre del cuadro al completar]
Descartar es la operación honesta cuando el productor no se puede frenar

Hay un principio general escondido en la elección de contrapresión, y es de los que reordenan la intuición de quien viene de programar sistemas donde nada se pierde. La formación habitual enseña que descartar datos es un fallo: se encolan las peticiones, se reintentan los mensajes, se garantiza la entrega. Esa doctrina es correcta cuando el productor puede esperar y cuando cada elemento tiene valor por sí mismo, que es el caso de una petición de usuario o de una transacción. Pero un sensor no es un cliente: no espera, no reintenta y no le importa que estés ocupado. Y el valor de sus cuadros no es individual sino posicional en el tiempo, porque lo que el cuadro representa es el estado del mundo en un instante que ya pasó. En cuanto se acepta eso, encolar se revela como la opción destructiva: cada cuadro retenido en una cola es una imagen del pasado que empuja hacia atrás a todas las siguientes, de modo que el sistema no solo va más lento sino que acumula un desfase creciente entre lo que la persona ve y lo que la aplicación cree ver. El usuario apunta al código de barras, la aplicación está analizando lo que había medio segundo antes, y el intento de no perder nada acaba perdiendo lo único que importaba. Descartar el cuadro intermedio, en cambio, mantiene acotado el desfase por construcción: se pierde información redundante para preservar la propiedad que sí es crítica, que la respuesta corresponda al presente. La generalización es útil mucho más allá de la cámara y describe la diferencia entre dos familias enteras de sistemas: los que transportan hechos, donde cada elemento debe llegar, y los que transportan estado, donde solo el último elemento significa algo. Confundirlas produce colas que crecen sin límite en un caso y datos perdidos en el otro, y la pregunta que las distingue es una sola: si me retraso, quiero recuperar lo que me perdí o quiero ponerme al día.

⚔️ Un escáner que no se atasca
  1. Implementa un escáner de códigos con la estrategia de conservar solo el último y mide la latencia entre apuntar y reconocer.
  2. Cambia a la estrategia de bloqueo, introduce un retardo artificial en el analizador y observa cómo crece el desfase.
  3. Provoca deliberadamente un cierre de cuadro perdido en la rama de error y confirma que el análisis se detiene sin avisar.
  4. Dibuja los recuadros detectados sobre el visor y verifica la alineación girando el dispositivo y cambiando de cámara.
  5. Añade suavizado temporal e histéresis, y compara la sensación con la versión sin suavizar en una grabación de pantalla.