12.8. Transmissão de fotogramas

A utilização real mais comum de um canal personalizado é a transmissão de fotogramas de imagem da câmara para um programa anfitrião à taxa de fotogramas da câmara. A mecânica é mais subtil do que parece: um JPEG pode atingir 25 KB ou mais, pelo que o anfitrião o lê em vários fragmentos, e o ciclo de captura da câmara deve ser impedido de sobrescrever o buffer durante a leitura. O padrão correto – apresentado aqui e utilizado pelas ferramentas em openmv-projects/tools/bloqueia o buffer até o anfitrião terminar de ler o último byte.

12.8.1. O lado da câmara

Um canal de fotogramas que captura para um único framebuffer, bloqueia-o na primeira leitura do anfitrião e só tira a próxima captura de imagem após o anfitrião ter consumido a imagem 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

Quatro componentes estão a fazer trabalho real aqui:

  • frame_available é o bloqueio. O ciclo de captura só tira uma nova captura de imagem quando está False – o que significa que o anfitrião já leu o último byte do fotograma anterior. A leitura do anfitrião repõe-o a False dentro de readp assim que o deslocamento final for servido. Sem esta guarda, o próximo csi0.snapshot() sobrescreveria o buffer durante a leitura e o anfitrião receberia um fotograma composto por duas capturas.

  • readp em vez de read é o que o backend implementa. A biblioteca de protocolo trata o buffer devolvido como autoritativo e lê os seus bytes diretamente para o pacote de saída – sem cópia. Para payloads do tamanho de fotogramas, readp é visivelmente mais rápido do que read, que força uma cópia intermédia.

  • size devolve o comprimento JPEG em cache sem recalcular nada; o ciclo de captura mantém-no sempre que atualiza o buffer. O anfitrião chama size entre poll e readp para saber quantos bytes deve ler.

  • send_event() notifica o anfitrião no instante em que um novo fotograma chega, para que possa começar a ler sem fazer polling. O ID de evento 0x01 é definido pela aplicação («fotograma pronto» neste caso); use um inteiro pequeno diferente para cada tipo de notificação.

12.8.2. Fragmentação

QVGA RGB565 com qualidade JPEG 85 comprime para aproximadamente 10-25 KB, dependendo da cena – muito maior do que o payload máximo negociado em qualquer câmara (consulte a tabela por placa em protocol.init()). Uma leitura JPEG não cabe num único pacote, o que é aceitável, porque a biblioteca de protocolo fragmenta-o de forma transparente.

Quando o anfitrião pede channel_read('frame', 12000):

  1. O readp da câmara é chamado uma vez com offset=0 e o pedido completo de 12000 bytes. Devolve uma memoryview que abrange todo o intervalo.

  2. A biblioteca de protocolo divide essa memoryview em fragmentos do tamanho máximo do payload no fio, um pacote de resposta CHANNEL_READ por fragmento, cada um com o seu próprio cabeçalho e CRC. Os bytes são transmitidos diretamente do buffer do backend – sem cópia.

  3. O anfitrião recebe os fragmentos por ordem, a camada de fiabilidade retransmite qualquer fragmento que falhe o seu CRC, e o SDK do anfitrião agrupa os fragmentos no resultado de 12000 bytes devolvido ao chamador.

Nota

Esta é a diferença prática fundamental entre readp e read. readp é chamado uma vez por pedido do anfitrião; a camada de protocolo fragmenta e transmite a partir do único buffer devolvido. read é chamado uma vez por fragmento, e a biblioteca copia cada fragmento devolvido para o seu próprio buffer de pacote. Para payloads do tamanho de fotogramas, readp poupa tanto a sobrecarga de chamada ao nível Python por fragmento como a cópia.

Dica

Quer ver a diferença por si mesmo? Renomeie o método readp do backend para read – mais nada muda; a biblioteca escolherá a capacidade read em vez disso – e compare o contador de taxa de fotogramas do anfitrião antes e depois. O número mais baixo é o custo de cópia por fragmento e de chamada Python que evita ao usar readp.

O bloqueio em FrameChannel.readp liberta o buffer quando offset + size == img_size – no momento em que o anfitrião leu o último byte. Até então, o buffer deve permanecer válido, razão pela qual o ciclo de captura só tira a próxima captura de imagem após frame_available voltar a False.

12.8.3. O lado do anfitrião

O anfitrião lê fotogramas num ciclo apertado:

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

A chamada channel_size() serve também como verificação de «há algo pronto» – zero significa que a câmara ainda não capturou – pelo que o ciclo ignora tentativas de leitura num buffer vazio. Para aplicações com GUI que já fazem polling num temporizador, este é o padrão natural.

O Image.open do Pillow descodifica o JPEG; a câmara já o comprimiu em JPEG, pelo que o anfitrião não precisa de refazer o dispendioso empacotamento de bits em RGB565. O script do anfitrião poderia igualmente guardar os bytes em disco, passá-los ao OpenCV, ou enviá-los através de uma vista web.

12.8.4. Considerações sobre débito

Três fatores limitam a taxa de fotogramas alcançável:

  • A taxa de captura da câmara. O protocolo não pode entregar fotogramas mais rapidamente do que o sensor os produz; o limite imposto pelo formato de pixel e pelo tamanho de fotograma escolhidos à captura é o teto.

  • O payload máximo negociado. Payloads maiores significam menos fragmentos por fotograma e menos sobrecarga de enquadramento, pelo que câmaras com buffers de protocolo maiores movem bytes mais rapidamente do que as mais pequenas.

  • Sobrecarga de CRC e ACK. Cada pacote custa 14 bytes de enquadramento mais uma ronda de ACK. Para fragmentos longos, a sobrecarga por payload é pequena; para payloads pequenos, domina.

Para a maior parte do trabalho com GUI de câmara para portátil, o fator limitante é o tempo de captura e compressão JPEG da câmara, não a pilha de protocolos. Onde o protocolo se torna o estrangulamento – por exemplo, transmitir fotogramas brutos não comprimidos a altas taxas de fotogramas – as alavancas são desativar os ACKs (protocol.init(ack=False)), aumentar o buffer de protocolo se a câmara o suportar, ou capturar em GRAYSCALE para que cada JPEG comprimido transporte um canal em vez de três e o fotograma codificado acabe visivelmente menor no fio.

O canal de fotogramas é o fluxo de dados canónico da câmara para o anfitrião. A mesma interface de backend, com um método write adicionado, permite ao anfitrião enviar dados no sentido inverso também – o que uma ferramenta de câmara interativa necessita assim que o operador quer alterar algo em vez de apenas observar.