12.8. Transmisión de fotogramas

El uso real más común de un canal personalizado es transmitir fotogramas de imagen desde la cámara a un programa anfitrión a la velocidad de fotogramas de la cámara. La mecánica es más sutil de lo que parece: un JPEG puede llegar a 25 KB o más, así que el anfitrión lo lee en varios fragmentos, y hay que impedir que el bucle de captura de la cámara sobrescriba el búfer en mitad de una lectura. El patrón correcto – mostrado aquí y utilizado por las herramientas de openmv-projects/tools/retiene (latch) el búfer hasta que el anfitrión termina de extraer el último byte.

12.8.1. El lado de la cámara

Un canal de fotogramas que captura en un único framebuffer, lo retiene en la primera lectura del anfitrión y solo toma la siguiente captura una vez que el anfitrión ha consumido la imagen completa:

import csi
import protocol

csi0 = csi.CSI()
csi0.reset()
csi0.pixformat(csi.RGB565)
csi0.framesize(csi.QVGA)
csi0.framebuffers(1)

img = csi0.snapshot()
img.compress(quality=85)
img_mv = memoryview(img.bytearray())
img_size = len(img_mv)
frame_available = True


class FrameChannel:
    def poll(self):
        return frame_available

    def size(self):
        return img_size

    def readp(self, offset, size):
        global frame_available
        end = offset + size
        mv = img_mv[offset:end]
        if end == img_size:
            # Host has just read the last byte of this frame --
            # release the buffer so the capture loop can refresh.
            frame_available = False
        return mv


ch = protocol.register(name='frame', backend=FrameChannel())

while True:
    if not frame_available:
        img = csi0.snapshot()
        img.compress(quality=85)
        img_mv = memoryview(img.bytearray())
        img_size = len(img_mv)
        frame_available = True
        ch.send_event(0x01)   # notify host that a new frame is ready

Aquí hay cuatro piezas haciendo trabajo real:

  • frame_available es el latch (retención). El bucle de captura solo toma una nueva captura cuando vale False – lo que significa que el anfitrión ha extraído el último byte del fotograma anterior. La lectura del anfitrión lo restablece a False desde dentro de readp una vez que se ha servido el desplazamiento final. Sin esta protección, el siguiente csi0.snapshot() sobrescribiría el búfer en mitad de la lectura y el anfitrión recibiría un fotograma cosido a partir de dos capturas.

  • El backend implementa readp en lugar de read. La biblioteca del protocolo trata el búfer devuelto como autoritativo y lee sus bytes directamente en el paquete saliente, sin copia. Para cargas útiles del tamaño de un fotograma, readp es notablemente más rápido que read, que fuerza una copia intermedia.

  • size devuelve la longitud JPEG en caché sin recalcular nada; el bucle de captura la mantiene actualizada cada vez que refresca el búfer. El anfitrión llama a size entre poll y readp para saber cuántos bytes extraer.

  • send_event() notifica al anfitrión en el instante en que llega un nuevo fotograma para que pueda empezar a extraerlo sin sondear. El ID de evento 0x01 está definido por la aplicación («frame ready» en este caso); usa un entero pequeño distinto para cada tipo de notificación.

12.8.2. Fragmentación

QVGA RGB565 con calidad JPEG 85 se comprime a aproximadamente 10-25 KB, según la escena – mucho más grande que la carga útil máxima negociada en cualquier cámara (consulta la tabla por placa en protocol.init()). Una lectura JPEG no cabe en un solo paquete, y no pasa nada, porque la biblioteca del protocolo la fragmenta de forma transparente.

Cuando el anfitrión solicita channel_read('frame', 12000):

  1. El readp de la cámara se llama una vez con offset=0 y la solicitud completa de 12000 bytes. Devuelve un único memoryview que cubre todo el rango.

  2. La biblioteca del protocolo divide ese memoryview en fragmentos del tamaño de la carga útil máxima sobre el cable, un paquete de respuesta CHANNEL_READ por fragmento, cada uno con su propia cabecera y CRC. Los bytes se transmiten directamente desde el búfer del backend, sin copia.

  3. El anfitrión recibe los fragmentos en orden, la capa de fiabilidad retransmite cualquier fragmento que falle su CRC, y el SDK del anfitrión une los fragmentos en el resultado de 12000 bytes devuelto al llamante.

Nota

Esta es la diferencia práctica clave entre readp y read. readp se llama una vez por solicitud del anfitrión; la capa del protocolo fragmenta y transmite a partir del único búfer devuelto. read se llama una vez por fragmento, y la biblioteca copia cada fragmento devuelto en su propio búfer de paquete. Para cargas útiles del tamaño de un fotograma, readp ahorra tanto la sobrecarga de la llamada a nivel de Python por fragmento como la copia.

Truco

¿Quieres comprobar la diferencia por ti mismo? Renombra el método readp del backend a read – no cambia nada más; la biblioteca detectará la capacidad read en su lugar – y compara el contador de velocidad de fotogramas del anfitrión antes y después. El número más lento es el coste de copia por fragmento y de llamada de Python que evitas usando readp.

El latch en FrameChannel.readp libera el búfer cuando offset + size == img_size – el momento en que el anfitrión ha extraído el último byte. Hasta entonces, el búfer debe permanecer válido, razón por la cual el bucle de captura solo toma la siguiente captura una vez que frame_available vuelve a False.

12.8.3. El lado del anfitrión

El anfitrión extrae fotogramas en un bucle ajustado:

import io
from PIL import Image
from openmv.camera import Camera

with Camera('/dev/ttyACM0', baudrate=921600) as cam:
    cam.update_channels()

    while True:
        size = cam.channel_size('frame')
        if not size:
            continue
        data = cam.channel_read('frame', size)
        img = Image.open(io.BytesIO(data))
        img.show()                  # or feed to a GUI

La llamada a channel_size() funciona también como una comprobación de «¿hay algo listo?» – cero significa que la cámara aún no ha capturado – de modo que el bucle omite los intentos de lectura sobre un búfer vacío. Para aplicaciones con interfaz gráfica que ya sondean con un temporizador, este es el patrón natural.

El Image.open de Pillow decodifica el JPEG; la cámara ya lo ha comprimido en JPEG, así que el anfitrión no tiene que rehacer el costoso empaquetado de bits sobre RGB565. El script del anfitrión podría igualmente guardar los bytes en disco, pasarlos a OpenCV o enviarlos a una vista web.

12.8.4. Reflexión sobre el rendimiento

Tres cosas limitan la velocidad de fotogramas alcanzable:

  • La velocidad de captura de la cámara. El protocolo no puede entregar fotogramas más rápido de lo que los produce el sensor; cualquier límite que el formato de píxel y el tamaño de fotograma elegidos impongan a la captura es el techo.

  • La carga útil máxima negociada. Cargas útiles más grandes significan menos fragmentos por fotograma y menos sobrecarga de tramado, así que las cámaras con búferes de protocolo más grandes mueven bytes más rápido que las más pequeñas.

  • La sobrecarga de CRC y ACK. Cada paquete cuesta 14 bytes de tramado más un viaje de ida y vuelta de ACK. Para fragmentos largos, la sobrecarga por carga útil es pequeña; para cargas útiles diminutas, domina.

Para la mayor parte del trabajo con interfaz gráfica de cámara a portátil, el factor limitante es el tiempo de captura y de compresión JPEG de la cámara, no la pila del protocolo. Donde el protocolo sí se convierte en el cuello de botella – por ejemplo, transmitiendo fotogramas en bruto sin comprimir a altas velocidades de fotogramas – las palancas son desactivar los ACK (protocol.init(ack=False)), aumentar el búfer del protocolo si la cámara lo admite, o capturar en GRAYSCALE para que cada JPEG comprimido lleve un canal en lugar de tres y el fotograma codificado acabe siendo notablemente más pequeño sobre el cable.

El canal de fotogramas es el flujo de datos canónico de cámara a anfitrión. La misma interfaz de backend, con un método write añadido, permite que el anfitrión envíe datos también en sentido contrario – que es lo que necesita una herramienta de cámara interactiva en cuanto el operador quiere cambiar algo en lugar de solo observar.