12.8. Transmitindo quadros

O uso real mais comum de um canal personalizado é transmitir quadros de imagem da câmera para um programa host na taxa de quadros da câmera. A mecânica é mais sutil do que parece: um JPEG pode chegar a 25 KB ou mais, então o host o lê como vários fragmentos, e o laço de captura da câmera deve ser impedido de sobrescrever o buffer no meio da leitura. O padrão correto – mostrado aqui e usado pelas ferramentas em openmv-projects/tools/trava o buffer até que o host termine de puxar o último byte.

12.8.1. O lado da câmera

Um canal de quadros que captura em um único framebuffer, o trava na primeira leitura do host e só tira o próximo snapshot depois que o host consumiu 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 peças fazem o trabalho de verdade aqui:

  • frame_available é a trava. O laço de captura só tira um novo snapshot quando ele é False – ou seja, quando o host puxou o último byte do quadro anterior. A leitura do host o redefine de volta para False de dentro de readp assim que o offset final é servido. Sem essa proteção, o próximo csi0.snapshot() sobrescreveria o buffer no meio da leitura e o host receberia um quadro costurado a partir de duas capturas.

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

  • size retorna o comprimento do JPEG em cache sem recalcular nada; o laço de captura o mantém sempre que atualiza o buffer. O host chama size entre poll e readp para saber quantos bytes deve puxar.

  • send_event() notifica o host no instante em que um novo quadro chega, para que ele possa começar a puxar sem fazer polling. O ID de evento 0x01 é definido pela aplicação (“frame ready” 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 que o payload máximo negociado em qualquer câmera (consulte a tabela por placa em protocol.init()). Uma leitura de JPEG não cabe em um único pacote, e tudo bem, porque a biblioteca do protocolo o fragmenta de forma transparente.

Quando o host solicita channel_read('frame', 12000):

  1. O readp da câmera é chamado uma vez com offset=0 e a requisição completa de 12000 bytes. Ele retorna uma única memoryview cobrindo toda a faixa.

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

  3. O host recebe os fragmentos em ordem, a camada de confiabilidade retransmite qualquer pedaço que falhe no CRC, e o SDK do host cola os pedaços no resultado de 12000 bytes retornado ao chamador.

Nota

Essa é a diferença prática chave entre readp e read. readp é chamado uma vez por requisição do host; a camada de protocolo fragmenta e transmite a partir do único buffer retornado. read é chamado uma vez por fragmento, e a biblioteca copia cada pedaço retornado para seu próprio buffer de pacote. Para payloads do tamanho de um quadro, readp economiza tanto a sobrecarga de chamada em nível de Python por fragmento quanto a cópia.

Dica

Quer ver a diferença você mesmo? Renomeie o método readp do backend para read – nada mais muda; a biblioteca passará a usar a capacidade read – e compare o contador de taxa de quadros do host antes e depois. O número mais lento é o custo de cópia por fragmento e de chamada Python que você evita ao usar readp.

A trava em FrameChannel.readp libera o buffer quando offset + size == img_size – o momento em que o host puxou o último byte. Até lá, o buffer deve permanecer válido, e é por isso que o laço de captura só tira o próximo snapshot quando frame_available volta para False.

12.8.3. O lado do host

O host puxa quadros em um laço 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() funciona também como uma verificação de “há algo pronto” – zero significa que a câmera ainda não capturou – de modo que o laço pula tentativas de leitura em um buffer vazio. Para aplicações com interface gráfica que já fazem polling em um timer, esse é o padrão natural.

O Image.open do Pillow decodifica o JPEG; a câmera já o comprimiu em JPEG, então o host não precisa refazer o caro empacotamento de bits do RGB565. O script do host poderia, com a mesma facilidade, salvar os bytes em disco, entregá-los ao OpenCV ou enviá-los através de uma visualização web.

12.8.4. Pensando em throughput

Três coisas limitam a taxa de quadros alcançável:

  • A taxa de captura da câmera. O protocolo não consegue entregar quadros mais rápido do que o sensor os produz; qualquer limite que o formato de pixel e o tamanho de quadro escolhidos imponham à captura é o teto.

  • O payload máximo negociado. Payloads maiores significam menos fragmentos por quadro e menos sobrecarga de enquadramento, então câmeras com buffers de protocolo maiores movem bytes mais rápido que as menores.

  • Sobrecarga de CRC e ACK. Cada pacote custa 14 bytes de enquadramento mais um round-trip de ACK. Para fragmentos longos, a sobrecarga por payload é pequena; para payloads minúsculos, ela domina.

Para a maior parte do trabalho de interface gráfica entre câmera e laptop, o fator limitante é o tempo de captura e de compressão JPEG da câmera, não a pilha do protocolo. Onde o protocolo de fato se torna o gargalo – transmitindo quadros brutos não comprimidos em altas taxas de quadros, por exemplo – as alavancas são desligar os ACKs (protocol.init(ack=False)), aumentar o buffer do protocolo se a câmera o suportar, ou capturar em GRAYSCALE para que cada JPEG comprimido carregue um canal em vez de três e o quadro codificado acabe ficando notavelmente menor na linha.

O canal de quadros é o fluxo de dados canônico de câmera para host. A mesma interface de backend, com um método write adicionado, permite que o host envie dados no sentido oposto também – que é o que uma ferramenta de câmera interativa precisa assim que o operador quer mudar algo em vez de apenas observar.