5.33. Streams ImageIO¶
save() e to_jpeg() cobrem o caso de E/S de quadro único: uma aplicação captura um quadro, o codifica e o envia para algum lugar. Uma classe diferente de aplicação precisa do caso de sequência: gravar muitos quadros em sequência na taxa natural de captura, armazená-los em algum lugar de onde possam ser recuperados depois e reproduzi-los na velocidade certa. Um script de coleta de dados de treinamento captura algumas centenas de quadros de exemplo para um pipeline de aprendizado de máquina; um log de estação de inspeção registra cada peça capturada para rastreabilidade; um script de desenvolvimento reproduz uma sequência armazenada para testar um novo algoritmo contra dados que foram previamente capturados ao vivo.
A classe ImageIO é o gravador / reprodutor do módulo image. Um único stream contém uma sequência de quadros Image – possivelmente de tamanhos e formatos de pixel diferentes – junto com o intervalo entre quadros de cada um, de modo que a reprodução possa recriar a taxa de quadros original. Dois armazenamentos de apoio estão disponíveis: um arquivo no sistema de arquivos ou um buffer de tamanho fixo na RAM.
5.33.1. Os dois armazenamentos de apoio¶
Um stream de arquivo persiste a gravação entre ciclos de energia e é dimensionado apenas pelo armazenamento que o sustenta. Ele começa com um cabeçalho mágico de 16 bytes OMV IMG STR Vx.y seguido por um chunk por quadro; o gravador atual emite V2.0 e o leitor ainda aceita arquivos V1.0 e V1.1 por compatibilidade retroativa. O caminho do arquivo é o argumento do construtor; o modo é o modo de abertura de arquivo ('r' para ler um stream existente, 'w' para truncar e escrever do zero).
# Recording to /sdcard/run.bin
stream = image.ImageIO("/sdcard/run.bin", "w")
for _ in range(120):
img = csi0.snapshot()
stream.write(img)
stream.close()
Um stream de memória reside em um buffer de RAM alocado na construção. O construtor recebe uma 3-tupla (w, h, pixformat) em vez de um caminho, e o argumento mode torna-se o número pré-alocado de slots de quadro. O buffer é dimensionado exatamente para essa quantidade de quadros nas dimensões fornecidas e não tem permissão para crescer uma vez alocado – escrever além do último slot levanta EOFError, e escrever um quadro maior que o buffer por slot levanta ValueError. Streams de memória são a ferramenta certa quando a aplicação precisa passar uma gravação para um estágio subsequente sem passar pelo sistema de arquivos (um pequeno ring buffer de quadros recentes para um padrão de disparar-e-reproduzir, por exemplo).
# Pre-allocate space for 32 QVGA RGB565 frames in RAM
stream = image.ImageIO((320, 240, image.RGB565), 32)
for _ in range(32):
stream.write(csi0.snapshot())
Para os formatos de pixel comprimidos (image.JPEG, image.PNG) o tamanho por slot é estimado em 2 bits por pixel; um quadro codificado maior que a estimativa levanta ValueError no momento da escrita, portanto uma aplicação que espera armazenar JPEGs de alta qualidade precisa ou superalocar a contagem de slots ou codificar com qualidade menor primeiro.
type() retorna image.ImageIO.FILE_STREAM ou image.ImageIO.MEMORY_STREAM para que o código subsequente possa se adaptar a qualquer que seja o armazenamento de apoio que receba.
5.33.2. Gravação¶
write() anexa um Image capturado a um stream de arquivo (ou o armazena no slot atual de um stream de memória) e avança o offset em um. A mesma chamada registra o intervalo entre quadros desde a última escrita, de modo que a metade de reprodução possa pausar pela quantidade certa de tempo entre os quadros e a taxa de quadros natural da gravação seja preservada.
Quadros heterogêneos são permitidos dentro de um único stream de arquivo: uma gravação pode misturar livremente capturas RGB565, recortes em escala de cinza e miniaturas codificadas em JPEG, e o leitor decodificará cada um em seu tamanho e formato originais. Streams de memória são homogêneos (todos os slots compartilham o (w, h, pixformat) fornecido no construtor), portanto uma gravação em memória é restrita a uma única configuração de quadro.
write() retorna o objeto de stream para que as chamadas possam ser encadeadas. Escrever em um offset que não seja o final de um stream de arquivo trunca o resto do arquivo – útil para editar uma sequência armazenada, arriscado se a posição da próxima escrita foi movida involuntariamente por um seek() anterior.
sync() descarrega escritas pendentes para o disco em streams de arquivo (é uma operação sem efeito em streams de memória) e deve ser chamado periodicamente quando a gravação é de longa duração, para evitar perder o final da gravação se a cam reiniciar antes do arquivo ser fechado. O destrutor fecha o stream automaticamente quando o ImageIO sai de escopo, mas o close() explícito é a disciplina correta.
5.33.3. Reprodução¶
read() lê o quadro no offset atual, avança o offset e retorna o novo Image. O receptor permanece no frame buffer quando copy_to_fb=True (o padrão), de modo que a imagem retornada possa ser desenhada através da pré-visualização do IDE; com copy_to_fb=False o quadro vai parar no heap do MicroPython.
# Loop a recorded stream at its natural frame rate
stream = image.ImageIO("/sdcard/run.bin", "r")
while True:
img = stream.read()
# img is now in the frame buffer; the IDE shows it
# and the script can run any analysis it likes
Duas palavras-chave controlam o comportamento da reprodução. loop=True (o padrão para streams de arquivo) faz o ponteiro de leitura voltar ao início quando o fim da gravação é alcançado, de modo que a chamada nunca retorna None; loop=False retorna None assim que a gravação se esgota e o laço do chamador termina. pause=True (o padrão) bloqueia a chamada até que o intervalo entre quadros registrado no momento da escrita tenha decorrido, de modo que a taxa de quadros da reprodução corresponda à taxa de quadros original de captura; pause=False retorna imediatamente, útil para pipelines de análise que querem processar a gravação o mais rápido possível sem respeitar o tempo original.
O mesmo padrão de laço funciona para streams de memória, exceto que loop é ignorado – ler além do fim de um stream de memória levanta EOFError. O padrão esperado para um ring de memória é fazer seek() de volta para zero explicitamente quando o reinício for desejado.
5.33.5. Gravações reproduzíveis em host¶
Streams ImageIO são a ferramenta certa quando a gravação vai ser reproduzida na própria cam – eles preservam cada quadro capturado em seu formato de pixel nativo, o intervalo entre quadros é registrado com exatidão, e um script subsequente pode percorrê-los, fazer seek e reanalisar sem perda. Eles não são, contudo, a ferramenta certa quando a gravação precisa ser reproduzível em um host – uma estação de trabalho, um telefone, um reprodutor web. Um host espera um contêiner de vídeo padrão, não o formato de cabeçalho mágico em disco da OpenMV.
Dois módulos separados cobrem o caso de reprodução em host. O módulo mjpeg grava Motion JPEG: uma sequência de quadros comprimidos em JPEG empacotados em um único contêiner no estilo AVI que VLC, QuickTime, ffmpeg e a tag de vídeo web padrão reproduzem diretamente. O módulo gif grava um GIF animado: uma sequência de quadros não comprimidos (ou comprimidos por paleta) com atrasos explícitos por quadro, reproduzível em qualquer navegador web ou visualizador de imagens que lide com GIFs animados.
O módulo mjpeg é a escolha natural para gravações longas. A compressão JPEG mantém o tamanho do arquivo gerenciável – comparável a to_jpeg() na qualidade configurada, quadro após quadro – de modo que uma sessão de captura prolongada permaneça dentro do orçamento do cartão SD. O uso espelha de perto a gravação com ImageIO:
import mjpeg
m = mjpeg.Mjpeg("/sdcard/run.mjpeg")
while running:
m.add_frame(csi0.snapshot(), quality=85)
m.close()
mjpeg.Mjpeg aceita as mesmas palavras-chave posicionais e de escala de estilo de desenho que outros métodos de imagem recebem, de modo que uma gravação possa ser escalada, recortada ou mapeada por paleta por quadro durante a entrada. Os argumentos width e height do construtor têm como padrão as dimensões do frame buffer principal e fixam a resolução de saída; cada quadro anexado é escalado (preservando a proporção) para caber. sync() descarrega o arquivo para o disco durante uma gravação longa, e close() finaliza o contêiner – um arquivo Motion JPEG que não foi fechado de forma limpa não é reproduzível, então a disciplina importa.
O módulo gif é a escolha natural para gravações curtas compartilhadas literalmente com um espectador não técnico – alguns segundos de ação capturados para uma demonstração, uma ilustração animada para documentação, um clipe de evento embutido em uma mensagem de chat. Quadros GIF são armazenados sem compressão (ou comprimidos por paleta com profundidade de cor de 7 bits), o que torna os arquivos muito maiores por segundo que o Motion JPEG e descarta o formato para gravações mais longas que alguns segundos, mas o resultado se insere diretamente em qualquer navegador:
import gif
g = gif.Gif("/sdcard/clip.gif")
while running:
g.add_frame(csi0.snapshot(), delay=10)
g.close()
O argumento delay em add_frame() é o tempo de exibição por quadro em centissegundos (10 é 100 ms por quadro, ou 10 fps), que é o controle padrão de reprodução de GIF. A palavra-chave loop do construtor define se o clipe resultante reproduz automaticamente em loop nos visualizadores (o padrão é True, o que corresponde à expectativa convencional de “GIF animado”).
Os três caminhos de gravação cobrem juntos os casos comuns: ImageIO para reprocessamento na própria cam, Motion JPEG para gravações longas reproduzíveis em host, GIF animado para clipes curtos reproduzíveis em host. A escolha entre eles se resume a quem reproduz a gravação. Um estágio subsequente rodando na própria cam lê ImageIO; uma estação de trabalho host ou visualizador web lê MJPEG ou GIF.
5.33.6. Um padrão de disparar-e-reproduzir¶
Um padrão útil combina um stream de memória com uma condição de disparo. A cam grava continuamente em um ring buffer de memória de count slots, sobrescrevendo o slot mais antigo a cada volta. Quando uma condição de disparo é acionada (um blob entra no quadro, um evento de movimento excede o limiar, um botão é pressionado) a aplicação captura o conteúdo do ring – os count quadros mais recentes – e os escreve em um stream de arquivo no cartão SD. O resultado é uma gravação pré-disparo que captura os segundos anteriores ao evento que a cam de fato notou, não apenas os segundos posteriores, que é a limitação clássica de um gravador ingênuo do tipo “capturar-quando-disparado”.
A implementação é simples uma vez que as classes de stream estão à mão: um stream de memória de tamanho fixo serve como o ring (com seek() explícito para zero quando o offset alcança a contagem de slots), o laço principal captura nele a cada iteração, e o manipulador de disparo lê o stream de memória quadro a quadro e escreve cada um em um stream de arquivo nomeado conforme o timestamp do disparo.